openreader/compute/core/src/pdf-layout/parsePdf.ts
Richard R f8672b073e refactor(compute): unify job concurrency and CPU thread allocation logic
Standardize compute job concurrency configuration by introducing a shared
COMPUTE_JOB_CONCURRENCY setting, replacing separate PDF and Whisper concurrency
controls. Add cross-platform CPU core detection and thread budgeting utilities,
and update both ONNX model execution and concurrency limiters to use dynamic
thread allocation per job. Refactor environment variable docs and examples to
reflect unified concurrency management. Streamline PDF.js font path resolution
for improved reliability across environments.
2026-05-21 15:58:29 -06:00

186 lines
6.3 KiB
TypeScript

import path from 'path';
import { createRequire } from 'node:module';
import type { TextItem } from 'pdfjs-dist/types/src/display/api';
import type { ParsedPdfDocument, ParsedPdfPage } from '../types/parsed-pdf';
import type { PdfTextItem } from './types';
import { ensureModel } from './ensureModel';
import { runLayoutModel } from './runLayoutModel';
import { mergeTextWithRegions } from './mergeTextWithRegions';
import { stitchCrossPageBlocks } from './stitchCrossPageBlocks';
import { renderPage } from './renderPage';
interface ParsePdfInput {
documentId: string;
pdfBytes: ArrayBuffer;
onPageParsed?: (input: {
pageNumber: number;
totalPages: number;
pageMs: number;
}) => void | Promise<void>;
}
const LAYOUT_RENDER_SCALE = 1.5;
const require = createRequire(import.meta.url);
function resolvePdfjsStandardFontDataUrl(): string {
const pdfjsPackageJson = require.resolve('pdfjs-dist/package.json');
const standardFontDir = path.join(path.dirname(pdfjsPackageJson), 'standard_fonts');
return `${standardFontDir.replace(/\/?$/, '/')}`;
}
export function normalizeTextItemsForLayout(items: TextItem[], pageHeight: number): PdfTextItem[] {
return items
.filter((item) => {
if (!(typeof item.str === 'string' && item.str.trim().length > 0)) return false;
const transform = item.transform;
if (!Array.isArray(transform) || transform.length < 6) return false;
// Reject heavily skewed/rotated text runs (e.g. vertical margin labels
// such as arXiv metadata) so they do not get merged into body blocks.
const skewX = Number(transform[1] ?? 0);
const skewY = Number(transform[2] ?? 0);
if (Math.abs(skewX) > 0.5 || Math.abs(skewY) > 0.5) return false;
return true;
})
.map((item) => {
const x = Number(item.transform[4] ?? 0);
const width = Math.max(0, Number(item.width ?? 0));
const height = Math.max(1, Math.abs(Number(item.transform[3] ?? 1)));
const baselineY = Number(item.transform[5] ?? 0);
// pdf.js text transforms are in PDF user-space (origin bottom-left).
// Normalize into top-left page coordinates to match rendered image/model boxes.
const y = Math.max(0, pageHeight - baselineY - height);
return {
text: item.str,
x,
y,
width,
height,
};
});
}
export async function parsePdf(input: ParsePdfInput): Promise<ParsedPdfDocument> {
await ensureModel();
// Keep independent byte copies for text extraction and page rendering. pdf.js
// can detach buffers passed to getDocument().
const pdfBytesForText = new Uint8Array(input.pdfBytes).slice();
const pdfBytesForRender = new Uint8Array(input.pdfBytes).slice();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const pdfjs = await import('pdfjs-dist/legacy/build/pdf.mjs');
if (pdfjs.GlobalWorkerOptions) {
pdfjs.GlobalWorkerOptions.workerSrc = 'pdfjs-dist/legacy/build/pdf.worker.mjs';
pdfjs.GlobalWorkerOptions.workerPort = null;
}
const standardFontDataUrl = resolvePdfjsStandardFontDataUrl();
const loadingTask = pdfjs.getDocument({
data: pdfBytesForText,
useWorkerFetch: false,
standardFontDataUrl,
isEvalSupported: false,
});
const pdf = await loadingTask.promise;
try {
const pages: ParsedPdfPage[] = [];
let nextBlockId = 1;
let sawText = false;
for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) {
const pageStartedAt = Date.now();
const page = await pdf.getPage(pageNumber);
const viewport = page.getViewport({ scale: 1.0 });
const textContent = await page.getTextContent();
const textItems = normalizeTextItemsForLayout(
textContent.items.filter((item): item is TextItem => 'str' in item && 'transform' in item),
viewport.height,
);
if (textItems.length > 0) sawText = true;
const rendered = await renderPage({
pdfBytes: pdfBytesForRender.buffer.slice(
pdfBytesForRender.byteOffset,
pdfBytesForRender.byteOffset + pdfBytesForRender.byteLength,
),
pageNumber,
scale: LAYOUT_RENDER_SCALE,
});
const scaleX = rendered.width / Math.max(1, viewport.width);
const scaleY = rendered.height / Math.max(1, viewport.height);
const layoutTextItems = textItems.map((item) => ({
...item,
x: item.x * scaleX,
y: item.y * scaleY,
width: item.width * scaleX,
height: item.height * scaleY,
}));
const regions = await runLayoutModel({
pageWidth: rendered.width,
pageHeight: rendered.height,
textItems: layoutTextItems,
pageImage: rendered.image,
});
const merged = mergeTextWithRegions(regions, layoutTextItems);
if (textItems.length > 0 && merged.length === 0) {
throw new Error(`layout-merge-empty: page=${pageNumber} regions=${regions.length}`);
}
const blocks = merged
.map((entry, readingOrder) => ({
id: `b${String(nextBlockId++).padStart(4, '0')}`,
kind: entry.region.label,
fragments: [{
page: pageNumber,
bbox: [
entry.region.bbox[0] / scaleX,
entry.region.bbox[1] / scaleY,
entry.region.bbox[2] / scaleX,
entry.region.bbox[3] / scaleY,
] as [number, number, number, number],
text: entry.text,
readingOrder,
...(typeof entry.region.confidence === 'number' ? { modelConfidence: entry.region.confidence } : {}),
}],
text: entry.text,
}));
pages.push({
pageNumber,
width: viewport.width,
height: viewport.height,
blocks,
});
if (input.onPageParsed) {
await input.onPageParsed({
pageNumber,
totalPages: pdf.numPages,
pageMs: Date.now() - pageStartedAt,
});
}
}
if (!sawText) {
throw new Error('no-text-layer');
}
const doc: ParsedPdfDocument = {
schemaVersion: 1,
documentId: input.documentId,
parserVersion: 'pp-doclayoutv3-onnx@800+pdfjs@4.8.69',
parsedAt: Date.now(),
pages,
};
return stitchCrossPageBlocks(doc);
} finally {
await pdf.destroy().catch(() => undefined);
await loadingTask.destroy().catch(() => undefined);
}
}