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


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 }