Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
22 changes: 15 additions & 7 deletions src/gfx/formatText.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down 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 = firstRune(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 = firstRune(text);
emit(grapheme);
text = text.slice(grapheme.length);
}

if (styleStack.length > 0) {
Expand Down
31 changes: 31 additions & 0 deletions src/utils/runes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
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"]);
});