Ultima attività 2 weeks ago

gistfile1.txt Raw
1/**
2 * TextAlign — approximate column alignment for Discord embeds using
3 * invisible filler characters (ㅤ U+3164 Hangul Filler).
4 *
5 * Discord embed text is NOT monospace, so naive character-count padding
6 * misaligns whenever strings contain narrow characters (punctuation,
7 * symbols like »«) mixed with regular letters. This module estimates
8 * VISUAL width per character (not raw count) for better alignment.
9 *
10 * EXPERIMENTAL — width weights are approximations tuned by visual
11 * comparison in actual Discord clients, not measured font metrics.
12 * Iterate WIDTH_TABLE based on screenshots.
13 *
14 * Usage:
15 * import { TextAlign } from "@ui/text-align";
16 *
17 * const width = TextAlign.estimateWidth("»Flash«");
18 * const padded = TextAlign.pad("»Flash«", maxWidth);
19 * const maxWidth = TextAlign.maxWidth(["»Flash«", "XefronYokuda", ...]);
20 */
21
22import { Logger } from "../systems/logger";
23
24 const log = Logger.for("TextAlign");
25
26 const FILLER = "\u2009"; // Thin Space — finer-grained than Hangul Filler, better precision
27
28 // ─── Width estimation ──────────────────────────────────────────────────────
29 // Flat character width (1.0) for ALL characters, including guillemets.
30 // IMPORTANT: guillemet narrowness is already implicitly absorbed into the
31 // FILLERS_PER_CHAR calibration below (calibrated using names that included
32 // guillemets). Adding a SEPARATE per-character guillemet correction here
33 // double-counts the effect and causes overcorrection. Do not reintroduce
34 // without recalibrating FILLERS_PER_CHAR from scratch using guillemet-free
35 // reference names only.
36 function charWidth(ch: string): number {
37 return 1.0;
38 }
39
40 // Estimated width of the invisible filler character itself, relative to
41 // one standard letter, SPECIFICALLY INSIDE EMBEDS using Thin Space (U+2009).
42 // CALIBRATED via live embed testing:
43 // «Keira» (7) vs XefronYokuda (12), diff 5 chars — exactly 14 thin fillers
44 // »No.1« (6) vs «MonkeyHunter» (14), diff 8 chars — exactly 23 thin fillers
45 // Combined: 37 fillers per 13 missing chars => 1 char ≈ 2.846 fillers
46 const FILLERS_PER_CHAR = 37 / 13; // ≈ 2.846
47 const FILLER_WIDTH = 1 / FILLERS_PER_CHAR; // ≈ 0.351 (letter-widths per filler)
48
49 // ─── Namespace ────────────────────────────────────────────────────────────────
50
51 export const TextAlign = {
52 /**
53 * Estimate the visual width of a string in "units" (1.0 = one average letter).
54 */
55 estimateWidth(text: string): number {
56 return text.split("").reduce((sum, ch) => sum + charWidth(ch), 0);
57 },
58
59 /**
60 * Get the max estimated width across a list of strings.
61 */
62 maxWidth(texts: string[]): number {
63 return Math.max(...texts.map((t) => TextAlign.estimateWidth(t)), 0);
64 },
65
66 /**
67 * Pad a string with invisible filler characters to reach a target width.
68 */
69 pad(text: string, targetWidth: number): string {
70 const current = TextAlign.estimateWidth(text);
71 const diff = targetWidth - current;
72 if (diff <= 0) return text;
73 const fillerCount = Math.round(diff / FILLER_WIDTH);
74 log.info(`[TextAlign] "${text}": estimatedWidth=${current.toFixed(3)} target=${targetWidth.toFixed(3)} diff=${diff.toFixed(3)} fillerCount=${fillerCount}`);
75 return text + FILLER.repeat(Math.max(0, fillerCount));
76 },
77
78 /**
79 * Pad a string to match the widest string in a list (convenience).
80 */
81 padToMax(text: string, allTexts: string[]): string {
82 return TextAlign.pad(text, TextAlign.maxWidth(allTexts));
83 },
84 };