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
2026-10-21 12:24:30 +05:30
committed by GitHub
2 changed files with 261 additions and 8 deletions
+61 -7
View File
@@ -90,6 +90,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();
@@ -187,6 +234,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;
@@ -195,13 +256,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);
});
});