From 27bd34c29b9135b9157b320c4c633ca8171f82af 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: Fri, 13 Feb 2026 16:10:48 +0100 Subject: [PATCH] feat(form-fill): FormFill tool with context and UI components for PDF form filling (#5711) --- .../model/FormFieldWithCoordinates.java | 98 +++ .../api/form/FormFillController.java | 30 + .../software/proprietary/util/FormUtils.java | 698 ++++++++++++++++-- build.gradle | 4 +- .../public/locales/en-GB/translation.toml | 14 + frontend/src/core/components/AppProviders.tsx | 3 + .../core/components/viewer/EmbedPdfViewer.tsx | 217 +++++- .../core/components/viewer/LocalEmbedPDF.tsx | 21 +- .../components/viewer/ThumbnailSidebar.tsx | 97 ++- .../viewer/useViewerRightRailButtons.tsx | 37 +- frontend/src/core/theme/mantineTheme.ts | 6 +- .../core/tools/formFill/FormFieldOverlay.tsx | 20 + .../core/tools/formFill/FormFieldSidebar.tsx | 17 + .../core/tools/formFill/FormFillContext.tsx | 85 +++ .../src/core/tools/formFill/FormSaveBar.tsx | 17 + .../data/useProprietaryToolRegistry.tsx | 23 +- .../proprietary/tools/formFill/FieldInput.tsx | 204 +++++ .../tools/formFill/FormFieldOverlay.tsx | 412 +++++++++++ .../tools/formFill/FormFieldSidebar.tsx | 210 ++++++ .../tools/formFill/FormFill.module.css | 286 +++++++ .../proprietary/tools/formFill/FormFill.tsx | 560 ++++++++++++++ .../tools/formFill/FormFillContext.tsx | 507 +++++++++++++ .../tools/formFill/FormSaveBar.tsx | 186 +++++ .../proprietary/tools/formFill/fieldMeta.tsx | 32 + .../src/proprietary/tools/formFill/formApi.ts | 50 ++ .../src/proprietary/tools/formFill/index.ts | 11 + .../formFill/providers/PdfBoxFormProvider.ts | 32 + .../formFill/providers/PdfLibFormProvider.ts | 588 +++++++++++++++ .../tools/formFill/providers/index.ts | 3 + .../tools/formFill/providers/types.ts | 37 + .../src/proprietary/tools/formFill/types.ts | 59 ++ .../proprietary/types/proprietaryToolId.ts | 1 + frontend/tsconfig.core.json | 4 +- frontend/tsconfig.proprietary.json | 3 +- frontend/tsconfig.proprietary.vite.json | 1 + 35 files changed, 4481 insertions(+), 92 deletions(-) create mode 100644 app/common/src/main/java/stirling/software/common/model/FormFieldWithCoordinates.java create mode 100644 frontend/src/core/tools/formFill/FormFieldOverlay.tsx create mode 100644 frontend/src/core/tools/formFill/FormFieldSidebar.tsx create mode 100644 frontend/src/core/tools/formFill/FormFillContext.tsx create mode 100644 frontend/src/core/tools/formFill/FormSaveBar.tsx create mode 100644 frontend/src/proprietary/tools/formFill/FieldInput.tsx create mode 100644 frontend/src/proprietary/tools/formFill/FormFieldOverlay.tsx create mode 100644 frontend/src/proprietary/tools/formFill/FormFieldSidebar.tsx create mode 100644 frontend/src/proprietary/tools/formFill/FormFill.module.css create mode 100644 frontend/src/proprietary/tools/formFill/FormFill.tsx create mode 100644 frontend/src/proprietary/tools/formFill/FormFillContext.tsx create mode 100644 frontend/src/proprietary/tools/formFill/FormSaveBar.tsx create mode 100644 frontend/src/proprietary/tools/formFill/fieldMeta.tsx create mode 100644 frontend/src/proprietary/tools/formFill/formApi.ts create mode 100644 frontend/src/proprietary/tools/formFill/index.ts create mode 100644 frontend/src/proprietary/tools/formFill/providers/PdfBoxFormProvider.ts create mode 100644 frontend/src/proprietary/tools/formFill/providers/PdfLibFormProvider.ts create mode 100644 frontend/src/proprietary/tools/formFill/providers/index.ts create mode 100644 frontend/src/proprietary/tools/formFill/providers/types.ts create mode 100644 frontend/src/proprietary/tools/formFill/types.ts diff --git a/app/common/src/main/java/stirling/software/common/model/FormFieldWithCoordinates.java b/app/common/src/main/java/stirling/software/common/model/FormFieldWithCoordinates.java new file mode 100644 index 000000000..54ccafd66 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/model/FormFieldWithCoordinates.java @@ -0,0 +1,98 @@ +package stirling.software.common.model; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import io.swagger.v3.oas.annotations.media.Schema; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** Form field information with coordinates for interactive form viewer. */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(description = "Form field with coordinates and metadata") +public class FormFieldWithCoordinates { + + @Schema(description = "Fully qualified field name", example = "form1.firstName") + private String name; + + @Schema(description = "Display label for the field", example = "First Name") + private String label; + + @Schema(description = "Field type: text, checkbox, radio, combobox, listbox, button, signature") + private String type; + + @Schema(description = "Current field value") + private String value; + + @Schema( + description = + "Available options (export values) for choice fields" + + " (dropdown, radio, listbox)") + private List options; + + @Schema( + description = + "Human-readable display labels for choice field options," + + " parallel to the 'options' list. Null when identical to options.") + private List displayOptions; + + @Schema(description = "Whether the field is required") + private boolean required; + + @Schema(description = "Whether the field is read-only") + private boolean readOnly; + + @Schema(description = "Whether this is a multi-select list box") + private boolean multiSelect; + + @Schema(description = "Whether this is a multi-line text field") + private boolean multiline; + + @Schema(description = "Tooltip/alternate name for the field") + private String tooltip; + + @Schema(description = "Widget coordinates on each page (fields can have multiple widgets)") + private List widgets; + + /** + * Coordinates for a single widget annotation (visual representation of the field). A field can + * have multiple widgets if it appears on multiple pages. + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonInclude(JsonInclude.Include.NON_NULL) + @Schema(description = "Widget coordinates in PDF space") + public static class WidgetCoordinates { + + @Schema(description = "Page index (0-based)", example = "0") + private int pageIndex; + + @Schema(description = "X coordinate in PDF points (lower-left origin)") + private float x; + + @Schema(description = "Y coordinate in PDF points (lower-left origin)") + private float y; + + @Schema(description = "Width in PDF points") + private float width; + + @Schema(description = "Height in PDF points") + private float height; + + @Schema(description = "Export value for this widget (radio/checkbox buttons only)") + private String exportValue; + + @Schema(description = "Font size in PDF points") + private Float fontSize; + } +} 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 index c28733f81..1b584af2e 100644 --- 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 @@ -27,6 +27,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; +import stirling.software.common.model.FormFieldWithCoordinates; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.WebResponseUtils; @@ -104,12 +105,40 @@ public class FormFillController { requirePdf(file); try (PDDocument document = pdfDocumentFactory.load(file, true)) { + FormUtils.repairMissingWidgetPageReferences(document); FormUtils.FormFieldExtraction extraction = FormUtils.extractFieldsWithTemplate(document); return ResponseEntity.ok(extraction); } } + @PostMapping(value = "/fields-with-coordinates", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation( + summary = "Inspect PDF form fields with widget coordinates", + description = + "Returns metadata describing each field in the provided PDF form, " + + "including precise widget coordinates for interactive rendering") + public ResponseEntity> listFieldsWithCoordinates( + @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.repairMissingWidgetPageReferences(document); + List fields = + FormUtils.extractFormFieldsWithCoordinates(document); + return ResponseEntity.ok(fields); + } + } + @PostMapping(value = "/modify-fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @Operation( summary = "Modify existing form fields", @@ -215,6 +244,7 @@ public class FormFillController { String baseName = buildBaseName(file, suffix); try (PDDocument document = pdfDocumentFactory.load(file)) { + FormUtils.repairMissingWidgetPageReferences(document); processor.accept(document); return saveDocument(document, baseName); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/util/FormUtils.java b/app/proprietary/src/main/java/stirling/software/proprietary/util/FormUtils.java index 3633a2544..b5d412f98 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/util/FormUtils.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/util/FormUtils.java @@ -5,6 +5,7 @@ import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; import java.util.IdentityHashMap; @@ -18,6 +19,7 @@ import java.util.Optional; import java.util.Set; import org.apache.pdfbox.cos.COSArray; +import org.apache.pdfbox.cos.COSBase; import org.apache.pdfbox.cos.COSDictionary; import org.apache.pdfbox.cos.COSName; import org.apache.pdfbox.pdmodel.PDDocument; @@ -46,6 +48,7 @@ import lombok.experimental.UtilityClass; import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.model.FormFieldWithCoordinates; import stirling.software.common.util.ApplicationContextProvider; import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.RegexPatternUtils; @@ -67,6 +70,13 @@ public class FormUtils { public final Set CHOICE_FIELD_TYPES = Set.of(FIELD_TYPE_COMBOBOX, FIELD_TYPE_LISTBOX, FIELD_TYPE_RADIO); + /** + * Threshold in PDF points for considering two widgets to be on the same line. Fields whose + * y-coordinates differ by less than this value are sorted left-to-right by x-coordinate instead + * of top-to-bottom. + */ + private static final float SAME_LINE_THRESHOLD_PT = 10.0f; + /** * Returns a normalized logical type string for the supplied PDFBox field instance. Centralized * so all callers share identical mapping logic. @@ -109,6 +119,8 @@ public class FormUtils { List fields = new ArrayList<>(); Map typeCounters = new HashMap<>(); Map pageOrderCounters = new HashMap<>(); + Map annotationPageMap = buildAnnotationPageMap(document); + for (PDField field : acroForm.getFieldTree()) { if (!(field instanceof PDTerminalField terminalField)) { continue; @@ -125,7 +137,7 @@ public class FormUtils { String currentValue = safeValue(terminalField); boolean required = field.isRequired(); - int pageIndex = resolveFirstWidgetPageIndex(document, terminalField); + int pageIndex = resolveFirstWidgetPageIndex(document, terminalField, annotationPageMap); List options = resolveOptions(terminalField); String tooltip = resolveTooltip(terminalField); int typeIndex = typeCounters.merge(type, 1, Integer::sum); @@ -164,6 +176,396 @@ public class FormUtils { return Collections.unmodifiableList(fields); } + /** + * Extract form fields with widget coordinates for the interactive form viewer. + * + * @param document PDF document + * @return List of form fields with coordinates and metadata + */ + public List extractFormFieldsWithCoordinates(PDDocument document) { + if (document == null) return List.of(); + + PDAcroForm acroForm = getAcroFormSafely(document); + if (acroForm == null) return List.of(); + + List fields = new ArrayList<>(); + Map typeCounters = new HashMap<>(); + + Map annotationPageMap = buildAnnotationPageMap(document); + + for (PDField field : acroForm.getFieldTree()) { + if (!(field instanceof PDTerminalField terminalField)) { + continue; + } + + String type = detectFieldType(terminalField); + String name = + Optional.ofNullable(field.getFullyQualifiedName()) + .orElseGet(field::getPartialName); + if (name == null || name.isBlank()) { + continue; + } + + String currentValue = safeValue(terminalField); + boolean required = field.isRequired(); + boolean readOnly = field.isReadOnly(); + List options = resolveOptions(terminalField); + List displayOptions = resolveDisplayOptions(terminalField); + String tooltip = resolveTooltip(terminalField); + int typeIndex = typeCounters.merge(type, 1, Integer::sum); + String displayLabel = + deriveDisplayLabel(field, name, tooltip, type, typeIndex, options); + boolean multiSelect = resolveMultiSelect(terminalField); + boolean multiline = + terminalField instanceof PDTextField + && ((PDTextField) terminalField).isMultiline(); + + // Extract widget coordinates + List widgets = + extractWidgetCoordinates(document, terminalField, annotationPageMap); + + // Only include displayOptions when they differ from export options + List displayOptsToSend = null; + if (displayOptions != null + && !displayOptions.isEmpty() + && !displayOptions.equals(options)) { + displayOptsToSend = displayOptions; + } + + fields.add( + FormFieldWithCoordinates.builder() + .name(name) + .label(displayLabel) + .type(type) + .value(currentValue) + .options(options.isEmpty() ? null : options) + .displayOptions(displayOptsToSend) + .required(required) + .readOnly(readOnly) + .multiSelect(multiSelect) + .multiline(multiline) + .tooltip(tooltip) + .widgets(widgets.isEmpty() ? null : widgets) + .build()); + } + + // Sort by page and position + fields.sort(new FieldCoordinateComparator()); + + log.debug("Total fields processed: {}", fields.size()); + log.debug( + "Fields WITH widgets: {}", + fields.stream() + .filter(f -> f.getWidgets() != null && !f.getWidgets().isEmpty()) + .count()); + log.debug( + "Fields WITHOUT widgets: {}", + fields.stream() + .filter(f -> f.getWidgets() == null || f.getWidgets().isEmpty()) + .count()); + + fields.stream() + .filter(f -> f.getWidgets() == null || f.getWidgets().isEmpty()) + .forEach( + f -> + log.debug( + "Field '{}' type={} has NO widget coordinates", + f.getName(), + f.getType())); + + return Collections.unmodifiableList(fields); + } + + /** + * Extract widget coordinates for a form field. + * + * @param document PDF document + * @param field Terminal field + * @return List of widget coordinates + */ + private List extractWidgetCoordinates( + PDDocument document, + PDTerminalField field, + Map annotationPageMap) { + List result = new ArrayList<>(); + + List widgets = field.getWidgets(); + + log.debug( + "Field '{}' type={} has {} widgets", + field.getFullyQualifiedName(), + field.getClass().getSimpleName(), + widgets != null ? widgets.size() : 0); + + if (widgets == null || widgets.isEmpty()) { + // Some fields (especially text fields) might be their own widget annotation + log.trace( + "Field '{}' has no widgets, checking if field acts as its own annotation", + field.getFullyQualifiedName()); + try { + COSDictionary fieldDict = field.getCOSObject(); + COSBase rectBase = fieldDict.getDictionaryObject(COSName.RECT); + if (rectBase instanceof COSArray rectArray) { + int pageIndex = + findPageIndexForAnnotation(document, fieldDict, annotationPageMap); + if (pageIndex >= 0) { + PDRectangle rectangle = new PDRectangle(rectArray); + result.add( + createWidgetCoordinates( + document, rectangle, pageIndex, null, field)); + } else { + log.warn( + "Found rectangle for field '{}' but could not resolve page index", + field.getFullyQualifiedName()); + } + } + } catch (Exception e) { + log.debug( + "Could not extract direct rectangle for field '{}': {}", + field.getFullyQualifiedName(), + e.getMessage()); + } + return result; + } + + // For radio buttons, pre-resolve export values per widget + List exportValues = null; + if (field instanceof PDRadioButton radio) { + exportValues = radio.getExportValues(); + } + + for (int i = 0; i < widgets.size(); i++) { + PDAnnotationWidget widget = widgets.get(i); + try { + PDRectangle rectangle = widget.getRectangle(); + if (rectangle == null) { + log.warn( + "Field '{}' widget {} has NULL rectangle", + field.getFullyQualifiedName(), + i); + continue; + } + + int pageIndex = resolveWidgetPageIndex(document, widget, annotationPageMap); + if (pageIndex < 0) { + log.warn( + "Field '{}' widget {} could not resolve page index", + field.getFullyQualifiedName(), + i); + continue; + } + + // Resolve export value for radio/checkbox widgets + String exportValue = null; + if (exportValues != null && i < exportValues.size()) { + exportValue = exportValues.get(i); + } else if (field instanceof PDButton) { + // Fall back to appearance state name from the widget's normal appearance + try { + var ap = widget.getAppearance(); + if (ap != null && ap.getNormalAppearance() != null) { + var normalAp = ap.getNormalAppearance(); + if (normalAp.isSubDictionary()) { + for (var cosName : normalAp.getSubDictionary().keySet()) { + String key = cosName.getName(); + if (!"Off".equals(key)) { + exportValue = key; + break; + } + } + } + } + } catch (Exception e) { + log.trace( + "Could not extract export value for widget in '{}': {}", + field.getFullyQualifiedName(), + e.getMessage()); + } + } + + result.add( + createWidgetCoordinates( + document, rectangle, pageIndex, exportValue, field)); + } catch (Exception e) { + log.debug( + "Failed to extract coordinates for widget in field '{}': {}", + field.getFullyQualifiedName(), + e.getMessage()); + } + } + + return result; + } + + private FormFieldWithCoordinates.WidgetCoordinates createWidgetCoordinates( + PDDocument document, + PDRectangle rectangle, + int pageIndex, + String exportValue, + PDTerminalField field) { + if (pageIndex < 0 || pageIndex >= document.getNumberOfPages()) { + return null; + } + + PDPage page = document.getPage(pageIndex); + PDRectangle cropBox = page.getCropBox(); + + // Use CropBox dimensions for the y-flip. + // Note: getWidth() and getHeight() return dimensions BEFORE rotation. + float cropHeight = cropBox.getHeight(); + + // Get absolute widget coordinates (in MediaBox space, un-rotated) + float pdfX = rectangle.getLowerLeftX(); + float pdfY = rectangle.getLowerLeftY(); + float width = rectangle.getWidth(); + float height = rectangle.getHeight(); + + // Adjust relative to CropBox origin + float relativeX = pdfX - cropBox.getLowerLeftX(); + float relativeY = pdfY - cropBox.getLowerLeftY(); + + // Convert from PDF lower-left origin to CSS upper-left origin (y-flip). + // Widget /Rect coordinates are always in un-rotated PDF user space. + // The embedpdf viewer wraps all page content inside a CSS + // component that handles visual rotation — we must NOT apply any + // rotation transform here, or widgets would be double-rotated. + float finalX = relativeX; + float finalY = cropHeight - relativeY - height; + float finalW = width; + float finalH = height; + + // Validate coordinates are within reasonable bounds + if (finalX < -1.0f + || finalY < -1.0f + || finalX > cropBox.getWidth() * 2 // Allow some horizontal overflow + || finalY > cropHeight + 1.0f) { + log.warn( + "Widget coordinates out of bounds for field '{}': page={}, x={}, y={}, w={}, h={}", + field.getFullyQualifiedName(), + pageIndex, + finalX, + finalY, + finalW, + finalH); + return null; + } + + return FormFieldWithCoordinates.WidgetCoordinates.builder() + .pageIndex(pageIndex) + .x(finalX) + .y(finalY) + .width(finalW) + .height(finalH) + .exportValue(exportValue) + .fontSize(extractFontSize(field)) + .build(); + } + + /** + * Repairs widgets with missing page references by scanning all pages and setting the /P entry + * for orphan widgets. + * + *

This should be called BEFORE extracting form field coordinates. + * + * @param document PDF document to repair + */ + public void repairMissingWidgetPageReferences(PDDocument document) { + try { + PDAcroForm acroForm = getAcroFormSafely(document); + if (acroForm == null) { + return; + } + + log.debug("Checking for widgets with missing page references..."); + int repairedCount = 0; + + Map annotationPageMap = buildAnnotationPageMap(document); + + for (PDField field : acroForm.getFieldTree()) { + if (!(field instanceof PDTerminalField terminalField)) { + continue; + } + + List widgets = terminalField.getWidgets(); + + if (widgets == null || widgets.isEmpty()) { + continue; + } + + for (PDAnnotationWidget widget : widgets) { + if (widget.getPage() == null) { + Integer pageIndex = annotationPageMap.get(widget.getCOSObject()); + if (pageIndex != null && pageIndex >= 0) { + PDPage foundPage = document.getPage(pageIndex); + widget.setPage(foundPage); + repairedCount++; + log.debug( + "Repaired widget for field '{}' - set page reference via map", + field.getFullyQualifiedName()); + } else { + log.warn( + "Could not find page for widget in field '{}'", + field.getFullyQualifiedName()); + } + } + } + } + + if (repairedCount > 0) { + log.debug( + "Successfully repaired {} widgets with missing page references", + repairedCount); + } else { + log.debug("No widgets needed repair"); + } + + } catch (Exception e) { + log.error("Error repairing widget page references: {}", e.getMessage(), e); + } + } + + private int findPageIndexForAnnotation( + PDDocument document, + COSDictionary annotDict, + Map annotationPageMap) { + try { + // Method 0: Check the pre-built lookup map (fastest) + if (annotationPageMap != null) { + Integer idx = annotationPageMap.get(annotDict); + if (idx != null) { + return idx; + } + } + + // Method 1: Check the /P entry if it points to a page + COSBase base = annotDict.getDictionaryObject(COSName.P); + COSDictionary pageDict = (base instanceof COSDictionary c) ? c : null; + if (pageDict != null) { + for (int i = 0; i < document.getNumberOfPages(); i++) { + if (document.getPage(i).getCOSObject() == pageDict) { + return i; + } + } + } + + // Method 2: Fallback search through all pages' annotations + for (int i = 0; i < document.getNumberOfPages(); i++) { + PDPage page = document.getPage(i); + List annotations = page.getAnnotations(); + if (annotations != null) { + for (PDAnnotation annot : annotations) { + if (annot != null && annot.getCOSObject() == annotDict) { + return i; + } + } + } + } + } catch (Exception e) { + log.trace("Error finding page for annotation: {}", e.getMessage()); + } + return -1; + } + /** * Build a single record object (field-name -> value placeholder) that can be directly submitted * to /api/v1/form/fill as the 'data' JSON. For checkboxes a boolean false is supplied unless @@ -312,7 +714,24 @@ public class FormUtils { return; } - flattenViaRendering(document, acroForm); + if (acroForm == null) { + return; + } + + // Use PDFBox's built-in field flattening which bakes form field values + // into the page content stream as static text/graphics, removing the + // interactive form structure but preserving all other document content + // (images, text, annotations, etc.) at full quality. + try { + ensureAppearances(acroForm); + acroForm.flatten(); + } catch (Exception e) { + log.warn( + "PDFBox acroForm.flatten() failed, falling back to rendering: {}", + e.getMessage(), + e); + flattenViaRendering(document, acroForm); + } } private void rebuildDocumentFromImages(PDDocument document, PDFRenderer renderer, int dpi) @@ -385,7 +804,7 @@ public class FormUtils { PDPage page = widget.getPage(); if (page == null) { - page = resolveWidgetPage(document, widget); + page = resolveWidgetPage(document, widget, null); if (page != null) { widget.setPage(page); } @@ -820,6 +1239,16 @@ public class FormUtils { private String safeValue(PDTerminalField field) { try { + // PDChoice.getValueAsString() returns a raw COS string representation + // that doesn't reliably reflect the selected value. Use getValue() + // which returns the proper List of selected options. + if (field instanceof PDChoice choiceField) { + List selected = choiceField.getValue(); + if (selected == null || selected.isEmpty()) { + return null; + } + return String.join(",", selected); + } return field.getValueAsString(); } catch (Exception e) { log.debug( @@ -833,14 +1262,25 @@ public class FormUtils { List resolveOptions(PDTerminalField field) { try { if (field instanceof PDChoice choice) { - List display = choice.getOptionsDisplayValues(); - if (display != null && !display.isEmpty()) { - return new ArrayList<>(display); - } + LinkedHashSet allowed = new LinkedHashSet<>(); List exportValues = choice.getOptionsExportValues(); - if (exportValues != null && !exportValues.isEmpty()) { - return new ArrayList<>(exportValues); + List displayValues = choice.getOptionsDisplayValues(); + + if (exportValues != null) { + exportValues.stream() + .filter(Objects::nonNull) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .forEach(allowed::add); } + if (displayValues != null) { + displayValues.stream() + .filter(Objects::nonNull) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .forEach(allowed::add); + } + return new ArrayList<>(allowed); } else if (field instanceof PDRadioButton radio) { List exports = radio.getExportValues(); if (exports != null && !exports.isEmpty()) { @@ -861,6 +1301,29 @@ public class FormUtils { return Collections.emptyList(); } + /** + * Returns the display-value labels for a choice field's options. For radio / checkbox this + * returns an empty list (no separate display values). For PDChoice fields, if the PDF provides + * distinct display values, those are returned; otherwise an empty list (indicating that the + * export values from {@link #resolveOptions} should be shown directly). + */ + List resolveDisplayOptions(PDTerminalField field) { + try { + if (field instanceof PDChoice choice) { + List display = choice.getOptionsDisplayValues(); + if (display != null && !display.isEmpty()) { + return new ArrayList<>(display); + } + } + } catch (Exception e) { + log.debug( + "Failed to resolve display options for field '{}': {}", + field.getFullyQualifiedName(), + e.getMessage()); + } + return Collections.emptyList(); + } + private boolean resolveMultiSelect(PDTerminalField field) { if (field instanceof PDListBox listBox) { try { @@ -875,6 +1338,44 @@ public class FormUtils { return false; } + private Float extractFontSize(PDTerminalField field) { + try { + String da = null; + if (field instanceof PDVariableText vt) { + da = vt.getDefaultAppearance(); + } + + if (da == null || da.isBlank()) { + // Check parent/acroform default appearance if field's is missing + PDAcroForm form = field.getAcroForm(); + if (form != null) { + da = form.getDefaultAppearance(); + } + } + + if (da != null && !da.isBlank()) { + // Standard DA looks like: /Helv 12 Tf 0 g + // We want the number before 'Tf' + String[] tokens = da.split("\\s+"); + for (int i = 0; i < tokens.length; i++) { + if ("Tf".equals(tokens[i]) && i > 0) { + try { + float size = Float.parseFloat(tokens[i - 1]); + return size > 0 ? size : null; + } catch (NumberFormatException ignored) { + } + } + } + } + } catch (Exception e) { + log.trace( + "Could not extract font size for field '{}': {}", + field.getFullyQualifiedName(), + e.getMessage()); + } + return null; + } + private boolean isSettableCheckBoxState(String state) { if (state == null) return false; String trimmed = state.trim(); @@ -952,6 +1453,13 @@ public class FormUtils { if (simplified.isEmpty()) return true; + // Detect UUID-like hex strings (e.g. "cdc47b7041524571 7b2d93017fe77bf7") + // Standard UUIDs are 32 hex characters; require at least that to avoid + // false positives on short hex-like field names. + String nospaces = simplified.replaceAll("\\s+", ""); + if (nospaces.length() >= 32 && nospaces.matches("^[0-9a-fA-F]{8}[0-9a-fA-F]{24,}$")) + return true; + return patterns.getGenericFieldNamePattern().matcher(simplified).matches() || patterns.getSimpleFormFieldPattern().matcher(simplified).matches() || patterns.getOptionalTNumericPattern().matcher(simplified).matches(); @@ -1007,7 +1515,7 @@ public class FormUtils { PDAnnotationWidget widget = widgets.get(0); PDRectangle originalRectangle = cloneRectangle(widget.getRectangle()); - PDPage page = resolveWidgetPage(document, widget); + PDPage page = resolveWidgetPage(document, widget, null); if (page == null || originalRectangle == null) { log.warn( "Unable to resolve widget page or rectangle for '{}'; skipping", @@ -1064,7 +1572,7 @@ public class FormUtils { desiredName, modification.label(), resolvedType, - determineWidgetPageIndex(document, widget), + determineWidgetPageIndex(document, widget, null), originalRectangle.getLowerLeftX(), originalRectangle.getLowerLeftY(), originalRectangle.getWidth(), @@ -1205,59 +1713,43 @@ public class FormUtils { return null; } - private int resolveFirstWidgetPageIndex(PDDocument document, PDTerminalField field) { + private int resolveFirstWidgetPageIndex( + PDDocument document, + PDTerminalField field, + Map annotationPageMap) { List widgets = field.getWidgets(); if (widgets == null || widgets.isEmpty()) { return -1; } - Map widgetPageFallbacks = null; for (PDAnnotationWidget widget : widgets) { - int idx = resolveWidgetPageIndex(document, widget); + int idx = resolveWidgetPageIndex(document, widget, annotationPageMap); if (idx >= 0) { return idx; } - try { - COSDictionary widgetDictionary = widget.getCOSObject(); - if (widgetDictionary != null - && widgetDictionary.getDictionaryObject(COSName.P) == null) { - if (widgetPageFallbacks == null) { - widgetPageFallbacks = buildWidgetPageFallbackMap(document); - } - Integer fallbackIndex = widgetPageFallbacks.get(widget); - if (fallbackIndex != null && fallbackIndex >= 0) { - return fallbackIndex; - } - } - } catch (Exception e) { - log.debug( - "Failed to inspect widget page reference for field '{}': {}", - field.getFullyQualifiedName(), - e.getMessage()); - } } return -1; } - private int resolveWidgetPageIndex(PDDocument document, PDAnnotationWidget widget) { + private int resolveWidgetPageIndex( + PDDocument document, + PDAnnotationWidget widget, + Map annotationPageMap) { if (document == null || widget == null) { return -1; } - try { - COSDictionary widgetDictionary = widget.getCOSObject(); - if (widgetDictionary != null - && widgetDictionary.getDictionaryObject(COSName.P) == null) { - Map fallback = buildWidgetPageFallbackMap(document); - Integer index = fallback.get(widget); - if (index != null) { - return index; - } + + // Method 0: Check the pre-built lookup map (fastest) + if (annotationPageMap != null) { + Integer idx = annotationPageMap.get(widget.getCOSObject()); + if (idx != null) { + return idx; } - } catch (Exception e) { - log.debug("Widget page lookup via fallback map failed: {}", e.getMessage()); } + try { PDPage page = widget.getPage(); if (page != null) { + // indexOf is O(N), still slower than map but better than scanning annotations int idx = document.getPages().indexOf(page); if (idx >= 0) { return idx; @@ -1267,14 +1759,36 @@ public class FormUtils { log.debug("Widget page lookup failed: {}", e.getMessage()); } + // Method 1: Check the /P entry if it points to a page + try { + COSDictionary widgetDictionary = widget.getCOSObject(); + if (widgetDictionary != null) { + COSBase base = widgetDictionary.getDictionaryObject(COSName.P); + COSDictionary pageDict = (base instanceof COSDictionary c) ? c : null; + if (pageDict != null) { + for (int i = 0; i < document.getNumberOfPages(); i++) { + if (document.getPage(i).getCOSObject() == pageDict) { + return i; + } + } + } + } + } catch (Exception e) { + log.debug("Widget page lookup via /P entry failed: {}", e.getMessage()); + } + + // Method 2: Fallback search through all pages' annotations int pageCount = document.getNumberOfPages(); + COSDictionary widgetDict = widget.getCOSObject(); for (int i = 0; i < pageCount; i++) { try { PDPage candidate = document.getPage(i); List annotations = candidate.getAnnotations(); - for (PDAnnotation annotation : annotations) { - if (annotation == widget) { - return i; + if (annotations != null) { + for (PDAnnotation annot : annotations) { + if (annot != null && annot.getCOSObject() == widgetDict) { + return i; + } } } } catch (IOException e) { @@ -1317,7 +1831,7 @@ public class FormUtils { List widgets = field.getWidgets(); if (widgets != null) { for (PDAnnotationWidget widget : widgets) { - PDPage page = resolveWidgetPage(document, widget); + PDPage page = resolveWidgetPage(document, widget, null); if (page != null) { page.getAnnotations().remove(widget); } @@ -1437,7 +1951,10 @@ public class FormUtils { rectangle.getHeight()); } - private PDPage resolveWidgetPage(PDDocument document, PDAnnotationWidget widget) { + private PDPage resolveWidgetPage( + PDDocument document, + PDAnnotationWidget widget, + Map annotationPageMap) { if (widget == null) { return null; } @@ -1445,7 +1962,7 @@ public class FormUtils { if (page != null) { return page; } - int pageIndex = determineWidgetPageIndex(document, widget); + int pageIndex = determineWidgetPageIndex(document, widget, annotationPageMap); if (pageIndex >= 0) { try { return document.getPage(pageIndex); @@ -1456,11 +1973,21 @@ public class FormUtils { return null; } - private int determineWidgetPageIndex(PDDocument document, PDAnnotationWidget widget) { + private int determineWidgetPageIndex( + PDDocument document, + PDAnnotationWidget widget, + Map annotationPageMap) { if (document == null || widget == null) { return -1; } + if (annotationPageMap != null) { + Integer idx = annotationPageMap.get(widget.getCOSObject()); + if (idx != null) { + return idx; + } + } + PDPage directPage = widget.getPage(); if (directPage != null) { int index = 0; @@ -1488,6 +2015,33 @@ public class FormUtils { return -1; } + /** + * Build a map of annotation COS dictionaries to their respective page index. Scan once + * per-document to avoid O(N^2) lookups during field extraction. + */ + public Map buildAnnotationPageMap(PDDocument document) { + if (document == null) { + return Collections.emptyMap(); + } + + Map map = new HashMap<>(); + int pageCount = document.getNumberOfPages(); + for (int i = 0; i < pageCount; i++) { + try { + PDPage page = document.getPage(i); + List annotations = page.getAnnotations(); + for (PDAnnotation annot : annotations) { + if (annot != null) { + map.putIfAbsent(annot.getCOSObject(), i); + } + } + } catch (Exception e) { + log.debug("Failed to index annotations for page {}: {}", i, e.getMessage()); + } + } + return map; + } + private Map buildWidgetPageFallbackMap(PDDocument document) { if (document == null) { return Collections.emptyMap(); @@ -1760,4 +2314,46 @@ public class FormUtils { boolean multiSelect, String tooltip, int pageOrder) {} + + /** + * Comparator for sorting form fields by page, then vertically (top-to-bottom), then + * horizontally (left-to-right) for fields on approximately the same line. + */ + static final class FieldCoordinateComparator implements Comparator { + + private static int firstWidgetPageIndex(FormFieldWithCoordinates f) { + return (f.getWidgets() != null && !f.getWidgets().isEmpty()) + ? f.getWidgets().get(0).getPageIndex() + : -1; + } + + private static float firstWidgetY(FormFieldWithCoordinates f) { + return (f.getWidgets() != null && !f.getWidgets().isEmpty()) + ? f.getWidgets().get(0).getY() + : 0; + } + + private static float firstWidgetX(FormFieldWithCoordinates f) { + return (f.getWidgets() != null && !f.getWidgets().isEmpty()) + ? f.getWidgets().get(0).getX() + : 0; + } + + @Override + public int compare(FormFieldWithCoordinates a, FormFieldWithCoordinates b) { + int pageA = firstWidgetPageIndex(a); + int pageB = firstWidgetPageIndex(b); + int pageCompare = Integer.compare(pageA, pageB); + if (pageCompare != 0) return pageCompare; + + float yA = firstWidgetY(a); + float yB = firstWidgetY(b); + + // Fields on approximately the same line should be sorted left-to-right + if (Math.abs(yA - yB) < SAME_LINE_THRESHOLD_PT) { + return Float.compare(firstWidgetX(a), firstWidgetX(b)); + } + return Float.compare(yA, yB); + } + } } diff --git a/build.gradle b/build.gradle index 05418cfa5..fdf864b86 100644 --- a/build.gradle +++ b/build.gradle @@ -5,7 +5,7 @@ plugins { id "org.springframework.boot" version "3.5.9" id "org.springdoc.openapi-gradle-plugin" version "1.9.0" id "io.swagger.swaggerhub" version "1.3.2" - id "com.diffplug.spotless" version "8.2.1" + id "com.diffplug.spotless" version "8.1.0" id "com.github.jk1.dependency-license-report" version "3.0.1" //id "nebula.lint" version "19.0.3" id "org.sonarqube" version "7.2.2.6593" @@ -67,7 +67,7 @@ springBoot { allprojects { group = 'stirling.software' - version = '2.4.6' + version = '2.4.5' configurations.configureEach { exclude group: 'commons-logging', module: 'commons-logging' diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index a610b2f6e..a72515fa0 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -3300,6 +3300,10 @@ desc = "Build multi-step workflows by chaining together PDF actions. Ideal for r tags = "workflow,sequence,automation,automate,batch,batch processing,pipeline,chain,multi-step,recurring,scheduled,automatic,process multiple,bulk operations" title = "Automate" +[home.formFill] +desc = "Fill PDF form fields interactively with a visual editor" +title = "Fill Form" + [home.autoRename] desc = "Auto renames a PDF file based on its detected header" tags = "auto-detect,header-based,organize,relabel,auto rename,automatic rename,smart rename,rename by content,filename,file naming,detect title" @@ -5282,6 +5286,7 @@ annotations = "Annotations" applyRedactionsFirst = "Apply redactions first" closePdf = "Close PDF" closeSelected = "Close Selected Files" +formFill = "Fill Form" deleteSelected = "Delete Selected Pages" deselectAll = "Deselect All" downloadAll = "Download All" @@ -6447,6 +6452,15 @@ loading = "Loading attachments..." empty = "No attachments in this document" noMatch = "No attachments match your search" +[viewer.formBar] +title = "Form Fields" +unsavedBadge = "Unsaved" +unsavedDesc = "You have unsaved changes" +hasFieldsDesc = "This PDF contains fillable fields" +dismiss = "Dismiss" +apply = "Apply Changes" +download = "Download PDF" + [viewPdf] header = "View PDF" tags = "view,read,annotate,text,image,highlight,edit" diff --git a/frontend/src/core/components/AppProviders.tsx b/frontend/src/core/components/AppProviders.tsx index 198bcd17a..cbf8e4923 100644 --- a/frontend/src/core/components/AppProviders.tsx +++ b/frontend/src/core/components/AppProviders.tsx @@ -23,6 +23,7 @@ import { useAppInitialization } from "@app/hooks/useAppInitialization"; import { useLogoAssets } from '@app/hooks/useLogoAssets'; import AppConfigLoader from '@app/components/shared/AppConfigLoader'; import { RedactionProvider } from "@app/contexts/RedactionContext"; +import { FormFillProvider } from "@app/tools/formFill/FormFillContext"; // Component to initialize scarf tracking (must be inside AppConfigProvider) function ScarfTrackingInitializer() { @@ -98,6 +99,7 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide + @@ -107,6 +109,7 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide + diff --git a/frontend/src/core/components/viewer/EmbedPdfViewer.tsx b/frontend/src/core/components/viewer/EmbedPdfViewer.tsx index 30fad6f5f..dc3e5a1bf 100644 --- a/frontend/src/core/components/viewer/EmbedPdfViewer.tsx +++ b/frontend/src/core/components/viewer/EmbedPdfViewer.tsx @@ -20,6 +20,8 @@ import { isStirlingFile } from '@app/types/fileContext'; import { useViewerRightRailButtons } from '@app/components/viewer/useViewerRightRailButtons'; import { StampPlacementOverlay } from '@app/components/viewer/StampPlacementOverlay'; import { useWheelZoom } from '@app/hooks/useWheelZoom'; +import { useFormFill } from '@app/tools/formFill/FormFillContext'; +import { FormSaveBar } from '@app/tools/formFill/FormSaveBar'; export interface EmbedPdfViewerProps { sidebarsVisible: boolean; @@ -86,9 +88,15 @@ const EmbedPdfViewerContent = ({ const pendingScrollRestoreRef = useRef(null); const scrollRestoreAttemptsRef = useRef(0); + // Rotation preservation system + // Similar to scroll preservation - track rotation across file reloads + const pendingRotationRestoreRef = useRef(null); + const rotationRestoreAttemptsRef = useRef(0); // Track the file ID we should be viewing after a save (to handle list reordering) const pendingFileIdRef = useRef(null); + const formApplyInProgressRef = useRef(false); + // Get redaction context const { redactionsApplied, setRedactionsApplied } = useRedaction(); @@ -107,6 +115,9 @@ const EmbedPdfViewerContent = ({ const { selectedTool } = useNavigationState(); + // Form fill context + const { fetchFields: fetchFormFields, setProviderMode } = useFormFill(); + const isInAnnotationTool = selectedTool === 'sign' || selectedTool === 'addText' || selectedTool === 'addImage' || selectedTool === 'annotate'; const isSignatureMode = isInAnnotationTool; const isManualRedactMode = selectedTool === 'redact'; @@ -117,6 +128,19 @@ const EmbedPdfViewerContent = ({ // Enable redaction only when redaction tool is selected const shouldEnableRedaction = selectedTool === 'redact'; + // FormFill tool mode — uses PDFBox backend for full-fidelity form handling + const isFormFillToolActive = (selectedTool as string) === 'formFill'; + + // Form overlays are shown in BOTH modes: + // - Normal viewer: form overlays visible (pdf-lib, frontend-only) + // - formFill tool: form overlays visible (PDFBox, backend) + const shouldEnableFormFill = true; + + // Switch the provider when the tool mode changes + useEffect(() => { + setProviderMode(isFormFillToolActive ? 'pdfbox' : 'pdflib'); + }, [isFormFillToolActive, setProviderMode]); + // Track previous annotation/redaction state to detect tool switches const prevEnableAnnotationsRef = useRef(shouldEnableAnnotations); const prevEnableRedactionRef = useRef(shouldEnableRedaction); @@ -367,6 +391,9 @@ const EmbedPdfViewerContent = ({ // Use the continuously tracked scroll position - more reliable than reading at this moment const pageToRestore = lastKnownScrollPageRef.current; + // Save the current rotation to restore after reload + const currentRotation = rotationState.rotation ?? 0; + // Step 0: Commit any pending redactions before export const hadPendingRedactions = (redactionTrackerRef.current?.getPendingCount() ?? 0) > 0; @@ -408,6 +435,9 @@ const EmbedPdfViewerContent = ({ pendingScrollRestoreRef.current = pageToRestore; scrollRestoreAttemptsRef.current = 0; + // Store the rotation to restore after file replacement + pendingRotationRestoreRef.current = currentRotation; + rotationRestoreAttemptsRef.current = 0; // Store the new file ID so we can track it after the list reorders const newFileId = stubs[0]?.id; if (newFileId) { @@ -424,7 +454,72 @@ const EmbedPdfViewerContent = ({ } catch (error) { console.error('Apply changes failed:', error); } - }, [currentFile, activeFiles, activeFileIndex, exportActions, actions, selectors, setHasUnsavedChanges, setRedactionsApplied]); + }, [currentFile, activeFiles, activeFileIndex, exportActions, actions, selectors, setHasUnsavedChanges, setRedactionsApplied, rotationState.rotation]); + + // Apply form fill changes - reload the filled PDF into the viewer + const handleFormApply = useCallback(async (filledBlob: Blob) => { + if (formApplyInProgressRef.current) return; + if (!currentFile || activeFileIds.length === 0) return; + + formApplyInProgressRef.current = true; + try { + console.log('[Viewer] Applying form fill changes - reloading filled PDF'); + + // Use the continuously tracked scroll position + const pageToRestore = lastKnownScrollPageRef.current; + + // Save the current rotation to restore after reload + const currentRotation = rotationState.rotation ?? 0; + + // Convert Blob to File + const filename = currentFile.name || 'document.pdf'; + const file = new File([filledBlob], filename, { type: 'application/pdf' }); + + // Get current file info for creating the updated version + const currentFileId = activeFiles[activeFileIndex]?.fileId; + if (!currentFileId) throw new Error('Current file ID not found'); + + const parentStub = selectors.getStirlingFileStub(currentFileId); + if (!parentStub) throw new Error('Parent stub not found'); + + // Create StirlingFiles and stubs for version history + const { stirlingFiles, stubs } = await createStirlingFilesAndStubs([file], parentStub, 'multiTool'); + + // Store the page to restore after file replacement + pendingScrollRestoreRef.current = pageToRestore; + scrollRestoreAttemptsRef.current = 0; + + // Store the rotation to restore after file replacement + pendingRotationRestoreRef.current = currentRotation; + rotationRestoreAttemptsRef.current = 0; + + // Store the new file ID for tracking + const newFileId = stubs[0]?.id; + if (newFileId) { + pendingFileIdRef.current = newFileId; + } + + // Replace the current file in context + await actions.consumeFiles([currentFileId], stirlingFiles, stubs); + + console.log('[Viewer] Form fill changes applied successfully'); + } catch (error) { + console.error('[Viewer] Apply form changes failed:', error); + } finally { + formApplyInProgressRef.current = false; + } + }, [currentFile, activeFiles, activeFileIndex, actions, selectors, activeFileIds.length, rotationState.rotation]); + + useEffect(() => { + const handler = (e: Event) => { + const blob = (e as CustomEvent).detail?.blob; + if (blob) { + handleFormApply(blob); + } + }; + window.addEventListener('formfill:apply', handler); + return () => window.removeEventListener('formfill:apply', handler); + }, [handleFormApply]); // Discard pending redactions but save already-applied ones // This is called when user clicks "Discard & Leave" - we want to: @@ -439,6 +534,10 @@ const EmbedPdfViewerContent = ({ try { console.log('[Viewer] Discarding pending marks but saving applied redactions'); + // Save current view state to restore after file replacement + const pageToRestore = lastKnownScrollPageRef.current; + const currentRotation = rotationState.rotation ?? 0; + // Export PDF WITHOUT committing pending marks - this saves only applied redactions const arrayBuffer = await exportActions.saveAsCopy(); if (!arrayBuffer) { @@ -459,6 +558,12 @@ const EmbedPdfViewerContent = ({ const { stirlingFiles, stubs } = await createStirlingFilesAndStubs([file], parentStub, 'multiTool'); + // Store view state to restore after file replacement + pendingScrollRestoreRef.current = pageToRestore; + scrollRestoreAttemptsRef.current = 0; + pendingRotationRestoreRef.current = currentRotation; + rotationRestoreAttemptsRef.current = 0; + // Consume only the current file (replace in context) await actions.consumeFiles([currentFileId], stirlingFiles, stubs); @@ -470,7 +575,7 @@ const EmbedPdfViewerContent = ({ } catch (error) { console.error('Failed to save applied redactions:', error); } - }, [redactionsApplied, currentFile, activeFiles, activeFileIndex, activeFileIds.length, exportActions, actions, selectors, setRedactionsApplied]); + }, [redactionsApplied, currentFile, activeFiles, activeFileIndex, activeFileIds.length, exportActions, actions, selectors, setRedactionsApplied, rotationState.rotation]); // Restore scroll position after file replacement or tool switch // Uses polling with retries to ensure the scroll succeeds @@ -524,6 +629,57 @@ const EmbedPdfViewerContent = ({ return () => clearTimeout(timer); }, [scrollState.totalPages, scrollActions, getScrollState]); + // Restore rotation after file replacement or tool switch + // Uses polling with retries to ensure the rotation succeeds + useEffect(() => { + if (pendingRotationRestoreRef.current === null) return; + + const rotationToRestore = pendingRotationRestoreRef.current; + const maxAttempts = 10; + const attemptInterval = 100; // ms between attempts + + const attemptRotation = () => { + const currentState = getScrollState(); + + // Only attempt if PDF is loaded (totalPages > 0) + if (currentState.totalPages > 0) { + _rotationActions.setRotation(rotationToRestore); + + // Check if rotation succeeded after a brief delay + setTimeout(() => { + const currentRotation = _rotationActions.getRotation(); + if (currentRotation === rotationToRestore || rotationRestoreAttemptsRef.current >= maxAttempts) { + // Success or max attempts reached - clear pending + pendingRotationRestoreRef.current = null; + rotationRestoreAttemptsRef.current = 0; + } else { + // Rotation might not have worked, retry + rotationRestoreAttemptsRef.current++; + if (rotationRestoreAttemptsRef.current < maxAttempts) { + setTimeout(attemptRotation, attemptInterval); + } else { + // Give up after max attempts + pendingRotationRestoreRef.current = null; + rotationRestoreAttemptsRef.current = 0; + } + } + }, 50); + } else if (rotationRestoreAttemptsRef.current < maxAttempts) { + // PDF not ready yet, retry + rotationRestoreAttemptsRef.current++; + setTimeout(attemptRotation, attemptInterval); + } else { + // Give up after max attempts + pendingRotationRestoreRef.current = null; + rotationRestoreAttemptsRef.current = 0; + } + }; + + // Start attempting after initial delay + const timer = setTimeout(attemptRotation, 150); + return () => clearTimeout(timer); + }, [scrollState.totalPages, _rotationActions, getScrollState]); + // Register applyChanges with ViewerContext so tools can access it directly useEffect(() => { setApplyChanges(applyChanges); @@ -535,6 +691,49 @@ const EmbedPdfViewerContent = ({ // Register viewer right-rail buttons useViewerRightRailButtons(); + // Auto-fetch form fields when a PDF is loaded in the viewer. + // In normal viewer mode, this uses pdf-lib (frontend-only). + // In formFill tool mode, this uses PDFBox (backend). + const formFillFileIdRef = useRef(null); + const formFillProviderRef = useRef(isFormFillToolActive); + + // Generate a unique identifier for the current file to detect file changes + const currentFileId = React.useMemo(() => { + if (!currentFile) return null; + + if (isStirlingFile(currentFile)) { + return `stirling-${currentFile.fileId}`; + } + + // File is also a Blob, but has more specific properties + if (currentFile instanceof File) { + return `file-${currentFile.name}-${currentFile.size}-${currentFile.lastModified}`; + } + + // Fallback for any other object (shouldn't happen in practice) + return `unknown-${(currentFile as any).size || 0}`; + }, [currentFile]); + + useEffect(() => { + const fileChanged = currentFileId !== formFillFileIdRef.current; + const providerChanged = formFillProviderRef.current !== isFormFillToolActive; + formFillProviderRef.current = isFormFillToolActive; + + if (fileChanged) { + console.log('[FormFill] File changed. Old:', formFillFileIdRef.current, 'New:', currentFileId); + formFillFileIdRef.current = currentFileId; + // NOTE: Don't call resetFormFill() here — fetchFormFields() handles + // clearing old state internally. Calling reset() before fetch() would + // double-increment fetchVersionRef, causing version mismatches when + // the effect re-fires before the async fetch completes. + } + + if (currentFile && (fileChanged || providerChanged)) { + console.log('[FormFill] Fetching form fields for:', currentFileId); + fetchFormFields(currentFile, currentFileId ?? undefined); + } + }, [isFormFillToolActive, currentFile, currentFileId, fetchFormFields]); + const sidebarWidthRem = 15; const totalRightMargin = (isThumbnailSidebarVisible ? sidebarWidthRem : 0) + @@ -586,7 +785,7 @@ const EmbedPdfViewerContent = ({ transition: 'margin-right 0.3s ease' }}> } annotationApiRef={annotationApiRef as React.RefObject} historyApiRef={historyApiRef as React.RefObject} redactionTrackerRef={redactionTrackerRef as React.RefObject} + fileId={currentFileId} onSignatureAdded={() => { // Handle signature added - for debugging, enable console logs as needed // Future: Handle signature completion }} /> + {/* Floating save bar for form-filled PDFs (like Chrome/Firefox PDF viewers) */} + { - return ; + return ( + + ); }; export default EmbedPdfViewer; diff --git a/frontend/src/core/components/viewer/LocalEmbedPDF.tsx b/frontend/src/core/components/viewer/LocalEmbedPDF.tsx index b50fe01d7..e38708581 100644 --- a/frontend/src/core/components/viewer/LocalEmbedPDF.tsx +++ b/frontend/src/core/components/viewer/LocalEmbedPDF.tsx @@ -56,6 +56,7 @@ import { DocumentPermissionsAPIBridge } from '@app/components/viewer/DocumentPer import { DocumentReadyWrapper } from '@app/components/viewer/DocumentReadyWrapper'; import { ActiveDocumentProvider } from '@app/components/viewer/ActiveDocumentContext'; import { absoluteWithBasePath } from '@app/constants/app'; +import { FormFieldOverlay } from '@app/tools/formFill/FormFieldOverlay'; const DOCUMENT_NAME = 'stirling-pdf-viewer'; @@ -65,6 +66,7 @@ interface LocalEmbedPDFProps { fileName?: string; enableAnnotations?: boolean; enableRedaction?: boolean; + enableFormFill?: boolean; isManualRedactionMode?: boolean; showBakedAnnotations?: boolean; onSignatureAdded?: (annotation: any) => void; @@ -72,9 +74,11 @@ interface LocalEmbedPDFProps { annotationApiRef?: React.RefObject; historyApiRef?: React.RefObject; redactionTrackerRef?: React.RefObject; + /** File identity passed through to FormFieldOverlay for stale-field guards */ + fileId?: string | null; } -export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false, enableRedaction = false, isManualRedactionMode = false, showBakedAnnotations = true, onSignatureAdded, signatureApiRef, annotationApiRef, historyApiRef, redactionTrackerRef }: LocalEmbedPDFProps) { +export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false, enableRedaction = false, enableFormFill = false, isManualRedactionMode = false, showBakedAnnotations = true, onSignatureAdded, signatureApiRef, annotationApiRef, historyApiRef, redactionTrackerRef, fileId }: LocalEmbedPDFProps) { const { t } = useTranslation(); const [pdfUrl, setPdfUrl] = useState(null); const [, setAnnotations] = useState>([]); @@ -121,7 +125,7 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false, }), createPluginRegistration(ScrollPluginPackage), createPluginRegistration(RenderPluginPackage, { - withForms: true, + withForms: !enableFormFill, // Disable native form rendering when our interactive overlay is active withAnnotations: showBakedAnnotations && !enableAnnotations, // Show baked annotations only when: visibility is ON and annotation layer is OFF }), @@ -200,7 +204,7 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false, // Register print plugin for printing PDFs createPluginRegistration(PrintPluginPackage), ]; - }, [pdfUrl, enableAnnotations, showBakedAnnotations, fileName, file, url]); + }, [pdfUrl, enableAnnotations, enableFormFill, showBakedAnnotations, fileName, file, url]); // Initialize the engine with the React hook - use local WASM for offline support const { engine, isLoading, error } = usePdfiumEngine({ @@ -731,6 +735,17 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false, + {/* FormFieldOverlay for interactive form filling */} + {enableFormFill && ( + + )} + {/* AnnotationLayer for annotation editing and annotation-based redactions */} {(enableAnnotations || enableRedaction) && ( { + thumbnailsRef.current = thumbnails; + }, [thumbnails]); + // Clear thumbnails when sidebar closes and revoke blob URLs to prevent memory leaks useEffect(() => { if (!visible) { - Object.values(thumbnails).forEach((thumbUrl) => { + Object.values(thumbnailsRef.current).forEach((thumbUrl) => { // Only revoke if it's a blob URL (not 'error') if (typeof thumbUrl === 'string' && thumbUrl.startsWith('blob:')) { URL.revokeObjectURL(thumbUrl); @@ -38,48 +44,89 @@ export function ThumbnailSidebar({ visible, onToggle: _onToggle, activeFileIndex }); setThumbnails({}); } - }, [visible]); // Remove thumbnails from dependency to prevent infinite loop + }, [visible]); + + // Cleanup all blob URLs on unmount to prevent memory leaks + useEffect(() => { + return () => { + Object.values(thumbnailsRef.current).forEach((thumbUrl) => { + if (typeof thumbUrl === 'string' && thumbUrl.startsWith('blob:')) { + URL.revokeObjectURL(thumbUrl); + } + }); + }; + }, []); // Generate thumbnails when sidebar becomes visible useEffect(() => { if (!visible || scrollState.totalPages === 0) return; if (!thumbnailAPI) return; + let isCancelled = false; + const generateThumbnails = async () => { - for (let pageIndex = 0; pageIndex < scrollState.totalPages; pageIndex++) { - if (thumbnails[pageIndex]) continue; // Skip if already generated + const allPages = Array.from({ length: scrollState.totalPages }, (_, i) => i); + const currentPage = scrollState.currentPage - 1; + + // Group pages by priority: + // 1. Current page + // 2. Visible neighbors (current +/- 3) + // 3. Everything else + const prioritized = [ + ...allPages.filter(i => i === currentPage), + ...allPages.filter(i => i !== currentPage && Math.abs(i - currentPage) <= 3), + ...allPages.filter(i => Math.abs(i - currentPage) > 3) + ]; + + const CONCURRENCY_LIMIT = 3; + const queue = [...prioritized]; + + const processNext = async () => { + if (queue.length === 0 || isCancelled) return; + const pageIndex = queue.shift()!; + + if (thumbnailsRef.current[pageIndex]) { + await processNext(); + return; + } try { const thumbTask = thumbnailAPI.renderThumb(pageIndex, 1.0); + const thumbBlob = await thumbTask.toPromise(); + if (isCancelled) { + // If cancelled during generation, revoke the new URL + return; + } + const thumbUrl = URL.createObjectURL(thumbBlob); - // Convert Task to Promise and handle properly - thumbTask.toPromise().then((thumbBlob: Blob) => { - const thumbUrl = URL.createObjectURL(thumbBlob); - - setThumbnails(prev => ({ - ...prev, - [pageIndex]: thumbUrl - })); - }).catch((error: any) => { - console.error('Failed to generate thumbnail for page', pageIndex + 1, error); + setThumbnails(prev => ({ + ...prev, + [pageIndex]: thumbUrl + })); + } catch (error) { + console.error('Failed to generate thumbnail for page', pageIndex + 1, error); + if (!isCancelled) { setThumbnails(prev => ({ ...prev, [pageIndex]: 'error' })); - }); - - } catch (error) { - console.error('Failed to generate thumbnail for page', pageIndex + 1, error); - setThumbnails(prev => ({ - ...prev, - [pageIndex]: 'error' - })); + } } - } + + await processNext(); + }; + + // Start initial concurrent batch + const workers = Array.from({ length: CONCURRENCY_LIMIT }, () => processNext()); + await Promise.all(workers); }; generateThumbnails(); - }, [visible, scrollState.totalPages, thumbnailAPI]); + + return () => { + isCancelled = true; + }; + }, [visible, scrollState.totalPages, thumbnailAPI, scrollState.currentPage]); const handlePageClick = (pageIndex: number) => { const pageNumber = pageIndex + 1; // Convert to 1-based diff --git a/frontend/src/core/components/viewer/useViewerRightRailButtons.tsx b/frontend/src/core/components/viewer/useViewerRightRailButtons.tsx index 918361c7b..3e742a54c 100644 --- a/frontend/src/core/components/viewer/useViewerRightRailButtons.tsx +++ b/frontend/src/core/components/viewer/useViewerRightRailButtons.tsx @@ -13,6 +13,7 @@ import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext'; import { useNavigationState, useNavigationGuard } from '@app/contexts/NavigationContext'; import { BASE_PATH, withBasePath } from '@app/constants/app'; import { useRedaction, useRedactionMode } from '@app/contexts/RedactionContext'; +import TextFieldsIcon from '@mui/icons-material/TextFields'; export function useViewerRightRailButtons() { const { t, i18n } = useTranslation(); @@ -21,7 +22,7 @@ export function useViewerRightRailButtons() { const [isPanning, setIsPanning] = useState(() => viewer.getPanState()?.isPanning ?? false); const { sidebarRefs } = useSidebarContext(); const { position: tooltipPosition } = useRightRailTooltipSide(sidebarRefs, 12); - const { handleToolSelect } = useToolWorkflow(); + const { handleToolSelect, handleBackToTools } = useToolWorkflow(); const { selectedTool } = useNavigationState(); const { requestNavigation } = useNavigationGuard(); const { redactionsApplied, activeType: redactionActiveType } = useRedaction(); @@ -77,6 +78,9 @@ export function useViewerRightRailButtons() { const attachmentLabel = t('rightRail.toggleAttachments', 'Toggle Attachments'); const printLabel = t('rightRail.print', 'Print PDF'); const annotationsLabel = t('rightRail.annotations', 'Annotations'); + const formFillLabel = t('rightRail.formFill', 'Fill Form'); + + const isFormFillActive = (selectedTool as string) === 'formFill'; const viewerButtons = useMemo(() => { const buttons: RightRailButtonWithAction[] = [ @@ -253,6 +257,35 @@ export function useViewerRightRailButtons() { ) }, + { + id: 'viewer-form-fill', + tooltip: formFillLabel, + ariaLabel: formFillLabel, + section: 'top' as const, + order: 62, + render: ({ disabled }) => ( + + { + if (disabled) return; + if (isFormFillActive) { + handleBackToTools(); + } else { + handleToolSelect('formFill' as any); + } + }} + disabled={disabled} + aria-pressed={isFormFillActive} + color={isFormFillActive ? 'blue' : undefined} + > + + + + ) + }, ]; // Optional: Save button for annotations (always registered when this hook is used @@ -282,6 +315,8 @@ export function useViewerRightRailButtons() { handleToolSelect, pendingCount, redactionActiveType, + formFillLabel, + isFormFillActive, ]); useRightRailButtons(viewerButtons); diff --git a/frontend/src/core/theme/mantineTheme.ts b/frontend/src/core/theme/mantineTheme.ts index 0e43db9a4..905c8b1fa 100644 --- a/frontend/src/core/theme/mantineTheme.ts +++ b/frontend/src/core/theme/mantineTheme.ts @@ -221,10 +221,10 @@ export const mantineTheme = createTheme({ }, option: { color: 'var(--text-primary)', - '&[dataHovered]': { + '&[data-hovered]': { backgroundColor: 'var(--hover-bg)', }, - '&[dataSelected]': { + '&[data-selected]': { backgroundColor: 'var(--color-primary-100)', color: 'var(--color-primary-900)', }, @@ -356,7 +356,7 @@ export const mantineTheme = createTheme({ }, control: { color: 'var(--text-secondary)', - '[dataActive]': { + '&[data-active]': { backgroundColor: 'var(--bg-surface)', color: 'var(--text-primary)', boxShadow: 'var(--shadow-sm)', diff --git a/frontend/src/core/tools/formFill/FormFieldOverlay.tsx b/frontend/src/core/tools/formFill/FormFieldOverlay.tsx new file mode 100644 index 000000000..9f673a003 --- /dev/null +++ b/frontend/src/core/tools/formFill/FormFieldOverlay.tsx @@ -0,0 +1,20 @@ +/** + * FormFieldOverlay stub for the core build. + * This file is overridden in src/proprietary/tools/formFill/FormFieldOverlay.tsx + * when building the proprietary variant. + */ + +interface FormFieldOverlayProps { + documentId: string; + pageIndex: number; + pageWidth: number; + pageHeight: number; + fileId?: string | null; +} + +export function FormFieldOverlay(_props: FormFieldOverlayProps) { + // Core build stub — renders nothing + return null; +} + +export default FormFieldOverlay; diff --git a/frontend/src/core/tools/formFill/FormFieldSidebar.tsx b/frontend/src/core/tools/formFill/FormFieldSidebar.tsx new file mode 100644 index 000000000..637fdc0fb --- /dev/null +++ b/frontend/src/core/tools/formFill/FormFieldSidebar.tsx @@ -0,0 +1,17 @@ +/** + * FormFieldSidebar stub for the core build. + * This file is overridden in src/proprietary/tools/formFill/FormFieldSidebar.tsx + * when building the proprietary variant. + */ + +interface FormFieldSidebarProps { + visible: boolean; + onToggle: () => void; +} + +export function FormFieldSidebar(_props: FormFieldSidebarProps) { + // Core build stub — renders nothing + return null; +} + +export default FormFieldSidebar; diff --git a/frontend/src/core/tools/formFill/FormFillContext.tsx b/frontend/src/core/tools/formFill/FormFillContext.tsx new file mode 100644 index 000000000..42ea4d8ca --- /dev/null +++ b/frontend/src/core/tools/formFill/FormFillContext.tsx @@ -0,0 +1,85 @@ +/** + * FormFillProvider stub for the core build. + * This file is overridden in src/proprietary/tools/formFill/FormFillContext.tsx + * when building the proprietary variant. + */ +import React, { createContext, useContext } from 'react'; + +interface FormFillContextValue { + state: { + fields: any[]; + values: Record; + loading: boolean; + error: string | null; + activeFieldName: string | null; + isDirty: boolean; + validationErrors: Record; + }; + fetchFields: (file: File | Blob, fileId?: string) => Promise; + setValue: (fieldName: string, value: string) => void; + setActiveField: (fieldName: string | null) => void; + submitForm: (file: File | Blob, flatten?: boolean) => Promise; + getField: (fieldName: string) => any | undefined; + getFieldsForPage: (pageIndex: number) => any[]; + getValue: (fieldName: string) => string; + validateForm: () => boolean; + reset: () => void; + fieldsByPage: Map; + activeProviderName: string; + setProviderMode: (mode: 'pdflib' | 'pdfbox') => void; + forFileId: string | null; +} + +const noopAsync = async () => {}; +const noop = () => {}; + +const FormFillContext = createContext(null); + +export const useFormFill = (): FormFillContextValue => { + const ctx = useContext(FormFillContext); + if (!ctx) { + // Return a default no-op value for core builds + return { + state: { + fields: [], + values: {}, + loading: false, + error: null, + activeFieldName: null, + isDirty: false, + validationErrors: {}, + }, + fetchFields: noopAsync, + setValue: noop, + setActiveField: noop, + submitForm: async () => new Blob(), + getField: () => undefined, + getFieldsForPage: () => [], + getValue: () => '', + validateForm: () => true, + reset: noop, + fieldsByPage: new Map(), + activeProviderName: 'none', + setProviderMode: noop, + forFileId: null, + }; + } + return ctx; +}; + +/** No-op stub for core builds */ +export function useFieldValue(_fieldName: string): string { + return ''; +} + +/** No-op stub for core builds */ +export function useAllFormValues(): Record { + return {}; +} + +export function FormFillProvider({ children }: { children: React.ReactNode }) { + // In core build, just render children without provider + return <>{children}; +} + +export default FormFillContext; diff --git a/frontend/src/core/tools/formFill/FormSaveBar.tsx b/frontend/src/core/tools/formFill/FormSaveBar.tsx new file mode 100644 index 000000000..673105997 --- /dev/null +++ b/frontend/src/core/tools/formFill/FormSaveBar.tsx @@ -0,0 +1,17 @@ +/** + * FormSaveBar stub for the core build. + * This file is overridden in src/proprietary/tools/formFill/FormSaveBar.tsx + * when building the proprietary variant. + */ + +interface FormSaveBarProps { + file: File | Blob | null; + isFormFillToolActive: boolean; + onApply?: (filledBlob: Blob) => Promise; +} + +export function FormSaveBar(_props: FormSaveBarProps) { + return null; +} + +export default FormSaveBar; diff --git a/frontend/src/proprietary/data/useProprietaryToolRegistry.tsx b/frontend/src/proprietary/data/useProprietaryToolRegistry.tsx index 7181598b9..228ee9e8c 100644 --- a/frontend/src/proprietary/data/useProprietaryToolRegistry.tsx +++ b/frontend/src/proprietary/data/useProprietaryToolRegistry.tsx @@ -1,5 +1,10 @@ import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; import { type ProprietaryToolRegistry } from "@app/data/toolsTaxonomy"; +import { ToolCategoryId, SubcategoryId } from "@app/data/toolsTaxonomy"; +import FormFill from "@app/tools/formFill/FormFill"; +import React from "react"; +import TextFieldsIcon from '@mui/icons-material/TextFields'; /** * Hook that provides the proprietary tool registry. @@ -8,5 +13,21 @@ import { type ProprietaryToolRegistry } from "@app/data/toolsTaxonomy"; * and will be included in the main tool registry. */ export function useProprietaryToolRegistry(): ProprietaryToolRegistry { - return useMemo(() => ({}), []); + const { t } = useTranslation(); + + return useMemo(() => ({ + formFill: { + icon: React.createElement(TextFieldsIcon, { sx: { fontSize: '1.5rem' } }), + name: t('home.formFill.title', 'Fill Form'), + component: FormFill, + description: t('home.formFill.desc', 'Fill PDF form fields interactively with a visual editor'), + categoryId: ToolCategoryId.STANDARD_TOOLS, + subcategoryId: SubcategoryId.GENERAL, + workbench: 'viewer' as const, + endpoints: ['form-fill'], + automationSettings: null, + supportsAutomate: false, + synonyms: ['form', 'fill', 'fillable', 'input', 'field', 'acroform'], + }, + }), [t]); } diff --git a/frontend/src/proprietary/tools/formFill/FieldInput.tsx b/frontend/src/proprietary/tools/formFill/FieldInput.tsx new file mode 100644 index 000000000..a2355ec39 --- /dev/null +++ b/frontend/src/proprietary/tools/formFill/FieldInput.tsx @@ -0,0 +1,204 @@ +/** + * FieldInput: Shared, self-subscribing form field input widget. + * + * Used by both FormFill (left panel) and FormFieldSidebar (right panel). + * Each instance subscribes to its own field value via useFieldValue(), + * so only the active widget re-renders when its value changes. + */ +import React, { useCallback, memo } from 'react'; +import { + TextInput, + Textarea, + Checkbox, + Radio, + Select, + MultiSelect, + Stack, +} from '@mantine/core'; +import { useFieldValue } from '@proprietary/tools/formFill/FormFillContext'; +import type { FormField } from '@proprietary/tools/formFill/types'; + +function FieldInputInner({ + field, + value, + onValueChange, +}: { + field: FormField; + value: string; + onValueChange: (fieldName: string, value: string) => void; +}) { + const onChange = useCallback( + (v: string) => onValueChange(field.name, v), + [onValueChange, field.name] + ); + + switch (field.type) { + case 'text': + if (field.multiline) { + return ( +