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 }