fix: keep flattened Sign PDF text at its placed position

readFormMatrix loads the appearance Matrix and uses the identity when
that entry is missing or is not six numbers. alignAppearanceToRect maps
the four BBox corners through that matrix and returns the scale and
translation that fit the transformed box to the annotation rectangle.
Those values are the cm concatenated before Do, and the original Matrix
stays on the form XObject so Do applies it once. A transformed box whose
width or height is under 0.001 is left unpainted, and that annotation is
still removed.

Fixes #647
This commit is contained in:
Matt Van Horn
2026-10-02 22:16:51 -07:00
parent c10511203e
commit 4fd0b1d3e6
2 changed files with 261 additions and 8 deletions
+61 -7
View File
@@ -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, [
+200 -1
View File
@@ -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<PDFDocument['getPage']>,
@@ -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);
});
});