commit e61eb8e0c8c25b88dc8a2fd825cc607b7cc02dce truex <truex@equwal.com> 2026-09-24 12:21:59 -0700 Add word ids to OCR tokens and build the book text from them The subtitle aligners read the book as text lines. bookText joins the OCR symbols into words, words into lines and pages into one text, with a blank line between pages. A space goes between words, but not between two CJK words.
src/align.ts | 2 ++ src/book-text.ts | 94 ++++++++++++++++++++++++++++++++++++++++++++++++ src/ocr-tokens.ts | 18 +++++++--- test/book-text.test.ts | 96 ++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 206 insertions(+), 4 deletions(-)
diff --git a/src/align.ts b/src/align.ts index 881851c..3246457 100644 --- a/src/align.ts +++ b/src/align.ts @@ -18,6 +18,8 @@ export interface OcrToken { page: number; /** Line id. Tokens with the same line id lie on one visual line. */ line: number; + /** Word id. Tokens with the same word id form one OCR word. Absent for hand-made tokens. */ + word?: number; bbox: { x0: number; y0: number; x1: number; y1: number }; } diff --git a/src/book-text.ts b/src/book-text.ts new file mode 100644 index 0000000..b285f0f --- /dev/null +++ b/src/book-text.ts @@ -0,0 +1,94 @@ +// The OCR text as a plain-text book. Pure: no DOM. +// +// Both aligners (the subread.space server and the in-app engine) read the +// book as text lines. This module turns the OCR tokens into that text and +// back into the paragraph list the in-app engine takes. + +import type { OcrToken } from './align'; + +/** Han, kana and CJK punctuation: scripts that are written without spaces. */ +const CJK = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana} -〿-]/u; + +/** True when a space belongs between two neighboring words. Not between two CJK words. */ +function needsSpace(prev: string, next: string): boolean { + return !(CJK.test(prev.slice(-1)) && CJK.test(next.slice(0, 1))); +} + +/** One line from its words: a space between words, except between CJK words. */ +export function joinWords(words: readonly string[]): string { + let out = ''; + for (const w of words) { + if (out.length > 0 && w.length > 0 && needsSpace(out, w)) out += ' '; + out += w; + } + return out; +} + +/** + * One text line per OCR line, pages in order, one blank line between + * pages. Words on a line are joined with `joinWords`. Blank lines inside + * a page are dropped. The result ends with a newline. + */ +export function bookText(tokens: readonly OcrToken[]): string { + const pages: string[][] = []; + let lines: string[] = []; + let words: string[] = []; + let word = ''; + let page = -1; + let lineId = -1; + let wordId: number | undefined; + + const endWord = (): void => { + const text = word.trim(); + if (text.length > 0) words.push(text); + word = ''; + }; + const endLine = (): void => { + endWord(); + const text = joinWords(words); + if (text.length > 0) lines.push(text); + words = []; + }; + const endPage = (): void => { + endLine(); + if (lines.length > 0) pages.push(lines); + lines = []; + }; + + for (const t of tokens) { + if (t.page !== page) { + endPage(); + page = t.page; + lineId = t.line; + wordId = t.word; + } else if (t.line !== lineId) { + endLine(); + lineId = t.line; + wordId = t.word; + } else if (t.word !== wordId) { + endWord(); + wordId = t.word; + } + word += t.text; + } + endPage(); + return pages.map((p) => p.join('\n')).join('\n\n') + (pages.length > 0 ? '\n' : ''); +} + +/** The non-blank lines of a book text, trimmed. This is what the aligner takes. */ +export function paragraphs(text: string): string[] { + return text + .split('\n') + .map((l) => l.trim()) + .filter((l) => l.length > 0); +} + +/** The book as a file for the upload. The server pairs it with the audio by suffix. */ +export function bookFile(tokens: readonly OcrToken[]): File { + return new File([bookText(tokens)], 'book.txt', { type: 'text/plain' }); +} + +/** The Whisper language code for a tesseract language ("jpn", "jpn_vert", "jpn+eng", "eng"). */ +export function whisperCode(ocrLang: string): 'ja' | 'en' { + return ocrLang.startsWith('jpn') ? 'ja' : 'en'; +} diff --git a/src/ocr-tokens.ts b/src/ocr-tokens.ts index 09a07fb..a20a9f6 100644 --- a/src/ocr-tokens.ts +++ b/src/ocr-tokens.ts @@ -9,19 +9,29 @@ const LINES_PER_PAGE = 100000; /** * One token per recognized symbol (character). Symbol level works for * both spaced scripts and CJK, where a Tesseract "word" can be a whole - * line or column. The UI merges tokens on one line into one box. + * line or column. The UI merges tokens on one line into one box. The + * word id marks the word boundaries, so the book text keeps its spaces. */ export function pageToTokens(page: Pick<Tesseract.Page, 'blocks'>, pageIndex: number): OcrToken[] { const tokens: OcrToken[] = []; let line = 0; + let word = 0; for (const block of page.blocks ?? []) { for (const paragraph of block.paragraphs) { for (const ln of paragraph.lines) { const lineId = pageIndex * LINES_PER_PAGE + line; line++; - for (const word of ln.words) { - for (const symbol of word.symbols) { - tokens.push({ text: symbol.text, page: pageIndex, line: lineId, bbox: symbol.bbox }); + for (const w of ln.words) { + const wordId = pageIndex * LINES_PER_PAGE + word; + word++; + for (const symbol of w.symbols) { + tokens.push({ + text: symbol.text, + page: pageIndex, + line: lineId, + word: wordId, + bbox: symbol.bbox, + }); } } } diff --git a/test/book-text.test.ts b/test/book-text.test.ts new file mode 100644 index 0000000..d154664 --- /dev/null +++ b/test/book-text.test.ts @@ -0,0 +1,96 @@ +import fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import type { OcrToken } from '../src/align'; +import { bookText, joinWords, paragraphs, whisperCode } from '../src/book-text'; + +const box = { x0: 0, y0: 0, x1: 1, y1: 1 }; + +/** Tokens for one page: `lines` is a list of lines, each a list of words. */ +function pageTokens(page: number, lines: readonly (readonly string[])[]): OcrToken[] { + const out: OcrToken[] = []; + lines.forEach((words, li) => { + words.forEach((word, wi) => { + for (const ch of word) { + out.push({ + text: ch, + page, + line: page * 1000 + li, + word: page * 1000 + li * 100 + wi, + bbox: box, + }); + } + }); + }); + return out; +} + +describe('bookText', () => { + it('joins symbols into words, words into lines, and separates pages with a blank line', () => { + const tokens = [ + ...pageTokens(0, [ + ['The', 'sun'], + ['rose', 'up'], + ]), + ...pageTokens(1, [['朝の', '光']]), + ]; + expect(bookText(tokens)).toBe('The sun\nrose up\n\n朝の光\n'); + }); + + it('puts a space between words unless both sides are CJK', () => { + expect(joinWords(['吾輩', 'は', '猫'])).toBe('吾輩は猫'); + expect(joinWords(['The', 'sun'])).toBe('The sun'); + expect(joinWords(['東京', 'Tokyo', '駅'])).toBe('東京 Tokyo 駅'); + expect(joinWords(['名前', '。', 'まだ'])).toBe('名前。まだ'); + }); + + it('returns an empty string for no tokens', () => { + expect(bookText([])).toBe(''); + }); + + it('treats tokens without a word id as one word per line', () => { + const tokens: OcrToken[] = [ + { text: 'a', page: 0, line: 0, bbox: box }, + { text: 'b', page: 0, line: 0, bbox: box }, + { text: 'c', page: 0, line: 1, bbox: box }, + ]; + expect(bookText(tokens)).toBe('ab\nc\n'); + }); + + it('drops lines that hold only whitespace', () => { + const tokens: OcrToken[] = [ + { text: ' ', page: 0, line: 0, bbox: box }, + { text: 'x', page: 0, line: 1, bbox: box }, + ]; + expect(bookText(tokens)).toBe('x\n'); + }); + + it('round trips through paragraphs: every OCR line once, in order', () => { + // A word: letters only, so a word never holds a space or a line break. + const word = fc.stringMatching(/^[\p{L}\p{N}]{1,6}$/u); + const line = fc.array(word, { minLength: 1, maxLength: 5 }); + const page = fc.array(line, { minLength: 1, maxLength: 6 }); + const book = fc.array(page, { maxLength: 4 }); + fc.assert( + fc.property(book, (pages) => { + const tokens = pages.flatMap((lines, p) => pageTokens(p, lines)); + const expected = pages.flatMap((lines) => lines.map(joinWords)); + expect(paragraphs(bookText(tokens))).toEqual(expected); + }), + ); + }); +}); + +describe('paragraphs', () => { + it('keeps non-blank lines, trimmed', () => { + expect(paragraphs('a\n\n b \n\n\nc\n')).toEqual(['a', 'b', 'c']); + }); +}); + +describe('whisperCode', () => { + it('maps tesseract languages to Whisper codes', () => { + expect(whisperCode('jpn')).toBe('ja'); + expect(whisperCode('jpn_vert')).toBe('ja'); + expect(whisperCode('jpn+eng')).toBe('ja'); + expect(whisperCode('eng')).toBe('en'); + }); +});