Recently Written · git

scan-subread

PDF reader that follows an audiobook with the SubRead suite

git clone https://github.com/equwal/scan-subread

Log | Files | Refs


commit b986fd30bbbad4b4c54d11a79545967fcd3b51d2
truex <truex@equwal.com>
2026-09-26 13:49:11 -0700

Make normalizeText stable when case folding moves marks

NFKC, the case map and the filter can leave combining marks out of canonical order. A second pass then changed the text, and the idempotence property failed in about 3.5% of the runs. normalizeText now repeats the pass until the text stays the same.

 src/align.ts       | 24 +++++++++++++++++++++---
 test/align.test.ts |  9 +++++++++
 2 files changed, 30 insertions(+), 3 deletions(-)
diff --git a/src/align.ts b/src/align.ts
index e632e94..3809f66 100644
--- a/src/align.ts
+++ b/src/align.ts
@@ -77,16 +77,34 @@ const MAX_LOCAL_EDIT = 2;
 
 const KEEP = /[\p{L}\p{N}\p{M}]/u;
 
+/** A limit on the passes of normalizeText. Real text needs one or two. */
+const MAX_NORMALIZE_PASSES = 4;
+
+/** One pass: NFKC, lowercase, then only letters, numbers and marks. */
+function normalizePass(text: string): string {
+  let out = '';
+  for (const ch of text.normalize('NFKC').toLowerCase()) {
+    if (KEEP.test(ch)) out += ch;
+  }
+  return out;
+}
+
 /**
  * Normalize text for comparison. NFKC folds full-width forms. Lowercase
  * folds case. Only letters, numbers and marks stay. Spaces and punctuation
  * go, so word boundaries do not matter. This works for CJK, where OCR and
  * subtitles rarely agree on spacing.
+ *
+ * The case map and the filter can leave combining marks out of canonical
+ * order, and then one more pass changes the text. The passes repeat until
+ * the text stays the same, so the result is stable.
  */
 export function normalizeText(text: string): string {
-  let out = '';
-  for (const ch of text.normalize('NFKC').toLowerCase()) {
-    if (KEEP.test(ch)) out += ch;
+  let out = normalizePass(text);
+  for (let pass = 1; pass < MAX_NORMALIZE_PASSES; pass++) {
+    const next = normalizePass(out);
+    if (next === out) break;
+    out = next;
   }
   return out;
 }
diff --git a/test/align.test.ts b/test/align.test.ts
index 5e97872..cc3c16a 100644
--- a/test/align.test.ts
+++ b/test/align.test.ts
@@ -33,6 +33,15 @@ describe('normalizeText', () => {
   it('keeps kana, kanji and the long vowel mark', () => {
     expect(normalizeText('コーヒーを飲む。')).toBe('コーヒーを飲む');
   });
+
+  it('gives a stable result when case folding moves combining marks', () => {
+    // fast-check found these inputs. The case map and the filter left the
+    // combining marks out of canonical order, so a second pass changed the text.
+    for (const s of ['\u{1D157}\u{1D165}\u{FC5E}', '\u{130}\u{1D15E}']) {
+      const once = normalizeText(s);
+      expect(normalizeText(once)).toBe(once);
+    }
+  });
 });
 
 describe('alignCuesToTokens on one page', () => {