From 4d349c047b907bc3171d3d123d9368d43e8f7e3f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bal=C3=A1zs=20Sz=C3=BCcs?=
<127139797+balazs-szucs@users.noreply.github.com>
Date: Tue, 11 Nov 2025 00:41:26 +0100
Subject: [PATCH] [V2] feat(delete-form,modify-form,fill-form,extract-forms):
add delete, modify, fill, and extract form functionality (#4830)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
# Description of Changes
TLDR
- Adds `/api/v1/form/fields`, `/fill`, `/modify-fields`, and
`/delete-fields` endpoints for end-to-end AcroForm workflows.
- Centralizes form field detection, filling, modification, and deletion
logic in `FormUtils` with strict type handling.
- Introduces `FormPayloadParser` for resilient JSON parsing across
legacy flat payloads and new structured payloads.
- Reuses and extends `FormCopyUtils` plus `FormFieldTypeSupport` to
create, clone, and normalize widget properties when transforming forms.
### Implementation Details
- `FormFillController` updates the new multipart APIs, and streams
updated documents or metadata responses.
- `FormUtils` now owns extraction, template building, value application
(including flattening strategies), and field CRUD helpers used by the
controller endpoints.
- `FormPayloadParser` normalizes request bodies: accepts flat key/value
maps, combined `fields` arrays, or nested templates, returning
deterministic LinkedHashMap ordering for repeatable fills.
- `FormFieldTypeSupport` encapsulates per-type creation, value copying,
default appearance, and option handling; utilized by both modification
flows and `FormCopyUtils` transformations.
- `FormCopyUtils` exposes shared routines for making widgets across
documents
### API Surface (Multipart Form Data)
- `POST /api/v1/form/fields` -> returns `FormUtils.FormFieldExtraction`
with ordered `FormFieldInfo` records plus a fill template.
- `POST /api/v1/form/fill` -> applies parsed values via
`FormUtils.applyFieldValues`; optional `flatten` renders appearances
while respecting strict validation.
- `POST /api/v1/form/modify-fields` -> updates existing fields in-place
using `FormUtils.modifyFormFields` with definitions parsed from
`updates` payloads.
- `POST /api/v1/form/delete-fields` -> removes named fields after
`FormPayloadParser.parseNameList` deduplication and validation.
### Individual endpoints:
### Data Validation & Type Safety
- Field type inference (`detectFieldType`) and choice option resolution
ensure only supported values are written; checkbox mapping uses export
states and boolean heuristics.
- Choice inputs pass through `filterChoiceSelections` /
`filterSingleChoiceSelection` to reject invalid entries and provide
actionable logs.
- Text fills leverage `setTextValue` to merge inline formatting
resources and regenerate appearances when necessary.
- `applyFieldValues` supports strict mode (default) to raise when
unknown fields are supplied, preventing silent data loss.
### Automation Workflow Support
The `/fill` and `/fields` endpoints are designed to work together for
automated form processing. The workflow is straightforward: extract the
form structure, modify the values, and submit for filling.
How It Works:
1. The `/fields` endpoint extracts all form field metadata from your PDF
2. You modify the returned JSON to set the desired values for each field
3. The `/fill` endpoint accepts this same JSON structure to populate the
form
Example Workflow:
```bash
# Step 1: Extract form structure and save to fields.json
curl -o fields.json \
-F file=@Form.pdf \
http://localhost:8080/api/v1/form/fields
# Step 2: Edit fields.json to update the "value" property for each field
# (Use your preferred text editor or script to modify the values)
# Step 3: Fill the form using the modified JSON
curl -o filled-form.pdf \
-F file=@Form.pdf \
-F data=@fields.json \
http://localhost:8080/api/v1/form/fill
```
#### How to Fill the `template` JSON
The `template` (your data) is filled by creating key-value pairs that
match the "rules" defined in the `fields` array (the schema).
1. Find the Field `name`: Look in the `fields` array for the `name` of
the field you want to fill.
* *Example:* `{"name": "Agent of Dependent", "type": "text", ...}`
2. Use `name` as the Key: This `name` becomes the key (in quotes) in
your `template` object.
* *Example:* `{"Agent of Dependent": ...}`
3. Find the `type`: Look at the `type` for that same field. This tells
you what *kind* of value to provide.
* `"type": "text"` requires a string (e.g., `"John Smith"`).
* `"type": "checkbox"` requires a boolean (e.g., `true` or `false`).
* `"type": "combobox"` requires a string that *exactly matches* one of
its `"options"` (e.g., `"Choice 1"`).
4. Add the Value: This matching value becomes the value for your key.
#### Correct Examples
* For a Textbox:
* Schema: `{"name": "Agent of Dependent", "type": "text", ...}`
* Template: `{"Agent of Dependent": "Mary Jane"}`
* For a Checkbox:
* Schema: `{"name": "Option 2", "type": "checkbox", ...}`
* Template: `{"Option 2": true}`
* For a Dropdown (Combobox):
* Schema: `{"name": "Dropdown2", "type": "combobox", "options": ["Choice
1", "Choice 2", ...] ...}`
* Template: `{"Dropdown2": "Choice 1"}`
### Incorrect Examples (These Will Error)
* Wrong Type: `{"Option 2": "Checked"}`
* Error: "Option 2" is a `checkbox` and expects `true` or `false`, not a
string.
* Wrong Option: `{"Dropdown2": "Choice 99"}`
* Error: `"Choice 99"` is not listed in the `options` for "Dropdown2".
### For people manually doing this
For users filling forms manually, there's a simplified format that
focuses only on field names and values:
```json
{
"FullName": "",
"ID": "",
"Gender": "Off",
"Married": false,
"City": "[]"
}
```
This format is easier to work with when you're manually editing the
JSON. You can skip the full metadata structure (type, label, required,
etc.) and just provide the field names with their values.
Important caveat: Even though the type information isn't visible in this
simplified format, type validation is still enforced by PDF viewers.
This simplified format just makes manual editing more convenient while
maintaining data integrity.
Please note: this suffers from:
https://issues.apache.org/jira/browse/PDFBOX-5962
Closes https://github.com/Stirling-Tools/Stirling-PDF/issues/237
Closes https://github.com/Stirling-Tools/Stirling-PDF/issues/3569
---
## Checklist
### General
- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [x] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings
### Documentation
- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)
### Translations (if applicable)
- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)
### UI Changes (if applicable)
- [x] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)
### Testing (if applicable)
- [x] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
---------
Signed-off-by: Balázs Szücs
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
---
.../software/common/util/FormUtils.java | 658 ------
.../common/util/RegexPatternUtils.java | 49 +
.../SPDF/config/EndpointConfiguration.java | 6 +
.../api/MultiPageLayoutController.java | 21 -
.../api/form/FormFillController.java | 215 ++
.../api/form/FormPayloadParser.java | 295 +++
.../filter/UserAuthenticationFilter.java | 3 +-
.../proprietary/util/FormCopyUtils.java | 345 ++++
.../util/FormFieldTypeSupport.java | 368 ++++
.../software/proprietary/util/FormUtils.java | 1762 +++++++++++++++++
.../proprietary/util/FormUtilsTest.java | 114 ++
11 files changed, 3156 insertions(+), 680 deletions(-)
delete mode 100644 app/common/src/main/java/stirling/software/common/util/FormUtils.java
create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/controller/api/form/FormFillController.java
create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/controller/api/form/FormPayloadParser.java
create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/util/FormCopyUtils.java
create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/util/FormFieldTypeSupport.java
create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/util/FormUtils.java
create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/util/FormUtilsTest.java
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
deleted file mode 100644
index 19cda95ed..000000000
--- a/app/common/src/main/java/stirling/software/common/util/FormUtils.java
+++ /dev/null
@@ -1,658 +0,0 @@
-package stirling.software.common.util;
-
-import java.io.IOException;
-import java.lang.reflect.Method;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-
-import org.apache.pdfbox.cos.COSName;
-import org.apache.pdfbox.pdmodel.PDDocument;
-import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
-import org.apache.pdfbox.pdmodel.PDPage;
-import org.apache.pdfbox.pdmodel.PDResources;
-import org.apache.pdfbox.pdmodel.common.PDRectangle;
-import org.apache.pdfbox.pdmodel.font.PDType1Font;
-import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
-import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
-import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
-import org.apache.pdfbox.pdmodel.interactive.form.*;
-
-import lombok.extern.slf4j.Slf4j;
-
-@Slf4j
-public final class FormUtils {
-
- private FormUtils() {}
-
- public static boolean hasAnyRotatedPage(PDDocument document) {
- try {
- for (PDPage page : document.getPages()) {
- int rot = page.getRotation();
- int norm = ((rot % 360) + 360) % 360;
- if (norm != 0) {
- return true;
- }
- }
- } catch (Exception e) {
- log.warn("Failed to inspect page rotations: {}", e.getMessage(), e);
- }
- return false;
- }
-
- public static void copyAndTransformFormFields(
- PDDocument sourceDocument,
- PDDocument newDocument,
- int totalPages,
- int pagesPerSheet,
- int cols,
- int rows,
- float cellWidth,
- float cellHeight)
- throws IOException {
-
- PDDocumentCatalog sourceCatalog = sourceDocument.getDocumentCatalog();
- PDAcroForm sourceAcroForm = sourceCatalog.getAcroForm();
-
- if (sourceAcroForm == null || sourceAcroForm.getFields().isEmpty()) {
- return;
- }
-
- PDDocumentCatalog newCatalog = newDocument.getDocumentCatalog();
- PDAcroForm newAcroForm = new PDAcroForm(newDocument);
- newCatalog.setAcroForm(newAcroForm);
-
- PDResources dr = new PDResources();
- PDType1Font helvetica = new PDType1Font(Standard14Fonts.FontName.HELVETICA);
- PDType1Font zapfDingbats = new PDType1Font(Standard14Fonts.FontName.ZAPF_DINGBATS);
- dr.put(COSName.getPDFName("Helv"), helvetica);
- dr.put(COSName.getPDFName("ZaDb"), zapfDingbats);
- newAcroForm.setDefaultResources(dr);
- newAcroForm.setDefaultAppearance("/Helv 12 Tf 0 g");
-
- // Do not mutate the source AcroForm; skip bad widgets during copy
- newAcroForm.setNeedAppearances(true);
-
- Map fieldNameCounters = new HashMap<>();
-
- // Build widget -> field map once for efficient lookups
- Map widgetFieldMap = buildWidgetFieldMap(sourceAcroForm);
-
- for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
- PDPage sourcePage = sourceDocument.getPage(pageIndex);
- List annotations = sourcePage.getAnnotations();
-
- if (annotations.isEmpty()) {
- continue;
- }
-
- int destinationPageIndex = pageIndex / pagesPerSheet;
- int adjustedPageIndex = pageIndex % pagesPerSheet;
- int rowIndex = adjustedPageIndex / cols;
- int colIndex = adjustedPageIndex % cols;
-
- if (destinationPageIndex >= newDocument.getNumberOfPages()) {
- continue;
- }
-
- PDPage destinationPage = newDocument.getPage(destinationPageIndex);
- PDRectangle sourceRect = sourcePage.getMediaBox();
-
- float scaleWidth = cellWidth / sourceRect.getWidth();
- float scaleHeight = cellHeight / sourceRect.getHeight();
- float scale = Math.min(scaleWidth, scaleHeight);
-
- float x = colIndex * cellWidth + (cellWidth - sourceRect.getWidth() * scale) / 2;
- float y =
- destinationPage.getMediaBox().getHeight()
- - ((rowIndex + 1) * cellHeight
- - (cellHeight - sourceRect.getHeight() * scale) / 2);
-
- copyBasicFormFields(
- sourceAcroForm,
- newAcroForm,
- sourcePage,
- destinationPage,
- x,
- y,
- scale,
- pageIndex,
- fieldNameCounters,
- widgetFieldMap);
- }
-
- // Refresh appearances to ensure widgets render correctly across viewers
- try {
- // Use reflection to avoid compile-time dependency on PDFBox version
- Method m = newAcroForm.getClass().getMethod("refreshAppearances");
- m.invoke(newAcroForm);
- } catch (NoSuchMethodException nsme) {
- log.warn(
- "AcroForm.refreshAppearances() not available in this PDFBox version; relying on NeedAppearances.");
- } catch (Throwable t) {
- log.warn("Failed to refresh field appearances via AcroForm: {}", t.getMessage(), t);
- }
- }
-
- private static void copyBasicFormFields(
- PDAcroForm sourceAcroForm,
- PDAcroForm newAcroForm,
- PDPage sourcePage,
- PDPage destinationPage,
- float offsetX,
- float offsetY,
- float scale,
- int pageIndex,
- Map fieldNameCounters,
- Map widgetFieldMap) {
-
- try {
- List sourceAnnotations = sourcePage.getAnnotations();
- List destinationAnnotations = destinationPage.getAnnotations();
-
- for (PDAnnotation annotation : sourceAnnotations) {
- if (annotation instanceof PDAnnotationWidget widgetAnnotation) {
- if (widgetAnnotation.getRectangle() == null) {
- continue;
- }
- PDField sourceField =
- widgetFieldMap != null ? widgetFieldMap.get(widgetAnnotation) : null;
- if (sourceField == null) {
- continue; // skip widgets without a matching field
- }
- if (sourceField instanceof PDTextField pdtextfield) {
- createSimpleTextField(
- newAcroForm,
- destinationPage,
- destinationAnnotations,
- pdtextfield,
- widgetAnnotation,
- offsetX,
- offsetY,
- scale,
- pageIndex,
- fieldNameCounters);
- } else if (sourceField instanceof PDCheckBox pdCheckBox) {
- createSimpleCheckBoxField(
- newAcroForm,
- destinationPage,
- destinationAnnotations,
- pdCheckBox,
- widgetAnnotation,
- offsetX,
- offsetY,
- scale,
- pageIndex,
- fieldNameCounters);
- } else if (sourceField instanceof PDRadioButton pdRadioButton) {
- createSimpleRadioButtonField(
- newAcroForm,
- destinationPage,
- destinationAnnotations,
- pdRadioButton,
- widgetAnnotation,
- offsetX,
- offsetY,
- scale,
- pageIndex,
- fieldNameCounters);
- } else if (sourceField instanceof PDComboBox pdComboBox) {
- createSimpleComboBoxField(
- newAcroForm,
- destinationPage,
- destinationAnnotations,
- pdComboBox,
- widgetAnnotation,
- offsetX,
- offsetY,
- scale,
- pageIndex,
- fieldNameCounters);
- } else if (sourceField instanceof PDListBox pdlistbox) {
- createSimpleListBoxField(
- newAcroForm,
- destinationPage,
- destinationAnnotations,
- pdlistbox,
- widgetAnnotation,
- offsetX,
- offsetY,
- scale,
- pageIndex,
- fieldNameCounters);
- } else if (sourceField instanceof PDSignatureField pdSignatureField) {
- createSimpleSignatureField(
- newAcroForm,
- destinationPage,
- destinationAnnotations,
- pdSignatureField,
- widgetAnnotation,
- offsetX,
- offsetY,
- scale,
- pageIndex,
- fieldNameCounters);
- } else if (sourceField instanceof PDPushButton pdPushButton) {
- createSimplePushButtonField(
- newAcroForm,
- destinationPage,
- destinationAnnotations,
- pdPushButton,
- widgetAnnotation,
- offsetX,
- offsetY,
- scale,
- pageIndex,
- fieldNameCounters);
- }
- }
- }
- } catch (Exception e) {
- log.warn(
- "Failed to copy basic form fields for page {}: {}",
- pageIndex,
- e.getMessage(),
- e);
- }
- }
-
- private static void createSimpleTextField(
- PDAcroForm newAcroForm,
- PDPage destinationPage,
- List destinationAnnotations,
- PDTextField sourceField,
- PDAnnotationWidget sourceWidget,
- float offsetX,
- float offsetY,
- float scale,
- int pageIndex,
- Map fieldNameCounters) {
-
- try {
- PDTextField newTextField = new PDTextField(newAcroForm);
- newTextField.setDefaultAppearance("/Helv 12 Tf 0 g");
-
- boolean initialized =
- initializeFieldWithWidget(
- newAcroForm,
- destinationPage,
- destinationAnnotations,
- newTextField,
- sourceField.getPartialName(),
- "textField",
- sourceWidget,
- offsetX,
- offsetY,
- scale,
- pageIndex,
- fieldNameCounters);
-
- if (!initialized) {
- return;
- }
-
- if (sourceField.getValueAsString() != null) {
- newTextField.setValue(sourceField.getValueAsString());
- }
-
- } catch (Exception e) {
- log.warn(
- "Failed to create text field '{}': {}",
- sourceField.getPartialName(),
- e.getMessage(),
- e);
- }
- }
-
- private static void createSimpleCheckBoxField(
- PDAcroForm newAcroForm,
- PDPage destinationPage,
- List destinationAnnotations,
- PDCheckBox sourceField,
- PDAnnotationWidget sourceWidget,
- float offsetX,
- float offsetY,
- float scale,
- int pageIndex,
- Map fieldNameCounters) {
-
- try {
- PDCheckBox newCheckBox = new PDCheckBox(newAcroForm);
-
- boolean initialized =
- initializeFieldWithWidget(
- newAcroForm,
- destinationPage,
- destinationAnnotations,
- newCheckBox,
- sourceField.getPartialName(),
- "checkBox",
- sourceWidget,
- offsetX,
- offsetY,
- scale,
- pageIndex,
- fieldNameCounters);
-
- if (!initialized) {
- return;
- }
-
- if (sourceField.isChecked()) {
- newCheckBox.check();
- } else {
- newCheckBox.unCheck();
- }
-
- } catch (Exception e) {
- log.warn(
- "Failed to create checkbox field '{}': {}",
- sourceField.getPartialName(),
- e.getMessage(),
- e);
- }
- }
-
- private static void createSimpleRadioButtonField(
- PDAcroForm newAcroForm,
- PDPage destinationPage,
- List destinationAnnotations,
- PDRadioButton sourceField,
- PDAnnotationWidget sourceWidget,
- float offsetX,
- float offsetY,
- float scale,
- int pageIndex,
- Map fieldNameCounters) {
-
- try {
- PDRadioButton newRadioButton = new PDRadioButton(newAcroForm);
-
- boolean initialized =
- initializeFieldWithWidget(
- newAcroForm,
- destinationPage,
- destinationAnnotations,
- newRadioButton,
- sourceField.getPartialName(),
- "radioButton",
- sourceWidget,
- offsetX,
- offsetY,
- scale,
- pageIndex,
- fieldNameCounters);
-
- if (!initialized) {
- return;
- }
-
- if (sourceField.getExportValues() != null) {
- newRadioButton.setExportValues(sourceField.getExportValues());
- }
- if (sourceField.getValue() != null) {
- newRadioButton.setValue(sourceField.getValue());
- }
- } catch (Exception e) {
- log.warn(
- "Failed to create radio button field '{}': {}",
- sourceField.getPartialName(),
- e.getMessage(),
- e);
- }
- }
-
- private static void createSimpleComboBoxField(
- PDAcroForm newAcroForm,
- PDPage destinationPage,
- List destinationAnnotations,
- PDComboBox sourceField,
- PDAnnotationWidget sourceWidget,
- float offsetX,
- float offsetY,
- float scale,
- int pageIndex,
- Map fieldNameCounters) {
-
- try {
- PDComboBox newComboBox = new PDComboBox(newAcroForm);
-
- boolean initialized =
- initializeFieldWithWidget(
- newAcroForm,
- destinationPage,
- destinationAnnotations,
- newComboBox,
- sourceField.getPartialName(),
- "comboBox",
- sourceWidget,
- offsetX,
- offsetY,
- scale,
- pageIndex,
- fieldNameCounters);
-
- if (!initialized) {
- return;
- }
-
- if (sourceField.getOptions() != null) {
- newComboBox.setOptions(sourceField.getOptions());
- }
- if (sourceField.getValue() != null && !sourceField.getValue().isEmpty()) {
- newComboBox.setValue(sourceField.getValue());
- }
- } catch (Exception e) {
- log.warn(
- "Failed to create combo box field '{}': {}",
- sourceField.getPartialName(),
- e.getMessage(),
- e);
- }
- }
-
- private static void createSimpleListBoxField(
- PDAcroForm newAcroForm,
- PDPage destinationPage,
- List destinationAnnotations,
- PDListBox sourceField,
- PDAnnotationWidget sourceWidget,
- float offsetX,
- float offsetY,
- float scale,
- int pageIndex,
- Map fieldNameCounters) {
-
- try {
- PDListBox newListBox = new PDListBox(newAcroForm);
-
- boolean initialized =
- initializeFieldWithWidget(
- newAcroForm,
- destinationPage,
- destinationAnnotations,
- newListBox,
- sourceField.getPartialName(),
- "listBox",
- sourceWidget,
- offsetX,
- offsetY,
- scale,
- pageIndex,
- fieldNameCounters);
-
- if (!initialized) {
- return;
- }
-
- if (sourceField.getOptions() != null) {
- newListBox.setOptions(sourceField.getOptions());
- }
- if (sourceField.getValue() != null && !sourceField.getValue().isEmpty()) {
- newListBox.setValue(sourceField.getValue());
- }
- } catch (Exception e) {
- log.warn(
- "Failed to create list box field '{}': {}",
- sourceField.getPartialName(),
- e.getMessage(),
- e);
- }
- }
-
- private static void createSimpleSignatureField(
- PDAcroForm newAcroForm,
- PDPage destinationPage,
- List destinationAnnotations,
- PDSignatureField sourceField,
- PDAnnotationWidget sourceWidget,
- float offsetX,
- float offsetY,
- float scale,
- int pageIndex,
- Map fieldNameCounters) {
-
- try {
- PDSignatureField newSignatureField = new PDSignatureField(newAcroForm);
-
- boolean initialized =
- initializeFieldWithWidget(
- newAcroForm,
- destinationPage,
- destinationAnnotations,
- newSignatureField,
- sourceField.getPartialName(),
- "signature",
- sourceWidget,
- offsetX,
- offsetY,
- scale,
- pageIndex,
- fieldNameCounters);
-
- if (!initialized) {
- return;
- }
- } catch (Exception e) {
- log.warn(
- "Failed to create signature field '{}': {}",
- sourceField.getPartialName(),
- e.getMessage(),
- e);
- }
- }
-
- private static void createSimplePushButtonField(
- PDAcroForm newAcroForm,
- PDPage destinationPage,
- List destinationAnnotations,
- PDPushButton sourceField,
- PDAnnotationWidget sourceWidget,
- float offsetX,
- float offsetY,
- float scale,
- int pageIndex,
- Map fieldNameCounters) {
-
- try {
- PDPushButton newPushButton = new PDPushButton(newAcroForm);
-
- boolean initialized =
- initializeFieldWithWidget(
- newAcroForm,
- destinationPage,
- destinationAnnotations,
- newPushButton,
- sourceField.getPartialName(),
- "pushButton",
- sourceWidget,
- offsetX,
- offsetY,
- scale,
- pageIndex,
- fieldNameCounters);
-
- } catch (Exception e) {
- log.warn(
- "Failed to create push button field '{}': {}",
- sourceField.getPartialName(),
- e.getMessage(),
- e);
- }
- }
-
- private static boolean initializeFieldWithWidget(
- PDAcroForm newAcroForm,
- PDPage destinationPage,
- List destinationAnnotations,
- T newField,
- String originalName,
- String fallbackName,
- PDAnnotationWidget sourceWidget,
- float offsetX,
- float offsetY,
- float scale,
- int pageIndex,
- Map fieldNameCounters) {
-
- String baseName = (originalName != null) ? originalName : fallbackName;
- String newFieldName = generateUniqueFieldName(baseName, pageIndex, fieldNameCounters);
- newField.setPartialName(newFieldName);
-
- PDAnnotationWidget newWidget = new PDAnnotationWidget();
- PDRectangle sourceRect = sourceWidget.getRectangle();
- if (sourceRect == null) {
- return false;
- }
-
- float newX = (sourceRect.getLowerLeftX() * scale) + offsetX;
- float newY = (sourceRect.getLowerLeftY() * scale) + offsetY;
- float newWidth = sourceRect.getWidth() * scale;
- float newHeight = sourceRect.getHeight() * scale;
- newWidget.setRectangle(new PDRectangle(newX, newY, newWidth, newHeight));
- newWidget.setPage(destinationPage);
-
- newField.getWidgets().add(newWidget);
- newWidget.setParent(newField);
- newAcroForm.getFields().add(newField);
- destinationAnnotations.add(newWidget);
- return true;
- }
-
- private static String generateUniqueFieldName(
- String originalName, int pageIndex, Map fieldNameCounters) {
- String baseName = "page" + pageIndex + "_" + originalName;
-
- Integer counter = fieldNameCounters.get(baseName);
- if (counter == null) {
- counter = 0;
- } else {
- counter++;
- }
- fieldNameCounters.put(baseName, counter);
-
- return counter == 0 ? baseName : baseName + "_" + counter;
- }
-
- private static Map buildWidgetFieldMap(PDAcroForm acroForm) {
- Map map = new HashMap<>();
- if (acroForm == null) {
- return map;
- }
- try {
- for (PDField field : acroForm.getFieldTree()) {
- List widgets = field.getWidgets();
- if (widgets != null) {
- for (PDAnnotationWidget w : widgets) {
- if (w != null) {
- map.put(w, field);
- }
- }
- }
- }
- } catch (Exception e) {
- log.warn("Failed to build widget->field map: {}", e.getMessage(), e);
- }
- return map;
- }
-}
diff --git a/app/common/src/main/java/stirling/software/common/util/RegexPatternUtils.java b/app/common/src/main/java/stirling/software/common/util/RegexPatternUtils.java
index 8858c99bf..778e42ca4 100644
--- a/app/common/src/main/java/stirling/software/common/util/RegexPatternUtils.java
+++ b/app/common/src/main/java/stirling/software/common/util/RegexPatternUtils.java
@@ -1,5 +1,6 @@
package stirling.software.common.util;
+import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;
@@ -241,6 +242,11 @@ public final class RegexPatternUtils {
return getPattern("\\s+");
}
+ /** Pattern for matching punctuation characters */
+ public Pattern getPunctuationPattern() {
+ return getPattern("[\\p{Punct}]+");
+ }
+
/** Pattern for matching newlines (Windows and Unix style) */
public Pattern getNewlinesPattern() {
return getPattern("\\r?\\n");
@@ -286,6 +292,24 @@ public final class RegexPatternUtils {
return getPattern("[^a-zA-Z0-9 ]");
}
+ /** Pattern for removing bracketed indices like [0], [Child], etc. in field names */
+ public Pattern getFormFieldBracketPattern() {
+ return getPattern("\\[[^\\]]*\\]");
+ }
+
+ /** Pattern that replaces underscores or hyphens with spaces */
+ public Pattern getUnderscoreHyphenPattern() {
+ return getPattern("[-_]+");
+ }
+
+ /**
+ * Pattern that matches camelCase or alpha-numeric boundaries to allow inserting spaces.
+ * Examples: firstName -> first Name, field1 -> field 1, A5Size -> A5 Size
+ */
+ public Pattern getCamelCaseBoundaryPattern() {
+ return getPattern("(?<=[a-z])(?=[A-Z])|(?<=[A-Za-z])(?=\\d)|(?<=\\d)(?=[A-Za-z])");
+ }
+
/** Pattern for removing angle brackets */
public Pattern getAngleBracketsPattern() {
return getPattern("[<>]");
@@ -335,6 +359,26 @@ public final class RegexPatternUtils {
return getPattern("[1-9][0-9]{0,2}");
}
+ /**
+ * Pattern for very simple generic field tokens such as "field", "text", "checkbox" with
+ * optional numeric suffix (e.g. "field 1"). Case-insensitive.
+ */
+ public Pattern getGenericFieldNamePattern() {
+ return getPattern(
+ "^(field|text|checkbox|radio|button|signature|name|value|option|select|choice)(\\s*\\d+)?$",
+ Pattern.CASE_INSENSITIVE);
+ }
+
+ /** Pattern for short identifiers like t1, f2, a10 etc. */
+ public Pattern getSimpleFormFieldPattern() {
+ return getPattern("^[A-Za-z]{1,2}\\s*\\d{1,3}$");
+ }
+
+ /** Pattern for optional leading 't' followed by digits, e.g., t1, 1, t 12. */
+ public Pattern getOptionalTNumericPattern() {
+ return getPattern("^(?:t\\s*)?\\d+$", Pattern.CASE_INSENSITIVE);
+ }
+
/** Pattern for validating mathematical expressions */
public Pattern getMathExpressionPattern() {
return getPattern("[0-9n+\\-*/() ]+");
@@ -467,6 +511,11 @@ public final class RegexPatternUtils {
return getPattern("/");
}
+ /** Supported logical types when creating new fields programmatically */
+ public Set getSupportedNewFieldTypes() {
+ return Set.of("text", "checkbox", "combobox", "listbox", "radio", "button", "signature");
+ }
+
/**
* Pre-compile commonly used patterns for immediate availability. This eliminates first-call
* compilation overhead for frequent patterns.
diff --git a/app/core/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java b/app/core/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java
index 2b4fa32d9..0178c2597 100644
--- a/app/core/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java
+++ b/app/core/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java
@@ -294,6 +294,12 @@ public class EndpointConfiguration {
addEndpointToGroup("Other", "replace-and-invert-color-pdf");
addEndpointToGroup("Other", "multi-tool");
+ // Adding form-related endpoints to "Other" group
+ addEndpointToGroup("Other", "fields");
+ addEndpointToGroup("Other", "modify-fields");
+ addEndpointToGroup("Other", "delete-fields");
+ addEndpointToGroup("Other", "fill");
+
// Adding endpoints to "Advance" group
addEndpointToGroup("Advance", "adjust-contrast");
addEndpointToGroup("Advance", "compress-pdf");
diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/MultiPageLayoutController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/MultiPageLayoutController.java
index 40301c63e..1cf33e730 100644
--- a/app/core/src/main/java/stirling/software/SPDF/controller/api/MultiPageLayoutController.java
+++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/MultiPageLayoutController.java
@@ -26,7 +26,6 @@ import stirling.software.SPDF.model.api.general.MergeMultiplePagesRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.GeneralApi;
import stirling.software.common.service.CustomPDFDocumentFactory;
-import stirling.software.common.util.FormUtils;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.WebResponseUtils;
@@ -137,26 +136,6 @@ public class MultiPageLayoutController {
contentStream.close();
- // If any source page is rotated, skip form copying/transformation entirely
- boolean hasRotation = FormUtils.hasAnyRotatedPage(sourceDocument);
- if (hasRotation) {
- log.info("Source document has rotated pages; skipping form field copying.");
- } else {
- try {
- FormUtils.copyAndTransformFormFields(
- sourceDocument,
- newDocument,
- totalPages,
- pagesPerSheet,
- cols,
- rows,
- cellWidth,
- cellHeight);
- } catch (Exception e) {
- log.warn("Failed to copy and transform form fields: {}", e.getMessage(), e);
- }
- }
-
sourceDocument.close();
ByteArrayOutputStream baos = new ByteArrayOutputStream();
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/form/FormFillController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/form/FormFillController.java
new file mode 100644
index 000000000..ddc7048bd
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/form/FormFillController.java
@@ -0,0 +1,215 @@
+package stirling.software.proprietary.controller.api.form;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RequestPart;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.multipart.MultipartFile;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import io.github.pixee.security.Filenames;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.tags.Tag;
+
+import lombok.RequiredArgsConstructor;
+
+import stirling.software.common.service.CustomPDFDocumentFactory;
+import stirling.software.common.util.ExceptionUtils;
+import stirling.software.common.util.WebResponseUtils;
+import stirling.software.proprietary.util.FormUtils;
+
+@RestController
+@RequestMapping("/api/v1/form")
+@Tag(name = "Forms", description = "PDF form APIs")
+@RequiredArgsConstructor
+public class FormFillController {
+
+ private final CustomPDFDocumentFactory pdfDocumentFactory;
+ private final ObjectMapper objectMapper;
+
+ private static ResponseEntity saveDocument(PDDocument document, String baseName)
+ throws IOException {
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ document.save(baos);
+ return WebResponseUtils.bytesToWebResponse(baos.toByteArray(), baseName + ".pdf");
+ }
+
+ private static String buildBaseName(MultipartFile file, String suffix) {
+ String original = Filenames.toSimpleFileName(file.getOriginalFilename());
+ if (original == null || original.isBlank()) {
+ original = "document";
+ }
+ if (!original.toLowerCase().endsWith(".pdf")) {
+ return original + "_" + suffix;
+ }
+ String withoutExtension = original.substring(0, original.length() - 4);
+ return withoutExtension + "_" + suffix;
+ }
+
+ private static void requirePdf(MultipartFile file) {
+ if (file == null || file.isEmpty()) {
+ throw ExceptionUtils.createIllegalArgumentException(
+ "error.fileFormatRequired", "{0} must be in PDF format", "file");
+ }
+ }
+
+ private static String decodePart(byte[] payload) {
+ if (payload == null || payload.length == 0) {
+ return null;
+ }
+ return new String(payload, StandardCharsets.UTF_8);
+ }
+
+ @PostMapping(value = "/fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+ @Operation(
+ summary = "Inspect PDF form fields",
+ description = "Returns metadata describing each field in the provided PDF form")
+ public ResponseEntity listFields(
+ @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)
+ throws IOException {
+
+ requirePdf(file);
+ try (PDDocument document = pdfDocumentFactory.load(file, true)) {
+ FormUtils.FormFieldExtraction extraction =
+ FormUtils.extractFieldsWithTemplate(document);
+ return ResponseEntity.ok(extraction);
+ }
+ }
+
+ @PostMapping(value = "/modify-fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+ @Operation(
+ summary = "Modify existing form fields",
+ description =
+ "Updates existing fields in the provided PDF and returns the updated file")
+ public ResponseEntity modifyFields(
+ @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,
+ @RequestPart(value = "updates", required = false) byte[] updatesPayload)
+ throws IOException {
+
+ String rawUpdates = decodePart(updatesPayload);
+ List modifications =
+ FormPayloadParser.parseModificationDefinitions(objectMapper, rawUpdates);
+ if (modifications.isEmpty()) {
+ throw ExceptionUtils.createIllegalArgumentException(
+ "error.dataRequired",
+ "{0} must contain at least one definition",
+ "updates payload");
+ }
+
+ return processSingleFile(
+ file, "updated", document -> FormUtils.modifyFormFields(document, modifications));
+ }
+
+ @PostMapping(value = "/delete-fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+ @Operation(
+ summary = "Delete form fields",
+ description = "Removes the specified fields from the PDF and returns the updated file")
+ public ResponseEntity deleteFields(
+ @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 field names or objects with a name property,"
+ + " matching the /fields response format",
+ example = "[{\"name\":\"Field1\"}]")
+ @RequestPart(value = "names", required = false)
+ byte[] namesPayload)
+ throws IOException {
+
+ String rawNames = decodePart(namesPayload);
+ List names = FormPayloadParser.parseNameList(objectMapper, rawNames);
+ if (names.isEmpty()) {
+ throw ExceptionUtils.createIllegalArgumentException(
+ "error.dataRequired", "{0} must contain at least one value", "names payload");
+ }
+
+ return processSingleFile(
+ file, "updated", document -> FormUtils.deleteFormFields(document, names));
+ }
+
+ @PostMapping(value = "/fill", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+ @Operation(
+ summary = "Fill PDF form fields",
+ description =
+ "Populates the supplied PDF form using values from the provided JSON payload"
+ + " and returns the filled PDF")
+ public ResponseEntity fillForm(
+ @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 of field-value pairs to apply",
+ example = "{\"field\":\"value\"}")
+ @RequestPart(value = "data", required = false)
+ byte[] valuesPayload,
+ @RequestParam(value = "flatten", defaultValue = "false") boolean flatten)
+ throws IOException {
+
+ String rawValues = decodePart(valuesPayload);
+ Map values = FormPayloadParser.parseValueMap(objectMapper, rawValues);
+
+ return processSingleFile(
+ file,
+ "filled",
+ document -> FormUtils.applyFieldValues(document, values, flatten, true));
+ }
+
+ private ResponseEntity processSingleFile(
+ MultipartFile file, String suffix, DocumentProcessor processor) throws IOException {
+ requirePdf(file);
+
+ String baseName = buildBaseName(file, suffix);
+ try (PDDocument document = pdfDocumentFactory.load(file)) {
+ processor.accept(document);
+ return saveDocument(document, baseName);
+ }
+ }
+
+ @FunctionalInterface
+ private interface DocumentProcessor {
+ void accept(PDDocument document) throws IOException;
+ }
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/form/FormPayloadParser.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/form/FormPayloadParser.java
new file mode 100644
index 000000000..2efffb21d
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/form/FormPayloadParser.java
@@ -0,0 +1,295 @@
+package stirling.software.proprietary.controller.api.form;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import stirling.software.common.util.ExceptionUtils;
+import stirling.software.proprietary.util.FormUtils;
+
+final class FormPayloadParser {
+
+ private static final String KEY_FIELDS = "fields";
+ private static final String KEY_NAME = "name";
+ private static final String KEY_TARGET_NAME = "targetName";
+ private static final String KEY_FIELD_NAME = "fieldName";
+ private static final String KEY_FIELD = "field";
+ private static final String KEY_VALUE = "value";
+ private static final String KEY_DEFAULT_VALUE = "defaultValue";
+
+ private static final TypeReference