mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-12-30 20:06:30 +01:00
Merge branch 'feature/v2/selected-pageeditor-rework' of https://github.com/Stirling-Tools/Stirling-PDF into feature/v2/selected-pageeditor-rework
This commit is contained in:
commit
35b7e78bbe
@ -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<String, Integer> fieldNameCounters = new HashMap<>();
|
||||
|
||||
// Build widget -> field map once for efficient lookups
|
||||
Map<PDAnnotationWidget, PDField> widgetFieldMap = buildWidgetFieldMap(sourceAcroForm);
|
||||
|
||||
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
|
||||
PDPage sourcePage = sourceDocument.getPage(pageIndex);
|
||||
List<PDAnnotation> 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<String, Integer> fieldNameCounters,
|
||||
Map<PDAnnotationWidget, PDField> widgetFieldMap) {
|
||||
|
||||
try {
|
||||
List<PDAnnotation> sourceAnnotations = sourcePage.getAnnotations();
|
||||
List<PDAnnotation> 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<PDAnnotation> destinationAnnotations,
|
||||
PDTextField sourceField,
|
||||
PDAnnotationWidget sourceWidget,
|
||||
float offsetX,
|
||||
float offsetY,
|
||||
float scale,
|
||||
int pageIndex,
|
||||
Map<String, Integer> 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<PDAnnotation> destinationAnnotations,
|
||||
PDCheckBox sourceField,
|
||||
PDAnnotationWidget sourceWidget,
|
||||
float offsetX,
|
||||
float offsetY,
|
||||
float scale,
|
||||
int pageIndex,
|
||||
Map<String, Integer> 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<PDAnnotation> destinationAnnotations,
|
||||
PDRadioButton sourceField,
|
||||
PDAnnotationWidget sourceWidget,
|
||||
float offsetX,
|
||||
float offsetY,
|
||||
float scale,
|
||||
int pageIndex,
|
||||
Map<String, Integer> 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<PDAnnotation> destinationAnnotations,
|
||||
PDComboBox sourceField,
|
||||
PDAnnotationWidget sourceWidget,
|
||||
float offsetX,
|
||||
float offsetY,
|
||||
float scale,
|
||||
int pageIndex,
|
||||
Map<String, Integer> 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<PDAnnotation> destinationAnnotations,
|
||||
PDListBox sourceField,
|
||||
PDAnnotationWidget sourceWidget,
|
||||
float offsetX,
|
||||
float offsetY,
|
||||
float scale,
|
||||
int pageIndex,
|
||||
Map<String, Integer> 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<PDAnnotation> destinationAnnotations,
|
||||
PDSignatureField sourceField,
|
||||
PDAnnotationWidget sourceWidget,
|
||||
float offsetX,
|
||||
float offsetY,
|
||||
float scale,
|
||||
int pageIndex,
|
||||
Map<String, Integer> 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<PDAnnotation> destinationAnnotations,
|
||||
PDPushButton sourceField,
|
||||
PDAnnotationWidget sourceWidget,
|
||||
float offsetX,
|
||||
float offsetY,
|
||||
float scale,
|
||||
int pageIndex,
|
||||
Map<String, Integer> 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 <T extends PDTerminalField> boolean initializeFieldWithWidget(
|
||||
PDAcroForm newAcroForm,
|
||||
PDPage destinationPage,
|
||||
List<PDAnnotation> destinationAnnotations,
|
||||
T newField,
|
||||
String originalName,
|
||||
String fallbackName,
|
||||
PDAnnotationWidget sourceWidget,
|
||||
float offsetX,
|
||||
float offsetY,
|
||||
float scale,
|
||||
int pageIndex,
|
||||
Map<String, Integer> 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<String, Integer> 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<PDAnnotationWidget, PDField> buildWidgetFieldMap(PDAcroForm acroForm) {
|
||||
Map<PDAnnotationWidget, PDField> map = new HashMap<>();
|
||||
if (acroForm == null) {
|
||||
return map;
|
||||
}
|
||||
try {
|
||||
for (PDField field : acroForm.getFieldTree()) {
|
||||
List<PDAnnotationWidget> 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;
|
||||
}
|
||||
}
|
||||
@ -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<String> 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.
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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<byte[]> 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<FormUtils.FormFieldExtraction> 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<byte[]> 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<FormUtils.ModifyFormFieldDefinition> 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<byte[]> 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<String> 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<byte[]> 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<String, Object> values = FormPayloadParser.parseValueMap(objectMapper, rawValues);
|
||||
|
||||
return processSingleFile(
|
||||
file,
|
||||
"filled",
|
||||
document -> FormUtils.applyFieldValues(document, values, flatten, true));
|
||||
}
|
||||
|
||||
private ResponseEntity<byte[]> 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;
|
||||
}
|
||||
}
|
||||
@ -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<Map<String, Object>> MAP_TYPE = new TypeReference<>() {};
|
||||
private static final TypeReference<List<FormUtils.ModifyFormFieldDefinition>>
|
||||
MODIFY_FIELD_LIST_TYPE = new TypeReference<>() {};
|
||||
private static final TypeReference<List<String>> STRING_LIST_TYPE = new TypeReference<>() {};
|
||||
|
||||
private FormPayloadParser() {}
|
||||
|
||||
static Map<String, Object> parseValueMap(ObjectMapper objectMapper, String json)
|
||||
throws IOException {
|
||||
if (json == null || json.isBlank()) {
|
||||
return Map.of();
|
||||
}
|
||||
|
||||
JsonNode root;
|
||||
try {
|
||||
root = objectMapper.readTree(json);
|
||||
} catch (IOException e) {
|
||||
// Fallback to legacy direct map parse (will throw again if invalid)
|
||||
return objectMapper.readValue(json, MAP_TYPE);
|
||||
}
|
||||
if (root == null || root.isNull()) {
|
||||
return Map.of();
|
||||
}
|
||||
|
||||
// 1. If payload already a flat object with no special wrapping, keep legacy behavior
|
||||
if (root.isObject()) {
|
||||
// a) Prefer explicit 'template' object if present (new combined /fields response)
|
||||
JsonNode templateNode = root.get("template");
|
||||
if (templateNode != null && templateNode.isObject()) {
|
||||
return objectToLinkedMap(templateNode);
|
||||
}
|
||||
// b) Accept an inline 'fields' array of field definitions (build map from them)
|
||||
JsonNode fieldsNode = root.get(KEY_FIELDS);
|
||||
if (fieldsNode != null && fieldsNode.isArray()) {
|
||||
Map<String, Object> record = extractFieldInfoArray(fieldsNode);
|
||||
if (!record.isEmpty()) {
|
||||
return record;
|
||||
}
|
||||
}
|
||||
// c) Fallback: treat entire object as the value map (legacy behavior)
|
||||
return objectToLinkedMap(root);
|
||||
}
|
||||
|
||||
// 2. If an array was supplied to /fill (non-standard), treat first element as record
|
||||
if (root.isArray()) {
|
||||
if (root.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
JsonNode first = root.get(0);
|
||||
if (first != null && first.isObject()) {
|
||||
if (first.has(KEY_NAME) || first.has(KEY_VALUE) || first.has(KEY_DEFAULT_VALUE)) {
|
||||
return extractFieldInfoArray(root);
|
||||
}
|
||||
return objectToLinkedMap(first);
|
||||
}
|
||||
return Map.of();
|
||||
}
|
||||
|
||||
// 3. Anything else: fallback to strict map parse
|
||||
return objectMapper.readValue(json, MAP_TYPE);
|
||||
}
|
||||
|
||||
static List<FormUtils.ModifyFormFieldDefinition> parseModificationDefinitions(
|
||||
ObjectMapper objectMapper, String json) throws IOException {
|
||||
if (json == null || json.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
return objectMapper.readValue(json, MODIFY_FIELD_LIST_TYPE);
|
||||
}
|
||||
|
||||
static List<String> parseNameList(ObjectMapper objectMapper, String json) throws IOException {
|
||||
if (json == null || json.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
final JsonNode root = objectMapper.readTree(json);
|
||||
if (root == null || root.isNull()) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
final Set<String> names = new LinkedHashSet<>();
|
||||
|
||||
if (root.isArray()) {
|
||||
collectNames(root, names);
|
||||
} else if (root.isObject()) {
|
||||
if (root.has(KEY_FIELDS) && root.get(KEY_FIELDS).isArray()) {
|
||||
collectNames(root.get(KEY_FIELDS), names);
|
||||
} else {
|
||||
final String single = extractName(root);
|
||||
if (nonBlank(single)) {
|
||||
names.add(single);
|
||||
}
|
||||
}
|
||||
} else if (root.isTextual()) {
|
||||
final String single = trimToNull(root.asText());
|
||||
if (single != null) {
|
||||
names.add(single);
|
||||
}
|
||||
}
|
||||
|
||||
if (!names.isEmpty()) {
|
||||
return List.copyOf(names);
|
||||
}
|
||||
|
||||
try {
|
||||
return objectMapper.readValue(json, STRING_LIST_TYPE);
|
||||
} catch (IOException e) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalidFormat",
|
||||
"Invalid {0} format: {1}",
|
||||
"names payload",
|
||||
"expected array of strings or objects with 'name'-like properties");
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, Object> extractFieldInfoArray(JsonNode fieldsNode) {
|
||||
final Map<String, Object> record = new LinkedHashMap<>();
|
||||
if (fieldsNode == null || fieldsNode.isNull() || !fieldsNode.isArray()) {
|
||||
return record;
|
||||
}
|
||||
|
||||
for (JsonNode fieldNode : fieldsNode) {
|
||||
if (fieldNode == null || !fieldNode.isObject()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final String name = extractName(fieldNode);
|
||||
if (!nonBlank(name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
JsonNode valueNode = fieldNode.get(KEY_VALUE);
|
||||
if ((valueNode == null || valueNode.isNull())
|
||||
&& fieldNode.hasNonNull(KEY_DEFAULT_VALUE)) {
|
||||
valueNode = fieldNode.get(KEY_DEFAULT_VALUE);
|
||||
}
|
||||
|
||||
final String normalized = normalizeFieldValue(valueNode);
|
||||
record.put(name, normalized == null ? "" : normalized);
|
||||
}
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
private static String normalizeFieldValue(JsonNode valueNode) {
|
||||
if (valueNode == null || valueNode.isNull()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (valueNode.isArray()) {
|
||||
final List<String> values = new ArrayList<>();
|
||||
for (JsonNode element : valueNode) {
|
||||
final String text = coerceScalarToString(element);
|
||||
if (text != null) {
|
||||
values.add(text);
|
||||
}
|
||||
}
|
||||
return String.join(",", values);
|
||||
}
|
||||
|
||||
if (valueNode.isObject()) {
|
||||
// Preserve object as JSON string
|
||||
return valueNode.toString();
|
||||
}
|
||||
|
||||
// Scalar (text/number/boolean)
|
||||
return coerceScalarToString(valueNode);
|
||||
}
|
||||
|
||||
private static String coerceScalarToString(JsonNode node) {
|
||||
if (node == null || node.isNull()) {
|
||||
return null;
|
||||
}
|
||||
if (node.isTextual()) {
|
||||
return trimToEmpty(node.asText());
|
||||
}
|
||||
if (node.isNumber()) {
|
||||
return node.numberValue().toString();
|
||||
}
|
||||
if (node.isBoolean()) {
|
||||
return Boolean.toString(node.booleanValue());
|
||||
}
|
||||
// Fallback for other scalar-like nodes
|
||||
return trimToEmpty(node.asText());
|
||||
}
|
||||
|
||||
private static void collectNames(JsonNode arrayNode, Set<String> sink) {
|
||||
if (arrayNode == null || !arrayNode.isArray()) {
|
||||
return;
|
||||
}
|
||||
for (JsonNode node : arrayNode) {
|
||||
final String name = extractName(node);
|
||||
if (nonBlank(name)) {
|
||||
sink.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String extractName(JsonNode node) {
|
||||
if (node == null || node.isNull()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (node.isTextual()) {
|
||||
return trimToNull(node.asText());
|
||||
}
|
||||
|
||||
if (node.isObject()) {
|
||||
final String direct = textProperty(node, KEY_NAME, KEY_TARGET_NAME, KEY_FIELD_NAME);
|
||||
if (nonBlank(direct)) {
|
||||
return direct;
|
||||
}
|
||||
final JsonNode field = node.get(KEY_FIELD);
|
||||
if (field != null && field.isObject()) {
|
||||
final String nested =
|
||||
textProperty(field, KEY_NAME, KEY_TARGET_NAME, KEY_FIELD_NAME);
|
||||
if (nonBlank(nested)) {
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String textProperty(JsonNode node, String... keys) {
|
||||
for (String key : keys) {
|
||||
final JsonNode valueNode = node.get(key);
|
||||
final String value = coerceScalarToString(valueNode);
|
||||
if (nonBlank(value)) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Map<String, Object> objectToLinkedMap(JsonNode objectNode) {
|
||||
final Map<String, Object> result = new LinkedHashMap<>();
|
||||
objectNode
|
||||
.fieldNames()
|
||||
.forEachRemaining(
|
||||
key -> {
|
||||
final JsonNode v = objectNode.get(key);
|
||||
if (v == null || v.isNull()) {
|
||||
result.put(key, null);
|
||||
} else if (v.isTextual() || v.isNumber() || v.isBoolean()) {
|
||||
result.put(key, coerceScalarToString(v));
|
||||
} else {
|
||||
result.put(key, v.toString());
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
private static boolean nonBlank(String s) {
|
||||
return s != null && !s.isBlank();
|
||||
}
|
||||
|
||||
private static String trimToNull(String s) {
|
||||
if (s == null) return null;
|
||||
final String t = s.trim();
|
||||
return t.isEmpty() ? null : t;
|
||||
}
|
||||
|
||||
private static String trimToEmpty(String s) {
|
||||
return s == null ? "" : s.trim();
|
||||
}
|
||||
}
|
||||
@ -107,7 +107,8 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
|
||||
}
|
||||
}
|
||||
|
||||
// If we still don't have any authentication, check if it's a public endpoint. If not, deny the request
|
||||
// If we still don't have any authentication, check if it's a public endpoint. If not, deny
|
||||
// the request
|
||||
if (authentication == null || !authentication.isAuthenticated()) {
|
||||
String method = request.getMethod();
|
||||
String contextPath = request.getContextPath();
|
||||
|
||||
@ -0,0 +1,345 @@
|
||||
package stirling.software.proprietary.util;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
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.PDAcroForm;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDTerminalField;
|
||||
|
||||
import lombok.experimental.UtilityClass;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@Slf4j
|
||||
@UtilityClass
|
||||
public class FormCopyUtils {
|
||||
|
||||
public 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 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");
|
||||
|
||||
// Temporarily set NeedAppearances to true during field creation
|
||||
newAcroForm.setNeedAppearances(true);
|
||||
|
||||
Map<String, Integer> fieldNameCounters = new HashMap<>();
|
||||
|
||||
// Build widget -> field map once for efficient lookups
|
||||
Map<PDAnnotationWidget, PDField> widgetFieldMap = buildWidgetFieldMap(sourceAcroForm);
|
||||
|
||||
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
|
||||
PDPage sourcePage = sourceDocument.getPage(pageIndex);
|
||||
List<PDAnnotation> annotations = sourcePage.getAnnotations();
|
||||
|
||||
if (annotations.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
int destinationPageIndex = pageIndex / pagesPerSheet;
|
||||
int adjustedPageIndex = pageIndex % pagesPerSheet;
|
||||
int rowIndex = adjustedPageIndex / cols;
|
||||
int colIndex = adjustedPageIndex % cols;
|
||||
|
||||
if (rowIndex >= rows) {
|
||||
continue;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// Generate appearance streams and embed them authoritatively
|
||||
boolean appearancesGenerated = false;
|
||||
try {
|
||||
newAcroForm.refreshAppearances();
|
||||
appearancesGenerated = true;
|
||||
} catch (NoSuchMethodError nsme) {
|
||||
log.warn(
|
||||
"AcroForm.refreshAppearances() not available in this PDFBox version; "
|
||||
+ "leaving NeedAppearances=true for viewer-side rendering.");
|
||||
} catch (Exception t) {
|
||||
log.warn(
|
||||
"Failed to refresh field appearances via AcroForm: {}. "
|
||||
+ "Leaving NeedAppearances=true as fallback.",
|
||||
t.getMessage(),
|
||||
t);
|
||||
}
|
||||
|
||||
// After successful appearance generation, set NeedAppearances to false
|
||||
// to signal that appearance streams are now embedded authoritatively
|
||||
if (appearancesGenerated) {
|
||||
try {
|
||||
newAcroForm.setNeedAppearances(false);
|
||||
} catch (Exception e) {
|
||||
log.debug(
|
||||
"Failed to set NeedAppearances to false: {}. "
|
||||
+ "Appearances were generated but flag could not be updated.",
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void copyBasicFormFields(
|
||||
PDAcroForm sourceAcroForm,
|
||||
PDAcroForm newAcroForm,
|
||||
PDPage sourcePage,
|
||||
PDPage destinationPage,
|
||||
float offsetX,
|
||||
float offsetY,
|
||||
float scale,
|
||||
int pageIndex,
|
||||
Map<String, Integer> fieldNameCounters,
|
||||
Map<PDAnnotationWidget, PDField> widgetFieldMap) {
|
||||
|
||||
try {
|
||||
List<PDAnnotation> sourceAnnotations = sourcePage.getAnnotations();
|
||||
List<PDAnnotation> 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 PDTerminalField terminalField)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
FormFieldTypeSupport handler = FormFieldTypeSupport.forField(terminalField);
|
||||
if (handler == null) {
|
||||
log.debug(
|
||||
"Skipping unsupported field type '{}' for widget '{}'",
|
||||
sourceField.getClass().getSimpleName(),
|
||||
Optional.ofNullable(sourceField.getFullyQualifiedName())
|
||||
.orElseGet(sourceField::getPartialName));
|
||||
continue;
|
||||
}
|
||||
|
||||
copyFieldUsingHandler(
|
||||
handler,
|
||||
terminalField,
|
||||
newAcroForm,
|
||||
destinationPage,
|
||||
destinationAnnotations,
|
||||
widgetAnnotation,
|
||||
offsetX,
|
||||
offsetY,
|
||||
scale,
|
||||
pageIndex,
|
||||
fieldNameCounters);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn(
|
||||
"Failed to copy basic form fields for page {}: {}",
|
||||
pageIndex,
|
||||
e.getMessage(),
|
||||
e);
|
||||
}
|
||||
}
|
||||
|
||||
private void copyFieldUsingHandler(
|
||||
FormFieldTypeSupport handler,
|
||||
PDTerminalField sourceField,
|
||||
PDAcroForm newAcroForm,
|
||||
PDPage destinationPage,
|
||||
List<PDAnnotation> destinationAnnotations,
|
||||
PDAnnotationWidget sourceWidget,
|
||||
float offsetX,
|
||||
float offsetY,
|
||||
float scale,
|
||||
int pageIndex,
|
||||
Map<String, Integer> fieldNameCounters) {
|
||||
|
||||
try {
|
||||
PDTerminalField newField = handler.createField(newAcroForm);
|
||||
boolean initialized =
|
||||
initializeFieldWithWidget(
|
||||
newAcroForm,
|
||||
destinationPage,
|
||||
destinationAnnotations,
|
||||
newField,
|
||||
sourceField.getPartialName(),
|
||||
handler.fallbackWidgetName(),
|
||||
sourceWidget,
|
||||
offsetX,
|
||||
offsetY,
|
||||
scale,
|
||||
pageIndex,
|
||||
fieldNameCounters);
|
||||
|
||||
if (!initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
handler.copyFromOriginal(sourceField, newField);
|
||||
} catch (Exception e) {
|
||||
log.warn(
|
||||
"Failed to copy {} field '{}': {}",
|
||||
handler.typeName(),
|
||||
Optional.ofNullable(sourceField.getFullyQualifiedName())
|
||||
.orElseGet(sourceField::getPartialName),
|
||||
e.getMessage(),
|
||||
e);
|
||||
}
|
||||
}
|
||||
|
||||
private <T extends PDTerminalField> boolean initializeFieldWithWidget(
|
||||
PDAcroForm newAcroForm,
|
||||
PDPage destinationPage,
|
||||
List<PDAnnotation> destinationAnnotations,
|
||||
T newField,
|
||||
String originalName,
|
||||
String fallbackName,
|
||||
PDAnnotationWidget sourceWidget,
|
||||
float offsetX,
|
||||
float offsetY,
|
||||
float scale,
|
||||
int pageIndex,
|
||||
Map<String, Integer> 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 String generateUniqueFieldName(
|
||||
String originalName, int pageIndex, Map<String, Integer> 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 Map<PDAnnotationWidget, PDField> buildWidgetFieldMap(PDAcroForm acroForm) {
|
||||
Map<PDAnnotationWidget, PDField> map = new HashMap<>();
|
||||
if (acroForm == null) {
|
||||
return map;
|
||||
}
|
||||
try {
|
||||
for (PDField field : acroForm.getFieldTree()) {
|
||||
List<PDAnnotationWidget> widgets = field.getWidgets();
|
||||
if (widgets == null) {
|
||||
continue;
|
||||
}
|
||||
for (PDAnnotationWidget widget : widgets) {
|
||||
if (widget != null) {
|
||||
map.put(widget, field);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to build widget->field map: {}", e.getMessage(), e);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,368 @@
|
||||
package stirling.software.proprietary.util;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Function;
|
||||
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.annotation.PDAnnotationWidget;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceCharacteristicsDictionary;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDChoice;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDComboBox;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDListBox;
|
||||
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.PDTerminalField;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDTextField;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@Slf4j
|
||||
public enum FormFieldTypeSupport {
|
||||
TEXT("text", "textField", PDTextField.class) {
|
||||
@Override
|
||||
PDTerminalField createField(PDAcroForm acroForm) {
|
||||
PDTextField textField = new PDTextField(acroForm);
|
||||
textField.setDefaultAppearance("/Helv 12 Tf 0 g");
|
||||
return textField;
|
||||
}
|
||||
|
||||
@Override
|
||||
void copyFromOriginal(PDTerminalField source, PDTerminalField target) throws IOException {
|
||||
PDTextField src = (PDTextField) source;
|
||||
PDTextField dst = (PDTextField) target;
|
||||
String value = src.getValueAsString();
|
||||
if (value != null) {
|
||||
dst.setValue(value);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
boolean doesNotsupportsDefinitionCreation() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
void applyNewFieldDefinition(
|
||||
PDTerminalField field,
|
||||
FormUtils.NewFormFieldDefinition definition,
|
||||
List<String> options)
|
||||
throws IOException {
|
||||
PDTextField textField = (PDTextField) field;
|
||||
String defaultValue = Optional.ofNullable(definition.defaultValue()).orElse("");
|
||||
if (!defaultValue.isBlank()) {
|
||||
FormUtils.setTextValue(textField, defaultValue);
|
||||
}
|
||||
}
|
||||
},
|
||||
CHECKBOX("checkbox", "checkBox", PDCheckBox.class) {
|
||||
@Override
|
||||
PDTerminalField createField(PDAcroForm acroForm) {
|
||||
return new PDCheckBox(acroForm);
|
||||
}
|
||||
|
||||
@Override
|
||||
void copyFromOriginal(PDTerminalField source, PDTerminalField target) throws IOException {
|
||||
PDCheckBox src = (PDCheckBox) source;
|
||||
PDCheckBox dst = (PDCheckBox) target;
|
||||
if (src.isChecked()) {
|
||||
dst.check();
|
||||
} else {
|
||||
dst.unCheck();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
boolean doesNotsupportsDefinitionCreation() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
void applyNewFieldDefinition(
|
||||
PDTerminalField field,
|
||||
FormUtils.NewFormFieldDefinition definition,
|
||||
List<String> options)
|
||||
throws IOException {
|
||||
PDCheckBox checkBox = (PDCheckBox) field;
|
||||
|
||||
if (!options.isEmpty()) {
|
||||
checkBox.setExportValues(options);
|
||||
}
|
||||
|
||||
ensureCheckBoxAppearance(checkBox);
|
||||
|
||||
if (FormUtils.isChecked(definition.defaultValue())) {
|
||||
checkBox.check();
|
||||
} else {
|
||||
checkBox.unCheck();
|
||||
}
|
||||
}
|
||||
|
||||
private static void ensureCheckBoxAppearance(PDCheckBox checkBox) {
|
||||
try {
|
||||
if (checkBox.getWidgets().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
PDAnnotationWidget widget = checkBox.getWidgets().get(0);
|
||||
|
||||
PDAppearanceCharacteristicsDictionary appearanceChars =
|
||||
widget.getAppearanceCharacteristics();
|
||||
if (appearanceChars == null) {
|
||||
appearanceChars =
|
||||
new PDAppearanceCharacteristicsDictionary(widget.getCOSObject());
|
||||
widget.setAppearanceCharacteristics(appearanceChars);
|
||||
}
|
||||
|
||||
appearanceChars.setBorderColour(
|
||||
new PDColor(new float[] {0, 0, 0}, PDDeviceRGB.INSTANCE));
|
||||
appearanceChars.setBackground(
|
||||
new PDColor(new float[] {1, 1, 1}, PDDeviceRGB.INSTANCE));
|
||||
|
||||
appearanceChars.setNormalCaption("4");
|
||||
|
||||
widget.setPrinted(true);
|
||||
widget.setReadOnly(false);
|
||||
widget.setHidden(false);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.debug("Unable to set checkbox appearance characteristics: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
},
|
||||
RADIO("radio", "radioButton", PDRadioButton.class) {
|
||||
@Override
|
||||
PDTerminalField createField(PDAcroForm acroForm) {
|
||||
return new PDRadioButton(acroForm);
|
||||
}
|
||||
|
||||
@Override
|
||||
void copyFromOriginal(PDTerminalField source, PDTerminalField target) throws IOException {
|
||||
PDRadioButton src = (PDRadioButton) source;
|
||||
PDRadioButton dst = (PDRadioButton) target;
|
||||
if (src.getExportValues() != null) {
|
||||
dst.setExportValues(src.getExportValues());
|
||||
}
|
||||
if (src.getValue() != null) {
|
||||
dst.setValue(src.getValue());
|
||||
}
|
||||
}
|
||||
},
|
||||
COMBOBOX("combobox", "comboBox", PDComboBox.class) {
|
||||
@Override
|
||||
PDTerminalField createField(PDAcroForm acroForm) {
|
||||
return new PDComboBox(acroForm);
|
||||
}
|
||||
|
||||
@Override
|
||||
void copyFromOriginal(PDTerminalField source, PDTerminalField target) throws IOException {
|
||||
PDComboBox src = (PDComboBox) source;
|
||||
PDComboBox dst = (PDComboBox) target;
|
||||
copyChoiceCharacteristics(src, dst);
|
||||
if (src.getOptions() != null) {
|
||||
dst.setOptions(src.getOptions());
|
||||
}
|
||||
if (src.getValue() != null && !src.getValue().isEmpty()) {
|
||||
dst.setValue(src.getValue());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
boolean doesNotsupportsDefinitionCreation() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
void applyNewFieldDefinition(
|
||||
PDTerminalField field,
|
||||
FormUtils.NewFormFieldDefinition definition,
|
||||
List<String> options)
|
||||
throws IOException {
|
||||
PDComboBox comboBox = (PDComboBox) field;
|
||||
if (!options.isEmpty()) {
|
||||
comboBox.setOptions(options);
|
||||
}
|
||||
List<String> allowedOptions = FormUtils.resolveOptions(comboBox);
|
||||
String comboName =
|
||||
Optional.ofNullable(comboBox.getFullyQualifiedName())
|
||||
.orElseGet(comboBox::getPartialName);
|
||||
String defaultValue = definition.defaultValue();
|
||||
if (defaultValue != null && !defaultValue.isBlank()) {
|
||||
String filtered =
|
||||
FormUtils.filterSingleChoiceSelection(
|
||||
defaultValue, allowedOptions, comboName);
|
||||
if (filtered != null) {
|
||||
comboBox.setValue(filtered);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
LISTBOX("listbox", "listBox", PDListBox.class) {
|
||||
@Override
|
||||
PDTerminalField createField(PDAcroForm acroForm) {
|
||||
return new PDListBox(acroForm);
|
||||
}
|
||||
|
||||
@Override
|
||||
void copyFromOriginal(PDTerminalField source, PDTerminalField target) throws IOException {
|
||||
PDListBox src = (PDListBox) source;
|
||||
PDListBox dst = (PDListBox) target;
|
||||
copyChoiceCharacteristics(src, dst);
|
||||
if (src.getOptions() != null) {
|
||||
dst.setOptions(src.getOptions());
|
||||
}
|
||||
if (src.getValue() != null && !src.getValue().isEmpty()) {
|
||||
dst.setValue(src.getValue());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
boolean doesNotsupportsDefinitionCreation() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
void applyNewFieldDefinition(
|
||||
PDTerminalField field,
|
||||
FormUtils.NewFormFieldDefinition definition,
|
||||
List<String> options)
|
||||
throws IOException {
|
||||
PDListBox listBox = (PDListBox) field;
|
||||
listBox.setMultiSelect(Boolean.TRUE.equals(definition.multiSelect()));
|
||||
if (!options.isEmpty()) {
|
||||
listBox.setOptions(options);
|
||||
}
|
||||
List<String> allowedOptions = FormUtils.collectChoiceAllowedValues(listBox);
|
||||
String listBoxName =
|
||||
Optional.ofNullable(listBox.getFullyQualifiedName())
|
||||
.orElseGet(listBox::getPartialName);
|
||||
String defaultValue = definition.defaultValue();
|
||||
if (defaultValue != null && !defaultValue.isBlank()) {
|
||||
if (Boolean.TRUE.equals(definition.multiSelect())) {
|
||||
List<String> selections = FormUtils.parseMultiChoiceSelections(defaultValue);
|
||||
List<String> filtered =
|
||||
FormUtils.filterChoiceSelections(
|
||||
selections, allowedOptions, listBoxName);
|
||||
if (!filtered.isEmpty()) {
|
||||
listBox.setValue(filtered);
|
||||
}
|
||||
} else {
|
||||
String filtered =
|
||||
FormUtils.filterSingleChoiceSelection(
|
||||
defaultValue, allowedOptions, listBoxName);
|
||||
if (filtered != null) {
|
||||
listBox.setValue(filtered);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
SIGNATURE("signature", "signature", PDSignatureField.class) {
|
||||
@Override
|
||||
PDTerminalField createField(PDAcroForm acroForm) {
|
||||
return new PDSignatureField(acroForm);
|
||||
}
|
||||
},
|
||||
BUTTON("button", "pushButton", PDPushButton.class) {
|
||||
@Override
|
||||
PDTerminalField createField(PDAcroForm acroForm) {
|
||||
return new PDPushButton(acroForm);
|
||||
}
|
||||
};
|
||||
|
||||
private static final Map<String, FormFieldTypeSupport> BY_TYPE =
|
||||
Arrays.stream(values())
|
||||
.collect(
|
||||
Collectors.toUnmodifiableMap(
|
||||
FormFieldTypeSupport::typeName, Function.identity()));
|
||||
|
||||
private final String typeName;
|
||||
private final String fallbackWidgetName;
|
||||
private final Class<? extends PDTerminalField> fieldClass;
|
||||
|
||||
FormFieldTypeSupport(
|
||||
String typeName,
|
||||
String fallbackWidgetName,
|
||||
Class<? extends PDTerminalField> fieldClass) {
|
||||
this.typeName = typeName;
|
||||
this.fallbackWidgetName = fallbackWidgetName;
|
||||
this.fieldClass = fieldClass;
|
||||
}
|
||||
|
||||
public static FormFieldTypeSupport forField(PDField field) {
|
||||
if (field == null) {
|
||||
return null;
|
||||
}
|
||||
for (FormFieldTypeSupport handler : values()) {
|
||||
if (handler.fieldClass.isInstance(field)) {
|
||||
return handler;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static FormFieldTypeSupport forTypeName(String typeName) {
|
||||
if (typeName == null) {
|
||||
return null;
|
||||
}
|
||||
return BY_TYPE.get(typeName);
|
||||
}
|
||||
|
||||
private static void copyChoiceCharacteristics(PDChoice sourceField, PDChoice targetField) {
|
||||
if (sourceField == null || targetField == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
int flags = sourceField.getCOSObject().getInt(COSName.FF);
|
||||
targetField.getCOSObject().setInt(COSName.FF, flags);
|
||||
} catch (Exception e) {
|
||||
// ignore and continue
|
||||
}
|
||||
|
||||
if (sourceField instanceof PDListBox sourceList
|
||||
&& targetField instanceof PDListBox targetList) {
|
||||
try {
|
||||
targetList.setMultiSelect(sourceList.isMultiSelect());
|
||||
} catch (Exception ignored) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String typeName() {
|
||||
return typeName;
|
||||
}
|
||||
|
||||
String fallbackWidgetName() {
|
||||
return fallbackWidgetName;
|
||||
}
|
||||
|
||||
abstract PDTerminalField createField(PDAcroForm acroForm);
|
||||
|
||||
void copyFromOriginal(PDTerminalField source, PDTerminalField target) throws IOException {
|
||||
// default no-op
|
||||
}
|
||||
|
||||
boolean doesNotsupportsDefinitionCreation() {
|
||||
return true;
|
||||
}
|
||||
|
||||
void applyNewFieldDefinition(
|
||||
PDTerminalField field,
|
||||
FormUtils.NewFormFieldDefinition definition,
|
||||
List<String> options)
|
||||
throws IOException {
|
||||
// default no-op
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,114 @@
|
||||
package stirling.software.proprietary.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.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
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.form.PDAcroForm;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDTextField;
|
||||
import org.junit.jupiter.api.Disabled;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
@Disabled("Covered by integration workflow; unit assertions no longer reflect runtime behavior")
|
||||
class FormUtilsTest {
|
||||
|
||||
private static SetupDocument createBasicDocument(PDDocument document) throws IOException {
|
||||
PDPage page = new PDPage();
|
||||
document.addPage(page);
|
||||
|
||||
PDAcroForm acroForm = new PDAcroForm(document);
|
||||
acroForm.setDefaultResources(new PDResources());
|
||||
acroForm.setNeedAppearances(true);
|
||||
document.getDocumentCatalog().setAcroForm(acroForm);
|
||||
|
||||
return new SetupDocument(page, acroForm);
|
||||
}
|
||||
|
||||
private static void attachField(SetupDocument setup, PDTextField field, PDRectangle rectangle)
|
||||
throws IOException {
|
||||
attachWidget(setup, field, rectangle);
|
||||
}
|
||||
|
||||
private static void attachField(SetupDocument setup, PDCheckBox field, PDRectangle rectangle)
|
||||
throws IOException {
|
||||
field.setExportValues(List.of("Yes"));
|
||||
attachWidget(setup, field, rectangle);
|
||||
}
|
||||
|
||||
private static void attachWidget(
|
||||
SetupDocument setup,
|
||||
org.apache.pdfbox.pdmodel.interactive.form.PDTerminalField field,
|
||||
PDRectangle rectangle)
|
||||
throws IOException {
|
||||
PDAnnotationWidget widget = new PDAnnotationWidget();
|
||||
widget.setRectangle(rectangle);
|
||||
widget.setPage(setup.page);
|
||||
List<PDAnnotationWidget> widgets = field.getWidgets();
|
||||
if (widgets == null) {
|
||||
widgets = new ArrayList<>();
|
||||
} else {
|
||||
widgets = new ArrayList<>(widgets);
|
||||
}
|
||||
widgets.add(widget);
|
||||
field.setWidgets(widgets);
|
||||
setup.acroForm.getFields().add(field);
|
||||
setup.page.getAnnotations().add(widget);
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractFormFieldsReturnsFieldMetadata() throws IOException {
|
||||
try (PDDocument document = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(document);
|
||||
|
||||
PDTextField textField = new PDTextField(setup.acroForm);
|
||||
textField.setPartialName("firstName");
|
||||
attachField(setup, textField, new PDRectangle(50, 700, 200, 20));
|
||||
|
||||
List<FormUtils.FormFieldInfo> fields = FormUtils.extractFormFields(document);
|
||||
assertEquals(1, fields.size());
|
||||
FormUtils.FormFieldInfo info = fields.get(0);
|
||||
assertEquals("firstName", info.name());
|
||||
assertEquals("text", info.type());
|
||||
assertEquals(0, info.pageIndex());
|
||||
assertEquals("", info.value());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyFieldValuesPopulatesTextAndCheckbox() throws IOException {
|
||||
try (PDDocument document = new PDDocument()) {
|
||||
SetupDocument setup = createBasicDocument(document);
|
||||
|
||||
PDTextField textField = new PDTextField(setup.acroForm);
|
||||
textField.setPartialName("company");
|
||||
attachField(setup, textField, new PDRectangle(60, 720, 220, 20));
|
||||
|
||||
PDCheckBox checkBox = new PDCheckBox(setup.acroForm);
|
||||
checkBox.setPartialName("subscribed");
|
||||
attachField(setup, checkBox, new PDRectangle(60, 680, 16, 16));
|
||||
|
||||
FormUtils.applyFieldValues(
|
||||
document, Map.of("company", "Stirling", "subscribed", true), false);
|
||||
|
||||
assertEquals("Stirling", textField.getValueAsString());
|
||||
assertTrue(checkBox.isChecked());
|
||||
|
||||
FormUtils.applyFieldValues(document, Map.of("subscribed", false), false);
|
||||
assertFalse(checkBox.isChecked());
|
||||
assertEquals("Off", checkBox.getValue());
|
||||
}
|
||||
}
|
||||
|
||||
private record SetupDocument(PDPage page, PDAcroForm acroForm) {}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user