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


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 }