Merge pull request #902 from mvanhorn/fix/647-sign-pdf-flatten-text-position

fix: keep flattened Sign PDF text at its placed position
This commit is contained in:
Alam authored and GitHub committed 2026-10-21 12:24:30 +05:30
commit 5222f6c0bf
2 files changed
+261 -8

No files matched your search

+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);
});
});