From 5fdd7bcf74c75c503f56118dad54a77362530d18 Mon Sep 17 00:00:00 2001 From: ihsieh31 <410110@email.wlsh.tyc.edu.tw> Date: Sun, 25 Oct 2026 16:41:25 +0800 Subject: [PATCH] fix(script): keep a comma-grouped number and its hyphenated suffix whole (#391) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The number alternative of LEXICAL_UNIT only took numeric hyphenated suffixes, and its lookahead rejects stopping before -letters, so a comma group followed by a word fell back to the bare prefix and split at the comma: 1,000-word became 1, and 000-word, which also let a Cue break between them through (SCRIPT_CAPTION_BREAK_TOKEN_BOUNDARY never fired). After its numeric suffixes the number alternative now takes one hyphenated word suffix, with the ' ’ . - joins the plain-word alternative allows. The joins matter: the number alternative comes first, so it now reaches compounds the word alternative used to own, and without them 90-year-old's or 2020-word.com would be cut at the apostrophe or dot. 1,000-word, 123,456,789-point and 1,000.5-word each stay one unit, every un-grouped compound tokenizes as before, and the regression test pins both. Co-Authored-By: Claude Opus 5.5 --- packages/script/src/lexical.ts | 2 +- packages/script/test/prose-boundaries.test.ts | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/script/src/lexical.ts b/packages/script/src/lexical.ts index b54a8d26c..19481df9e 100644 --- a/packages/script/src/lexical.ts +++ b/packages/script/src/lexical.ts @@ -7,7 +7,7 @@ const CHARACTER_UNIT = String.raw`[\p{Script=Han}\p{Script=Hiragana}\p{Script=Ka // A Latin name beside Han prose ends at the script boundary, even without an authored space. const WORD_CHARACTER = String.raw`(?:(?!${CHARACTER_UNIT})[\p{L}\p{M}\p{N}])`; const LEXICAL_UNIT = new RegExp([ - String.raw`(?:\p{N}{1,3}(?:[,,]\p{N}{3})+|\p{N}+)(?:[..]\p{N}+)?(?:-\p{N}+(?:[..]\p{N}+)?)*(?!\p{N}|-[\p{L}\p{M}])`, + String.raw`(?:\p{N}{1,3}(?:[,,]\p{N}{3})+|\p{N}+)(?:[..]\p{N}+)?(?:-\p{N}+(?:[..]\p{N}+)?)*(?:-${WORD_CHARACTER}+(?:['’.-]${WORD_CHARACTER}+)*)?(?!\p{N}|-[\p{L}\p{M}])`, String.raw`${CHARACTER_UNIT}\p{M}*`, String.raw`(?:(?!${CHARACTER_UNIT})[\p{L}\p{N}])${WORD_CHARACTER}*(?:['’.-]${WORD_CHARACTER}+)*`, ].join("|"), "gu"); diff --git a/packages/script/test/prose-boundaries.test.ts b/packages/script/test/prose-boundaries.test.ts index 01e15e07b..47f42c370 100644 --- a/packages/script/test/prose-boundaries.test.ts +++ b/packages/script/test/prose-boundaries.test.ts @@ -17,6 +17,25 @@ test("annotations cannot create speech boundaries inside words or attached punct assert.equal(caption('hello,@{beat!} world'), 'hello, world'); }); +test("comma-grouped numbers keep a hyphenated suffix as one word", () => { + // A comma group is unreachable for the plain-word alternative, so the compound used to + // fall back to the bare prefix and accept a Cue break between `1,` and `000-word`. + const parsed = parse('a 1,000-word essay works'); + assert.deepEqual(parsed.tokens.map(token => token.text), ['a', '1,000-word', 'essay', 'works']); + assert.equal(parse('123,456,789-point').tokens[0]!.text, '123,456,789-point'); + assert.equal(parse('1,000.5-word').tokens[0]!.text, '1,000.5-word'); + assert.equal(parse('a 1,000-word essay').tokens[1]!.text, '1,000-word'); + // The number alternative now reaches hyphenated words first, so it must keep the word + // alternative's apostrophe and dot joins rather than cutting `'s` off a compound. + for (const word of ["90-year-old's", "2020-word's", "20-something’s", '3-in-1\'s', '2020-word.com', "1,000-dollar's"]) { + assert.deepEqual(parse(`the ${word} here`).tokens.map(token => token.text), ['the', word, 'here'], word); + } + assert.equal(parse('a 1,000-word.').tokens[1]!.text, '1,000-word'); + for (const body of ['a 1,||000-word essay works', 'a 123,||456,789-point here', 'a 2020-||word essay works']) { + assert.throws(() => parse(body), /SCRIPT_CAPTION_BREAK_TOKEN_BOUNDARY/u, body); + } +}); + test("comments do not divide words, and source edits retain the comment", () => { const source = 'hello 한글. @{beat!}'; const parsed = parseScript("boundary", source);