Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 14 additions & 6 deletions src/gfx/formatText.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,22 +60,29 @@ export function compileStyledText(txt: any): StyledTextInfo {
const charStyleMap = {} as Record<number, [string, string][]>;
let renderText = "";
let styleStack: [string, string][] = [];
let text = String(txt);
// Normalize to NFC and walk grapheme clusters instead of UTF-16 code units,
// so style positions stay aligned with formatText(), which iterates the
// rendered text via runes(). Keying by renderText.length would desync every
// style after an emoji, ZWJ sequence or astral char (e.g. CJK Extension B).
let text = String(txt).normalize("NFC");
let charIndex = 0;

const emit = (ch: string) => {
if (styleStack.length > 0) {
charStyleMap[renderText.length] = styleStack.slice();
charStyleMap[charIndex] = styleStack.slice();
}
renderText += ch;
charIndex++;
};

while (text !== "") {
if (text[0] === "\\") {
if (text.length === 1) {
throw new Error("Styled text error: \\ at end of string");
}
emit(text[1]);
text = text.slice(2);
const [escaped] = runes(text.slice(1));
emit(escaped);
text = text.slice(1 + escaped.length);
continue;
}
if (text[0] === "[") {
Expand Down Expand Up @@ -109,8 +116,9 @@ export function compileStyledText(txt: any): StyledTextInfo {
text = text.slice(m.length);
continue;
}
emit(text[0]);
text = text.slice(1);
const [grapheme] = runes(text);
emit(grapheme);
text = text.slice(grapheme.length);
}

if (styleStack.length > 0) {
Expand Down
47 changes: 47 additions & 0 deletions tests/auto/styledText.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { expect, test } from "vitest";
import { compileStyledText } from "../../src/gfx/formatText";
import { runes } from "../../src/utils/runes";

// compileStyledText() should key charStyleMap by grapheme index, the same way
// formatText() walks the rendered text via runes(). Keying by UTF-16 code unit
// desyncs every style after an emoji, ZWJ sequence or astral character.

// the styled graphemes of `input`, resolved through the same runes() indexing
// that formatText() uses to apply styles
function styledGraphemes(input: string): (string | undefined)[] {
const { charStyleMap, text } = compileStyledText(input);
const graphemes = runes(text);
return Object.keys(charStyleMap)
.map(Number)
.sort((a, b) => a - b)
.map(i => graphemes[i]);
}

test("compileStyledText keeps ASCII style indices unchanged", () => {
const { charStyleMap } = compileStyledText("a[c]b[/c]c");
expect(charStyleMap).toEqual({ 1: [["c", undefined]] });
});

test("compileStyledText aligns styles after an emoji", () => {
expect(styledGraphemes("\u{1F600}[c]x[/c]")).toEqual(["x"]);
});

test("compileStyledText aligns styles after an astral CJK character", () => {
// U+20BB7 (a CJK Extension B ideograph) is a surrogate pair, so .length
// counts it as two code units
expect(styledGraphemes("\u{20BB7}[c]a[/c]")).toEqual(["a"]);
});

test("compileStyledText aligns styles after a ZWJ emoji sequence", () => {
const family = "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}";
expect(styledGraphemes(`${family}[c]z[/c]`)).toEqual(["z"]);
});

test("compileStyledText maps every style key to a real grapheme", () => {
const { charStyleMap, text } = compileStyledText("[c]\u{1F600}b[/c]");
const graphemes = runes(text);
for (const key of Object.keys(charStyleMap).map(Number)) {
expect(graphemes[key]).toBeDefined();
}
expect(styledGraphemes("[c]\u{1F600}b[/c]")).toEqual(["\u{1F600}", "b"]);
});