/** * TextAlign — approximate column alignment for Discord embeds using * invisible filler characters (ㅤ U+3164 Hangul Filler). * * Discord embed text is NOT monospace, so naive character-count padding * misaligns whenever strings contain narrow characters (punctuation, * symbols like »«) mixed with regular letters. This module estimates * VISUAL width per character (not raw count) for better alignment. * * EXPERIMENTAL — width weights are approximations tuned by visual * comparison in actual Discord clients, not measured font metrics. * Iterate WIDTH_TABLE based on screenshots. * * Usage: * import { TextAlign } from "@ui/text-align"; * * const width = TextAlign.estimateWidth("»Flash«"); * const padded = TextAlign.pad("»Flash«", maxWidth); * const maxWidth = TextAlign.maxWidth(["»Flash«", "XefronYokuda", ...]); */ import { Logger } from "../systems/logger"; const log = Logger.for("TextAlign"); const FILLER = "\u2009"; // Thin Space — finer-grained than Hangul Filler, better precision // ─── Width estimation ────────────────────────────────────────────────────── // Flat character width (1.0) for ALL characters, including guillemets. // IMPORTANT: guillemet narrowness is already implicitly absorbed into the // FILLERS_PER_CHAR calibration below (calibrated using names that included // guillemets). Adding a SEPARATE per-character guillemet correction here // double-counts the effect and causes overcorrection. Do not reintroduce // without recalibrating FILLERS_PER_CHAR from scratch using guillemet-free // reference names only. function charWidth(ch: string): number { return 1.0; } // Estimated width of the invisible filler character itself, relative to // one standard letter, SPECIFICALLY INSIDE EMBEDS using Thin Space (U+2009). // CALIBRATED via live embed testing: // «Keira» (7) vs XefronYokuda (12), diff 5 chars — exactly 14 thin fillers // »No.1« (6) vs «MonkeyHunter» (14), diff 8 chars — exactly 23 thin fillers // Combined: 37 fillers per 13 missing chars => 1 char ≈ 2.846 fillers const FILLERS_PER_CHAR = 37 / 13; // ≈ 2.846 const FILLER_WIDTH = 1 / FILLERS_PER_CHAR; // ≈ 0.351 (letter-widths per filler) // ─── Namespace ──────────────────────────────────────────────────────────────── export const TextAlign = { /** * Estimate the visual width of a string in "units" (1.0 = one average letter). */ estimateWidth(text: string): number { return text.split("").reduce((sum, ch) => sum + charWidth(ch), 0); }, /** * Get the max estimated width across a list of strings. */ maxWidth(texts: string[]): number { return Math.max(...texts.map((t) => TextAlign.estimateWidth(t)), 0); }, /** * Pad a string with invisible filler characters to reach a target width. */ pad(text: string, targetWidth: number): string { const current = TextAlign.estimateWidth(text); const diff = targetWidth - current; if (diff <= 0) return text; const fillerCount = Math.round(diff / FILLER_WIDTH); log.info(`[TextAlign] "${text}": estimatedWidth=${current.toFixed(3)} target=${targetWidth.toFixed(3)} diff=${diff.toFixed(3)} fillerCount=${fillerCount}`); return text + FILLER.repeat(Math.max(0, fillerCount)); }, /** * Pad a string to match the widest string in a list (convenience). */ padToMax(text: string, allTexts: string[]): string { return TextAlign.pad(text, TextAlign.maxWidth(allTexts)); }, };