diff --git a/src/annotations/annotations.test.ts b/src/annotations/annotations.test.ts index 5564e2f..74de24b 100644 --- a/src/annotations/annotations.test.ts +++ b/src/annotations/annotations.test.ts @@ -8,6 +8,7 @@ import { PdfArray } from "#src/objects/pdf-array"; import { PdfDict } from "#src/objects/pdf-dict"; import { PdfName } from "#src/objects/pdf-name"; import { PdfNumber } from "#src/objects/pdf-number"; +import { PdfRef } from "#src/objects/pdf-ref"; import { describe, expect, it } from "vitest"; import type { PDFLinkAnnotation } from "./link"; @@ -629,6 +630,54 @@ describe("PDFAnnotations", () => { expect(page.getAnnotations()).toHaveLength(0); }); + it("drops dangling /Annots refs during flatten", async () => { + // Regression: /Annots entries whose target object doesn't exist were + // skipped (`continue`) instead of removed. The dangling ref survived + // into the full save, where renumbering left it pointing above /Size — + // and a later incremental update (signing) reused that number, + // invalidating signatures in Adobe. + const pdf = PDF.create(); + const page = pdf.addPage(); + + page.addHighlightAnnotation({ + rect: { x: 100, y: 700, width: 200, height: 14 }, + color: rgb(1, 1, 0), + }); + + // Inject a ref whose target is never defined. + const annots = page.dict.get("Annots", ref => pdf.context.registry.resolve(ref)); + expect(annots).toBeInstanceOf(PdfArray); + (annots as PdfArray).push(PdfRef.of(999, 0)); + + const count = page.flattenAnnotations(); + expect(count).toBe(1); + + // The dangling ref must not survive in /Annots. + const after = page.dict.get("Annots", ref => pdf.context.registry.resolve(ref)); + + if (after instanceof PdfArray) { + for (const item of after) { + expect(item).not.toBe(PdfRef.of(999, 0)); + } + } + }); + + it("drops dangling /Annots refs even when nothing is flattenable", async () => { + const pdf = PDF.create(); + const page = pdf.addPage(); + + page.dict.set("Annots", new PdfArray([PdfRef.of(999, 0)])); + + const count = page.flattenAnnotations(); + expect(count).toBe(0); + + const after = page.dict.get("Annots", ref => pdf.context.registry.resolve(ref)); + + if (after instanceof PdfArray) { + expect(after.length).toBe(0); + } + }); + it("preserves link annotations during flatten", async () => { const pdf = PDF.create(); const page = pdf.addPage(); diff --git a/src/annotations/flattener.ts b/src/annotations/flattener.ts index 5199766..93741dc 100644 --- a/src/annotations/flattener.ts +++ b/src/annotations/flattener.ts @@ -129,6 +129,13 @@ export class AnnotationFlattener { } if (!annotDict) { + // Dangling ref (target doesn't exist) or resolves to a non-dict. + // Per PDF 1.7 §7.3.10 such refs are null, remove them from /Annots + // rather than skipping. + if (annotRef) { + refsToRemove.add(`${annotRef.objectNumber} ${annotRef.generation}`); + } + continue; } diff --git a/src/document/forms/acro-form.test.ts b/src/document/forms/acro-form.test.ts index d20ae6c..7e1e228 100644 --- a/src/document/forms/acro-form.test.ts +++ b/src/document/forms/acro-form.test.ts @@ -4,6 +4,7 @@ import { PdfDict } from "#src/objects/pdf-dict"; import { PdfName } from "#src/objects/pdf-name"; import { PdfNumber } from "#src/objects/pdf-number"; import type { PdfObject } from "#src/objects/pdf-object"; +import { PdfRef } from "#src/objects/pdf-ref"; import { loadFixture, toAsciiString } from "#src/test-utils"; import { describe, expect, it } from "vitest"; @@ -873,6 +874,35 @@ describe("Form Writing", () => { expect(countPageWidgets(pdf2)).toBe(0); }); + it("drops dangling /Annots refs when removing flattened widgets", async () => { + // Regression: removeAnnotations rebuilt /Annots keeping every ref not + // in the removal set — refs whose targets don't exist were never in + // that set, so they survived flatten and later broke signature + // validation after full-save renumbering + incremental signing. + const bytes = await loadFixture("forms", "sample_form.pdf"); + const pdf = await PDF.load(bytes); + const form = pdf.getForm()?.acroForm(); + + const page = pdf.getPages()[0]; + const annots = page.dict.get("Annots", ref => pdf.context.registry.resolve(ref)); + expect(annots?.type).toBe("array"); + + // Inject a ref whose target is never defined anywhere in the file. + (annots as PdfArray).push(PdfRef.of(9999, 0)); + + form!.flatten(); + + const after = page.dict.get("Annots", ref => pdf.context.registry.resolve(ref)); + + if (after && after.type === "array") { + for (const item of after) { + if (item instanceof PdfRef) { + expect(item.objectNumber).not.toBe(9999); + } + } + } + }); + it("handles checkbox fields correctly", async () => { const bytes = await loadFixture("forms", "sample_form.pdf"); const pdf = await PDF.load(bytes); diff --git a/src/document/forms/form-flattener.ts b/src/document/forms/form-flattener.ts index d0301e6..efb7a64 100644 --- a/src/document/forms/form-flattener.ts +++ b/src/document/forms/form-flattener.ts @@ -617,9 +617,17 @@ export class FormFlattener { if (item instanceof PdfRef) { const key = `${item.objectNumber} ${item.generation}`; - if (!toRemove.has(key)) { - remaining.push(item); + if (toRemove.has(key)) { + continue; } + + // Drop dangling refs (targets that don't exist). Per PDF 1.7 + // §7.3.10 they are null anyway. + if (this.registry.resolve(item) === null) { + continue; + } + + remaining.push(item); } } diff --git a/src/document/object-registry.test.ts b/src/document/object-registry.test.ts index bd205b6..94f8c8b 100644 --- a/src/document/object-registry.test.ts +++ b/src/document/object-registry.test.ts @@ -159,6 +159,43 @@ describe("ObjectRegistry", () => { }); }); + describe("dangling reference defense", () => { + it("bumps nextObjectNumber when resolving a ref beyond the xref range", () => { + // Regression: malformed files can reference object numbers above their + // own /Size (dangling refs). If the allocation cursor only considers + // DEFINED objects, a later incremental update (e.g. signing) allocates + // numbers that signed content already references — redefining them and + // breaking signature validation in Adobe. + const xref = new Map([ + [1, { type: "uncompressed", offset: 100, generation: 0 }], + [5, { type: "uncompressed", offset: 200, generation: 0 }], + ]); + + const registry = new ObjectRegistry(xref); + expect(registry.nextObjectNumber).toBe(6); + + // Resolving a dangling ref (no resolver → null) must still bump the cursor. + expect(registry.resolve(PdfRef.of(295, 0))).toBeNull(); + expect(registry.nextObjectNumber).toBe(296); + + const ref = registry.register(new PdfDict()); + expect(ref.objectNumber).toBe(296); + }); + + it("does not bump nextObjectNumber for in-range refs", () => { + const xref = new Map([ + [1, { type: "uncompressed", offset: 100, generation: 0 }], + [5, { type: "uncompressed", offset: 200, generation: 0 }], + ]); + + const registry = new ObjectRegistry(xref); + + registry.resolve(PdfRef.of(3, 0)); + + expect(registry.nextObjectNumber).toBe(6); + }); + }); + describe("commitNewObjects", () => { it("moves new objects to loaded", () => { const registry = new ObjectRegistry(); diff --git a/src/document/object-registry.ts b/src/document/object-registry.ts index fd6ca2b..ea2abb2 100644 --- a/src/document/object-registry.ts +++ b/src/document/object-registry.ts @@ -168,6 +168,14 @@ export class ObjectRegistry { * @returns The object, or null if not found */ resolve(ref: PdfRef): PdfObject | null { + // Malformed files can reference object numbers at or above + // the allocation cursor (dangling refs beyond the xref's /Size). Bump + // the cursor so newly allocated numbers never collide with numbers the + // existing content already references. + if (ref.objectNumber >= this._nextObjNum) { + this._nextObjNum = ref.objectNumber + 1; + } + // Check registry first const existing = this.getObject(ref); diff --git a/src/writer/pdf-writer.test.ts b/src/writer/pdf-writer.test.ts index d812347..f7fcf55 100644 --- a/src/writer/pdf-writer.test.ts +++ b/src/writer/pdf-writer.test.ts @@ -485,6 +485,39 @@ describe("writeComplete", () => { } } + /** + * Build a minimal raw PDF whose page /Annots references object numbers + * (295, 299) that are not defined anywhere in the file — dangling refs + * above the file's own /Size, as seen in malformed uploads and files + * pre-processed by broken third-party flatteners. + */ + function buildPdfWithDanglingAnnots(): Uint8Array { + const objects = [ + "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n", + "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n", + "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Annots [295 0 R 299 0 R] >>\nendobj\n", + ]; + + let body = "%PDF-1.7\n%\xe2\xe3\xcf\xd3\n"; + const offsets: number[] = []; + + for (const obj of objects) { + offsets.push(body.length); + body += obj; + } + + const xrefOffset = body.length; + let xref = "xref\n0 4\n0000000000 65535 f \n"; + + for (const off of offsets) { + xref += `${String(off).padStart(10, "0")} 00000 n \n`; + } + + const trailer = `trailer\n<< /Size 4 /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`; + + return Uint8Array.from(Buffer.from(body + xref + trailer, "latin1")); + } + it("produces a single contiguous xref subsection (0..N)", async () => { // Build a PDF that registers extra objects then orphans some, so the // pre-fix code would have produced gap-laden subsections. @@ -613,6 +646,74 @@ describe("writeComplete", () => { expect(pdf2.getForm()?.getFields().length).toBe(fieldsBefore); }); + it("replaces dangling refs (targets never registered) with null", () => { + // Regression: refs whose targets are not in the registry used to pass + // through renumbering with their ORIGINAL object numbers. After dense + // compaction those numbers land above the new /Size, and a later + // incremental update (signing, DSS) re-allocates them — redefining + // objects that signed content still references. Adobe then reports + // "Document has been altered or corrupted since it was signed". + // Per PDF 1.7 §7.3.10 a ref to a nonexistent object is null. + const registry = new ObjectRegistry(); + + // Orphan forces renumbering to actually move numbers around. + registry.register(PdfDict.of({ Type: PdfName.of("Orphan") })); + + const page = PdfDict.of({ + Type: PdfName.of("Page"), + // 295 and 299 are never registered — dangling refs. + Annots: new PdfArray([PdfRef.of(295, 0), PdfRef.of(299, 0)]), + }); + const pageRef = registry.register(page); + + const catalog = PdfDict.of({ Type: PdfName.Catalog, Page: pageRef }); + const catalogRef = registry.register(catalog); + + const result = writeComplete(registry, { root: catalogRef }); + const text = Buffer.from(result.bytes).toString("latin1"); + + // The dangling refs must not survive with their original numbers. + expect(text).not.toContain("295 0 R"); + expect(text).not.toContain("299 0 R"); + + // They must be written as null. + expect(text).toMatch(/\/Annots\s*\[\s*null\s+null\s*\]/); + + assertNoDanglingRefs(result.bytes); + }); + + it("full save of a loaded PDF with dangling /Annots refs emits no refs at or above /Size", async () => { + // End-to-end variant through the real parse path: a file whose page + // /Annots references object numbers that are not defined anywhere + // (above its own /Size), as produced by broken third-party tools. + const raw = buildPdfWithDanglingAnnots(); + const pdf = await PDF.load(raw); + + // Force a dirty mark so save() actually rewrites. + pdf.getCatalog().set("LangX", PdfString.fromString("x")); + + const saved = await pdf.save(); + const text = Buffer.from(saved).toString("latin1"); + + // Original dangling numbers must not appear as refs in the output. + expect(text).not.toContain("295 0 R"); + expect(text).not.toContain("299 0 R"); + assertNoDanglingRefs(saved); + + // No reference anywhere in the output may be >= /Size. + const sizeMatch = text.match(/\/Size\s+(\d+)/); + expect(sizeMatch).not.toBeNull(); + const size = Number(sizeMatch![1]); + + for (const [, numStr] of text.matchAll(/(\d+) \d+ R\b/g)) { + expect(Number(numStr)).toBeLessThan(size); + } + + // The file must still reload cleanly. + const pdf2 = await PDF.load(saved); + expect(pdf2.getPages().length).toBe(1); + }); + it("preserves generation numbers on renumbered refs", () => { // A defensive check: even though most PDFs use generation 0 // exclusively, the spec allows non-zero generations and the diff --git a/src/writer/pdf-writer.ts b/src/writer/pdf-writer.ts index 8fb1256..fe58e8b 100644 --- a/src/writer/pdf-writer.ts +++ b/src/writer/pdf-writer.ts @@ -16,6 +16,7 @@ import { ByteWriter } from "#src/io/byte-writer"; import { PdfArray } from "#src/objects/pdf-array"; import { PdfDict } from "#src/objects/pdf-dict"; import { PdfName } from "#src/objects/pdf-name"; +import { PdfNull } from "#src/objects/pdf-null"; import type { PdfObject } from "#src/objects/pdf-object"; import { PdfRef } from "#src/objects/pdf-ref"; import { PdfStream } from "#src/objects/pdf-stream"; @@ -302,7 +303,13 @@ function renumberRef(ref: PdfRef, renumberMap: Map): PdfRef { */ function renumberRefs(obj: PdfObject, renumberMap: Map): PdfObject { if (obj instanceof PdfRef) { - return renumberRef(obj, renumberMap); + const newNum = renumberMap.get(obj.objectNumber); + + if (newNum === undefined) { + return PdfNull.instance; + } + + return PdfRef.of(newNum, obj.generation); } if (obj instanceof PdfStream) {