183 lines
No EOL
7.6 KiB
TypeScript
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));
|
|
},
|
|
}; |