test/book-text.test.ts (4037 bytes)
1 import fc from 'fast-check'; 2 import { describe, expect, it } from 'vitest'; 3 import type { OcrToken } from '../src/align'; 4 import { bookText, joinWords } from '../src/book-text'; 5 6 const box = { x0: 0, y0: 0, x1: 1, y1: 1 }; 7 8 /** The non-blank lines of a book text, trimmed. */ 9 function paragraphs(text: string): string[] { 10 return text 11 .split('\n') 12 .map((l) => l.trim()) 13 .filter((l) => l.length > 0); 14 } 15 16 /** Tokens for one page: `lines` is a list of lines, each a list of words. */ 17 function pageTokens(page: number, lines: readonly (readonly string[])[]): OcrToken[] { 18 const out: OcrToken[] = []; 19 lines.forEach((words, li) => { 20 words.forEach((word, wi) => { 21 for (const ch of word) { 22 out.push({ 23 text: ch, 24 page, 25 line: page * 1000 + li, 26 word: page * 1000 + li * 100 + wi, 27 bbox: box, 28 }); 29 } 30 }); 31 }); 32 return out; 33 } 34 35 describe('bookText', () => { 36 it('joins symbols into words, words into lines, and separates pages with a blank line', () => { 37 const tokens = [ 38 ...pageTokens(0, [ 39 ['The', 'sun'], 40 ['rose', 'up'], 41 ]), 42 ...pageTokens(1, [['朝の', '光']]), 43 ]; 44 expect(bookText(tokens)).toBe('The sun\nrose up\n\n朝の光\n'); 45 }); 46 47 it('puts a space between words unless both sides are CJK', () => { 48 expect(joinWords(['吾輩', 'は', '猫'])).toBe('吾輩は猫'); 49 expect(joinWords(['The', 'sun'])).toBe('The sun'); 50 expect(joinWords(['東京', 'Tokyo', '駅'])).toBe('東京 Tokyo 駅'); 51 expect(joinWords(['名前', '。', 'まだ'])).toBe('名前。まだ'); 52 }); 53 54 it('puts no space around the prolonged sound mark and the other kana marks', () => { 55 // OCR put ー in a word of its own. Its Script is Common, not Katakana. 56 const words = ['ニャ', 'ー', '泣いていた事だけは記憶している。']; 57 expect(joinWords(words)).toBe('ニャー泣いていた事だけは記憶している。'); 58 expect(bookText(pageTokens(0, [words]))).toBe('ニャー泣いていた事だけは記憶している。\n'); 59 for (const mark of ['ー', '・', '゛', '゜', '々', '〆']) { 60 expect(joinWords(['ア', mark, 'イ'])).toBe(`ア${mark}イ`); 61 } 62 }); 63 64 it('puts no space between words of Han, kana, ー and ・, outside the BMP too', () => { 65 // Each list of words is one split of the string that the words make. 66 const word = fc.stringMatching( 67 /^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}ー・]{1,6}$/u, 68 ); 69 fc.assert( 70 fc.property(fc.array(word, { minLength: 1, maxLength: 8 }), (words) => { 71 expect(joinWords(words)).toBe(words.join('')); 72 }), 73 ); 74 }); 75 76 it('returns an empty string for no tokens', () => { 77 expect(bookText([])).toBe(''); 78 }); 79 80 it('treats tokens without a word id as one word per line', () => { 81 const tokens: OcrToken[] = [ 82 { text: 'a', page: 0, line: 0, bbox: box }, 83 { text: 'b', page: 0, line: 0, bbox: box }, 84 { text: 'c', page: 0, line: 1, bbox: box }, 85 ]; 86 expect(bookText(tokens)).toBe('ab\nc\n'); 87 }); 88 89 it('drops lines that hold only whitespace', () => { 90 const tokens: OcrToken[] = [ 91 { text: ' ', page: 0, line: 0, bbox: box }, 92 { text: 'x', page: 0, line: 1, bbox: box }, 93 ]; 94 expect(bookText(tokens)).toBe('x\n'); 95 }); 96 97 it('round trips through paragraphs: every OCR line once, in order', () => { 98 // A word: letters only, so a word never holds a space or a line break. 99 const word = fc.stringMatching(/^[\p{L}\p{N}]{1,6}$/u); 100 const line = fc.array(word, { minLength: 1, maxLength: 5 }); 101 const page = fc.array(line, { minLength: 1, maxLength: 6 }); 102 const book = fc.array(page, { maxLength: 4 }); 103 fc.assert( 104 fc.property(book, (pages) => { 105 const tokens = pages.flatMap((lines, p) => pageTokens(p, lines)); 106 const expected = pages.flatMap((lines) => lines.map(joinWords)); 107 expect(paragraphs(bookText(tokens))).toEqual(expected); 108 }), 109 ); 110 }); 111 });