Skip to content

Commit fe12bc7

Browse files
committed
fix: translate scanned PDFs by recovering hidden OCR text with pdf.js
Native extraction returns only an image for scanned pages, so only footers were translated. Re-extract such pages with pdf.js, normalize OCR words into lines, weight body size by characters, ship pdfjs-dist on every platform, and bump the layout cache schema to 4.
1 parent 71d9501 commit fe12bc7

7 files changed

Lines changed: 214 additions & 12 deletions

File tree

‎electron/build/production-stage.mjs‎

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,13 @@
1-
import { cp, mkdir, mkdtemp, readFile, writeFile, readdir, stat } from 'node:fs/promises';
1+
import {
2+
cp,
3+
mkdir,
4+
mkdtemp,
5+
readFile,
6+
writeFile,
7+
readdir,
8+
stat,
9+
rm as removePath,
10+
} from 'node:fs/promises';
211
import { createRequire } from 'node:module';
312
import { dirname, join, resolve, relative } from 'node:path';
413
import { tmpdir } from 'node:os';
@@ -103,9 +112,25 @@ export async function stageApplication({
103112
if (!names.includes('onnxruntime-node')) names.push('onnxruntime-node');
104113
if (test && !names.includes('pdf-lib')) names.push('pdf-lib');
105114
const fallback = !hasNativeInspector(platform, arch);
106-
if (fallback && !names.includes('pdfjs-dist')) names.push('pdfjs-dist');
107-
if (fallback && !names.includes('@thednp/dommatrix')) names.push('@thednp/dommatrix');
115+
// PDF.js is the whole extractor on fallback platforms and recovers OCR text layers
116+
// that the native inspector drops on scanned pages elsewhere.
117+
if (!names.includes('pdfjs-dist')) names.push('pdfjs-dist');
118+
if (!names.includes('@thednp/dommatrix')) names.push('@thednp/dommatrix');
108119
const copied = await copyDependencies(root, stage, names, fallback);
120+
const pdfjsRoot = join(stage, 'node_modules/pdfjs-dist');
121+
for (const entry of await readdir(pdfjsRoot))
122+
if (
123+
!['package.json', 'LICENSE', 'legacy', 'cmaps', 'standard_fonts', 'wasm', 'iccs'].includes(
124+
entry,
125+
)
126+
)
127+
await removePath(join(pdfjsRoot, entry), { recursive: true, force: true });
128+
for (const entry of await readdir(join(pdfjsRoot, 'legacy')))
129+
if (entry !== 'build')
130+
await removePath(join(pdfjsRoot, 'legacy', entry), { recursive: true, force: true });
131+
for (const entry of await readdir(join(pdfjsRoot, 'legacy/build')))
132+
if (!['pdf.mjs', 'pdf.worker.mjs'].includes(entry))
133+
await removePath(join(pdfjsRoot, 'legacy/build', entry), { force: true });
109134
// Ship only the target CPU runtime; other architectures multiply bundle size.
110135
const onnxBin = join(stage, 'node_modules/onnxruntime-node/bin/napi-v6');
111136
const { rm: removeRuntime } = await import('node:fs/promises');

‎server/documents/layout.mjs‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,20 @@ export function paragraphs(items, height) {
3939
});
4040
}
4141
lines.sort((a, b) => a.top - b.top || a.x - b.x);
42-
const bodySize =
43-
runs.map((r) => r.fontSize || r.height).sort((a, b) => a - b)[Math.floor(runs.length / 2)] ||
44-
12;
42+
// Weight by characters so many short runs (footnotes, superscripts) cannot outvote the body.
43+
const sizes = runs
44+
.map((r) => [r.fontSize || r.height, r.text.trim().length])
45+
.sort((a, b) => a[0] - b[0]);
46+
let bodySize = 12;
47+
for (
48+
let i = 0, seen = 0, half = sizes.reduce((sum, [, n]) => sum + n, 0) / 2;
49+
i < sizes.length;
50+
i++
51+
)
52+
if ((seen += sizes[i][1]) >= half) {
53+
bodySize = sizes[i][0];
54+
break;
55+
}
4556
// Learn normal leading from nearby lines of the same size, independently of columns.
4657
const gaps = new Map();
4758
for (let i = 0; i < lines.length; i++) {

‎server/documents/pdf-extractor.mjs‎

Lines changed: 86 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export function hasNativeInspector(platform = process.platform, arch = process.a
1010
: platform === 'linux' && ['x64', 'arm64'].includes(arch);
1111
}
1212

13-
let native, pdfjs;
13+
let native, pdfjs, recoveryWarned;
1414
export async function extractPdfJsPositions(bytes, pages) {
1515
pdfjs ??= (async () => {
1616
if (!globalThis.DOMMatrix) globalThis.DOMMatrix = (await import('@thednp/dommatrix')).default;
@@ -66,8 +66,90 @@ export async function extractPdfJsPositions(bytes, pages) {
6666
}
6767
}
6868

69-
export async function extractTextWithPositionsAsync(...args) {
70-
if (!hasNativeInspector()) return extractPdfJsPositions(...args);
69+
const textLength = (items) =>
70+
items.reduce(
71+
(sum, item) => sum + (item.itemType === 'Image' ? 0 : item.text?.trim().length || 0),
72+
0,
73+
);
74+
75+
// OCR words jitter in baseline, size and spacing. Merge each baseline into runs and
76+
// snap near-body sizes to the page's body size so layout grouping sees whole lines.
77+
export function normalizeOcrItems(items) {
78+
const groups = [];
79+
for (const item of [...items].sort((a, b) => b.y - a.y)) {
80+
const group = groups.at(-1);
81+
if (group && Math.abs(group[0].y - item.y) < Math.min(group[0].fontSize, item.fontSize) * 0.3)
82+
group.push(item);
83+
else groups.push([item]);
84+
}
85+
const runs = groups.flatMap((group) => {
86+
const size = group.map((item) => item.fontSize).sort((a, b) => a - b)[group.length >> 1],
87+
merged = [];
88+
for (const item of group.sort((a, b) => a.x - b.x)) {
89+
const run = merged.at(-1);
90+
if (run && item.x - (run.x + run.width) < size * 1.6) {
91+
run.text += ' ' + item.text;
92+
run.width = Math.max(run.width, item.x + item.width - run.x);
93+
run.isBold ||= item.isBold;
94+
} else merged.push({ ...item, y: group[0].y, fontSize: size, height: size });
95+
}
96+
return merged;
97+
});
98+
const sizes = runs
99+
.flatMap((run) => Array(run.text.length).fill(run.fontSize))
100+
.sort((a, b) => a - b),
101+
body = sizes[sizes.length >> 1];
102+
for (const run of runs)
103+
if (run.fontSize >= body * 0.8 && run.fontSize <= body * 1.3) run.fontSize = run.height = body;
104+
return runs;
105+
}
106+
107+
// The native inspector drops invisible OCR text layers on scanned pages, leaving only
108+
// the page image plus any real overlay text (e.g. a JSTOR footer). Re-read those pages
109+
// with pdf.js and keep its text when it finds substantially more.
110+
export async function recoverScannedPages(bytes, items, pdfJs = extractPdfJsPositions) {
111+
const byPage = new Map();
112+
for (const item of items) {
113+
if (!byPage.has(item.page)) byPage.set(item.page, []);
114+
byPage.get(item.page).push(item);
115+
}
116+
const candidates = [...byPage]
117+
.filter(([, pageItems]) => pageItems.some((item) => item.itemType === 'Image'))
118+
.map(([page]) => page);
119+
if (!candidates.length) return items;
120+
const recovered = new Map();
121+
for (const item of await pdfJs(bytes, candidates)) {
122+
if (!recovered.has(item.page)) recovered.set(item.page, []);
123+
recovered.get(item.page).push(item);
124+
}
125+
for (const [page, pageItems] of recovered) recovered.set(page, normalizeOcrItems(pageItems));
126+
const replaced = new Set(
127+
candidates.filter((page) => {
128+
const before = textLength(byPage.get(page)),
129+
after = textLength(recovered.get(page) || []);
130+
return after >= 200 && after > before * 2;
131+
}),
132+
);
133+
if (!replaced.size) return items;
134+
const result = [];
135+
for (const [page, pageItems] of byPage)
136+
result.push(
137+
...(replaced.has(page)
138+
? [...pageItems.filter((item) => item.itemType === 'Image'), ...recovered.get(page)]
139+
: pageItems),
140+
);
141+
return result;
142+
}
143+
144+
export async function extractTextWithPositionsAsync(bytes, ...args) {
145+
if (!hasNativeInspector()) return extractPdfJsPositions(bytes, ...args);
71146
native ??= import('@firecrawl/pdf-inspector');
72-
return (await native).extractTextWithPositionsAsync(...args);
147+
const items = await (await native).extractTextWithPositionsAsync(bytes, ...args);
148+
try {
149+
return await recoverScannedPages(bytes, items);
150+
} catch (error) {
151+
if (!recoveryWarned) console.warn('Scanned-page text recovery unavailable:', error?.message);
152+
recoveryWarned = true;
153+
return items;
154+
}
73155
}

‎server/http/document-routes.mjs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
44
import { dirname, join } from 'node:path';
55
import { paragraphs } from '../documents/layout.mjs';
66

7-
const LAYOUT_CACHE_SCHEMA = 2;
7+
const LAYOUT_CACHE_SCHEMA = 4;
88
const DOCUMENT_HASH_PATTERN = /^[0-9a-f]{64}$/;
99
const CACHE_GENERATION_PATTERN = /^[a-z0-9-]+$/i;
1010

‎tests/server/documents/pdf-extractor.test.mjs‎

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import { PDFDocument, StandardFonts, degrees } from 'pdf-lib';
44
import {
55
hasNativeInspector,
66
extractPdfJsPositions,
7+
recoverScannedPages,
78
} from '../../../server/documents/pdf-extractor.mjs';
9+
import { paragraphs } from '../../../server/documents/layout.mjs';
810

911
test('portable extraction preserves selected pages, display-frame positions and font sizes', async () => {
1012
const pdf = await PDFDocument.create(),
@@ -28,3 +30,82 @@ test('portable extraction preserves selected pages, display-frame positions and
2830
assert.ok(Math.abs(rotated[0].rotation) === 90);
2931
await assert.rejects(extractPdfJsPositions(bytes, [3]));
3032
});
33+
34+
test('scanned pages recover the hidden OCR layer the native inspector drops', async () => {
35+
const image = (page) => ({
36+
page,
37+
text: '[Image]',
38+
itemType: 'Image',
39+
x: 0,
40+
y: 0,
41+
width: 400,
42+
height: 600,
43+
}),
44+
footer = (page) => ({
45+
page,
46+
text: 'All use subject to terms',
47+
itemType: 'Text',
48+
x: 100,
49+
y: 10,
50+
width: 100,
51+
height: 6,
52+
fontSize: 6,
53+
}),
54+
native = [
55+
image(1),
56+
footer(1),
57+
{
58+
page: 2,
59+
text: 'Born digital page',
60+
itemType: 'Text',
61+
x: 40,
62+
y: 500,
63+
width: 120,
64+
height: 10,
65+
fontSize: 10,
66+
},
67+
],
68+
word = (text, x, y, fontSize = 10) => ({
69+
page: 1,
70+
text,
71+
itemType: 'Text',
72+
x,
73+
y,
74+
width: text.length * 5,
75+
height: fontSize,
76+
fontSize,
77+
isBold: false,
78+
rotation: 0,
79+
});
80+
const ocr = [];
81+
// Words jitter in baseline and size and sit just over a 1.2-em gap apart.
82+
for (let line = 0; line < 8; line++)
83+
for (let column = 0; column < 4; column++)
84+
ocr.push(
85+
word(
86+
'sociology',
87+
60 + column * 57.5,
88+
500 - line * 12 + (column % 2) * 0.4,
89+
column === 1 ? 8 : 10,
90+
),
91+
);
92+
ocr.push(footer(1));
93+
const requested = [];
94+
const items = await recoverScannedPages(new Uint8Array(), native, async (_bytes, pages) => {
95+
requested.push(...pages);
96+
return ocr;
97+
});
98+
assert.deepEqual(requested, [1]);
99+
assert.equal(items.filter((item) => item.page === 2).length, 1);
100+
assert.ok(items.some((item) => item.page === 1 && item.itemType === 'Image'));
101+
const result = paragraphs(
102+
items.filter((item) => item.page === 1),
103+
600,
104+
);
105+
assert.equal(result[0].text, Array(32).fill('sociology').join(' '));
106+
assert.equal(result.at(-1).text, 'All use subject to terms');
107+
assert.equal(
108+
await recoverScannedPages(new Uint8Array(), native, async () => [footer(1)]),
109+
native,
110+
);
111+
});

‎tests/server/persistent-layout-cache.test.mjs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ async function layout(origin, documentId, height = 792) {
8282
});
8383
}
8484

85-
async function cachePath(directory, height = 792, schema = 2) {
85+
async function cachePath(directory, height = 792, schema = 4) {
8686
const documentHash = createHash('sha256').update(PDF).digest('hex');
8787
const generation = (
8888
await readFile(join(directory, 'documents', `${documentHash}.generation`), 'utf8')

‎tests/server/production-stage.test.mjs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ test('production bundle serves and extracts PDFs without an external Express ins
1616
await access(join(stage, 'server/kernels/python', name));
1717
await access(join(stage, 'server/platform/macos/local-translation.swift'));
1818
await access(join(stage, 'server/formula/formula-ocr-worker.mjs'));
19+
// Scanned pages recover their OCR text layer through PDF.js on every platform.
20+
await access(join(stage, 'node_modules/pdfjs-dist/legacy/build/pdf.worker.mjs'));
21+
await access(join(stage, 'node_modules/pdfjs-dist/standard_fonts'));
1922
await access(
2023
join(stage, 'node_modules/onnxruntime-node/bin/napi-v6', process.platform, process.arch),
2124
);

0 commit comments

Comments
 (0)