diff --git a/src/js/utils/flatten-annotations.ts b/src/js/utils/flatten-annotations.ts index fa8bd931..5c69b358 100644 --- a/src/js/utils/flatten-annotations.ts +++ b/src/js/utils/flatten-annotations.ts @@ -33,6 +33,53 @@ function resolveStreamDict(obj: unknown): PDFDict | null { return null; } +const IDENTITY_MATRIX = [1, 0, 0, 1, 0, 0]; + +function readFormMatrix(dict: PDFDict | null): number[] { + if (!dict) return IDENTITY_MATRIX; + const matrixObj = dict.lookup(PDFName.of('Matrix')); + if (!(matrixObj instanceof PDFArray) || matrixObj.size() !== 6) { + return IDENTITY_MATRIX; + } + return extractNumbers(matrixObj, 6) ?? IDENTITY_MATRIX; +} + +// ISO 32000-1 ยง12.5.5: map the matrix-transformed BBox onto Rect. +// Do still applies the form Matrix, so this cm must not include it again. +function alignAppearanceToRect( + bbox: number[], + matrix: number[], + x1: number, + y1: number, + x2: number, + y2: number +): [number, number, number, number] | null { + const [a, b, c, d, e, f] = matrix; + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + + for (const x of [bbox[0], bbox[2]]) { + for (const y of [bbox[1], bbox[3]]) { + const px = a * x + c * y + e; + const py = b * x + d * y + f; + if (px < minX) minX = px; + if (py < minY) minY = py; + if (px > maxX) maxX = px; + if (py > maxY) maxY = py; + } + } + + const bw = maxX - minX; + const bh = maxY - minY; + if (bw < 0.001 || bh < 0.001) return null; + + const sx = (x2 - x1) / bw; + const sy = (y2 - y1) / bh; + return [sx, sy, x1 - minX * sx, y1 - minY * sy]; +} + export function flattenAnnotations(pdfDoc: PDFDocument): void { const pages = pdfDoc.getPages(); @@ -132,6 +179,20 @@ export function flattenAnnotations(pdfDoc: PDFDocument): void { } } + const placement = alignAppearanceToRect( + bbox, + readFormMatrix(streamDict), + x1, + y1, + x2, + y2 + ); + if (!placement) { + hasChanges = true; + continue; + } + const [sx, sy, tx, ty] = placement; + let appRef: PDFRef; if (normalAppRef instanceof PDFRef) { appRef = normalAppRef; @@ -140,13 +201,6 @@ export function flattenAnnotations(pdfDoc: PDFDocument): void { } const xObjKey = pageNode.newXObject('FlatAnnot', appRef); - const bw = bbox[2] - bbox[0]; - const bh = bbox[3] - bbox[1]; - const sx = Math.abs(bw) > 0.001 ? (x2 - x1) / bw : 1; - const sy = Math.abs(bh) > 0.001 ? (y2 - y1) / bh : 1; - const tx = x1 - bbox[0] * sx; - const ty = y1 - bbox[1] * sy; - page.pushOperators( PDFOperator.of(PDFOperatorNames.PushGraphicsState), PDFOperator.of(PDFOperatorNames.ConcatTransformationMatrix, [ diff --git a/src/tests/flatten-annotations.test.ts b/src/tests/flatten-annotations.test.ts index c7b2d772..a9a13ffe 100644 --- a/src/tests/flatten-annotations.test.ts +++ b/src/tests/flatten-annotations.test.ts @@ -1,26 +1,113 @@ import { describe, it, expect } from 'vitest'; +import { inflateSync } from 'node:zlib'; import { PDFDocument, PDFName, PDFDict, PDFArray, PDFNumber, + PDFRawStream, PDFRef, + PDFObject, } from 'pdf-lib'; import { flattenAnnotations } from '../js/utils/flatten-annotations'; function createAppearanceStream( doc: PDFDocument, - bbox: [number, number, number, number] + bbox: [number, number, number, number], + matrix?: number[] | PDFArray ): PDFRef { const stream = doc.context.stream('0 0 1 rg 0 0 100 20 re f', { Type: 'XObject', Subtype: 'Form', BBox: bbox, }); + if (matrix) { + stream.dict.set( + PDFName.of('Matrix'), + Array.isArray(matrix) ? doc.context.obj(matrix) : matrix + ); + } return doc.context.register(stream); } +function resolve(doc: PDFDocument, ref?: PDFObject): PDFObject | undefined { + if (ref instanceof PDFRef) { + return doc.context.lookup(ref) as PDFObject | undefined; + } + return ref; +} + +function decodeStream(doc: PDFDocument, ref?: PDFObject): string { + const stream = resolve(doc, ref); + if (!(stream instanceof PDFRawStream)) return ''; + const raw = Buffer.from(stream.contents); + const filter = stream.dict.get(PDFName.of('Filter')); + return filter?.toString().includes('FlateDecode') + ? inflateSync(raw).toString('latin1') + : raw.toString('latin1'); +} + +function pageContent(doc: PDFDocument, pageIndex = 0): string { + const contents = doc.getPage(pageIndex).node.get(PDFName.of('Contents')); + if (!contents) return ''; + const resolved = doc.context.lookup(contents); + const parts = + resolved instanceof PDFArray + ? resolved.asArray().map((entry) => decodeStream(doc, entry)) + : [decodeStream(doc, contents)]; + return parts.join('\n'); +} + +function cmBeforeDo(content: string): number[] | null { + const doAt = content.lastIndexOf(' Do'); + if (doAt < 0) return null; + const cmAt = content.lastIndexOf(' cm', doAt); + if (cmAt < 0) return null; + const lineStart = content.lastIndexOf('\n', cmAt) + 1; + const operands = content.slice(lineStart, cmAt).trim().split(/\s+/); + if (operands.length < 6) return null; + const nums = operands.slice(-6).map(Number); + if (nums.some((n) => Number.isNaN(n))) return null; + return nums; +} + +function expectCm(actual: number[] | null, expected: number[]) { + expect(actual).not.toBeNull(); + expected.forEach((n, i) => expect(actual![i]).toBeCloseTo(n, 4)); +} + +function savedFormMatrix(doc: PDFDocument, pageIndex = 0): number[] | null { + const resources = doc.getPage(pageIndex).node.lookup(PDFName.of('Resources')); + if (!(resources instanceof PDFDict)) return null; + const xobjects = resources.lookup(PDFName.of('XObject')); + if (!(xobjects instanceof PDFDict)) return null; + + for (const [, value] of xobjects.entries()) { + const obj = doc.context.lookup(value); + const dict = + obj instanceof PDFDict + ? obj + : obj !== null && + typeof obj === 'object' && + 'dict' in obj && + (obj as { dict: unknown }).dict instanceof PDFDict + ? (obj as { dict: PDFDict }).dict + : null; + if (!dict) continue; + const matrixObj = dict.lookup(PDFName.of('Matrix')); + if (!(matrixObj instanceof PDFArray) || matrixObj.size() !== 6) continue; + const nums: number[] = []; + for (let i = 0; i < 6; i++) { + const n = matrixObj.lookup(i); + if (!(n instanceof PDFNumber)) return null; + nums.push(n.asNumber()); + } + return nums; + } + return null; +} + function addAnnotation( doc: PDFDocument, page: ReturnType, @@ -370,4 +457,116 @@ describe('flattenAnnotations', () => { const reloaded = await roundTrip(doc); expect(reloaded.getPageCount()).toBe(1); }); + + it('stamps a PDF.js FreeText appearance back onto its rectangle', async () => { + const doc = await PDFDocument.create(); + const page = doc.addPage([612, 792]); + const rect: [number, number, number, number] = [100, 500, 250, 540]; + const matrix = [1, 0, 0, 1, -100, -500]; + const appRef = createAppearanceStream(doc, rect, matrix); + + addAnnotation(doc, page, { + subtype: 'FreeText', + rect, + appearance: appRef, + }); + + flattenAnnotations(doc); + + const saved = await roundTrip(doc); + expect(getAnnotCount(saved.getPage(0))).toBe(0); + expectCm(cmBeforeDo(pageContent(saved)), [1, 0, 0, 1, 100, 500]); + expectCm(savedFormMatrix(saved), matrix); + }); + + it('keeps an appearance with no matrix on its rectangle', async () => { + const doc = await PDFDocument.create(); + const page = doc.addPage([612, 792]); + const appRef = createAppearanceStream(doc, [0, 0, 50, 10]); + + addAnnotation(doc, page, { + subtype: 'FreeText', + rect: [80, 600, 180, 640], + appearance: appRef, + }); + + flattenAnnotations(doc); + + const saved = await roundTrip(doc); + expectCm(cmBeforeDo(pageContent(saved)), [2, 0, 0, 4, 80, 600]); + }); + + it('aligns a rotated appearance box to the annotation rectangle', async () => { + const doc = await PDFDocument.create(); + const page = doc.addPage([612, 792]); + const w = 80; + const h = 30; + const c = 0; + const s = 1; + const xs = [0, w * c, -h * s, w * c - h * s]; + const ys = [0, w * s, h * c, w * s + h * c]; + const matrix = [c, s, -s, c, -Math.min(...xs), -Math.min(...ys)]; + const appRef = createAppearanceStream(doc, [0, 0, w, h], matrix); + + addAnnotation(doc, page, { + subtype: 'FreeText', + rect: [40, 150, 70, 310], + appearance: appRef, + }); + + flattenAnnotations(doc); + + const saved = await roundTrip(doc); + expectCm(cmBeforeDo(pageContent(saved)), [1, 0, 0, 2, 40, 150]); + expectCm(savedFormMatrix(saved), matrix); + }); + + it('drops an appearance whose matrix collapses the box', async () => { + const doc = await PDFDocument.create(); + const page = doc.addPage([612, 792]); + const appRef = createAppearanceStream( + doc, + [0, 0, 100, 40], + [0.000004, 0, 0, 1, 0, 0] + ); + + addAnnotation(doc, page, { + subtype: 'FreeText', + rect: [10, 20, 110, 60], + appearance: appRef, + }); + + flattenAnnotations(doc); + + const saved = await roundTrip(doc); + expect(getAnnotCount(saved.getPage(0))).toBe(0); + expect(pageContent(saved).includes(' Do')).toBe(false); + }); + + it('treats a matrix that is not six numbers as identity', async () => { + const doc = await PDFDocument.create(); + const page = doc.addPage([612, 792]); + const other = doc.addPage([612, 792]); + const shortMatrix = doc.context.obj([9, 0, 0, 9, 25]) as PDFArray; + const namedMatrix = doc.context.obj([1, 0, 0, 1, 0, 0]) as PDFArray; + namedMatrix.set(4, PDFName.of('Bad')); + + addAnnotation(doc, page, { + subtype: 'FreeText', + rect: [80, 600, 180, 640], + appearance: createAppearanceStream(doc, [0, 0, 50, 10], shortMatrix), + }); + addAnnotation(doc, other, { + subtype: 'FreeText', + rect: [80, 600, 180, 640], + appearance: createAppearanceStream(doc, [0, 0, 50, 10], namedMatrix), + }); + + flattenAnnotations(doc); + + const saved = await roundTrip(doc); + const expected = [2, 0, 0, 4, 80, 600]; + expectCm(cmBeforeDo(pageContent(saved, 0)), expected); + expectCm(cmBeforeDo(pageContent(saved, 1)), expected); + }); });