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/text-layer.ts (5845 bytes)

1 // The text layer of a PDF page as tokens, the same shape as OCR. Pure.
2 //
3 // pdf.js `getTextContent()` gives one item per run of text: the string,
4 // the transform of its origin, its length along the run and its font
5 // size. Each character gets an even share of the run. The boxes go
6 // through `toPixel`, so they land in the pixel space of the rendered page
7 // like the OCR boxes do.
8 
9 import type { OcrToken } from './align';
10 import { LINES_PER_PAGE } from './ocr-tokens';
11 
12 /** The fields of a pdf.js TextItem that the conversion uses. */
13 export interface TextItemLike {
14   str: string;
15   /** 'ltr', 'rtl' or 'ttb'. 'ttb' is a vertical font: the run goes down. */
16   dir: string;
17   /** [a, b, c, d, e, f]: the x axis (a, b), the y axis (c, d), the origin (e, f). */
18   transform: readonly number[];
19   /** Length of the run along the x axis of the font, or the font size for 'ttb'. */
20   width: number;
21   /** The font size, or the length of the run down the page for 'ttb'. */
22   height: number;
23   /** True when a line break follows the item. */
24   hasEOL: boolean;
25 }
26 
27 /** From PDF user space to page pixels. */
28 export type ToPixel = (x: number, y: number) => readonly [number, number];
29 
30 /** Part of the font size above the baseline, and below it. */
31 const ASCENT = 0.8;
32 const DESCENT = 0.25;
33 
34 /** Fewer non-space characters than this means the page has no useful text layer. */
35 export const MIN_TEXT_CHARS = 10;
36 
37 interface Vec {
38   x: number;
39   y: number;
40 }
41 
42 function unit(x: number, y: number): Vec {
43   const n = Math.hypot(x, y) || 1;
44   return { x: x / n, y: y / n };
45 }
46 
47 /** The geometry of one run: where it starts, which way it goes, how big the glyphs are. */
48 interface Run {
49   origin: Vec;
50   /** Unit vector along the run. */
51   along: Vec;
52   /** Unit vector across the run, toward the top of the glyphs. */
53   up: Vec;
54   /** Length of the run. */
55   length: number;
56   /** Size of the glyphs across the run. */
57   size: number;
58   /** True for a vertical font: the glyphs are centered on the run. */
59   centered: boolean;
60 }
61 
62 function runOf(item: TextItemLike): Run {
63   const [a = 1, b = 0, c = 0, d = 1, e = 0, f = 0] = item.transform;
64   const origin = { x: e, y: f };
65   if (item.dir === 'ttb') {
66     // A vertical font advances down the y axis of the font.
67     return {
68       origin,
69       along: unit(-c, -d),
70       up: unit(a, b),
71       length: item.height,
72       size: item.width,
73       centered: true,
74     };
75   }
76   // A horizontal font advances along the x axis of the font. A rotated
77   // transform (|b| > |a|) lays the run along the page's y.
78   return {
79     origin,
80     along: unit(a, b),
81     up: unit(c, d),
82     length: item.width,
83     size: item.height,
84     centered: false,
85   };
86 }
87 
88 /** The axis-aligned pixel box of a parallelogram given by its four corners. */
89 function pixelBox(corners: readonly Vec[], toPixel: ToPixel): OcrToken['bbox'] {
90   const box = { x0: Infinity, y0: Infinity, x1: -Infinity, y1: -Infinity };
91   for (const p of corners) {
92     const [x, y] = toPixel(p.x, p.y);
93     box.x0 = Math.min(box.x0, x);
94     box.y0 = Math.min(box.y0, y);
95     box.x1 = Math.max(box.x1, x);
96     box.y1 = Math.max(box.y1, y);
97   }
98   return box;
99 }
100 
101 /** Box of character `i` of `n` in the run. */
102 function charBox(run: Run, i: number, n: number, toPixel: ToPixel): OcrToken['bbox'] {
103   const step = run.length / n;
104   const from = i * step;
105   const to = from + step;
106   const [lo, hi] = run.centered
107     ? [-run.size / 2, run.size / 2]
108     : [-run.size * DESCENT, run.size * ASCENT];
109   const at = (t: number, s: number): Vec => ({
110     x: run.origin.x + run.along.x * t + run.up.x * s,
111     y: run.origin.y + run.along.y * t + run.up.y * s,
112   });
113   return pixelBox([at(from, lo), at(to, lo), at(to, hi), at(from, hi)], toPixel);
114 }
115 
116 /** True for a character that separates words and gets no token. */
117 function isSpace(ch: string): boolean {
118   return /\s/u.test(ch);
119 }
120 
121 /**
122  * One token per character, with line and word ids, in content-stream
123  * order. Runs on one baseline (within half a font size) share a line.
124  * Spaces separate words and get no token.
125  */
126 export function textItemsToTokens(
127   items: readonly TextItemLike[],
128   pageIndex: number,
129   toPixel: ToPixel,
130 ): OcrToken[] {
131   const tokens: OcrToken[] = [];
132   const lines: { key: number; tolerance: number; id: number }[] = [];
133   let lineId = -1;
134   let word = 0;
135   let breakLine = true;
136   for (const item of items) {
137     const chars = [...item.str];
138     if (chars.length === 0) {
139       if (item.hasEOL) breakLine = true;
140       continue;
141     }
142     const run = runOf(item);
143     // The baseline: the origin projected on the "up" axis.
144     const key = run.origin.x * run.up.x + run.origin.y * run.up.y;
145     const tolerance = run.size / 2;
146     let line = breakLine ? undefined : lines.find((l) => Math.abs(l.key - key) <= l.tolerance);
147     if (!line) {
148       line = { key, tolerance, id: pageIndex * LINES_PER_PAGE + lines.length };
149       lines.push(line);
150       word++;
151     }
152     if (line.id !== lineId) word++;
153     lineId = line.id;
154     breakLine = item.hasEOL;
155     chars.forEach((ch, i) => {
156       if (isSpace(ch)) {
157         word++;
158         return;
159       }
160       tokens.push({
161         text: ch,
162         page: pageIndex,
163         line: line.id,
164         word: pageIndex * LINES_PER_PAGE + word,
165         bbox: charBox(run, i, chars.length, toPixel),
166       });
167     });
168   }
169   return tokens;
170 }
171 
172 /** The text items of a pdf.js text content. Marked-content items have no `str` and are dropped. */
173 export function textItems(items: readonly unknown[]): TextItemLike[] {
174   return items.filter((i): i is TextItemLike => typeof i === 'object' && i !== null && 'str' in i);
175 }
176 
177 /** The number of non-space characters in the items. */
178 export function textChars(items: readonly TextItemLike[]): number {
179   let n = 0;
180   for (const item of items) for (const ch of item.str) if (!isSpace(ch)) n++;
181   return n;
182 }