mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Import and export XFDF and FDF form data in Fill Form
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xfdf xmlns="http://ns.adobe.com/xfdf/" xml:space="preserve">
|
||||
<f href="sample.pdf"/>
|
||||
<fields>
|
||||
<field name="FullName"><value>Ada Lovelace</value></field>
|
||||
<field name="Address">
|
||||
<field name="Street"><value>1 High Street</value></field>
|
||||
</field>
|
||||
<field name="Languages">
|
||||
<value>English</value>
|
||||
<value>French</value>
|
||||
</field>
|
||||
<field name="NotInThisPdf"><value>ignored</value></field>
|
||||
</fields>
|
||||
</xfdf>
|
||||
`;
|
||||
|
||||
async function openFormFill(page: Page): Promise<void> {
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [importSummary, setImportSummary] = useState<string | null>(null);
|
||||
const importInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [lastSavedFlatten, setLastSavedFlatten] = useState<boolean | null>(
|
||||
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) => {
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className={styles.importRow}>
|
||||
<Tooltip
|
||||
label={t(
|
||||
"formFill.importTooltip",
|
||||
"Import values from an Acrobat XFDF or FDF export",
|
||||
)}
|
||||
withArrow
|
||||
position="bottom"
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
leftSection={<FileUploadIcon sx={{ fontSize: 14 }} />}
|
||||
onClick={() => importInputRef.current?.click()}
|
||||
size="sm"
|
||||
>
|
||||
{t("formFill.importData", "Import form data")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<input
|
||||
ref={importInputRef}
|
||||
type="file"
|
||||
accept=".xfdf,.fdf,application/vnd.adobe.xfdf,application/vnd.fdf"
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
const file = e.currentTarget.files?.[0];
|
||||
// Clear first so re-picking the same file re-fires.
|
||||
e.currentTarget.value = "";
|
||||
if (file) void handleImportFormData(file);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Four formats don't fit this panel with an icon each, so
|
||||
the row is labelled once instead. */}
|
||||
<Text size="xs" c="dimmed" className={styles.exportLabel}>
|
||||
<FileDownloadIcon sx={{ fontSize: 12 }} />
|
||||
{t("formFill.exportAs", "Export data as")}
|
||||
</Text>
|
||||
<div className={styles.secondaryActions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
leftSection={<FileDownloadIcon sx={{ fontSize: 14 }} />}
|
||||
loading={extracting}
|
||||
onClick={handleExtractJson}
|
||||
size="sm"
|
||||
>
|
||||
JSON
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
leftSection={<FileDownloadIcon sx={{ fontSize: 14 }} />}
|
||||
loading={extracting}
|
||||
onClick={handleExtractCsv}
|
||||
size="sm"
|
||||
>
|
||||
CSV
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
leftSection={<FileDownloadIcon sx={{ fontSize: 14 }} />}
|
||||
loading={extracting}
|
||||
onClick={handleExtractXlsx}
|
||||
size="sm"
|
||||
>
|
||||
XLSX
|
||||
</Button>
|
||||
{(
|
||||
[
|
||||
["XFDF", handleExportXfdf],
|
||||
["JSON", handleExtractJson],
|
||||
["CSV", handleExtractCsv],
|
||||
["XLSX", handleExtractXlsx],
|
||||
] as const
|
||||
).map(([label, onClick]) => (
|
||||
<Button
|
||||
key={label}
|
||||
variant="secondary"
|
||||
loading={extracting}
|
||||
onClick={() => void onClick()}
|
||||
size="sm"
|
||||
aria-label={t(
|
||||
"formFill.exportAsFormat",
|
||||
"Export as {{format}}",
|
||||
{
|
||||
format: label,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -580,6 +708,12 @@ const FormFill = (_props: BaseToolProps) => {
|
||||
<Text size="xs">{saveError}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{importSummary && (
|
||||
<Alert color="blue" variant="light" p="xs" radius="sm">
|
||||
<Text size="xs">{importSummary}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -60,8 +60,22 @@ class FormValuesStore {
|
||||
|
||||
private _values: Record<string, string> = {};
|
||||
|
||||
/**
|
||||
* 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<string, string> = {};
|
||||
private _snapshotVersion = -1;
|
||||
|
||||
get values(): Record<string, string> {
|
||||
return this._values;
|
||||
if (this._snapshotVersion !== this._version) {
|
||||
this._snapshot = { ...this._values };
|
||||
this._snapshotVersion = this._version;
|
||||
}
|
||||
return this._snapshot;
|
||||
}
|
||||
|
||||
private _version = 0;
|
||||
|
||||
@@ -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 = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xfdf xmlns="http://ns.adobe.com/xfdf/" xml:space="preserve">
|
||||
<f href="application-form.pdf"/>
|
||||
<ids original="7A9B" modified="7A9C"/>
|
||||
<fields>
|
||||
<field name="FullName">
|
||||
<value>Ada Lovelace</value>
|
||||
</field>
|
||||
<field name="Address">
|
||||
<field name="Street">
|
||||
<value>1 High Street</value>
|
||||
</field>
|
||||
<field name="City">
|
||||
<value>London</value>
|
||||
</field>
|
||||
</field>
|
||||
<field name="AgreeToTerms">
|
||||
<value>Yes</value>
|
||||
</field>
|
||||
<field name="Languages">
|
||||
<value>English</value>
|
||||
<value>French</value>
|
||||
</field>
|
||||
<field name="Notes">
|
||||
<value> leading and trailing spaces </value>
|
||||
</field>
|
||||
</fields>
|
||||
</xfdf>
|
||||
`;
|
||||
|
||||
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 `<f>` before `<fields>`.
|
||||
*/
|
||||
const APRYSE_XFDF = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xfdf xmlns="http://ns.adobe.com/xfdf/" xml:space="preserve"
|
||||
><f href="form1.pdf"
|
||||
/><fields
|
||||
><field name="c1-1"
|
||||
><value
|
||||
>Yes</value
|
||||
></field
|
||||
><field name="c1-3"
|
||||
><value
|
||||
>Off</value
|
||||
></field
|
||||
><field name="f1-1"
|
||||
><value
|
||||
>John Smith</value
|
||||
></field
|
||||
></fields
|
||||
><ids original="67B7546B7635ED89E01C7BB03AA168C7" modified="4284ECDB48B3FF4398965B6C2FF19638"
|
||||
/></xfdf
|
||||
>`;
|
||||
|
||||
/** 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
|
||||
<</FDF <</Fields [<</T <67656E6572617465417070656172616E636573> /V <FEFF> >>
|
||||
<</T <4C6173744E616D65> /V <FEFF0053006D006900740068> >>
|
||||
<</T <46697273744E616D65> /V <FEFF004A006F0068006E0020> >>
|
||||
] >>
|
||||
/Version /1#2E2 >>
|
||||
endobj
|
||||
trailer
|
||||
<</Root 1 0 R >>
|
||||
%%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<</T <46697273744E616D65> /V <41424344> >>endobj
|
||||
2 0 obj<</T <4C6173744E616D65> /V <58595A> >>endobj
|
||||
3 0 obj<</T <436F6D70616E79206E616D65> /V <53796E63667573696F6E> >>endobj
|
||||
8 0 obj<</F <4163726F466F726D31> /Fields [1 0 R 2 0 R 3 0 R ]>>endobj
|
||||
9 0 obj<</Version /1.4 /FDF 8 0 R>>endobj
|
||||
trailer
|
||||
<</Root 9 0 R>>
|
||||
`;
|
||||
|
||||
/**
|
||||
* 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("<xfdf><fields></xfdf>")).toThrow(/not valid XML/);
|
||||
});
|
||||
|
||||
test("rejects XML that is not XFDF", () => {
|
||||
expect(() => parseXfdf('<?xml version="1.0"?><foo/>')).toThrow(
|
||||
/expected an <xfdf> 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<</FDF 2 0 R>>endobj
|
||||
2 0 obj 1 0 R endobj
|
||||
trailer<</Root 1 0 R>>
|
||||
`;
|
||||
// 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('<field name="Address">');
|
||||
expect(xml).toContain('<field name="Street">');
|
||||
});
|
||||
|
||||
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(/<value>/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("escapes XML metacharacters in names and values", () => {
|
||||
const xml = buildXfdf({ "a&b": '<script> "quoted"' });
|
||||
expect(xml).toContain('name="a&b"');
|
||||
expect(xml).toContain("<script> "quoted"");
|
||||
expect(parseXfdf(xml).values["a&b"]).toBe('<script> "quoted"');
|
||||
});
|
||||
|
||||
test("omits the <f> element when no href is given", () => {
|
||||
expect(buildXfdf({ A: "1" })).not.toContain("<f ");
|
||||
});
|
||||
|
||||
test("an empty form still produces valid XFDF", () => {
|
||||
expect(parseXfdf(buildXfdf({})).values).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseFormDataFile", () => {
|
||||
test("detects XFDF from content, not the extension", async () => {
|
||||
const file = fileWithBytes(XFDF, "data.txt");
|
||||
expect((await parseFormDataFile(file)).format).toBe("xfdf");
|
||||
});
|
||||
|
||||
test("detects FDF from content, not the extension", async () => {
|
||||
const file = fileWithBytes(FDF, "data.txt");
|
||||
expect((await parseFormDataFile(file)).format).toBe("fdf");
|
||||
});
|
||||
|
||||
test("reads UTF-8 XFDF correctly", async () => {
|
||||
const xml = buildXfdf({ Name: "Ünïcode € dash" });
|
||||
const file = fileWithBytes(xml, "data.xfdf");
|
||||
expect((await parseFormDataFile(file)).values.Name).toBe(
|
||||
"Ünïcode € dash",
|
||||
);
|
||||
});
|
||||
|
||||
test("reads binary FDF with UTF-16 values", async () => {
|
||||
const file = fileWithBytes(
|
||||
fdfWithUtf16Value("Name", "Ünïcode € dash"),
|
||||
"data.fdf",
|
||||
);
|
||||
expect((await parseFormDataFile(file)).values.Name).toBe(
|
||||
"Ünïcode € dash",
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects anything else", async () => {
|
||||
const file = fileWithBytes('{"a":1}', "data.json");
|
||||
await expect(parseFormDataFile(file)).rejects.toThrow(
|
||||
/Unrecognised form data file/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("decodeLatin1", () => {
|
||||
test("maps every byte to one character", () => {
|
||||
const bytes = new Uint8Array([0x00, 0x41, 0xfe, 0xff]);
|
||||
const text = decodeLatin1(bytes);
|
||||
expect(text).toHaveLength(4);
|
||||
expect(text.charCodeAt(2)).toBe(0xfe);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reconcileImportedValues", () => {
|
||||
test("splits values into applied and unmatched", () => {
|
||||
const { applied, unmatched } = reconcileImportedValues(
|
||||
{ A: "1", B: "2", C: "3" },
|
||||
["A", "C"],
|
||||
);
|
||||
expect(applied).toEqual({ A: "1", C: "3" });
|
||||
expect(unmatched).toEqual(["B"]);
|
||||
});
|
||||
|
||||
test("an empty document matches nothing", () => {
|
||||
const { applied, unmatched } = reconcileImportedValues({ A: "1" }, []);
|
||||
expect(applied).toEqual({});
|
||||
expect(unmatched).toEqual(["A"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,439 @@
|
||||
/**
|
||||
* XFDF and FDF form-data exchange.
|
||||
*
|
||||
* These are Acrobat's two interchange formats for the *values* of a PDF form,
|
||||
* separate from the document itself. They are what a "submit form" button
|
||||
* posts, what "Export Data" writes, and what most enterprise form pipelines
|
||||
* hand around - so reading and writing them is the difference between
|
||||
* inheriting an Acrobat forms workflow and having to rebuild it.
|
||||
*
|
||||
* **XFDF** (ISO 19444-1) is XML:
|
||||
*
|
||||
* <xfdf xmlns="http://ns.adobe.com/xfdf/" xml:space="preserve">
|
||||
* <f href="form.pdf"/>
|
||||
* <fields>
|
||||
* <field name="Name"><value>Ada</value></field>
|
||||
* <field name="Address">
|
||||
* <field name="Street"><value>1 High St</value></field>
|
||||
* </field>
|
||||
* </fields>
|
||||
* </xfdf>
|
||||
*
|
||||
* Nested `<field>` elements build a dotted fully-qualified name
|
||||
* (`Address.Street`), matching how PDF names hierarchical fields. A field
|
||||
* with several `<value>` children is a multi-select list box.
|
||||
*
|
||||
* **FDF** is PDF object syntax:
|
||||
*
|
||||
* %FDF-1.2
|
||||
* 1 0 obj << /FDF << /Fields [ << /T (Name) /V (Ada) >> ] /F (form.pdf) >> >>
|
||||
* endobj
|
||||
*
|
||||
* with `/Kids` for hierarchy and `/V` holding the value - a string for text
|
||||
* fields, a name for checkboxes and radio groups.
|
||||
*/
|
||||
|
||||
import {
|
||||
psArray,
|
||||
psDict,
|
||||
psName,
|
||||
PsName,
|
||||
PsRef,
|
||||
readObjects,
|
||||
type PsDict,
|
||||
type PsValue,
|
||||
} from "@app/utils/postscriptObjects";
|
||||
|
||||
export type FormDataFormat = "xfdf" | "fdf";
|
||||
|
||||
export interface FormDataImport {
|
||||
format: FormDataFormat;
|
||||
/**
|
||||
* Field values keyed by fully-qualified name. Multi-select values are
|
||||
* comma-joined, matching how the form store holds them.
|
||||
*/
|
||||
values: Record<string, string>;
|
||||
/** `<f href>` / `/F` - the PDF the data was exported from, if declared. */
|
||||
pdfHref?: string;
|
||||
}
|
||||
|
||||
/** Join a hierarchical field path the way PDF fully-qualified names do. */
|
||||
const qualify = (path: string[]): string => path.join(".");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// XFDF
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const XFDF_NS = "http://ns.adobe.com/xfdf/";
|
||||
|
||||
/**
|
||||
* Parse XFDF text into field values.
|
||||
*
|
||||
* @throws if the text is not well-formed XML or has no `<xfdf>` root.
|
||||
*/
|
||||
export function parseXfdf(xmlText: string): FormDataImport {
|
||||
const doc = new DOMParser().parseFromString(xmlText, "application/xml");
|
||||
|
||||
// DOMParser signals XML syntax errors with a <parsererror> node.
|
||||
const parserError = doc.getElementsByTagName("parsererror")[0];
|
||||
if (parserError) {
|
||||
throw new Error(
|
||||
`File is not valid XML: ${parserError.textContent?.trim().split("\n")[0] ?? "unknown error"}`,
|
||||
);
|
||||
}
|
||||
|
||||
const root = doc.documentElement;
|
||||
if (!root || root.localName !== "xfdf") {
|
||||
throw new Error("Not an XFDF file: expected an <xfdf> root element.");
|
||||
}
|
||||
|
||||
const values: Record<string, string> = {};
|
||||
|
||||
const walk = (container: Element, path: string[]): void => {
|
||||
for (const child of Array.from(container.children)) {
|
||||
if (child.localName !== "field") continue;
|
||||
const name = child.getAttribute("name");
|
||||
if (!name) continue;
|
||||
const nextPath = [...path, name];
|
||||
|
||||
// A field is either a leaf holding <value>s or a branch holding
|
||||
// more <field>s. Adobe permits both, so collect values first.
|
||||
const valueNodes = Array.from(child.children).filter(
|
||||
(node) => node.localName === "value",
|
||||
);
|
||||
if (valueNodes.length > 0) {
|
||||
// xml:space="preserve" is the XFDF default; never trim.
|
||||
values[qualify(nextPath)] = valueNodes
|
||||
.map((node) => node.textContent ?? "")
|
||||
.join(",");
|
||||
}
|
||||
walk(child, nextPath);
|
||||
}
|
||||
};
|
||||
|
||||
const fieldsEl = Array.from(root.children).find(
|
||||
(child) => child.localName === "fields",
|
||||
);
|
||||
if (fieldsEl) walk(fieldsEl, []);
|
||||
|
||||
const fEl = Array.from(root.children).find(
|
||||
(child) => child.localName === "f",
|
||||
);
|
||||
const href = fEl?.getAttribute("href") ?? undefined;
|
||||
|
||||
return { format: "xfdf", values, pdfHref: href };
|
||||
}
|
||||
|
||||
/** True when the text looks like XFDF. */
|
||||
export function looksLikeXfdf(text: string): boolean {
|
||||
const head = text.slice(0, 2048);
|
||||
return head.includes(XFDF_NS) || /<xfdf[\s>]/.test(head);
|
||||
}
|
||||
|
||||
const XML_ESCAPES: Record<string, string> = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
};
|
||||
|
||||
const escapeXml = (value: string): string =>
|
||||
value.replace(/[&<>"]/g, (ch) => XML_ESCAPES[ch]);
|
||||
|
||||
/**
|
||||
* A tree node used to rebuild the `<field>` hierarchy from dotted names on
|
||||
* export, so `Address.Street` round-trips as nested elements rather than a
|
||||
* single field literally named "Address.Street".
|
||||
*/
|
||||
interface FieldNode {
|
||||
children: Map<string, FieldNode>;
|
||||
values?: string[];
|
||||
}
|
||||
|
||||
const emptyNode = (): FieldNode => ({ children: new Map() });
|
||||
|
||||
function buildFieldTree(
|
||||
values: Record<string, string>,
|
||||
multiSelectFields: ReadonlySet<string>,
|
||||
): FieldNode {
|
||||
const root = emptyNode();
|
||||
for (const [name, value] of Object.entries(values)) {
|
||||
let node = root;
|
||||
for (const segment of name.split(".")) {
|
||||
let child = node.children.get(segment);
|
||||
if (!child) {
|
||||
child = emptyNode();
|
||||
node.children.set(segment, child);
|
||||
}
|
||||
node = child;
|
||||
}
|
||||
// Only split on commas for fields the form actually declares as
|
||||
// multi-select - a comma in an address line is not a value separator.
|
||||
node.values = multiSelectFields.has(name)
|
||||
? value.split(",").filter((part) => part.length > 0)
|
||||
: [value];
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
function serializeFieldNode(
|
||||
name: string,
|
||||
node: FieldNode,
|
||||
indent: string,
|
||||
): string {
|
||||
const lines: string[] = [`${indent}<field name="${escapeXml(name)}">`];
|
||||
for (const value of node.values ?? []) {
|
||||
lines.push(`${indent} <value>${escapeXml(value)}</value>`);
|
||||
}
|
||||
for (const [childName, child] of node.children) {
|
||||
lines.push(serializeFieldNode(childName, child, `${indent} `));
|
||||
}
|
||||
lines.push(`${indent}</field>`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export interface BuildXfdfOptions {
|
||||
/** Written as `<f href>` so Acrobat can reopen the source document. */
|
||||
pdfHref?: string;
|
||||
/**
|
||||
* Fields whose value is a comma-joined multi-selection, so they can be
|
||||
* written back out as separate `<value>` elements.
|
||||
*/
|
||||
multiSelectFields?: Iterable<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize form values as XFDF.
|
||||
*
|
||||
* The output is accepted by Acrobat's Import Data and by any XFDF-aware form
|
||||
* server, which is the point: a form filled in Stirling can be handed back to
|
||||
* an Acrobat-based process.
|
||||
*/
|
||||
export function buildXfdf(
|
||||
values: Record<string, string>,
|
||||
options: BuildXfdfOptions = {},
|
||||
): string {
|
||||
const tree = buildFieldTree(values, new Set(options.multiSelectFields ?? []));
|
||||
const fields = Array.from(tree.children).map(([name, node]) =>
|
||||
serializeFieldNode(name, node, " "),
|
||||
);
|
||||
|
||||
return [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<xfdf xmlns="http://ns.adobe.com/xfdf/" xml:space="preserve">',
|
||||
...(options.pdfHref ? [` <f href="${escapeXml(options.pdfHref)}"/>`] : []),
|
||||
" <fields>",
|
||||
...fields,
|
||||
" </fields>",
|
||||
"</xfdf>",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FDF
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Decode a PDF text string. Strings prefixed with the UTF-16BE byte-order
|
||||
* mark carry non-Latin text; everything else is PDFDocEncoding, which is
|
||||
* close enough to latin1 for field values.
|
||||
*/
|
||||
function decodePdfString(raw: string): string {
|
||||
if (
|
||||
raw.length >= 2 &&
|
||||
raw.charCodeAt(0) === 0xfe &&
|
||||
raw.charCodeAt(1) === 0xff
|
||||
) {
|
||||
let out = "";
|
||||
for (let i = 2; i + 1 < raw.length; i += 2) {
|
||||
out += String.fromCharCode(
|
||||
(raw.charCodeAt(i) << 8) | raw.charCodeAt(i + 1),
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow indirect references to their object bodies. Writers such as
|
||||
* Syncfusion emit every field as its own `N 0 obj`, so `/Fields` is an array
|
||||
* of references rather than inline dictionaries.
|
||||
*/
|
||||
type Resolver = (value: PsValue) => PsValue;
|
||||
|
||||
function makeResolver(indirect: Map<number, PsValue>): Resolver {
|
||||
return function resolve(value: PsValue): PsValue {
|
||||
const seen = new Set<number>();
|
||||
let current = value;
|
||||
while (current instanceof PsRef) {
|
||||
// A reference cycle would otherwise spin forever.
|
||||
if (seen.has(current.objectNumber)) return null;
|
||||
seen.add(current.objectNumber);
|
||||
current = indirect.get(current.objectNumber) ?? null;
|
||||
}
|
||||
return current;
|
||||
};
|
||||
}
|
||||
|
||||
/** Render an FDF `/V` entry as the string the form store holds. */
|
||||
function fdfValueToString(
|
||||
value: PsValue,
|
||||
resolve: Resolver,
|
||||
): string | undefined {
|
||||
const resolved = resolve(value);
|
||||
if (typeof resolved === "string") return decodePdfString(resolved);
|
||||
// Checkboxes and radio groups store their export value as a name.
|
||||
if (resolved instanceof PsName) return resolved.name;
|
||||
if (typeof resolved === "number") return String(resolved);
|
||||
if (Array.isArray(resolved)) {
|
||||
// Multi-select list boxes hold an array of selected export values.
|
||||
const parts = resolved
|
||||
.map((item) => fdfValueToString(item, resolve))
|
||||
.filter((item): item is string => item !== undefined);
|
||||
return parts.length > 0 ? parts.join(",") : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function walkFdfFields(
|
||||
fields: PsValue[],
|
||||
path: string[],
|
||||
values: Record<string, string>,
|
||||
resolve: Resolver,
|
||||
): void {
|
||||
for (const entry of fields) {
|
||||
const field = psDict(resolve(entry));
|
||||
if (!field) continue;
|
||||
const title = resolve(field.T);
|
||||
const name =
|
||||
typeof title === "string" ? decodePdfString(title) : psName(title);
|
||||
if (!name) continue;
|
||||
const nextPath = [...path, name];
|
||||
|
||||
if (field.V !== undefined) {
|
||||
const value = fdfValueToString(field.V, resolve);
|
||||
if (value !== undefined) values[qualify(nextPath)] = value;
|
||||
}
|
||||
const kids = psArray(resolve(field.Kids));
|
||||
if (kids) walkFdfFields(kids, nextPath, values, resolve);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode bytes as latin1 so every byte maps to one char, preserving the
|
||||
* UTF-16 sequences inside PDF strings for {@link decodePdfString}.
|
||||
*/
|
||||
export function decodeLatin1(bytes: ArrayBuffer | Uint8Array): string {
|
||||
const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
||||
let out = "";
|
||||
// Chunked to stay clear of the argument-count limit on large files.
|
||||
const CHUNK = 0x8000;
|
||||
for (let i = 0; i < view.length; i += CHUNK) {
|
||||
out += String.fromCharCode(...view.subarray(i, i + CHUNK));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse FDF into field values.
|
||||
*
|
||||
* @param source Raw FDF bytes, or latin1-decoded text.
|
||||
* @throws if no `/FDF` dictionary with a `/Fields` array is present.
|
||||
*/
|
||||
export function parseFdf(
|
||||
source: ArrayBuffer | Uint8Array | string,
|
||||
): FormDataImport {
|
||||
const text = typeof source === "string" ? source : decodeLatin1(source);
|
||||
const { topLevelDicts, indirect } = readObjects(text);
|
||||
const resolve = makeResolver(indirect);
|
||||
|
||||
let fdf: PsDict | undefined;
|
||||
for (const dict of topLevelDicts) {
|
||||
const candidate = psDict(resolve(dict.FDF));
|
||||
if (candidate) {
|
||||
fdf = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!fdf) {
|
||||
throw new Error(
|
||||
"Not an FDF file: no /FDF dictionary found. Acrobat writes '<< /FDF << /Fields [...] >> >>'.",
|
||||
);
|
||||
}
|
||||
|
||||
const fields = psArray(resolve(fdf.Fields));
|
||||
if (!fields) {
|
||||
throw new Error(
|
||||
"FDF file has no /Fields array - there is no form data to import.",
|
||||
);
|
||||
}
|
||||
|
||||
const values: Record<string, string> = {};
|
||||
walkFdfFields(fields, [], values, resolve);
|
||||
|
||||
const href = resolve(fdf.F);
|
||||
return {
|
||||
format: "fdf",
|
||||
values,
|
||||
pdfHref: typeof href === "string" ? decodePdfString(href) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** True when the text looks like an FDF file. */
|
||||
export function looksLikeFdf(text: string): boolean {
|
||||
return (
|
||||
/%FDF-/.test(text.slice(0, 1024)) || /\/FDF\s*<</.test(text.slice(0, 8192))
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Format-sniffing entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Read an exported form-data file, detecting XFDF vs FDF from its contents
|
||||
* rather than its extension.
|
||||
*
|
||||
* @throws with a user-readable message when the file is neither.
|
||||
*/
|
||||
export async function parseFormDataFile(
|
||||
file: File | Blob,
|
||||
): Promise<FormDataImport> {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const latin1 = decodeLatin1(buffer);
|
||||
|
||||
if (looksLikeFdf(latin1)) return parseFdf(latin1);
|
||||
if (looksLikeXfdf(latin1)) {
|
||||
// XFDF is XML and may be UTF-8; re-decode before handing it to DOMParser.
|
||||
return parseXfdf(new TextDecoder("utf-8").decode(buffer));
|
||||
}
|
||||
throw new Error(
|
||||
"Unrecognised form data file. Expected XFDF (<xfdf> XML) or FDF (%FDF-).",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restrict imported values to fields the open document actually has, so a
|
||||
* mismatched export cannot inject junk keys into the form store.
|
||||
*
|
||||
* Returns the values to apply plus the names that were dropped, which the UI
|
||||
* reports rather than silently ignoring.
|
||||
*/
|
||||
export function reconcileImportedValues(
|
||||
imported: Record<string, string>,
|
||||
knownFieldNames: Iterable<string>,
|
||||
): { applied: Record<string, string>; unmatched: string[] } {
|
||||
const known = new Set(knownFieldNames);
|
||||
const applied: Record<string, string> = {};
|
||||
const unmatched: string[] = [];
|
||||
for (const [name, value] of Object.entries(imported)) {
|
||||
if (known.has(name)) {
|
||||
applied[name] = value;
|
||||
} else {
|
||||
unmatched.push(name);
|
||||
}
|
||||
}
|
||||
return { applied, unmatched };
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
/**
|
||||
* Minimal reader for PostScript / PDF object syntax.
|
||||
*
|
||||
* Shared by the two Adobe migration importers, which both consume files
|
||||
* written in this syntax:
|
||||
* - Distiller `.joboptions` - a `<< … >> setdistillerparams` dictionary
|
||||
* - Acrobat `.fdf` form data - `1 0 obj << /FDF << … >> >> endobj`
|
||||
*
|
||||
* This is deliberately not a PDF parser: there is no xref, no stream
|
||||
* decoding and no indirect-reference resolution. It reads the object
|
||||
* *syntax* - dictionaries, arrays, names, strings, numbers - which is all
|
||||
* either format needs.
|
||||
*
|
||||
* Strings are returned as raw bytes widened to one char each (latin1), so
|
||||
* callers can detect a UTF-16 BOM and re-decode. Feed it latin1-decoded text
|
||||
* for byte fidelity.
|
||||
*/
|
||||
|
||||
/** A PostScript/PDF name (`/Bicubic`), kept distinct from a `(string)`. */
|
||||
export class PsName {
|
||||
constructor(public readonly name: string) {}
|
||||
toString(): string {
|
||||
return this.name;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A PDF indirect reference (`8 0 R`). Only FDF uses these; resolve them with
|
||||
* the `indirect` map from {@link readObjects}.
|
||||
*/
|
||||
export class PsRef {
|
||||
constructor(
|
||||
public readonly objectNumber: number,
|
||||
public readonly generation: number,
|
||||
) {}
|
||||
}
|
||||
|
||||
export type PsValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| PsName
|
||||
| PsRef
|
||||
| PsValue[]
|
||||
| PsDict;
|
||||
|
||||
export interface PsDict {
|
||||
[key: string]: PsValue;
|
||||
}
|
||||
|
||||
type Token =
|
||||
| { kind: "dictOpen" }
|
||||
| { kind: "dictClose" }
|
||||
| { kind: "arrayOpen" }
|
||||
| { kind: "arrayClose" }
|
||||
| { kind: "name"; value: string }
|
||||
| { kind: "string"; value: string }
|
||||
| { kind: "number"; value: number }
|
||||
| { kind: "boolean"; value: boolean }
|
||||
| { kind: "null" }
|
||||
| { kind: "operator"; value: string };
|
||||
|
||||
const isWhitespace = (ch: string): boolean =>
|
||||
ch === " " ||
|
||||
ch === "\t" ||
|
||||
ch === "\r" ||
|
||||
ch === "\n" ||
|
||||
ch === "\f" ||
|
||||
ch === "\0";
|
||||
|
||||
const isDelimiter = (ch: string): boolean =>
|
||||
ch === "(" ||
|
||||
ch === ")" ||
|
||||
ch === "<" ||
|
||||
ch === ">" ||
|
||||
ch === "[" ||
|
||||
ch === "]" ||
|
||||
ch === "{" ||
|
||||
ch === "}" ||
|
||||
ch === "/" ||
|
||||
ch === "%";
|
||||
|
||||
/**
|
||||
* Read a `(...)` literal string, honouring nested parentheses and the
|
||||
* backslash escapes both PostScript and PDF allow (including `\ddd` octal).
|
||||
*/
|
||||
function readLiteralString(src: string, start: number): [string, number] {
|
||||
let depth = 1;
|
||||
let i = start;
|
||||
let out = "";
|
||||
while (i < src.length && depth > 0) {
|
||||
const ch = src[i];
|
||||
if (ch === "\\") {
|
||||
const next = src[i + 1];
|
||||
i += 2;
|
||||
switch (next) {
|
||||
case "n":
|
||||
out += "\n";
|
||||
break;
|
||||
case "r":
|
||||
out += "\r";
|
||||
break;
|
||||
case "t":
|
||||
out += "\t";
|
||||
break;
|
||||
case "b":
|
||||
out += "\b";
|
||||
break;
|
||||
case "f":
|
||||
out += "\f";
|
||||
break;
|
||||
case "(":
|
||||
case ")":
|
||||
case "\\":
|
||||
out += next;
|
||||
break;
|
||||
case "\n":
|
||||
break; // line continuation
|
||||
case "\r":
|
||||
if (src[i] === "\n") i++;
|
||||
break;
|
||||
default:
|
||||
if (next >= "0" && next <= "7") {
|
||||
let octal = next;
|
||||
while (octal.length < 3 && src[i] >= "0" && src[i] <= "7") {
|
||||
octal += src[i];
|
||||
i++;
|
||||
}
|
||||
out += String.fromCharCode(parseInt(octal, 8));
|
||||
} else {
|
||||
out += next ?? "";
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch === "(") depth++;
|
||||
if (ch === ")") {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
out += ch;
|
||||
i++;
|
||||
}
|
||||
return [out, i];
|
||||
}
|
||||
|
||||
/** Read a `<...>` hex string into its decoded bytes. */
|
||||
function readHexString(src: string, start: number): [string, number] {
|
||||
let i = start;
|
||||
let hex = "";
|
||||
while (i < src.length && src[i] !== ">") {
|
||||
if (!isWhitespace(src[i])) hex += src[i];
|
||||
i++;
|
||||
}
|
||||
i++; // consume '>'
|
||||
// An odd digit count is padded with a trailing zero, per the PDF spec.
|
||||
if (hex.length % 2 === 1) hex += "0";
|
||||
let out = "";
|
||||
for (let j = 0; j + 1 < hex.length; j += 2) {
|
||||
const code = parseInt(hex.slice(j, j + 2), 16);
|
||||
if (!Number.isNaN(code)) out += String.fromCharCode(code);
|
||||
}
|
||||
return [out, i];
|
||||
}
|
||||
|
||||
/**
|
||||
* `#XX` hex escapes are legal inside PDF names (`/A#20B` is "A B"). Harmless
|
||||
* to apply to PostScript names, which never contain `#`.
|
||||
*/
|
||||
const decodeNameEscapes = (raw: string): string =>
|
||||
raw.includes("#")
|
||||
? raw.replace(/#([0-9a-fA-F]{2})/g, (_, hex) =>
|
||||
String.fromCharCode(parseInt(hex, 16)),
|
||||
)
|
||||
: raw;
|
||||
|
||||
function tokenize(src: string): Token[] {
|
||||
const tokens: Token[] = [];
|
||||
let i = 0;
|
||||
while (i < src.length) {
|
||||
const ch = src[i];
|
||||
|
||||
if (isWhitespace(ch)) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Comments run to end of line. `%!PS`, `%FDF-1.2` and `%%EOF` are all
|
||||
// just comments.
|
||||
if (ch === "%") {
|
||||
while (i < src.length && src[i] !== "\n" && src[i] !== "\r") i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "<" && src[i + 1] === "<") {
|
||||
tokens.push({ kind: "dictOpen" });
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (ch === ">" && src[i + 1] === ">") {
|
||||
tokens.push({ kind: "dictClose" });
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (ch === "<") {
|
||||
const [value, next] = readHexString(src, i + 1);
|
||||
tokens.push({ kind: "string", value });
|
||||
i = next;
|
||||
continue;
|
||||
}
|
||||
if (ch === "[") {
|
||||
tokens.push({ kind: "arrayOpen" });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === "]") {
|
||||
tokens.push({ kind: "arrayClose" });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === "(") {
|
||||
const [value, next] = readLiteralString(src, i + 1);
|
||||
tokens.push({ kind: "string", value });
|
||||
i = next;
|
||||
continue;
|
||||
}
|
||||
// Procedure braces carry no data; drop the delimiters and let the
|
||||
// contents tokenize as ordinary values.
|
||||
if (ch === "{" || ch === "}") {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === "/") {
|
||||
i++;
|
||||
let name = "";
|
||||
while (i < src.length && !isWhitespace(src[i]) && !isDelimiter(src[i])) {
|
||||
name += src[i];
|
||||
i++;
|
||||
}
|
||||
tokens.push({ kind: "name", value: decodeNameEscapes(name) });
|
||||
continue;
|
||||
}
|
||||
|
||||
let word = "";
|
||||
while (i < src.length && !isWhitespace(src[i]) && !isDelimiter(src[i])) {
|
||||
word += src[i];
|
||||
i++;
|
||||
}
|
||||
if (word.length === 0) {
|
||||
i++; // unrecognised delimiter, e.g. a stray ')'
|
||||
continue;
|
||||
}
|
||||
if (word === "true" || word === "false") {
|
||||
tokens.push({ kind: "boolean", value: word === "true" });
|
||||
} else if (word === "null") {
|
||||
tokens.push({ kind: "null" });
|
||||
} else if (/^[+-]?(\d+\.?\d*|\.\d+)(e[+-]?\d+)?$/i.test(word)) {
|
||||
tokens.push({ kind: "number", value: Number(word) });
|
||||
} else {
|
||||
tokens.push({ kind: "operator", value: word });
|
||||
}
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
interface ParseCursor {
|
||||
index: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the `<num> <gen> R` indirect-reference triple. Without this, `8 0 R`
|
||||
* would read as the number 8 and the reference would be lost.
|
||||
*/
|
||||
function readReferenceAt(
|
||||
tokens: Token[],
|
||||
index: number,
|
||||
): { ref: PsRef; next: number } | undefined {
|
||||
const first = tokens[index];
|
||||
const second = tokens[index + 1];
|
||||
const third = tokens[index + 2];
|
||||
if (
|
||||
first?.kind === "number" &&
|
||||
second?.kind === "number" &&
|
||||
third?.kind === "operator" &&
|
||||
third.value === "R"
|
||||
) {
|
||||
return { ref: new PsRef(first.value, second.value), next: index + 3 };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseValue(tokens: Token[], cursor: ParseCursor): PsValue {
|
||||
const token = tokens[cursor.index];
|
||||
if (!token) return null;
|
||||
// A close token means the key had no value; leave it for the caller so the
|
||||
// enclosing dictionary/array still terminates correctly.
|
||||
if (token.kind === "dictClose" || token.kind === "arrayClose") return null;
|
||||
|
||||
const reference = readReferenceAt(tokens, cursor.index);
|
||||
if (reference) {
|
||||
cursor.index = reference.next;
|
||||
return reference.ref;
|
||||
}
|
||||
|
||||
cursor.index++;
|
||||
switch (token.kind) {
|
||||
case "dictOpen":
|
||||
return parseDict(tokens, cursor);
|
||||
case "arrayOpen": {
|
||||
const items: PsValue[] = [];
|
||||
while (cursor.index < tokens.length) {
|
||||
const next = tokens[cursor.index];
|
||||
if (!next) break;
|
||||
if (next.kind === "arrayClose") {
|
||||
cursor.index++;
|
||||
break;
|
||||
}
|
||||
if (next.kind === "dictClose") break; // malformed; let the dict close
|
||||
items.push(parseValue(tokens, cursor));
|
||||
}
|
||||
return items;
|
||||
}
|
||||
case "name":
|
||||
return new PsName(token.value);
|
||||
case "string":
|
||||
return token.value;
|
||||
case "number":
|
||||
return token.value;
|
||||
case "boolean":
|
||||
return token.value;
|
||||
case "null":
|
||||
return null;
|
||||
default:
|
||||
// A bare operator in value position (`R`, `obj`, …) is not data.
|
||||
return new PsName(token.value);
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse tokens positioned just after a `<<` up to the matching `>>`. */
|
||||
function parseDict(tokens: Token[], cursor: ParseCursor): PsDict {
|
||||
const dict: PsDict = {};
|
||||
while (cursor.index < tokens.length) {
|
||||
const token = tokens[cursor.index];
|
||||
if (!token) break;
|
||||
if (token.kind === "dictClose") {
|
||||
cursor.index++;
|
||||
break;
|
||||
}
|
||||
if (token.kind !== "name") {
|
||||
// Skip anything that isn't a key - keeps a malformed file from
|
||||
// derailing the rest of the dictionary.
|
||||
cursor.index++;
|
||||
continue;
|
||||
}
|
||||
cursor.index++;
|
||||
dict[token.value] = parseValue(tokens, cursor);
|
||||
}
|
||||
return dict;
|
||||
}
|
||||
|
||||
export interface PostScriptObjects {
|
||||
/**
|
||||
* Every dictionary not nested inside another dictionary or array, in file
|
||||
* order - so an FDF's `1 0 obj << … >>` body and its `trailer << … >>` each
|
||||
* yield one entry, and a `.joboptions` file usually yields exactly one.
|
||||
*/
|
||||
topLevelDicts: PsDict[];
|
||||
/**
|
||||
* Bodies of `<num> <gen> obj … endobj` definitions, keyed by object number,
|
||||
* so {@link PsRef} values can be resolved.
|
||||
*/
|
||||
indirect: Map<number, PsValue>;
|
||||
}
|
||||
|
||||
/** Read every object in the source in a single tokenizing pass. */
|
||||
export function readObjects(src: string): PostScriptObjects {
|
||||
const tokens = tokenize(src);
|
||||
const topLevelDicts: PsDict[] = [];
|
||||
const indirect = new Map<number, PsValue>();
|
||||
const cursor: ParseCursor = { index: 0 };
|
||||
|
||||
while (cursor.index < tokens.length) {
|
||||
// `<num> <gen> obj` opens an indirect definition. Capture the number so
|
||||
// the body can be looked up, then let the body parse normally.
|
||||
const first = tokens[cursor.index];
|
||||
const second = tokens[cursor.index + 1];
|
||||
const third = tokens[cursor.index + 2];
|
||||
if (
|
||||
first?.kind === "number" &&
|
||||
second?.kind === "number" &&
|
||||
third?.kind === "operator" &&
|
||||
third.value === "obj"
|
||||
) {
|
||||
cursor.index += 3;
|
||||
const body = parseValue(tokens, cursor);
|
||||
indirect.set(first.value, body);
|
||||
const bodyDict = psDict(body);
|
||||
if (bodyDict) topLevelDicts.push(bodyDict);
|
||||
continue;
|
||||
}
|
||||
|
||||
cursor.index++;
|
||||
if (first.kind === "dictOpen") {
|
||||
topLevelDicts.push(parseDict(tokens, cursor));
|
||||
}
|
||||
}
|
||||
|
||||
return { topLevelDicts, indirect };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience wrapper over {@link readObjects} for callers that only need the
|
||||
* dictionaries and have no indirect references to resolve.
|
||||
*/
|
||||
export function collectTopLevelDicts(src: string): PsDict[] {
|
||||
return readObjects(src).topLevelDicts;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Typed accessors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const psBoolean = (value: PsValue): boolean | undefined =>
|
||||
typeof value === "boolean" ? value : undefined;
|
||||
|
||||
export const psNumber = (value: PsValue): number | undefined =>
|
||||
typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
|
||||
/** Read a `/Name`, tolerating writers that quote it as a string. */
|
||||
export const psName = (value: PsValue): string | undefined => {
|
||||
if (value instanceof PsName) return value.name;
|
||||
if (typeof value === "string") return value;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const psDict = (value: PsValue): PsDict | undefined =>
|
||||
value !== null &&
|
||||
typeof value === "object" &&
|
||||
!Array.isArray(value) &&
|
||||
!(value instanceof PsName) &&
|
||||
!(value instanceof PsRef)
|
||||
? (value as PsDict)
|
||||
: undefined;
|
||||
|
||||
export const psArray = (value: PsValue): PsValue[] | undefined =>
|
||||
Array.isArray(value) ? value : undefined;
|
||||
Reference in New Issue
Block a user