From 087e345223570cd69dd90e800f4e70f00be36b0f Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Wed, 11 Mar 2026 13:37:26 +1100 Subject: [PATCH] fix: replace Math.max/min spreads with iterative helpers to avoid stack overflow Math.max(...iterable) and Math.min(...iterable) spread into function arguments, which overflows the call stack on large collections (~200k+). PDFs with many xref/object-stream entries hit this during PDF.load(). Adds min/max helpers in src/helpers/math.ts that iterate without spreading, and uses them in all affected call sites. Closes #48 --- src/api/pdf.ts | 5 ++- src/document/object-registry.test.ts | 14 ++++++ src/document/object-registry.ts | 5 +-- src/fonts/font-embedder.ts | 3 +- src/helpers/math.test.ts | 65 ++++++++++++++++++++++++++++ src/helpers/math.ts | 37 ++++++++++++++++ src/text/types.ts | 10 +++-- 7 files changed, 129 insertions(+), 10 deletions(-) create mode 100644 src/helpers/math.test.ts create mode 100644 src/helpers/math.ts diff --git a/src/api/pdf.ts b/src/api/pdf.ts index db8f12a..c1ef393 100644 --- a/src/api/pdf.ts +++ b/src/api/pdf.ts @@ -31,6 +31,7 @@ import { import { serializeOperators } from "#src/drawing/serialize"; import type { EmbeddedFont, EmbedFontOptions } from "#src/fonts/embedded-font"; import { formatPdfDate, parsePdfDate } from "#src/helpers/format"; +import { min } from "#src/helpers/math"; import { resolvePageSize } from "#src/helpers/page-size"; import { checkIncrementalSaveBlocker, type IncrementalSaveBlocker } from "#src/helpers/save-utils"; import { isJpeg, parseJpegHeader } from "#src/images/jpeg"; @@ -385,7 +386,7 @@ export class PDF { try { // Find the first object (lowest object number) - const firstObjNum = Math.min(...parsed.xref.keys()); + const firstObjNum = min(parsed.xref.keys(), 0); if (firstObjNum > 0) { const firstObj = parsed.getObject(PdfRef.of(firstObjNum, 0)); @@ -485,7 +486,7 @@ export class PDF { let isLinearized = false; try { - const firstObjNum = Math.min(...parsed.xref.keys()); + const firstObjNum = min(parsed.xref.keys(), 0); if (firstObjNum > 0) { const firstObj = parsed.getObject(PdfRef.of(firstObjNum, 0)); diff --git a/src/document/object-registry.test.ts b/src/document/object-registry.test.ts index bb58e0f..bd205b6 100644 --- a/src/document/object-registry.test.ts +++ b/src/document/object-registry.test.ts @@ -26,6 +26,20 @@ describe("ObjectRegistry", () => { expect(registry.nextObjectNumber).toBe(6); // max(1,5,3) + 1 }); + + it("handles very large xref maps without stack overflow", () => { + // Math.max(...keys) blows the call stack around ~200k args. + // Build a map with 250k entries to verify the iterative approach. + const xref = new Map(); + + for (let i = 0; i < 250_000; i++) { + xref.set(i, { type: "uncompressed", offset: i * 100, generation: 0 }); + } + + const registry = new ObjectRegistry(xref); + + expect(registry.nextObjectNumber).toBe(250_000); // max key is 249999 + }); }); describe("loaded objects", () => { diff --git a/src/document/object-registry.ts b/src/document/object-registry.ts index 688dec4..fd6ca2b 100644 --- a/src/document/object-registry.ts +++ b/src/document/object-registry.ts @@ -5,6 +5,7 @@ * tracks new objects, and assigns object numbers. */ +import { max } from "#src/helpers/math"; import type { RefResolver } from "#src/helpers/types"; import type { PdfObject } from "#src/objects/pdf-object"; import { PdfRef } from "#src/objects/pdf-ref"; @@ -47,9 +48,7 @@ export class ObjectRegistry { */ constructor(xref?: Map) { if (xref && xref.size > 0) { - // Find max object number from xref - const maxObjNum = Math.max(...xref.keys()); - this._nextObjNum = maxObjNum + 1; + this._nextObjNum = max(xref.keys()) + 1; } else { // Start from 1 (0 is reserved for free list head) this._nextObjNum = 1; diff --git a/src/fonts/font-embedder.ts b/src/fonts/font-embedder.ts index 2e8ad8a..1c636c0 100644 --- a/src/fonts/font-embedder.ts +++ b/src/fonts/font-embedder.ts @@ -11,6 +11,7 @@ import { CFFSubsetter } from "#src/fontbox/cff/subsetter.ts"; import { TTFSubsetter } from "#src/fontbox/ttf/subsetter.ts"; +import { max } from "#src/helpers/math"; import { PdfArray } from "#src/objects/pdf-array.ts"; import { PdfDict } from "#src/objects/pdf-dict.ts"; import { PdfName } from "#src/objects/pdf-name.ts"; @@ -546,7 +547,7 @@ function buildFullToUnicodeCMap(program: FontProgram): Uint8Array { */ function buildCidToGidMapStream(oldToNewGidMap: Map): PdfStream { // Find the maximum old GID (CID) we need to map - const maxOldGid = Math.max(...oldToNewGidMap.keys()); + const maxOldGid = max(oldToNewGidMap.keys()); // Create array of 2-byte entries (one per CID from 0 to maxOldGid) const data = new Uint8Array((maxOldGid + 1) * 2); diff --git a/src/helpers/math.test.ts b/src/helpers/math.test.ts new file mode 100644 index 0000000..031ca4a --- /dev/null +++ b/src/helpers/math.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; + +import { max, min } from "./math"; + +describe("max", () => { + it("returns the largest value", () => { + expect(max([3, 1, 4, 1, 5, 9, 2, 6])).toBe(9); + }); + + it("works with negative values", () => { + expect(max([-10, -3, -7])).toBe(-3); + }); + + it("returns fallback for empty iterable", () => { + expect(max([])).toBe(0); + expect(max([], -1)).toBe(-1); + }); + + it("works with Map keys", () => { + const map = new Map([ + [5, "a"], + [2, "b"], + [8, "c"], + ]); + + expect(max(map.keys())).toBe(8); + }); + + it("handles 250k elements without stack overflow", () => { + const arr = Array.from({ length: 250_000 }, (_, i) => i); + + expect(max(arr)).toBe(249_999); + }); +}); + +describe("min", () => { + it("returns the smallest value", () => { + expect(min([3, 1, 4, 1, 5, 9, 2, 6])).toBe(1); + }); + + it("works with negative values", () => { + expect(min([-10, -3, -7])).toBe(-10); + }); + + it("returns fallback for empty iterable", () => { + expect(min([])).toBe(0); + expect(min([], 999)).toBe(999); + }); + + it("works with Map keys", () => { + const map = new Map([ + [5, "a"], + [2, "b"], + [8, "c"], + ]); + + expect(min(map.keys())).toBe(2); + }); + + it("handles 250k elements without stack overflow", () => { + const arr = Array.from({ length: 250_000 }, (_, i) => i); + + expect(min(arr)).toBe(0); + }); +}); diff --git a/src/helpers/math.ts b/src/helpers/math.ts new file mode 100644 index 0000000..cb9670b --- /dev/null +++ b/src/helpers/math.ts @@ -0,0 +1,37 @@ +/** + * Math utilities that operate on iterables. + * + * Unlike `Math.max(...arr)` / `Math.min(...arr)`, these don't spread into + * function arguments and therefore can't overflow the call stack on large + * collections. + */ + +/** + * Return the largest value in an iterable, or `fallback` if empty. + */ +export function max(values: Iterable, fallback = 0): number { + let result = Number.NEGATIVE_INFINITY; + + for (const v of values) { + if (v > result) { + result = v; + } + } + + return result === Number.NEGATIVE_INFINITY ? fallback : result; +} + +/** + * Return the smallest value in an iterable, or `fallback` if empty. + */ +export function min(values: Iterable, fallback = 0): number { + let result = Number.POSITIVE_INFINITY; + + for (const v of values) { + if (v < result) { + result = v; + } + } + + return result === Number.POSITIVE_INFINITY ? fallback : result; +} diff --git a/src/text/types.ts b/src/text/types.ts index a505f38..e9825ef 100644 --- a/src/text/types.ts +++ b/src/text/types.ts @@ -5,6 +5,8 @@ * including position information for searching and highlighting. */ +import { max, min } from "#src/helpers/math"; + /** * Rectangle in PDF coordinates (origin at bottom-left). */ @@ -128,10 +130,10 @@ export function mergeBboxes(boxes: BoundingBox[]): BoundingBox { return { x: 0, y: 0, width: 0, height: 0 }; } - const minX = Math.min(...boxes.map(b => b.x)); - const minY = Math.min(...boxes.map(b => b.y)); - const maxX = Math.max(...boxes.map(b => b.x + b.width)); - const maxY = Math.max(...boxes.map(b => b.y + b.height)); + const minX = min(boxes.map(b => b.x)); + const minY = min(boxes.map(b => b.y)); + const maxX = max(boxes.map(b => b.x + b.width)); + const maxY = max(boxes.map(b => b.y + b.height)); return { x: minX,