Fix AcroForm appearance resources when merging pages

Merging pages with conflicting AcroForm /DR must inline the resources
each field's appearance relies on, since only one /DR survives in the
output. That fixup:
- resolved the appearance /Resources ref against the source document's
  xref instead of the merged one, so the lookup failed and merging threw
  (null.has(...));
- skipped checkbox/radio widgets, whose /AP /N is a sub-dictionary of
  appearance states rather than a single stream;
- threw on a non-dictionary /Resources (e.g. a stray name) instead of
  falling back to the default resources.
This commit is contained in:
Calixte Denizet 2026-07-13 11:14:03 +02:00
parent c661ba1408
commit 914545dea3
2 changed files with 184 additions and 37 deletions

View File

@ -2264,47 +2264,51 @@ class PDFEditor {
}
}
const resourcesValuesCache = new Map();
const fixAppearanceResources = async stream => {
let resources = stream.dict.getRaw("Resources");
resources &&= this.xrefWrapper.fetchIfRef(resources);
if (!(resources instanceof Dict)) {
const newResourcesRef = await resourcesValuesCache.getOrInsertComputed(
acroFormDefaultResources,
() => this.#cloneObject(acroFormDefaultResources, xref)
);
stream.dict.set("Resources", newResourcesRef);
return;
}
for (const [
resKey,
resValue,
] of acroFormDefaultResources.getRawEntries()) {
if (resources.has(resKey)) {
continue;
}
let newResValue = resValue;
if (resValue instanceof Ref) {
newResValue = await this.#collectDependencies(resValue, true, xref);
} else if (
resValue instanceof Dict ||
resValue instanceof BaseStream ||
Array.isArray(resValue)
) {
newResValue = await resourcesValuesCache.getOrInsertComputed(
resValue,
() => this.#cloneObject(resValue, xref)
);
}
resources.set(resKey, newResValue);
}
};
for (const field of drToFix) {
const ap = field.get("AP");
for (const [, value] of ap) {
if (!(value instanceof BaseStream)) {
continue;
}
let resources = value.dict.getRaw("Resources");
if (!resources) {
const newResourcesRef =
await resourcesValuesCache.getOrInsertComputed(
acroFormDefaultResources,
() => this.#cloneObject(acroFormDefaultResources, xref)
);
value.dict.set("Resources", newResourcesRef);
continue;
}
resources = xref.fetchIfRef(resources);
for (const [
resKey,
resValue,
] of acroFormDefaultResources.getRawEntries()) {
if (!resources.has(resKey)) {
let newResValue = resValue;
if (resValue instanceof Ref) {
newResValue = await this.#collectDependencies(
resValue,
true,
xref
);
} else if (
resValue instanceof Dict ||
resValue instanceof BaseStream ||
Array.isArray(resValue)
) {
newResValue = await resourcesValuesCache.getOrInsertComputed(
resValue,
() => this.#cloneObject(resValue, xref)
);
if (value instanceof BaseStream) {
await fixAppearanceResources(value);
} else if (value instanceof Dict) {
for (const [, stream] of value) {
if (stream instanceof BaseStream) {
await fixAppearanceResources(stream);
}
resources.set(resKey, newResValue);
}
}
}

View File

@ -7468,6 +7468,39 @@ small scripts as well as for`);
});
describe("AcroForm", function () {
const buildTextFieldPdf = (
fieldName,
fontKey = "Helv",
baseFont = "Helvetica"
) => {
const appearance = `BT /${fontKey} 10 Tf (x) Tj ET`;
return assemblePdf([
"1 0 obj\n<< /Type /Catalog /Pages 2 0 R /AcroForm 6 0 R >>\nendobj\n",
"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n",
"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] " +
"/Resources << >> /Annots [4 0 R] >>\nendobj\n",
"4 0 obj\n<< /Type /Annot /Subtype /Widget /FT /Tx " +
`/T (${fieldName}) /Rect [0 0 20 10] /AP << /N 5 0 R >> >>\nendobj\n`,
`5 0 obj\n<< /Subtype /Form /BBox [0 0 20 10] /Resources 7 0 R ` +
`/Length ${appearance.length} >>\n` +
`stream\n${appearance}\nendstream\nendobj\n`,
`6 0 obj\n<< /Fields [4 0 R] /DR << /Font << ` +
`/${fontKey} 8 0 R >> >> /DA (/${fontKey} 10 Tf) >>\nendobj\n`,
"7 0 obj\n<< >>\nendobj\n",
`8 0 obj\n<< /Type /Font /Subtype /Type1 ` +
`/BaseFont /${baseFont} >>\nendobj\n`,
]);
};
const getAppearanceFontName = async (pdfDoc, pageNumber) => {
const page = await pdfDoc.getPage(pageNumber);
const operatorList = await page.getOperatorList({
annotationMode: AnnotationMode.ENABLE,
});
const fontIndex = operatorList.fnArray.indexOf(OPS.setFont);
return fontIndex < 0 ? null : operatorList.argsArray[fontIndex][0];
};
it("extract page 2 and check AcroForm Fields T entries", async function () {
let loadingTask = getDocument(
buildGetDocumentParams("form_two_pages.pdf")
@ -7623,6 +7656,116 @@ small scripts as well as for`);
expect(newPdfDoc.numPages).toEqual(2);
await newLoadingTask.destroy();
});
it("merges appearance resources from conflicting AcroForms", async function () {
let loadingTask = getDocument({
data: buildTextFieldPdf("first", "F1", "Helvetica"),
});
let pdfDoc = await loadingTask.promise;
const data = await pdfDoc.extractPages([
{ document: null },
{ document: buildTextFieldPdf("second", "F2", "Courier") },
]);
expect(data).not.toBeNull();
await loadingTask.destroy();
loadingTask = getDocument({ data });
pdfDoc = await loadingTask.promise;
expect(pdfDoc.numPages).toEqual(2);
expect(
Object.keys((await pdfDoc.getFieldObjects()) ?? {}).sort()
).toEqual(["first", "second"]);
for (const pageNumber of [1, 2]) {
const fontName = await getAppearanceFontName(pdfDoc, pageNumber);
expect(fontName).not.toBeNull();
expect(fontName).not.toEqual("g_font_error");
}
await loadingTask.destroy();
});
it("replaces invalid appearance resources with default resources", async function () {
const appearance = "BT /Cour 10 Tf (x) Tj ET";
const broken = assemblePdf([
"1 0 obj\n<< /Type /Catalog /Pages 2 0 R /AcroForm 6 0 R >>\nendobj\n",
"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n",
"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] " +
"/Resources << >> /Annots [4 0 R] >>\nendobj\n",
"4 0 obj\n<< /Type /Annot /Subtype /Widget /FT /Tx /T (broken) " +
"/Rect [0 0 20 10] /AP << /N 5 0 R >> >>\nendobj\n",
"5 0 obj\n<< /Subtype /Form /BBox [0 0 20 10] " +
`/Resources /Nope /Length ${appearance.length} >>\n` +
`stream\n${appearance}\nendstream\nendobj\n`,
"6 0 obj\n<< /Fields [4 0 R] /DR << /Font << /Cour 7 0 R >> >> " +
"/DA (/Cour 10 Tf) >>\nendobj\n",
"7 0 obj\n<< /Type /Font /Subtype /Type1 " +
"/BaseFont /Courier >>\nendobj\n",
]);
let loadingTask = getDocument({ data: buildTextFieldPdf("main") });
let pdfDoc = await loadingTask.promise;
const data = await pdfDoc.extractPages([
{ document: null },
{ document: broken },
]);
expect(data).not.toBeNull();
await loadingTask.destroy();
loadingTask = getDocument({ data });
pdfDoc = await loadingTask.promise;
expect(pdfDoc.numPages).toEqual(2);
expect(
Object.keys((await pdfDoc.getFieldObjects()) ?? {}).sort()
).toEqual(["broken", "main"]);
const fontName = await getAppearanceFontName(pdfDoc, 2);
expect(fontName).not.toBeNull();
expect(fontName).not.toEqual("g_font_error");
await loadingTask.destroy();
});
it("merges default resources into checkbox appearance streams", async function () {
const on = "/Tx BMC q BT /ZaDb 12 Tf (4) Tj ET Q EMC";
const withCheckbox = assemblePdf([
"1 0 obj\n<< /Type /Catalog /Pages 2 0 R /AcroForm 7 0 R >>\nendobj\n",
"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n",
"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] " +
"/Resources << >> /Annots [4 0 R] >>\nendobj\n",
"4 0 obj\n<< /Type /Annot /Subtype /Widget /FT /Btn /T (check) " +
"/Rect [0 0 20 20] /AS /On " +
"/AP << /N << /On 5 0 R /Off 6 0 R >> >> >>\nendobj\n",
`5 0 obj\n<< /Subtype /Form /BBox [0 0 20 20] /Resources 8 0 R ` +
`/Length ${on.length} >>\nstream\n${on}\nendstream\nendobj\n`,
"6 0 obj\n<< /Subtype /Form /BBox [0 0 20 20] /Resources 8 0 R " +
"/Length 1 >>\nstream\n \nendstream\nendobj\n",
"7 0 obj\n<< /Fields [4 0 R] /DR << " +
"/Font << /ZaDb 9 0 R >> " +
"/ColorSpace << /CsDefault /DeviceRGB >> >> " +
"/DA (/ZaDb 0 Tf) >>\nendobj\n",
"8 0 obj\n<< >>\nendobj\n",
"9 0 obj\n<< /Type /Font /Subtype /Type1 " +
"/BaseFont /ZapfDingbats >>\nendobj\n",
]);
let loadingTask = getDocument({ data: buildTextFieldPdf("main") });
let pdfDoc = await loadingTask.promise;
const data = await pdfDoc.extractPages([
{ document: null },
{ document: withCheckbox },
]);
expect(data).not.toBeNull();
expect(countMarker(data, "/CsDefault")).toEqual(1);
await loadingTask.destroy();
loadingTask = getDocument({ data });
pdfDoc = await loadingTask.promise;
expect(pdfDoc.numPages).toEqual(2);
expect(
Object.keys((await pdfDoc.getFieldObjects()) ?? {}).sort()
).toEqual(["check", "main"]);
const fontName = await getAppearanceFontName(pdfDoc, 2);
expect(fontName).not.toBeNull();
expect(fontName).not.toEqual("g_font_error");
await loadingTask.destroy();
});
});
describe("Outlines", function () {