Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
"no-useless-concat": "off",
"no-unused-vars": "off",
"prefer-const": "error",
"@typescript-eslint/prefer-const": "error",
"@typescript-eslint/no-extraneous-class": "off",
"@typescript-eslint/await-thenable": "error",
"@typescript-eslint/no-floating-promises": "error",
Expand Down
172 changes: 102 additions & 70 deletions bun.lock

Large diffs are not rendered by default.

30 changes: 15 additions & 15 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,30 +64,30 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@noble/ciphers": "^2.1.1",
"@noble/hashes": "^2.0.1",
"@scure/base": "^2.0.0",
"asn1js": "^3.0.7",
"lru-cache": "^11.2.6",
"@noble/ciphers": "^2.2.0",
"@noble/hashes": "^2.2.0",
"@scure/base": "^2.2.0",
"asn1js": "^3.0.10",
"lru-cache": "^11.3.6",
"pako": "^2.1.0",
"pkijs": "^3.3.3"
"pkijs": "^3.4.0"
},
"devDependencies": {
"@cantoo/pdf-lib": "^2.6.1",
"@google-cloud/kms": "^5.0.0",
"@google-cloud/secret-manager": "^6.0.0",
"@types/bun": "^1.3.5",
"@cantoo/pdf-lib": "^2.6.5",
"@google-cloud/kms": "^5.5.0",
"@google-cloud/secret-manager": "^6.1.2",
"@types/bun": "^1.3.14",
"@types/pako": "^2.0.4",
"@vitest/coverage-v8": "4.0.16",
"husky": "^9.1.7",
"lint-staged": "^16.2.7",
"lint-staged": "^16.4.0",
"oxfmt": "^0.24.0",
"oxlint": "^1.39.0",
"oxlint-tsgolint": "^0.11.1",
"oxlint": "^1.65.0",
"oxlint-tsgolint": "^0.11.5",
"pdf-lib": "^1.17.1",
"tsdown": "^0.18.4",
"typescript": "^5",
"vitest": "^4.0.16"
"typescript": "^5.9.3",
"vitest": "^4.1.6"
},
"peerDependencies": {
"@google-cloud/kms": "^5.0.0",
Expand Down
12 changes: 12 additions & 0 deletions src/objects/pdf-ref.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export class PdfRef implements PdfPrimitive {
* Get or create an interned PdfRef for the given object/generation pair.
*/
static of(objectNumber: number, generation: number = 0): PdfRef {
// Build the cache key without allocating a temporary PdfRef.
const key = `${objectNumber} ${generation}`;

let cached = PdfRef.cache.get(key);
Expand All @@ -46,6 +47,17 @@ export class PdfRef implements PdfPrimitive {
return cached;
}

/**
* Stable string key for this ref ("objNum gen").
*
* Useful as a Map/Set key when PdfRef identity isn't reliable — e.g. when
* instances may be evicted from the LRU cache, or when two parts of the
* code construct equivalent refs independently.
*/
key(): string {
return `${this.objectNumber} ${this.generation}`;
}

/**
* Clear the reference cache.
*
Expand Down
96 changes: 92 additions & 4 deletions src/writer/pdf-writer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,12 +260,100 @@ describe("writeComplete", () => {
const result = writeComplete(registry, { root: catalogRef });
const text = new TextDecoder().decode(result.bytes);

// Should NOT include orphan object
// Orphan dropped entirely, catalog kept
expect(text).not.toContain("/Type /Orphan");
expect(text).not.toContain("1 0 obj"); // Orphan was obj 1
// Should include catalog
expect(text).toContain("/Type /Catalog");
expect(text).toContain("2 0 obj"); // Catalog is obj 2
});

it("renumbers reachable objects densely starting at 1", () => {
const registry = new ObjectRegistry();

// Orphan takes the first slot so renumbering visibly shifts the catalog.
registry.register(PdfDict.of({ Type: PdfName.of("Orphan") }));

const catalog = PdfDict.of({ Type: PdfName.Catalog });
const catalogRef = registry.register(catalog);

const result = writeComplete(registry, { root: catalogRef });
const text = new TextDecoder().decode(result.bytes);

// Catalog (originally object 2) gets renumbered to 1 so the xref is dense
expect(text).toContain("1 0 obj");
expect(text).not.toContain("2 0 obj");

// Dense xref: free entry + one in-use entry, /Root points at the new slot
expect(text).toContain("/Size 2");
expect(text).toContain("/Root 1 0 R");
});

it("produces dense xref when only the tail of the registry is reachable", () => {
const registry = new ObjectRegistry();

// Several orphan objects come before the reachable catalog
registry.register(PdfDict.of({ Type: PdfName.of("Orphan1") }));
registry.register(PdfDict.of({ Type: PdfName.of("Orphan2") }));
registry.register(PdfDict.of({ Type: PdfName.of("Orphan3") }));

const catalog = PdfDict.of({ Type: PdfName.Catalog });
const catalogRef = registry.register(catalog);

const result = writeComplete(registry, { root: catalogRef });
const text = new TextDecoder().decode(result.bytes);

// Only the catalog survives, renumbered to object 1
expect(text).toContain("1 0 obj");
expect(text).not.toContain("2 0 obj");
expect(text).not.toContain("3 0 obj");
expect(text).not.toContain("4 0 obj");

// /Size lines up with the actual number of in-use entries
expect(text).toContain("/Size 2");
expect(text).toContain("/Root 1 0 R");

// xref has a single subsection covering 0..1, no gaps
expect(text).toMatch(/xref\n0 2\n/);
});

it("rewrites internal refs to use the new numbering", () => {
const registry = new ObjectRegistry();

// Orphan padding so renumbering actually shifts the catalog/child.
registry.register(PdfDict.of({ Type: PdfName.of("Orphan1") }));
registry.register(PdfDict.of({ Type: PdfName.of("Orphan2") }));

const child = PdfDict.of({ Type: PdfName.of("Child") });
const childRef = registry.register(child);

const catalog = PdfDict.of({ Type: PdfName.Catalog, Child: childRef });
const catalogRef = registry.register(catalog);

const result = writeComplete(registry, { root: catalogRef });
const text = new TextDecoder().decode(result.bytes);

// child becomes object 1, catalog becomes object 2 (registry order)
expect(text).toContain("/Child 1 0 R");
expect(text).toContain("/Root 2 0 R");
expect(text).toContain("/Size 3");

// Old numbers must not leak through (childRef was originally 3)
expect(text).not.toContain("/Child 3 0 R");
});

it("replaces dangling refs with null", () => {
const registry = new ObjectRegistry();

// Catalog points at a ref whose target was never registered.
const dangling = PdfRef.of(99, 0);
const catalog = PdfDict.of({ Type: PdfName.Catalog, Stale: dangling });
const catalogRef = registry.register(catalog);

const result = writeComplete(registry, { root: catalogRef });
const text = new TextDecoder().decode(result.bytes);

// The dangling ref is replaced with literal null in the output
expect(text).toContain("/Stale null");
expect(text).not.toContain("99 0 R");
expect(text).toContain("/Size 2");
});

it("includes objects reachable through indirect references", () => {
Expand Down
Loading
Loading