From b961925de2ac0ee331bfe451989f39f2a7c5540b Mon Sep 17 00:00:00 2001
From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
Date: Thu, 13 Aug 2026 20:48:47 +0100
Subject: [PATCH] Import and export XFDF and FDF form data in Fill Form
---
.../public/locales/en-GB/translation.toml | 10 +
.../public/locales/en-US/translation.toml | 10 +
.../core/tests/stubbed/form-fill-xfdf.spec.ts | 173 +++++++
.../core/tools/formFill/FormFill.module.css | 18 +
.../src/core/tools/formFill/FormFill.tsx | 192 ++++++--
.../core/tools/formFill/FormFillContext.tsx | 16 +-
.../src/core/utils/formDataExchange.test.ts | 406 ++++++++++++++++
.../editor/src/core/utils/formDataExchange.ts | 439 +++++++++++++++++
.../src/core/utils/postscriptObjects.ts | 450 ++++++++++++++++++
9 files changed, 1684 insertions(+), 30 deletions(-)
create mode 100644 frontend/editor/src/core/tests/stubbed/form-fill-xfdf.spec.ts
create mode 100644 frontend/editor/src/core/utils/formDataExchange.test.ts
create mode 100644 frontend/editor/src/core/utils/formDataExchange.ts
create mode 100644 frontend/editor/src/core/utils/postscriptObjects.ts
diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml
index bdf6a739ed..50c73afc73 100644
--- a/frontend/editor/public/locales/en-GB/translation.toml
+++ b/frontend/editor/public/locales/en-GB/translation.toml
@@ -4253,9 +4253,19 @@ issues = "GitHub"
[formFill]
allSaved = "All saved"
+exportAs = "Export data as"
+exportAsFormat = "Export as {{format}}"
extractCsvError = "Failed to extract CSV"
extractXlsxError = "Failed to extract XLSX"
flattenAfterFilling = "Flatten after filling"
+importData = "Import form data"
+importError = "Failed to import form data"
+importSkipped_one = "{{count}} field is not in this PDF: {{names}}"
+importSkipped_other = "{{count}} fields are not in this PDF: {{names}}"
+importSkippedNames = "{{names}} and {{rest}} more"
+importSuccess_one = "Imported {{count}} field from {{format}}"
+importSuccess_other = "Imported {{count}} fields from {{format}}"
+importTooltip = "Import values from an Acrobat XFDF or FDF export"
requiredAbbreviation = "req"
requiredFieldsError = "Please fill in all required fields"
rescanFields = "Re-scan fields"
diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml
index d6b38bc2e8..cc59a054bc 100644
--- a/frontend/editor/public/locales/en-US/translation.toml
+++ b/frontend/editor/public/locales/en-US/translation.toml
@@ -4263,10 +4263,20 @@ issues = "GitHub"
[formFill]
allSaved = "All saved"
analyzingFields = "Analysing form fields..."
+exportAs = "Export data as"
+exportAsFormat = "Export as {{format}}"
extractCsvError = "Failed to extract CSV"
extractXlsxError = "Failed to extract XLSX"
filled = "filled"
flattenAfterFilling = "Flatten after filling"
+importData = "Import form data"
+importError = "Failed to import form data"
+importSkipped_one = "{{count}} field is not in this PDF: {{names}}"
+importSkipped_other = "{{count}} fields are not in this PDF: {{names}}"
+importSkippedNames = "{{names}} and {{rest}} more"
+importSuccess_one = "Imported {{count}} field from {{format}}"
+importSuccess_other = "Imported {{count}} fields from {{format}}"
+importTooltip = "Import values from an Acrobat XFDF or FDF export"
noFields = "No fillable form fields found in this PDF."
placeholderEnter = "Enter"
placeholderSelect = "Select"
diff --git a/frontend/editor/src/core/tests/stubbed/form-fill-xfdf.spec.ts b/frontend/editor/src/core/tests/stubbed/form-fill-xfdf.spec.ts
new file mode 100644
index 0000000000..293564244c
--- /dev/null
+++ b/frontend/editor/src/core/tests/stubbed/form-fill-xfdf.spec.ts
@@ -0,0 +1,173 @@
+import { test, expect } from "@app/tests/helpers/stub-test-base";
+import { uploadFiles } from "@app/tests/helpers/ui-helpers";
+import type { Page } from "@playwright/test";
+import path from "path";
+
+/**
+ * Stubbed coverage for the Form Fill tool's XFDF / FDF exchange.
+ *
+ * These are Acrobat's interchange formats for form *values*, so this is the
+ * seam that lets an Acrobat-based forms workflow move over: import an
+ * existing export, or hand a filled form back to a process that expects
+ * XFDF.
+ *
+ * The field list normally comes from the PDFBox backend; here
+ * `/api/v1/form/fields-with-coordinates` is stubbed so the flow runs without
+ * a server.
+ */
+
+const SAMPLE_PDF = path.join(
+ import.meta.dirname,
+ "../test-fixtures/sample.pdf",
+);
+
+const FIELDS = [
+ {
+ name: "FullName",
+ label: "Full name",
+ type: "text",
+ value: "",
+ options: null,
+ displayOptions: null,
+ required: false,
+ readOnly: false,
+ multiSelect: false,
+ multiline: false,
+ tooltip: null,
+ widgets: [
+ { pageIndex: 0, x: 100, y: 100, width: 200, height: 20, fontSize: 10 },
+ ],
+ },
+ {
+ name: "Address.Street",
+ label: "Street",
+ type: "text",
+ value: "",
+ options: null,
+ displayOptions: null,
+ required: false,
+ readOnly: false,
+ multiSelect: false,
+ multiline: false,
+ tooltip: null,
+ widgets: [
+ { pageIndex: 0, x: 100, y: 140, width: 200, height: 20, fontSize: 10 },
+ ],
+ },
+ {
+ name: "Languages",
+ label: "Languages",
+ type: "listbox",
+ value: "",
+ options: ["English", "French", "German"],
+ displayOptions: null,
+ required: false,
+ readOnly: false,
+ multiSelect: true,
+ multiline: false,
+ tooltip: null,
+ widgets: [
+ { pageIndex: 0, x: 100, y: 180, width: 200, height: 40, fontSize: 10 },
+ ],
+ },
+];
+
+/** A real-shaped Acrobat XFDF export, including a field this PDF lacks. */
+const XFDF = `
+
+
+
+ Ada Lovelace
+
+ 1 High Street
+
+
+ English
+ French
+
+ ignored
+
+
+`;
+
+async function openFormFill(page: Page): Promise {
+ await page.route("**/api/v1/form/fields-with-coordinates", (route) =>
+ route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(FIELDS),
+ }),
+ );
+ await page.goto("/form-fill");
+ await page.waitForLoadState("domcontentloaded");
+ await uploadFiles(page, SAMPLE_PDF);
+ // The panel only renders its actions once the field fetch resolves.
+ await expect(
+ page.getByRole("button", { name: /Import form data/i }),
+ ).toBeVisible({
+ timeout: 20_000,
+ });
+}
+
+test.describe("Form Fill — XFDF / FDF exchange", () => {
+ test("importing an XFDF export fills the matching fields and reports the rest", async ({
+ page,
+ }) => {
+ await openFormFill(page);
+
+ await page.locator('input[type="file"][accept*="xfdf"]').setInputFiles({
+ name: "export.xfdf",
+ mimeType: "application/vnd.adobe.xfdf",
+ buffer: Buffer.from(XFDF),
+ });
+
+ // Three of the four fields exist in this document. The two counts
+ // pluralise independently: "3 fields" imported, "1 field" skipped.
+ await expect(
+ page.getByText(
+ /Imported 3 fields from XFDF\. 1 field is not in this PDF: NotInThisPdf/,
+ ),
+ ).toBeVisible({ timeout: 10_000 });
+
+ // Values reached the form store: the panel's progress counter moves and
+ // the text input shows the imported value.
+ await expect(page.getByText("3 / 3 filled")).toBeVisible();
+ await expect(
+ page.locator('input[value="Ada Lovelace"]').first(),
+ ).toBeVisible();
+ });
+
+ test("a filled form exports as XFDF", async ({ page }) => {
+ await openFormFill(page);
+
+ await page.locator('input[type="file"][accept*="xfdf"]').setInputFiles({
+ name: "export.xfdf",
+ mimeType: "application/vnd.adobe.xfdf",
+ buffer: Buffer.from(XFDF),
+ });
+ await expect(page.getByText(/Imported 3 fields from XFDF/)).toBeVisible({
+ timeout: 10_000,
+ });
+
+ const downloadPromise = page.waitForEvent("download");
+ await page.getByRole("button", { name: /Export as XFDF/i }).click();
+ const download = await downloadPromise;
+ expect(download.suggestedFilename()).toMatch(/\.xfdf$/);
+ });
+
+ test("an unrecognised data file is rejected with a readable message", async ({
+ page,
+ }) => {
+ await openFormFill(page);
+
+ await page.locator('input[type="file"][accept*="xfdf"]').setInputFiles({
+ name: "notes.xfdf",
+ mimeType: "application/vnd.adobe.xfdf",
+ buffer: Buffer.from('{"FullName":"Ada"}'),
+ });
+
+ await expect(page.getByText(/Unrecognised form data file/)).toBeVisible({
+ timeout: 10_000,
+ });
+ });
+});
diff --git a/frontend/editor/src/core/tools/formFill/FormFill.module.css b/frontend/editor/src/core/tools/formFill/FormFill.module.css
index edd19d0814..1d30961ec4 100644
--- a/frontend/editor/src/core/tools/formFill/FormFill.module.css
+++ b/frontend/editor/src/core/tools/formFill/FormFill.module.css
@@ -106,6 +106,24 @@
padding-right: 0.25rem;
}
+/* Import is an input action, not one of the export formats, so it gets its
+ own full-width row. Four format buttons is already the most this panel
+ width fits without truncating their labels. */
+.importRow {
+ display: flex;
+}
+
+.importRow > button {
+ flex: 1;
+}
+
+.exportLabel {
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+ margin-top: 0.125rem;
+}
+
.fieldList {
flex: 1;
overflow: hidden;
diff --git a/frontend/editor/src/core/tools/formFill/FormFill.tsx b/frontend/editor/src/core/tools/formFill/FormFill.tsx
index 599d586319..a16a059f23 100644
--- a/frontend/editor/src/core/tools/formFill/FormFill.tsx
+++ b/frontend/editor/src/core/tools/formFill/FormFill.tsx
@@ -54,10 +54,16 @@ import FileCopyIcon from "@mui/icons-material/FileCopy";
import BuildCircleIcon from "@mui/icons-material/BuildCircle";
import DescriptionIcon from "@mui/icons-material/Description";
import FileDownloadIcon from "@mui/icons-material/FileDownload";
+import FileUploadIcon from "@mui/icons-material/FileUpload";
import {
extractFormFieldsCsv,
extractFormFieldsXlsx,
} from "@app/tools/formFill/formApi";
+import {
+ buildXfdf,
+ parseFormDataFile,
+ reconcileImportedValues,
+} from "@app/utils/formDataExchange";
import styles from "@app/tools/formFill/FormFill.module.css";
// ---------------------------------------------------------------------------
@@ -149,6 +155,8 @@ const FormFill = (_props: BaseToolProps) => {
const [saving, setSaving] = useState(false);
const [extracting, setExtracting] = useState(false);
const [saveError, setSaveError] = useState(null);
+ const [importSummary, setImportSummary] = useState(null);
+ const importInputRef = useRef(null);
const [lastSavedFlatten, setLastSavedFlatten] = useState(
null,
@@ -210,6 +218,91 @@ const FormFill = (_props: BaseToolProps) => {
}
}, [currentFile, allValues]);
+ /**
+ * Export as XFDF - the interchange format Acrobat's "Import Data" reads, so
+ * a form filled here can be handed back to an Acrobat-based process.
+ */
+ const handleExportXfdf = useCallback(() => {
+ setExtracting(true);
+ try {
+ const multiSelectFields = formState.fields
+ .filter((field) => field.multiSelect)
+ .map((field) => field.name);
+ const xfdf = buildXfdf(allValues, {
+ pdfHref: currentFile instanceof File ? currentFile.name : undefined,
+ multiSelectFields,
+ });
+ const blob = new Blob([xfdf], { type: "application/vnd.adobe.xfdf" });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = `form-data-${new Date().getTime()}.xfdf`;
+ a.click();
+ setTimeout(() => URL.revokeObjectURL(url), 250);
+ } finally {
+ setExtracting(false);
+ }
+ }, [allValues, currentFile, formState.fields]);
+
+ /**
+ * Import an Acrobat XFDF/FDF export into the open form. Values for fields
+ * this document doesn't have are reported rather than silently dropped.
+ */
+ const handleImportFormData = useCallback(
+ async (file: File) => {
+ setSaveError(null);
+ setImportSummary(null);
+ try {
+ const { values, format } = await parseFormDataFile(file);
+ const { applied, unmatched } = reconcileImportedValues(
+ values,
+ formState.fields.map((field) => field.name),
+ );
+ for (const [name, value] of Object.entries(applied)) {
+ setValue(name, value);
+ }
+ // Two sentences, not one: each count needs its own plural form, so
+ // "Imported 1 field" can sit next to "3 fields are not in this PDF".
+ const imported = t(
+ "formFill.importSuccess",
+ "Imported {{count}} field(s) from {{format}}",
+ { count: Object.keys(applied).length, format: format.toUpperCase() },
+ );
+ const NAME_LIMIT = 5;
+ const skipped =
+ unmatched.length > 0
+ ? t(
+ "formFill.importSkipped",
+ "{{count}} field(s) are not in this PDF: {{names}}",
+ {
+ count: unmatched.length,
+ names:
+ unmatched.length > NAME_LIMIT
+ ? t(
+ "formFill.importSkippedNames",
+ "{{names}} and {{rest}} more",
+ {
+ names: unmatched.slice(0, NAME_LIMIT).join(", "),
+ rest: unmatched.length - NAME_LIMIT,
+ },
+ )
+ : unmatched.join(", "),
+ },
+ )
+ : "";
+ setImportSummary([imported, skipped].filter(Boolean).join(". "));
+ } catch (err) {
+ console.error("[FormFill] Form data import failed:", err);
+ setSaveError(
+ err instanceof Error
+ ? err.message
+ : t("formFill.importError", "Failed to import form data"),
+ );
+ }
+ },
+ [formState.fields, setValue, t],
+ );
+
const handleExtractXlsx = useCallback(async () => {
if (!currentFile) return;
setExtracting(true);
@@ -541,36 +634,71 @@ const FormFill = (_props: BaseToolProps) => {
+
+
+ }
+ onClick={() => importInputRef.current?.click()}
+ size="sm"
+ >
+ {t("formFill.importData", "Import form data")}
+
+
+
+ {
+ const file = e.currentTarget.files?.[0];
+ // Clear first so re-picking the same file re-fires.
+ e.currentTarget.value = "";
+ if (file) void handleImportFormData(file);
+ }}
+ />
+
+
+ {/* Four formats don't fit this panel with an icon each, so
+ the row is labelled once instead. */}
+
+
+ {t("formFill.exportAs", "Export data as")}
+