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 }