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 });