src/anki-card.ts (2908 bytes)
1 // The fields of a SubRead Anki card for a long press on the page. Pure: no DOM. 2 3 import type { OcrToken, TokenSpan } from './align'; 4 import { isCjk, joinWords } from './book-text'; 5 import { sentenceAround } from './sentence'; 6 import type { AnkiCard } from './subread'; 7 8 /** Punctuation and symbols at the start and at the end of a word. */ 9 const EDGES = /^[\p{P}\p{S}]+|[\p{P}\p{S}]+$/gu; 10 11 /** 12 * The word of token `index`: the text of the tokens with its page, line 13 * and word id, without punctuation and symbols at its edges. "hills," 14 * gives "hills", and "don't" stays "don't". 15 */ 16 export function wordAt(tokens: readonly OcrToken[], index: number): string { 17 const token = tokens[index]; 18 if (!token) return ''; 19 const same = (t: OcrToken | undefined): boolean => 20 t !== undefined && t.page === token.page && t.line === token.line && t.word === token.word; 21 let start = index; 22 let end = index + 1; 23 while (same(tokens[start - 1])) start--; 24 while (same(tokens[end])) end++; 25 return tokens 26 .slice(start, end) 27 .map((t) => t.text) 28 .join('') 29 .replace(EDGES, ''); 30 } 31 32 /** Where the sentence of a card is from: "<book name without .pdf>, p. <page number>". */ 33 export function cardSource(bookName: string, page: number): string { 34 return `${bookName.replace(/\.pdf$/i, '')}, p. ${page + 1}`; 35 } 36 37 export interface CardInput { 38 tokens: readonly OcrToken[]; 39 /** The pressed token. */ 40 index: number; 41 /** The span of each cue, from the alignment. */ 42 spans: readonly TokenSpan[]; 43 /** The cue with the mark on the page, or -1. */ 44 markedCue: number; 45 /** The texts of the cues. */ 46 cues: readonly { text: string }[]; 47 /** Where the sentence is from: see cardSource. */ 48 source: string; 49 } 50 51 /** 52 * The card for a long press on token `index`, or null when there is no 53 * such token. 54 * 55 * - The sentence is the text of the marked cue when the token is in the 56 * span of that cue, else the sentence around the token (sentenceAround). 57 * - A CJK token gives `{ text, source }`. OCR does not show where a 58 * Japanese word ends, so SubRead Anki shows the sentence, and the user 59 * taps the word. 60 * - Another token gives `{ word, sentence, source }`. A token of 61 * punctuation only has no word: it gives `{ text, source }`. 62 */ 63 export function ankiCard(input: CardInput): AnkiCard | null { 64 const { tokens, index, source } = input; 65 const token = tokens[index]; 66 if (!token) return null; 67 const span = input.spans[input.markedCue]; 68 const cue = input.cues[input.markedCue]; 69 const inCue = !!span && !!cue && span.matched && span.start <= index && index < span.end; 70 const sentence = inCue 71 ? joinWords(cue.text.split('\n').map((line) => line.trim())) 72 : sentenceAround(tokens, index); 73 if (isCjk(token.text)) return { text: sentence, source }; 74 const word = wordAt(tokens, index); 75 return word ? { word, sentence, source } : { text: sentence, source }; 76 }