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/sentence.ts (3925 bytes)

1 // The sentence around a tapped token, for a SubRead Anki card. Pure: no DOM.
2 
3 import type { OcrToken } from './align';
4 import { joinWords } from './book-text';
5 
6 /** Longest sentence, in characters. */
7 export const SENTENCE_LENGTH = 200;
8 
9 /**
10  * A sentence end: one or more of 。.!?.!? and the closing marks after
11  * them, as in 。」 or ." so that the closing mark stays with its sentence.
12  */
13 const END = /[。.!?.!?]+[」』))"'”’]*/gu;
14 
15 /** An end of only ".", "!" and "?", with no closing mark. */
16 const LATIN_END = /^[.!?]+$/;
17 
18 /** After such an end, a Latin letter or a digit means no sentence end: 3.14, e.g. */
19 const LATIN_NEXT = /[A-Za-z0-9]/;
20 
21 /** True when two tokens are in one word: the same page, line id and word id. */
22 function sameWord(a: OcrToken, b: OcrToken): boolean {
23   return a.page === b.page && a.line === b.line && a.word === b.word;
24 }
25 
26 /**
27  * The text of the page that holds token `index`, and the offset of that
28  * token in it. The words of a line and the lines of the page are joined
29  * with `joinWords`, the same as in the book text. The tokens of one page
30  * are next to each other in `tokens`.
31  */
32 function pageText(tokens: readonly OcrToken[], index: number): { text: string; at: number } {
33   const page = tokens[index]!.page;
34   let i = index;
35   while (i > 0 && tokens[i - 1]!.page === page) i--;
36   let text = '';
37   let at = 0;
38   while (i < tokens.length && tokens[i]!.page === page) {
39     const first = tokens[i]!;
40     let word = '';
41     let tapped = -1;
42     do {
43       if (i === index) tapped = word.length;
44       word += tokens[i]!.text;
45       i++;
46     } while (i < tokens.length && sameWord(first, tokens[i]!));
47     const trimmed = word.trim();
48     const joined = joinWords([text, trimmed]);
49     if (tapped >= 0) {
50       const lead = word.length - word.trimStart().length;
51       at = joined.length - trimmed.length + Math.max(0, tapped - lead);
52     }
53     text = joined;
54   }
55   return { text, at };
56 }
57 
58 /** The start and the end of the sentence that holds offset `at` of `text`. */
59 function sentenceSpan(text: string, at: number): [number, number] {
60   let start = 0;
61   for (const m of text.matchAll(END)) {
62     const end = m.index + m[0].length;
63     if (LATIN_END.test(m[0]) && LATIN_NEXT.test(text.charAt(end))) continue;
64     if (end > at) return [start, end];
65     start = end;
66   }
67   return [start, text.length];
68 }
69 
70 /**
71  * The sentence that holds token `index`: from the end of the sentence
72  * before it to the end of its own sentence, across the lines of its page.
73  *
74  * A sentence ends at 。.!? and at .!?, with the closing marks that follow
75  * them (」』) and closing quotes). A "." before a Latin letter or a digit is
76  * not an end (3.14, e.g.). A 」 with no end mark before it is not an end, so
77  * 「はい」と言った。 is one sentence. On a page with no sentence end, the
78  * sentence runs to the edge of the page.
79  *
80  * The words are joined with `joinWords`, and the result is trimmed. When the
81  * sentence is longer than `max` characters, the result is the `max`
82  * characters around the tapped token.
83  */
84 export function sentenceAround(
85   tokens: readonly OcrToken[],
86   index: number,
87   max = SENTENCE_LENGTH,
88 ): string {
89   const tapped = tokens[index];
90   if (!tapped) return '';
91   const { text, at } = pageText(tokens, index);
92   const [start, end] = sentenceSpan(text, at);
93   const sentence = text.slice(start, end);
94   const trimmed = sentence.trim();
95   const chars = [...trimmed];
96   if (chars.length <= max) return trimmed;
97   // A window of `max` characters, with the tapped token in the middle.
98   const lead = sentence.length - sentence.trimStart().length;
99   const tap = [...trimmed.slice(0, Math.max(0, at - start - lead))].length;
100   const size = [...tapped.text].length;
101   const from = Math.max(0, Math.min(tap - Math.floor((max - size) / 2), chars.length - max));
102   return chars
103     .slice(from, from + max)
104     .join('')
105     .trim();
106 }