mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
pdf.js decodes an image at the size the file declares, whatever size the page is drawn at, and the server opened documents without an image limit. A small upload declaring a very large image made the web process allocate gigabytes until the container was killed, for every user at once, and the thumbnail job ran the same document again after each restart. An image whose soft mask declares the size does the same past pdf.js' own limit, and nothing bounded how long a text read could take. pdf.js is now opened with maxImageSize (20 million pixels, which keeps one image inside the reader's ceiling and admits a 300 dpi A3 scan), and every read and render runs in a child process, one at a time: its V8 heap is capped, its resident memory is sampled and the child killed past 640 MB, on Linux it volunteers to the out-of-memory killer first, a time limit ends a read that runs on, and it starts with an almost empty environment. The loader and the rendering moved into pdf-engine.mjs, which the child runs with plain Node and which is traced into the standalone image. A document that crosses a bound answers resource-limit. The thumbnail job records it as a final failure, so the backfill does not queue it again.
510 lines
18 KiB
JavaScript
510 lines
18 KiB
JavaScript
// @ts-check
|
|
/**
|
|
* The one pdf.js this server runs, the one way it is opened, and everything
|
|
* done with an open document: reading its text layer and rendering pages.
|
|
*
|
|
* The server never runs this module in its own process. A PDF is untrusted
|
|
* input whose cost is set by whoever wrote it: an image a few hundred bytes
|
|
* long can declare twenty thousand pixels square and make pdf.js allocate
|
|
* gigabytes while decoding it, and a content stream can keep the parser busy
|
|
* for minutes. Both used to happen inside the web process, where they took
|
|
* every user's requests down with them. Every caller now goes through
|
|
* `./pdf-isolation.ts`, which runs this module in a child process
|
|
* (`./pdf-child.mjs`) under a memory ceiling and a time limit; a document that
|
|
* crosses either one ends the child, not the server. The suites still import
|
|
* it directly to test the reading and rendering themselves.
|
|
*
|
|
* It is plain JavaScript because the child runs it with plain Node, from the
|
|
* checkout and from the standalone image alike; `@ts-check` keeps the
|
|
* compiler on it all the same. It imports nothing from the app, and it logs
|
|
* nothing: the caller in the server says what happened.
|
|
*
|
|
* 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
|
|
* copy of pdf.js opens a document first owns that global until the process
|
|
* exits, and any other copy then refuses every document with "The API version
|
|
* … does not match the Worker version …" (#1277). This is the only module
|
|
* that loads pdf.js; `src/__tests__/single-pdfjs-guard.test.ts` keeps a
|
|
* second copy out.
|
|
*
|
|
* pdf.js is loaded lazily with a runtime `import()`: its module top level
|
|
* reads the browser-only `DOMMatrix` global, and a static import in a module
|
|
* the server bundles would evaluate it at instantiation.
|
|
*/
|
|
import { Buffer } from "node:buffer";
|
|
import { existsSync } from "node:fs";
|
|
import { createRequire } from "node:module";
|
|
import { dirname, join } from "node:path";
|
|
|
|
/** @typedef {import("pdfjs-dist").PDFDocumentLoadingTask} PDFDocumentLoadingTask */
|
|
/** @typedef {import("pdfjs-dist/types/src/display/api").TextItem} TextItem */
|
|
/** @typedef {import("pdfjs-dist/types/src/display/api").TextMarkedContent} TextMarkedContent */
|
|
|
|
/**
|
|
* The largest image, in pixels, pdf.js decodes; a larger one is left out of
|
|
* the page and the rest renders. pdf.js decodes an image at the size the file
|
|
* declares, whatever size the page is drawn at, so without this a 400 KB file
|
|
* declaring a 20000 x 20000 image allocated about 7 GB. Decoding costs about
|
|
* 21 bytes per pixel on top of the reader's own 120 MB, so this keeps one
|
|
* image inside the child's memory ceiling (`PDF_CHILD_MEMORY_MB` in
|
|
* `./pdf-isolation.ts`). A 300 dpi A3 scan is about 17 million pixels.
|
|
*/
|
|
export const PDF_MAX_IMAGE_PIXELS = 20_000_000;
|
|
|
|
/**
|
|
* Where pdf.js finds the data it does not carry in its JS: the standard 14
|
|
* fonts (a PDF that names Helvetica or Times without embedding them), the
|
|
* CMaps (CID fonts with a predefined encoding), and the wasm image decoders
|
|
* (JPEG 2000, JBIG2). Under Node they are read from disk, and without a
|
|
* directory pdf.js draws the text without glyphs and cannot map a
|
|
* predefined-CMap font to characters. The runtime image carries no system
|
|
* fonts at all, so nothing else catches that.
|
|
*
|
|
* Resolved from the working directory with Node's own resolution, which is
|
|
* what finds the hoisted `node_modules/pdfjs-dist` in the standalone image
|
|
* and the pnpm link in a checkout. The ICC profile option is not set: its
|
|
* loader needs a synchronous XMLHttpRequest that Node lacks, and pdf.js falls
|
|
* back to each colour space's alternate.
|
|
*
|
|
* @typedef {object} PdfjsAssets
|
|
* @property {string} standardFontDataUrl
|
|
* @property {string} cMapUrl
|
|
* @property {string} wasmUrl
|
|
* @property {string[]} fallbackFonts The Liberation Sans faces registered as fallback families.
|
|
*/
|
|
|
|
/** @type {PdfjsAssets | null | undefined} */
|
|
let cachedAssets;
|
|
|
|
/** @returns {PdfjsAssets | null} */
|
|
export function resolvePdfjsAssets() {
|
|
if (cachedAssets !== undefined) return cachedAssets;
|
|
cachedAssets = null;
|
|
try {
|
|
const anchor = createRequire(join(process.cwd(), "package.json"));
|
|
const root = dirname(
|
|
anchor.resolve(["pdfjs-dist", "package.json"].join("/")),
|
|
);
|
|
const fonts = join(root, "standard_fonts");
|
|
/** @type {PdfjsAssets} */
|
|
const assets = {
|
|
standardFontDataUrl: `${fonts}/`,
|
|
cMapUrl: `${join(root, "cmaps")}/`,
|
|
wasmUrl: `${join(root, "wasm")}/`,
|
|
fallbackFonts: [
|
|
"LiberationSans-Regular.ttf",
|
|
"LiberationSans-Bold.ttf",
|
|
"LiberationSans-Italic.ttf",
|
|
"LiberationSans-BoldItalic.ttf",
|
|
].map((file) => join(fonts, file)),
|
|
};
|
|
if (
|
|
existsSync(join(fonts, "FoxitSerif.pfb")) &&
|
|
existsSync(join(root, "cmaps", "UniJIS-UTF16-H.bcmap")) &&
|
|
existsSync(join(root, "wasm", "openjpeg.wasm")) &&
|
|
assets.fallbackFonts.every((file) => existsSync(file))
|
|
) {
|
|
cachedAssets = assets;
|
|
}
|
|
} catch {
|
|
// Unresolvable: open without the data; the caller says so.
|
|
}
|
|
return cachedAssets;
|
|
}
|
|
|
|
/** Test hook: the asset lookup is per process. */
|
|
export function __resetPdfjsAssetsForTests() {
|
|
cachedAssets = undefined;
|
|
}
|
|
|
|
/**
|
|
* Start loading a PDF. The caller awaits `task.promise` and always awaits
|
|
* `task.destroy()` afterwards, which also tears the document down.
|
|
*
|
|
* Of pdf.js' resource options only `maxImageSize` acts under Node. The canvas
|
|
* area limit (`canvasMaxAreaInBytes`) only steers its OffscreenCanvas image
|
|
* resizer, which Node does not have, and pdf.js 6 has no eval path left for
|
|
* `isEvalSupported` to switch off. `disableFontFace` is already Node's
|
|
* default and is stated so a later default cannot quietly change it.
|
|
*
|
|
* @param {Uint8Array} data
|
|
* @returns {Promise<PDFDocumentLoadingTask>}
|
|
*/
|
|
export async function openPdfDocument(data) {
|
|
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.
|
|
// There are no system fonts to use: substitutes come from pdf.js' own
|
|
// standard font data (and, when rendering, the registered fallbacks).
|
|
useSystemFonts: false,
|
|
disableFontFace: true,
|
|
maxImageSize: PDF_MAX_IMAGE_PIXELS,
|
|
...(assets
|
|
? {
|
|
standardFontDataUrl: assets.standardFontDataUrl,
|
|
cMapUrl: assets.cMapUrl,
|
|
cMapPacked: true,
|
|
wasmUrl: assets.wasmUrl,
|
|
}
|
|
: {}),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* @typedef {object} RunPosition
|
|
* @property {number} x
|
|
* @property {number} y The baseline the run sits on.
|
|
* @property {number} end Where the run ends: its start plus its width.
|
|
* @property {number} fontSize
|
|
*/
|
|
|
|
/**
|
|
* @param {TextItem} run
|
|
* @returns {RunPosition}
|
|
*/
|
|
function positionOf(run) {
|
|
const [, , , scaleY, x = 0, y = 0] = /** @type {number[]} */ (run.transform);
|
|
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.
|
|
*
|
|
* @param {RunPosition} previous
|
|
* @param {RunPosition} next
|
|
*/
|
|
function startsNewLine(previous, next) {
|
|
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.
|
|
*
|
|
* @param {RunPosition} previous
|
|
* @param {RunPosition} next
|
|
*/
|
|
function leavesWordGap(previous, next) {
|
|
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.
|
|
*
|
|
* @param {Array<TextItem | TextMarkedContent>} items
|
|
* @returns {string}
|
|
*/
|
|
export function joinTextRuns(items) {
|
|
let text = "";
|
|
/** @type {RunPosition | null} */
|
|
let previous = 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.
|
|
*
|
|
* @param {Uint8Array} data
|
|
* @param {number} [maxPages]
|
|
* @returns {Promise<string>}
|
|
*/
|
|
export async function readTextLayer(data, maxPages = Number.POSITIVE_INFINITY) {
|
|
const task = await openPdfDocument(data);
|
|
try {
|
|
const doc = await task.promise;
|
|
const pageCount = Math.min(doc.numPages, maxPages);
|
|
/** @type {string[]} */
|
|
const pages = [];
|
|
for (let n = 1; n <= pageCount; n++) {
|
|
const page = await doc.getPage(n);
|
|
try {
|
|
pages.push(joinTextRuns((await page.getTextContent()).items));
|
|
} finally {
|
|
page.cleanup();
|
|
}
|
|
}
|
|
return pages.join("\n");
|
|
} finally {
|
|
try {
|
|
await task.destroy();
|
|
} catch {
|
|
// Best-effort teardown; a destroy failure must not mask the result.
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The most text kept from one page's text layer when pages are rendered.
|
|
* Collection stops here while the items are read, so a PDF whose content
|
|
* streams inflate to megabytes of text cannot hold them in memory for the
|
|
* length of a run. A dense lab page is well below it.
|
|
*/
|
|
export const RASTER_PAGE_TEXT_MAX = 12_000;
|
|
|
|
/** Marks where a text layer was cut. */
|
|
export const TEXT_LAYER_CUT_MARK = "[text layer cut here]";
|
|
|
|
/**
|
|
* The head of `text` that fits `room` characters: cut at the last space before
|
|
* the limit so no number is cut in half, or hard at the limit when the text
|
|
* has no space to cut at.
|
|
*
|
|
* @param {string} text
|
|
* @param {number} room
|
|
*/
|
|
function headThatFits(text, room) {
|
|
if (text.length <= room) return text;
|
|
const head = text.slice(0, room);
|
|
const space = head.lastIndexOf(" ");
|
|
return space > 0 ? head.slice(0, space) : head;
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* @typedef {object} TextLayerPage
|
|
* @property {() => Promise<{ items: Array<{ str?: string; hasEOL?: boolean; type?: string }> }>} getTextContent
|
|
*/
|
|
|
|
/**
|
|
* A page's text layer, line by line: what the PDF states as characters.
|
|
* Empty for a page without one or on any failure. Past
|
|
* `RASTER_PAGE_TEXT_MAX` it is cut (at a line, else a space, else hard) and
|
|
* marked.
|
|
*
|
|
* @param {TextLayerPage} page
|
|
* @returns {Promise<string>}
|
|
*/
|
|
export async function pageText(page) {
|
|
const max = RASTER_PAGE_TEXT_MAX;
|
|
try {
|
|
const { items } = await page.getTextContent();
|
|
/** @type {string[]} */
|
|
const lines = [];
|
|
let length = 0;
|
|
let line = "";
|
|
// Cut at the last whole line; only a page whose first line alone runs
|
|
// past the cap keeps part of that line, cut at a space or hard.
|
|
const cut = () => {
|
|
if (lines.length === 0) {
|
|
const room = max - TEXT_LAYER_CUT_MARK.length - 1;
|
|
const head = headThatFits(line.replace(/\s+/g, " ").trim(), room);
|
|
if (head.trim()) lines.push(head.trim());
|
|
}
|
|
lines.push(TEXT_LAYER_CUT_MARK);
|
|
return lines.join("\n");
|
|
};
|
|
/** Adds the line in hand; false when it does not fit. */
|
|
const flush = () => {
|
|
const clean = line.replace(/\s+/g, " ").trim();
|
|
if (!clean) {
|
|
line = "";
|
|
return true;
|
|
}
|
|
if (length + clean.length + 1 > max - TEXT_LAYER_CUT_MARK.length - 1) {
|
|
return false;
|
|
}
|
|
lines.push(clean);
|
|
length += clean.length + 1;
|
|
line = "";
|
|
return true;
|
|
};
|
|
for (const item of items) {
|
|
line += `${item.str ?? ""} `;
|
|
// A line that runs on without a break is judged at the cap, so one
|
|
// endless line cannot grow past it either.
|
|
if ((item.hasEOL || line.length > max) && !flush()) return cut();
|
|
}
|
|
if (!flush()) return cut();
|
|
return lines.join("\n");
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
let fallbackFamiliesRegistered = false;
|
|
|
|
/**
|
|
* Give the canvas a face for the generic families pdfjs falls back to.
|
|
*
|
|
* When pdfjs cannot use a font program (a damaged or unsupported embedded
|
|
* font, or a non-embedded face outside the standard 14) it draws the text
|
|
* with the canvas' own `fillText` under the PDF's font name and a generic
|
|
* family. With no system fonts in the image that text vanished. Registering
|
|
* Liberation Sans, which pdfjs ships, under `sans-serif`, `serif` and
|
|
* `monospace` makes it legible instead. Once per process.
|
|
*
|
|
* @param {PdfjsAssets} assets
|
|
*/
|
|
async function registerFallbackFamilies(assets) {
|
|
if (fallbackFamiliesRegistered) return;
|
|
fallbackFamiliesRegistered = true;
|
|
const { GlobalFonts } = await import("@napi-rs/canvas");
|
|
for (const family of ["sans-serif", "serif", "monospace"]) {
|
|
for (const file of assets.fallbackFonts) {
|
|
GlobalFonts.registerFromPath(file, family);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Test hook: the font registration is per process. */
|
|
export function __resetFallbackFamiliesForTests() {
|
|
fallbackFamiliesRegistered = false;
|
|
}
|
|
|
|
/**
|
|
* Share of dark pixels on a rendered page (luminance below half).
|
|
*
|
|
* @param {{ getImageData(x: number, y: number, w: number, h: number): { data: Uint8ClampedArray } }} context
|
|
* @param {number} width
|
|
* @param {number} height
|
|
*/
|
|
function inkRatio(context, width, height) {
|
|
const { data } = context.getImageData(0, 0, width, height);
|
|
let dark = 0;
|
|
for (let i = 0; i < data.length; i += 4) {
|
|
if (
|
|
/** @type {number} */ (data[i]) +
|
|
/** @type {number} */ (data[i + 1]) +
|
|
/** @type {number} */ (data[i + 2]) <
|
|
384
|
|
) {
|
|
dark++;
|
|
}
|
|
}
|
|
const pixels = data.length / 4;
|
|
return pixels > 0 ? dark / pixels : 0;
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* @typedef {object} RenderTarget
|
|
* @property {{ width: number; height: number; toBuffer(mime: "image/jpeg", quality?: number): Buffer }} canvas
|
|
* @property {{ getImageData(x: number, y: number, w: number, h: number): { data: Uint8ClampedArray } }} context
|
|
*
|
|
* @typedef {object} RenderTargetFactory
|
|
* @property {(width: number, height: number) => RenderTarget} create
|
|
* @property {(target: RenderTarget) => void} [destroy]
|
|
*/
|
|
|
|
/**
|
|
* @typedef {object} RenderOptions
|
|
* @property {number} maxPages Pages to render from the first.
|
|
* @property {number} targetLongEdge Longest edge of a rendered page, never upscaled.
|
|
* @property {number} jpegQuality
|
|
* @property {number} minInkRatio Below this share of dark pixels a page counts as blank.
|
|
* @property {boolean} [withText] Also read each rendered page's text layer.
|
|
*
|
|
* @typedef {object} RenderedPage
|
|
* @property {Buffer} jpeg
|
|
* @property {boolean} blank
|
|
* @property {string} [text]
|
|
*
|
|
* @typedef {object} RenderedDocument
|
|
* @property {number} pageCount Pages in the document, rendered or not.
|
|
* @property {RenderedPage[]} pages
|
|
* @property {boolean} assetsMissing Whether pdf.js ran without its font and decoder data.
|
|
*/
|
|
|
|
/**
|
|
* Render the first `maxPages` pages of a PDF to JPEG. Throws what pdf.js
|
|
* throws; the document is torn down either way.
|
|
*
|
|
* @param {Uint8Array} data
|
|
* @param {RenderOptions} options
|
|
* @returns {Promise<RenderedDocument>}
|
|
*/
|
|
export async function renderPdfPages(data, options) {
|
|
const assets = resolvePdfjsAssets();
|
|
if (assets) await registerFallbackFamilies(assets);
|
|
|
|
const task = await openPdfDocument(data);
|
|
try {
|
|
const doc = await task.promise;
|
|
const pageCount = Math.min(doc.numPages, Math.max(1, options.maxPages));
|
|
/** @type {RenderedPage[]} */
|
|
const pages = [];
|
|
for (let pageNumber = 1; pageNumber <= pageCount; pageNumber++) {
|
|
const page = await doc.getPage(pageNumber);
|
|
try {
|
|
const base = page.getViewport({ scale: 1 });
|
|
const longEdge = Math.max(base.width, base.height);
|
|
// Cap the long edge, never upscale (scale <= 1).
|
|
const scale =
|
|
longEdge > 0 ? Math.min(1, options.targetLongEdge / longEdge) : 1;
|
|
const viewport = page.getViewport({ scale });
|
|
|
|
const factory = /** @type {RenderTargetFactory} */ (doc.canvasFactory);
|
|
const target = factory.create(
|
|
Math.ceil(viewport.width),
|
|
Math.ceil(viewport.height),
|
|
);
|
|
await page.render({
|
|
canvasContext: /** @type {never} */ (target.context),
|
|
viewport,
|
|
canvas: /** @type {never} */ (target.canvas),
|
|
}).promise;
|
|
const blank =
|
|
inkRatio(target.context, target.canvas.width, target.canvas.height) <
|
|
options.minInkRatio;
|
|
const jpeg = target.canvas.toBuffer("image/jpeg", options.jpegQuality);
|
|
factory.destroy?.(target);
|
|
/** @type {RenderedPage} */
|
|
const rendered = { jpeg: Buffer.from(jpeg), blank };
|
|
if (options.withText) rendered.text = await pageText(page);
|
|
pages.push(rendered);
|
|
} finally {
|
|
page.cleanup();
|
|
}
|
|
}
|
|
return { pageCount: doc.numPages, pages, assetsMissing: !assets };
|
|
} finally {
|
|
try {
|
|
await task.destroy();
|
|
} catch {
|
|
// Best-effort teardown; a destroy failure must not mask the result.
|
|
}
|
|
}
|
|
}
|