diff --git a/.gitignore b/.gitignore index 4b34350f67..1290056e05 100644 --- a/.gitignore +++ b/.gitignore @@ -176,6 +176,8 @@ app/core/src/main/resources/static/images/google-drive.svg *.nar *.ear *.zip +# Real backend archives the form-bundle reader is tested against. +!frontend/editor/src/core/tools/formFill/__fixtures__/*.zip *.tar.gz *.rar *.db diff --git a/app/common/src/main/java/stirling/software/common/model/FormFieldWithCoordinates.java b/app/common/src/main/java/stirling/software/common/model/FormFieldWithCoordinates.java index 54ccafd665..9a0f23aa33 100644 --- a/app/common/src/main/java/stirling/software/common/model/FormFieldWithCoordinates.java +++ b/app/common/src/main/java/stirling/software/common/model/FormFieldWithCoordinates.java @@ -62,6 +62,15 @@ public class FormFieldWithCoordinates { @Schema(description = "Widget coordinates on each page (fields can have multiple widgets)") private List widgets; + @Schema(description = "Maximum character count for a text field (/MaxLen); null when unset") + private Integer maxLength; + + @Schema( + description = + "Push button activation action as a spec string:" + + " 'reset', 'print', 'uri:' or 'submit:'") + private String buttonActionSpec; + /** * Coordinates for a single widget annotation (visual representation of the field). A field can * have multiple widgets if it appears on multiple pages. @@ -94,5 +103,12 @@ public class FormFieldWithCoordinates { @Schema(description = "Font size in PDF points") private Float fontSize; + + @Schema( + description = + "CropBox height in PDF points. Lets the frontend reverse the backend's" + + " Y-flip when sending new widget coordinates back for" + + " create/modify operations.") + private Float cropBoxHeight; } } diff --git a/app/common/src/main/java/stirling/software/common/util/FormFieldTypeSupport.java b/app/common/src/main/java/stirling/software/common/util/FormFieldTypeSupport.java index 1f971d8d9e..5d833290ec 100644 --- a/app/common/src/main/java/stirling/software/common/util/FormFieldTypeSupport.java +++ b/app/common/src/main/java/stirling/software/common/util/FormFieldTypeSupport.java @@ -3,14 +3,20 @@ package stirling.software.common.util; import java.io.IOException; import java.util.Arrays; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Optional; import java.util.function.Function; +import java.util.regex.Pattern; import java.util.stream.Collectors; import org.apache.pdfbox.cos.COSName; import org.apache.pdfbox.pdmodel.graphics.color.PDColor; import org.apache.pdfbox.pdmodel.graphics.color.PDDeviceRGB; +import org.apache.pdfbox.pdmodel.interactive.action.PDActionNamed; +import org.apache.pdfbox.pdmodel.interactive.action.PDActionResetForm; +import org.apache.pdfbox.pdmodel.interactive.action.PDActionSubmitForm; +import org.apache.pdfbox.pdmodel.interactive.action.PDActionURI; import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget; import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceCharacteristicsDictionary; import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; @@ -59,6 +65,24 @@ public enum FormFieldTypeSupport { List options) throws IOException { PDTextField textField = (PDTextField) field; + if (definition.fontSize() != null && definition.fontSize() > 0) { + textField.setDefaultAppearance("/Helv " + definition.fontSize() + " Tf 0 g"); + } + if (Boolean.TRUE.equals(definition.multiline())) { + textField.setMultiline(true); + } + // Comb field: evenly spaced character cells (e.g. SSN, phone). Requires + // a positive MaxLen and is mutually exclusive with multiline. + if (definition.maxLength() != null && definition.maxLength() > 0) { + textField.setMaxLen(definition.maxLength()); + if (!Boolean.TRUE.equals(definition.multiline())) { + try { + textField.setComb(true); + } catch (Exception e) { + log.debug("Unable to set comb flag: {}", e.getMessage()); + } + } + } String defaultValue = Optional.ofNullable(definition.defaultValue()).orElse(""); if (!defaultValue.isBlank()) { FormUtils.setTextValue(textField, defaultValue); @@ -272,14 +296,108 @@ public enum FormFieldTypeSupport { PDTerminalField createField(PDAcroForm acroForm) { return new PDSignatureField(acroForm); } + + @Override + boolean doesNotsupportsDefinitionCreation() { + return false; + } + // Empty signature placeholder: no value to apply (signed later by a sign tool). }, BUTTON("button", "pushButton", PDPushButton.class) { @Override PDTerminalField createField(PDAcroForm acroForm) { return new PDPushButton(acroForm); } + + @Override + boolean doesNotsupportsDefinitionCreation() { + return false; + } + + @Override + void applyNewFieldDefinition( + PDTerminalField field, + FormUtils.NewFormFieldDefinition definition, + List options) + throws IOException { + if (field.getWidgets().isEmpty()) { + return; + } + PDAnnotationWidget widget = field.getWidgets().get(0); + + // Visible caption (/MK /CA). + String caption = definition.label(); + if (caption == null || caption.isBlank()) { + caption = definition.name(); + } + if (caption != null && !caption.isBlank()) { + PDAppearanceCharacteristicsDictionary mk = widget.getAppearanceCharacteristics(); + if (mk == null) { + mk = new PDAppearanceCharacteristicsDictionary(widget.getCOSObject()); + widget.setAppearanceCharacteristics(mk); + } + mk.setNormalCaption(caption); + } + widget.setPrinted(true); + + applyButtonAction(widget, definition.buttonAction()); + } }; + /** + * Writes a push button's activation action from a "reset"/"print"/"uri:"/"submit:" spec, + * returning why it could not, or null on success. A blank spec clears the action. + */ + public static String applyButtonAction(PDAnnotationWidget widget, String action) { + if (action == null) { + return null; + } + if (action.isBlank()) { + // An explicit blank clears the action rather than leaving the old one behind. + widget.getCOSObject().removeItem(COSName.A); + return null; + } + String spec = action.trim(); + if (!ACTION_SPEC.matcher(spec).matches()) { + return "'" + action + "' is not a button action this editor understands"; + } + // The editor emits "uri:" the moment that kind is picked, before a URL is typed; an + // empty target is not yet an action, so clear rather than write an inert one. + int colon = spec.indexOf(':'); + if (colon >= 0 && spec.substring(colon + 1).isBlank()) { + widget.getCOSObject().removeItem(COSName.A); + return null; + } + try { + String lower = spec.toLowerCase(Locale.ROOT); + if (lower.equals("reset")) { + widget.getCOSObject().setItem(COSName.A, new PDActionResetForm().getCOSObject()); + } else if (lower.equals("print")) { + PDActionNamed named = new PDActionNamed(); + named.setN("Print"); + widget.getCOSObject().setItem(COSName.A, named.getCOSObject()); + } else if (lower.startsWith("uri:")) { + PDActionURI uri = new PDActionURI(); + uri.setURI(spec.substring(4)); + widget.getCOSObject().setItem(COSName.A, uri.getCOSObject()); + } else if (lower.startsWith("submit:")) { + PDActionSubmitForm submit = new PDActionSubmitForm(); + // Store the target URL on the action dictionary's /F entry. + submit.getCOSObject().setString(COSName.F, spec.substring(7)); + widget.getCOSObject().setItem(COSName.A, submit.getCOSObject()); + } + return null; + } catch (Exception e) { + log.debug("Unable to apply button action '{}': {}", action, e.getMessage()); + return e.getMessage(); + } + } + + /** The spec forms applyButtonAction understands; anything else is reported, not dropped. */ + private static final Pattern ACTION_SPEC = + Pattern.compile( + "^(reset|print|uri:.*|submit:.*)$", Pattern.CASE_INSENSITIVE | Pattern.DOTALL); + private static final Map BY_TYPE = Arrays.stream(values()) .collect( diff --git a/app/common/src/main/java/stirling/software/common/util/FormUtils.java b/app/common/src/main/java/stirling/software/common/util/FormUtils.java index a1862d379c..401b1eb1ac 100644 --- a/app/common/src/main/java/stirling/software/common/util/FormUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/FormUtils.java @@ -23,6 +23,7 @@ import org.apache.pdfbox.cos.COSArray; import org.apache.pdfbox.cos.COSBase; import org.apache.pdfbox.cos.COSDictionary; import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.cos.COSString; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDDocumentCatalog; import org.apache.pdfbox.pdmodel.PDPage; @@ -32,10 +33,12 @@ import org.apache.pdfbox.pdmodel.common.PDRectangle; import org.apache.pdfbox.pdmodel.font.PDFont; import org.apache.pdfbox.pdmodel.font.PDType1Font; import org.apache.pdfbox.pdmodel.font.Standard14Fonts; +import org.apache.pdfbox.pdmodel.graphics.color.PDColor; import org.apache.pdfbox.pdmodel.graphics.image.JPEGFactory; import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject; import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation; import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceCharacteristicsDictionary; import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceDictionary; import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceEntry; import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceStream; @@ -68,6 +71,12 @@ public class FormUtils { public final Set CHOICE_FIELD_TYPES = Set.of(FIELD_TYPE_COMBOBOX, FIELD_TYPE_LISTBOX, FIELD_TYPE_RADIO); + /** The reserved off-state name every toggle widget must carry an appearance for. */ + private final String OFF_STATE = "Off"; + + /** The on-state a checkbox gets when the definition supplies no export values. */ + private final String DEFAULT_CHECKBOX_ON_STATE = "Yes"; + /** * Threshold in PDF points for considering two widgets to be on the same line. Fields whose * y-coordinates differ by less than this value are sorted left-to-right by x-coordinate instead @@ -75,6 +84,9 @@ public class FormUtils { */ private static final float SAME_LINE_THRESHOLD_PT = 10.0f; + /** Below this, a rect change is a no-op and the existing /AP still maps exactly. */ + private static final float GEOMETRY_EPSILON_PT = 0.01f; + private static final Pattern HEX_UUID_PATTERN = Pattern.compile("^[0-9a-fA-F]{8}[0-9a-fA-F]{24,}$"); private static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s+"); @@ -236,6 +248,8 @@ public class FormUtils { .multiline(multiline) .tooltip(tooltip) .widgets(widgets.isEmpty() ? null : widgets) + .maxLength(extractMaxLength(terminalField)) + .buttonActionSpec(extractButtonAction(terminalField)) .build()); } @@ -300,7 +314,8 @@ public class FormUtils { findPageIndexForAnnotation(document, fieldDict, annotationPageMap); if (pageIndex >= 0) { PDRectangle rectangle = new PDRectangle(rectArray); - result.add( + addWidget( + result, createWidgetCoordinates( document, rectangle, pageIndex, null, field)); } else { @@ -373,7 +388,8 @@ public class FormUtils { } } - result.add( + addWidget( + result, createWidgetCoordinates( document, rectangle, pageIndex, exportValue, field)); } catch (Exception e) { @@ -387,6 +403,15 @@ public class FormUtils { return result; } + /** Unreadable geometry yields null, which must never reach the list the comparator walks. */ + private void addWidget( + List target, + FormFieldWithCoordinates.WidgetCoordinates widget) { + if (widget != null) { + target.add(widget); + } + } + private FormFieldWithCoordinates.WidgetCoordinates createWidgetCoordinates( PDDocument document, PDRectangle rectangle, @@ -424,13 +449,14 @@ public class FormUtils { float finalW = width; float finalH = height; - // Validate coordinates are within reasonable bounds - if (finalX < -1.0f - || finalY < -1.0f - || finalX > cropBox.getWidth() * 2 // Allow some horizontal overflow - || finalY > cropHeight + 1.0f) { + // Only nonsense is rejected. A widget outside the visible page is legal and must still be + // reported, or the field loses its geometry and the user cannot drag it back. + if (!Float.isFinite(finalX) + || !Float.isFinite(finalY) + || !Float.isFinite(finalW) + || !Float.isFinite(finalH)) { log.warn( - "Widget coordinates out of bounds for field '{}': page={}, x={}, y={}, w={}, h={}", + "Widget coordinates are not finite for field '{}': page={}, x={}, y={}, w={}, h={}", field.getFullyQualifiedName(), pageIndex, finalX, @@ -439,6 +465,12 @@ public class FormUtils { finalH); return null; } + if (finalX < 0 || finalY < 0 || finalX > cropBox.getWidth() || finalY > cropHeight) { + log.debug( + "Widget for field '{}' sits outside page {}", + field.getFullyQualifiedName(), + pageIndex); + } return FormFieldWithCoordinates.WidgetCoordinates.builder() .pageIndex(pageIndex) @@ -448,6 +480,7 @@ public class FormUtils { .height(finalH) .exportValue(exportValue) .fontSize(extractFontSize(field)) + .cropBoxHeight(cropHeight) .build(); } @@ -459,12 +492,40 @@ public class FormUtils { * * @param document PDF document to repair */ + /** + * PDFBox reads /Opt entries without following references, so an option stored indirectly - as + * real forms do - silently disappears. Resolving in place keeps the value and the reader + * honest. + */ + private void resolveIndirectChoiceOptions(PDAcroForm acroForm) { + try { + for (PDField field : acroForm.getFieldTree()) { + if (!(field instanceof PDChoice)) { + continue; + } + COSBase raw = field.getCOSObject().getDictionaryObject(COSName.OPT); + if (!(raw instanceof COSArray options)) { + continue; + } + for (int i = 0; i < options.size(); i++) { + COSBase resolved = options.getObject(i); + if (resolved != null && resolved != options.get(i)) { + options.set(i, resolved); + } + } + } + } catch (Exception e) { + log.debug("Could not resolve indirect choice options: {}", e.getMessage()); + } + } + public void repairMissingWidgetPageReferences(PDDocument document) { try { PDAcroForm acroForm = getAcroFormSafely(document); if (acroForm == null) { return; } + resolveIndirectChoiceOptions(acroForm); log.debug("Checking for widgets with missing page references..."); int repairedCount = 0; @@ -887,7 +948,9 @@ public class FormUtils { } PDFont helvetica = new PDType1Font(Standard14Fonts.FontName.HELVETICA); try { - // Map standard name used by many DAs + // Both spellings: a DA naming a font missing from /DR makes + // refreshAppearances throw for the whole form, not just that field. + dr.put(COSName.getPDFName("Helv"), helvetica); dr.put(COSName.getPDFName("Helvetica"), helvetica); } catch (Exception ignore) { try { @@ -991,7 +1054,7 @@ public class FormUtils { try { textField.setValue(value != null ? value : ""); return; - } catch (IOException initial) { + } catch (IOException | RuntimeException initial) { log.debug( "Primary fill failed for text field '{}': {}", textField.getFullyQualifiedName(), @@ -1277,6 +1340,11 @@ public class FormUtils { } return String.join(",", selected); } + // A signature has no text value; getValueAsString would emit a JVM identity hash that + // changes on every load, so the same document would describe itself differently. + if (field instanceof PDSignatureField) { + return null; + } return field.getValueAsString(); } catch (Exception e) { log.debug( @@ -1451,16 +1519,26 @@ public class FormUtils { return tooltipLabel; } - // Only check options for choice-type fields (combobox, listbox, radio) - if (CHOICE_FIELD_TYPES.contains(type) && options != null && !options.isEmpty()) { + // A clearly meaningful field name describes the whole field and matches + // the name shown in the editor, so it is the best label. + String humanized = cleanLabel(humanizeName(name)); + if (humanized != null && !looksGeneric(humanized)) { + return humanized; + } + + // An option only beats the name when the name is auto-generated; a human-typed + // one wins below, so a group named "Choice" does not read as its first option. + if (CHOICE_FIELD_TYPES.contains(type) + && options != null + && !options.isEmpty() + && looksAutoGenerated(name)) { String optionCandidate = cleanLabel(options.getFirst()); if (optionCandidate != null && !looksGeneric(optionCandidate)) { return optionCandidate; } } - String humanized = cleanLabel(humanizeName(name)); - if (humanized != null && !looksGeneric(humanized)) { + if (humanized != null && !looksAutoGenerated(name)) { return humanized; } @@ -1497,6 +1575,27 @@ public class FormUtils { || patterns.getOptionalTNumericPattern().matcher(simplified).matches(); } + /** + * True only for auto-generated identifiers ("Field_5", "t3", UUIDs). Unlike {@link + * #looksGeneric} it keeps human-typed placeholders like "Choice", which are usable labels. + */ + private boolean looksAutoGenerated(String value) { + if (value == null) return true; + + RegexPatternUtils patterns = RegexPatternUtils.getInstance(); + String simplified = patterns.getPunctuationPattern().matcher(value).replaceAll(" ").trim(); + if (simplified.isEmpty()) return true; + + String nospaces = WHITESPACE_PATTERN.matcher(simplified).replaceAll(""); + if (nospaces.length() >= 32 && HEX_UUID_PATTERN.matcher(nospaces).matches()) return true; + + return patterns.getPattern("^field(\\s*\\d+)?$", Pattern.CASE_INSENSITIVE) + .matcher(simplified) + .matches() + || patterns.getSimpleFormFieldPattern().matcher(simplified).matches() + || patterns.getOptionalTNumericPattern().matcher(simplified).matches(); + } + private String humanizeName(String name) { if (name == null) return null; @@ -1513,35 +1612,62 @@ public class FormUtils { public void modifyFormFields( PDDocument document, List modifications) { + modifyFormFields(document, modifications, null); + } + + public void modifyFormFields( + PDDocument document, + List modifications, + List skipped) { if (document == null || modifications == null || modifications.isEmpty()) return; PDAcroForm acroForm = getAcroFormSafely(document); if (acroForm == null) { log.warn("Cannot modify fields because the document has no AcroForm"); + for (ModifyFormFieldDefinition modification : modifications) { + if (modification != null) { + recordSkip( + skipped, + "modify", + modification.targetName(), + "the document has no form to modify"); + } + } return; } Set existingNames = collectExistingFieldNames(acroForm); for (ModifyFormFieldDefinition modification : modifications) { - if (modification == null || modification.targetName() == null) { + if (modification == null) { continue; } - String lookupName = modification.targetName().trim(); + String lookupName = + modification.targetName() == null ? "" : modification.targetName().trim(); if (lookupName.isEmpty()) { + recordSkip(skipped, "modify", null, "the request named no field to change"); + continue; + } + + String nameProblem = renameProblem(lookupName, modification.name()); + if (nameProblem != null) { + log.warn("Rejecting rename of '{}': {}", sanitizeForLog(lookupName), nameProblem); + recordSkip(skipped, "modify", lookupName, nameProblem); continue; } PDField originalField = locateField(acroForm, lookupName); if (originalField == null) { log.warn("No matching field '{}' found for modification", lookupName); + recordSkip(skipped, "modify", lookupName, "no field with that name exists"); continue; } List widgets = originalField.getWidgets(); if (widgets == null || widgets.isEmpty()) { log.warn("Field '{}' has no widgets; skipping modification", lookupName); + recordSkip(skipped, "modify", lookupName, "the field has nothing drawn on a page"); continue; } @@ -1552,6 +1678,8 @@ public class FormUtils { log.warn( "Unable to resolve widget page or rectangle for '{}'; skipping", lookupName); + recordSkip( + skipped, "modify", lookupName, "the field is not placed on a known page"); continue; } @@ -1564,6 +1692,11 @@ public class FormUtils { .getSupportedNewFieldTypes() .contains(resolvedType)) { log.warn("Unsupported target type '{}' for field '{}'", resolvedType, lookupName); + recordSkip( + skipped, + "modify", + lookupName, + "'" + abbreviate(resolvedType, 60) + "' is not a supported field type"); continue; } @@ -1571,13 +1704,22 @@ public class FormUtils { Optional.ofNullable(modification.name()) .map(String::trim) .filter(s -> !s.isEmpty()) + // The editor seeds the box with the qualified name, so a submission + // equal to it is not a rename; keep the field's own partial name. + .filter(name -> !name.equals(lookupName)) + .map(name -> leafName(lookupName, name)) .orElseGet(originalField::getPartialName); + String qualified = originalField.getFullyQualifiedName(); + // desiredName is a PARTIAL name but existingNames holds qualified ones, so compare + // under this field's own parent or siblings collide unnoticed. + String prefix = parentPrefix(qualified); + String reservedName = null; if (desiredName != null) { - existingNames.remove(originalField.getFullyQualifiedName()); - existingNames.remove(originalField.getPartialName()); - desiredName = generateUniqueFieldName(desiredName, existingNames); - existingNames.add(desiredName); + existingNames.remove(qualified); + desiredName = generateUniqueFieldName(desiredName, existingNames, prefix); + reservedName = prefix + desiredName; + existingNames.add(reservedName); } // Try to modify field in-place first for simple property changes @@ -1586,17 +1728,27 @@ public class FormUtils { if (!typeChanging) { try { - modifyFieldPropertiesInPlace(originalField, modification, desiredName); + modifyFieldPropertiesInPlace( + document, originalField, modification, desiredName, skipped); log.debug("Successfully modified field '{}' in-place", lookupName); continue; // Skip the remove-and-recreate process } catch (Exception e) { log.debug( "In-place modification failed for '{}', falling back to recreation: {}", - lookupName, + sanitizeForLog(lookupName), e.getMessage()); } } + // Recreation always builds a top-level field, so running it on a field nested under a + // parent would silently move it out of that parent and change its qualified name. + if (!prefix.isEmpty()) { + log.warn("Cannot recreate nested field '{}'; leaving it as it was", lookupName); + recordSkip(skipped, "modify", lookupName, refusalReason(typeChanging)); + releaseReservedName(existingNames, reservedName, qualified); + continue; + } + // For type changes or when in-place modification fails, use remove-and-recreate // But create the new field first to ensure success before removing the original NewFormFieldDefinition replacementDefinition = @@ -1613,16 +1765,31 @@ public class FormUtils { modification.multiSelect(), modification.options(), modification.defaultValue(), - modification.tooltip()); + modification.tooltip(), + modification.fontSize(), + modification.readOnly(), + modification.multiline(), + modification.maxLength(), + modification.buttonAction()); List sanitizedOptions = sanitizeOptions(modification.options()); - try { - FormFieldTypeSupport handler = FormFieldTypeSupport.forTypeName(resolvedType); - if (handler == null || handler.doesNotsupportsDefinitionCreation()) { - handler = FormFieldTypeSupport.TEXT; - } + FormFieldTypeSupport handler = FormFieldTypeSupport.forTypeName(resolvedType); + if (handler == null || handler.doesNotsupportsDefinitionCreation()) { + // Falling back to a text field here would silently retype the field and report + // success, so refuse instead and leave the original alone. + recordSkip( + skipped, + "modify", + lookupName, + "'" + + resolvedType + + "' cannot be rebuilt, so the field was left as it was"); + releaseReservedName(existingNames, reservedName, qualified); + continue; + } + try { // Create new field first - if this fails, original field is preserved createNewField( handler, @@ -1635,25 +1802,56 @@ public class FormUtils { removeFieldFromDocument(document, acroForm, originalField); + // A rebuilt toggle has no /AP yet, so without this it renders blank and cannot + // tick. + applyButtonAppearances( + document, + acroForm, + List.of(Map.entry(prefix + desiredName, replacementDefinition))); + log.debug( "Successfully replaced field '{}' with type '{}'", - lookupName, + sanitizeForLog(lookupName), resolvedType); } catch (Exception e) { log.warn( "Failed to modify form field '{}' to type '{}': {}", - lookupName, + sanitizeForLog(lookupName), resolvedType, e.getMessage(), e); + recordSkip(skipped, "modify", lookupName, readableFailure(e)); + releaseReservedName(existingNames, reservedName, qualified); } } ensureAppearances(acroForm); } + /** Nothing was applied, so give the field back its real name and drop the one we reserved. */ + private void releaseReservedName( + Set existingNames, String reservedName, String originalQualifiedName) { + if (reservedName != null) { + existingNames.remove(reservedName); + } + if (originalQualifiedName != null) { + existingNames.add(originalQualifiedName); + } + } + + /** Why the edit was refused, which is not always the type change that triggered the path. */ + private String refusalReason(boolean typeChanging) { + return typeChanging + ? "a field nested under a parent cannot have its type changed here" + : "this change needs the field rebuilt, which a nested field does not support"; + } + private void modifyFieldPropertiesInPlace( - PDField field, ModifyFormFieldDefinition modification, String newName) + PDDocument document, + PDField field, + ModifyFormFieldDefinition modification, + String newName, + List skipped) throws IOException { if (newName != null && !newName.equals(field.getPartialName())) { field.setPartialName(newName); @@ -1690,6 +1888,14 @@ public class FormUtils { if (modification.multiSelect() != null) { choiceField.setMultiSelect(modification.multiSelect()); } + } else if (modification.options() != null && !(field instanceof PDChoice)) { + // Only a choice field stores an option list, so say so rather than drop the edit and + // let the panel report it as saved. + recordSkip( + skipped, + "modify", + field.getFullyQualifiedName(), + "only dropdown and list fields have an editable option list"); } // Update tooltip on widgets @@ -1703,6 +1909,318 @@ public class FormUtils { } } } + + // Update read-only flag + if (modification.readOnly() != null) { + field.setReadOnly(modification.readOnly()); + } + + // Update multiline flag (text fields only) + if (modification.multiline() != null && field instanceof PDTextField tf) { + tf.setMultiline(modification.multiline()); + } + + // Update the activation action (push buttons only) + if (modification.buttonAction() != null && field instanceof PDPushButton) { + String actionProblem = null; + for (PDAnnotationWidget widget : field.getWidgets()) { + String problem = + FormFieldTypeSupport.applyButtonAction(widget, modification.buttonAction()); + if (problem != null && actionProblem == null) { + actionProblem = problem; + } + } + if (actionProblem != null) { + recordSkip(skipped, "modify", field.getFullyQualifiedName(), actionProblem); + } + } + + // Update comb / max length (text fields only). Zero clears it, since a null means + // "unchanged" and the editor otherwise has no way to remove an existing /MaxLen. + if (modification.maxLength() != null && field instanceof PDTextField combTf) { + int maxLength = modification.maxLength(); + if (maxLength > 0) { + combTf.setMaxLen(maxLength); + if (!combTf.isMultiline()) { + try { + combTf.setComb(true); + } catch (Exception ignore) { + // comb is best-effort + } + } + } else { + combTf.getCOSObject().removeItem(COSName.MAX_LEN); + try { + combTf.setComb(false); + } catch (Exception ignore) { + // comb is best-effort + } + } + } + + // Update font size (variable-text fields only: text/combo/list) + if (modification.fontSize() != null + && modification.fontSize() > 0 + && field instanceof PDVariableText vt) { + applyFontSizeToDefaultAppearance(vt, modification.fontSize()); + // Clear the cached appearance so ensureAppearances() regenerates it + // with the new font size; otherwise viewers keep the old glyph sizing. + removeWidgetAppearanceStreams(field); + } + + // Incoming coordinates are CropBox-relative and lower-left-origin, the reverse + // of what createWidgetCoordinates extracts. + if (modification.x() != null + || modification.y() != null + || modification.width() != null + || modification.height() != null + || modification.optionGap() != null + || modification.optionSize() != null) { + updateWidgetGeometry(document, field, modification, skipped); + } + } + + /** + * Moves/resizes a field's widgets. The rect describes widget 0; the rest shift by the same + * delta and keep their own size, so a radio group travels intact instead of being normalised. + */ + /** A size PDFBox would write as "Infinity" or a zero-area box makes the field unusable. */ + private static boolean unusableSize(Float value) { + return value != null && (!Float.isFinite(value) || value <= 0); + } + + /** The rectangle enclosing every widget, or null when none has one. */ + private static PDRectangle widgetBounds(List widgets) { + float minX = Float.MAX_VALUE; + float minY = Float.MAX_VALUE; + float maxX = -Float.MAX_VALUE; + float maxY = -Float.MAX_VALUE; + boolean any = false; + for (PDAnnotationWidget widget : widgets) { + PDRectangle r = widget.getRectangle(); + if (r == null) continue; + any = true; + minX = Math.min(minX, r.getLowerLeftX()); + minY = Math.min(minY, r.getLowerLeftY()); + maxX = Math.max(maxX, r.getUpperRightX()); + maxY = Math.max(maxY, r.getUpperRightY()); + } + return any ? new PDRectangle(minX, minY, maxX - minX, maxY - minY) : null; + } + + private void updateWidgetGeometry( + PDDocument document, + PDField field, + ModifyFormFieldDefinition modification, + List skipped) { + List widgets = field.getWidgets(); + if (widgets == null || widgets.isEmpty()) { + return; + } + if (unusableSize(modification.width()) || unusableSize(modification.height())) { + recordSkip( + skipped, + "modify", + field.getFullyQualifiedName(), + "a width and height above zero are required, so the size was left as it was"); + return; + } + if (modification.x() != null && !Float.isFinite(modification.x()) + || modification.y() != null && !Float.isFinite(modification.y())) { + recordSkip( + skipped, + "modify", + field.getFullyQualifiedName(), + "the position is not a usable number, so the field was left where it was"); + return; + } + PDAnnotationWidget anchor = widgets.get(0); + PDRectangle anchorRect = anchor.getRectangle(); + if (anchorRect == null) { + return; + } + + Map pageMap = buildAnnotationPageMap(document); + int anchorPage = determineWidgetPageIndex(document, anchor, pageMap); + float offX = 0; + float offY = 0; + if (anchorPage >= 0) { + PDRectangle cropBox = document.getPage(anchorPage).getCropBox(); + offX = cropBox.getLowerLeftX(); + offY = cropBox.getLowerLeftY(); + } + + float newX = + modification.x() != null ? modification.x() + offX : anchorRect.getLowerLeftX(); + float newY = + modification.y() != null ? modification.y() + offY : anchorRect.getLowerLeftY(); + float dx = newX - anchorRect.getLowerLeftX(); + float dy = newY - anchorRect.getLowerLeftY(); + Float newW = modification.width(); + Float newH = modification.height(); + + // Spacing and size are a property of the whole group, so an explicit change re-flows + // every option. Gated on those two: a plain drag sends widget 0's size, which would be + // mistaken for the group's height and collapse the stack. + if ((modification.optionGap() != null || modification.optionSize() != null) + && field instanceof PDRadioButton + && widgets.size() > 1) { + PDRectangle bounds = widgetBounds(widgets); + if (bounds != null) { + PDRectangle box = + new PDRectangle( + bounds.getLowerLeftX() + dx, + bounds.getLowerLeftY() + dy, + modification.width() != null + ? modification.width() + : bounds.getWidth(), + modification.height() != null + ? modification.height() + : bounds.getHeight()); + List reflowed = + radioOptionRects( + box, + widgets.size(), + modification.optionGap(), + modification.optionSize()); + List groupStates = currentWidgetOnStates((PDButton) field); + for (int i = 0; i < widgets.size(); i++) { + widgets.get(i).setRectangle(reflowed.get(i)); + rebuildWidgetAppearance(document, field, widgets.get(i), i, groupStates, true); + } + return; + } + } + + // Read the on-states off /AP /N before the strip: PDFBox derives a button's + // value vocabulary from those keys, so a guessed state orphans /V. + List onStates = + field instanceof PDButton button ? currentWidgetOnStates(button) : List.of(); + boolean isRadio = field instanceof PDRadioButton; + + int leftOffPage = 0; + for (int i = 0; i < widgets.size(); i++) { + PDAnnotationWidget widget = widgets.get(i); + PDRectangle rect = widget.getRectangle(); + if (rect == null) { + continue; + } + if (i > 0 && determineWidgetPageIndex(document, widget, pageMap) != anchorPage) { + // A delta measured in another page's user space means nothing here. + log.warn( + "Field '{}' widget {} sits on a different page; geometry left alone", + field.getFullyQualifiedName(), + i); + leftOffPage++; + continue; + } + // Only the widget the request describes takes the new size; the rest keep theirs. + float targetW = i == 0 && newW != null ? newW : rect.getWidth(); + float targetH = i == 0 && newH != null ? newH : rect.getHeight(); + boolean resized = + Math.abs(targetW - rect.getWidth()) > GEOMETRY_EPSILON_PT + || Math.abs(targetH - rect.getHeight()) > GEOMETRY_EPSILON_PT; + widget.setRectangle( + new PDRectangle( + rect.getLowerLeftX() + dx, + rect.getLowerLeftY() + dy, + targetW, + targetH)); + // A pure translation re-maps the same /AP onto the new /Rect unchanged, but a toggle + // with no /AP at all has no on-state vocabulary and must be given one regardless. + boolean toggle = field instanceof PDCheckBox || field instanceof PDRadioButton; + if (resized || (toggle && normalAppearanceOnState(widget) == null)) { + rebuildWidgetAppearance(document, field, widget, i, onStates, isRadio); + } + } + // One row per field, not per widget, so a split group cannot fill the report on its own. + if (leftOffPage > 0) { + recordSkip( + skipped, + "modify", + field.getFullyQualifiedName(), + leftOffPage + " widget(s) on another page were left where they were"); + } + } + + /** After a resize, rebuilds the appearance PDFBox cannot regenerate by itself. */ + private void rebuildWidgetAppearance( + PDDocument document, + PDField field, + PDAnnotationWidget widget, + int index, + List onStates, + boolean isRadio) { + if (field instanceof PDSignatureField) { + // PDFBox never rebuilds a signature appearance; dropping it blanks the field. + return; + } + COSName priorState = widget.getCOSObject().getCOSName(COSName.AS); + try { + if (field instanceof PDCheckBox || field instanceof PDRadioButton) { + // Drop the stale streams: viewers stretch an /AP built for the old BBox + // onto the new /Rect, so a resized toggle looks distorted. + widget.getCOSObject().removeItem(COSName.AP); + String onState = + index < onStates.size() ? onStates.get(index) : DEFAULT_CHECKBOX_ON_STATE; + applyToggleAppearance(document, widget, onState, isRadio); + // applyToggleAppearance parks the widget on Off; put the selection back. + if (priorState != null && !OFF_STATE.equals(priorState.getName())) { + widget.getCOSObject().setName(COSName.AS, onState); + } + } else if (field instanceof PDPushButton) { + // /D and /R still carry the old BBox and cannot be regenerated, so drop them + // rather than leave a stretched down-state; viewers fall back to /N. + PDAppearanceDictionary existing = widget.getAppearance(); + if (existing != null) { + existing.getCOSObject().removeItem(COSName.D); + existing.getCOSObject().removeItem(COSName.R); + } + applyPushButtonAppearance(document, widget); + } else { + // text/choice: refreshAppearances() rebuilds these from /DA in ensureAppearances(). + widget.getCOSObject().removeItem(COSName.AP); + } + } catch (Exception e) { + log.warn( + "Could not rebuild the appearance for '{}' widget {}: {}", + field.getFullyQualifiedName(), + index, + e.getMessage()); + } + } + + /** Rewrites only the size token in a variable-text field's /DA, keeping font and colour. */ + private void applyFontSizeToDefaultAppearance(PDVariableText field, float fontSize) { + String da = field.getDefaultAppearance(); + if (da != null && !da.isBlank()) { + // Replace the size token (the operand immediately before "Tf"). + String[] tokens = da.split("\\s+"); + boolean replaced = false; + for (int i = 0; i < tokens.length; i++) { + if ("Tf".equals(tokens[i]) && i > 0) { + tokens[i - 1] = String.valueOf(fontSize); + replaced = true; + break; + } + } + if (replaced) { + field.setDefaultAppearance(String.join(" ", tokens)); + return; + } + } + field.setDefaultAppearance("/Helv " + fontSize + " Tf 0 g"); + } + + /** Drops cached /AP appearance streams from every widget of a field. */ + private void removeWidgetAppearanceStreams(PDField field) { + List widgets = field.getWidgets(); + if (widgets == null) { + return; + } + for (PDAnnotationWidget widget : widgets) { + widget.getCOSObject().removeItem(COSName.AP); + } } private String fallbackLabelForType(String type, int typeIndex) { @@ -1830,12 +2348,700 @@ public class FormUtils { return -1; } + /** + * Adds fields, creating an AcroForm if absent. Definition coordinates are CropBox-relative and + * lower-left-origin (the inverse of {@link #createWidgetCoordinates}). + */ + public void addNewFields(PDDocument document, List definitions) + throws IOException { + addNewFields(document, definitions, null); + } + + /** + * A form with variable-text fields needs /DR and /DA; PDFBox refuses to set a value without + * them, and a PDF that never had a form has neither. + */ + private void ensureAcroFormDefaults(PDAcroForm acroForm) { + if (acroForm == null) return; + try { + PDResources dr = acroForm.getDefaultResources(); + if (dr == null) { + dr = new PDResources(); + acroForm.setDefaultResources(dr); + } + String resourceName = "Helv"; + COSName alias = dr.add(new PDType1Font(Standard14Fonts.FontName.HELVETICA)); + if (alias != null && alias.getName() != null && !alias.getName().isBlank()) { + resourceName = alias.getName(); + } + String da = acroForm.getDefaultAppearance(); + if (da == null || da.isBlank()) { + acroForm.setDefaultAppearance("/" + resourceName + " 12 Tf 0 g"); + } + } catch (Exception e) { + log.debug("Could not prepare AcroForm defaults: {}", e.getMessage()); + } + } + + public void addNewFields( + PDDocument document, + List definitions, + List skipped) + throws IOException { + if (document == null || definitions == null || definitions.isEmpty()) return; + // A page-less document has nowhere to put a widget; the clamp below cannot make it safe. + if (document.getNumberOfPages() == 0) { + log.warn("Cannot add form fields: document has no pages"); + for (NewFormFieldDefinition definition : definitions) { + if (definition != null) { + recordSkip(skipped, "add", definition.name(), "the document has no pages"); + } + } + return; + } + + PDAcroForm acroForm = getAcroFormSafely(document); + if (acroForm == null) { + // Create a new AcroForm for PDFs that don't have one yet + acroForm = new PDAcroForm(document); + document.getDocumentCatalog().setAcroForm(acroForm); + } + ensureAcroFormDefaults(acroForm); + + Set existingNames = collectExistingFieldNames(acroForm); + int pageCount = document.getNumberOfPages(); + // Buttons need their appearances built after creation; see applyButtonAppearances. + List> createdButtons = new ArrayList<>(); + + for (NewFormFieldDefinition definition : definitions) { + if (definition == null) continue; + + String nameProblem = invalidFieldNameReason(definition.name()); + if (nameProblem != null) { + log.warn("Rejecting new field: {}", nameProblem); + recordSkip(skipped, "add", definition.name(), nameProblem); + continue; + } + + String resolvedType = + Optional.ofNullable(definition.type()) + .map(FormUtils::normalizeFieldType) + .orElse(FIELD_TYPE_TEXT); + + int pageIdx = definition.pageIndex() != null ? definition.pageIndex() : 0; + if (pageIdx < 0 || pageIdx >= pageCount) { + log.warn( + "Page index {} out of range (0-{}); clamping to last page", + pageIdx, + pageCount - 1); + // Clamped, so the field IS created; not a dropped edit and not reported as one. + pageIdx = Math.max(0, pageCount - 1); + } + PDPage page; + try { + page = document.getPage(pageIdx); + } catch (RuntimeException e) { + // getNumberOfPages() reports the raw /Count, which a broken /Pages tree can + // overstate, so the page may still not be there. + recordSkip( + skipped, + "add", + definition.name(), + "page " + (pageIdx + 1) + " could not be read from this PDF"); + continue; + } + PDRectangle cropBox = page.getCropBox(); + + // CropBox-relative, lower-left-origin -> absolute PDF user space. + float x = (definition.x() != null ? definition.x() : 0f) + cropBox.getLowerLeftX(); + float y = (definition.y() != null ? definition.y() : 0f) + cropBox.getLowerLeftY(); + float w = definition.width() != null ? definition.width() : 150f; + float h = definition.height() != null ? definition.height() : 20f; + PDRectangle rectangle = new PDRectangle(x, y, w, h); + + String baseName = + Optional.ofNullable(definition.name()) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .orElse("field"); + String uniqueName = generateUniqueFieldName(baseName, existingNames); + existingNames.add(uniqueName); + + List options = sanitizeOptions(definition.options()); + + try { + if (FIELD_TYPE_RADIO.equals(resolvedType)) { + // Radio is a single field with one widget per option; it can't go + // through the single-widget createNewField path. + createRadioField(acroForm, page, rectangle, uniqueName, definition, options); + } else { + FormFieldTypeSupport handler = FormFieldTypeSupport.forTypeName(resolvedType); + if (handler == null || handler.doesNotsupportsDefinitionCreation()) { + // Quietly making it a text field reported success for a field the + // caller never asked for; say so instead. + recordSkip( + skipped, + "add", + uniqueName, + "'" + resolvedType + "' fields cannot be created"); + existingNames.remove(uniqueName); + continue; + } + createNewField( + handler, acroForm, page, rectangle, uniqueName, definition, options); + } + createdButtons.add(Map.entry(uniqueName, definition)); + } catch (Exception e) { + log.warn( + "Failed to create field '{}' of type '{}': {}", + sanitizeForLog(uniqueName), + resolvedType, + e.getMessage(), + e); + recordSkip(skipped, "add", uniqueName, readableFailure(e)); + } + } + + applyButtonAppearances(document, acroForm, createdButtons); + ensureAppearances(acroForm); + } + + /** + * {@link PDAcroForm#refreshAppearances()} never synthesizes /AP for the button family, and + * PDFBox reads a button's on-state from the /AP /N keys, so draw them then re-apply the value. + */ + private void applyButtonAppearances( + PDDocument document, + PDAcroForm acroForm, + List> created) { + for (Map.Entry entry : created) { + PDField field = acroForm.getField(entry.getKey()); + if (field instanceof PDPushButton) { + for (PDAnnotationWidget widget : field.getWidgets()) { + try { + applyPushButtonAppearance(document, widget); + } catch (Exception e) { + log.warn( + "Could not build an appearance for button '{}': {}", + entry.getKey(), + e.getMessage()); + } + } + continue; + } + if (!(field instanceof PDCheckBox) && !(field instanceof PDRadioButton)) { + continue; + } + boolean isRadio = field instanceof PDRadioButton; + List onStates = buttonOnStates((PDButton) field); + List widgets = field.getWidgets(); + for (int i = 0; i < widgets.size(); i++) { + String onState = i < onStates.size() ? onStates.get(i) : DEFAULT_CHECKBOX_ON_STATE; + try { + applyToggleAppearance(document, widgets.get(i), onState, isRadio); + } catch (Exception e) { + log.warn( + "Could not build an appearance for '{}' widget {}: {}", + entry.getKey(), + i, + e.getMessage()); + } + } + applyButtonDefault((PDButton) field, entry.getValue(), onStates); + } + } + + /** The on-state per widget: the export values when set, else the single checkbox state. */ + private List buttonOnStates(PDButton button) { + List exportValues = button.getExportValues(); + if (exportValues != null && !exportValues.isEmpty()) { + return exportValues; + } + return List.of(DEFAULT_CHECKBOX_ON_STATE); + } + + /** Each widget's live on-state, read from /AP /N before that dictionary is dropped. */ + private List currentWidgetOnStates(PDButton button) { + List exportValues = button.getExportValues(); + List widgets = button.getWidgets(); + // A checkbox's widgets all share one on-state, so /V names it however many there are. + // A radio's widgets each have their own, so /V identifies one and cannot stand in. + boolean sharedOnState = !(button instanceof PDRadioButton); + String fromValue = sharedOnState || widgets.size() == 1 ? nonOffValueName(button) : null; + List states = new ArrayList<>(widgets.size()); + for (int i = 0; i < widgets.size(); i++) { + String state = normalAppearanceOnState(widgets.get(i)); + if ((state == null || state.isEmpty()) + && exportValues != null + && i < exportValues.size()) { + state = sanitizePdfName(exportValues.get(i)); + } + if (state == null || state.isEmpty()) { + state = fromValue; + } + states.add(state == null || state.isEmpty() ? DEFAULT_CHECKBOX_ON_STATE : state); + } + return states; + } + + /** A button's current value when it names a real on-state, else null. */ + private String nonOffValueName(PDButton button) { + try { + // /V is inheritable, so walk up. A malformed PDF can point /Parent back at an + // ancestor, so track what we have seen rather than trusting the chain to end. + COSBase raw = null; + Set seen = Collections.newSetFromMap(new IdentityHashMap<>()); + COSDictionary d = button.getCOSObject(); + while (d != null && raw == null && seen.add(d)) { + raw = d.getDictionaryObject(COSName.V); + COSBase parent = d.getDictionaryObject(COSName.PARENT); + d = parent instanceof COSDictionary parentDict ? parentDict : null; + } + String name = + switch (raw) { + case COSName cosName -> cosName.getName(); + case COSString cosString -> cosString.getString(); + case null, default -> null; + }; + return name == null || name.isEmpty() || OFF_STATE.equals(name) ? null : name; + } catch (Exception e) { + log.debug("Could not read a button's value: {}", e.getMessage()); + return null; + } + } + + /** The first non-Off key of a widget's /AP /N sub-dictionary, or null. */ + private String normalAppearanceOnState(PDAnnotationWidget widget) { + try { + PDAppearanceDictionary appearance = widget.getAppearance(); + PDAppearanceEntry normal = appearance != null ? appearance.getNormalAppearance() : null; + if (normal == null || !normal.isSubDictionary()) { + return null; + } + for (COSName name : normal.getSubDictionary().keySet()) { + if (!OFF_STATE.equals(name.getName())) { + return name.getName(); + } + } + } catch (Exception e) { + log.debug("Could not read a widget's on-state: {}", e.getMessage()); + } + return null; + } + + /** Re-applies the definition's default now that the on-state keys exist to resolve it. */ + private void applyButtonDefault( + PDButton button, NewFormFieldDefinition definition, List onStates) { + try { + if (button instanceof PDCheckBox checkBox) { + if (isChecked(definition.defaultValue())) { + checkBox.check(); + } else { + checkBox.unCheck(); + } + return; + } + String requested = definition.defaultValue(); + if (requested == null || requested.isBlank()) { + return; + } + // The widget states are sanitized PDF names, so match the raw request against those. + String match = + onStates.stream() + .filter( + state -> + state.equals(requested) + || state.equalsIgnoreCase( + sanitizePdfName(requested))) + .findFirst() + .orElse(null); + if (match != null) { + button.setValue(match); + } + } catch (Exception e) { + log.debug( + "Could not apply default value for '{}': {}", + button.getPartialName(), + e.getMessage()); + } + } + + /** + * Builds a toggle's two-state /AP /N from drawing primitives, so no font resource is needed. + */ + private void applyToggleAppearance( + PDDocument document, PDAnnotationWidget widget, String onState, boolean isRadio) + throws IOException { + PDRectangle rect = widget.getRectangle(); + if (rect == null || rect.getWidth() <= 0 || rect.getHeight() <= 0) { + return; + } + float w = rect.getWidth(); + float h = rect.getHeight(); + PDRectangle bbox = new PDRectangle(w, h); + + PDAppearanceDictionary appearance = new PDAppearanceDictionary(); + COSDictionary normalStates = new COSDictionary(); + normalStates.setItem( + COSName.getPDFName(OFF_STATE), + toggleStream(document, bbox, false, false).getCOSObject()); + normalStates.setItem( + COSName.getPDFName(onState), + toggleStream(document, bbox, true, isRadio).getCOSObject()); + appearance.getCOSObject().setItem(COSName.N, normalStates); + widget.setAppearance(appearance); + // Until a value selects it, the widget shows the Off appearance. + widget.getCOSObject().setName(COSName.AS, OFF_STATE); + } + + private PDAppearanceStream toggleStream( + PDDocument document, PDRectangle bbox, boolean on, boolean isRadio) throws IOException { + PDAppearanceStream stream = new PDAppearanceStream(document); + stream.setBBox(bbox); + stream.setResources(new PDResources()); + + float w = bbox.getWidth(); + float h = bbox.getHeight(); + float inset = Math.min(w, h) * 0.1f; + try (PDPageContentStream content = + new PDPageContentStream( + document, stream, stream.getStream().createOutputStream())) { + content.setStrokingColor(0f, 0f, 0f); + content.setNonStrokingColor(0f, 0f, 0f); + content.setLineWidth(Math.max(0.5f, Math.min(w, h) * 0.06f)); + if (isRadio) { + drawCircle(content, w / 2, h / 2, Math.min(w, h) / 2 - inset); + content.stroke(); + if (on) { + drawCircle(content, w / 2, h / 2, Math.min(w, h) / 4 - inset / 2); + content.fill(); + } + } else { + content.addRect(inset, inset, w - 2 * inset, h - 2 * inset); + content.stroke(); + if (on) { + content.moveTo(w * 0.25f, h * 0.5f); + content.lineTo(w * 0.45f, h * 0.28f); + content.lineTo(w * 0.78f, h * 0.72f); + content.stroke(); + } + } + } + return stream; + } + + /** + * Draws a push button's single {@code /AP /N} stream from its {@code /MK} characteristics. + * PDFBox never synthesizes one, so without this a push button has no appearance at all. + */ + private void applyPushButtonAppearance(PDDocument document, PDAnnotationWidget widget) + throws IOException { + PDRectangle rect = widget.getRectangle(); + if (rect == null || rect.getWidth() <= 0 || rect.getHeight() <= 0) { + return; + } + float w = rect.getWidth(); + float h = rect.getHeight(); + + PDAppearanceStream stream = new PDAppearanceStream(document); + stream.setBBox(new PDRectangle(w, h)); + stream.setResources(new PDResources()); + + PDAppearanceCharacteristicsDictionary mk = widget.getAppearanceCharacteristics(); + String caption = mk != null ? mk.getNormalCaption() : null; + // Honour the authored /MK colours; a hardcoded grey would restyle an existing button. + float[] background = mkColour(mk == null ? null : mk.getBackground(), 0.85f); + float[] border = mkColour(mk == null ? null : mk.getBorderColour(), 0f); + PDFont font = new PDType1Font(Standard14Fonts.FontName.HELVETICA); + float fontSize = Math.min(12f, h * 0.6f); + + try (PDPageContentStream content = + new PDPageContentStream( + document, stream, stream.getStream().createOutputStream())) { + content.setNonStrokingColor(background[0], background[1], background[2]); + content.addRect(0, 0, w, h); + content.fill(); + content.setStrokingColor(border[0], border[1], border[2]); + content.setLineWidth(1f); + content.addRect(0.5f, 0.5f, w - 1f, h - 1f); + content.stroke(); + if (caption != null && !caption.isBlank()) { + try { + float textWidth = font.getStringWidth(caption) / 1000f * fontSize; + content.beginText(); + content.setFont(font, fontSize); + content.setNonStrokingColor(0f, 0f, 0f); + content.newLineAtOffset( + Math.max(2f, (w - textWidth) / 2f), + (h - fontSize) / 2f + fontSize * 0.2f); + content.showText(caption); + content.endText(); + } catch (Exception e) { + // Unencodable caption: keep the frame, drop the text. + log.debug("Could not draw button caption '{}': {}", caption, e.getMessage()); + } + } + } + + // Reuse the existing dictionary so an authored /D or /R is not collateral damage. + PDAppearanceDictionary appearance = widget.getAppearance(); + if (appearance == null) { + appearance = new PDAppearanceDictionary(); + widget.setAppearance(appearance); + } + appearance.setNormalAppearance(stream); + // A push button has no value, so no /AS. + widget.getCOSObject().removeItem(COSName.AS); + } + + /** A /MK colour array as RGB, falling back to a grey level when absent or unsupported. */ + private float[] mkColour(PDColor colour, float fallback) { + float[] rgb = {fallback, fallback, fallback}; + if (colour == null) { + return rgb; + } + float[] components; + try { + components = colour.getComponents(); + } catch (Exception e) { + log.debug("Unreadable /MK colour: {}", e.getMessage()); + return rgb; + } + if (components.length == 3) { + rgb = components.clone(); + } else if (components.length == 1) { + rgb = new float[] {components[0], components[0], components[0]}; + } else if (components.length == 4) { + // CMYK to RGB, good enough for button chrome. + float k = components[3]; + rgb = + new float[] { + (1 - components[0]) * (1 - k), + (1 - components[1]) * (1 - k), + (1 - components[2]) * (1 - k) + }; + } + // PDPageContentStream rejects anything outside 0..1, and a throw here loses the appearance. + for (int i = 0; i < rgb.length; i++) { + rgb[i] = Math.min(1f, Math.max(0f, rgb[i])); + } + return rgb; + } + + /** A circle from four Bezier arcs; PDF has no primitive for one. */ + private void drawCircle(PDPageContentStream content, float cx, float cy, float r) + throws IOException { + if (r <= 0) { + return; + } + float k = r * 0.5523f; + content.moveTo(cx - r, cy); + content.curveTo(cx - r, cy + k, cx - k, cy + r, cx, cy + r); + content.curveTo(cx + k, cy + r, cx + r, cy + k, cx + r, cy); + content.curveTo(cx + r, cy - k, cx + k, cy - r, cx, cy - r); + content.curveTo(cx - k, cy - r, cx - r, cy - k, cx - r, cy); + content.closePath(); + } + + /** + * One widget per option stacked below {@code baseRect}, each keyed by its sanitized export + * value so the group behaves as a single selectable field. + */ + /** + * Per-option widget rects laid out INSIDE the drawn box, which is the group's total extent. + * Stacking outside it made a three-option group three times taller than what was drawn. + */ + public static List radioOptionRects( + PDRectangle box, int count, Float gapOverride, Float sizeOverride) { + List rects = new ArrayList<>(); + int n = Math.max(1, count); + float h = box.getHeight(); + float slot = h / n; + + float size; + if (sizeOverride != null && sizeOverride > 0f) { + size = sizeOverride; + } else if (gapOverride != null && gapOverride >= 0f) { + size = (h - (n - 1) * gapOverride) / n; + } else { + // A quarter of each slot is breathing room, so the stack fills the drawn height. + size = slot * 0.75f; + } + // Square keeps the circle round; a wide box becomes a left-aligned column. + size = Math.max(1f, Math.min(size, box.getWidth())); + + float gap; + if (gapOverride != null && gapOverride >= 0f) { + gap = gapOverride; + } else { + gap = n > 1 ? Math.max(0f, (h - n * size) / (n - 1)) : 0f; + } + + float top = box.getLowerLeftY() + h; + for (int i = 0; i < n; i++) { + float y = top - (i + 1) * size - i * gap; + rects.add(new PDRectangle(box.getLowerLeftX(), y, size, size)); + } + return rects; + } + + private void createRadioField( + PDAcroForm acroForm, + PDPage page, + PDRectangle baseRect, + String name, + NewFormFieldDefinition definition, + List options) + throws IOException { + + List values = (options == null || options.isEmpty()) ? List.of("1", "2") : options; + + PDRadioButton radio = new PDRadioButton(acroForm); + radio.setPartialName(name); + if (definition.label() != null && !definition.label().isBlank()) { + try { + radio.setAlternateFieldName(definition.label()); + } catch (Exception ignore) { + // alternate name is best-effort + } + } + radio.setRequired(Boolean.TRUE.equals(definition.required())); + if (Boolean.TRUE.equals(definition.readOnly())) { + radio.setReadOnly(true); + } + + List optionRects = + radioOptionRects( + baseRect, values.size(), definition.optionGap(), definition.optionSize()); + + List widgets = new ArrayList<>(); + List exportValues = new ArrayList<>(); + Set usedStates = new HashSet<>(); + for (int i = 0; i < values.size(); i++) { + String onState = sanitizeOnState(values.get(i), i, usedStates); + exportValues.add(onState); + + PDRectangle rect = optionRects.get(i); + + PDAnnotationWidget widget = new PDAnnotationWidget(); + widget.setRectangle(rect); + widget.setPage(page); + widget.getCOSObject().setItem(COSName.P, page.getCOSObject()); + widget.getCOSObject().setItem(COSName.TYPE, COSName.getPDFName("Annot")); + widget.getCOSObject().setItem(COSName.SUBTYPE, COSName.getPDFName("Widget")); + widget.setParent(radio); + // The widget's appearance state is "Off" until the group value selects it. + widget.getCOSObject().setName(COSName.AS, OFF_STATE); + widgets.add(widget); + + List annotations = page.getAnnotations(); + if (annotations == null) { + annotations = new ArrayList<>(); + page.setAnnotations(annotations); + } + annotations.add(widget); + } + + radio.setWidgets(widgets); + try { + radio.setExportValues(exportValues); + } catch (Exception e) { + log.debug("Unable to set radio export values for '{}': {}", name, e.getMessage()); + } + + String defaultValue = definition.defaultValue(); + if (defaultValue != null + && !defaultValue.isBlank() + && exportValues.contains(defaultValue)) { + try { + radio.setValue(defaultValue); + } catch (Exception e) { + log.debug("Unable to set radio default '{}': {}", defaultValue, e.getMessage()); + } + } + + acroForm.getFields().add(radio); + } + + /** Builds a unique, PDF-name-safe "on" state for a radio widget. */ + private String sanitizeOnState(String raw, int index, Set used) { + String base = + Optional.ofNullable(raw) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .map(FormUtils::sanitizePdfName) + .orElse("Option" + (index + 1)); + if (OFF_STATE.equalsIgnoreCase(base)) { + base = "Option" + (index + 1); + } + String candidate = base; + int suffix = 1; + while (!used.add(candidate)) { + candidate = base + "_" + suffix++; + } + return candidate; + } + + /** Reduces a label to characters that are safe inside a PDF name. */ + private static String sanitizePdfName(String raw) { + return raw == null ? "" : raw.trim().replaceAll("[^A-Za-z0-9_-]", "_"); + } + + /** Modify, then delete, then add, so generated names dedupe against the surviving set. */ + public void applyFieldEdits( + PDDocument document, + List adds, + List modifies, + List deletes) + throws IOException { + applyFieldEdits(document, adds, modifies, deletes, null); + } + + /** + * As above, but records every operation that could not be applied into {@code skipped} so the + * caller can report "3 of 4" instead of a bare success. + */ + public void applyFieldEdits( + PDDocument document, + List adds, + List modifies, + List deletes, + List skipped) + throws IOException { + if (document == null) return; + if (modifies != null && !modifies.isEmpty()) { + modifyFormFields(document, modifies, skipped); + } + if (deletes != null && !deletes.isEmpty()) { + deleteFormFields(document, deletes, skipped); + } + if (adds != null && !adds.isEmpty()) { + addNewFields(document, adds, skipped); + } + } + + /** Adds an entry to a skip list that may be absent, so call sites stay one-liners. */ + private void recordSkip( + List skipped, String operation, String target, String reason) { + if (skipped != null) { + skipped.add(new SkippedFieldEdit(operation, target, reason)); + } + } + public void deleteFormFields(PDDocument document, List fieldNames) { + deleteFormFields(document, fieldNames, null); + } + + public void deleteFormFields( + PDDocument document, List fieldNames, List skipped) { if (document == null || fieldNames == null || fieldNames.isEmpty()) return; PDAcroForm acroForm = getAcroFormSafely(document); if (acroForm == null) { log.warn("Cannot delete fields because the document has no AcroForm"); + for (String name : fieldNames) { + recordSkip(skipped, "delete", name, "the document has no form to delete from"); + } return; } @@ -1847,6 +3053,7 @@ public class FormUtils { PDField field = locateField(acroForm, name.trim()); if (field == null) { log.warn("No matching field '{}' found for deletion", name); + recordSkip(skipped, "delete", name, "no field with that name exists"); continue; } @@ -2138,17 +3345,122 @@ public class FormUtils { return Collections.emptySet(); } Set existing = new HashSet<>(); + // Group (non-terminal) names occupy the namespace too, so a new field must not be + // allowed to take one; omitting them hides a whole class of collision. for (PDField field : acroForm.getFieldTree()) { - if (field instanceof PDTerminalField) { - String fqn = field.getFullyQualifiedName(); - if (fqn != null && !fqn.isEmpty()) { - existing.add(fqn); - } + String fqn = field.getFullyQualifiedName(); + if (fqn != null && !fqn.isEmpty()) { + existing.add(fqn); } } return existing; } + /** A text field's /MaxLen, or null when unset so the editor shows an empty box. */ + private Integer extractMaxLength(PDField field) { + if (field instanceof PDTextField textField) { + int maxLen = textField.getMaxLen(); + return maxLen > 0 ? maxLen : null; + } + return null; + } + + /** Reads a push button's action back into the same spec string the editor sends. */ + private String extractButtonAction(PDField field) { + if (!(field instanceof PDPushButton)) { + return null; + } + for (PDAnnotationWidget widget : field.getWidgets()) { + COSBase raw = widget.getCOSObject().getDictionaryObject(COSName.A); + if (!(raw instanceof COSDictionary action)) { + continue; + } + String subtype = action.getNameAsString(COSName.S); + if ("ResetForm".equals(subtype)) { + return "reset"; + } + if ("Named".equals(subtype)) { + return "Print".equalsIgnoreCase(action.getNameAsString(COSName.N)) ? "print" : null; + } + if ("URI".equals(subtype)) { + return "uri:" + Optional.ofNullable(action.getString(COSName.URI)).orElse(""); + } + if ("SubmitForm".equals(subtype)) { + return "submit:" + Optional.ofNullable(action.getString(COSName.F)).orElse(""); + } + } + return null; + } + + /** The parent prefix of a qualified name, including the trailing dot, or "" if top level. */ + private String parentPrefix(String qualifiedName) { + int dot = qualifiedName == null ? -1 : qualifiedName.lastIndexOf('.'); + return dot < 0 ? "" : qualifiedName.substring(0, dot + 1); + } + + /** + * The partial name a rename should set. Only a new name under the target's own parent may be + * qualified; anything else is used verbatim so it cannot silently re-parent the field. + */ + private String leafName(String targetName, String newName) { + String prefix = parentPrefix(targetName); + return !prefix.isEmpty() && newName.startsWith(prefix) + ? newName.substring(prefix.length()) + : newName; + } + + /** + * Why the rename is impossible, or null. An unchanged name is not a rename, so a field nested + * under a parent is not rejected for the period in its qualified name. + */ + public String renameProblem(String targetName, String newName) { + if (newName == null || newName.equals(targetName)) { + return null; + } + // A nested field's box shows "Parent.Child", so renaming the leaf under the same + // parent is legitimate; only the leaf has to be a storable partial name. + String trimmed = newName.trim(); + String leaf = leafName(targetName, trimmed); + if (!trimmed.isEmpty() && leaf.isBlank()) { + return "Field name '" + newName + "' has no name after the parent prefix."; + } + return invalidFieldNameReason(leaf); + } + + /** + * Why {@code name} is unusable as a field name, or null when it is fine. AcroForm reserves the + * period as the parent/child separator, so PDFBox rejects it outright in a partial name. + */ + public String invalidFieldNameReason(String name) { + if (name == null || name.isBlank()) { + return null; + } + if (name.chars().anyMatch(Character::isISOControl)) { + // A line break in a name would also forge a second line in every log it reaches. + return "Field name cannot contain line breaks or control characters."; + } + if (name.indexOf('.') >= 0) { + return "Field name '" + + sanitizeForLog(name) + + "' cannot contain a period. PDF forms use '.' to separate a parent field" + + " from its children."; + } + return null; + } + + /** + * A caller-supplied string made safe to log. Without this a name containing CR/LF writes an + * extra, attacker-chosen line into the log file (CWE-117). + */ + public static String sanitizeForLog(String value) { + if (value == null) { + return null; + } + StringBuilder out = new StringBuilder(value.length()); + value.chars().forEach(c -> out.append(Character.isISOControl(c) ? ' ' : (char) c)); + return out.toString(); + } + private PDField locateField(PDAcroForm acroForm, String name) { if (acroForm == null || name == null) { return null; @@ -2185,20 +3497,26 @@ public class FormUtils { } private String generateUniqueFieldName(String baseName, Set existingNames) { - String sanitized = + return generateUniqueFieldName(baseName, existingNames, ""); + } + + /** + * A partial name no sibling already uses. {@code qualifiedPrefix} is prepended only for the + * collision check, because {@code existingNames} holds fully qualified names. + */ + private String generateUniqueFieldName( + String baseName, Set existingNames, String qualifiedPrefix) { + // Trimmed, not sanitized: callers must reject bad names first via invalidFieldNameReason. + String trimmed = Optional.ofNullable(baseName) .map(String::trim) .filter(s -> !s.isEmpty()) .orElse("field"); - StringBuilder candidateBuilder = new StringBuilder(sanitized); - String candidate = candidateBuilder.toString(); + String candidate = trimmed; int counter = 1; - - while (existingNames.contains(candidate)) { - candidateBuilder.setLength(0); - candidateBuilder.append(sanitized).append("_").append(counter); - candidate = candidateBuilder.toString(); + while (existingNames.contains(qualifiedPrefix + candidate)) { + candidate = trimmed + "_" + counter; counter++; } @@ -2235,9 +3553,29 @@ public class FormUtils { } } field.setRequired(Boolean.TRUE.equals(definition.required())); + if (Boolean.TRUE.equals(definition.readOnly())) { + field.setReadOnly(true); + } - PDAnnotationWidget widget = - existingWidget != null ? existingWidget : new PDAnnotationWidget(); + // A terminal field with no /Kids shares its dictionary with one merged widget. + // A separately built widget is not linked via /Kids, so its /Rect is lost on save. + boolean reuseFieldDict; + PDAnnotationWidget widget; + if (existingWidget != null) { + widget = existingWidget; + reuseFieldDict = false; + } else { + List current = field.getWidgets(); + if (current != null && !current.isEmpty()) { + widget = current.get(0); + } else { + widget = new PDAnnotationWidget(); + } + reuseFieldDict = widget.getCOSObject() == field.getCOSObject(); + // Make sure the shared dictionary is recognised as a widget annotation. + widget.getCOSObject().setItem(COSName.TYPE, COSName.getPDFName("Annot")); + widget.getCOSObject().setItem(COSName.SUBTYPE, COSName.getPDFName("Widget")); + } // Ensure rectangle is valid and set before any appearance-related operations // please note removal of this might cause **subtle** issues @@ -2250,10 +3588,10 @@ public class FormUtils { } widget.setRectangle(validRectangle); widget.setPage(page); - - if (existingWidget == null) { - widget.setPrinted(true); - } + // Explicitly set the /P entry so the widget keeps a valid page reference + // after save/reload (some viewers rely on it to resolve the widget page). + widget.getCOSObject().setItem(COSName.P, page.getCOSObject()); + widget.setPrinted(true); if (definition.tooltip() != null && !definition.tooltip().isBlank()) { widget.getCOSObject().setString(COSName.TU, definition.tooltip()); @@ -2265,13 +3603,25 @@ public class FormUtils { } } - field.getWidgets().add(widget); - widget.setParent(field); + // Only link a SEPARATE widget into the field; the merged widget IS the + // field dictionary and is already its own widget. + if (!reuseFieldDict) { + List widgets = new ArrayList<>(field.getWidgets()); + if (!widgets.contains(widget)) { + widgets.add(widget); + field.setWidgets(widgets); + } + widget.setParent(field); + } List annotations = page.getAnnotations(); if (annotations == null) { - page.getAnnotations().add(widget); - } else if (!annotations.contains(widget)) { + // page.getAnnotations() can return null; calling it again and adding + // would NPE. Initialise the list and attach it to the page first. + annotations = new ArrayList<>(); + page.setAnnotations(annotations); + } + if (!annotations.contains(widget)) { annotations.add(widget); } acroForm.getFields().add(field); @@ -2399,7 +3749,60 @@ public class FormUtils { Boolean multiSelect, List options, String defaultValue, - String tooltip) {} + String tooltip, + Float fontSize, + Boolean readOnly, + Boolean multiline, + Integer maxLength, + String buttonAction, + /** Gap between radio options in points; derived from the drawn box when null. */ + Float optionGap, + /** Radio option size in points; derived from the drawn box when null. */ + Float optionSize) { + + /** The shape before option layout was tunable; both extras default to derived. */ + public NewFormFieldDefinition( + String name, + String label, + String type, + Integer pageIndex, + Float x, + Float y, + Float width, + Float height, + Boolean required, + Boolean multiSelect, + List options, + String defaultValue, + String tooltip, + Float fontSize, + Boolean readOnly, + Boolean multiline, + Integer maxLength, + String buttonAction) { + this( + name, + label, + type, + pageIndex, + x, + y, + width, + height, + required, + multiSelect, + options, + defaultValue, + tooltip, + fontSize, + readOnly, + multiline, + maxLength, + buttonAction, + null, + null); + } + } @JsonInclude(JsonInclude.Include.NON_NULL) public record ModifyFormFieldDefinition( @@ -2407,11 +3810,120 @@ public class FormUtils { String name, String label, String type, + Integer pageIndex, + Float x, + Float y, + Float width, + Float height, Boolean required, Boolean multiSelect, List options, String defaultValue, - String tooltip) {} + String tooltip, + Float fontSize, + Boolean readOnly, + Boolean multiline, + Integer maxLength, + String buttonAction, + /** Gap between radio options in points; leaves the existing layout alone when null. */ + Float optionGap, + /** Radio option size in points; leaves the existing layout alone when null. */ + Float optionSize) { + + /** The shape before option layout was tunable; both extras default to unchanged. */ + public ModifyFormFieldDefinition( + String targetName, + String name, + String label, + String type, + Integer pageIndex, + Float x, + Float y, + Float width, + Float height, + Boolean required, + Boolean multiSelect, + List options, + String defaultValue, + String tooltip, + Float fontSize, + Boolean readOnly, + Boolean multiline, + Integer maxLength, + String buttonAction) { + this( + targetName, + name, + label, + type, + pageIndex, + x, + y, + width, + height, + required, + multiSelect, + options, + defaultValue, + tooltip, + fontSize, + readOnly, + multiline, + maxLength, + buttonAction, + null, + null); + } + } + + /** A mixed batch of field edits applied in one request via {@link #applyFieldEdits}. */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public record FieldEditBatch( + List add, + List modify, + List delete) {} + + /** + * One requested edit the document could not take, so the caller can report "3 of 4" rather than + * a bare success. {@code operation} is "add", "modify" or "delete". + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + /** + * Turns a library failure into something a person can act on. Raw messages like "/DR is a + * required entry" name PDF internals the user has never heard of. + */ + public static String readableFailure(Throwable failure) { + String raw = failure == null ? null : failure.getMessage(); + if (raw == null || raw.isBlank()) { + return "this PDF would not accept the change"; + } + String lower = raw.toLowerCase(java.util.Locale.ROOT); + if (lower.contains("/dr") || lower.contains("default resources")) { + return "this PDF's form has no font settings, so the field could not be styled"; + } + if (lower.contains("font") && lower.contains("not")) { + return "the font this field asks for is not embedded in the PDF"; + } + if (lower.contains("encrypt") || lower.contains("password")) { + return "the PDF is protected, so its form cannot be changed"; + } + if (lower.contains("read-only") || lower.contains("readonly")) { + return "the field is read-only in this PDF"; + } + // Anything unrecognised stays vague rather than leaking internals at the user. + log.debug("Unmapped form edit failure: {}", raw); + return "this PDF would not accept the change"; + } + + /** Skip reasons travel in a response header, so an echoed value cannot be unbounded. */ + public static String abbreviate(String value, int max) { + if (value == null || value.length() <= max) { + return value; + } + return value.substring(0, max) + "..."; + } + + public record SkippedFieldEdit(String operation, String target, String reason) {} @JsonInclude(JsonInclude.Include.NON_NULL) public record FormFieldInfo( @@ -2433,19 +3945,25 @@ public class FormUtils { static final class FieldCoordinateComparator implements Comparator { private static int firstWidgetPageIndex(FormFieldWithCoordinates f) { - return (f.getWidgets() != null && !f.getWidgets().isEmpty()) + return (f.getWidgets() != null + && !f.getWidgets().isEmpty() + && f.getWidgets().getFirst() != null) ? f.getWidgets().getFirst().getPageIndex() : -1; } private static float firstWidgetY(FormFieldWithCoordinates f) { - return (f.getWidgets() != null && !f.getWidgets().isEmpty()) + return (f.getWidgets() != null + && !f.getWidgets().isEmpty() + && f.getWidgets().getFirst() != null) ? f.getWidgets().getFirst().getY() : 0; } private static float firstWidgetX(FormFieldWithCoordinates f) { - return (f.getWidgets() != null && !f.getWidgets().isEmpty()) + return (f.getWidgets() != null + && !f.getWidgets().isEmpty() + && f.getWidgets().getFirst() != null) ? f.getWidgets().getFirst().getX() : 0; } diff --git a/app/common/src/test/java/stirling/software/common/util/ChoiceOptionRoundTripTest.java b/app/common/src/test/java/stirling/software/common/util/ChoiceOptionRoundTripTest.java new file mode 100644 index 0000000000..b5312576e4 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/ChoiceOptionRoundTripTest.java @@ -0,0 +1,116 @@ +package stirling.software.common.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.List; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.cos.COSArray; +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.cos.COSObject; +import org.apache.pdfbox.cos.COSObjectKey; +import org.apache.pdfbox.cos.COSString; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.interactive.digitalsignature.PDSignature; +import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; +import org.apache.pdfbox.pdmodel.interactive.form.PDComboBox; +import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** Pins how a choice field's options survive a save, which real forms rely on. */ +class ChoiceOptionRoundTripTest { + + private static PDComboBox combo(PDDocument document, List options) throws IOException { + document.addPage(new PDPage(PDRectangle.A4)); + PDAcroForm form = new PDAcroForm(document); + document.getDocumentCatalog().setAcroForm(form); + PDComboBox field = new PDComboBox(form); + field.setPartialName("state"); + field.setOptions(options); + form.getFields().add(field); + return field; + } + + @Test + @DisplayName("a whitespace-only option survives a load, save and reload") + void whitespaceOptionSurvivesRoundTrip() throws IOException { + List options = List.of(" ", "Alabama", "Alaska"); + + byte[] first; + try (PDDocument document = new PDDocument(); + ByteArrayOutputStream out = new ByteArrayOutputStream()) { + combo(document, options); + document.save(out); + first = out.toByteArray(); + } + // The real path edits a document loaded from bytes, not one built in memory. + byte[] saved; + try (PDDocument loaded = Loader.loadPDF(first); + ByteArrayOutputStream out = new ByteArrayOutputStream()) { + loaded.save(out); + saved = out.toByteArray(); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDComboBox reread = + (PDComboBox) reloaded.getDocumentCatalog().getAcroForm(null).getField("state"); + assertEquals( + options, + reread.getOptionsExportValues(), + "an option must not vanish because the writer made it indirect"); + } + } + + @Test + @DisplayName("an option stored as an indirect reference is still reported") + void indirectOptionIsStillReported() throws IOException { + try (PDDocument document = new PDDocument()) { + PDComboBox field = combo(document, List.of(" ", "Alabama")); + + // Real forms reference option strings indirectly; the reader must follow the reference. + COSArray options = new COSArray(); + options.add(new COSObject(new COSString(" "), new COSObjectKey(629, 0))); + options.add(new COSString("Alabama")); + field.getCOSObject().setItem(COSName.OPT, options); + + // Every read path runs this repair first, which is where the reference is followed. + FormUtils.repairMissingWidgetPageReferences(document); + + assertEquals( + List.of(" ", "Alabama"), + field.getOptionsExportValues(), + "an indirectly stored option must not be dropped"); + } + } + + @Test + @DisplayName("a signature field reports no value rather than a JVM identity hash") + void signatureValueIsNotAnIdentityHash() throws IOException { + try (PDDocument document = new PDDocument()) { + document.addPage(new PDPage(PDRectangle.A4)); + PDAcroForm form = new PDAcroForm(document); + document.getDocumentCatalog().setAcroForm(form); + PDSignatureField signature = new PDSignatureField(form); + signature.setPartialName("approval"); + // Only a field that actually holds a signature hits getValueAsString's toString(). + signature.setValue(new PDSignature()); + form.getFields().add(signature); + + List fields = FormUtils.extractFormFields(document); + + FormUtils.FormFieldInfo field = + fields.stream() + .filter(f -> "approval".equals(f.name())) + .findFirst() + .orElseThrow(); + // An identity hash differs per load, so the same document would describe itself twice. + assertNull(field.value(), "a signature has no text value"); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/DeepFieldTreeTest.java b/app/common/src/test/java/stirling/software/common/util/DeepFieldTreeTest.java new file mode 100644 index 0000000000..4c28952ff0 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/DeepFieldTreeTest.java @@ -0,0 +1,62 @@ +package stirling.software.common.util; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.cos.COSArray; +import org.apache.pdfbox.cos.COSDictionary; +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** A hostile or corrupt form must fail as a rejected request, never as a crashed thread. */ +class DeepFieldTreeTest { + + private static byte[] chainOfKids(int depth) throws IOException { + try (PDDocument document = new PDDocument(); + ByteArrayOutputStream out = new ByteArrayOutputStream()) { + document.addPage(new PDPage(PDRectangle.A4)); + PDAcroForm form = new PDAcroForm(document); + document.getDocumentCatalog().setAcroForm(form); + + COSDictionary root = new COSDictionary(); + root.setString(COSName.T, "n0"); + COSDictionary cursor = root; + for (int i = 1; i < depth; i++) { + COSDictionary kid = new COSDictionary(); + kid.setString(COSName.T, "n" + i); + kid.setItem(COSName.PARENT, cursor); + COSArray kids = new COSArray(); + kids.add(kid); + cursor.setItem(COSName.KIDS, kids); + cursor = kid; + } + cursor.setItem(COSName.FT, COSName.getPDFName("Tx")); + + COSArray fields = new COSArray(); + fields.add(root); + form.getCOSObject().setItem(COSName.FIELDS, fields); + document.save(out); + return out.toByteArray(); + } + } + + @Test + @DisplayName("a deeply nested field tree extracts without overflowing the stack") + void deepKidsChainDoesNotOverflow() throws IOException { + // 2000 is as deep as PDFBox's own writer can build here; beyond that the overflow is in + // the writer, not in extraction, so it is not something a read endpoint would hit. + byte[] pdf = chainOfKids(2000); + + try (PDDocument document = Loader.loadPDF(pdf)) { + assertDoesNotThrow(() -> FormUtils.extractFormFieldsWithCoordinates(document)); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/FormEditSafetyTest.java b/app/common/src/test/java/stirling/software/common/util/FormEditSafetyTest.java new file mode 100644 index 0000000000..1f213c15ba --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/FormEditSafetyTest.java @@ -0,0 +1,201 @@ +package stirling.software.common.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; +import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox; +import org.apache.pdfbox.pdmodel.interactive.form.PDField; +import org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import stirling.software.common.model.FormFieldWithCoordinates; + +/** An edit that cannot be honoured must be refused and reported, never silently reshaped. */ +class FormEditSafetyTest { + + private static PDDocument formWith(String name, String type) throws IOException { + PDDocument document = new PDDocument(); + document.addPage(new PDPage(PDRectangle.A4)); + document.getDocumentCatalog().setAcroForm(new PDAcroForm(document)); + FormUtils.addNewFields( + document, + List.of( + new FormUtils.NewFormFieldDefinition( + name, + null, + type, + 0, + 50f, + 700f, + 200f, + 20f, + null, + null, + type.equals("radio") ? List.of("a", "b") : null, + null, + null, + null, + null, + null, + null, + null))); + return document; + } + + private static FormUtils.ModifyFormFieldDefinition modify( + String target, String type, Float width, Float height) { + // Order: targetName, name, label, type, pageIndex, x, y, width, height, then the rest. + return new FormUtils.ModifyFormFieldDefinition( + target, null, null, type, null, null, null, width, height, null, null, null, null, + null, null, null, null, null, null); + } + + @Test + @DisplayName("a type that cannot be rebuilt is refused instead of becoming a text field") + void unrebuildableTypeIsRefused() throws IOException { + try (PDDocument document = formWith("choice", "text")) { + List skipped = new ArrayList<>(); + + FormUtils.modifyFormFields( + document, List.of(modify("choice", "radio", null, null)), skipped); + + PDField field = document.getDocumentCatalog().getAcroForm(null).getField("choice"); + assertFalse(skipped.isEmpty(), "the refusal must be reported to the caller"); + assertFalse( + field instanceof PDRadioButton, + "it could not become a radio, so it must not claim to be one"); + assertEquals( + "text", + FormUtils.extractFormFields(document).getFirst().type(), + "the original field must survive untouched rather than be retyped"); + } + } + + @Test + @DisplayName("a field rebuilt as a checkbox gets an appearance so it can be ticked") + void rebuiltCheckboxIsUsable() throws IOException { + try (PDDocument document = formWith("agree", "text")) { + List skipped = new ArrayList<>(); + + FormUtils.modifyFormFields( + document, List.of(modify("agree", "checkbox", null, null)), skipped); + + PDField field = document.getDocumentCatalog().getAcroForm(null).getField("agree"); + assertTrue(field instanceof PDCheckBox, "the rebuild should have produced a checkbox"); + assertNotNull( + field.getWidgets().getFirst().getAppearance(), + "without an appearance the checkbox renders blank and cannot be ticked"); + } + } + + @Test + @DisplayName("a size of zero or infinity is refused rather than written into the page") + void unusableSizeIsRefused() throws IOException { + for (Float bad : new Float[] {0f, -5f, Float.POSITIVE_INFINITY, Float.NaN}) { + try (PDDocument document = formWith("box", "text")) { + List skipped = new ArrayList<>(); + + FormUtils.modifyFormFields( + document, List.of(modify("box", null, bad, 20f)), skipped); + + PDRectangle rect = + document.getDocumentCatalog() + .getAcroForm(null) + .getField("box") + .getWidgets() + .getFirst() + .getRectangle(); + assertFalse(skipped.isEmpty(), "a refused resize must be reported: width " + bad); + assertEquals( + 200f, + rect.getWidth(), + 0.01f, + "the original size must survive: width " + bad); + } + } + } + + @Test + @DisplayName("a widget off the page still reports its geometry instead of dropping the field") + void offPageWidgetKeepsItsGeometry() throws IOException { + try (PDDocument document = formWith("stray", "text")) { + PDField field = document.getDocumentCatalog().getAcroForm(null).getField("stray"); + // Above the page top: legal PDF, and the user needs the coordinates to drag it back. + field.getWidgets().getFirst().setRectangle(new PDRectangle(50f, 2000f, 200f, 20f)); + + List fields = + FormUtils.extractFormFieldsWithCoordinates(document); + + FormFieldWithCoordinates stray = + fields.stream() + .filter(f -> "stray".equals(f.getName())) + .findFirst() + .orElseThrow(); + assertNotNull(stray.getWidgets(), "the field must keep its widget list"); + assertFalse(stray.getWidgets().isEmpty(), "the off-page widget must still be reported"); + assertNotNull(stray.getWidgets().getFirst(), "a null entry would crash the overlay"); + } + } + + private static FormUtils.ModifyFormFieldDefinition withValue(String target, String value) { + return new FormUtils.ModifyFormFieldDefinition( + target, null, null, null, null, null, null, null, null, null, null, null, value, + null, null, null, null, null, null); + } + + private static FormUtils.ModifyFormFieldDefinition withOptions( + String target, List options) { + return new FormUtils.ModifyFormFieldDefinition( + target, null, null, null, null, null, null, null, null, null, null, options, null, + null, null, null, null, null, null); + } + + @Test + @DisplayName("a value a radio group cannot hold does not destroy the group") + void badRadioValueLeavesTheGroupIntact() throws IOException { + try (PDDocument document = formWith("plan", "radio")) { + List skipped = new ArrayList<>(); + + FormUtils.modifyFormFields( + document, List.of(withValue("plan", "not-an-option")), skipped); + + PDField field = document.getDocumentCatalog().getAcroForm(null).getField("plan"); + assertTrue( + field instanceof PDRadioButton, + "a rejected value must not turn the group into another kind of field"); + assertEquals( + 2, + field.getWidgets().size(), + "the group's options must survive a rejected value"); + assertFalse(skipped.isEmpty(), "the caller must be told the value was not applied"); + } + } + + @Test + @DisplayName("editing a radio group's options is either applied or reported, never ignored") + void radioOptionEditIsNotSilentlyDropped() throws IOException { + try (PDDocument document = formWith("plan", "radio")) { + List skipped = new ArrayList<>(); + + FormUtils.modifyFormFields( + document, List.of(withOptions("plan", List.of("a", "b", "c"))), skipped); + + PDField field = document.getDocumentCatalog().getAcroForm(null).getField("plan"); + boolean applied = field.getWidgets().size() == 3; + assertTrue( + applied || !skipped.isEmpty(), + "a change the UI shows as saved must either happen or be reported as skipped"); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/FormFieldNameSafetyTest.java b/app/common/src/test/java/stirling/software/common/util/FormFieldNameSafetyTest.java new file mode 100644 index 0000000000..c1b0c806e1 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/FormFieldNameSafetyTest.java @@ -0,0 +1,61 @@ +package stirling.software.common.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +/** + * A field name is caller-supplied and reaches several loggers. A line break in one would forge a + * second log line (CWE-117), so names carrying control characters are refused outright. + */ +class FormFieldNameSafetyTest { + + @Test + void aNameWithCrLfIsRefused() { + String forged = "evil\r\n2026-01-01 00:00:00 ERROR admin login from 1.2.3.4"; + String reason = FormUtils.invalidFieldNameReason(forged); + assertNotNull(reason, "a name containing CR/LF must be refused"); + assertFalse(reason.contains("\n"), "the refusal itself must not carry a line break"); + assertFalse(reason.contains("\r"), "the refusal itself must not carry a carriage return"); + } + + @Test + void otherControlCharactersAreRefusedToo() { + assertNotNull(FormUtils.invalidFieldNameReason("tab\there")); + assertNotNull(FormUtils.invalidFieldNameReason("null\u0000byte")); + } + + @Test + void ordinaryNamesStillPass() { + assertNull(FormUtils.invalidFieldNameReason("Full Name")); + assertNull(FormUtils.invalidFieldNameReason("weird/[]{}")); + assertNull(FormUtils.invalidFieldNameReason("Mr Smith")); + } + + @Test + void thePeriodRefusalDoesNotEchoControlCharacters() { + // Both problems at once: the period branch must not leak the raw name into a log line. + String reason = FormUtils.invalidFieldNameReason("Customer.Name\r\nFORGED"); + assertNotNull(reason); + assertFalse(reason.contains("\r") || reason.contains("\n"), "no raw line break: " + reason); + } + + @Test + void sanitizeForLogFlattensControlCharacters() { + assertEquals("a b", FormUtils.sanitizeForLog("a\nb")); + assertEquals("a b", FormUtils.sanitizeForLog("a\rb")); + assertEquals("plain", FormUtils.sanitizeForLog("plain")); + assertNull(FormUtils.sanitizeForLog(null)); + } + + @Test + void aPeriodIsStillRefusedWithTheOffendingCharacterNamed() { + String reason = FormUtils.invalidFieldNameReason("Customer.Name"); + assertNotNull(reason); + assertTrue(reason.contains("period"), "the message should name the problem: " + reason); + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/FormFieldTypeSupportTest.java b/app/common/src/test/java/stirling/software/common/util/FormFieldTypeSupportTest.java index 771ce89659..6924764a65 100644 --- a/app/common/src/test/java/stirling/software/common/util/FormFieldTypeSupportTest.java +++ b/app/common/src/test/java/stirling/software/common/util/FormFieldTypeSupportTest.java @@ -130,13 +130,15 @@ class FormFieldTypeSupportTest { } @Test - void doesNotSupportsDefinitionCreation_signatureReturnsTrue() { - assertTrue(FormFieldTypeSupport.SIGNATURE.doesNotsupportsDefinitionCreation()); + void doesNotSupportsDefinitionCreation_signatureReturnsFalse() { + // Signature placeholders are now creatable via the editor. + assertFalse(FormFieldTypeSupport.SIGNATURE.doesNotsupportsDefinitionCreation()); } @Test - void doesNotSupportsDefinitionCreation_buttonReturnsTrue() { - assertTrue(FormFieldTypeSupport.BUTTON.doesNotsupportsDefinitionCreation()); + void doesNotSupportsDefinitionCreation_buttonReturnsFalse() { + // Push buttons (with actions) are now creatable via the editor. + assertFalse(FormFieldTypeSupport.BUTTON.doesNotsupportsDefinitionCreation()); } @Test diff --git a/app/common/src/test/java/stirling/software/common/util/FormUtilsEditRegressionTest.java b/app/common/src/test/java/stirling/software/common/util/FormUtilsEditRegressionTest.java new file mode 100644 index 0000000000..408380b790 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/FormUtilsEditRegressionTest.java @@ -0,0 +1,911 @@ +package stirling.software.common.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDResources; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceDictionary; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceEntry; +import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; +import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox; +import org.apache.pdfbox.pdmodel.interactive.form.PDField; +import org.apache.pdfbox.pdmodel.interactive.form.PDNonTerminalField; +import org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton; +import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField; +import org.apache.pdfbox.pdmodel.interactive.form.PDTerminalField; +import org.apache.pdfbox.pdmodel.interactive.form.PDTextField; +import org.junit.jupiter.api.Test; + +/** + * Guards the form editor against silently destroying a field it edits. Assertions run after a + * save/reload cycle because only the serialised document reflects what a viewer sees. + */ +class FormUtilsEditRegressionTest { + + private static PDAcroForm setupForm(PDDocument document) { + document.addPage(new PDPage(PDRectangle.A4)); + PDAcroForm acroForm = new PDAcroForm(document); + acroForm.setDefaultResources(new PDResources()); + document.getDocumentCatalog().setAcroForm(acroForm); + return acroForm; + } + + private static byte[] save(PDDocument document) throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + document.save(baos); + return baos.toByteArray(); + } + + private static FormUtils.NewFormFieldDefinition newField( + String type, String name, float x, float y, float w, float h, List options) { + return new FormUtils.NewFormFieldDefinition( + name, null, type, 0, x, y, w, h, null, null, options, null, null, null, null, null, + null, null); + } + + /** Moves a field to a rect; null width/height leave the size alone. */ + private static FormUtils.ModifyFormFieldDefinition moveTo( + String target, float x, float y, Float w, Float h) { + return new FormUtils.ModifyFormFieldDefinition( + target, null, null, null, 0, x, y, w, h, null, null, null, null, null, null, null, + null, null, null); + } + + private static PDRectangle firstWidgetRect(PDAcroForm acroForm, String name) { + PDField field = acroForm.getField(name); + assertNotNull(field, "field '" + name + "' should exist"); + return field.getWidgets().get(0).getRectangle(); + } + + /** The /AP /N state names on a widget. */ + private static Set normalStateNames(PDAnnotationWidget widget) { + PDAppearanceDictionary appearance = widget.getAppearance(); + assertNotNull(appearance, "widget should have an /AP dictionary"); + PDAppearanceEntry normal = appearance.getNormalAppearance(); + assertNotNull(normal, "widget should have an /AP /N entry"); + assertTrue(normal.isSubDictionary(), "a toggle needs per-state appearances"); + return normal.getSubDictionary().keySet().stream() + .map(COSName::getName) + .collect(Collectors.toSet()); + } + + @Test + void movingCheckboxKeepsItFillable() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document); + FormUtils.addNewFields( + document, List.of(newField("checkbox", "agree", 50, 700, 14, 14, null))); + FormUtils.modifyFormFields(document, List.of(moveTo("agree", 200f, 400f, null, null))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + PDField field = acroForm.getField("agree"); + assertTrue(field instanceof PDCheckBox, "'agree' should still be a checkbox"); + assertFalse( + ((PDCheckBox) field).getOnValue().isEmpty(), + "a moved checkbox must keep an on-state, or it can never be ticked again"); + assertTrue( + normalStateNames(field.getWidgets().get(0)).size() >= 2, + "both /AP /N states must survive a move"); + PDRectangle rect = firstWidgetRect(acroForm, "agree"); + assertEquals(200f, rect.getLowerLeftX(), 0.5f); + assertEquals(400f, rect.getLowerLeftY(), 0.5f); + } + } + + @Test + void resizingCheckboxRebuildsAppearanceAtTheNewSize() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document); + FormUtils.addNewFields( + document, List.of(newField("checkbox", "agree", 50, 700, 14, 14, null))); + FormUtils.modifyFormFields(document, List.of(moveTo("agree", 50f, 700f, 28f, 28f))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + PDCheckBox checkBox = (PDCheckBox) acroForm.getField("agree"); + assertFalse( + checkBox.getOnValue().isEmpty(), "a resized checkbox must keep its on-state"); + PDAnnotationWidget widget = checkBox.getWidgets().get(0); + assertTrue(normalStateNames(widget).size() >= 2, "both /AP /N states must be rebuilt"); + PDRectangle bbox = + widget.getAppearance() + .getNormalAppearance() + .getSubDictionary() + .get(COSName.getPDFName(checkBox.getOnValue())) + .getBBox(); + assertEquals(28f, bbox.getWidth(), 0.5f, "the rebuilt /AP must match the new size"); + } + } + + /** applyToggleAppearance parks /AS on Off, so a resize must put the selection back. */ + @Test + void resizingCheckboxKeepsItChecked() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document); + FormUtils.addNewFields( + document, List.of(newField("checkbox", "agree", 50, 700, 14, 14, null))); + PDAcroForm form = document.getDocumentCatalog().getAcroForm(null); + ((PDCheckBox) form.getField("agree")).check(); + FormUtils.modifyFormFields(document, List.of(moveTo("agree", 50f, 700f, 30f, 30f))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + assertTrue( + ((PDCheckBox) acroForm.getField("agree")).isChecked(), + "a resize must not silently untick the box"); + } + } + + /** Only widgets.get(0) used to move, so a radio group lost every option but the first. */ + @Test + void movingRadioGroupMovesEveryOption() throws IOException { + byte[] saved; + float[] before = new float[6]; + try (PDDocument document = new PDDocument()) { + setupForm(document); + FormUtils.addNewFields( + document, + List.of(newField("radio", "choice", 50, 700, 14, 14, List.of("A", "B", "C")))); + PDAcroForm form = document.getDocumentCatalog().getAcroForm(null); + List widgets = form.getField("choice").getWidgets(); + assertEquals(3, widgets.size(), "the fixture needs three option widgets"); + for (int i = 0; i < 3; i++) { + before[i * 2] = widgets.get(i).getRectangle().getLowerLeftX(); + before[i * 2 + 1] = widgets.get(i).getRectangle().getLowerLeftY(); + } + FormUtils.modifyFormFields(document, List.of(moveTo("choice", 90f, 670f, null, null))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + PDField field = acroForm.getField("choice"); + assertTrue(field instanceof PDRadioButton, "'choice' should still be a radio group"); + List widgets = field.getWidgets(); + assertEquals(3, widgets.size(), "no option may be left behind"); + float dx = 90f - before[0]; + float dy = 670f - before[1]; + for (int i = 0; i < 3; i++) { + PDRectangle rect = widgets.get(i).getRectangle(); + assertEquals( + before[i * 2] + dx, + rect.getLowerLeftX(), + 0.5f, + "option " + i + " should shift by the same delta"); + assertEquals(before[i * 2 + 1] + dy, rect.getLowerLeftY(), 0.5f); + } + } + } + + /** A signature's /AP is the signature, so it must never be dropped. */ + @Test + void movingSignatureKeepsItsAppearance() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document); + FormUtils.addNewFields( + document, List.of(newField("signature", "sig", 50, 700, 120, 40, null))); + FormUtils.modifyFormFields(document, List.of(moveTo("sig", 60f, 600f, 140f, 50f))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + assertTrue( + acroForm.getField("sig") instanceof PDSignatureField, + "'sig' should still be a signature"); + assertEquals(60f, firstWidgetRect(acroForm, "sig").getLowerLeftX(), 0.5f); + } + } + + @Test + void invalidFieldNameReason_rejectsPeriodAndAllowsTheRest() { + String reason = FormUtils.invalidFieldNameReason("Customer.Name"); + assertNotNull(reason, "a period must be refused, not silently dropped"); + assertTrue(reason.contains("period"), "the message should name the offending character"); + assertNull(FormUtils.invalidFieldNameReason("Has Space")); + assertNull(FormUtils.invalidFieldNameReason("weird/[]{}")); + assertNull(FormUtils.invalidFieldNameReason(null)); + } + + /** Dropped operations used to log a warning and still report success. */ + @Test + void applyFieldEdits_reportsEveryDroppedOperation() throws IOException { + try (PDDocument document = new PDDocument()) { + setupForm(document); + FormUtils.addNewFields( + document, List.of(newField("text", "present", 50, 700, 200, 20, null))); + + List skipped = new ArrayList<>(); + FormUtils.applyFieldEdits( + document, + List.of(newField("text", "Bad.Name", 50, 600, 100, 20, null)), + List.of(moveTo("ghost", 10f, 10f, null, null)), + List.of("alsoGhost"), + skipped); + + assertEquals(3, skipped.size(), "each dropped operation should be reported"); + assertTrue(skipped.stream().anyMatch(s -> "add".equals(s.operation()))); + assertTrue(skipped.stream().anyMatch(s -> "modify".equals(s.operation()))); + assertTrue(skipped.stream().anyMatch(s -> "delete".equals(s.operation()))); + assertNotNull( + document.getDocumentCatalog().getAcroForm(null).getField("present"), + "the rest of the document must still be applied"); + } + } + + /** A clean batch must not report anything, or the UI would cry wolf on every save. */ + @Test + void applyFieldEdits_reportsNothingWhenEverythingApplies() throws IOException { + try (PDDocument document = new PDDocument()) { + setupForm(document); + List skipped = new ArrayList<>(); + FormUtils.applyFieldEdits( + document, + List.of(newField("text", "fine", 50, 700, 200, 20, null)), + List.of(), + List.of(), + skipped); + assertTrue(skipped.isEmpty(), "a fully applied batch reports no skips"); + } + } + + /** A drag must not normalise other options to the dragged widget's size. */ + @Test + void movingRadioGroupKeepsEachOptionsOwnSize() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document); + FormUtils.addNewFields( + document, + List.of(newField("radio", "choice", 50, 700, 20, 20, List.of("A", "B")))); + PDAcroForm form = document.getDocumentCatalog().getAcroForm(null); + List widgets = form.getField("choice").getWidgets(); + // Hand-authored groups legitimately have option boxes of differing size. + PDRectangle second = widgets.get(1).getRectangle(); + widgets.get(1) + .setRectangle( + new PDRectangle( + second.getLowerLeftX(), second.getLowerLeftY(), 40f, 40f)); + FormUtils.modifyFormFields(document, List.of(moveTo("choice", 90f, 700f, 20f, 20f))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + List widgets = acroForm.getField("choice").getWidgets(); + assertEquals( + 40f, + widgets.get(1).getRectangle().getWidth(), + 0.5f, + "a pure drag must not shrink the other options"); + assertEquals(90f, widgets.get(0).getRectangle().getLowerLeftX(), 0.5f); + } + } + + /** With no /AP and no /Opt the on-state must come from /V, not the invented "Yes". */ + @Test + void resizingCheckboxWithoutAppearanceKeepsItsExportValue() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document); + FormUtils.addNewFields( + document, List.of(newField("checkbox", "agree", 50, 700, 14, 14, null))); + PDAcroForm form = document.getDocumentCatalog().getAcroForm(null); + PDCheckBox box = (PDCheckBox) form.getField("agree"); + // A NeedAppearances form exported by Word/LibreOffice looks exactly like this. + box.getWidgets().get(0).getCOSObject().removeItem(COSName.AP); + box.getCOSObject().setItem(COSName.V, COSName.getPDFName("On")); + FormUtils.modifyFormFields(document, List.of(moveTo("agree", 50f, 700f, 30f, 30f))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + PDCheckBox box = (PDCheckBox) acroForm.getField("agree"); + assertEquals( + "On", + box.getOnValue(), + "the export value must survive; inventing 'Yes' would orphan /V"); + assertTrue(box.isChecked(), "the box was ticked and must stay ticked"); + } + } + + /** Renaming to the same qualified name is not a rename, so a nested field is not rejected. */ + @Test + void renameProblem_ignoresAnUnchangedQualifiedName() { + assertNull( + FormUtils.renameProblem("Customer.Name", "Customer.Name"), + "a field standing still must not be rejected for its parent's period"); + assertNull(FormUtils.renameProblem("plain", null)); + assertNotNull( + FormUtils.renameProblem("plain", "New.Name"), + "an actual rename introducing a period must still be refused"); + } + + /** A nested field whose name box was left at its qualified name must still be modified. */ + @Test + void modifyingNestedFieldKeepsWorkingWhenNameIsUntouched() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + PDAcroForm form = setupForm(document); + FormUtils.addNewFields( + document, List.of(newField("text", "Name", 50, 700, 200, 20, null))); + // Re-parent it so its qualified name legitimately contains a period. + PDNonTerminalField parent = new PDNonTerminalField(form); + parent.setPartialName("Customer"); + PDField child = form.getField("Name"); + parent.setChildren(List.of(child)); + child.getCOSObject().setItem(COSName.PARENT, parent.getCOSObject()); + form.setFields(List.of(parent)); + + FormUtils.ModifyFormFieldDefinition mod = + new FormUtils.ModifyFormFieldDefinition( + "Customer.Name", + "Customer.Name", + null, + null, + 0, + 90f, + 600f, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null); + List skipped = new ArrayList<>(); + FormUtils.modifyFormFields(document, List.of(mod), skipped); + assertTrue( + skipped.isEmpty(), "an untouched qualified name is not a rename: " + skipped); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + PDField field = acroForm.getField("Customer.Name"); + assertNotNull(field, "the nested field must survive the edit"); + assertEquals(90f, field.getWidgets().get(0).getRectangle().getLowerLeftX(), 0.5f); + } + } + + /** Zero clears /MaxLen; null means unchanged, so it could never be removed otherwise. */ + @Test + void maxLengthZeroClearsTheCombSetting() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document); + FormUtils.addNewFields( + document, + List.of( + new FormUtils.NewFormFieldDefinition( + "code", null, "text", 0, 50f, 700f, 200f, 20f, null, null, null, + null, null, null, null, null, 8, null))); + PDAcroForm form = document.getDocumentCatalog().getAcroForm(null); + assertEquals(8, ((PDTextField) form.getField("code")).getMaxLen()); + + FormUtils.ModifyFormFieldDefinition clear = + new FormUtils.ModifyFormFieldDefinition( + "code", null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, 0, null); + FormUtils.modifyFormFields(document, List.of(clear)); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + assertEquals( + -1, + ((PDTextField) acroForm.getField("code")).getMaxLen(), + "/MaxLen should be gone, not merely zero"); + } + } + + /** An unrecognised button action must be reported rather than silently ignored. */ + @Test + void unknownButtonActionIsReported() throws IOException { + try (PDDocument document = new PDDocument()) { + setupForm(document); + FormUtils.addNewFields( + document, List.of(newField("button", "go", 50, 700, 100, 24, null))); + + FormUtils.ModifyFormFieldDefinition mod = + new FormUtils.ModifyFormFieldDefinition( + "go", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + "launchTheMissiles"); + List skipped = new ArrayList<>(); + FormUtils.modifyFormFields(document, List.of(mod), skipped); + + assertEquals(1, skipped.size(), "an unusable action spec should be reported"); + assertTrue(skipped.get(0).reason().contains("launchTheMissiles")); + } + } + + /** Renaming a nested field must not re-parent it to the top level. */ + @Test + void renamingNestedFieldKeepsItUnderItsParent() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + PDAcroForm form = setupForm(document); + FormUtils.addNewFields( + document, List.of(newField("text", "Name", 50, 700, 200, 20, null))); + PDNonTerminalField parent = new PDNonTerminalField(form); + parent.setPartialName("Customer"); + PDField child = form.getField("Name"); + parent.setChildren(List.of(child)); + child.getCOSObject().setItem(COSName.PARENT, parent.getCOSObject()); + form.setFields(List.of(parent)); + + FormUtils.ModifyFormFieldDefinition rename = + new FormUtils.ModifyFormFieldDefinition( + "Customer.Name", + "Customer.Phone", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null); + List skipped = new ArrayList<>(); + FormUtils.modifyFormFields(document, List.of(rename), skipped); + assertTrue( + skipped.isEmpty(), "a leaf rename under the same parent is legal: " + skipped); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + assertNotNull( + acroForm.getField("Customer.Phone"), + "the field should still live under Customer, not at the top level"); + assertNull(acroForm.getField("Customer.Name"), "the old name should be gone"); + } + } + + /** One rejected action on a multi-widget button is one report, not one per widget. */ + @Test + void unknownButtonActionIsReportedOncePerField() throws IOException { + try (PDDocument document = new PDDocument()) { + setupForm(document); + FormUtils.addNewFields( + document, List.of(newField("button", "go", 50, 700, 100, 24, null))); + PDAcroForm form = document.getDocumentCatalog().getAcroForm(null); + PDField button = form.getField("go"); + // Give it a second widget, as a button repeated on two pages would have. + PDAnnotationWidget extra = new PDAnnotationWidget(); + extra.setRectangle(new PDRectangle(50, 600, 100, 24)); + extra.getCOSObject().setItem(COSName.PARENT, button.getCOSObject()); + List widgets = new ArrayList<>(button.getWidgets()); + widgets.add(extra); + button.getCOSObject() + .setItem( + COSName.KIDS, + new org.apache.pdfbox.cos.COSArray() { + { + for (PDAnnotationWidget w : widgets) add(w.getCOSObject()); + } + }); + + FormUtils.ModifyFormFieldDefinition mod = + new FormUtils.ModifyFormFieldDefinition( + "go", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + "launchTheMissiles"); + List skipped = new ArrayList<>(); + FormUtils.modifyFormFields(document, List.of(mod), skipped); + + assertEquals(1, skipped.size(), "one field, one report: " + skipped); + } + } + + /** A clamped page index still creates the field, so it is not a dropped edit. */ + @Test + void clampedPageIsNotReportedAsSkipped() throws IOException { + try (PDDocument document = new PDDocument()) { + setupForm(document); + List skipped = new ArrayList<>(); + FormUtils.addNewFields( + document, + List.of( + new FormUtils.NewFormFieldDefinition( + "late", null, "text", 9, 50f, 700f, 100f, 20f, null, null, null, + null, null, null, null, null, null, null)), + skipped); + + assertNotNull( + document.getDocumentCatalog().getAcroForm(null).getField("late"), + "the field is created on the clamped page"); + assertTrue(skipped.isEmpty(), "an applied edit must not appear as skipped: " + skipped); + } + } + + /** Recreation builds a top-level field, so it must refuse rather than re-parent. */ + @Test + void typeChangeOnNestedFieldIsRefusedNotSilentlyReparented() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + PDAcroForm form = setupForm(document); + FormUtils.addNewFields( + document, List.of(newField("text", "Name", 50, 700, 200, 20, null))); + PDNonTerminalField parent = new PDNonTerminalField(form); + parent.setPartialName("Customer"); + PDField child = form.getField("Name"); + parent.setChildren(List.of(child)); + child.getCOSObject().setItem(COSName.PARENT, parent.getCOSObject()); + form.setFields(List.of(parent)); + + FormUtils.ModifyFormFieldDefinition retype = + new FormUtils.ModifyFormFieldDefinition( + "Customer.Name", + null, + null, + "checkbox", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null); + List skipped = new ArrayList<>(); + FormUtils.modifyFormFields(document, List.of(retype), skipped); + + assertEquals(1, skipped.size(), "the refusal must be reported: " + skipped); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + assertNotNull( + acroForm.getField("Customer.Name"), + "the original nested field must be left intact"); + assertNull(acroForm.getField("Name"), "nothing should be re-parented to the top level"); + } + } + + /** The editor emits "uri:" the moment that kind is picked, which must not fail the edit. */ + @Test + void incompleteUrlActionClearsRatherThanFailing() throws IOException { + try (PDDocument document = new PDDocument()) { + setupForm(document); + FormUtils.addNewFields( + document, List.of(newField("button", "go", 50, 700, 100, 24, null))); + + FormUtils.ModifyFormFieldDefinition pickUri = + new FormUtils.ModifyFormFieldDefinition( + "go", null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, "uri:"); + List skipped = new ArrayList<>(); + FormUtils.modifyFormFields(document, List.of(pickUri), skipped); + + assertTrue( + skipped.isEmpty(), + "choosing a URL action before typing the URL is not an error: " + skipped); + PDField button = document.getDocumentCatalog().getAcroForm(null).getField("go"); + assertNull( + button.getWidgets().get(0).getCOSObject().getDictionaryObject(COSName.A), + "an empty target must leave no action behind"); + } + } + + /** A real URL still writes a real action. */ + @Test + void completeUrlActionIsApplied() throws IOException { + try (PDDocument document = new PDDocument()) { + setupForm(document); + FormUtils.addNewFields( + document, List.of(newField("button", "go", 50, 700, 100, 24, null))); + + FormUtils.ModifyFormFieldDefinition setUri = + new FormUtils.ModifyFormFieldDefinition( + "go", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + "uri:https://example.com"); + List skipped = new ArrayList<>(); + FormUtils.modifyFormFields(document, List.of(setUri), skipped); + + assertTrue(skipped.isEmpty(), "a complete spec applies cleanly: " + skipped); + PDField button = document.getDocumentCatalog().getAcroForm(null).getField("go"); + assertNotNull( + button.getWidgets().get(0).getCOSObject().getDictionaryObject(COSName.A), + "the action should be written"); + } + } + + /** Builds a parent with the given terminal children already attached. */ + private static PDNonTerminalField nest( + PDDocument document, PDAcroForm form, String parentName, String... childNames) + throws IOException { + List defs = new ArrayList<>(); + for (int i = 0; i < childNames.length; i++) { + defs.add(newField("text", childNames[i], 50, 700 - i * 40, 200, 20, null)); + } + FormUtils.addNewFields(document, defs); + + PDNonTerminalField parent = new PDNonTerminalField(form); + parent.setPartialName(parentName); + List kids = new ArrayList<>(); + for (String child : childNames) { + PDField field = form.getField(child); + field.getCOSObject().setItem(COSName.PARENT, parent.getCOSObject()); + kids.add(field); + } + parent.setChildren(kids); + form.setFields(List.of(parent)); + return parent; + } + + /** A refused edit must not release the name the field still really has. */ + @Test + void refusedNestedEditDoesNotFreeItsNameForALaterEdit() throws IOException { + try (PDDocument document = new PDDocument()) { + PDAcroForm form = setupForm(document); + nest(document, form, "Customer", "Name", "Email"); + + // Edit 1 is refused (type change on a nested field). Edit 2 then asks for the + // name edit 1 still occupies, which must not be handed out. + FormUtils.ModifyFormFieldDefinition refused = + new FormUtils.ModifyFormFieldDefinition( + "Customer.Name", + "Customer.Foo", + null, + "checkbox", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null); + FormUtils.ModifyFormFieldDefinition rename = + new FormUtils.ModifyFormFieldDefinition( + "Customer.Email", + "Customer.Name", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null); + + List skipped = new ArrayList<>(); + FormUtils.modifyFormFields(document, List.of(refused, rename), skipped); + + List names = new ArrayList<>(); + for (PDField f : document.getDocumentCatalog().getAcroForm(null).getFieldTree()) { + if (f instanceof PDTerminalField) names.add(f.getFullyQualifiedName()); + } + assertEquals( + names.size(), + new java.util.HashSet<>(names).size(), + "two fields must never share a qualified name: " + names); + assertTrue( + names.contains("Customer.Name"), "the refused field keeps its name: " + names); + } + } + + /** A group name occupies the namespace, so a new field must not be able to take it. */ + @Test + void groupNamesParticipateInCollisionChecks() throws IOException { + try (PDDocument document = new PDDocument()) { + PDAcroForm form = setupForm(document); + nest(document, form, "Customer", "Name"); + + FormUtils.addNewFields( + document, List.of(newField("text", "Customer", 50, 500, 100, 20, null))); + + List names = new ArrayList<>(); + for (PDField f : document.getDocumentCatalog().getAcroForm(null).getFieldTree()) { + String fqn = f.getFullyQualifiedName(); + if (fqn != null) names.add(fqn); + } + assertEquals( + names.size(), + new java.util.HashSet<>(names).size(), + "the new field must not take the group's name: " + names); + } + } + + /** "Customer." has no leaf, so it must be refused rather than become "Customer.field". */ + @Test + void renameToBareParentPrefixIsRefused() { + assertNotNull( + FormUtils.renameProblem("Customer.Name", "Customer."), + "a name with nothing after the parent prefix is not a rename"); + assertNull(FormUtils.renameProblem("Customer.Name", "Customer.Phone")); + } + + /** A type change must leave the field on its own page, not relocate it to the last one. */ + @Test + void typeChangeKeepsTheFieldOnItsPage() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + PDAcroForm form = new PDAcroForm(document); + for (int i = 0; i < 5; i++) { + document.addPage(new PDPage(PDRectangle.A4)); + } + form.setDefaultResources(new PDResources()); + document.getDocumentCatalog().setAcroForm(form); + + FormUtils.addNewFields( + document, + List.of( + new FormUtils.NewFormFieldDefinition( + "onPageTwo", + null, + "text", + 1, + 50f, + 700f, + 200f, + 20f, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null))); + + FormUtils.ModifyFormFieldDefinition retype = + new FormUtils.ModifyFormFieldDefinition( + "onPageTwo", + null, + null, + "checkbox", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null); + FormUtils.modifyFormFields(document, List.of(retype)); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + PDField field = acroForm.getField("onPageTwo"); + assertNotNull(field, "the retyped field should exist"); + int page = -1; + for (int i = 0; i < reloaded.getNumberOfPages(); i++) { + for (var annot : reloaded.getPage(i).getAnnotations()) { + if (annot.getCOSObject() == field.getWidgets().get(0).getCOSObject()) page = i; + } + } + assertEquals( + 1, page, "a retyped field must stay on its own page, not move to the last"); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/FormUtilsEditReportingTest.java b/app/common/src/test/java/stirling/software/common/util/FormUtilsEditReportingTest.java new file mode 100644 index 0000000000..2c606d481a --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/FormUtilsEditReportingTest.java @@ -0,0 +1,118 @@ +package stirling.software.common.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; +import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox; +import org.junit.jupiter.api.Test; + +/** An edit the backend cannot honour must be reported, not logged and reported as success. */ +class FormUtilsEditReportingTest { + + private static FormUtils.NewFormFieldDefinition field(String type, String name) { + return new FormUtils.NewFormFieldDefinition( + name, name, type, 0, 60f, 700f, 120f, 20f, null, null, null, null, null, null, null, + null, null, null); + } + + private static PDDocument blank() { + PDDocument document = new PDDocument(); + document.addPage(new PDPage(PDRectangle.LETTER)); + document.getDocumentCatalog().setAcroForm(new PDAcroForm(document)); + return document; + } + + @Test + void anUncreatableTypeIsReportedRatherThanSilentlyMadeText() throws IOException { + List skipped = new ArrayList<>(); + try (PDDocument document = blank()) { + FormUtils.addNewFields(document, List.of(field("nonsense", "mystery")), skipped); + PDAcroForm acroForm = document.getDocumentCatalog().getAcroForm(null); + assertTrue( + acroForm.getFields().isEmpty(), + "an unsupported type must not quietly become a text field"); + } + assertEquals(1, skipped.size(), "the caller must be told: " + skipped); + assertTrue(skipped.get(0).reason().contains("nonsense"), skipped.get(0).reason()); + } + + @Test + void aLyingPageCountIsSurvivable() throws IOException { + // /Count overstates the tree, so getNumberOfPages() passes the guard but getPage throws. + byte[] broken = + ("%PDF-1.4\n" + + "1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n" + + "2 0 obj << /Type /Pages /Count 1 /Kids [] >> endobj\n" + + "trailer << /Root 1 0 R >>\n") + .getBytes(java.nio.charset.StandardCharsets.ISO_8859_1); + List skipped = new ArrayList<>(); + try (PDDocument document = Loader.loadPDF(broken)) { + // Must not throw; the field is reported as skipped instead. + FormUtils.addNewFields(document, List.of(field("text", "ghost")), skipped); + } catch (IOException loadFailure) { + // A parser that refuses the file outright is an equally acceptable outcome. + return; + } + assertFalse(skipped.isEmpty(), "an unreachable page must be reported, not thrown"); + } + + @Test + void aTwoWidgetCheckboxKeepsItsOnStateWhenMoved() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + document.addPage(new PDPage(PDRectangle.LETTER)); + document.addPage(new PDPage(PDRectangle.LETTER)); + document.getDocumentCatalog().setAcroForm(new PDAcroForm(document)); + FormUtils.addNewFields( + document, + List.of( + new FormUtils.NewFormFieldDefinition( + "agree", + "agree", + "checkbox", + 0, + 60f, + 700f, + 14f, + 14f, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null)), + new ArrayList<>()); + FormUtils.modifyFormFields( + document, + List.of( + new FormUtils.ModifyFormFieldDefinition( + "agree", null, null, null, 0, 200f, 400f, null, null, null, + null, null, null, null, null, null, null, null, null))); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + document.save(out); + saved = out.toByteArray(); + } + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + PDCheckBox box = (PDCheckBox) acroForm.getField("agree"); + assertNotNull(box); + assertFalse(box.getOnValue().isEmpty(), "a moved checkbox must stay tickable"); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/FormUtilsEditingTest.java b/app/common/src/test/java/stirling/software/common/util/FormUtilsEditingTest.java new file mode 100644 index 0000000000..a97c06daba --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/FormUtilsEditingTest.java @@ -0,0 +1,467 @@ +package stirling.software.common.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.List; +import java.util.Set; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDResources; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceDictionary; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceEntry; +import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; +import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox; +import org.apache.pdfbox.pdmodel.interactive.form.PDField; +import org.apache.pdfbox.pdmodel.interactive.form.PDPushButton; +import org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton; +import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField; +import org.apache.pdfbox.pdmodel.interactive.form.PDTextField; +import org.apache.pdfbox.pdmodel.interactive.form.PDVariableText; +import org.junit.jupiter.api.Test; + +/** + * Assertions run after a save/reload cycle: PDFBox synthesises widgets for fields with no explicit + * {@code /Kids}, so only the serialised document reflects what a viewer sees. + */ +class FormUtilsEditingTest { + + private static PDAcroForm setupForm(PDDocument document, PDRectangle pageSize) { + PDPage page = new PDPage(pageSize); + document.addPage(page); + PDAcroForm acroForm = new PDAcroForm(document); + acroForm.setDefaultResources(new PDResources()); + document.getDocumentCatalog().setAcroForm(acroForm); + return acroForm; + } + + private static byte[] save(PDDocument document) throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + document.save(baos); + return baos.toByteArray(); + } + + private static FormUtils.NewFormFieldDefinition newText( + String name, float x, float y, float w, float h) { + return new FormUtils.NewFormFieldDefinition( + name, null, "text", 0, x, y, w, h, null, null, null, null, null, null, null, null, + null, null); + } + + private static FormUtils.NewFormFieldDefinition newField( + String type, + String name, + float x, + float y, + float w, + float h, + List options, + Integer maxLength, + String buttonAction) { + return new FormUtils.NewFormFieldDefinition( + name, + null, + type, + 0, + x, + y, + w, + h, + null, + null, + options, + null, + null, + null, + null, + null, + maxLength, + buttonAction); + } + + private static PDRectangle firstWidgetRect(PDAcroForm acroForm, String name) { + PDField field = acroForm.getField(name); + assertNotNull(field, "field '" + name + "' should exist"); + assertTrue(!field.getWidgets().isEmpty(), "field should have at least one widget"); + return field.getWidgets().get(0).getRectangle(); + } + + /** + * PDAcroForm.refreshAppearances() never synthesizes /AP for the button family, so without an + * explicit appearance a created checkbox or radio renders blank and resolves to Off. + */ + @Test + void addNewFields_givesToggleFieldsAppearanceStreamsAndKeepsTheirDefault() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document, PDRectangle.A4); + FormUtils.addNewFields( + document, + List.of( + newField("checkbox", "agree", 50, 600, 20, 20, null, null, null), + newField( + "radio", + "choice", + 50, + 500, + 20, + 20, + List.of("Yes", "No"), + null, + null), + newText("fullname", 50, 400, 200, 24))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + assertNotNull(acroForm); + + // NeedAppearances=false means viewers trust our streams, so they must exist. + assertFalse(acroForm.getNeedAppearances(), "appearance generation should have run"); + + PDField checkBox = acroForm.getField("agree"); + assertTrue(checkBox instanceof PDCheckBox); + assertEquals( + Set.of("Off", "Yes"), + normalStateNames(checkBox.getWidgets().get(0)), + "checkbox needs an Off and an on-state appearance"); + + PDField radio = acroForm.getField("choice"); + assertTrue(radio instanceof PDRadioButton); + assertEquals(2, radio.getWidgets().size()); + assertEquals(Set.of("Off", "Yes"), normalStateNames(radio.getWidgets().get(0))); + assertEquals(Set.of("Off", "No"), normalStateNames(radio.getWidgets().get(1))); + + // A text field's DA names /Helv; if /DR lacks that alias refreshAppearances throws for + // the whole form and every field above loses its appearance too. + PDField text = acroForm.getField("fullname"); + assertNotNull( + text.getWidgets().get(0).getAppearance().getNormalAppearance(), + "text field should have a generated appearance"); + } + } + + /** The /AP /N state names on a widget. */ + private static Set normalStateNames(PDAnnotationWidget widget) { + PDAppearanceDictionary appearance = widget.getAppearance(); + assertNotNull(appearance, "widget should have an /AP dictionary"); + PDAppearanceEntry normal = appearance.getNormalAppearance(); + assertNotNull(normal, "widget should have an /AP /N entry"); + assertTrue(normal.isSubDictionary(), "a toggle needs per-state appearances"); + return normal.getSubDictionary().keySet().stream() + .map(COSName::getName) + .collect(java.util.stream.Collectors.toSet()); + } + + @Test + void addNewFields_createsTextFieldAtRequestedRectangle() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document, PDRectangle.A4); + FormUtils.addNewFields(document, List.of(newText("created", 50, 700, 200, 20))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + assertNotNull(acroForm, "AcroForm should exist after reload"); + assertTrue(acroForm.getField("created") instanceof PDTextField); + PDRectangle rect = firstWidgetRect(acroForm, "created"); + assertNotNull(rect, "created widget should keep its rectangle after reload"); + assertEquals(50f, rect.getLowerLeftX(), 0.5f); + assertEquals(700f, rect.getLowerLeftY(), 0.5f); + assertEquals(200f, rect.getWidth(), 0.5f); + assertEquals(20f, rect.getHeight(), 0.5f); + } + } + + @Test + void addNewFields_appliesCropBoxOffsetToCoordinates() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document, PDRectangle.A4); + // Shift the CropBox origin; the frontend sends CropBox-relative coords. + document.getPage(0).setCropBox(new PDRectangle(10, 20, 500, 700)); + FormUtils.addNewFields(document, List.of(newText("shifted", 5, 5, 100, 15))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + PDRectangle rect = firstWidgetRect(acroForm, "shifted"); + // Absolute = CropBox-relative + CropBox lower-left offset. + assertEquals(15f, rect.getLowerLeftX(), 0.5f); + assertEquals(25f, rect.getLowerLeftY(), 0.5f); + } + } + + @Test + void addNewFields_appliesReadOnlyFontSizeAndMultiline() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document, PDRectangle.A4); + FormUtils.NewFormFieldDefinition def = + new FormUtils.NewFormFieldDefinition( + "opts", + null, + "text", + 0, + 10f, + 10f, + 120f, + 18f, + null, + null, + null, + null, + null, + 18f, + Boolean.TRUE, + Boolean.TRUE, + null, + null); + FormUtils.addNewFields(document, List.of(def)); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + PDField field = acroForm.getField("opts"); + assertNotNull(field); + assertTrue(field.isReadOnly(), "read-only flag should survive reload"); + assertTrue(field instanceof PDTextField); + assertTrue(((PDTextField) field).isMultiline(), "multiline flag should survive reload"); + String da = ((PDVariableText) field).getDefaultAppearance(); + assertTrue(da.contains("18"), "default appearance should carry the font size: " + da); + } + } + + @Test + void modifyFormFields_movesAndResizesWidget() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document, PDRectangle.A4); + FormUtils.addNewFields(document, List.of(newText("movable", 50, 700, 200, 20))); + + FormUtils.ModifyFormFieldDefinition mod = + new FormUtils.ModifyFormFieldDefinition( + "movable", null, null, null, 0, 100f, 600f, 150f, 30f, null, null, null, + null, null, null, null, null, null, null); + FormUtils.modifyFormFields(document, List.of(mod)); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + PDRectangle rect = firstWidgetRect(acroForm, "movable"); + assertEquals(100f, rect.getLowerLeftX(), 0.5f); + assertEquals(600f, rect.getLowerLeftY(), 0.5f); + assertEquals(150f, rect.getWidth(), 0.5f); + assertEquals(30f, rect.getHeight(), 0.5f); + } + } + + @Test + void modifyFormFields_setsReadOnlyAndFontSize() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document, PDRectangle.A4); + FormUtils.addNewFields(document, List.of(newText("editable", 50, 700, 200, 20))); + + FormUtils.ModifyFormFieldDefinition mod = + new FormUtils.ModifyFormFieldDefinition( + "editable", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + 22f, + Boolean.TRUE, + null, + null, + null); + FormUtils.modifyFormFields(document, List.of(mod)); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + PDField field = acroForm.getField("editable"); + assertNotNull(field); + assertTrue(field.isReadOnly(), "read-only flag should survive reload"); + String da = ((PDVariableText) field).getDefaultAppearance(); + assertTrue(da.contains("22"), "font size should be reflected in DA: " + da); + } + } + + @Test + void deleteFormFields_removesField() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + PDAcroForm acroForm = setupForm(document, PDRectangle.A4); + FormUtils.addNewFields(document, List.of(newText("temp", 50, 700, 200, 20))); + FormUtils.deleteFormFields(document, List.of("temp")); + // After delete the AcroForm may still exist; the field must be gone. + if (acroForm != null) { + assertNull(acroForm.getField("temp")); + } + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + assertTrue(acroForm == null || acroForm.getField("temp") == null); + } + } + + @Test + void addNewFields_createsRadioGroupWithOneWidgetPerOption() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document, PDRectangle.A4); + FormUtils.addNewFields( + document, + List.of( + newField( + "radio", + "choice", + 60, + 700, + 16, + 16, + List.of("Yes", "No"), + null, + null))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + PDField field = acroForm.getField("choice"); + assertNotNull(field, "radio field should exist"); + assertTrue(field instanceof PDRadioButton, "should be a radio button group"); + assertEquals(2, field.getWidgets().size(), "one widget per option"); + assertTrue(((PDRadioButton) field).getExportValues().contains("Yes")); + assertTrue(((PDRadioButton) field).getExportValues().contains("No")); + } + } + + @Test + void extractFormFields_prefersFieldNameOverFirstOptionForChoiceLabel() throws IOException { + // A radio group's label is its field name, not its first option, so the viewer label + // matches the name shown in the editor. + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document, PDRectangle.A4); + FormUtils.addNewFields( + document, + List.of( + newField( + "radio", + "Choice", + 60, + 700, + 16, + 16, + List.of("Yes", "No"), + null, + null))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + FormUtils.FormFieldInfo choice = + FormUtils.extractFormFields(reloaded).stream() + .filter(f -> "Choice".equals(f.name())) + .findFirst() + .orElse(null); + assertNotNull(choice, "radio field should be extracted"); + assertEquals( + "Choice", choice.label(), "field name should win over the first option value"); + } + } + + @Test + void addNewFields_createsCombTextField() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document, PDRectangle.A4); + FormUtils.addNewFields( + document, List.of(newField("text", "ssn", 50, 700, 200, 20, null, 9, null))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + PDTextField field = (PDTextField) acroForm.getField("ssn"); + assertNotNull(field); + assertEquals(9, field.getMaxLen(), "comb max length should persist"); + assertTrue(field.isComb(), "comb flag should be set"); + } + } + + @Test + void addNewFields_createsSignatureAndButton() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document, PDRectangle.A4); + FormUtils.addNewFields( + document, + List.of( + newField("signature", "sig", 50, 600, 200, 60, null, null, null), + newField("button", "btn", 50, 500, 120, 24, null, null, "reset"))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + assertTrue( + acroForm.getField("sig") instanceof PDSignatureField, + "signature placeholder should exist"); + assertTrue( + acroForm.getField("btn") instanceof PDPushButton, "push button should exist"); + } + } + + @Test + void applyFieldEdits_addsModifiesAndDeletesInOnePass() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document, PDRectangle.A4); + FormUtils.addNewFields(document, List.of(newText("old", 50, 700, 200, 20))); + + FormUtils.applyFieldEdits( + document, + List.of(newText("fresh", 50, 600, 200, 20)), + List.of(), + List.of("old")); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + assertNotNull(acroForm.getField("fresh"), "added field should be present"); + assertNull(acroForm.getField("old"), "deleted field should be gone"); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/FormUtilsGapTest.java b/app/common/src/test/java/stirling/software/common/util/FormUtilsGapTest.java index dd828327b1..5c13b13908 100644 --- a/app/common/src/test/java/stirling/software/common/util/FormUtilsGapTest.java +++ b/app/common/src/test/java/stirling/software/common/util/FormUtilsGapTest.java @@ -705,10 +705,20 @@ class FormUtilsGapTest { "newName", "New Label", null, // keep type (text) -> in-place path + null, + null, + null, + null, + null, Boolean.TRUE, null, null, null, + null, + null, + null, + null, + null, null); FormUtils.modifyFormFields(doc, List.of(mod)); @@ -731,7 +741,8 @@ class FormUtilsGapTest { FormUtils.ModifyFormFieldDefinition mod = new FormUtils.ModifyFormFieldDefinition( - "missing", null, null, null, null, null, null, null, null); + "missing", null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null); FormUtils.modifyFormFields(doc, List.of(mod)); @@ -754,7 +765,8 @@ class FormUtilsGapTest { mods.add(null); mods.add( new FormUtils.ModifyFormFieldDefinition( - " ", null, null, null, null, null, null, null, null)); + " ", null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null)); FormUtils.modifyFormFields(doc, mods); assertEquals(1, FormUtils.extractFormFields(doc).size()); diff --git a/app/common/src/test/java/stirling/software/common/util/FormUtilsMoreTest.java b/app/common/src/test/java/stirling/software/common/util/FormUtilsMoreTest.java index f4e013e082..fa3425176a 100644 --- a/app/common/src/test/java/stirling/software/common/util/FormUtilsMoreTest.java +++ b/app/common/src/test/java/stirling/software/common/util/FormUtilsMoreTest.java @@ -285,13 +285,13 @@ class FormUtilsMoreTest { } @Test - void widgetOutOfBoundsYieldsNullCoordinateEntry() throws IOException { + void widgetOutOfBoundsStillReportsItsCoordinates() throws IOException { try (PDDocument doc = new PDDocument()) { SetupDocument setup = createBasicDocument(doc); PDTextField text = new PDTextField(setup.acroForm()); text.setPartialName("offpage"); - // Far below the page origin -> finalY exceeds bounds -> createWidgetCoordinates - // returns null, which is still added to the per-field widget list. + // Off the page is legal PDF; dropping it would leave the user unable to drag it + // back. attachWidget(setup, text, new PDRectangle(50, -5000, 200, 20)); List fields = @@ -301,7 +301,8 @@ class FormUtilsMoreTest { fields.get(0).getWidgets(); assertNotNull(widgets); assertEquals(1, widgets.size()); - assertNull(widgets.get(0)); + assertNotNull(widgets.get(0), "a null entry here crashes sorting and the overlay"); + assertEquals(50f, widgets.get(0).getX(), 0.01f); } } @@ -476,8 +477,18 @@ class FormUtilsMoreTest { "combobox", null, null, + null, + null, + null, + null, + null, List.of("One", "Two"), "One", + null, + null, + null, + null, + null, null); FormUtils.modifyFormFields(doc, List.of(mod)); @@ -505,10 +516,20 @@ class FormUtilsMoreTest { null, "listbox", // same type -> in-place path null, + null, + null, + null, + null, + null, Boolean.TRUE, List.of("X", "Y", "Z"), null, - "Choose items"); + "Choose items", + null, + null, + null, + null, + null); FormUtils.modifyFormFields(doc, List.of(mod)); @@ -529,7 +550,25 @@ class FormUtilsMoreTest { FormUtils.ModifyFormFieldDefinition mod = new FormUtils.ModifyFormFieldDefinition( - "keep", null, null, "bogusType", null, null, null, null, null); + "keep", + null, + null, + "bogusType", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null); FormUtils.modifyFormFields(doc, List.of(mod)); // The field is preserved unchanged because the target type is unsupported. @@ -554,7 +593,8 @@ class FormUtilsMoreTest { // Rename beta -> alpha; should be uniquified to avoid the collision. FormUtils.ModifyFormFieldDefinition mod = new FormUtils.ModifyFormFieldDefinition( - "beta", "alpha", null, null, null, null, null, null, null); + "beta", "alpha", null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null); FormUtils.modifyFormFields(doc, List.of(mod)); @@ -575,7 +615,8 @@ class FormUtilsMoreTest { doc.addPage(new PDPage()); FormUtils.ModifyFormFieldDefinition mod = new FormUtils.ModifyFormFieldDefinition( - "x", null, null, null, null, null, null, null, null); + "x", null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null); FormUtils.modifyFormFields(doc, List.of(mod)); } } diff --git a/app/common/src/test/java/stirling/software/common/util/FormUtilsNoAcroFormTest.java b/app/common/src/test/java/stirling/software/common/util/FormUtilsNoAcroFormTest.java new file mode 100644 index 0000000000..d8f5e1deb2 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/FormUtilsNoAcroFormTest.java @@ -0,0 +1,102 @@ +package stirling.software.common.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; +import org.apache.pdfbox.pdmodel.interactive.form.PDTextField; +import org.junit.jupiter.api.Test; + +/** + * Most real PDFs have no AcroForm at all, so adding the very first field has to build one that + * PDFBox will accept. + */ +class FormUtilsNoAcroFormTest { + + private static final Path PLAIN_PDF = + Path.of("src/test/resources/pdf-ingestion-fixtures/many-tables-test_stress.pdf"); + + private static FormUtils.NewFormFieldDefinition newField( + String type, String name, float y, List options, String defaultValue) { + // name, label, type, pageIndex, x, y, width, height, required, multiSelect, + // options, defaultValue, tooltip, fontSize, readOnly, multiline, maxLength, buttonAction + return new FormUtils.NewFormFieldDefinition( + name, + name, + type, + 0, + 60f, + y, + 200f, + 20f, + null, + null, + options, + defaultValue, + null, + null, + null, + null, + null, + null); + } + + private static PDDocument loadPlain() throws IOException { + return Loader.loadPDF(Files.readAllBytes(PLAIN_PDF)); + } + + @Test + void plainPdfReallyHasNoAcroForm() throws IOException { + try (PDDocument document = loadPlain()) { + assertNull( + document.getDocumentCatalog().getAcroForm(null), + "fixture must have no AcroForm or this test proves nothing"); + } + } + + @Test + void addsFirstFieldToAPdfWithNoAcroForm() throws IOException { + byte[] saved; + List skipped = new ArrayList<>(); + try (PDDocument document = loadPlain()) { + FormUtils.addNewFields( + document, + List.of( + newField("text", "fullName", 700f, null, "Ada"), + newField("checkbox", "agree", 660f, null, null), + newField("radio", "contact", 600f, List.of("Email", "Post"), null)), + skipped); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + document.save(out); + saved = out.toByteArray(); + } + + assertTrue(skipped.isEmpty(), "no field should be skipped: " + skipped); + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + assertNotNull(acroForm, "an AcroForm should have been created"); + assertNotNull(acroForm.getDefaultResources(), "/DR is required for variable text"); + assertTrue( + acroForm.getDefaultAppearance() != null + && !acroForm.getDefaultAppearance().isBlank(), + "/DA is required for variable text"); + PDTextField text = (PDTextField) acroForm.getField("fullName"); + assertNotNull(text, "the text field should exist"); + assertEquals("Ada", text.getValueAsString()); + assertNotNull(acroForm.getField("agree")); + assertNotNull(acroForm.getField("contact")); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/FormUtilsRadioCaptionTest.java b/app/common/src/test/java/stirling/software/common/util/FormUtilsRadioCaptionTest.java new file mode 100644 index 0000000000..04b317feb8 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/FormUtilsRadioCaptionTest.java @@ -0,0 +1,175 @@ +package stirling.software.common.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.List; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; +import org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton; +import org.apache.pdfbox.text.PDFTextStripper; +import org.junit.jupiter.api.Test; + +/** + * Option captions belong to the viewer, not the page. Drawing them into the content stream left + * orphan text behind on every move and delete, so these pin the page staying clean. + */ +class FormUtilsRadioCaptionTest { + + private static FormUtils.NewFormFieldDefinition newField( + String type, String name, float x, float y, float w, float h, List options) { + return new FormUtils.NewFormFieldDefinition( + name, null, type, 0, x, y, w, h, null, null, options, null, null, null, null, null, + null, null); + } + + private static byte[] save(PDDocument document) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + document.save(out); + return out.toByteArray(); + } + + private static PDDocument blankWithForm() { + PDDocument document = new PDDocument(); + document.addPage(new PDPage(PDRectangle.LETTER)); + document.getDocumentCatalog().setAcroForm(new PDAcroForm(document)); + return document; + } + + private static String textOf(byte[] pdf) throws IOException { + try (PDDocument reloaded = Loader.loadPDF(pdf)) { + return new PDFTextStripper().getText(reloaded); + } + } + + @Test + void radioOptionsAreNotBakedIntoThePage() throws IOException { + byte[] saved; + try (PDDocument document = blankWithForm()) { + FormUtils.addNewFields( + document, + List.of( + newField( + "radio", + "contact", + 72, + 600, + 12, + 12, + List.of("Email", "Telephone", "Post")))); + saved = save(document); + } + + // The caption is the viewer's job; page content cannot follow a widget that moves. + String text = textOf(saved); + assertFalse(text.contains("Email"), "options must not be page content: " + text); + assertFalse(text.contains("Telephone"), "options must not be page content: " + text); + assertFalse(text.contains("Post"), "options must not be page content: " + text); + } + + @Test + void captionsDoNotReplaceTheWidgetsThemselves() throws IOException { + byte[] saved; + try (PDDocument document = blankWithForm()) { + FormUtils.addNewFields( + document, + List.of(newField("radio", "size", 72, 600, 12, 12, List.of("S", "M", "L")))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + PDRadioButton radio = (PDRadioButton) acroForm.getField("size"); + assertEquals(3, radio.getWidgets().size(), "one widget per option"); + assertFalse(radio.getExportValues().isEmpty(), "export values must survive"); + } + } + + @Test + void aTextFieldDrawsNoStrayCaption() throws IOException { + // Control: proves the assertions above read the captions and not some unrelated content. + byte[] saved; + try (PDDocument document = blankWithForm()) { + FormUtils.addNewFields( + document, List.of(newField("text", "fullName", 72, 600, 200, 18, null))); + saved = save(document); + } + assertTrue(textOf(saved).isBlank(), "a text field should add no page content"); + } + + @Test + void deletingARadioGroupTakesItsCaptionsWithIt() throws IOException { + byte[] withRadio; + try (PDDocument document = blankWithForm()) { + FormUtils.addNewFields( + document, + List.of( + newField( + "radio", + "contact", + 72, + 600, + 12, + 12, + List.of("Email", "Telephone", "Post")))); + withRadio = save(document); + } + assertFalse( + textOf(withRadio).contains("Telephone"), + "the group adds no page text to begin with"); + + byte[] afterDelete; + try (PDDocument document = Loader.loadPDF(withRadio)) { + FormUtils.applyFieldEdits(document, List.of(), List.of(), List.of("contact")); + afterDelete = save(document); + } + + String text = textOf(afterDelete); + assertFalse( + text.contains("Telephone"), + "a deleted radio group must not leave its captions on the page: " + text); + } + + @Test + void theDrawnBoxIsTheWholeGroupNotOneOption() { + // A 90pt box used to become a 360pt stack because each option got the full height. + PDRectangle box = new PDRectangle(72f, 500f, 100f, 90f); + var rects = FormUtils.radioOptionRects(box, 3, null, null); + + assertEquals(3, rects.size()); + float top = rects.get(0).getUpperRightY(); + float bottom = rects.get(2).getLowerLeftY(); + assertEquals(90f, top - bottom, 0.01f, "the group must fill exactly the drawn height"); + assertEquals( + box.getUpperRightY(), top, 0.01f, "the first option starts at the box's top edge"); + for (PDRectangle r : rects) { + assertEquals(r.getWidth(), r.getHeight(), 0.01f, "options stay square"); + assertTrue(r.getWidth() <= box.getWidth() + 0.01f, "an option never exceeds the box"); + } + } + + @Test + void explicitSizeAndGapWin() { + PDRectangle box = new PDRectangle(0f, 0f, 100f, 90f); + var rects = FormUtils.radioOptionRects(box, 3, 20f, 14f); + for (PDRectangle r : rects) { + assertEquals(14f, r.getHeight(), 0.01f, "the requested size is used verbatim"); + } + float gap = rects.get(0).getLowerLeftY() - rects.get(1).getUpperRightY(); + assertEquals(20f, gap, 0.01f, "the requested gap is used verbatim"); + } + + @Test + void aSingleOptionStillFitsTheBox() { + var rects = FormUtils.radioOptionRects(new PDRectangle(0f, 0f, 40f, 40f), 1, null, null); + assertEquals(1, rects.size()); + assertTrue(rects.get(0).getHeight() <= 40f, "one option cannot exceed its box"); + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/MissingDefaultResourcesTest.java b/app/common/src/test/java/stirling/software/common/util/MissingDefaultResourcesTest.java new file mode 100644 index 0000000000..8c8d7e14be --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/MissingDefaultResourcesTest.java @@ -0,0 +1,57 @@ +package stirling.software.common.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** A form with no default resources is ordinary; adding a field to it must still work. */ +class MissingDefaultResourcesTest { + + @Test + @DisplayName("a text field can be added to a form that has no default resources") + void addsToFormWithoutDefaultResources() throws IOException { + // A real upload arrives as bytes, and plenty of forms in the wild carry no /DR at all. + byte[] pdf; + try (PDDocument built = new PDDocument(); + java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream()) { + built.addPage(new PDPage(PDRectangle.A4)); + PDAcroForm form = new PDAcroForm(built); + // A /DA naming a font with no /DR to resolve it is what PDFBox refuses. + form.setDefaultAppearance("/Helv 0 Tf 0 g"); + form.getCOSObject().removeItem(org.apache.pdfbox.cos.COSName.DR); + built.getDocumentCatalog().setAcroForm(form); + built.save(out); + pdf = out.toByteArray(); + } + + try (PDDocument document = org.apache.pdfbox.Loader.loadPDF(pdf)) { + + List skipped = new ArrayList<>(); + FormUtils.addNewFields( + document, + List.of( + new FormUtils.NewFormFieldDefinition( + "note", null, "text", 0, 50f, 700f, 200f, 20f, null, null, null, + null, null, null, null, null, null, null)), + skipped); + + assertTrue( + skipped.isEmpty(), + "adding a plain text field should not be refused: " + skipped); + assertEquals( + 1, + FormUtils.extractFormFields(document).size(), + "the field should be in the document"); + } + } +} diff --git a/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java b/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java index 2726f8d764..01d3f49e2c 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java @@ -183,7 +183,9 @@ public class WebMvcConfig implements WebMvcConfigurer { "X-Page-Number", "X-Page-Size", "Content-Disposition", - "Content-Type") + "Content-Type", + "X-Stirling-Skipped-Field-Edits", + "X-Stirling-Skipped-Field-Edits-Total") .allowCredentials(true) .maxAge(3600); } else if (hasConfiguredOrigins) { @@ -229,7 +231,9 @@ public class WebMvcConfig implements WebMvcConfigurer { "X-Page-Number", "X-Page-Size", "Content-Disposition", - "Content-Type") + "Content-Type", + "X-Stirling-Skipped-Field-Edits", + "X-Stirling-Skipped-Field-Edits-Total") .allowCredentials(true) .maxAge(3600); } else { @@ -256,7 +260,9 @@ public class WebMvcConfig implements WebMvcConfigurer { "X-Page-Number", "X-Page-Size", "Content-Disposition", - "Content-Type") + "Content-Type", + "X-Stirling-Skipped-Field-Edits", + "X-Stirling-Skipped-Field-Edits-Total") .allowCredentials(true) .maxAge(3600); } diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormFillController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormFillController.java index d24d175f5c..0bd3daf180 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormFillController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormFillController.java @@ -2,10 +2,20 @@ package stirling.software.SPDF.controller.api.form; import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.io.InputStream; import java.io.StringWriter; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Base64; import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.stream.Stream; +import java.util.zip.CRC32; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.poi.ss.usermodel.*; @@ -35,6 +45,7 @@ import stirling.software.common.model.FormFieldWithCoordinates; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.FormUtils; +import stirling.software.common.util.TempFile; import stirling.software.common.util.TempFileManager; import stirling.software.common.util.WebResponseUtils; @@ -59,6 +70,25 @@ import tools.jackson.databind.ObjectMapper; @RequiredArgsConstructor public class FormFillController { + /** Carries the edits a request asked for but the document could not take, as base64 JSON. */ + public static final String SKIPPED_EDITS_HEADER = "X-Stirling-Skipped-Field-Edits"; + + /** How many were skipped in total, which may exceed the number listed in the header above. */ + public static final String SKIPPED_EDITS_TOTAL_HEADER = "X-Stirling-Skipped-Field-Edits-Total"; + + /** Keeps the header well inside Jetty's response-header budget. */ + private static final int MAX_REPORTED_SKIPS = 20; + + /** Bytes of encoded header value, well under the container's limit for the whole header set. */ + private static final int MAX_SKIP_HEADER_BYTES = 4096; + + private static final int MAX_SKIP_FIELD_CHARS = 120; + + /** Entry names inside the {@code ?includeFields=true} bundle. */ + private static final String FIELDS_ENTRY = "fields.json"; + + private static final String DOCUMENT_ENTRY = "document.pdf"; + private final CustomPDFDocumentFactory pdfDocumentFactory; private final ObjectMapper objectMapper; private final TempFileManager tempFileManager; @@ -68,6 +98,72 @@ public class FormFillController { return WebResponseUtils.pdfDocToWebResponse(document, baseName + ".pdf", tempFileManager); } + /** + * Rejects field names PDFBox cannot store before the document is touched, so the caller gets a + * 400 naming the offending character instead of a 200 with the field quietly missing. + */ + private static void requireUsableFieldNames( + List adds, + List modifies) { + Stream problems = + Stream.concat( + adds.stream() + .map(FormUtils.NewFormFieldDefinition::name) + .map(FormUtils::invalidFieldNameReason), + // A rename to the same name is not a rename, so a nested field whose + // qualified name already contains a period is left alone. + modifies.stream() + .map(m -> FormUtils.renameProblem(m.targetName(), m.name()))); + problems.filter(Objects::nonNull) + .findFirst() + .ifPresent( + reason -> { + throw ExceptionUtils.createIllegalArgumentException( + "error.invalidArgument", "{0}", reason); + }); + } + + /** + * The body is the updated PDF, so dropped edits travel as a base64 JSON header; + * percent-encoding would turn every space into a plus sign. + */ + private ResponseEntity withSkippedEdits( + ResponseEntity response, List skipped) { + if (skipped.isEmpty()) { + return response; + } + // A count cap alone is not enough: one very long field name can still overflow the + // header budget and turn the response into an error page, losing the edited PDF. + List reported = new ArrayList<>(); + String encoded = ""; + for (FormUtils.SkippedFieldEdit edit : skipped) { + if (reported.size() >= MAX_REPORTED_SKIPS) { + break; + } + reported.add( + new FormUtils.SkippedFieldEdit( + edit.operation(), + FormUtils.abbreviate(edit.target(), MAX_SKIP_FIELD_CHARS), + FormUtils.abbreviate(edit.reason(), MAX_SKIP_FIELD_CHARS))); + String candidate = + Base64.getEncoder() + .encodeToString( + objectMapper + .writeValueAsString(reported) + .getBytes(StandardCharsets.UTF_8)); + if (candidate.length() > MAX_SKIP_HEADER_BYTES) { + reported.removeLast(); + break; + } + encoded = candidate; + } + return ResponseEntity.status(response.getStatusCode()) + .headers(response.getHeaders()) + .header(SKIPPED_EDITS_TOTAL_HEADER, String.valueOf(skipped.size())) + .header(SKIPPED_EDITS_HEADER, encoded) + .body(response.getBody()); + } + private static String buildBaseName(MultipartFile file, String suffix) { String original = Filenames.toSimpleFileName(file.getOriginalFilename()); if (original == null || original.isBlank()) { @@ -257,6 +353,110 @@ public class FormFillController { } } + @PostMapping(value = "/add-fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation( + summary = "Add new form fields", + description = + "Creates new form fields in the provided PDF and returns the updated file") + public ResponseEntity addFields( + @Parameter( + description = "The input PDF file", + required = true, + content = + @Content( + mediaType = MediaType.APPLICATION_PDF_VALUE, + schema = @Schema(type = "string", format = "binary"))) + @RequestParam("file") + MultipartFile file, + @Parameter( + description = "JSON array of new field definitions", + example = + "[{\"name\":\"NewField\",\"type\":\"text\",\"pageIndex\":0," + + "\"x\":50,\"y\":700,\"width\":200,\"height\":20}]") + @RequestPart(value = "fields", required = false) + byte[] fieldsPayload) + throws IOException { + + String rawFields = decodePart(fieldsPayload); + List definitions = + FormPayloadParser.parseNewFieldDefinitions(objectMapper, rawFields); + if (definitions.isEmpty()) { + throw ExceptionUtils.createIllegalArgumentException( + "error.dataRequired", + "{0} must contain at least one definition", + "fields payload"); + } + + requireUsableFieldNames(definitions, List.of()); + + List skipped = new ArrayList<>(); + return withSkippedEdits( + processSingleFile( + file, + "updated", + document -> FormUtils.addNewFields(document, definitions, skipped)), + skipped); + } + + @PostMapping(value = "/edit-fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation( + summary = "Apply a batch of form field edits", + description = + "Adds, modifies, and deletes form fields in a single request (one document" + + " load/save) and returns the updated file") + public ResponseEntity editFields( + @Parameter( + description = "The input PDF file", + required = true, + content = + @Content( + mediaType = MediaType.APPLICATION_PDF_VALUE, + schema = @Schema(type = "string", format = "binary"))) + @RequestParam("file") + MultipartFile file, + @Parameter( + description = + "JSON object with optional 'add', 'modify' and 'delete'" + + " sections", + example = + "{\"add\":[{\"name\":\"f\",\"type\":\"text\",\"pageIndex\":0," + + "\"x\":50,\"y\":700,\"width\":200,\"height\":20}]," + + "\"modify\":[],\"delete\":[]}") + @RequestPart(value = "edits", required = false) + byte[] editsPayload, + @Parameter( + description = + "Return a ZIP holding the updated PDF plus the field list it" + + " produced, instead of the bare PDF. Saves re-uploading" + + " the result just to read its fields back.") + @RequestParam(value = "includeFields", defaultValue = "false") + boolean includeFields) + throws IOException { + + String rawEdits = decodePart(editsPayload); + FormUtils.FieldEditBatch batch = FormPayloadParser.parseFieldEdits(objectMapper, rawEdits); + if (batch.add().isEmpty() && batch.modify().isEmpty() && batch.delete().isEmpty()) { + throw ExceptionUtils.createIllegalArgumentException( + "error.dataRequired", "{0} must contain at least one edit", "edits payload"); + } + requireUsableFieldNames(batch.add(), batch.modify()); + + List skipped = new ArrayList<>(); + return withSkippedEdits( + processSingleFile( + file, + "updated", + includeFields, + document -> + FormUtils.applyFieldEdits( + document, + batch.add(), + batch.modify(), + batch.delete(), + skipped)), + skipped); + } + @PostMapping(value = "/modify-fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @Operation( summary = "Modify existing form fields", @@ -285,8 +485,15 @@ public class FormFillController { "updates payload"); } - return processSingleFile( - file, "updated", document -> FormUtils.modifyFormFields(document, modifications)); + requireUsableFieldNames(List.of(), modifications); + + List skipped = new ArrayList<>(); + return withSkippedEdits( + processSingleFile( + file, + "updated", + document -> FormUtils.modifyFormFields(document, modifications, skipped)), + skipped); } @PostMapping(value = "/delete-fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @@ -319,8 +526,13 @@ public class FormFillController { "error.dataRequired", "{0} must contain at least one value", "names payload"); } - return processSingleFile( - file, "updated", document -> FormUtils.deleteFormFields(document, names)); + List skipped = new ArrayList<>(); + return withSkippedEdits( + processSingleFile( + file, + "updated", + document -> FormUtils.deleteFormFields(document, names, skipped)), + skipped); } @PostMapping(value = "/fill", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @@ -358,13 +570,81 @@ public class FormFillController { private ResponseEntity processSingleFile( MultipartFile file, String suffix, DocumentProcessor processor) throws IOException { + return processSingleFile(file, suffix, false, processor); + } + + private ResponseEntity processSingleFile( + MultipartFile file, String suffix, boolean includeFields, DocumentProcessor processor) + throws IOException { requirePdf(file); String baseName = buildBaseName(file, suffix); try (PDDocument document = pdfDocumentFactory.load(file)) { FormUtils.repairMissingWidgetPageReferences(document); processor.accept(document); - return saveDocument(document, baseName); + return includeFields + ? saveDocumentWithFields(document, baseName) + : saveDocument(document, baseName); + } + } + + /** + * Answers "what fields does the saved file have?" from the document still open here, so the + * caller does not have to upload the result back to ask. + */ + private ResponseEntity saveDocumentWithFields(PDDocument document, String baseName) + throws IOException { + TempFile zip = null; + boolean zipTransferred = false; + try (TempFile pdf = tempFileManager.createManagedTempFile(".pdf")) { + document.save(pdf.getFile()); + // Read the fields after the save so they describe the bytes actually being returned. + byte[] fields = + objectMapper.writeValueAsBytes( + FormUtils.extractFormFieldsWithCoordinates(document)); + zip = tempFileManager.createManagedTempFile(".zip"); + writeFieldBundle(zip.getPath(), pdf.getPath(), fields); + ResponseEntity response = + WebResponseUtils.zipFileToWebResponse(zip, baseName + ".zip"); + zipTransferred = true; + return response; + } finally { + if (zip != null && !zipTransferred) { + zip.close(); + } + } + } + + /** + * Deflates the JSON because it is text, but stores the PDF: its streams are already compressed, + * so deflating costs ~25ms per MB to save a few percent. + */ + private static void writeFieldBundle(Path zipPath, Path pdfPath, byte[] fields) + throws IOException { + long pdfSize = Files.size(pdfPath); + CRC32 crc = new CRC32(); + try (InputStream in = Files.newInputStream(pdfPath)) { + byte[] buffer = new byte[8192]; + for (int read; (read = in.read(buffer)) != -1; ) { + crc.update(buffer, 0, read); + } + } + try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(zipPath))) { + ZipEntry fieldsEntry = new ZipEntry(FIELDS_ENTRY); + fieldsEntry.setMethod(ZipEntry.DEFLATED); + zip.putNextEntry(fieldsEntry); + zip.write(fields); + zip.closeEntry(); + + ZipEntry documentEntry = new ZipEntry(DOCUMENT_ENTRY); + documentEntry.setMethod(ZipEntry.STORED); + documentEntry.setSize(pdfSize); + documentEntry.setCompressedSize(pdfSize); + documentEntry.setCrc(crc.getValue()); + zip.putNextEntry(documentEntry); + Files.copy(pdfPath, zip); + zip.closeEntry(); + zip.finish(); } } diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormPayloadParser.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormPayloadParser.java index 6f82c7546e..f48f419a6d 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormPayloadParser.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormPayloadParser.java @@ -28,6 +28,8 @@ final class FormPayloadParser { private static final TypeReference> MAP_TYPE = new TypeReference<>() {}; private static final TypeReference> MODIFY_FIELD_LIST_TYPE = new TypeReference<>() {}; + private static final TypeReference> NEW_FIELD_LIST_TYPE = + new TypeReference<>() {}; private static final TypeReference> STRING_LIST_TYPE = new TypeReference<>() {}; private FormPayloadParser() {} @@ -94,6 +96,43 @@ final class FormPayloadParser { return objectMapper.readValue(json, MODIFY_FIELD_LIST_TYPE); } + static List parseNewFieldDefinitions( + ObjectMapper objectMapper, String json) { + if (json == null || json.isBlank()) { + return List.of(); + } + return objectMapper.readValue(json, NEW_FIELD_LIST_TYPE); + } + + /** + * Parses a combined edit batch: {@code {"add":[...],"modify":[...],"delete":[...]}}. Each + * section is optional. The delete section accepts the same shapes as {@link #parseNameList}. + */ + static FormUtils.FieldEditBatch parseFieldEdits(ObjectMapper objectMapper, String json) { + if (json == null || json.isBlank()) { + return new FormUtils.FieldEditBatch(List.of(), List.of(), List.of()); + } + final JsonNode root = objectMapper.readTree(json); + List adds = List.of(); + List modifies = List.of(); + List deletes = List.of(); + if (root != null && root.isObject()) { + final JsonNode addNode = root.get("add"); + if (addNode != null && addNode.isArray()) { + adds = objectMapper.readValue(addNode.toString(), NEW_FIELD_LIST_TYPE); + } + final JsonNode modifyNode = root.get("modify"); + if (modifyNode != null && modifyNode.isArray()) { + modifies = objectMapper.readValue(modifyNode.toString(), MODIFY_FIELD_LIST_TYPE); + } + final JsonNode deleteNode = root.get("delete"); + if (deleteNode != null && !deleteNode.isNull()) { + deletes = parseNameList(objectMapper, deleteNode.toString()); + } + } + return new FormUtils.FieldEditBatch(adds, modifies, deletes); + } + static List parseNameList(ObjectMapper objectMapper, String json) { if (json == null || json.isBlank()) { return List.of(); diff --git a/app/core/src/main/resources/static/3rdPartyLicenses.json b/app/core/src/main/resources/static/3rdPartyLicenses.json index c411735489..a0dae640e0 100644 --- a/app/core/src/main/resources/static/3rdPartyLicenses.json +++ b/app/core/src/main/resources/static/3rdPartyLicenses.json @@ -17,7 +17,7 @@ { "moduleName": "ch.qos.logback:logback-classic", "moduleUrl": "http://www.qos.ch", - "moduleVersion": "1.6.1", + "moduleVersion": "1.6.3", "moduleLicense": "LGPL-2.1-only", "moduleLicenseUrl": "https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html" }, @@ -31,7 +31,7 @@ { "moduleName": "ch.qos.logback:logback-core", "moduleUrl": "http://www.qos.ch", - "moduleVersion": "1.6.1", + "moduleVersion": "1.6.3", "moduleLicense": "LGPL-2.1-only", "moduleLicenseUrl": "https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html" }, @@ -1064,21 +1064,14 @@ { "moduleName": "io.swagger.core.v3:swagger-annotations-jakarta", "moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-annotations", - "moduleVersion": "2.2.46", + "moduleVersion": "2.2.47", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, { "moduleName": "io.swagger.core.v3:swagger-annotations-jakarta", "moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-annotations", - "moduleVersion": "2.2.47", - "moduleLicense": "Apache License, Version 2.0", - "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" - }, - { - "moduleName": "io.swagger.core.v3:swagger-core-jakarta", - "moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-core", - "moduleVersion": "2.2.46", + "moduleVersion": "2.2.53", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, @@ -1090,9 +1083,9 @@ "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, { - "moduleName": "io.swagger.core.v3:swagger-models-jakarta", - "moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-models", - "moduleVersion": "2.2.46", + "moduleName": "io.swagger.core.v3:swagger-core-jakarta", + "moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-core", + "moduleVersion": "2.2.53", "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, @@ -1103,6 +1096,13 @@ "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, + { + "moduleName": "io.swagger.core.v3:swagger-models-jakarta", + "moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-models", + "moduleVersion": "2.2.53", + "moduleLicense": "Apache License, Version 2.0", + "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" + }, { "moduleName": "jakarta.activation:jakarta.activation-api", "moduleUrl": "https://www.eclipse.org", @@ -2304,7 +2304,7 @@ }, { "moduleName": "org.simplejavamail:core-module", - "moduleVersion": "9.3.1", + "moduleVersion": "9.3.2", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, @@ -2317,13 +2317,13 @@ }, { "moduleName": "org.simplejavamail:outlook-module", - "moduleVersion": "9.3.1", + "moduleVersion": "9.3.2", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, { "moduleName": "org.simplejavamail:simple-java-mail", - "moduleVersion": "9.3.1", + "moduleVersion": "9.3.2", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, @@ -2343,10 +2343,10 @@ }, { "moduleName": "org.snakeyaml:snakeyaml-engine", - "moduleUrl": "https://bitbucket.org/snakeyaml/snakeyaml-engine", - "moduleVersion": "3.0.1", + "moduleUrl": "https://codeberg.org/snakeyaml/snakeyaml-engine", + "moduleVersion": "3.1.1", "moduleLicense": "Apache License, Version 2.0", - "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" + "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" }, { "moduleName": "org.springdoc:springdoc-openapi-starter-common", diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/form/FormFieldBundleTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/form/FormFieldBundleTest.java new file mode 100644 index 0000000000..8082949c8e --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/form/FormFieldBundleTest.java @@ -0,0 +1,374 @@ +package stirling.software.SPDF.controller.api.form; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; +import org.apache.pdfbox.pdmodel.interactive.form.PDField; +import org.apache.pdfbox.pdmodel.interactive.form.PDNonTerminalField; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.io.Resource; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockMultipartFile; + +import stirling.software.common.model.FormFieldWithCoordinates; +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.util.FormUtils; +import stirling.software.common.util.TempFile; +import stirling.software.common.util.TempFileManager; + +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; + +/** + * Drives ?includeFields=true across a spread of real form shapes, checking the bundled list stays + * interchangeable with the follow-up request it exists to remove. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("edit-fields field bundle") +class FormFieldBundleTest { + + /** Set to a directory to dump the produced archives for the frontend reader's fixtures. */ + private static final String FIXTURE_DIR = System.getProperty("bundle.fixtures"); + + @Mock private CustomPDFDocumentFactory pdfDocumentFactory; + @Mock private TempFileManager tempFileManager; + @InjectMocks private FormFillController controller; + + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() throws Exception { + lenient() + .when(tempFileManager.createManagedTempFile(anyString())) + .thenAnswer( + invocation -> { + File file = + Files.createTempFile( + "bundle", invocation.getArgument(0)) + .toFile(); + TempFile temp = mock(TempFile.class); + lenient().when(temp.getFile()).thenReturn(file); + lenient().when(temp.getPath()).thenReturn(file.toPath()); + return temp; + }); + objectMapper = JsonMapper.builder().build(); + var field = FormFillController.class.getDeclaredField("objectMapper"); + field.setAccessible(true); + field.set(controller, objectMapper); + } + + // -- document shapes ---------------------------------------------- + + private record Style( + String name, int pages, int rotation, List fields) {} + + private static FormUtils.NewFormFieldDefinition field( + String name, String type, int page, float y, List options) { + return new FormUtils.NewFormFieldDefinition( + name, null, type, page, 50f, y, 200f, 20f, null, null, options, null, null, null, + null, null, null, null); + } + + static List