mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
refactor(documents): read the text layer through the one shared loader
The text read and the rasterizer each opened pdf.js on their own: the text read with bare options, the rasterizer through src/lib/documents/pdfjs.ts with the standard font, CMap and wasm directories. The loader now serves both, so a CID font with a predefined CMap is mapped to characters on the text path too, and there is one set of options to keep in step. The loader is typed with pdf.js' own declarations instead of a hand-written shape. That drops isEvalSupported, which pdf.js 6 no longer has and which did nothing. Only the render slice the DOM-typed declarations cannot describe (the native canvas factory) keeps a named local type in the rasterizer. The run joiner with its named steps moves into the loader as joinTextRuns, and readPdfText is its one page walk; the second layout copy that mimicked the previous library's tabs and blank lines is gone. extractPdfText keeps its outcomes and page cap on top of it, and a failed read still says what pdf.js threw (documents.local_extract.failed). A test draws a word in two fonts into a real PDF and reads it back whole. Refs #1277
This commit is contained in:
1 parent
d3621617c6
commit
540c6bfaeb
4 files changed
+224
-334
No files matched your search
@@ -1,80 +1,89 @@
|
||||
import { jsPDF } from "jspdf";
|
||||
import type {
|
||||
TextItem,
|
||||
TextMarkedContent,
|
||||
} from "pdfjs-dist/types/src/display/api";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { pageTextLayer, readPdfText, type PdfTextItem } from "../pdfjs";
|
||||
import { joinTextRuns, readPdfText } from "../pdfjs";
|
||||
|
||||
/**
|
||||
* The text layer as the content index reads it. The layout is the one the
|
||||
* local text read produced before it moved onto the app's own pdf.js (#1277):
|
||||
* a break where pdf.js marks one or the next item sits on a lower line, a tab
|
||||
* between far-apart items on one line, each page followed by a blank line.
|
||||
* The text layer as the content index reads it (#1277): runs in order, a line
|
||||
* break where a run ends its line or the next one sits on another, a space
|
||||
* where two runs on one line leave a gap, and nothing where they touch, so a
|
||||
* word drawn in two fonts stays one word and a search for it still finds it.
|
||||
*/
|
||||
|
||||
/** A page whose viewport maps PDF space to itself, flipped like pdf.js. */
|
||||
function pageOf(items: PdfTextItem[]) {
|
||||
return {
|
||||
getViewport: () => ({
|
||||
width: 600,
|
||||
height: 800,
|
||||
convertToViewportPoint: (x: number, y: number) => [x, 800 - y],
|
||||
}),
|
||||
getTextContent: async () => ({ items }),
|
||||
};
|
||||
}
|
||||
|
||||
function item(
|
||||
/** A 10 pt run starting at (x, y), 5 pt per character. */
|
||||
function run(
|
||||
str: string,
|
||||
x: number,
|
||||
y: number,
|
||||
extra: Partial<PdfTextItem> = {},
|
||||
): PdfTextItem {
|
||||
extra: Partial<TextItem> = {},
|
||||
): TextItem {
|
||||
return {
|
||||
str,
|
||||
dir: "ltr",
|
||||
transform: [10, 0, 0, 10, x, y],
|
||||
width: str.length * 5,
|
||||
height: 10,
|
||||
fontName: "g_d0_f1",
|
||||
hasEOL: false,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
describe("pageTextLayer", () => {
|
||||
it("breaks once where pdf.js marks the end of a line", async () => {
|
||||
const text = await pageTextLayer(
|
||||
pageOf([
|
||||
item("Haemoglobin", 10, 700, { hasEOL: true }),
|
||||
item("14.2 g/dL", 10, 688),
|
||||
describe("joinTextRuns", () => {
|
||||
it("keeps a word drawn in two fonts whole", () => {
|
||||
expect(
|
||||
joinTextRuns([
|
||||
run("Haem", 10, 700),
|
||||
run("oglobin", 30, 700, { fontName: "g_d0_f2" }),
|
||||
]),
|
||||
);
|
||||
expect(text).toBe("Haemoglobin\n14.2 g/dL");
|
||||
).toBe("Haemoglobin");
|
||||
});
|
||||
|
||||
it("breaks when the next item sits a line lower without a marked end", async () => {
|
||||
const text = await pageTextLayer(
|
||||
pageOf([item("Creatinine", 10, 700), item("0.9 mg/dL", 10, 680)]),
|
||||
it("puts a space between runs on one line that leave a gap", () => {
|
||||
expect(joinTextRuns([run("Glucose", 10, 700), run("92", 200, 700)])).toBe(
|
||||
"Glucose 92",
|
||||
);
|
||||
expect(text).toBe("Creatinine\n0.9 mg/dL");
|
||||
});
|
||||
|
||||
it("does not break for a small baseline shift on one line", async () => {
|
||||
const text = await pageTextLayer(
|
||||
pageOf([item("10", 10, 700), item("3", 21, 703)]),
|
||||
);
|
||||
expect(text).toBe("103");
|
||||
it("breaks once where a run ends its line", () => {
|
||||
expect(
|
||||
joinTextRuns([
|
||||
run("Haemoglobin", 10, 700, { hasEOL: true }),
|
||||
run("14.2 g/dL", 10, 688),
|
||||
]),
|
||||
).toBe("Haemoglobin\n14.2 g/dL");
|
||||
});
|
||||
|
||||
it("separates far-apart items on one line with a tab", async () => {
|
||||
const text = await pageTextLayer(
|
||||
pageOf([item("Glucose", 10, 700), item("92", 200, 700)]),
|
||||
);
|
||||
expect(text).toBe("Glucose\t92");
|
||||
it("breaks where the next run sits on another line without a marked end", () => {
|
||||
expect(
|
||||
joinTextRuns([run("Creatinine", 10, 700), run("0.9", 10, 680)]),
|
||||
).toBe("Creatinine\n0.9");
|
||||
});
|
||||
|
||||
it("skips marked-content items, which carry no text", async () => {
|
||||
const text = await pageTextLayer(
|
||||
pageOf([{ hasEOL: false } as PdfTextItem, item("Text", 10, 700)]),
|
||||
);
|
||||
expect(text).toBe("Text");
|
||||
it("keeps a raised run on its line", () => {
|
||||
expect(joinTextRuns([run("10", 10, 700), run("9", 20, 703)])).toBe("109");
|
||||
});
|
||||
|
||||
it("adds no blank line for an empty run that only ends a line", () => {
|
||||
expect(
|
||||
joinTextRuns([
|
||||
run("Line", 10, 700, { hasEOL: true }),
|
||||
run("", 30, 700, { hasEOL: true }),
|
||||
run("Next", 10, 688),
|
||||
]),
|
||||
).toBe("Line\nNext");
|
||||
});
|
||||
|
||||
it("skips marked-content markers, which carry no text", () => {
|
||||
const marker: TextMarkedContent = {
|
||||
type: "beginMarkedContent",
|
||||
id: "mc0",
|
||||
};
|
||||
expect(joinTextRuns([marker, run("Text", 10, 700)])).toBe("Text");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -89,10 +98,10 @@ describe("readPdfText", () => {
|
||||
return Buffer.from(doc.output("arraybuffer"));
|
||||
}
|
||||
|
||||
it("joins pages with a blank line and adds no page markers", async () => {
|
||||
it("reads the pages one after another and adds no page markers", async () => {
|
||||
const text = await readPdfText(pages(2));
|
||||
expect(text).toBe(
|
||||
"Page 1 lab report\nsecond line\n\nPage 2 lab report\nsecond line\n\n",
|
||||
"Page 1 lab report\nsecond line\nPage 2 lab report\nsecond line",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -102,6 +111,17 @@ describe("readPdfText", () => {
|
||||
expect(text).not.toContain("Page 3");
|
||||
});
|
||||
|
||||
it("keeps a word whole that a real PDF draws in two fonts", async () => {
|
||||
const doc = new jsPDF();
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.text("Haem", 10, 10);
|
||||
const joint = 10 + doc.getTextWidth("Haem");
|
||||
doc.setFont("times", "normal");
|
||||
doc.text("oglobin 14.2 g/dL", joint, 10);
|
||||
const text = await readPdfText(Buffer.from(doc.output("arraybuffer")));
|
||||
expect(text).toBe("Haemoglobin 14.2 g/dL");
|
||||
});
|
||||
|
||||
it("throws what pdf.js throws on a file that is not a PDF", async () => {
|
||||
await expect(readPdfText(Buffer.from("not a pdf"))).rejects.toThrow();
|
||||
});
|
||||
|
||||
@@ -7,13 +7,14 @@
|
||||
* never the native `@napi-rs/canvas`), in milliseconds, with no OCR and no
|
||||
* third-party egress.
|
||||
*
|
||||
* It reads through the app's own `pdfjs-dist`, the copy `./rasterize-pdf`
|
||||
* renders with. It used to go through `pdf-parse`, which brings a second
|
||||
* pdfjs of its own, and the two cannot share a process: both take their
|
||||
* worker from `globalThis.pdfjsWorker`, so whichever ran first broke the
|
||||
* other ("The API version "6.4.299" does not match the Worker version
|
||||
* "5.4.296""). Thumbnails and vision reads failed after a text extraction,
|
||||
* and the text extraction failed after a raster.
|
||||
* It reads through `./pdfjs`, the one loader `./rasterize-pdf` opens documents
|
||||
* with too, so both run the app's own `pdfjs-dist` with the same options. It
|
||||
* used to go through `pdf-parse`, which brings a second pdfjs of its own, and
|
||||
* the two cannot share a process: both take their worker from
|
||||
* `globalThis.pdfjsWorker`, so whichever ran first broke the other ("The API
|
||||
* version "6.4.299" does not match the Worker version "5.4.296""). Thumbnails
|
||||
* and vision reads failed after a text extraction, and the text extraction
|
||||
* failed after a raster.
|
||||
* The extracted text feeds the SAME blind, encrypted content index the provider
|
||||
* path writes (`upsertContentIndex`), so nothing readable is stored at rest.
|
||||
*
|
||||
@@ -28,92 +29,10 @@
|
||||
*/
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
import type { PDFDocumentLoadingTask } from "pdfjs-dist";
|
||||
import type {
|
||||
TextItem,
|
||||
TextMarkedContent,
|
||||
} from "pdfjs-dist/types/src/display/api";
|
||||
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { logCaught } from "@/lib/logging/signal";
|
||||
|
||||
// pdfjs' module top-level references the browser-only `DOMMatrix` global. A
|
||||
// STATIC import makes the Turbopack server chunk evaluate that reference at
|
||||
// instantiation and throw `DOMMatrix is not defined`, taking down every
|
||||
// route/worker that shares the chunk (the document routes among them). The
|
||||
// module is therefore loaded lazily inside `extractPdfText` via a runtime
|
||||
// `import()`. The type imports above are erased at build, so they load
|
||||
// nothing; they let the compiler check this file against pdfjs' own API.
|
||||
|
||||
/**
|
||||
* Where a text run sits on the page. pdfjs gives each run a text matrix
|
||||
* `[a, b, c, d, x, y]`: `x` and `y` are where the run starts, and `d` is its
|
||||
* vertical scale, which for upright text is the font size.
|
||||
*/
|
||||
interface RunPosition {
|
||||
x: number;
|
||||
/** The baseline the run sits on. */
|
||||
y: number;
|
||||
/** Where the run ends: its start plus its width. */
|
||||
end: number;
|
||||
fontSize: number;
|
||||
}
|
||||
|
||||
function positionOf(run: TextItem): RunPosition {
|
||||
const [, , , scaleY, x = 0, y = 0] = run.transform as number[];
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
end: x + run.width,
|
||||
fontSize: Math.abs(scaleY ?? 0) || run.height || 1,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The next run is on a new line when its baseline moved by more than half a
|
||||
* font size. Runs on one line can sit a little higher or lower (a superscript,
|
||||
* a mixed font), so a small shift does not count.
|
||||
*/
|
||||
function startsNewLine(previous: RunPosition, next: RunPosition): boolean {
|
||||
return Math.abs(next.y - previous.y) > previous.fontSize / 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Two runs on one line are separate words when the gap between them is wider
|
||||
* than a tenth of the font size. Runs that touch, such as a word drawn in two
|
||||
* fonts, stay one word.
|
||||
*/
|
||||
function leavesWordGap(previous: RunPosition, next: RunPosition): boolean {
|
||||
return next.x - previous.end > next.fontSize / 10;
|
||||
}
|
||||
|
||||
/**
|
||||
* One page's text: its runs in order, with a line break where a run ends a
|
||||
* line or the next one starts a new line, and a space where two runs on one
|
||||
* line leave a gap between words.
|
||||
*/
|
||||
function pageText(items: Array<TextItem | TextMarkedContent>): string {
|
||||
let text = "";
|
||||
let previous: RunPosition | null = null;
|
||||
|
||||
for (const item of items) {
|
||||
// Marked-content markers carry no text.
|
||||
if (!("str" in item)) continue;
|
||||
const position = positionOf(item);
|
||||
|
||||
if (previous && !text.endsWith("\n")) {
|
||||
if (startsNewLine(previous, position)) {
|
||||
text += "\n";
|
||||
} else if (leavesWordGap(previous, position) && !/\s$/.test(text)) {
|
||||
text += " ";
|
||||
}
|
||||
}
|
||||
text += item.str;
|
||||
if (item.hasEOL && !text.endsWith("\n")) text += "\n";
|
||||
|
||||
previous = position;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
import { readPdfText } from "./pdfjs";
|
||||
|
||||
/**
|
||||
* Minimum joined text length for a PDF to count as a real text-layer document.
|
||||
@@ -145,49 +64,32 @@ export type LocalExtractResult =
|
||||
* Extract a text-layer PDF's embedded text server-side. Returns `empty` for a
|
||||
* scanned/no-text-layer PDF and `error` for a malformed/encrypted one — never
|
||||
* throws, so a bad document can never abort the caller (the upload/backfill job
|
||||
* just leaves it un-indexed).
|
||||
* just leaves it un-indexed). An `error` leaves a `warn` line naming what
|
||||
* pdf.js threw, because the outcome alone does not say why a document stayed
|
||||
* out of the index.
|
||||
*/
|
||||
export async function extractPdfText(
|
||||
buffer: Buffer,
|
||||
): Promise<LocalExtractResult> {
|
||||
let task: PDFDocumentLoadingTask | null = null;
|
||||
let text: string;
|
||||
try {
|
||||
// Lazy load: keeps pdfjs/DOMMatrix out of the eager server chunk (see the
|
||||
// import-site note). Resolved at first PDF extraction, then module-cached.
|
||||
const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
|
||||
task = pdfjs.getDocument({
|
||||
data: new Uint8Array(buffer),
|
||||
verbosity: 0, // VerbosityLevel.ERRORS — no console spam on odd PDFs.
|
||||
useSystemFonts: false,
|
||||
});
|
||||
const doc = await task.promise;
|
||||
// Bound the page walk so a pathological many-page PDF costs no more.
|
||||
const pageCount = Math.min(doc.numPages, LOCAL_MAX_PAGES);
|
||||
const pages: string[] = [];
|
||||
for (let n = 1; n <= pageCount; n++) {
|
||||
const page = await doc.getPage(n);
|
||||
try {
|
||||
pages.push(pageText((await page.getTextContent()).items));
|
||||
} finally {
|
||||
page.cleanup();
|
||||
}
|
||||
}
|
||||
const text = pages.join("\n").trim();
|
||||
if (text.length < LOCAL_TEXT_MIN_CHARS) {
|
||||
return { ok: false, reason: "empty" };
|
||||
}
|
||||
return { ok: true, text, source: "local-pdf" };
|
||||
} catch {
|
||||
text = (await readPdfText(buffer, LOCAL_MAX_PAGES)).trim();
|
||||
} catch (err) {
|
||||
const errorType = err instanceof Error ? err.name || "Error" : typeof err;
|
||||
annotate({
|
||||
action: { name: "documents.localIndex.extractFailed" },
|
||||
meta: { localExtractError: errorType },
|
||||
});
|
||||
logCaught("documents.local_extract.failed", err, {
|
||||
bytes: buffer.length,
|
||||
});
|
||||
return { ok: false, reason: "error" };
|
||||
} finally {
|
||||
if (task) {
|
||||
try {
|
||||
await task.destroy();
|
||||
} catch {
|
||||
// Best-effort teardown; a destroy failure must not mask the result.
|
||||
}
|
||||
}
|
||||
}
|
||||
if (text.length < LOCAL_TEXT_MIN_CHARS) {
|
||||
return { ok: false, reason: "empty" };
|
||||
}
|
||||
return { ok: true, text, source: "local-pdf" };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+90
-142
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* The one pdf.js this server runs, and the one way it is opened.
|
||||
* The one pdf.js this server runs, the one way it is opened, and the one way
|
||||
* its text layer is read.
|
||||
*
|
||||
* Under Node, pdf.js does not start a worker thread: it loads its worker code
|
||||
* into the same process and keeps it on `globalThis.pdfjsWorker`. Whichever
|
||||
@@ -15,7 +16,9 @@
|
||||
* reads the browser-only `DOMMatrix` global, so a static import would make the
|
||||
* server chunk evaluate it at instantiation and throw `DOMMatrix is not
|
||||
* defined`, taking down every route and worker sharing the chunk. The import
|
||||
* resolves once and is module-cached after that.
|
||||
* resolves once and is module-cached after that. The type imports below are
|
||||
* erased at build, so they load nothing; they let the compiler check every
|
||||
* call here against pdf.js' own API.
|
||||
*
|
||||
* This module has no logging of its own, so the end-to-end suite can read
|
||||
* report text with it; the callers say what failed.
|
||||
@@ -25,74 +28,11 @@ import { existsSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
// Structural types for the slice of the pdf.js API used here and in the
|
||||
// rasterizer. Type-only, so nothing evaluates pdf.js for its types.
|
||||
export interface PdfViewport {
|
||||
width: number;
|
||||
height: number;
|
||||
convertToViewportPoint(x: number, y: number): number[];
|
||||
}
|
||||
export interface PdfCanvas {
|
||||
width: number;
|
||||
height: number;
|
||||
toBuffer(mime: "image/jpeg", quality?: number): Buffer;
|
||||
}
|
||||
export interface PdfContext2D {
|
||||
getImageData(
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
): { data: Uint8ClampedArray };
|
||||
}
|
||||
interface PdfCanvasAndContext {
|
||||
canvas: PdfCanvas;
|
||||
context: PdfContext2D;
|
||||
}
|
||||
interface PdfCanvasFactory {
|
||||
create(width: number, height: number): PdfCanvasAndContext;
|
||||
destroy?(target: PdfCanvasAndContext): void;
|
||||
}
|
||||
/** A text-layer item; marked-content items carry no `str` and are skipped. */
|
||||
export interface PdfTextItem {
|
||||
str?: string;
|
||||
hasEOL?: boolean;
|
||||
transform?: number[];
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
export interface PdfPage {
|
||||
getViewport(opts: { scale: number }): PdfViewport;
|
||||
render(opts: {
|
||||
canvasContext: unknown;
|
||||
viewport: PdfViewport;
|
||||
canvas: unknown;
|
||||
}): { promise: Promise<void> };
|
||||
cleanup(): void;
|
||||
getTextContent(): Promise<{ items: PdfTextItem[] }>;
|
||||
}
|
||||
export interface PdfDocument {
|
||||
numPages: number;
|
||||
canvasFactory: PdfCanvasFactory;
|
||||
getPage(pageNumber: number): Promise<PdfPage>;
|
||||
}
|
||||
export interface PdfLoadingTask {
|
||||
promise: Promise<PdfDocument>;
|
||||
destroy(): Promise<void>;
|
||||
}
|
||||
interface PdfDocumentOptions {
|
||||
data: Uint8Array;
|
||||
verbosity?: number;
|
||||
isEvalSupported?: boolean;
|
||||
useSystemFonts?: boolean;
|
||||
standardFontDataUrl?: string;
|
||||
cMapUrl?: string;
|
||||
cMapPacked?: boolean;
|
||||
wasmUrl?: string;
|
||||
}
|
||||
interface PdfjsModule {
|
||||
getDocument(opts: PdfDocumentOptions): PdfLoadingTask;
|
||||
}
|
||||
import type { PDFDocumentLoadingTask } from "pdfjs-dist";
|
||||
import type {
|
||||
TextItem,
|
||||
TextMarkedContent,
|
||||
} from "pdfjs-dist/types/src/display/api";
|
||||
|
||||
/**
|
||||
* Where pdf.js finds the data it does not carry in its JS: the standard 14
|
||||
@@ -169,15 +109,13 @@ export function __resetPdfjsAssetsForTests(): void {
|
||||
*/
|
||||
export async function openPdfDocument(
|
||||
data: Uint8Array | Buffer,
|
||||
): Promise<PdfLoadingTask> {
|
||||
const pdfjs =
|
||||
(await import("pdfjs-dist/legacy/build/pdf.mjs")) as unknown as PdfjsModule;
|
||||
): Promise<PDFDocumentLoadingTask> {
|
||||
const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
|
||||
const assets = resolvePdfjsAssets();
|
||||
return pdfjs.getDocument({
|
||||
// A copy: pdf.js transfers the buffer it is given to its worker.
|
||||
data: new Uint8Array(data),
|
||||
verbosity: 0, // VerbosityLevel.ERRORS: no console noise on odd PDFs.
|
||||
isEvalSupported: false, // never eval font programs (defence in depth).
|
||||
// There are no system fonts to use: substitutes come from pdf.js' own
|
||||
// standard font data (and, when rendering, the registered fallbacks).
|
||||
useSystemFonts: false,
|
||||
@@ -192,73 +130,83 @@ export async function openPdfDocument(
|
||||
});
|
||||
}
|
||||
|
||||
/** Vertical gap (pt) past which the next item starts a new line. */
|
||||
const LINE_THRESHOLD = 4.6;
|
||||
/** Horizontal gap (pt) on one line past which the next item is a new cell. */
|
||||
const CELL_THRESHOLD = 7;
|
||||
|
||||
/**
|
||||
* One page's text layer as plain text: items in content order, a line break
|
||||
* where pdf.js marks one or where the next item sits on a lower line, and a
|
||||
* tab between items on one line that are far apart (table cells).
|
||||
*
|
||||
* This is the layout `pdf-parse` produced with its defaults, kept so that
|
||||
* swapping it out did not change what the content index sees.
|
||||
* Where a text run sits on the page. pdfjs gives each run a text matrix
|
||||
* `[a, b, c, d, x, y]`: `x` and `y` are where the run starts, and `d` is its
|
||||
* vertical scale, which for upright text is the font size.
|
||||
*/
|
||||
export async function pageTextLayer(
|
||||
page: Pick<PdfPage, "getViewport" | "getTextContent">,
|
||||
): Promise<string> {
|
||||
const viewport = page.getViewport({ scale: 1 });
|
||||
const { items } = await page.getTextContent();
|
||||
const parts: string[] = [];
|
||||
let lastX: number | undefined;
|
||||
let lastY: number | undefined;
|
||||
let lineHeight = 0;
|
||||
for (const item of items) {
|
||||
if (typeof item.str !== "string") continue;
|
||||
const transform = item.transform ?? [1, 0, 0, 1, 0, 0];
|
||||
const [x = 0, y = 0] = viewport.convertToViewportPoint(
|
||||
transform[4] ?? 0,
|
||||
transform[5] ?? 0,
|
||||
);
|
||||
let str = item.str;
|
||||
if (lastY !== undefined) {
|
||||
const gap = Math.abs(lastY - y);
|
||||
if (gap > LINE_THRESHOLD) {
|
||||
const previous = parts.at(-1);
|
||||
const opensLine =
|
||||
str.startsWith("\n") || (str.trim() === "" && item.hasEOL === true);
|
||||
if (
|
||||
previous !== undefined &&
|
||||
!previous.endsWith("\n") &&
|
||||
!opensLine &&
|
||||
gap - 1 > lineHeight
|
||||
) {
|
||||
parts.push("\n");
|
||||
lineHeight = 0;
|
||||
}
|
||||
} else if (
|
||||
gap < LINE_THRESHOLD &&
|
||||
lastX !== undefined &&
|
||||
Math.abs(lastX - x) > CELL_THRESHOLD
|
||||
) {
|
||||
str = `\t${str}`;
|
||||
}
|
||||
}
|
||||
parts.push(str);
|
||||
lastX = x + (item.width ?? 0);
|
||||
lastY = y;
|
||||
lineHeight = Math.max(lineHeight, item.height ?? 0);
|
||||
if (item.hasEOL) parts.push("\n");
|
||||
if (item.hasEOL || str.endsWith("\n")) lineHeight = 0;
|
||||
}
|
||||
return parts.join("");
|
||||
interface RunPosition {
|
||||
x: number;
|
||||
/** The baseline the run sits on. */
|
||||
y: number;
|
||||
/** Where the run ends: its start plus its width. */
|
||||
end: number;
|
||||
fontSize: number;
|
||||
}
|
||||
|
||||
function positionOf(run: TextItem): RunPosition {
|
||||
const [, , , scaleY, x = 0, y = 0] = run.transform as number[];
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
end: x + run.width,
|
||||
fontSize: Math.abs(scaleY ?? 0) || run.height || 1,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The text layer of the first `maxPages` pages, each page followed by a blank
|
||||
* line. Throws what pdf.js throws (a malformed file, a password, a page that
|
||||
* will not parse); the document is torn down either way.
|
||||
* The next run is on a new line when its baseline moved by more than half a
|
||||
* font size. Runs on one line can sit a little higher or lower (a superscript,
|
||||
* a mixed font), so a small shift does not count.
|
||||
*/
|
||||
function startsNewLine(previous: RunPosition, next: RunPosition): boolean {
|
||||
return Math.abs(next.y - previous.y) > previous.fontSize / 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Two runs on one line are separate words when the gap between them is wider
|
||||
* than a tenth of the font size. Runs that touch, such as a word drawn in two
|
||||
* fonts, stay one word.
|
||||
*/
|
||||
function leavesWordGap(previous: RunPosition, next: RunPosition): boolean {
|
||||
return next.x - previous.end > next.fontSize / 10;
|
||||
}
|
||||
|
||||
/**
|
||||
* One page's text: its runs in order, with a line break where a run ends a
|
||||
* line or the next one starts a new line, and a space where two runs on one
|
||||
* line leave a gap between words.
|
||||
*/
|
||||
export function joinTextRuns(
|
||||
items: Array<TextItem | TextMarkedContent>,
|
||||
): string {
|
||||
let text = "";
|
||||
let previous: RunPosition | null = null;
|
||||
|
||||
for (const item of items) {
|
||||
// Marked-content markers carry no text.
|
||||
if (!("str" in item)) continue;
|
||||
const position = positionOf(item);
|
||||
|
||||
if (previous && !text.endsWith("\n")) {
|
||||
if (startsNewLine(previous, position)) {
|
||||
text += "\n";
|
||||
} else if (leavesWordGap(previous, position) && !/\s$/.test(text)) {
|
||||
text += " ";
|
||||
}
|
||||
}
|
||||
text += item.str;
|
||||
if (item.hasEOL && !text.endsWith("\n")) text += "\n";
|
||||
|
||||
previous = position;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* The text layer of the first `maxPages` pages, one page after another.
|
||||
* Throws what pdf.js throws (a malformed file, a password, a page that will
|
||||
* not parse); the document is torn down either way.
|
||||
*/
|
||||
export async function readPdfText(
|
||||
data: Uint8Array | Buffer,
|
||||
@@ -267,17 +215,17 @@ export async function readPdfText(
|
||||
const task = await openPdfDocument(data);
|
||||
try {
|
||||
const doc = await task.promise;
|
||||
const last = Math.min(doc.numPages, maxPages);
|
||||
let text = "";
|
||||
for (let pageNumber = 1; pageNumber <= last; pageNumber++) {
|
||||
const page = await doc.getPage(pageNumber);
|
||||
const pageCount = Math.min(doc.numPages, maxPages);
|
||||
const pages: string[] = [];
|
||||
for (let n = 1; n <= pageCount; n++) {
|
||||
const page = await doc.getPage(n);
|
||||
try {
|
||||
text += `${await pageTextLayer(page)}\n\n`;
|
||||
pages.push(joinTextRuns((await page.getTextContent()).items));
|
||||
} finally {
|
||||
page.cleanup();
|
||||
}
|
||||
}
|
||||
return text;
|
||||
return pages.join("\n");
|
||||
} finally {
|
||||
try {
|
||||
await task.destroy();
|
||||
|
||||
@@ -33,15 +33,7 @@ import { Buffer } from "node:buffer";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
import { nativeCanvasSupported } from "./native-canvas-support";
|
||||
import {
|
||||
openPdfDocument,
|
||||
resolvePdfjsAssets,
|
||||
type PdfContext2D,
|
||||
type PdfDocument,
|
||||
type PdfjsAssets,
|
||||
type PdfLoadingTask,
|
||||
type PdfPage,
|
||||
} from "./pdfjs";
|
||||
import { openPdfDocument, resolvePdfjsAssets, type PdfjsAssets } from "./pdfjs";
|
||||
|
||||
/**
|
||||
* Whether the PDF rasterizer is available in this build. The native
|
||||
@@ -114,6 +106,33 @@ export type RasterResult =
|
||||
}
|
||||
| { ok: false; reason: "unsupported" | "render-failed" };
|
||||
|
||||
/**
|
||||
* The canvas pdf.js draws into under Node is `@napi-rs/canvas`. pdf.js' own
|
||||
* declarations are written for the DOM and type the document's canvas factory
|
||||
* as a bare `object`, so the slice used here is named: a DOM-shaped canvas
|
||||
* and 2D context, plus the JPEG encoder the native canvas adds.
|
||||
*/
|
||||
interface RenderTarget {
|
||||
canvas: HTMLCanvasElement & {
|
||||
toBuffer(mime: "image/jpeg", quality?: number): Buffer;
|
||||
};
|
||||
context: CanvasRenderingContext2D;
|
||||
}
|
||||
interface RenderTargetFactory {
|
||||
create(width: number, height: number): RenderTarget;
|
||||
destroy?(target: RenderTarget): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A page's text layer, as far as `pageText` reads it: text runs, and
|
||||
* marked-content markers that carry only a `type` and no text.
|
||||
*/
|
||||
interface TextLayerPage {
|
||||
getTextContent(): Promise<{
|
||||
items: Array<{ str?: string; hasEOL?: boolean; type?: string }>;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface GlobalFontsLike {
|
||||
registerFromPath(path: string, nameAlias?: string): unknown;
|
||||
}
|
||||
@@ -144,7 +163,11 @@ async function registerFallbackFamilies(assets: PdfjsAssets): Promise<void> {
|
||||
}
|
||||
|
||||
/** Share of dark pixels on a rendered page (luminance below half). */
|
||||
function inkRatio(context: PdfContext2D, width: number, height: number) {
|
||||
function inkRatio(
|
||||
context: Pick<CanvasRenderingContext2D, "getImageData">,
|
||||
width: number,
|
||||
height: number,
|
||||
) {
|
||||
const { data } = context.getImageData(0, 0, width, height);
|
||||
let dark = 0;
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
@@ -188,9 +211,7 @@ function headThatFits(text: string, room: number): string {
|
||||
* `RASTER_PAGE_TEXT_MAX` it is cut (at a line, else a space, else hard) and
|
||||
* marked.
|
||||
*/
|
||||
export async function pageText(
|
||||
page: Pick<PdfPage, "getTextContent">,
|
||||
): Promise<string> {
|
||||
export async function pageText(page: TextLayerPage): Promise<string> {
|
||||
const max = RASTER_PAGE_TEXT_MAX;
|
||||
try {
|
||||
const { items } = await page.getTextContent();
|
||||
@@ -248,8 +269,7 @@ export async function rasterizePdf(
|
||||
maxPages: number = RASTER_MAX_PAGES,
|
||||
opts: { withText?: boolean } = {},
|
||||
): Promise<RasterResult> {
|
||||
let doc: PdfDocument | null = null;
|
||||
let task: PdfLoadingTask | null = null;
|
||||
let task: Awaited<ReturnType<typeof openPdfDocument>> | null = null;
|
||||
// CPU-feature gate: pdfjs renders through @napi-rs/canvas (Skia), whose x64
|
||||
// build uses AVX2 — an unsupported CPU dies with an uncatchable SIGILL on
|
||||
// the first render. Degrade to the existing no-raster path instead (scanned
|
||||
@@ -273,7 +293,7 @@ export async function rasterizePdf(
|
||||
}
|
||||
|
||||
task = await openPdfDocument(buffer);
|
||||
doc = await task.promise;
|
||||
const doc = await task.promise;
|
||||
|
||||
// Never exceed the standing DoS ceiling, even if a caller asks for more.
|
||||
const cap = Math.min(Math.max(1, maxPages), RASTER_HARD_MAX_PAGES);
|
||||
@@ -292,7 +312,7 @@ export async function rasterizePdf(
|
||||
longEdge > 0 ? Math.min(1, RASTER_TARGET_LONG_EDGE / longEdge) : 1;
|
||||
const viewport = page.getViewport({ scale });
|
||||
|
||||
const factory = doc.canvasFactory;
|
||||
const factory = doc.canvasFactory as RenderTargetFactory;
|
||||
const cc = factory.create(
|
||||
Math.ceil(viewport.width),
|
||||
Math.ceil(viewport.height),
|
||||
|
||||
Reference in new issue
Block a user