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 }