From 6f04c6ef41033546896b7ced1c75cfcdf03dfb91 Mon Sep 17 00:00:00 2001 From: viktar-b <60892287+viktar-b@users.noreply.github.com> Date: Fri, 6 Nov 2026 09:01:37 +0000 Subject: [PATCH] fix(core): parse grouped glyph scripts in linear time --- .../cso-core/src/sheet-model/glyphCodeName.ts | 57 ++++++++++++++++--- .../cso-core/tests/glyph-code-name.test.ts | 46 +++++++++++++++ 2 files changed, 96 insertions(+), 7 deletions(-) create mode 100644 packages/cso-core/tests/glyph-code-name.test.ts diff --git a/packages/cso-core/src/sheet-model/glyphCodeName.ts b/packages/cso-core/src/sheet-model/glyphCodeName.ts index 4a736f8..78bf7f7 100644 --- a/packages/cso-core/src/sheet-model/glyphCodeName.ts +++ b/packages/cso-core/src/sheet-model/glyphCodeName.ts @@ -1,19 +1,62 @@ -const groupedScriptPattern = /[_^][({[]([^)}\]]+)[)}\]]/g; const bareScriptPattern = /[_^]([A-Za-z0-9]+)/g; +const scriptOpeners = new Set(['(', '{', '[']); +const scriptClosers = new Set([')', '}', ']']); + +const replaceGroupedScripts = (value: string): string => { + const parts: string[] = []; + let unchangedStart = 0; + let index = 0; + + while (index < value.length) { + const char = value[index]; + if ( + (char !== '_' && char !== '^') || + !scriptOpeners.has(value.charAt(index + 1)) + ) { + index += 1; + continue; + } + + const contentStart = index + 2; + let end = contentStart; + while (end < value.length && !scriptClosers.has(value[end])) { + end += 1; + } + + // No later group can close either. Preserve the remaining input without + // rescanning each opening delimiter in an unmatched suffix. + if (end === value.length) break; + + // Match the former regex: groups need content, and any closer ends them, + // even when delimiters are mismatched or the content contains openers. + if (end === contentStart) { + index += 1; + continue; + } + + parts.push( + value.slice(unchangedStart, index), + '_', + value.slice(contentStart, end), + ); + index = end + 1; + unchangedStart = index; + } + + parts.push(value.slice(unchangedStart)); + return parts.join(''); +}; const removeNonAscii = (value: string): string => [...value].filter((char) => char.charCodeAt(0) <= 127).join(''); const toAsciiCodeName = (value: string): string => - removeNonAscii(value) - .trim() - .normalize('NFKD') - .replace(groupedScriptPattern, '_$1') + replaceGroupedScripts(removeNonAscii(value).trim().normalize('NFKD')) .replace(bareScriptPattern, '_$1') .replace(/[{}()[\]]+/g, '_') .replace(/[^A-Za-z0-9_]+/g, '_') - .replace(/^_+|_+$/g, '') - .replace(/_+/g, '_'); + .replace(/_+/g, '_') + .replace(/^_|_$/g, ''); export const deriveGlyphCodeName = ({ glyph, diff --git a/packages/cso-core/tests/glyph-code-name.test.ts b/packages/cso-core/tests/glyph-code-name.test.ts new file mode 100644 index 0000000..c2c8751 --- /dev/null +++ b/packages/cso-core/tests/glyph-code-name.test.ts @@ -0,0 +1,46 @@ +import { execFileSync } from 'node:child_process'; +import { expect, test } from 'vitest'; +import { deriveGlyphCodeName } from '../src/sheet-model/glyphCodeName.ts'; + +test.each([ + ['K_(y_1)', 'K_y_1'], + ['A^{2}', 'A_2'], + ['A_[x]', 'A_x'], + ['A_(x)B^{2}', 'A_xB_2'], + ['A_(x]B', 'A_xB'], + ['A_()B', 'A_B'], + ['A_((x))B', 'A_x_B'], + ['A_(x', 'A_x'], + ['A_()_(x)', 'A_x'], +])('derives the code name for %s', (glyph, expected) => { + expect(deriveGlyphCodeName({ glyph, id: 'fallback-id' })).toBe(expected); +}); + +test('handles long unmatched glyphs without repeatedly scanning their suffixes', () => { + const moduleUrl = new URL( + '../src/sheet-model/glyphCodeName.ts', + import.meta.url, + ); + // Run separately so a quadratic-time regression can be terminated even + // though synchronous parsing would block the test runner's own timeout. + const output = execFileSync( + process.execPath, + [ + '--input-type=module', + '-e', + ` + import { deriveGlyphCodeName } from ${JSON.stringify(moduleUrl.href)}; + const malformed = '^(' + '^(('.repeat(100_000); + process.stdout.write(JSON.stringify([ + deriveGlyphCodeName({ glyphPlaintext: malformed, glyph: 'A_(x)', id: 'fallback-id' }), + deriveGlyphCodeName({ glyph: malformed, id: 'fallback-id' }), + deriveGlyphCodeName({ id: malformed }), + deriveGlyphCodeName({ glyph: 'A' + malformed + 'Z', id: 'fallback-id' }), + ])); + `, + ], + { encoding: 'utf8', timeout: 5_000 }, + ); + + expect(JSON.parse(output)).toEqual(['A_x', 'fallback_id', 'symbol', 'A_Z']); +}, 10_000);