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/book-text.ts (2583 bytes)

1 // The page text as a plain-text book. Pure: no DOM.
2 //
3 // SubRead reads the book as text lines when it makes the subtitles. This
4 // module turns the page tokens (OCR or text layer) into that text.
5 
6 import type { OcrToken } from './align';
7 
8 /**
9  * Han, kana and CJK punctuation: scripts that are written without spaces.
10  * Script_Extensions (scx) also include the characters that these scripts
11  * share, for example ー, ・, ゛ and ゜. Their Script is Common.
12  */
13 const CJK = /^[\p{scx=Han}\p{scx=Hiragana}\p{scx=Katakana} -〿＀-￯]/u;
14 
15 /** True when the first character of `text` is Han, kana or CJK punctuation. */
16 export function isCjk(text: string): boolean {
17   return CJK.test(text);
18 }
19 
20 /** True when a space belongs between two neighboring words. Not between two CJK words. */
21 function needsSpace(prev: string, next: string): boolean {
22   // Split into code points: a character outside the BMP is two UTF-16 units.
23   const last = [...prev].at(-1) ?? '';
24   return !(isCjk(last) && isCjk(next));
25 }
26 
27 /** One line from its words: a space between words, except between CJK words. */
28 export function joinWords(words: readonly string[]): string {
29   let out = '';
30   for (const w of words) {
31     if (out.length > 0 && w.length > 0 && needsSpace(out, w)) out += ' ';
32     out += w;
33   }
34   return out;
35 }
36 
37 /**
38  * One text line per OCR line, pages in order, one blank line between
39  * pages. Words on a line are joined with `joinWords`. Blank lines inside
40  * a page are dropped. The result ends with a newline.
41  */
42 export function bookText(tokens: readonly OcrToken[]): string {
43   const pages: string[][] = [];
44   let lines: string[] = [];
45   let words: string[] = [];
46   let word = '';
47   let page = -1;
48   let lineId = -1;
49   let wordId: number | undefined;
50 
51   const endWord = (): void => {
52     const text = word.trim();
53     if (text.length > 0) words.push(text);
54     word = '';
55   };
56   const endLine = (): void => {
57     endWord();
58     const text = joinWords(words);
59     if (text.length > 0) lines.push(text);
60     words = [];
61   };
62   const endPage = (): void => {
63     endLine();
64     if (lines.length > 0) pages.push(lines);
65     lines = [];
66   };
67 
68   for (const t of tokens) {
69     if (t.page !== page) {
70       endPage();
71       page = t.page;
72       lineId = t.line;
73       wordId = t.word;
74     } else if (t.line !== lineId) {
75       endLine();
76       lineId = t.line;
77       wordId = t.word;
78     } else if (t.word !== wordId) {
79       endWord();
80       wordId = t.word;
81     }
82     word += t.text;
83   }
84   endPage();
85   return pages.map((p) => p.join('\n')).join('\n\n') + (pages.length > 0 ? '\n' : '');
86 }