Skip to content
Merged
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
57 changes: 50 additions & 7 deletions packages/cso-core/src/sheet-model/glyphCodeName.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
46 changes: 46 additions & 0 deletions packages/cso-core/tests/glyph-code-name.test.ts
Original file line number Diff line number Diff line change
@@ -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);
Loading