mirror of
https://github.com/alam00000/bentopdf.git
synced 2026-11-07 00:28:39 +01:00
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:
@@ -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, [
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user