/** * TextAlign — approximate column alignment for Discord embeds using * invisible filler characters. * * Discord embed text is NOT monospace. Character widths below are * EXACT values extracted directly from Discord's actual font (gg sans * Regular), via font metrics (advance width / units-per-em), normalized * to 1.0 = full em. This is real font data, not approximation. * * CRITICAL CAVEAT: the invisible filler character used for padding * (Thin Space U+2009, Hangul Filler U+3164, etc.) does NOT exist in * gg sans itself — confirmed by checking the font's cmap. Discord's * renderer falls back to some OTHER font (OS/system fallback) to draw * these glyphs, which we have no programmatic access to measure. This * means FILLER_WIDTH below MUST remain empirically calibrated via live * Discord testing — it cannot be derived from the gg sans font file. * * 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 // ─── Character widths — EXACT, extracted from gg sans Regular.ttf ─────────── // Source: hmtx table advance widths / unitsPerEm (1000). Generated via // fontTools. Unknown characters fall back to FALLBACK_WIDTH (average of // lowercase letters, a reasonable default for unmeasured glyphs). const CHAR_WIDTHS: Record = { "A": 0.644, "B": 0.612, "C": 0.636, "D": 0.658, "E": 0.56, "F": 0.545, "G": 0.67, "H": 0.67, "I": 0.242, "J": 0.513, "K": 0.605, "L": 0.556, "M": 0.838, "N": 0.681, "O": 0.684, "P": 0.607, "Q": 0.684, "R": 0.623, "S": 0.556, "T": 0.532, "U": 0.668, "V": 0.602, "W": 0.816, "X": 0.588, "Y": 0.59, "Z": 0.551, "a": 0.509, "b": 0.553, "c": 0.504, "d": 0.553, "e": 0.507, "f": 0.337, "g": 0.503, "h": 0.538, "i": 0.228, "j": 0.228, "k": 0.488, "l": 0.253, "m": 0.812, "n": 0.528, "o": 0.53, "p": 0.553, "q": 0.553, "r": 0.377, "s": 0.452, "t": 0.383, "u": 0.528, "v": 0.472, "w": 0.702, "x": 0.458, "y": 0.47, "z": 0.438, "0": 0.584, "1": 0.356, "2": 0.519, "3": 0.546, "4": 0.556, "5": 0.536, "6": 0.535, "7": 0.458, "8": 0.528, "9": 0.535, " ": 0.22, ".": 0.242, ",": 0.242, "'": 0.178, "!": 0.234, "»": 0.462, "«": 0.462, "-": 0.408, "_": 0.508, "/": 0.438, "[": 0.31, "]": 0.31, ":": 0.242, }; const FALLBACK_WIDTH = 0.5; // reasonable default for unmeasured characters // Estimated width of the invisible filler character itself, relative to // one standard (uppercase) letter, SPECIFICALLY INSIDE EMBEDS using Thin // Space (U+2009). CALIBRATED via live embed testing — see file header. const FILLER_WIDTH = 0.203; // Discord custom emoji tags <:name:id> or (animated) render as // ONE fixed-width visual glyph regardless of how long the internal name/ID // text happens to be. Without this, estimateWidth would wrongly measure // the RAW TAG TEXT character-by-character, making emoji with longer // internal names appear "wider" than equally-sized emoji with shorter // names — a category error, since emoji width has nothing to do with // their Discord ID string length. const EMOJI_TAG_REGEX = //g; // Estimated visual width of a single custom emoji, roughly equivalent to // one full em (similar to a wide character). Approximate — emoji can vary // slightly by their actual artwork, but this is consistent enough for // alignment purposes. const EMOJI_WIDTH = 1.0; function charWidth(ch: string): number { if (ch === FILLER) return FILLER_WIDTH; return CHAR_WIDTHS[ch] ?? FALLBACK_WIDTH; } // ─── Namespace ──────────────────────────────────────────────────────────────── export const TextAlign = { /** * Estimate the visual width of a string in "em units" (1.0 = one full em). * Uses exact gg sans font metrics for text. Discord custom emoji tags * (<:name:id>) are detected and treated as ONE fixed-width unit each, * NOT measured character-by-character (their internal ID text has no * bearing on the emoji's actual rendered width). */ estimateWidth(text: string): number { let total = 0; let lastIndex = 0; for (const match of text.matchAll(EMOJI_TAG_REGEX)) { const matchStart = match.index!; const plainText = text.slice(lastIndex, matchStart); total += plainText.split("").reduce((sum, ch) => sum + charWidth(ch), 0); total += EMOJI_WIDTH; lastIndex = matchStart + match[0].length; } const remaining = text.slice(lastIndex); total += remaining.split("").reduce((sum, ch) => sum + charWidth(ch), 0); return total; }, /** * 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; const fillerCount = diff > 0 ? Math.round(diff / FILLER_WIDTH) : 0; // log.debug(`"${text}": estimatedWidth=${current.toFixed(3)} target=${targetWidth.toFixed(3)} diff=${diff.toFixed(3)} fillerCount=${fillerCount}`); if (fillerCount <= 0) return text; return text + FILLER.repeat(fillerCount); }, /** * Pad a string with invisible filler characters BEFORE it (prefix), * so a fixed-width value (e.g. before a separator) ends up * right-aligned relative to the target width. */ padLeft(text: string, targetWidth: number): string { const current = TextAlign.estimateWidth(text); const diff = targetWidth - current; const fillerCount = diff > 0 ? Math.round(diff / FILLER_WIDTH) : 0; if (fillerCount <= 0) return text; return FILLER.repeat(fillerCount) + text; }, /** * 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)); }, /** * Pad-left a string to match the widest string in a list (convenience). */ padLeftToMax(text: string, allTexts: string[]): string { return TextAlign.padLeft(text, TextAlign.maxWidth(allTexts)); }, /** * Pad a string to match the widest string in a list, with a small * width offset applied to the target — for fine-tuning a specific * value's alignment relative to a shared column without affecting * the column's target width for other values. * offset > 0 pads MORE (pushes right), offset < 0 pads LESS (pulls left). */ padToMaxOffset(text: string, allTexts: string[], offset: number): string { return TextAlign.pad(text, TextAlign.maxWidth(allTexts) + offset); }, /** * Pad-left variant of padToMaxOffset. */ padLeftToMaxOffset(text: string, allTexts: string[], offset: number): string { return TextAlign.padLeft(text, TextAlign.maxWidth(allTexts) + offset); }, /** * Get N filler characters as a standalone spacing buffer, for adding * extra breathing room between columns beyond what alignment requires. */ gap(n: number = 1): string { return FILLER.repeat(Math.max(0, n)); }, };