Fix form-fill hang when flattening with empty values (#6143)

This commit is contained in:
Anthony Stirling
2026-04-20 13:12:25 +01:00
committed by GitHub
parent 4e7f435016
commit 308da01d96
2 changed files with 110 additions and 18 deletions
@@ -629,11 +629,12 @@ public class FormUtils {
} }
log.debug("Skipping form fill because document has no AcroForm"); log.debug("Skipping form fill because document has no AcroForm");
if (flatten) { if (flatten) {
flattenEntireDocument(document, null); flattenEntireDocument(document, null, false);
} }
return; return;
} }
boolean valuesApplied = false;
if (values != null && !values.isEmpty()) { if (values != null && !values.isEmpty()) {
acroForm.setCacheFields(true); acroForm.setCacheFields(true);
@@ -667,18 +668,26 @@ public class FormUtils {
Object rawValue = entry.getValue(); Object rawValue = entry.getValue();
String value = rawValue == null ? null : Objects.toString(rawValue, null); String value = rawValue == null ? null : Objects.toString(rawValue, null);
applyValueToField(field, value, strict); applyValueToField(field, value, strict);
valuesApplied = true;
} }
if (valuesApplied) {
ensureAppearances(acroForm); ensureAppearances(acroForm);
} }
}
repairWidgetGeometry(document, acroForm); repairWidgetGeometry(document, acroForm);
if (flatten) { if (flatten) {
flattenEntireDocument(document, acroForm); flattenEntireDocument(document, acroForm, valuesApplied);
} }
} }
// Cap the fallback rendering DPI. This path only runs when acroForm.flatten()
// throws, and the goal is a readable flattened document — not print quality —
// so clamping avoids runaway memory/CPU on pathological inputs.
private static final int FLATTEN_FALLBACK_MAX_DPI = 200;
private void flattenViaRendering(PDDocument document, PDAcroForm acroForm) throws IOException { private void flattenViaRendering(PDDocument document, PDAcroForm acroForm) throws IOException {
if (document == null) { if (document == null) {
return; return;
@@ -704,28 +713,34 @@ public class FormUtils {
properties != null && properties.getSystem() != null properties != null && properties.getSystem() != null
? properties.getSystem().getMaxDPI() ? properties.getSystem().getMaxDPI()
: 300; : 300;
int effectiveDpi = Math.min(requestedDpi, FLATTEN_FALLBACK_MAX_DPI);
rebuildDocumentFromImages(document, renderer, requestedDpi); rebuildDocumentFromImages(document, renderer, effectiveDpi);
}
// note: this implementation suffers from:
// https://issues.apache.org/jira/browse/PDFBOX-5962
private void flattenEntireDocument(PDDocument document, PDAcroForm acroForm)
throws IOException {
if (document == null) {
return;
}
if (acroForm == null) {
return;
} }
// Use PDFBox's built-in field flattening which bakes form field values // Use PDFBox's built-in field flattening which bakes form field values
// into the page content stream as static text/graphics, removing the // into the page content stream as static text/graphics, removing the
// interactive form structure but preserving all other document content // interactive form structure but preserving all other document content
// (images, text, annotations, etc.) at full quality. // (images, text, annotations, etc.) at full quality.
try { //
// Forcing appearance regeneration via setNeedAppearances(true) drives
// PDFBox into refreshAppearances inside flatten(), where it can hang on
// certain documents (PDFBOX-5962). We therefore only regenerate when we
// actually wrote new values, or when some widgets are missing appearance
// streams and would otherwise flatten blank.
private void flattenEntireDocument(
PDDocument document, PDAcroForm acroForm, boolean valuesWritten) throws IOException {
if (document == null || acroForm == null) {
return;
}
if (valuesWritten || hasWidgetWithoutAppearance(acroForm)) {
ensureAppearances(acroForm); ensureAppearances(acroForm);
} else {
acroForm.setNeedAppearances(false);
}
try {
acroForm.flatten(); acroForm.flatten();
} catch (Exception e) { } catch (Exception e) {
log.warn( log.warn(
@@ -736,6 +751,28 @@ public class FormUtils {
} }
} }
private boolean hasWidgetWithoutAppearance(PDAcroForm acroForm) {
for (PDField field : acroForm.getFieldTree()) {
if (!(field instanceof PDTerminalField terminalField)) {
continue;
}
List<PDAnnotationWidget> widgets = terminalField.getWidgets();
if (widgets == null) {
continue;
}
for (PDAnnotationWidget widget : widgets) {
if (widget == null) {
continue;
}
PDAppearanceDictionary appearance = widget.getAppearance();
if (appearance == null || appearance.getNormalAppearance() == null) {
return true;
}
}
}
return false;
}
private void rebuildDocumentFromImages(PDDocument document, PDFRenderer renderer, int dpi) private void rebuildDocumentFromImages(PDDocument document, PDFRenderer renderer, int dpi)
throws IOException { throws IOException {
int pageCount = document.getNumberOfPages(); int pageCount = document.getNumberOfPages();
@@ -3,6 +3,7 @@ package stirling.software.common.util;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
import java.io.IOException; import java.io.IOException;
import java.time.Duration;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
@@ -249,6 +250,60 @@ class FormUtilsAdditionalTest {
} }
} }
// Regression: PDFBOX-5962. Flattening with an empty values map used to force
// setNeedAppearances(true), triggering PDFBox's refreshAppearances loop which
// could hang indefinitely. The call must complete quickly and clear form fields.
@Test
void testApplyFieldValues_emptyValuesWithFlatten_completesAndFlattens() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDTextField textField = new PDTextField(setup.acroForm);
textField.setPartialName("company");
attachWidget(setup, textField, new PDRectangle(60, 720, 220, 20));
assertTrue(setup.acroForm.getNeedAppearances());
assertTimeoutPreemptively(
Duration.ofSeconds(10),
() -> FormUtils.applyFieldValues(doc, Map.of(), true, false));
PDAcroForm after = doc.getDocumentCatalog().getAcroForm();
assertTrue(after == null || after.getFields().isEmpty());
}
}
@Test
void testApplyFieldValues_nullValuesWithFlatten_completesAndFlattens() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDTextField textField = new PDTextField(setup.acroForm);
textField.setPartialName("company");
attachWidget(setup, textField, new PDRectangle(60, 720, 220, 20));
assertTimeoutPreemptively(
Duration.ofSeconds(10),
() -> FormUtils.applyFieldValues(doc, null, true, false));
PDAcroForm after = doc.getDocumentCatalog().getAcroForm();
assertTrue(after == null || after.getFields().isEmpty());
}
}
@Test
void testApplyFieldValues_valuesWithFlatten_appliesValueAndFlattens() throws IOException {
try (PDDocument doc = new PDDocument()) {
SetupDocument setup = createBasicDocument(doc);
PDTextField textField = new PDTextField(setup.acroForm);
textField.setPartialName("company");
attachWidget(setup, textField, new PDRectangle(60, 720, 220, 20));
FormUtils.applyFieldValues(doc, Map.of("company", "Stirling"), true, false);
PDAcroForm after = doc.getDocumentCatalog().getAcroForm();
assertTrue(after == null || after.getFields().isEmpty());
}
}
// --- filterSingleChoiceSelection --- // --- filterSingleChoiceSelection ---
@Test @Test