diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/util/FormFieldTypeSupport.java b/app/common/src/main/java/stirling/software/common/util/FormFieldTypeSupport.java similarity index 99% rename from app/proprietary/src/main/java/stirling/software/proprietary/util/FormFieldTypeSupport.java rename to app/common/src/main/java/stirling/software/common/util/FormFieldTypeSupport.java index 4f1c1e0d8..3018a26fd 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/util/FormFieldTypeSupport.java +++ b/app/common/src/main/java/stirling/software/common/util/FormFieldTypeSupport.java @@ -1,4 +1,4 @@ -package stirling.software.proprietary.util; +package stirling.software.common.util; import java.io.IOException; import java.util.Arrays; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/util/FormUtils.java b/app/common/src/main/java/stirling/software/common/util/FormUtils.java similarity index 99% rename from app/proprietary/src/main/java/stirling/software/proprietary/util/FormUtils.java rename to app/common/src/main/java/stirling/software/common/util/FormUtils.java index b5d412f98..fa8020a23 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/util/FormUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/FormUtils.java @@ -1,4 +1,4 @@ -package stirling.software.proprietary.util; +package stirling.software.common.util; import java.awt.image.BufferedImage; import java.io.IOException; @@ -49,9 +49,6 @@ 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; @Slf4j @UtilityClass diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/util/FormUtilsTest.java b/app/common/src/test/java/stirling/software/common/util/FormUtilsTest.java similarity index 99% rename from app/proprietary/src/test/java/stirling/software/proprietary/util/FormUtilsTest.java rename to app/common/src/test/java/stirling/software/common/util/FormUtilsTest.java index 6416e82bf..592402b88 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/util/FormUtilsTest.java +++ b/app/common/src/test/java/stirling/software/common/util/FormUtilsTest.java @@ -1,4 +1,4 @@ -package stirling.software.proprietary.util; +package stirling.software.common.util; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/form/FormFillController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormFillController.java similarity index 99% rename from app/proprietary/src/main/java/stirling/software/proprietary/controller/api/form/FormFillController.java rename to app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormFillController.java index 1b584af2e..ce8f77faa 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/form/FormFillController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormFillController.java @@ -1,4 +1,4 @@ -package stirling.software.proprietary.controller.api.form; +package stirling.software.SPDF.controller.api.form; import java.io.ByteArrayOutputStream; import java.io.IOException; @@ -30,8 +30,8 @@ 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.FormUtils; import stirling.software.common.util.WebResponseUtils; -import stirling.software.proprietary.util.FormUtils; @RestController @RequestMapping("/api/v1/form") diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/form/FormPayloadParser.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormPayloadParser.java similarity index 98% rename from app/proprietary/src/main/java/stirling/software/proprietary/controller/api/form/FormPayloadParser.java rename to app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormPayloadParser.java index 2efffb21d..f9cd97811 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/form/FormPayloadParser.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormPayloadParser.java @@ -1,4 +1,4 @@ -package stirling.software.proprietary.controller.api.form; +package stirling.software.SPDF.controller.api.form; import java.io.IOException; import java.util.ArrayList; @@ -13,7 +13,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import stirling.software.common.util.ExceptionUtils; -import stirling.software.proprietary.util.FormUtils; +import stirling.software.common.util.FormUtils; final class FormPayloadParser { diff --git a/frontend/src/core/data/useTranslatedToolRegistry.tsx b/frontend/src/core/data/useTranslatedToolRegistry.tsx index 0bfb6c9f1..f055f9c2c 100644 --- a/frontend/src/core/data/useTranslatedToolRegistry.tsx +++ b/frontend/src/core/data/useTranslatedToolRegistry.tsx @@ -39,6 +39,7 @@ import AutoRename from "@app/tools/AutoRename"; import SingleLargePage from "@app/tools/SingleLargePage"; import PageLayout from "@app/tools/PageLayout"; import UnlockPdfForms from "@app/tools/UnlockPdfForms"; +import FormFill from "@app/tools/formFill/FormFill"; import RemoveCertificateSign from "@app/tools/RemoveCertificateSign"; import RemoveImage from "@app/tools/RemoveImage"; import CertSign from "@app/tools/CertSign"; @@ -346,6 +347,19 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog { synonyms: getSynonyms(t, "unlockPDFForms"), automationSettings: null }, + formFill: { + icon: , + 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'], + }, changePermissions: { icon: , name: t("home.changePermissions.title", "Change Permissions"), diff --git a/frontend/src/proprietary/tools/formFill/FieldInput.tsx b/frontend/src/core/tools/formFill/FieldInput.tsx similarity index 97% rename from frontend/src/proprietary/tools/formFill/FieldInput.tsx rename to frontend/src/core/tools/formFill/FieldInput.tsx index a2355ec39..d310a63bc 100644 --- a/frontend/src/proprietary/tools/formFill/FieldInput.tsx +++ b/frontend/src/core/tools/formFill/FieldInput.tsx @@ -15,8 +15,8 @@ import { MultiSelect, Stack, } from '@mantine/core'; -import { useFieldValue } from '@proprietary/tools/formFill/FormFillContext'; -import type { FormField } from '@proprietary/tools/formFill/types'; +import { useFieldValue } from '@app/tools/formFill/FormFillContext'; +import type { FormField } from '@app/tools/formFill/types'; function FieldInputInner({ field, diff --git a/frontend/src/core/tools/formFill/FormFieldOverlay.tsx b/frontend/src/core/tools/formFill/FormFieldOverlay.tsx index 9f673a003..22698b905 100644 --- a/frontend/src/core/tools/formFill/FormFieldOverlay.tsx +++ b/frontend/src/core/tools/formFill/FormFieldOverlay.tsx @@ -1,20 +1,473 @@ /** - * FormFieldOverlay stub for the core build. - * This file is overridden in src/proprietary/tools/formFill/FormFieldOverlay.tsx - * when building the proprietary variant. + * FormFieldOverlay — Renders interactive HTML form widgets on top of a PDF page. + * + * This layer is placed inside the renderPage callback of the EmbedPDF Scroller, + * similar to how AnnotationLayer, RedactionLayer, and LinkLayer work. + * + * It reads the form field coordinates (in un-rotated CSS space, top-left origin) + * and scales them using the document scale from EmbedPDF. + * + * Each widget renders an appropriate HTML input (text, checkbox, dropdown, etc.) + * that synchronises bidirectionally with FormFillContext values. + * + * Coordinate handling: + * Both providers (PdfLibFormProvider and PdfBoxFormProvider) output widget + * coordinates in un-rotated PDF space (y-flipped to CSS upper-left origin). + * The component (which wraps this overlay along with page tiles) + * handles visual rotation via CSS transforms — same as TilingLayer, + * AnnotationLayer, and LinkLayer. */ +import React, { useCallback, useMemo, memo } from 'react'; +import { useDocumentState } from '@embedpdf/core/react'; +import { useFormFill, useFieldValue } from '@app/tools/formFill/FormFillContext'; +import type { FormField, WidgetCoordinates } from '@app/tools/formFill/types'; + +interface WidgetInputProps { + field: FormField; + widget: WidgetCoordinates; + isActive: boolean; + error?: string; + scaleX: number; + scaleY: number; + onFocus: (fieldName: string) => void; + onChange: (fieldName: string, value: string) => void; +} + +/** + * WidgetInput subscribes to its own field value via useSyncExternalStore, + * so it only re-renders when its specific value changes — not when ANY + * form value in the entire document changes. + */ +function WidgetInputInner({ + field, + widget, + isActive, + error, + scaleX, + scaleY, + onFocus, + onChange, +}: WidgetInputProps) { + // Per-field value subscription — only this widget re-renders when its value changes + const value = useFieldValue(field.name); + + // Coordinates are in visual CSS space (top-left origin). + // Multiply by per-axis scale to get rendered pixel coordinates. + const left = widget.x * scaleX; + const top = widget.y * scaleY; + const width = widget.width * scaleX; + const height = widget.height * scaleY; + + const borderColor = error ? '#f44336' : (isActive ? '#2196F3' : 'rgba(33, 150, 243, 0.4)'); + const bgColor = error + ? '#FFEBEE' // Red 50 (Opaque) + : (isActive ? '#E3F2FD' : '#FFFFFF'); // Blue 50 (Opaque) : White (Opaque) + + const commonStyle: React.CSSProperties = { + position: 'absolute', + left, + top, + width, + height, + zIndex: 10, + boxSizing: 'border-box', + border: `1px solid ${borderColor}`, + borderRadius: 1, + background: isActive ? bgColor : 'transparent', + transition: 'border-color 0.15s, background 0.15s, box-shadow 0.15s', + boxShadow: + isActive && field.type !== 'radio' && field.type !== 'checkbox' + ? `0 0 0 2px ${error ? 'rgba(244, 67, 54, 0.25)' : 'rgba(33, 150, 243, 0.25)'}` + : 'none', + cursor: field.readOnly ? 'default' : 'text', + pointerEvents: 'auto', + display: 'flex', + alignItems: field.multiline ? 'stretch' : 'center', + }; + + const stopPropagation = (e: React.SyntheticEvent) => { + e.stopPropagation(); + // Also stop immediate propagation to native listeners to block non-React subscribers + if (e.nativeEvent) { + e.nativeEvent.stopImmediatePropagation?.(); + } + }; + + const commonProps = { + style: commonStyle, + onPointerDown: stopPropagation, + onPointerUp: stopPropagation, + onMouseDown: stopPropagation, + onMouseUp: stopPropagation, + onClick: stopPropagation, + onDoubleClick: stopPropagation, + onKeyDown: stopPropagation, + onKeyUp: stopPropagation, + onKeyPress: stopPropagation, + onDragStart: stopPropagation, + onSelect: stopPropagation, + onContextMenu: stopPropagation, + }; + + const captureStopProps = { + onPointerDownCapture: stopPropagation, + onPointerUpCapture: stopPropagation, + onMouseDownCapture: stopPropagation, + onMouseUpCapture: stopPropagation, + onClickCapture: stopPropagation, + onKeyDownCapture: stopPropagation, + onKeyUpCapture: stopPropagation, + onKeyPressCapture: stopPropagation, + }; + + const fontSize = widget.fontSize + ? widget.fontSize * scaleY + : field.multiline + ? Math.max(6, Math.min(height * 0.60, 14)) + : Math.max(6, height * 0.65); + + const inputBaseStyle: React.CSSProperties = { + width: '100%', + height: '100%', + border: 'none', + outline: 'none', + background: 'transparent', + padding: 0, + paddingLeft: `${Math.max(2, 4 * scaleX)}px`, + paddingRight: `${Math.max(2, 4 * scaleX)}px`, + fontSize: `${fontSize}px`, + fontFamily: 'Helvetica, Arial, sans-serif', + color: '#000', + boxSizing: 'border-box', + lineHeight: 'normal', + }; + + const handleFocus = () => onFocus(field.name); + + switch (field.type) { + case 'text': + return ( +
+ {field.multiline ? ( +