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/anki-card.test.ts (6586 bytes)

1 import fc from 'fast-check';
2 import { describe, expect, it } from 'vitest';
3 import type { OcrToken, TokenSpan } from '../src/align';
4 import { ankiCard, cardSource, wordAt } from '../src/anki-card';
5 
6 const box = { x0: 0, y0: 0, x1: 1, y1: 1 };
7 
8 /** Tokens for one page, one per character: each line is a list of words. */
9 function pageTokens(lines: readonly (readonly string[])[], page = 0): OcrToken[] {
10   const out: OcrToken[] = [];
11   lines.forEach((words, li) => {
12     words.forEach((word, wi) => {
13       for (const ch of word) {
14         out.push({
15           text: ch,
16           page,
17           line: page * 1000 + li,
18           word: page * 1000 + li * 100 + wi,
19           bbox: box,
20         });
21       }
22     });
23   });
24   return out;
25 }
26 
27 /** The index of token number `n` (from 0) with the text `ch`. */
28 function find(tokens: readonly OcrToken[], ch: string, n = 0): number {
29   let seen = 0;
30   for (let i = 0; i < tokens.length; i++) {
31     if (tokens[i]!.text !== ch) continue;
32     if (seen === n) return i;
33     seen++;
34   }
35   throw new Error(`No token ${ch} number ${n}.`);
36 }
37 
38 // The first page of sample-eng-2p: two cues on two lines.
39 const eng = pageTokens([
40   ['The', 'morning', 'sun', 'rose', 'over', 'the', 'quiet', 'hills,'],
41   ['and', 'the', 'village', 'below', 'began', 'to', 'stir.'],
42 ]);
43 const engCues = [
44   { text: 'The morning sun rose over the quiet hills,' },
45   { text: 'and the village below began to stir.' },
46 ];
47 const firstLine = eng.filter((t) => t.line === 0).length;
48 const engSpans: TokenSpan[] = [
49   { start: 0, end: firstLine, matched: true },
50   { start: firstLine, end: eng.length, matched: true },
51 ];
52 
53 // sample-jpn: one OCR word for a whole line, as Japanese OCR gives.
54 const jpn = pageTokens([['吾輩は猫である。名前はまだ無い。']]);
55 const jpnCues = [{ text: '吾輩は猫である。' }, { text: '名前はまだ無い。' }];
56 const jpnSpans: TokenSpan[] = [
57   { start: 0, end: 8, matched: true },
58   { start: 8, end: 16, matched: true },
59 ];
60 
61 const source = 'sample-eng-2p, p. 1';
62 
63 describe('ankiCard', () => {
64   it('gives the word without its comma, and the marked cue as the sentence', () => {
65     const card = ankiCard({
66       tokens: eng,
67       index: find(eng, ','),
68       spans: engSpans,
69       markedCue: 0,
70       cues: engCues,
71       source,
72     });
73     expect(card).toEqual({
74       word: 'hills',
75       sentence: 'The morning sun rose over the quiet hills,',
76       source,
77     });
78   });
79 
80   it('gives the sentence around the token when the token is not in the marked cue', () => {
81     const input = { tokens: eng, spans: engSpans, cues: engCues, source };
82     // The first "v" is in "over".
83     const village = find(eng, 'v', 1);
84     const sentence =
85       'The morning sun rose over the quiet hills, and the village below began to stir.';
86     expect(ankiCard({ ...input, index: village, markedCue: 0 })).toEqual({
87       word: 'village',
88       sentence,
89       source,
90     });
91     expect(ankiCard({ ...input, index: village, markedCue: -1 })).toEqual({
92       word: 'village',
93       sentence,
94       source,
95     });
96     const unmatched = [engSpans[0]!, { start: 0, end: 0, matched: false }];
97     expect(ankiCard({ ...input, spans: unmatched, index: village, markedCue: 1 })).toEqual({
98       word: 'village',
99       sentence,
100       source,
101     });
102   });
103 
104   it('gives a CJK token the text only, so the user taps the word in SubRead Anki', () => {
105     const input = { tokens: jpn, spans: jpnSpans, cues: jpnCues, source: 'sample-jpn, p. 1' };
106     expect(ankiCard({ ...input, index: find(jpn, '猫'), markedCue: 0 })).toEqual({
107       text: '吾輩は猫である。',
108       source: 'sample-jpn, p. 1',
109     });
110     expect(ankiCard({ ...input, index: find(jpn, '前'), markedCue: -1 })).toEqual({
111       text: '名前はまだ無い。',
112       source: 'sample-jpn, p. 1',
113     });
114   });
115 
116   it('joins the lines of a cue: a space between Latin lines, none between CJK lines', () => {
117     const cues = [{ text: 'was clear and bright,\nover the hills.' }];
118     const spans: TokenSpan[] = [{ start: 0, end: eng.length, matched: true }];
119     expect(ankiCard({ tokens: eng, index: 0, spans, markedCue: 0, cues, source })?.sentence).toBe(
120       'was clear and bright, over the hills.',
121     );
122     const jpnCue = [{ text: '吾輩は猫\nである。' }];
123     const all: TokenSpan[] = [{ start: 0, end: jpn.length, matched: true }];
124     expect(
125       ankiCard({ tokens: jpn, index: 0, spans: all, markedCue: 0, cues: jpnCue, source })?.text,
126     ).toBe('吾輩は猫である。');
127   });
128 
129   it('gives the text only for a token of punctuation with no word', () => {
130     const dash = pageTokens([['Wait', '—', 'no.']]);
131     const card = ankiCard({
132       tokens: dash,
133       index: find(dash, '—'),
134       spans: [],
135       markedCue: -1,
136       cues: [],
137       source,
138     });
139     expect(card).toEqual({ text: 'Wait — no.', source });
140   });
141 
142   it('gives null for a token that does not exist', () => {
143     const input = { tokens: eng, spans: engSpans, markedCue: 0, cues: engCues, source };
144     expect(ankiCard({ ...input, index: eng.length })).toBeNull();
145     expect(ankiCard({ ...input, index: -1 })).toBeNull();
146   });
147 });
148 
149 describe('wordAt', () => {
150   it('keeps the punctuation inside a word', () => {
151     const tokens = pageTokens([['"don\'t"', 'go.']]);
152     expect(wordAt(tokens, find(tokens, 'd'))).toBe("don't");
153     expect(wordAt(tokens, find(tokens, '"', 1))).toBe("don't");
154   });
155 
156   it('gives a word with no punctuation at its edges, from the letters of its line', () => {
157     const ch = fc.constantFrom('a', 'Z', '7', ',', '.', '"', '!', '(', ')', "'", '-');
158     const word = fc.array(ch, { minLength: 1, maxLength: 6 }).map((cs) => cs.join(''));
159     const lines = fc.array(fc.array(word, { minLength: 1, maxLength: 4 }), {
160       minLength: 1,
161       maxLength: 3,
162     });
163     fc.assert(
164       fc.property(lines, fc.nat(), (text, pick) => {
165         const tokens = pageTokens(text);
166         const index = pick % tokens.length;
167         const got = wordAt(tokens, index);
168         expect(got).not.toMatch(/^[\p{P}\p{S}]|[\p{P}\p{S}]$/u);
169         const t = tokens[index]!;
170         const own = tokens
171           .filter((o) => o.line === t.line && o.word === t.word)
172           .map((o) => o.text)
173           .join('');
174         expect(own).toContain(got);
175       }),
176     );
177   });
178 });
179 
180 describe('cardSource', () => {
181   it('names the book without .pdf and the page from 1', () => {
182     expect(cardSource('sample-eng-2p.pdf', 0)).toBe('sample-eng-2p, p. 1');
183     expect(cardSource('吾輩は猫である.PDF', 9)).toBe('吾輩は猫である, p. 10');
184   });
185 });