test/book.ts (4096 bytes)
1 // Synthetic books for the alignment tests: cue texts, pages of one token 2 // per character, and OCR noise. All randomness comes from fast-check. 3 4 import fc from 'fast-check'; 5 import { normalizeText, type OcrToken } from '../src/align'; 6 7 /** A small mixed alphabet: Latin, kana and kanji, so CJK is always covered. */ 8 export const ALPHABET = [...'abcdefghijklmnop', ...'あいうえおかきくけこ', ...'吾輩猫名前春夏秋冬']; 9 10 export const char = fc.constantFrom(...ALPHABET); 11 12 export const word = (min: number, max: number): fc.Arbitrary<string> => 13 fc.array(char, { minLength: min, maxLength: max }).map((cs) => cs.join('')); 14 15 export type Token = Pick<OcrToken, 'text' | 'page'>; 16 17 export interface Book { 18 cues: string[]; 19 /** The start of each cue in the book text, and the end of the text last. */ 20 offsets: number[]; 21 /** The page of each character of the book text. */ 22 pageOf: number[]; 23 /** The tokens of each page, one for each character, after the noise. */ 24 pages: Token[][]; 25 } 26 27 /** 28 * One noise draw for each character: a number in [0, 1) and a character 29 * for a substitution or an insertion. No bias, so the rate is exact. 30 */ 31 const draw = fc.tuple( 32 fc.noBias(fc.integer({ min: 0, max: 9999 })).map((n) => n / 10000), 33 char, 34 ); 35 36 export interface BookShape { 37 /** The count of cues. */ 38 cues: [number, number]; 39 /** The length of a cue in characters. */ 40 cueLength: [number, number]; 41 /** The length of a page in characters. The last page takes the rest. */ 42 pageLength: [number, number]; 43 /** The share of characters with an edit: a drop, a substitution or an insertion. */ 44 noise: number; 45 } 46 47 /** Books of the given shape. */ 48 export function books(shape: BookShape): fc.Arbitrary<Book> { 49 return fc 50 .record({ 51 cues: fc.array(word(shape.cueLength[0], shape.cueLength[1]), { 52 minLength: shape.cues[0], 53 maxLength: shape.cues[1], 54 }), 55 pageLength: fc.integer({ min: shape.pageLength[0], max: shape.pageLength[1] }), 56 }) 57 .chain(({ cues, pageLength }) => { 58 const length = shape.noise > 0 ? cues.join('').length : 0; 59 return fc 60 .array(draw, { minLength: length, maxLength: length }) 61 .map((draws) => makeBook(cues, pageLength, draws, shape.noise)); 62 }); 63 } 64 65 function makeBook( 66 cues: string[], 67 pageLength: number, 68 draws: readonly [number, string][], 69 noise: number, 70 ): Book { 71 const text = cues.join(''); 72 const offsets: number[] = []; 73 let at = 0; 74 for (const cue of cues) { 75 offsets.push(at); 76 at += cue.length; 77 } 78 offsets.push(at); 79 const pageCount = Math.max(1, Math.floor(text.length / pageLength)); 80 const pageOf = [...text].map((_, i) => Math.min(pageCount - 1, Math.floor(i / pageLength))); 81 const pages: Token[][] = Array.from({ length: pageCount }, () => []); 82 [...text].forEach((ch, i) => { 83 const page = pageOf[i]!; 84 const [r, other] = draws[i] ?? [1, ch]; 85 // A third of the edits drop the character, a third put another in its 86 // place, and a third put another before it. 87 let read = [ch]; 88 if (r < noise / 3) read = []; 89 else if (r < (2 * noise) / 3) read = [other]; 90 else if (r < noise) read = [other, ch]; 91 for (const text of read) pages[page]!.push({ text, page }); 92 }); 93 return { cues, offsets, pageOf, pages }; 94 } 95 96 /** The pages that hold the characters of cue `i`. */ 97 export function pagesOfCue(book: Book, i: number): Set<number> { 98 const pages = new Set<number>(); 99 for (let k = book.offsets[i]!; k < book.offsets[i + 1]!; k++) pages.add(book.pageOf[k]!); 100 return pages; 101 } 102 103 /** The runs of `k` characters in the normalized form of `text`. */ 104 export function grams(text: string, k = 8): Set<string> { 105 const norm = normalizeText(text); 106 const out = new Set<string>(); 107 for (let i = 0; i + k <= norm.length; i++) out.add(norm.slice(i, i + k)); 108 return out; 109 } 110 111 /** The read pages of a book: `read[p]` true keeps page p, else it is undefined. */ 112 export function readPages(book: Book, read: readonly boolean[]): (Token[] | undefined)[] { 113 return book.pages.map((tokens, p) => (read[p] ? tokens : undefined)); 114 }