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) => { +
+ + + + + { + 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")} +
- - - - - + {( + [ + ["XFDF", handleExportXfdf], + ["JSON", handleExtractJson], + ["CSV", handleExtractCsv], + ["XLSX", handleExtractXlsx], + ] as const + ).map(([label, onClick]) => ( + + ))}
@@ -580,6 +708,12 @@ const FormFill = (_props: BaseToolProps) => { {saveError} )} + + {importSummary && ( + + {importSummary} + + )} )} diff --git a/frontend/editor/src/core/tools/formFill/FormFillContext.tsx b/frontend/editor/src/core/tools/formFill/FormFillContext.tsx index 4377984111..a62c7488a0 100644 --- a/frontend/editor/src/core/tools/formFill/FormFillContext.tsx +++ b/frontend/editor/src/core/tools/formFill/FormFillContext.tsx @@ -60,8 +60,22 @@ class FormValuesStore { private _values: Record = {}; + /** + * Snapshot for useSyncExternalStore. `_values` is mutated in place to keep + * per-keystroke writes cheap, so returning it directly would hand React a + * reference that never changes — subscribers would never re-render and any + * memo keyed on it would stay stale. Copy lazily instead: the cost is paid + * once per change, and only by the components that read all values. + */ + private _snapshot: Record = {}; + private _snapshotVersion = -1; + get values(): Record { - return this._values; + if (this._snapshotVersion !== this._version) { + this._snapshot = { ...this._values }; + this._snapshotVersion = this._version; + } + return this._snapshot; } private _version = 0; diff --git a/frontend/editor/src/core/utils/formDataExchange.test.ts b/frontend/editor/src/core/utils/formDataExchange.test.ts new file mode 100644 index 0000000000..adf90db1e5 --- /dev/null +++ b/frontend/editor/src/core/utils/formDataExchange.test.ts @@ -0,0 +1,406 @@ +/** + * Unit tests for XFDF / FDF form-data exchange. + */ + +import { describe, test, expect } from "vitest"; +import { + buildXfdf, + decodeLatin1, + looksLikeFdf, + looksLikeXfdf, + parseFdf, + parseFormDataFile, + parseXfdf, + reconcileImportedValues, +} from "@app/utils/formDataExchange"; + +const XFDF = ` + + + + + + Ada Lovelace + + + + 1 High Street + + + London + + + + Yes + + + English + French + + + leading and trailing spaces + + + +`; + +const FDF = `%FDF-1.2 +1 0 obj +<< +/FDF +<< +/Fields [ +<< /T (FullName) /V (Ada Lovelace) >> +<< /T (Address) /Kids [ << /T (Street) /V (1 High Street) >> << /T (City) /V (London) >> ] >> +<< /T (AgreeToTerms) /V /Yes >> +<< /T (Languages) /V [ (English) (French) ] >> +<< /T (Escaped) /V (a \\(nested\\) value) >> +] +/F (application-form.pdf) +>> +>> +endobj +trailer +<< /Root 1 0 R >> +%%EOF +`; + +/** + * Real Apryse output: no whitespace between elements, line breaks *inside* + * the tags, and `` before ``. + */ +const APRYSE_XFDF = ` +YesOffJohn Smith`; + +/** Real output with /V before /T and a blank line before the header. */ +const SF52_FDF = ` +%FDF-1.2 +1 0 obj +<< +/FDF << /Fields [ + << /V (Sample EdLevel)/T (EdLevel) >> +<< /V (Sample DegAttan)/T (DegAttan) >> + +] >> +>> +endobj +trailer +<< +/Root 1 0 R +>> +%%EOF +`; + +/** Real DevExpress output: hex strings with UTF-16BE values throughout. */ +const HEX_FDF = `%FDF-1.2 +1 0 obj +< /V >> + < /V >> + < /V >> +] >> + /Version /1#2E2 >> +endobj +trailer +<> +%%EOF +`; + +/** + * Real Syncfusion output: each field is its own indirect object and /Fields + * is an array of references, reached through a second reference (/FDF 8 0 R). + */ +const INDIRECT_FDF = `%FDF-1.2 +1 0 obj< /V <41424344> >>endobj +2 0 obj< /V <58595A> >>endobj +3 0 obj< /V <53796E63667573696F6E> >>endobj +8 0 obj< /Fields [1 0 R 2 0 R 3 0 R ]>>endobj +9 0 obj<>endobj +trailer +<> +`; + +/** + * jsdom has no real `Blob.arrayBuffer()`, and setupTests stubs it with eight + * dummy bytes — so a plain `new File([...])` cannot be read back. Attach a + * working implementation for the files under test. + */ +function fileWithBytes(content: string | Uint8Array, name: string): File { + const bytes = + typeof content === "string" ? new TextEncoder().encode(content) : content; + const buffer = new ArrayBuffer(bytes.byteLength); + new Uint8Array(buffer).set(bytes); + const file = new File([buffer], name); + Object.defineProperty(file, "arrayBuffer", { value: async () => buffer }); + return file; +} + +/** Build an FDF byte array whose value is a UTF-16BE string, as Acrobat writes. */ +function fdfWithUtf16Value(fieldName: string, value: string): Uint8Array { + const bytes: number[] = []; + const push = (text: string) => { + for (const ch of text) bytes.push(ch.charCodeAt(0)); + }; + push(`%FDF-1.2\n1 0 obj\n<< /FDF << /Fields [ << /T (${fieldName}) /V (`); + bytes.push(0xfe, 0xff); // UTF-16BE byte-order mark + for (let i = 0; i < value.length; i++) { + const code = value.charCodeAt(i); + bytes.push((code >> 8) & 0xff, code & 0xff); + } + push(") >> ] >> >>\nendobj\n%%EOF\n"); + return new Uint8Array(bytes); +} + +describe("formDataExchange", () => { + describe("parseXfdf", () => { + test("reads flat and hierarchical field values", () => { + const { values, pdfHref, format } = parseXfdf(XFDF); + expect(format).toBe("xfdf"); + expect(pdfHref).toBe("application-form.pdf"); + expect(values.FullName).toBe("Ada Lovelace"); + expect(values["Address.Street"]).toBe("1 High Street"); + expect(values["Address.City"]).toBe("London"); + expect(values.AgreeToTerms).toBe("Yes"); + }); + + test("joins multiple values for multi-select fields", () => { + expect(parseXfdf(XFDF).values.Languages).toBe("English,French"); + }); + + test("preserves whitespace, since xml:space is preserve", () => { + expect(parseXfdf(XFDF).values.Notes).toBe( + " leading and trailing spaces ", + ); + }); + + test("a branch field with no value of its own is not emitted", () => { + expect(parseXfdf(XFDF).values).not.toHaveProperty("Address"); + }); + + test("rejects malformed XML", () => { + expect(() => parseXfdf("")).toThrow(/not valid XML/); + }); + + test("rejects XML that is not XFDF", () => { + expect(() => parseXfdf('')).toThrow( + /expected an root/, + ); + }); + }); + + describe("parseFdf", () => { + test("reads values, Kids hierarchy and name values", () => { + const { values, pdfHref, format } = parseFdf(FDF); + expect(format).toBe("fdf"); + expect(pdfHref).toBe("application-form.pdf"); + expect(values.FullName).toBe("Ada Lovelace"); + expect(values["Address.Street"]).toBe("1 High Street"); + expect(values["Address.City"]).toBe("London"); + // A checkbox export value is stored as a PDF name, not a string. + expect(values.AgreeToTerms).toBe("Yes"); + }); + + test("joins array values for multi-select fields", () => { + expect(parseFdf(FDF).values.Languages).toBe("English,French"); + }); + + test("decodes escaped parentheses", () => { + expect(parseFdf(FDF).values.Escaped).toBe("a (nested) value"); + }); + + test("decodes UTF-16BE strings", () => { + const bytes = fdfWithUtf16Value("Name", "Ada Ada € ünïcode"); + expect(parseFdf(bytes).values.Name).toBe("Ada Ada € ünïcode"); + }); + + test("rejects a file with no /FDF dictionary", () => { + expect(() => parseFdf("%PDF-1.7\n<< /Type /Catalog >>\n")).toThrow( + /no \/FDF dictionary/, + ); + }); + + test("rejects an FDF with no /Fields array", () => { + expect(() => parseFdf("%FDF-1.2\n<< /FDF << /F (x.pdf) >> >>\n")).toThrow( + /no \/Fields array/, + ); + }); + }); + + describe("real-world exports", () => { + test("Apryse XFDF with line breaks inside tags", () => { + const { values, pdfHref } = parseXfdf(APRYSE_XFDF); + expect(pdfHref).toBe("form1.pdf"); + expect(values).toEqual({ + "c1-1": "Yes", + "c1-3": "Off", + "f1-1": "John Smith", + }); + }); + + test("FDF with /V before /T and a leading blank line", () => { + expect(parseFdf(SF52_FDF).values).toEqual({ + EdLevel: "Sample EdLevel", + DegAttan: "Sample DegAttan", + }); + }); + + test("FDF using hex strings and UTF-16BE values", () => { + expect(parseFdf(HEX_FDF).values).toEqual({ + generateAppearances: "", + LastName: "Smith", + FirstName: "John ", + }); + }); + + test("FDF whose fields are indirect objects behind a /FDF reference", () => { + const { values, pdfHref } = parseFdf(INDIRECT_FDF); + expect(pdfHref).toBe("AcroForm1"); + expect(values).toEqual({ + FirstName: "ABCD", + LastName: "XYZ", + "Company name": "Syncfusion", + }); + }); + + test("a reference cycle terminates with an error instead of hanging", () => { + const cyclic = `%FDF-1.2 +1 0 obj<>endobj +2 0 obj 1 0 R endobj +trailer<> +`; + // Which error it lands on doesn't matter; not hanging does. + expect(() => parseFdf(cyclic)).toThrow(/no form data|no \/FDF/); + }); + }); + + describe("format detection", () => { + test("recognises each format and rejects the other", () => { + expect(looksLikeXfdf(XFDF)).toBe(true); + expect(looksLikeFdf(XFDF)).toBe(false); + expect(looksLikeFdf(FDF)).toBe(true); + expect(looksLikeXfdf(FDF)).toBe(false); + }); + }); + + describe("buildXfdf", () => { + test("round-trips flat, hierarchical and multi-select values", () => { + const values = { + FullName: "Ada Lovelace", + "Address.Street": "1 High Street", + Languages: "English,French", + }; + const xml = buildXfdf(values, { + pdfHref: "application-form.pdf", + multiSelectFields: ["Languages"], + }); + + const reparsed = parseXfdf(xml); + expect(reparsed.values).toEqual(values); + expect(reparsed.pdfHref).toBe("application-form.pdf"); + // The hierarchy is rebuilt as nested elements, not a literal dotted name. + expect(xml).toContain(''); + expect(xml).toContain(''); + }); + + test("commas in a single-value field are not split into two values", () => { + const xml = buildXfdf({ Address: "1 High Street, London" }); + expect(parseXfdf(xml).values.Address).toBe("1 High Street, London"); + expect(xml.match(//g)).toHaveLength(1); + }); + + test("escapes XML metacharacters in names and values", () => { + const xml = buildXfdf({ "a&b": '