最終更新 2 weeks ago

nuno revised this gist 2 weeks ago. Go to revision

1 file changed, 84 insertions

gistfile1.txt(file created)

@@ -0,0 +1,84 @@
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 +
22 + import { 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 + };
Newer Older