From e8d322ce72905d6a02041a612fa1891a731e2962 Mon Sep 17 00:00:00 2001 From: greymoth <246701683+greymoth-jp@users.noreply.github.com> Date: Tue, 30 Jun 2026 08:36:52 +0900 Subject: [PATCH 1/2] fix: align styled-text style positions with grapheme clusters compileStyledText keyed charStyleMap by renderText.length (UTF-16 code units) while formatText walks the rendered text with runes() and looks up styles by grapheme index. For any emoji, ZWJ sequence or astral character (e.g. a CJK Extension B ideograph), the two indexings diverge, so every style after such a character was applied to the wrong grapheme or dropped. Walk grapheme clusters via runes() and normalize to NFC so the style keys line up with how formatText consumes them. --- src/gfx/formatText.ts | 20 ++++++++++----- tests/auto/styledText.spec.ts | 47 +++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 6 deletions(-) create mode 100644 tests/auto/styledText.spec.ts diff --git a/src/gfx/formatText.ts b/src/gfx/formatText.ts index ab445c797..f278aaed0 100644 --- a/src/gfx/formatText.ts +++ b/src/gfx/formatText.ts @@ -60,13 +60,19 @@ export function compileStyledText(txt: any): StyledTextInfo { const charStyleMap = {} as Record; 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 !== "") { @@ -74,8 +80,9 @@ export function compileStyledText(txt: any): StyledTextInfo { 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] === "[") { @@ -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) { diff --git a/tests/auto/styledText.spec.ts b/tests/auto/styledText.spec.ts new file mode 100644 index 000000000..38ae9174c --- /dev/null +++ b/tests/auto/styledText.spec.ts @@ -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"]); +}); From 73268428b6800dcdf1c3ba3f5a3bfabd59bfde6b Mon Sep 17 00:00:00 2001 From: greymoth <246701683+greymoth-jp@users.noreply.github.com> Date: Sat, 4 Jul 2026 06:35:19 +0900 Subject: [PATCH 2/2] perf: read first grapheme via firstRune so styled-text walk stays O(n) --- src/gfx/formatText.ts | 6 +++--- src/utils/runes.ts | 31 +++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/src/gfx/formatText.ts b/src/gfx/formatText.ts index f278aaed0..faa3ab31d 100644 --- a/src/gfx/formatText.ts +++ b/src/gfx/formatText.ts @@ -6,7 +6,7 @@ import { Color, rgb } from "../math/color"; import { vec2 } from "../math/math"; import { _k } from "../shared"; import type { Outline } from "../types"; -import { runes } from "../utils/runes"; +import { firstRune, runes } from "../utils/runes"; import { alignPt } from "./anchor"; import type { FormattedChar, FormattedText } from "./draw/drawFormattedText"; import type { CharTransform, DrawTextOpt } from "./draw/drawText"; @@ -80,7 +80,7 @@ export function compileStyledText(txt: any): StyledTextInfo { if (text.length === 1) { throw new Error("Styled text error: \\ at end of string"); } - const [escaped] = runes(text.slice(1)); + const escaped = firstRune(text.slice(1)); emit(escaped); text = text.slice(1 + escaped.length); continue; @@ -116,7 +116,7 @@ export function compileStyledText(txt: any): StyledTextInfo { text = text.slice(m.length); continue; } - const [grapheme] = runes(text); + const grapheme = firstRune(text); emit(grapheme); text = text.slice(grapheme.length); } diff --git a/src/utils/runes.ts b/src/utils/runes.ts index f7c2a6f02..1593c3d6c 100644 --- a/src/utils/runes.ts +++ b/src/utils/runes.ts @@ -93,6 +93,37 @@ export function runes(string: string, locale?: string): string[] { return runesLegacy(string); } +/** + * Get the first grapheme cluster of a string without splitting the rest of it. + * Equivalent to runes(string, locale)[0], but stops after the first cluster so + * callers that consume one grapheme per iteration stay O(n) overall instead of + * O(n^2). + * + * @param string - The string to read the first grapheme cluster from + * @param locale - Optional locale for locale-aware segmentation + * + * @returns The first grapheme cluster, or "" for an empty string + */ +export function firstRune(string: string, locale?: string): string { + if (typeof string !== "string") { + throw new TypeError("string cannot be undefined or null"); + } + + // Use Intl.Segmenter if available (Chrome 87+, Safari 14.1+, Firefox 125+) + if (typeof Intl !== "undefined" && Intl.Segmenter) { + // Normalize to NFC for consistent representation of combining characters + const normalized = string.normalize("NFC"); + const segmenter = getSegmenter(locale); + for (const { segment } of segmenter.segment(normalized)) { + return segment; + } + return ""; + } + + // Fallback to legacy implementation for older browsers + return runesLegacy(string)[0] ?? ""; +} + /** * Legacy grapheme cluster splitting implementation. * Used as fallback for browsers without Intl.Segmenter support.