Import and export XFDF and FDF form data in Fill Form

This commit is contained in:
Anthony Stirling
2026-08-13 20:48:47 +01:00
parent a7eb6ebcc3
commit b961925de2
9 changed files with 1684 additions and 30 deletions
@@ -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&amp;b"');
expect(xml).toContain("&lt;script&gt; &quot;quoted&quot;");
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> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
};
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;