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


src/align.ts (14309 bytes)

1 // Pure text alignment. No DOM, no OCR engine.
2 //
3 // Input: the texts of the subtitle cues, and the tokens of the pages that
4 // are read so far. Output: for each cue, the span of tokens that shows the
5 // cue.
6 //
7 // A book has some hundred thousand characters, and the reader reads its
8 // pages one by one. One diff of all cues against a few pages is slow, and
9 // it matches cues to wrong places. So each page is aligned on its own:
10 //
11 // 1. All cues, normalized and joined, make the cue stream. An index holds
12 //    each K-gram (a run of K characters) that occurs only once in it.
13 // 2. Each K-gram of a page that is in the index gives an anchor: a cue
14 //    position and a page position. The longest chain of anchors that rises
15 //    in both stays. An anchor far from the median shift of the chain is a
16 //    chance hit and goes too. A page with fewer than MIN_ANCHORS anchors is
17 //    not in the subtitles.
18 // 3. The first and the last anchor give the region of the cue stream that
19 //    the page shows. A character diff (Myers, via fast-diff) of the region
20 //    against the page maps cue characters to page tokens.
21 // 4. The pairs (cue character, token) of all read pages must rise in both.
22 //    The longest rising chain of pairs stays. The other pairs go, for
23 //    example the pairs of a page that is out of order.
24 // 5. Each cue gets the span from its first to its last mapped token.
25 //
26 // The aligner keeps the result of steps 2 and 3 for each page, so one more
27 // page costs the work of one page. A short cue stream has too few K-grams
28 // for anchors: its region is the whole stream.
29 
30 import diff from 'fast-diff';
31 
32 /** A recognized unit of text with a box on a page. */
33 export interface OcrToken {
34   text: string;
35   page: number;
36   /** Line id. Tokens with the same line id lie on one visual line. */
37   line: number;
38   /** Word id. Tokens with the same word id form one OCR word. Absent for hand-made tokens. */
39   word?: number;
40   bbox: { x0: number; y0: number; x1: number; y1: number };
41 }
42 
43 /** Half-open token span [start, end) in the tokens of the read pages. */
44 export interface TokenSpan {
45   start: number;
46   end: number;
47   /** True when a character of the cue is mapped. An unmatched cue has the span [0, 0). */
48   matched: boolean;
49 }
50 
51 /** The length of the K-grams in the index. */
52 const K = 8;
53 
54 /** A page with fewer anchors than this is not in the subtitles. */
55 const MIN_ANCHORS = 3;
56 
57 /**
58  * An anchor whose shift (cue position minus page position) is further than
59  * this from the median shift of its page is a chance hit. A long page can
60  * have a longer part that is not narrated, such as a caption, so a quarter
61  * of the page length applies when it is more.
62  */
63 const SHIFT_LIMIT = 50;
64 
65 /**
66  * A cue stream of this length or less (a few subtitle lines) is too short
67  * for anchors. Each page is then compared with the whole stream, so text
68  * that is not in the subtitles can match by chance.
69  */
70 const SHORT_STREAM = 64;
71 
72 /** The shortest equal run of the diff that counts as a match. */
73 const MIN_RUN = 2;
74 
75 /** The longest edit next to a shorter equal run that still counts as local noise. */
76 const MAX_LOCAL_EDIT = 2;
77 
78 const KEEP = /[\p{L}\p{N}\p{M}]/u;
79 
80 /** A limit on the passes of normalizeText. Real text needs one or two. */
81 const MAX_NORMALIZE_PASSES = 4;
82 
83 /** One pass: NFKC, lowercase, then only letters, numbers and marks. */
84 function normalizePass(text: string): string {
85   let out = '';
86   for (const ch of text.normalize('NFKC').toLowerCase()) {
87     if (KEEP.test(ch)) out += ch;
88   }
89   return out;
90 }
91 
92 /**
93  * Normalize text for comparison. NFKC folds full-width forms. Lowercase
94  * folds case. Only letters, numbers and marks stay. Spaces and punctuation
95  * go, so word boundaries do not matter. This works for CJK, where OCR and
96  * subtitles rarely agree on spacing.
97  *
98  * The case map and the filter can leave combining marks out of canonical
99  * order, and then one more pass changes the text. The passes repeat until
100  * the text stays the same, so the result is stable.
101  */
102 export function normalizeText(text: string): string {
103   let out = normalizePass(text);
104   for (let pass = 1; pass < MAX_NORMALIZE_PASSES; pass++) {
105     const next = normalizePass(out);
106     if (next === out) break;
107     out = next;
108   }
109   return out;
110 }
111 
112 export interface Stream {
113   chars: string;
114   /** tokenOf[i] is the index of the token that holds chars[i]. */
115   tokenOf: number[];
116 }
117 
118 /** Join normalized token texts into one stream with a char-to-token map. */
119 export function buildStream(texts: readonly string[]): Stream {
120   let chars = '';
121   const tokenOf: number[] = [];
122   texts.forEach((text, i) => {
123     const norm = normalizeText(text);
124     chars += norm;
125     for (let k = 0; k < norm.length; k++) tokenOf.push(i);
126   });
127   return { chars, tokenOf };
128 }
129 
130 /** The tokens of one page. The aligner uses only their text. */
131 export type PageTokens = readonly Pick<OcrToken, 'text'>[];
132 
133 /** Aligns the cues of one subtitle file to the pages that are read. */
134 export interface Aligner {
135   /**
136    * One span for each cue. `pages[p]` holds the tokens of page p, or
137    * undefined when page p is not read. The spans index the tokens of the
138    * read pages, joined in page order. The spans of the matched cues rise:
139    * a later cue starts and ends at or after an earlier cue.
140    */
141   spans(pages: readonly (PageTokens | undefined)[]): TokenSpan[];
142 }
143 
144 /** The pairs (cue character, page token) of one page, in page order. Both rise. */
145 interface PageMatch {
146   /** Positions in the cue stream. */
147   cue: Int32Array;
148   /** For each position in `cue`, the index of its token on the page. */
149   token: Int32Array;
150 }
151 
152 /** A half-open range [from, to) of the cue stream. */
153 interface Region {
154   from: number;
155   to: number;
156 }
157 
158 /**
159  * Make an aligner for the cues of one subtitle file. Keep it while the
160  * subtitles stay loaded: it keeps the result of each page that it saw.
161  */
162 export function createAligner(cueTexts: readonly string[]): Aligner {
163   const cues = cueTexts.map(normalizeText);
164   const stream = cues.join('');
165   /** cueOf[i] is the index of the cue that holds stream[i]. */
166   const cueOf = new Int32Array(stream.length);
167   let at = 0;
168   cues.forEach((cue, i) => {
169     cueOf.fill(i, at, at + cue.length);
170     at += cue.length;
171   });
172   const grams = uniqueGrams(stream);
173   /** The match of each page, by the identity of its token array. */
174   const matches = new WeakMap<PageTokens, PageMatch>();
175 
176   function matchPage(tokens: PageTokens): PageMatch {
177     const page = buildStream(tokens.map((t) => t.text));
178     const region =
179       stream.length <= SHORT_STREAM
180         ? { from: 0, to: stream.length }
181         : locate(page.chars, stream, grams);
182     return region
183       ? mapRegion(stream, region, page)
184       : { cue: new Int32Array(), token: new Int32Array() };
185   }
186 
187   return {
188     spans(pages) {
189       // Join the pairs of the read pages in page order. A token index counts
190       // the tokens of all read pages before it.
191       const parts: { match: PageMatch; base: number }[] = [];
192       let base = 0;
193       let count = 0;
194       for (const tokens of pages) {
195         if (!tokens) continue;
196         let match = matches.get(tokens);
197         if (!match) {
198           match = matchPage(tokens);
199           matches.set(tokens, match);
200         }
201         parts.push({ match, base });
202         base += tokens.length;
203         count += match.cue.length;
204       }
205       const cue = new Int32Array(count);
206       const token = new Int32Array(count);
207       let n = 0;
208       for (const part of parts) {
209         cue.set(part.match.cue, n);
210         for (let k = 0; k < part.match.token.length; k++) {
211           token[n + k] = part.base + part.match.token[k]!;
212         }
213         n += part.match.cue.length;
214       }
215 
216       // The tokens rise already. Keep the longest chain of pairs whose cue
217       // positions rise too. Then each cue spans its first to its last token.
218       const spans: TokenSpan[] = cues.map(() => ({ start: 0, end: 0, matched: false }));
219       for (const k of risingChain(cue)) {
220         const span = spans[cueOf[cue[k]!]!]!;
221         if (!span.matched) {
222           span.start = token[k]!;
223           span.matched = true;
224         }
225         span.end = token[k]! + 1;
226       }
227       return spans;
228     },
229   };
230 }
231 
232 /** A hash of the K characters of `text` at `at`. Two different K-grams can have the same hash. */
233 function gramHash(text: string, at: number): number {
234   let h = 0;
235   for (let i = at; i < at + K; i++) h = (Math.imul(h, 31) + text.charCodeAt(i)) | 0;
236   return h;
237 }
238 
239 /**
240  * For each K-gram hash of the stream: the position of the K-gram when the
241  * hash occurs once, else -1.
242  */
243 function uniqueGrams(stream: string): Map<number, number> {
244   const grams = new Map<number, number>();
245   for (let i = 0; i + K <= stream.length; i++) {
246     const h = gramHash(stream, i);
247     grams.set(h, grams.has(h) ? -1 : i);
248   }
249   return grams;
250 }
251 
252 /**
253  * The region of the cue stream that a page shows, found with anchors.
254  * Null when the page has too few anchors: it is not in the subtitles.
255  */
256 function locate(page: string, stream: string, grams: ReadonlyMap<number, number>): Region | null {
257   // The anchors in page order. A hash hit counts only when the text agrees.
258   const cuePos: number[] = [];
259   const pagePos: number[] = [];
260   for (let j = 0; j + K <= page.length; j++) {
261     const at = grams.get(gramHash(page, j));
262     if (at !== undefined && at >= 0 && stream.startsWith(page.slice(j, j + K), at)) {
263       cuePos.push(at);
264       pagePos.push(j);
265     }
266   }
267   // A chance hit breaks the order, so keep the longest chain that rises in
268   // both. A chance hit in the chain is far from the median shift.
269   const chain = risingChain(cuePos);
270   if (chain.length < MIN_ANCHORS) return null;
271   const shift = (k: number): number => cuePos[k]! - pagePos[k]!;
272   const median = chain.map(shift).sort((x, y) => x - y)[chain.length >> 1]!;
273   const limit = Math.max(SHIFT_LIMIT, page.length / 4);
274   const kept = chain.filter((k) => Math.abs(shift(k) - median) <= limit);
275   if (kept.length < MIN_ANCHORS) return null;
276   // The page ends, projected along the shift of the first and the last anchor.
277   const first = kept[0]!;
278   const last = kept[kept.length - 1]!;
279   return {
280     from: Math.max(0, shift(first)),
281     to: Math.min(stream.length, shift(last) + page.length),
282   };
283 }
284 
285 /** Map the cue characters of a region to the tokens of a page with a character diff. */
286 function mapRegion(stream: string, region: Region, page: Stream): PageMatch {
287   const cue: number[] = [];
288   const token: number[] = [];
289   const ops = diff(stream.slice(region.from, region.to), page.chars);
290   let a = region.from;
291   let b = 0;
292   ops.forEach(([op, text], i) => {
293     if (op === diff.EQUAL) {
294       if (text.length >= MIN_RUN || isLocalNoise(ops, i)) {
295         for (let k = 0; k < text.length; k++) {
296           cue.push(a + k);
297           token.push(page.tokenOf[b + k]!);
298         }
299       }
300       a += text.length;
301       b += text.length;
302     } else if (op === diff.DELETE) {
303       a += text.length;
304     } else {
305       b += text.length;
306     }
307   });
308   return { cue: Int32Array.from(cue), token: Int32Array.from(token) };
309 }
310 
311 /**
312  * True when the equal run at ops[i] sits between short edits, such as one
313  * substituted or one dropped character. A short equal run inside a long
314  * insert or delete is a chance match and does not count.
315  */
316 function isLocalNoise(ops: readonly diff.Diff[], i: number): boolean {
317   let before = 0;
318   for (let k = i - 1; k >= 0 && ops[k]![0] !== diff.EQUAL; k--) before += ops[k]![1].length;
319   let after = 0;
320   for (let k = i + 1; k < ops.length && ops[k]![0] !== diff.EQUAL; k++) after += ops[k]![1].length;
321   return before <= MAX_LOCAL_EDIT && after <= MAX_LOCAL_EDIT;
322 }
323 
324 /**
325  * The indices of a longest chain of `values` that rises strictly, in index
326  * order. Patience sorting: O(n log n).
327  */
328 export function risingChain(values: ArrayLike<number>): number[] {
329   /** ends[k] is the index of the smallest last value of a rising chain of length k + 1. */
330   const ends = new Int32Array(values.length);
331   /** before[i] is the index before i in the chain that ends at i, or -1. */
332   const before = new Int32Array(values.length);
333   let length = 0;
334   for (let i = 0; i < values.length; i++) {
335     const value = values[i]!;
336     // Find the shortest chain whose last value is not below `value`. Most
337     // values extend the longest chain, so try that first.
338     let lo = length;
339     if (length === 0 || values[ends[length - 1]!]! >= value) {
340       lo = 0;
341       let hi = length;
342       while (lo < hi) {
343         const mid = (lo + hi) >> 1;
344         if (values[ends[mid]!]! < value) lo = mid + 1;
345         else hi = mid;
346       }
347     }
348     before[i] = lo > 0 ? ends[lo - 1]! : -1;
349     ends[lo] = i;
350     if (lo === length) length++;
351   }
352   const chain = new Array<number>(length);
353   for (let k = length - 1, i = length > 0 ? ends[length - 1]! : -1; k >= 0; k--, i = before[i]!) {
354     chain[k] = i;
355   }
356   return chain;
357 }
358 
359 /**
360  * The spans after `count` tokens go into the tokens at index `at`: each
361  * matched span keeps its first and its last token. The reader reads the
362  * pages after the current page first. A page before it then puts its tokens
363  * in front of the tokens that the spans index, and without this shift the
364  * spans point at other tokens until the next alignment. A span across `at`
365  * also holds the new tokens. An unmatched span stays as it is.
366  */
367 export function shiftSpans(spans: readonly TokenSpan[], at: number, count: number): TokenSpan[] {
368   return spans.map((span) =>
369     span.matched
370       ? {
371           start: span.start < at ? span.start : span.start + count,
372           end: span.end <= at ? span.end : span.end + count,
373           matched: true,
374         }
375       : span,
376   );
377 }
378 
379 /**
380  * Align cues to tokens in one call. The tokens must be in page order, the
381  * order that the reader keeps them in. The spans index `tokens`.
382  */
383 export function alignCuesToTokens(
384   cueTexts: readonly string[],
385   tokens: readonly Pick<OcrToken, 'text' | 'page'>[],
386 ): TokenSpan[] {
387   const pages: Pick<OcrToken, 'text' | 'page'>[][] = [];
388   tokens.forEach((t, i) => {
389     if (i > 0 && t.page < tokens[i - 1]!.page) {
390       throw new Error('alignCuesToTokens: the tokens are not in page order.');
391     }
392     (pages[t.page] ??= []).push(t);
393   });
394   return createAligner(cueTexts).spans(pages);
395 }