tg-bot-ts/src/ui/text-align.ts
2026-07-30 00:18:53 +01:00

183 lines
No EOL
7.6 KiB
TypeScript

/**
* 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<string, number> = {
"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 <a:name:id> (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 = /<a?:\w+:\d+>/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));
},
};