Import Acrobat Actions and Distiller job options into Automate

This commit is contained in:
Anthony Stirling
2026-08-13 20:46:16 +01:00
parent a7eb6ebcc3
commit f64ae7bcdc
12 changed files with 2926 additions and 168 deletions
@@ -2066,6 +2066,8 @@ import = "Import"
importPartialSuccess_one = "Imported with {{count}} unmapped operation: {{ops}}"
importPartialSuccess_other = "Imported with {{count}} unmapped operations: {{ops}}"
importSuccess = "Imported automation: {{name}}"
importWithWarnings_one = "Imported {{name}} - {{count}} step needs checking"
importWithWarnings_other = "Imported {{name}} - {{count}} steps need checking"
invalidStep = "Invalid step"
reviewTitle = "Automation Results"
tags = "workflow,sequence,automation,automate,batch,batch processing,pipeline,chain,multi-step,recurring,scheduled,automatic,process multiple,bulk operations"
@@ -2116,21 +2118,26 @@ title = "Unsaved Changes"
label = "Open menu for {{title}}"
[automate.importModal]
acrobatInstructionsTitle = "Operator notes from the Acrobat Action"
cancel = "Cancel"
confirm = "Import"
detectedAcrobat = "Acrobat Action"
detectedAutomation = "Automate JSON"
detectedFolderScan = "Folder Scanning JSON"
dropHint = "Drop JSON here or click to choose a file"
dropSubhint = "Both Automate and Folder Scanning configs are accepted"
dropzoneAriaLabel = "Drop an automation JSON file here"
intro = "Drop a JSON file or paste its contents below. The format (Automate or Folder Scanning) is detected automatically."
detectedJobOptions = "Distiller job options"
dropHint = "Drop a file here or click to choose one"
dropReplace = "Drop another to replace it"
dropSubhint = "Accepts .json, .sequ (Acrobat Action) and .joboptions"
dropzoneAriaLabel = "Drop an automation file here"
intro = "Drop a file or paste its contents below. Automate JSON, Folder Scanning JSON, Acrobat Actions (.sequ) and Distiller job options (.joboptions) are detected automatically."
opCount_one = "{{count}} operation"
opCount_other = "{{count}} operations"
parseError = "Could not parse: {{message}}"
pasteLabel = "Or paste JSON"
pastePlaceholder = "Paste your automation JSON here…"
pasteLabel = "Or paste file contents"
pastePlaceholder = "Paste your automation file contents here…"
title = "Import automation"
unresolved = "Unmapped: {{ops}}"
warningsTitle = "Check these steps after importing"
[automate.run]
title = "Run Automation"
@@ -2066,6 +2066,8 @@ import = "Import"
importPartialSuccess_one = "Imported with {{count}} unmapped operation: {{ops}}"
importPartialSuccess_other = "Imported with {{count}} unmapped operations: {{ops}}"
importSuccess = "Imported automation: {{name}}"
importWithWarnings_one = "Imported {{name}} - {{count}} step needs checking"
importWithWarnings_other = "Imported {{name}} - {{count}} steps need checking"
invalidStep = "Invalid step"
reviewTitle = "Automation Results"
tags = "workflow,sequence,automation,automate,batch,batch processing,pipeline,chain,multi-step,recurring,scheduled,automatic,process multiple,bulk operations"
@@ -2116,21 +2118,26 @@ title = "Unsaved Changes"
label = "Open menu for {{title}}"
[automate.importModal]
acrobatInstructionsTitle = "Operator notes from the Acrobat Action"
cancel = "Cancel"
confirm = "Import"
detectedAcrobat = "Acrobat Action"
detectedAutomation = "Automate JSON"
detectedFolderScan = "Folder Scanning JSON"
dropHint = "Drop JSON here or click to choose a file"
dropSubhint = "Both Automate and Folder Scanning configs are accepted"
dropzoneAriaLabel = "Drop an automation JSON file here"
intro = "Drop a JSON file or paste its contents below. The format (Automate or Folder Scanning) is detected automatically."
detectedJobOptions = "Distiller job options"
dropHint = "Drop a file here or click to choose one"
dropReplace = "Drop another to replace it"
dropSubhint = "Accepts .json, .sequ (Acrobat Action) and .joboptions"
dropzoneAriaLabel = "Drop an automation file here"
intro = "Drop a file or paste its contents below. Automate JSON, Folder Scanning JSON, Acrobat Actions (.sequ) and Distiller job options (.joboptions) are detected automatically."
opCount_one = "{{count}} operation"
opCount_other = "{{count}} operations"
parseError = "Could not parse: {{message}}"
pasteLabel = "Or paste JSON"
pastePlaceholder = "Paste your automation JSON here…"
pasteLabel = "Or paste file contents"
pastePlaceholder = "Paste your automation file contents here…"
title = "Import automation"
unresolved = "Unmapped: {{ops}}"
warningsTitle = "Check these steps after importing"
[automate.run]
title = "Run Automation"
@@ -5,6 +5,7 @@ import {
Badge,
Group,
Modal,
ScrollArea,
Stack,
Text,
Textarea,
@@ -26,16 +27,21 @@ interface AutomationImportModalProps {
onCancel: () => void;
onImport: (
automation: ImportableAutomation,
meta: { format: ParsedAutomationImport["format"]; unresolved: string[] },
meta: {
format: ParsedAutomationImport["format"];
unresolved: string[];
warnings: string[];
},
) => void | Promise<void>;
}
/**
* Single import surface for both supported automation JSON shapes.
* Single import surface for every supported automation file.
*
* Accepts a file drop or pasted text, auto-detects whether the JSON is the
* native Automate config or the backend folder-scanning config, and shows
* the resolved name + format before the user commits the import.
* Accepts a file drop or pasted text, auto-detects the format (native
* Automate JSON, backend folder-scanning JSON, Acrobat Action or Distiller
* job options), and shows the resolved name, format and any migration
* warnings before the user commits the import.
*/
export default function AutomationImportModal({
opened,
@@ -46,6 +52,7 @@ export default function AutomationImportModal({
const { t } = useTranslation();
const [pastedText, setPastedText] = useState("");
const [fileName, setFileName] = useState<string | undefined>(undefined);
const [parsed, setParsed] = useState<ParsedAutomationImport | null>(null);
const [parseError, setParseError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
@@ -55,6 +62,7 @@ export default function AutomationImportModal({
useEffect(() => {
if (opened) {
setPastedText("");
setFileName(undefined);
setParsed(null);
setParseError(null);
setSubmitting(false);
@@ -71,20 +79,28 @@ export default function AutomationImportModal({
return;
}
try {
const result = parseAutomationFile(trimmed, toolRegistry);
const result = parseAutomationFile(
trimmed,
toolRegistry,
undefined,
fileName,
);
setParsed(result);
setParseError(null);
} catch (err) {
setParsed(null);
setParseError(err instanceof Error ? err.message : String(err));
}
}, [pastedText, toolRegistry]);
}, [pastedText, toolRegistry, fileName]);
const handleFileDrop = async (files: File[]) => {
const file = files[0];
if (!file) return;
try {
const text = await file.text();
// Distiller job options carry no name of their own, so the file name is
// the only thing that can name the imported automation.
setFileName(file.name);
setPastedText(text);
} catch (err) {
setParseError(err instanceof Error ? err.message : String(err));
@@ -98,6 +114,7 @@ export default function AutomationImportModal({
await onImport(parsed.automation, {
format: parsed.format,
unresolved: parsed.unresolvedOperations,
warnings: parsed.warnings,
});
} finally {
setSubmitting(false);
@@ -106,15 +123,21 @@ export default function AutomationImportModal({
const dropzoneLabel = t(
"automate.importModal.dropzoneAriaLabel",
"Drop an automation JSON file here",
"Drop an automation file here",
);
const formatLabel =
parsed?.format === "automate"
const formatLabel = !parsed
? null
: parsed.format === "automate"
? t("automate.importModal.detectedAutomation", "Automate JSON")
: parsed?.format === "folderScanning"
: parsed.format === "folderScanning"
? t("automate.importModal.detectedFolderScan", "Folder Scanning JSON")
: null;
: parsed.format === "acrobatSequence"
? t("automate.importModal.detectedAcrobat", "Acrobat Action")
: t(
"automate.importModal.detectedJobOptions",
"Distiller job options",
);
return (
<Modal
@@ -125,116 +148,197 @@ export default function AutomationImportModal({
size="lg"
zIndex={Z_INDEX_AUTOMATE_MODAL}
>
<Stack gap="md">
<Text size="sm" c="dimmed">
{t(
"automate.importModal.intro",
"Drop a JSON file or paste its contents below. The format (Automate or Folder Scanning) is detected automatically.",
)}
</Text>
<Dropzone
onDrop={(files) => void handleFileDrop(files)}
accept={["application/json", "text/plain"]}
multiple={false}
maxSize={10 * 1024 * 1024}
aria-label={dropzoneLabel}
// Dropzone's own aria-label lands on the wrapper; the hidden file
// input it renders needs naming separately.
inputProps={{ "aria-label": dropzoneLabel }}
>
<Group
gap="md"
align="center"
wrap="nowrap"
mih={80}
justify="center"
>
<UploadFileIcon style={{ fontSize: 32, opacity: 0.6 }} />
<div>
<Text size="sm" fw={500}>
{t(
"automate.importModal.dropHint",
"Drop JSON here or click to choose a file",
)}
</Text>
<Text size="xs" c="dimmed">
{t(
"automate.importModal.dropSubhint",
"Both Automate and Folder Scanning configs are accepted",
)}
</Text>
</div>
</Group>
</Dropzone>
<Textarea
label={t("automate.importModal.pasteLabel", "Or paste JSON")}
placeholder={t(
"automate.importModal.pastePlaceholder",
"Paste your automation JSON here…",
)}
value={pastedText}
onChange={(e) => setPastedText(e.currentTarget.value)}
autosize
minRows={6}
maxRows={12}
spellCheck={false}
styles={{ input: { fontFamily: "monospace", fontSize: 12 } }}
/>
{parseError && (
<Alert color="red" variant="light">
{/* An Acrobat Action with several unmappable steps makes this content
taller than a short viewport. Scroll the content, not the whole body,
so Cancel/Import stay pinned and reachable. */}
<ScrollArea.Autosize mah="calc(100vh - 16rem)" offsetScrollbars>
<Stack gap="md" pr="xs">
<Text size="sm" c="dimmed">
{t(
"automate.importModal.parseError",
"Could not parse: {{message}}",
{
message: parseError,
},
"automate.importModal.intro",
"Drop a file or paste its contents below. Automate JSON, Folder Scanning JSON, Acrobat Actions (.sequ) and Distiller job options (.joboptions) are detected automatically.",
)}
</Alert>
)}
</Text>
{parsed && (
<Alert color="green" variant="light">
<Stack gap="xs">
<Group gap="xs" align="center">
<Badge color="green" variant="light">
{formatLabel}
</Badge>
<Dropzone
onDrop={(files) => void handleFileDrop(files)}
// .sequ and .joboptions have no registered MIME type, so the
// extensions have to be listed explicitly for the file picker.
accept={[
"application/json",
"application/xml",
"text/xml",
"text/plain",
".json",
".sequ",
".joboptions",
]}
multiple={false}
maxSize={10 * 1024 * 1024}
aria-label={dropzoneLabel}
// Dropzone's own aria-label lands on the wrapper; the hidden file
// input it renders needs naming separately.
inputProps={{ "aria-label": dropzoneLabel }}
>
<Group
gap="md"
align="center"
wrap="nowrap"
mih={80}
justify="center"
>
<UploadFileIcon style={{ fontSize: 32, opacity: 0.6 }} />
<div>
<Text size="sm" fw={500}>
{parsed.automation.name}
{fileName ??
t(
"automate.importModal.dropHint",
"Drop a file here or click to choose one",
)}
</Text>
</Group>
<Text size="xs" c="dimmed">
{t("automate.importModal.opCount", "{{count}} operation(s)", {
count: parsed.automation.operations.length,
})}
</Text>
{parsed.unresolvedOperations.length > 0 && (
<Text size="xs" c="var(--color-amber-dark)">
{t("automate.importModal.unresolved", "Unmapped: {{ops}}", {
ops: parsed.unresolvedOperations.join(", "),
<Text size="xs" c="dimmed">
{fileName
? t(
"automate.importModal.dropReplace",
"Drop another to replace it",
)
: t(
"automate.importModal.dropSubhint",
"Accepts .json, .sequ (Acrobat Action) and .joboptions",
)}
</Text>
</div>
</Group>
</Dropzone>
<Textarea
label={t(
"automate.importModal.pasteLabel",
"Or paste file contents",
)}
placeholder={t(
"automate.importModal.pastePlaceholder",
"Paste your automation file contents here…",
)}
value={pastedText}
onChange={(e) => {
setFileName(undefined);
setPastedText(e.currentTarget.value);
}}
autosize
minRows={6}
maxRows={12}
spellCheck={false}
styles={{ input: { fontFamily: "monospace", fontSize: 12 } }}
/>
{parseError && (
<Alert color="red" variant="light">
{t(
"automate.importModal.parseError",
"Could not parse: {{message}}",
{
message: parseError,
},
)}
</Alert>
)}
{parsed && (
<Alert color="green" variant="light">
<Stack gap="xs">
<Group gap="xs" align="center">
<Badge color="green" variant="light">
{formatLabel}
</Badge>
<Text size="sm" fw={500}>
{parsed.automation.name}
</Text>
</Group>
<Text size="xs" c="dimmed">
{t("automate.importModal.opCount", "{{count}} operation(s)", {
count: parsed.automation.operations.length,
})}
</Text>
)}
</Stack>
</Alert>
)}
{/* Only when nothing richer follows: the warnings panel lists the
same commands with a reason, and amber-on-green reads as a
colour clash. */}
{parsed.unresolvedOperations.length > 0 &&
parsed.warnings.length === 0 && (
<Text size="xs" c="var(--color-amber-dark)">
{t(
"automate.importModal.unresolved",
"Unmapped: {{ops}}",
{
ops: parsed.unresolvedOperations.join(", "),
},
)}
</Text>
)}
</Stack>
</Alert>
)}
<Group gap="sm" justify="flex-end">
<Button variant="tertiary" onClick={onCancel} disabled={submitting}>
{t("automate.importModal.cancel", "Cancel")}
</Button>
<Button
onClick={() => void handleSubmit()}
disabled={!parsed || submitting}
loading={submitting}
>
{t("automate.importModal.confirm", "Import")}
</Button>
</Group>
</Stack>
{parsed && parsed.warnings.length > 0 && (
<Alert color="yellow" variant="light">
<Stack gap={4}>
<Text size="sm" fw={500}>
{t(
"automate.importModal.warningsTitle",
"Check these steps after importing",
)}
</Text>
{/* No inner scroller: the modal itself scrolls, and nesting a
second scroll surface hides warnings behind a scrollbar the
user has no reason to look for. */}
<Stack gap={4}>
{parsed.warnings.map((warning) => (
<Text key={warning} size="xs">
{warning}
</Text>
))}
</Stack>
</Stack>
</Alert>
)}
{parsed?.format === "acrobatSequence" &&
parsed.instructions.length > 0 && (
<Alert color="blue" variant="light">
<Stack gap={4}>
<Text size="sm" fw={500}>
{t(
"automate.importModal.acrobatInstructionsTitle",
"Operator notes from the Acrobat Action",
)}
</Text>
{parsed.instructions.map((instruction) => (
<Text
key={instruction}
size="xs"
style={{ whiteSpace: "pre-wrap" }}
>
{instruction}
</Text>
))}
</Stack>
</Alert>
)}
</Stack>
</ScrollArea.Autosize>
<Group gap="sm" justify="flex-end" pt="md">
<Button variant="tertiary" onClick={onCancel} disabled={submitting}>
{t("automate.importModal.cancel", "Cancel")}
</Button>
<Button
onClick={() => void handleSubmit()}
disabled={!parsed || submitting}
loading={submitting}
>
{t("automate.importModal.confirm", "Import")}
</Button>
</Group>
</Modal>
);
}
@@ -12,6 +12,7 @@ import { ToolRegistry } from "@app/data/toolsTaxonomy";
import {
downloadAutomationConfig,
downloadFolderScanningConfig,
type AutomationImportFormat,
} from "@app/utils/automationConverter";
import type { ImportableAutomation } from "@app/hooks/tools/automate/useSavedAutomations";
@@ -49,7 +50,11 @@ export default function AutomationSelection({
const handleImportSubmit = async (
automation: ImportableAutomation,
meta: { format: "automate" | "folderScanning"; unresolved: string[] },
meta: {
format: AutomationImportFormat;
unresolved: string[];
warnings: string[];
},
) => {
try {
await onImportAutomation(automation);
@@ -65,6 +70,14 @@ export default function AutomationSelection({
},
),
);
} else if (meta.warnings.length > 0) {
onImportSuccess?.(
t(
"automate.importWithWarnings",
"Imported {{name}} - {{count}} step(s) need checking",
{ name: automation.name, count: meta.warnings.length },
),
);
} else {
onImportSuccess?.(
t("automate.importSuccess", "Imported automation: {{name}}", {
@@ -105,6 +105,42 @@ function makeFolderScanJson(name: string): string {
});
}
/**
* A real Acrobat Action: one command that maps cleanly (Sanitize Document),
* one that cannot be reproduced (an Acrobat JavaScript step), plus an
* operator instruction.
*/
const ACROBAT_ACTION = `<?xml version="1.0" encoding="UTF-8"?>
<Workflow xmlns="http://ns.adobe.com/acrobat/workflow/2012" title="Imported Acrobat Action" description="Sanitise then run a script" majorVersion="1" minorVersion="0">
\t<Sources defaultCommand="WorkflowPlaybackSelectFile"/>
\t<Group label="Notice">
\t\t<Instruction label="Run this on scanned intake only." pauseBefore="false"/>
\t</Group>
\t<Group label="Clean">
\t\t<Command name="DIGSIG:SanitizeDocument" pauseBefore="false" promptUser="false"/>
\t\t<Command name="JavaScript" pauseBefore="false" promptUser="false">
\t\t\t<Items>
\t\t\t\t<Item name="ScriptCode" type="text" value="this.flattenPages();"/>
\t\t\t\t<Item name="ScriptName" type="text" value=""/>
\t\t\t</Items>
\t\t</Command>
\t</Group>
</Workflow>
`;
/** A Distiller profile in the shape Adobe writes: 150 dpi, web-optimised. */
const JOB_OPTIONS = `<<
/AutoRotatePages /None
/ColorImageResolution 150
/GrayImageResolution 150
/DownsampleColorImages true
/DownsampleGrayImages true
/EmbedAllFonts true
/Optimize true
/CompatibilityLevel 1.4
>> setdistillerparams
`;
/**
* Open the import modal from the "Create New Automation" kebab.
*/
@@ -155,7 +191,7 @@ test.describe("12. Automation Page — Import / Export", () => {
const importBtn = page.getByRole("button", { name: /^Import$/ }).last();
await expect(importBtn).toBeDisabled();
const textarea = page.getByLabel(/Or paste JSON/i);
const textarea = page.getByLabel(/Or paste file contents/i);
await textarea.fill(makeAutomateJson("Pasted Automate"));
// Detected-format badge should appear.
@@ -207,7 +243,7 @@ test.describe("12. Automation Page — Import / Export", () => {
}) => {
await openImportModal(page);
const textarea = page.getByLabel(/Or paste JSON/i);
const textarea = page.getByLabel(/Or paste file contents/i);
await textarea.fill(makeFolderScanJson("Pasted Folder Scan"));
await expect(page.getByText(/Folder Scanning JSON/).first()).toBeVisible({
@@ -228,7 +264,7 @@ test.describe("12. Automation Page — Import / Export", () => {
}) => {
await openImportModal(page);
const textarea = page.getByLabel(/Or paste JSON/i);
const textarea = page.getByLabel(/Or paste file contents/i);
await textarea.fill("{ this is not valid json");
await expect(page.getByText(/Could not parse/i).first()).toBeVisible({
@@ -257,7 +293,7 @@ test.describe("12. Automation Page — Import / Export", () => {
});
// The textarea should reflect the dropped content.
await expect(page.getByLabel(/Or paste JSON/i)).toHaveValue(
await expect(page.getByLabel(/Or paste file contents/i)).toHaveValue(
/Dropped Automate/,
{ timeout: 5_000 },
);
@@ -299,6 +335,103 @@ test.describe("12. Automation Page — Import / Export", () => {
});
});
test.describe("12.3b Import modal — Adobe migration formats", () => {
test("dropping an Acrobat Action imports it and reports the steps that need work", async ({
page,
}) => {
await openImportModal(page);
const fileInput = page
.getByRole("dialog", { name: /Import automation/i })
.locator('input[type="file"]');
await fileInput.setInputFiles({
name: "Imported Acrobat Action.sequ",
mimeType: "application/xml",
buffer: Buffer.from(ACROBAT_ACTION),
});
await expect(page.getByText(/Acrobat Action/).first()).toBeVisible({
timeout: 5_000,
});
// Only the sanitise command maps to a tool; the JS step does not. The
// unmapped command is named in the warnings panel with a reason, rather
// than as a bare list in the summary.
await expect(page.getByText(/^1 operation$/)).toBeVisible();
await expect(
page.getByText(/Check these steps after importing/),
).toBeVisible();
await expect(
page.getByText(/JavaScript: .*does not run Acrobat's JS API/),
).toBeVisible();
// The Action's own operator note is surfaced, not thrown away. Matched
// exactly so the raw XML echoed in the paste textarea doesn't also hit.
await expect(
page.getByText("Run this on scanned intake only.", { exact: true }),
).toBeVisible();
await page
.getByRole("button", { name: /^Import$/ })
.last()
.click();
await expect(
page.getByRole("button", { name: /Imported Acrobat Action/i }).first(),
).toBeVisible({ timeout: 10_000 });
});
test("dropping a .joboptions file imports it named after the file", async ({
page,
}) => {
await openImportModal(page);
const fileInput = page
.getByRole("dialog", { name: /Import automation/i })
.locator('input[type="file"]');
await fileInput.setInputFiles({
name: "Press Quality.joboptions",
mimeType: "application/octet-stream",
buffer: Buffer.from(JOB_OPTIONS),
});
await expect(page.getByText(/Distiller job options/).first()).toBeVisible(
{ timeout: 5_000 },
);
await expect(page.getByText(/^1 operation$/)).toBeVisible();
await expect(
page.getByText(/Target PDF version 1.4 is not enforced/),
).toBeVisible();
await page
.getByRole("button", { name: /^Import$/ })
.last()
.click();
// The automation takes its name from the file, since a Distiller
// profile carries none.
await expect(
page.getByRole("button", { name: /Press Quality/i }).first(),
).toBeVisible({ timeout: 10_000 });
});
test("pasting an Acrobat Action works without a file name", async ({
page,
}) => {
await openImportModal(page);
await page.getByLabel(/Or paste file contents/i).fill(ACROBAT_ACTION);
await expect(page.getByText(/Acrobat Action/).first()).toBeVisible({
timeout: 5_000,
});
await page
.getByRole("button", { name: /^Import$/ })
.last()
.click();
await expect(
page.getByRole("button", { name: /Imported Acrobat Action/i }).first(),
).toBeVisible({ timeout: 10_000 });
});
});
test.describe("12.4 Export — per-automation kebab menu", () => {
test("saved entry kebab exposes Export and Export for Folder Scanning", async ({
page,
@@ -306,7 +439,7 @@ test.describe("12. Automation Page — Import / Export", () => {
// Seed a saved automation by importing one first.
await openImportModal(page);
await page
.getByLabel(/Or paste JSON/i)
.getByLabel(/Or paste file contents/i)
.fill(makeAutomateJson("Export Menu Seed"));
await page
.getByRole("button", { name: /^Import$/ })
@@ -338,7 +471,7 @@ test.describe("12. Automation Page — Import / Export", () => {
}) => {
await openImportModal(page);
await page
.getByLabel(/Or paste JSON/i)
.getByLabel(/Or paste file contents/i)
.fill(makeAutomateJson("Download Test"));
await page
.getByRole("button", { name: /^Import$/ })
@@ -360,7 +493,7 @@ test.describe("12. Automation Page — Import / Export", () => {
}) => {
await openImportModal(page);
await page
.getByLabel(/Or paste JSON/i)
.getByLabel(/Or paste file contents/i)
.fill(makeAutomateJson("Folder Download Test"));
await page
.getByRole("button", { name: /^Import$/ })
@@ -0,0 +1,468 @@
/**
* Unit tests for the Acrobat Action Wizard (.sequ) importer.
*
* The fixtures are verbatim real Actions - Adobe publishes no schema for this
* format, so testing against anything reconstructed from the docs would prove
* nothing.
*/
import { describe, test, expect } from "vitest";
import {
importAcrobatSequence,
looksLikeAcrobatSequence,
mapAcrobatCommand,
parseAcrobatSequenceXml,
} from "@app/utils/acrobatSequence";
import type { ToolRegistry } from "@app/data/toolsTaxonomy";
const registry = {
compress: {
operationConfig: {
endpoint: "/api/v1/misc/compress-pdf",
defaultParameters: { compressionLevel: 5, grayscale: false },
},
},
ocr: { operationConfig: { defaultParameters: { languages: [] } } },
convert: { operationConfig: { defaultParameters: {} } },
removeAnnotations: { operationConfig: { defaultParameters: {} } },
sanitize: { operationConfig: { defaultParameters: {} } },
changeMetadata: {
operationConfig: { defaultParameters: { deleteAll: false } },
},
watermark: { operationConfig: { defaultParameters: { opacity: 50 } } },
redact: { operationConfig: { defaultParameters: {} } },
} as unknown as Partial<ToolRegistry>;
/** A real single-command Action. */
const DELETE_COMMENTS = `<?xml version="1.0" encoding="UTF-8"?>
<Workflow xmlns="http://ns.adobe.com/acrobat/workflow/2012" title="Delete All Comments" description="This Action deletes all existing comments on a PDF and then saves a copy of the original file." majorVersion="1" minorVersion="0">
<Sources defaultCommand="WorkflowPlaybackSelectFile"/>
<Group label="Delete All Comments">
<Command name="DeleteAll" pauseBefore="false" promptUser="true"/>
</Group>
</Workflow>
`;
/** A real Action that exports to plain text via the save handler. */
const EXPORT_TXT = `<?xml version="1.0" encoding="UTF-8"?>
<Workflow xmlns="http://ns.adobe.com/acrobat/workflow/2012" title="Export PDFs to TXTs" description="" majorVersion="1" minorVersion="0">
<Sources defaultCommand="WorkflowPlaybackSelectFolder"/>
<Group label="Save as DOCX">
<Command name="WorkflowPlaybackSaveFiles" pauseBefore="false" promptUser="false">
<Items>
<Item name="AddToBaseName" type="boolean" value="false"/>
<Item name="DocSaveDestType" type="string" value="WorkflowPlaybackSave"/>
<Item name="FS" type="atom" value="DOS"/>
<Item name="HandlerUniqueID" type="string" value="com.adobe.acrobat.plain-text"/>
<Item name="OptimizePDF" type="boolean" value="true"/>
<Item name="PresetName" type="text" value="Standard"/>
<Item name="RunPDFOptimizer" type="boolean" value="false"/>
</Items>
</Command>
</Group>
</Workflow>
`;
/** A real multi-group Action: preflight, scan optimisation, view prefs, save. */
const COMPRESS_ACTION = `<?xml version="1.0" encoding="UTF-8"?>
<Workflow xmlns="http://ns.adobe.com/acrobat/workflow/2012" title="Compress PDF pictures (Save As)" description="Compress PDF pictures, then save as another PDF." majorVersion="1" minorVersion="0">
<Group label="PDF compress">
<Command name="CALS:Preflight" pauseBefore="false" promptUser="false">
<Items>
<Item name="CALS_PREFLIGHT_CMD_OMIT_FIXUPS" type="boolean" value="false"/>
<Item name="CALS_PREFLIGHT_CMD_PROFILE_NAME" type="text" value="Shrink pages to A4"/>
</Items>
</Command>
<Command name="Scan:OPT" pauseBefore="false" promptUser="false">
<Items>
<Item name="ApplyMRC" type="boolean" value="true"/>
<Item name="ColorCompression" type="integer" value="4"/>
<Item name="Deskew" type="boolean" value="false"/>
<Item name="QualityLevel" type="integer" value="1"/>
<Item name="doOCR" type="boolean" value="false"/>
</Items>
</Command>
</Group>
<Group label="Post-processing">
<Command name="OpenInfo" pauseBefore="false" promptUser="false">
<Items>
<Item name="DisplayDocTitle" type="boolean" value="true"/>
<Items name="LeaveAsIs">
<Item name="CenterWindow" type="boolean" value="true"/>
</Items>
<Item name="PageLayout" type="integer" value="1"/>
</Items>
</Command>
<Command name="WorkflowPlaybackSaveFiles" pauseBefore="false" promptUser="false">
<Items>
<Item name="HandlerUniqueID" type="string" value="com.callas.preflight.pdfa"/>
<Item name="InsertAfterBaseName" type="text" value="_c"/>
<Item name="RunPDFOptimizer" type="boolean" value="true"/>
</Items>
</Command>
</Group>
</Workflow>
`;
/** A real Action carrying operator instructions and an Acrobat JS step. */
const FIND_AND_HIGHLIGHT = `<?xml version="1.0" encoding="UTF-8"?>
<Workflow xmlns="http://ns.adobe.com/acrobat/workflow/2012" title="Find and Highlight Words" description="Searches for words using the redaction command." majorVersion="1" minorVersion="0">
<Group label="Notice">
<Instruction label="The words that are highlighted will not be redacted." pauseBefore="false"/>
</Group>
<Group label="Step 1: Search for words">
<Command name="SearchAndRedactCmd" pauseBefore="false" promptUser="true"/>
</Group>
<Group label="Step 2: Convert highlight annotation">
<Command name="JavaScript" pauseBefore="false" promptUser="false">
<Items>
<Item name="ScriptCode" type="text" value="// header comment&#xD;&#xA;var oDoc = event.target;&#xD;&#xA;"/>
<Item name="ScriptName" type="text" value=""/>
</Items>
</Command>
</Group>
</Workflow>
`;
const WATERMARK_ACTION = `<?xml version="1.0" encoding="UTF-8"?>
<Workflow xmlns="http://ns.adobe.com/acrobat/workflow/2012" title="Stamp Draft" description="" majorVersion="1" minorVersion="0">
<Group label="Watermark">
<Command name="COMP:AddWatermark" pauseBefore="false" promptUser="false">
<Items>
<Items name="WaterBackCmd">
<Item name="BACKGROUND" type="boolean" value="false"/>
<Item name="COLOR1" type="double" value="1.000000"/>
<Item name="COLOR2" type="double" value="0.000000"/>
<Item name="COLOR3" type="double" value="0.000000"/>
<Item name="COLORSPACE" type="atom" value="DeviceRGB"/>
<Item name="FONT_SIZE" type="double" value="24.000000"/>
<Item name="FROM_FILE" type="boolean" value="false"/>
<Item name="OPACITY" type="double" value="0.400000"/>
<Item name="ROTATION" type="integer" value="45"/>
<Item name="SRCTEXT" type="text" value="DRAFT"/>
<Item name="WATERMARK" type="boolean" value="true"/>
</Items>
</Items>
</Command>
</Group>
</Workflow>
`;
const findMapping = (
mappings: ReturnType<typeof mapAcrobatCommand>,
command: string,
) => mappings.find((mapping) => mapping.command === command);
describe("acrobatSequence", () => {
describe("parseAcrobatSequenceXml", () => {
test("reads title, description and a self-closing command", () => {
const sequence = parseAcrobatSequenceXml(DELETE_COMMENTS);
expect(sequence.title).toBe("Delete All Comments");
expect(sequence.description).toContain("deletes all existing comments");
expect(sequence.sourceIsFolder).toBe(false);
expect(sequence.commands).toEqual([
{
name: "DeleteAll",
groupLabel: "Delete All Comments",
items: {},
promptUser: true,
pauseBefore: false,
},
]);
});
test("detects a folder source", () => {
expect(parseAcrobatSequenceXml(EXPORT_TXT).sourceIsFolder).toBe(true);
});
test("coerces item types and recurses into nested Items", () => {
const sequence = parseAcrobatSequenceXml(COMPRESS_ACTION);
const scan = sequence.commands.find((c) => c.name === "Scan:OPT")!;
expect(scan.items.ApplyMRC).toBe(true);
expect(scan.items.QualityLevel).toBe(1);
expect(scan.items.Deskew).toBe(false);
const openInfo = sequence.commands.find((c) => c.name === "OpenInfo")!;
expect(openInfo.items.LeaveAsIs).toEqual({ CenterWindow: true });
expect(openInfo.items.PageLayout).toBe(1);
});
test("collects Instruction text separately from commands", () => {
const sequence = parseAcrobatSequenceXml(FIND_AND_HIGHLIGHT);
expect(sequence.instructions).toEqual([
"The words that are highlighted will not be redacted.",
]);
expect(sequence.commands.map((c) => c.name)).toEqual([
"SearchAndRedactCmd",
"JavaScript",
]);
});
test("rejects malformed XML", () => {
expect(() =>
parseAcrobatSequenceXml("<Workflow><Group></Workflow>"),
).toThrow(/not valid XML/);
});
test("rejects XML that is not an Action", () => {
expect(() =>
parseAcrobatSequenceXml('<?xml version="1.0"?><root><a/></root>'),
).toThrow(/expected a <Workflow> root/);
});
});
describe("looksLikeAcrobatSequence", () => {
test("accepts real Actions", () => {
expect(looksLikeAcrobatSequence(DELETE_COMMENTS)).toBe(true);
expect(looksLikeAcrobatSequence(COMPRESS_ACTION)).toBe(true);
});
test("rejects JSON and job options", () => {
expect(looksLikeAcrobatSequence('{"operations":[]}')).toBe(false);
expect(looksLikeAcrobatSequence("<< /CompatibilityLevel 1.4 >>")).toBe(
false,
);
});
});
describe("mapAcrobatCommand", () => {
test("the save step's HandlerUniqueID drives the conversion target", () => {
const [command] = parseAcrobatSequenceXml(EXPORT_TXT).commands;
const [mapping] = mapAcrobatCommand(command);
expect(mapping).toMatchObject({
confidence: "exact",
toolId: "convert",
parameters: { fromExtension: "pdf", toExtension: "txt" },
});
});
test("the callas PDF/A writer becomes a PDF/A conversion", () => {
const command = parseAcrobatSequenceXml(COMPRESS_ACTION).commands.find(
(c) => c.name === "WorkflowPlaybackSaveFiles",
)!;
const [mapping] = mapAcrobatCommand(command);
expect(mapping.toolId).toBe("convert");
expect(mapping.parameters).toMatchObject({ toExtension: "pdfa" });
});
test("a plain PDF save is skipped rather than mapped", () => {
const [mapping] = mapAcrobatCommand({
name: "WorkflowPlaybackSaveFiles",
groupLabel: "",
items: { RunPDFOptimizer: false },
promptUser: false,
pauseBefore: false,
});
expect(mapping.confidence).toBe("skipped");
expect(mapping.toolId).toBeUndefined();
});
test("a save that runs the PDF Optimizer becomes a compress step", () => {
const [mapping] = mapAcrobatCommand({
name: "WorkflowPlaybackSaveFiles",
groupLabel: "",
items: { RunPDFOptimizer: true, PresetName: "Mobile" },
promptUser: false,
pauseBefore: false,
});
expect(mapping.toolId).toBe("compress");
expect(mapping.note).toContain("Mobile");
});
test("scan optimisation inverts Acrobat's quality scale", () => {
const command = parseAcrobatSequenceXml(COMPRESS_ACTION).commands.find(
(c) => c.name === "Scan:OPT",
)!;
const [mapping] = mapAcrobatCommand(command);
// QualityLevel 1 is Acrobat's smallest-file setting, so it maps to a
// heavy Stirling compression level, not a light one.
expect(mapping.parameters).toMatchObject({ compressionLevel: 8 });
});
test("scan optimisation with OCR emits a second step", () => {
const mappings = mapAcrobatCommand({
name: "Scan:OPT",
groupLabel: "",
items: { QualityLevel: 4, doOCR: true },
promptUser: false,
pauseBefore: false,
});
expect(mappings.map((m) => m.toolId)).toEqual(["compress", "ocr"]);
expect(mappings[0].parameters).toMatchObject({ compressionLevel: 2 });
// The note belongs to the command, not to every step it expands into.
expect(mappings[1].note).toBeUndefined();
});
test("a non-standards preflight profile is reported by name for manual work", () => {
const command = parseAcrobatSequenceXml(COMPRESS_ACTION).commands.find(
(c) => c.name === "CALS:Preflight",
)!;
const [mapping] = mapAcrobatCommand(command);
expect(mapping.confidence).toBe("manual");
expect(mapping.note).toContain("Shrink pages to A4");
expect(mapping.note).toContain("compliance policy");
});
test("a PDF/A preflight profile maps to conversion", () => {
const [mapping] = mapAcrobatCommand({
name: "CALS:Preflight",
groupLabel: "",
items: { CALS_PREFLIGHT_CMD_PROFILE_NAME: "Convert to PDF/A-2b" },
promptUser: false,
pauseBefore: false,
});
expect(mapping.toolId).toBe("convert");
expect(mapping.parameters).toMatchObject({ toExtension: "pdfa" });
});
test("JavaScript steps are reported with an identifying line", () => {
const command = parseAcrobatSequenceXml(FIND_AND_HIGHLIGHT).commands.find(
(c) => c.name === "JavaScript",
)!;
const [mapping] = mapAcrobatCommand(command);
expect(mapping.confidence).toBe("manual");
// The leading comment line is skipped in favour of real code.
expect(mapping.note).toContain("var oDoc = event.target;");
});
test("watermark colours, opacity and rotation are translated", () => {
const [command] = parseAcrobatSequenceXml(WATERMARK_ACTION).commands;
const [mapping] = mapAcrobatCommand(command);
expect(mapping.toolId).toBe("watermark");
expect(mapping.parameters).toMatchObject({
watermarkType: "text",
watermarkText: "DRAFT",
fontSize: 24,
rotation: 45,
opacity: 40,
customColor: "#ff0000",
});
});
test("an image watermark is flagged because the image is not in the file", () => {
const [mapping] = mapAcrobatCommand({
name: "COMP:AddWatermark",
groupLabel: "",
items: { WaterBackCmd: { FROM_FILE: true } },
promptUser: false,
pauseBefore: false,
});
expect(mapping.confidence).toBe("manual");
expect(mapping.note).toContain("external image file");
});
test("metadata honours the LeaveAsIs flags", () => {
const [mapping] = mapAcrobatCommand({
name: "GeneralInfo",
groupLabel: "",
items: {
Title: "New title",
Author: "Ada",
LeaveAsIs: { Author: true },
},
promptUser: false,
pauseBefore: false,
});
expect(mapping.toolId).toBe("changeMetadata");
expect(mapping.parameters).toEqual({ title: "New title" });
});
test("a metadata step that changes nothing is skipped", () => {
const [mapping] = mapAcrobatCommand({
name: "GeneralInfo",
groupLabel: "",
items: { Title: "x", LeaveAsIs: { Title: true } },
promptUser: false,
pauseBefore: false,
});
expect(mapping.confidence).toBe("skipped");
});
test("promptUser commands are flagged as having no saved settings", () => {
const [command] = parseAcrobatSequenceXml(DELETE_COMMENTS).commands;
const [mapping] = mapAcrobatCommand(command);
expect(mapping.toolId).toBe("removeAnnotations");
expect(mapping.note).toContain("opened a dialog in Acrobat");
});
test("unknown commands fall back to a keyword match", () => {
const [mapping] = mapAcrobatCommand({
name: "Bates:AddBatesNumbering",
groupLabel: "",
items: {},
promptUser: false,
pauseBefore: false,
});
expect(mapping.confidence).toBe("heuristic");
expect(mapping.toolId).toBe("addPageNumbers");
expect(mapping.note).toContain("check the step's configuration");
});
test("commands with no keyword match are reported, never silently dropped", () => {
const [mapping] = mapAcrobatCommand({
name: "Xyz:SomethingElse",
groupLabel: "",
items: {},
promptUser: false,
pauseBefore: false,
});
expect(mapping.confidence).toBe("manual");
expect(mapping.note).toContain("Unrecognised Acrobat command");
});
});
describe("importAcrobatSequence", () => {
test("merges registry defaults under the mapped parameters", () => {
const result = importAcrobatSequence(COMPRESS_ACTION, registry);
const compress = result.operations.find(
(op) => op.operation === "compress",
)!;
expect(compress.parameters).toEqual({
// From the registry defaults…
grayscale: false,
// …with the Acrobat-derived value winning.
compressionLevel: 8,
compressionMethod: "quality",
});
});
test("keeps the Action's own title and description", () => {
const result = importAcrobatSequence(DELETE_COMMENTS, registry);
expect(result.name).toBe("Delete All Comments");
expect(result.description).toContain("deletes all existing comments");
});
test("falls back to the file name for an untitled Action", () => {
const untitled = DELETE_COMMENTS.replace(
'title="Delete All Comments"',
'title=""',
);
const result = importAcrobatSequence(
untitled,
registry,
"My Action.sequ",
);
expect(result.name).toBe("My Action");
});
test("skipped and manual steps produce no operations but stay in the report", () => {
const result = importAcrobatSequence(COMPRESS_ACTION, registry);
expect(result.operations.map((op) => op.operation)).toEqual([
"compress",
"convert",
]);
expect(findMapping(result.mappings, "OpenInfo")?.confidence).toBe(
"skipped",
);
expect(findMapping(result.mappings, "CALS:Preflight")?.confidence).toBe(
"manual",
);
expect(result.mappings).toHaveLength(4);
});
test("carries operator instructions through", () => {
const result = importAcrobatSequence(FIND_AND_HIGHLIGHT, registry);
expect(result.instructions).toHaveLength(1);
});
});
});
@@ -0,0 +1,719 @@
/**
* Adobe Acrobat Action Wizard (`.sequ`) importer.
*
* An Action file is XML in the `http://ns.adobe.com/acrobat/workflow/2012`
* namespace:
*
* <Workflow title="…" description="…">
* <Sources defaultCommand="WorkflowPlaybackSelectFile"/>
* <Group label="Step 1">
* <Instruction label="Free text shown to the operator"/>
* <Command name="Cpt:CapturePages" pauseBefore="false" promptUser="false">
* <Items>
* <Item name="Language" type="integer" value="26"/>
* <Items name="LeaveAsIs"><Item name="Title" type="boolean" value="true"/></Items>
* </Items>
* </Command>
* </Group>
* </Workflow>
*
* Adobe does not publish the command vocabulary, so the mapping below is
* built from observed Action files. Every step is reported with a confidence
* so the import summary can distinguish a command we recognise exactly from
* one matched on a keyword, and from one that has no Stirling equivalent at
* all. Nothing is silently dropped.
*/
import { AutomationOperation } from "@app/types/automation";
import { ToolRegistry } from "@app/data/toolsTaxonomy";
import { ToolId } from "@app/types/toolId";
// ---------------------------------------------------------------------------
// Raw XML model
// ---------------------------------------------------------------------------
/** A parsed `<Item>` tree. Nested `<Items name="…">` become nested records. */
export type AcrobatItems = {
[key: string]: string | number | boolean | null | AcrobatItems;
};
export interface AcrobatCommand {
/** Raw command name, e.g. "CALS:Preflight". */
name: string;
/** Label of the enclosing `<Group>`, used for display. */
groupLabel: string;
items: AcrobatItems;
/** Acrobat opens this command's dialog at run time. */
promptUser: boolean;
pauseBefore: boolean;
}
export interface AcrobatSequence {
title: string;
description: string;
commands: AcrobatCommand[];
/** `<Instruction>` text - operator notes, not processing steps. */
instructions: string[];
/** True when the Action reads a whole folder rather than one file. */
sourceIsFolder: boolean;
}
const ACROBAT_WORKFLOW_NS = "http://ns.adobe.com/acrobat/workflow/2012";
/** Coerce an `<Item type="…" value="…">` pair to a JS value. */
function coerceItemValue(type: string | null, value: string | null) {
if (type === "null") return null;
if (value === null) return null;
switch (type) {
case "boolean":
return value === "true";
case "integer":
case "double": {
const num = Number(value);
return Number.isFinite(num) ? num : value;
}
default:
return value;
}
}
/** Read an `<Items>` element into a plain record, recursing into sub-groups. */
function readItems(container: Element): AcrobatItems {
const items: AcrobatItems = {};
for (const child of Array.from(container.children)) {
const name = child.getAttribute("name");
if (!name) continue;
if (child.localName === "Items") {
items[name] = readItems(child);
} else if (child.localName === "Item") {
items[name] = coerceItemValue(
child.getAttribute("type"),
child.getAttribute("value"),
);
}
}
return items;
}
/**
* Parse `.sequ` XML into its command list.
*
* @throws if the document is not well-formed XML or is not an Acrobat Action.
*/
export function parseAcrobatSequenceXml(xmlText: string): AcrobatSequence {
const doc = new DOMParser().parseFromString(xmlText, "application/xml");
// DOMParser reports XML syntax errors as a <parsererror> element rather
// than throwing.
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 !== "Workflow") {
throw new Error(
"Not an Acrobat Action file: expected a <Workflow> root element.",
);
}
if (root.namespaceURI && root.namespaceURI !== ACROBAT_WORKFLOW_NS) {
throw new Error(
`Unexpected Action namespace "${root.namespaceURI}". Only Acrobat X and later Actions are supported.`,
);
}
const commands: AcrobatCommand[] = [];
const instructions: string[] = [];
for (const group of Array.from(root.children)) {
if (group.localName !== "Group") continue;
const groupLabel = group.getAttribute("label") ?? "";
for (const node of Array.from(group.children)) {
if (node.localName === "Instruction") {
const label = node.getAttribute("label");
if (label) instructions.push(label);
continue;
}
if (node.localName !== "Command") continue;
const name = node.getAttribute("name");
if (!name) continue;
const itemsEl = Array.from(node.children).find(
(child) => child.localName === "Items",
);
commands.push({
name,
groupLabel,
items: itemsEl ? readItems(itemsEl) : {},
promptUser: node.getAttribute("promptUser") === "true",
pauseBefore: node.getAttribute("pauseBefore") === "true",
});
}
}
const sources = Array.from(root.children).find(
(child) => child.localName === "Sources",
);
return {
title: root.getAttribute("title") ?? "",
description: root.getAttribute("description") ?? "",
commands,
instructions,
sourceIsFolder:
sources?.getAttribute("defaultCommand") ===
"WorkflowPlaybackSelectFolder",
};
}
/** True when the text looks like an Acrobat Action file. */
export function looksLikeAcrobatSequence(text: string): boolean {
const head = text.slice(0, 2048);
return (
head.includes(ACROBAT_WORKFLOW_NS) ||
(/<Workflow[\s>]/.test(head) && /<Group[\s>]/.test(text))
);
}
// ---------------------------------------------------------------------------
// Command mapping
// ---------------------------------------------------------------------------
/**
* How a command was matched:
* - `exact` - a known Acrobat command with a translated parameter set
* - `heuristic`- matched on a keyword in the command name; verify the settings
* - `manual` - recognised, but the work has to be redone by hand
* - `skipped` - deliberately dropped (no-op in Stirling)
*/
export type AcrobatMappingConfidence =
| "exact"
| "heuristic"
| "manual"
| "skipped";
export interface AcrobatStepMapping {
command: string;
groupLabel: string;
confidence: AcrobatMappingConfidence;
/** Set for `exact` and `heuristic` mappings. */
toolId?: ToolId;
parameters?: Record<string, unknown>;
/** Why this step needs attention, shown in the import summary. */
note?: string;
}
type MappedStep = { toolId: ToolId; parameters: Record<string, unknown> };
type HandlerResult =
| { kind: "tools"; steps: MappedStep[]; note?: string }
| { kind: "manual"; note: string }
| { kind: "skipped"; note: string };
type CommandHandler = (command: AcrobatCommand) => HandlerResult;
const num = (items: AcrobatItems, key: string): number | undefined => {
const value = items[key];
return typeof value === "number" ? value : undefined;
};
const bool = (items: AcrobatItems, key: string): boolean | undefined => {
const value = items[key];
return typeof value === "boolean" ? value : undefined;
};
const str = (items: AcrobatItems, key: string): string | undefined => {
const value = items[key];
return typeof value === "string" && value.length > 0 ? value : undefined;
};
const sub = (items: AcrobatItems, key: string): AcrobatItems => {
const value = items[key];
return value && typeof value === "object" ? (value as AcrobatItems) : {};
};
/**
* `WorkflowPlaybackSaveFiles` is Acrobat's save step, but the output *format*
* lives in `HandlerUniqueID` - so "Save" is really "convert" whenever the
* handler is not the plain PDF writer.
*/
const SAVE_HANDLER_TO_EXTENSION: Record<string, string> = {
"com.adobe.acrobat.plain-text": "txt",
"com.adobe.acrobat.accesstext": "txt",
"com.adobe.acrobat.rtf": "rtf",
"com.adobe.acrobat.doc": "docx",
"com.adobe.acrobat.docx": "docx",
"com.adobe.acrobat.word": "docx",
"com.adobe.acrobat.xlsx": "xlsx",
"com.adobe.acrobat.spreadsheet": "xlsx",
"com.adobe.acrobat.pptx": "pptx",
"com.adobe.acrobat.jpeg": "jpg",
"com.adobe.acrobat.jpeg2000": "jpg",
"com.adobe.acrobat.png": "png",
"com.adobe.acrobat.tiff": "tiff",
"com.adobe.acrobat.bmp": "bmp",
"com.adobe.acrobat.xml-1-00": "xml",
"com.adobe.acrobat.html": "html",
"com.adobe.acrobat.xhtml": "html",
};
/** Acrobat's `/OPACITY` and colour components are 0..1; ours are 0..100 / hex. */
const toPercent = (value: number | undefined): number | undefined =>
value === undefined
? undefined
: Math.round(Math.max(0, Math.min(1, value)) * 100);
const toHexColor = (
r: number | undefined,
g: number | undefined,
b: number | undefined,
): string | undefined => {
if (r === undefined || g === undefined || b === undefined) return undefined;
const channel = (v: number) =>
Math.max(0, Math.min(255, Math.round(v * 255)))
.toString(16)
.padStart(2, "0");
return `#${channel(r)}${channel(g)}${channel(b)}`;
};
const handleSaveFiles: CommandHandler = (command) => {
const handler = str(command.items, "HandlerUniqueID");
const runOptimizer = bool(command.items, "RunPDFOptimizer") === true;
// The callas preflight PDF/A writer is Acrobat's "Save as PDF/A".
if (handler === "com.callas.preflight.pdfa") {
return {
kind: "tools",
steps: [
{
toolId: "convert",
parameters: {
fromExtension: "pdf",
toExtension: "pdfa",
pdfaOptions: { outputFormat: "pdfa-2b", strict: false },
},
},
],
};
}
const extension = handler ? SAVE_HANDLER_TO_EXTENSION[handler] : undefined;
if (extension) {
return {
kind: "tools",
steps: [
{
toolId: "convert",
parameters: { fromExtension: "pdf", toExtension: extension },
},
],
};
}
if (runOptimizer) {
return {
kind: "tools",
steps: [
{
toolId: "compress",
parameters: { compressionMethod: "quality", compressionLevel: 5 },
},
],
note: `Acrobat ran the PDF Optimizer with the "${str(command.items, "PresetName") ?? "default"}" preset. Mapped to compression level 5 - adjust to taste.`,
};
}
return {
kind: "skipped",
note: "Acrobat's Save step. Automate returns the processed file, so no explicit save is needed.",
};
};
const handleGeneralInfo: CommandHandler = (command) => {
// `LeaveAsIs` marks the fields Acrobat should not touch.
const leaveAsIs = sub(command.items, "LeaveAsIs");
const parameters: Record<string, unknown> = {};
const fields = ["Title", "Author", "Subject", "Keywords"] as const;
for (const field of fields) {
if (leaveAsIs[field] === true) continue;
const value = str(command.items, field);
if (value !== undefined) parameters[field.toLowerCase()] = value;
}
if (Object.keys(parameters).length === 0) {
return {
kind: "skipped",
note: "Document metadata step left every field unchanged.",
};
}
return {
kind: "tools",
steps: [{ toolId: "changeMetadata", parameters }],
};
};
const handleAddWatermark: CommandHandler = (command) => {
const wm = sub(command.items, "WaterBackCmd");
if (bool(wm, "FROM_FILE") === true) {
return {
kind: "manual",
note: "Image watermark: Acrobat referenced an external image file that is not stored in the Action. Re-select the image in the Watermark tool.",
};
}
const parameters: Record<string, unknown> = { watermarkType: "text" };
const text = str(wm, "SRCTEXT");
if (text !== undefined) parameters.watermarkText = text;
const fontSize = num(wm, "FONT_SIZE");
if (fontSize !== undefined) parameters.fontSize = fontSize;
const rotation = num(wm, "ROTATION");
if (rotation !== undefined) parameters.rotation = rotation;
const opacity = toPercent(num(wm, "OPACITY"));
if (opacity !== undefined) parameters.opacity = opacity;
// Only DeviceRGB maps cleanly; CMYK jobs keep the tool default.
if (str(wm, "COLORSPACE") === "DeviceRGB") {
const color = toHexColor(
num(wm, "COLOR1"),
num(wm, "COLOR2"),
num(wm, "COLOR3"),
);
if (color) parameters.customColor = color;
}
const isBackground = bool(wm, "BACKGROUND") === true;
return {
kind: "tools",
steps: [{ toolId: "watermark", parameters }],
note: isBackground
? "Acrobat placed this behind the page content. Stirling's watermark always draws on top."
: undefined,
};
};
const handleScanOptimize: CommandHandler = (command) => {
const steps: MappedStep[] = [];
// Acrobat's scan-optimisation quality slider runs 1 (smallest file) to 4
// (highest quality); Stirling's level runs the other way, 1..9.
const qualityLevel = num(command.items, "QualityLevel");
const compressionLevel =
qualityLevel === undefined
? 5
: ({ 1: 8, 2: 6, 3: 4, 4: 2 }[Math.round(qualityLevel)] ?? 5);
steps.push({
toolId: "compress",
parameters: { compressionMethod: "quality", compressionLevel },
});
if (bool(command.items, "doOCR") === true) {
steps.push({ toolId: "ocr", parameters: { ocrType: "skip-text" } });
}
return {
kind: "tools",
steps,
note: "Optimise Scanned Pages mapped to compression; deskew, descreen and background removal have no Stirling equivalent.",
};
};
const handlePreflight: CommandHandler = (command) => {
const profile = str(command.items, "CALS_PREFLIGHT_CMD_PROFILE_NAME");
const label = profile ?? "unnamed profile";
const normalized = (profile ?? "").toLowerCase();
// The stock "Convert to PDF/A" and "Convert to PDF/X" profiles have direct
// equivalents; everything else is a rules engine we can't reproduce.
if (normalized.includes("pdf/a") || normalized.includes("pdfa")) {
return {
kind: "tools",
steps: [
{
toolId: "convert",
parameters: {
fromExtension: "pdf",
toExtension: "pdfa",
pdfaOptions: { outputFormat: "pdfa-2b", strict: false },
},
},
],
note: `Preflight profile "${label}" mapped to PDF/A conversion.`,
};
}
if (normalized.includes("pdf/x") || normalized.includes("pdfx")) {
return {
kind: "tools",
steps: [
{
toolId: "convert",
parameters: {
fromExtension: "pdf",
toExtension: "pdfx",
pdfxOptions: { outputFormat: "pdfx" },
},
},
],
note: `Preflight profile "${label}" mapped to PDF/X conversion.`,
};
}
return {
kind: "manual",
note: `Preflight profile "${label}" runs callas checks and fixups that are stored outside the Action file. Rebuild the equivalent checks as a compliance policy.`,
};
};
const handleJavaScript: CommandHandler = (command) => {
const scriptName = str(command.items, "ScriptName");
const code = str(command.items, "ScriptCode") ?? "";
const firstLine = code
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.length > 0 && !line.startsWith("//"));
const label = scriptName || firstLine || "unnamed script";
return {
kind: "manual",
note: `Acrobat JavaScript step (${label}). Stirling does not run Acrobat's JS API - reimplement the logic with tools or the API.`,
};
};
const manual =
(note: string): CommandHandler =>
() => ({ kind: "manual", note });
const skipped =
(note: string): CommandHandler =>
() => ({ kind: "skipped", note });
const simple =
(
toolId: ToolId,
parameters: Record<string, unknown> = {},
note?: string,
): CommandHandler =>
() => ({ kind: "tools", steps: [{ toolId, parameters }], note });
/**
* Acrobat command name to Stirling tool. Built from observed `.sequ` files -
* Adobe publishes no command reference, so unknown names fall through to the
* keyword heuristics below rather than failing the import.
*/
const COMMAND_HANDLERS: Record<string, CommandHandler> = {
WorkflowPlaybackSaveFiles: handleSaveFiles,
GeneralInfo: handleGeneralInfo,
"COMP:AddWatermark": handleAddWatermark,
"Scan:OPT": handleScanOptimize,
"CALS:Preflight": handlePreflight,
JavaScript: handleJavaScript,
DeleteAll: simple("removeAnnotations"),
"Annots:DeleteAll": simple("removeAnnotations"),
"DIGSIG:SanitizeDocument": simple(
"sanitize",
{
removeJavaScript: true,
removeEmbeddedFiles: true,
removeMetadata: true,
removeXMPMetadata: true,
removeLinks: true,
},
"Acrobat's Sanitize Document removes all hidden information; every Stirling sanitise option was enabled to match.",
),
"Cpt:CapturePages": simple(
"ocr",
{ ocrType: "skip-text" },
"OCR language is stored as an Acrobat-internal index and cannot be translated - set the language on the OCR step.",
),
SearchAndRedactCmd: simple(
"redact",
{ mode: "automatic" },
"Search terms are entered interactively in Acrobat and are not saved in the Action - add the words to redact.",
),
OpenInfo: skipped(
"Initial view settings (page layout, window options) have no Stirling equivalent.",
),
CreateAllThumbs: skipped(
"Page thumbnails are generated on demand, so embedding them is unnecessary.",
),
PagesApp: manual(
"Page operations (insert, extract, replace, crop, rotate) are configured in Acrobat's dialog and are not stored in the Action. Rebuild them with the page tools.",
),
"Adobe:MakeAccessible": manual(
"The Make Accessible action runs Acrobat's tagging wizard. Stirling has no tag-generation equivalent.",
),
"AccCheck:DoCheck": manual(
"Accessibility Full Check has no Stirling equivalent.",
),
SetTabOrder: manual(
"Tab order is set by Acrobat's tagging engine and has no Stirling equivalent.",
),
SetReadingLanguage: manual(
"Document reading language has no Stirling equivalent.",
),
};
/**
* Keyword fallbacks for command names we have not catalogued. Ordered - the
* first substring that appears in the command name wins, so more specific
* keywords must come first.
*/
const HEURISTIC_KEYWORDS: Array<[needle: string, toolId: ToolId]> = [
["watermark", "watermark"],
["background", "watermark"],
["bates", "addPageNumbers"],
["pagenumber", "addPageNumbers"],
["headerfooter", "addPageNumbers"],
["header", "addPageNumbers"],
["footer", "addPageNumbers"],
["redact", "redact"],
["sanitiz", "sanitize"],
["flatten", "flatten"],
["attach", "addAttachments"],
["metadata", "changeMetadata"],
["docinfo", "changeMetadata"],
["encrypt", "addPassword"],
["security", "addPassword"],
["password", "addPassword"],
["optimiz", "compress"],
["compress", "compress"],
["reduce", "compress"],
["ocr", "ocr"],
["recognize", "ocr"],
["recognise", "ocr"],
["rotate", "rotate"],
["crop", "crop"],
["split", "split"],
["merge", "merge"],
["combine", "merge"],
["stamp", "addStamp"],
["sign", "sign"],
];
function heuristicMatch(commandName: string): ToolId | undefined {
const normalized = commandName.toLowerCase().replace(/[^a-z]/g, "");
for (const [needle, toolId] of HEURISTIC_KEYWORDS) {
if (normalized.includes(needle)) return toolId;
}
return undefined;
}
/**
* Map one Acrobat command onto zero or more Stirling steps.
*
* Exported for the import summary, which lists every command and what became
* of it.
*/
export function mapAcrobatCommand(
command: AcrobatCommand,
): AcrobatStepMapping[] {
const handler: CommandHandler | undefined =
Object.prototype.hasOwnProperty.call(COMMAND_HANDLERS, command.name)
? COMMAND_HANDLERS[command.name]
: undefined;
const result: HandlerResult = handler
? handler(command)
: (() => {
const toolId = heuristicMatch(command.name);
if (toolId) {
return {
kind: "tools",
steps: [{ toolId, parameters: {} }],
note: `Matched "${command.name}" to the ${toolId} tool by name. Acrobat's settings for this command could not be translated - check the step's configuration.`,
} satisfies HandlerResult;
}
return {
kind: "manual",
note: `Unrecognised Acrobat command "${command.name}". No Stirling equivalent was found.`,
} satisfies HandlerResult;
})();
// Commands run with promptUser="true" have no stored settings at all -
// Acrobat asks the operator each time. Always flag those.
const interactiveNote = command.promptUser
? "This step opened a dialog in Acrobat, so its settings were never saved in the Action file."
: undefined;
const joinNotes = (note?: string) =>
[note, interactiveNote].filter(Boolean).join(" ") || undefined;
if (result.kind === "manual") {
return [
{
command: command.name,
groupLabel: command.groupLabel,
confidence: "manual",
note: joinNotes(result.note),
},
];
}
if (result.kind === "skipped") {
return [
{
command: command.name,
groupLabel: command.groupLabel,
confidence: "skipped",
note: joinNotes(result.note),
},
];
}
const confidence: AcrobatMappingConfidence = handler ? "exact" : "heuristic";
return result.steps.map((step, index) => ({
command: command.name,
groupLabel: command.groupLabel,
confidence,
toolId: step.toolId,
parameters: step.parameters,
// Attach the note to the first emitted step only, so a command that
// expands into two steps doesn't repeat itself in the summary.
note: index === 0 ? joinNotes(result.note) : undefined,
}));
}
// ---------------------------------------------------------------------------
// Import
// ---------------------------------------------------------------------------
export interface AcrobatSequenceImport {
name: string;
description: string;
operations: AutomationOperation[];
/** One entry per command in the Action, in file order. */
mappings: AcrobatStepMapping[];
/** `<Instruction>` text from the Action, preserved for the user. */
instructions: string[];
}
/**
* Parse and map a `.sequ` file into an importable automation.
*
* Mapped steps are merged over the tool's registry defaults so the resulting
* automation is runnable without opening every step.
*/
export function importAcrobatSequence(
xmlText: string,
toolRegistry: Partial<ToolRegistry>,
fileName?: string,
): AcrobatSequenceImport {
const sequence = parseAcrobatSequenceXml(xmlText);
const mappings = sequence.commands.flatMap(mapAcrobatCommand);
const operations: AutomationOperation[] = mappings
.filter((mapping) => mapping.toolId)
.map((mapping) => {
const defaults =
toolRegistry[mapping.toolId as ToolId]?.operationConfig
?.defaultParameters ?? {};
return {
operation: mapping.toolId as string,
parameters: { ...defaults, ...(mapping.parameters ?? {}) },
};
});
const name =
sequence.title.trim() ||
fileName?.replace(/\.sequ$/i, "").trim() ||
"Imported Acrobat Action";
return {
name,
description: sequence.description.trim(),
operations,
mappings,
instructions: sequence.instructions,
};
}
@@ -7,6 +7,7 @@ import { expectConsole } from "@app/tests/failOnConsole";
import {
convertToAutomationConfig,
convertToFolderScanningConfig,
detectAutomationFileFormat,
detectAutomationFormat,
parseAutomationConfigJson,
parseAutomationFile,
@@ -364,7 +365,123 @@ describe("automationConverter", () => {
test("rejects unrecognized shape", () => {
expect(() =>
parseAutomationFile(JSON.stringify({ foo: "bar" }), registry),
).toThrow(/Unrecognized JSON/);
).toThrow(/Unrecognized file/);
});
test("JSON imports carry an empty warning list", () => {
const text = JSON.stringify({
name: "x",
operations: [{ operation: "merge", parameters: {} }],
});
expect(parseAutomationFile(text, registry).warnings).toEqual([]);
});
});
describe("parseAutomationFile - Adobe migration formats", () => {
const ACROBAT_ACTION = `<?xml version="1.0" encoding="UTF-8"?>
<Workflow xmlns="http://ns.adobe.com/acrobat/workflow/2012" title="Sanitise and OCR" description="" majorVersion="1" minorVersion="0">
\t<Group label="Clean">
\t\t<Command name="DIGSIG:SanitizeDocument" pauseBefore="false" promptUser="false"/>
\t\t<Command name="JavaScript" pauseBefore="false" promptUser="false">
\t\t\t<Items>
\t\t\t\t<Item name="ScriptCode" type="text" value="this.flattenPages();"/>
\t\t\t\t<Item name="ScriptName" type="text" value="Flatten"/>
\t\t\t</Items>
\t\t</Command>
\t</Group>
</Workflow>
`;
const JOB_OPTIONS = `<<
/AutoRotatePages /None
/ColorImageResolution 150
/GrayImageResolution 150
/DownsampleColorImages true
/EmbedAllFonts true
/Optimize true
/CompatibilityLevel 1.4
>> setdistillerparams
`;
test("auto-detects an Acrobat Action", () => {
const result = parseAutomationFile(ACROBAT_ACTION, registry);
expect(result.format).toBe("acrobatSequence");
expect(result.automation.name).toBe("Sanitise and OCR");
expect(result.automation.operations.map((op) => op.operation)).toEqual([
"sanitize",
]);
});
test("reports Acrobat commands that need manual work", () => {
const result = parseAutomationFile(ACROBAT_ACTION, registry);
expect(result.unresolvedOperations).toEqual(["JavaScript"]);
expect(result.warnings.some((w) => w.startsWith("JavaScript:"))).toBe(
true,
);
});
test("exposes the per-command mapping for the import summary", () => {
const result = parseAutomationFile(ACROBAT_ACTION, registry);
if (result.format !== "acrobatSequence") throw new Error("wrong format");
expect(result.mappings.map((m) => m.confidence)).toEqual([
"exact",
"manual",
]);
});
test("auto-detects Distiller job options and names them from the file", () => {
const result = parseAutomationFile(
JOB_OPTIONS,
registry,
undefined,
"Press Quality.joboptions",
);
expect(result.format).toBe("distillerJobOptions");
expect(result.automation.name).toBe("Press Quality");
expect(result.automation.operations).toEqual([
{
operation: "compress",
parameters: {
compressionMethod: "quality",
compressionLevel: 3,
grayscale: false,
linearize: true,
},
},
]);
});
test("job options report the settings that were not carried over", () => {
const result = parseAutomationFile(JOB_OPTIONS, registry);
expect(result.unresolvedOperations).toEqual([]);
expect(
result.warnings.some((w) => w.startsWith("CompatibilityLevel:")),
).toBe(true);
});
test("rejects a file whose declared format does not match its contents", () => {
expect(() =>
parseAutomationFile(ACROBAT_ACTION, registry, "distillerJobOptions"),
).toThrow(/Acrobat Action/);
});
});
describe("detectAutomationFileFormat", () => {
test.each([
['{"operations":[]}', "automate"],
['{"pipeline":[]}', "folderScanning"],
[
'<Workflow xmlns="http://ns.adobe.com/acrobat/workflow/2012"/>',
"acrobatSequence",
],
[
"<< /CompatibilityLevel 1.4 >> setdistillerparams",
"distillerJobOptions",
],
["not a file at all", "unknown"],
["", "unknown"],
])("detects %s as %s", (text, expected) => {
expect(detectAutomationFileFormat(text)).toBe(expected);
});
});
});
@@ -10,12 +10,28 @@
* 2. **Folder Scanning JSON** — the format consumed by the backend
* PipelineDirectoryProcessor. Operation names are full backend endpoint
* paths (e.g. "/api/v1/general/merge-pdfs").
*
* Two Adobe formats are accepted for migration:
*
* 3. **Acrobat Action (`.sequ`)** - Action Wizard XML, see acrobatSequence.ts.
* 4. **Distiller job options (`.joboptions`)** - PostScript settings
* dictionary, see distillerJobOptions.ts.
*/
import { AutomationConfig, AutomationOperation } from "@app/types/automation";
import { ToolRegistry } from "@app/data/toolsTaxonomy";
import { downloadFile } from "@app/services/downloadService";
import { ToolId } from "@app/types/toolId";
import {
importAcrobatSequence,
looksLikeAcrobatSequence,
type AcrobatStepMapping,
} from "@app/utils/acrobatSequence";
import {
importJobOptions,
looksLikeJobOptions,
type JobOptionsImportNote,
} from "@app/utils/distillerJobOptions";
/**
* Pipeline configuration format used by folder scanning.
@@ -40,20 +56,43 @@ interface FolderScanningPipeline {
outputFileName: string;
}
/** Every format {@link parseAutomationFile} understands. */
export type AutomationImportFormat =
| "automate"
| "folderScanning"
| "acrobatSequence"
| "distillerJobOptions";
interface ParsedAutomationImportBase {
automation: Omit<AutomationConfig, "id" | "createdAt" | "updatedAt">;
/** Operations with no matching tool, kept verbatim in the automation. */
unresolvedOperations: string[];
/**
* Human-readable messages about anything that did not survive the import.
* Always present so callers can render them without switching on `format`.
*/
warnings: string[];
}
/**
* Discriminated result returned by {@link parseAutomationFile}.
*/
export type ParsedAutomationImport =
| {
format: "automate";
automation: Omit<AutomationConfig, "id" | "createdAt" | "updatedAt">;
unresolvedOperations: string[];
}
| {
format: "folderScanning";
automation: Omit<AutomationConfig, "id" | "createdAt" | "updatedAt">;
unresolvedOperations: string[];
};
export type ParsedAutomationImport = ParsedAutomationImportBase &
(
| { format: "automate" }
| { format: "folderScanning" }
| {
format: "acrobatSequence";
/** One entry per Acrobat command, in file order. */
mappings: AcrobatStepMapping[];
/** `<Instruction>` text from the Action. */
instructions: string[];
}
| {
format: "distillerJobOptions";
notes: JobOptionsImportNote[];
}
);
/**
* Sanitize a filename so it works on Windows / macOS / Linux.
@@ -361,6 +400,16 @@ export function parseAutomationConfigJson(
};
}
/** Display names used in format-mismatch errors and in the import UI. */
export const FORMAT_LABELS: Record<AutomationImportFormat | "unknown", string> =
{
automate: "Automate JSON",
folderScanning: "Folder Scanning JSON",
acrobatSequence: "Acrobat Action (.sequ)",
distillerJobOptions: "Distiller job options (.joboptions)",
unknown: "an unrecognised format",
};
/**
* Heuristic format detector. Folder-scanning JSON uses a `pipeline` array;
* native Automate JSON uses an `operations` array. Both is invalid.
@@ -378,15 +427,94 @@ export function detectAutomationFormat(
}
/**
* Parse a JSON file's text content into a normalized AutomationConfig.
* Detect the on-disk format from raw file text.
*
* The Adobe formats are recognised by their own markers (XML root element,
* PostScript dictionary); anything else is treated as JSON and discriminated
* by {@link detectAutomationFormat}.
*/
export function detectAutomationFileFormat(
fileText: string,
): AutomationImportFormat | "unknown" {
const trimmed = fileText.trim();
if (!trimmed) return "unknown";
if (looksLikeAcrobatSequence(trimmed)) return "acrobatSequence";
if (looksLikeJobOptions(trimmed)) return "distillerJobOptions";
try {
return detectAutomationFormat(JSON.parse(trimmed));
} catch {
return "unknown";
}
}
/**
* Turn an Acrobat command mapping list into user-facing warning lines.
* Skipped steps are intentionally omitted - they are noise, not warnings.
*/
function acrobatWarnings(mappings: AcrobatStepMapping[]): string[] {
return mappings
.filter((mapping) => mapping.note && mapping.confidence !== "skipped")
.map((mapping) => `${mapping.command}: ${mapping.note}`);
}
/**
* Parse a file's text content into a normalized AutomationConfig.
* Auto-detects the format unless `expectedFormat` is supplied; throws with a
* user-readable message on any structural problem.
*
* @param fileName Original file name, used to name imports whose format
* carries no name of its own (Distiller job options).
*/
export function parseAutomationFile(
fileText: string,
toolRegistry: Partial<ToolRegistry>,
expectedFormat?: "automate" | "folderScanning",
expectedFormat?: AutomationImportFormat,
fileName?: string,
): ParsedAutomationImport {
const detected = detectAutomationFileFormat(fileText);
const format = expectedFormat ?? detected;
if (expectedFormat && detected !== "unknown" && detected !== expectedFormat) {
throw new Error(
`Expected ${FORMAT_LABELS[expectedFormat]} but file looks like ${FORMAT_LABELS[detected]}.`,
);
}
if (format === "acrobatSequence") {
const result = importAcrobatSequence(fileText, toolRegistry, fileName);
return {
format: "acrobatSequence",
automation: {
name: result.name,
description: result.description,
operations: result.operations,
},
// Acrobat commands are mapped, not passed through, so anything without
// a tool is reported here rather than left in the operation list.
unresolvedOperations: result.mappings
.filter((mapping) => mapping.confidence === "manual")
.map((mapping) => mapping.command),
warnings: acrobatWarnings(result.mappings),
mappings: result.mappings,
instructions: result.instructions,
};
}
if (format === "distillerJobOptions") {
const result = importJobOptions(fileText, fileName);
return {
format: "distillerJobOptions",
automation: {
name: result.name,
description: result.description,
operations: result.operations,
},
unresolvedOperations: [],
warnings: result.notes.map((note) => `${note.setting}: ${note.message}`),
notes: result.notes,
};
}
let raw: unknown;
try {
raw = JSON.parse(fileText);
@@ -396,31 +524,22 @@ export function parseAutomationFile(
});
}
const detected = detectAutomationFormat(raw);
const format = expectedFormat ?? detected;
if (expectedFormat && detected !== "unknown" && detected !== expectedFormat) {
throw new Error(
`Expected ${
expectedFormat === "automate"
? "Automate JSON (operations array)"
: "Folder Scanning JSON (pipeline array)"
} but file looks like ${
detected === "automate" ? "Automate JSON" : "Folder Scanning JSON"
}.`,
);
}
if (format === "automate") {
const result = parseAutomationConfigJson(raw, toolRegistry);
return { format: "automate", ...result };
return {
format: "automate",
warnings: [],
...parseAutomationConfigJson(raw, toolRegistry),
};
}
if (format === "folderScanning") {
const result = parseFolderScanningConfig(raw, toolRegistry);
return { format: "folderScanning", ...result };
return {
format: "folderScanning",
warnings: [],
...parseFolderScanningConfig(raw, toolRegistry),
};
}
throw new Error(
"Unrecognized JSON shape. Expected an Automate config (operations[]) or a Folder Scanning config (pipeline[]).",
"Unrecognized file. Expected an Automate config (operations[]), a Folder Scanning config (pipeline[]), an Acrobat Action (.sequ) or Distiller job options (.joboptions).",
);
}
@@ -0,0 +1,329 @@
/**
* Unit tests for the Adobe Distiller .joboptions importer.
*
* The fixtures below are verbatim excerpts of real Distiller profiles, so the
* parser is exercised against Adobe's actual output shape (nested image
* dictionaries, arrays, parenthesised description strings and the trailing
* `setpagedevice` block) rather than a tidied-up approximation.
*/
import { describe, test, expect } from "vitest";
import {
compressionLevelForResolution,
importJobOptions,
jobOptionsToOperations,
looksLikeJobOptions,
parseJobOptions,
readDistillerSettings,
} from "@app/utils/distillerJobOptions";
import { PsName } from "@app/utils/postscriptObjects";
/** Excerpt of a real print-quality profile (300 dpi, no colour conversion). */
const PRINT_PROFILE = `<<
/ASCII85EncodePages false
/AllowPSXObjects false
/AlwaysEmbed [
true
]
/AutoRotatePages /None
/Binding /Left
/CalGrayProfile (Dot Gain 20%)
/CheckCompliance [
/None
]
/ColorACSImageDict <<
/HSamples [
1
1
1
1
]
/QFactor 0.15000
>>
/ColorConversionStrategy /LeaveColorUnchanged
/ColorImageResolution 300
/CompatibilityLevel 1.3
/Description <<
/ENU ([Based on 'Lulu'] Use these settings to create Adobe PDF documents best suited for Lulu's printing.)
>>
/DownsampleColorImages true
/DownsampleGrayImages true
/DownsampleMonoImages true
/EmbedAllFonts true
/GrayImageResolution 300
/MonoImageResolution 1200
/Optimize true
/SubsetFonts true
/sRGBProfile (sRGB IEC61966-2.1)
>> setdistillerparams
<<
/HWResolution [1200 1200]
/PageSize [612.000 792.000]
>> setpagedevice
`;
/** Excerpt of a smallest-file-size style profile. */
const SCREEN_PROFILE = `%!
<<
/AutoRotatePages /All
/ColorConversionStrategy /Gray
/ColorImageResolution 72
/GrayImageResolution 72
/DownsampleColorImages true
/DownsampleGrayImages true
/EmbedAllFonts false
/Optimize false
/CompatibilityLevel 1.5
>> setdistillerparams
`;
const PDFX_PROFILE = `<<
/CompatibilityLevel 1.3
/ColorImageResolution 300
/DownsampleColorImages true
/PDFX1aCheck true
/PDFXOutputIntentProfile (U.S. Web Coated \\(SWOP\\) v2)
>> setdistillerparams
`;
describe("distillerJobOptions", () => {
describe("parseJobOptions", () => {
test("reads scalars, names, arrays and nested dictionaries", () => {
const dict = parseJobOptions(PRINT_PROFILE);
expect(dict.ASCII85EncodePages).toBe(false);
expect(dict.ColorImageResolution).toBe(300);
expect(dict.CompatibilityLevel).toBe(1.3);
expect(dict.AutoRotatePages).toBeInstanceOf(PsName);
expect(String(dict.AutoRotatePages)).toBe("None");
expect(dict.AlwaysEmbed).toEqual([true]);
expect(dict.CheckCompliance).toHaveLength(1);
expect(String((dict.CheckCompliance as unknown[])[0])).toBe("None");
});
test("keeps parenthesised strings intact, including brackets and quotes", () => {
const dict = parseJobOptions(PRINT_PROFILE);
const description = dict.Description as Record<string, unknown>;
expect(description.ENU).toContain("[Based on 'Lulu']");
expect(description.ENU).toContain("Lulu's printing.");
expect(dict.CalGrayProfile).toBe("Dot Gain 20%");
});
test("decodes escaped parentheses in strings", () => {
const dict = parseJobOptions(PDFX_PROFILE);
expect(dict.PDFXOutputIntentProfile).toBe("U.S. Web Coated (SWOP) v2");
});
test("nested QFactor is not confused with a top-level key", () => {
const dict = parseJobOptions(PRINT_PROFILE);
expect(dict.QFactor).toBeUndefined();
expect(
(dict.ColorACSImageDict as Record<string, unknown>).QFactor,
).toBeCloseTo(0.15);
});
test("merges the setpagedevice dictionary alongside setdistillerparams", () => {
const dict = parseJobOptions(PRINT_PROFILE);
expect(dict.PageSize).toEqual([612, 792]);
});
});
describe("looksLikeJobOptions", () => {
test("accepts real profiles", () => {
expect(looksLikeJobOptions(PRINT_PROFILE)).toBe(true);
expect(looksLikeJobOptions(SCREEN_PROFILE)).toBe(true);
});
test("rejects JSON and XML", () => {
expect(looksLikeJobOptions('{"operations": []}')).toBe(false);
expect(looksLikeJobOptions("<Workflow title='x'/>")).toBe(false);
});
});
describe("readDistillerSettings", () => {
test("normalises names and booleans", () => {
const settings = readDistillerSettings(parseJobOptions(PRINT_PROFILE));
expect(settings).toMatchObject({
compatibilityLevel: 1.3,
colorImageResolution: 300,
grayImageResolution: 300,
monoImageResolution: 1200,
downsampleColorImages: true,
embedAllFonts: true,
optimize: true,
colorConversionStrategy: "LeaveColorUnchanged",
autoRotatePages: "None",
});
expect(settings.standard).toBeUndefined();
});
test("detects the PDF/X-1a check", () => {
const settings = readDistillerSettings(parseJobOptions(PDFX_PROFILE));
expect(settings.pdfxCheck).toBe(true);
expect(settings.standard).toBe("PDF/X-1a");
});
test("detects the PDF/A-1b check", () => {
const settings = readDistillerSettings(
parseJobOptions(
"<< /PDFA1bCheck true /ColorImageResolution 150 >> setdistillerparams",
),
);
expect(settings.standard).toBe("PDF/A-1b");
});
});
describe("compressionLevelForResolution", () => {
test.each([
[300, 1],
[250, 2],
[150, 3],
[120, 6],
[100, 7],
[72, 8],
[50, 9],
])("%i dpi maps to level %i", (dpi, expected) => {
expect(
compressionLevelForResolution({
colorImageResolution: dpi,
grayImageResolution: dpi,
downsampleColorImages: true,
downsampleGrayImages: true,
}),
).toBe(expected);
});
test("a profile that never downsamples is the lightest level", () => {
expect(
compressionLevelForResolution({
colorImageResolution: 72,
downsampleColorImages: false,
downsampleGrayImages: false,
}),
).toBe(1);
});
test("falls back to a middling level when no resolution is declared", () => {
expect(compressionLevelForResolution({})).toBe(3);
});
test("uses the lower of the colour and greyscale resolutions", () => {
expect(
compressionLevelForResolution({
colorImageResolution: 300,
grayImageResolution: 72,
downsampleColorImages: true,
}),
).toBe(8);
});
});
describe("jobOptionsToOperations", () => {
test("a print profile becomes a single light compress step", () => {
const { operations } = jobOptionsToOperations(
readDistillerSettings(parseJobOptions(PRINT_PROFILE)),
);
expect(operations).toEqual([
{
operation: "compress",
parameters: {
compressionMethod: "quality",
compressionLevel: 1,
grayscale: false,
linearize: true,
},
},
]);
});
test("AutoRotatePages other than None adds an autoRotate step first", () => {
const { operations } = jobOptionsToOperations(
readDistillerSettings(parseJobOptions(SCREEN_PROFILE)),
);
expect(operations[0]).toEqual({
operation: "autoRotate",
parameters: {},
});
expect(operations[1]).toMatchObject({
operation: "compress",
parameters: { compressionLevel: 8, grayscale: true, linearize: false },
});
});
test("a PDF/X profile appends a convert step and warns about the output intent", () => {
const { operations, notes } = jobOptionsToOperations(
readDistillerSettings(parseJobOptions(PDFX_PROFILE)),
);
expect(operations.at(-1)).toEqual({
operation: "convert",
parameters: {
fromExtension: "pdf",
toExtension: "pdfx",
pdfxOptions: { outputFormat: "pdfx" },
},
});
expect(
notes.some((note) => note.setting === "PDFXOutputIntentProfile"),
).toBe(true);
});
test("a PDF/A profile appends a PDF/A conversion", () => {
const { operations } = jobOptionsToOperations(
readDistillerSettings(
parseJobOptions(
"<< /PDFA1bCheck true /ColorImageResolution 150 >> setdistillerparams",
),
),
);
expect(operations.at(-1)).toEqual({
operation: "convert",
parameters: {
fromExtension: "pdf",
toExtension: "pdfa",
pdfaOptions: { outputFormat: "pdfa-1b", strict: false },
},
});
});
test("notes the settings that have no equivalent", () => {
const { notes } = jobOptionsToOperations(
readDistillerSettings(parseJobOptions(SCREEN_PROFILE)),
);
const settings = notes.map((note) => note.setting);
expect(settings).toContain("EmbedAllFonts");
expect(settings).toContain("CompatibilityLevel");
});
test("LeaveColorUnchanged is not reported as a lost colour conversion", () => {
const { notes } = jobOptionsToOperations(
readDistillerSettings(parseJobOptions(PRINT_PROFILE)),
);
expect(
notes.some((note) => note.setting === "ColorConversionStrategy"),
).toBe(false);
});
});
describe("importJobOptions", () => {
test("names the automation after the file", () => {
const result = importJobOptions(
PRINT_PROFILE,
"Lulu Press Quality.joboptions",
);
expect(result.name).toBe("Lulu Press Quality");
expect(result.description).toContain("compression level 1");
});
test("falls back to a generic name without a file name", () => {
expect(importJobOptions(PRINT_PROFILE).name).toBe(
"Imported Distiller settings",
);
});
test("rejects a file with no settings dictionary", () => {
expect(() => importJobOptions("%!PS\nshowpage\n")).toThrow(
/No Distiller settings dictionary/,
);
});
});
});
@@ -0,0 +1,292 @@
/**
* Adobe Distiller `.joboptions` importer.
*
* A .joboptions file is a PostScript fragment whose payload is a single
* dictionary passed to `setdistillerparams`:
*
* <<
* /CompatibilityLevel 1.4
* /DownsampleColorImages true
* /ColorImageResolution 150
* /ColorImageDownsampleType /Bicubic
* /EmbedAllFonts true
* /NeverEmbed [ /Courier /Symbol ]
* >> setdistillerparams
*
* Real files wrap this in `%!`/`%%` comments, `currentdistillerparams`
* lookups and `setpagedevice` blocks, so the parser scans the whole text for
* dictionaries rather than assuming a fixed layout.
*
* The settings are then translated into Automate steps - a `compress` step
* plus, when the profile declares a standards check, a `convert` step to
* PDF/A or PDF/X.
*/
import { AutomationOperation } from "@app/types/automation";
import {
collectTopLevelDicts,
psBoolean,
psName,
psNumber,
type PsDict,
} from "@app/utils/postscriptObjects";
/**
* Extract every top-level dictionary in the file and merge them, later keys
* winning. Distiller profiles put nearly everything in one `setdistillerparams`
* dictionary but may add a second for `setpagedevice`.
*/
export function parseJobOptions(text: string): PsDict {
return Object.assign({}, ...collectTopLevelDicts(text)) as PsDict;
}
/** True when the text looks like a Distiller job options file. */
export function looksLikeJobOptions(text: string): boolean {
const head = text.slice(0, 4096);
if (!head.includes("<<")) return false;
return (
/setdistillerparams|setpagedevice|currentdistillerparams/.test(text) ||
/\/CompatibilityLevel|\/AutoRotatePages|\/EmbedAllFonts/.test(head)
);
}
/**
* The subset of Distiller settings that has a Stirling equivalent, normalised
* away from PostScript types.
*/
export interface DistillerSettings {
compatibilityLevel?: number;
/** Effective colour-image DPI, i.e. only set when downsampling is on. */
colorImageResolution?: number;
grayImageResolution?: number;
monoImageResolution?: number;
downsampleColorImages?: boolean;
downsampleGrayImages?: boolean;
downsampleMonoImages?: boolean;
embedAllFonts?: boolean;
subsetFonts?: boolean;
optimize?: boolean;
/** `/ColorConversionStrategy`, e.g. "Gray", "sRGB", "LeaveColorUnchanged". */
colorConversionStrategy?: string;
/** `/AutoRotatePages`, e.g. "None", "All", "PageByPage". */
autoRotatePages?: string;
pdfaCheck?: boolean;
pdfxCheck?: boolean;
/** Standards label derived from the *Check keys, e.g. "PDF/X-1a". */
standard?: "PDF/A-1b" | "PDF/X-1a" | "PDF/X-3";
}
export function readDistillerSettings(dict: PsDict): DistillerSettings {
const settings: DistillerSettings = {
compatibilityLevel: psNumber(dict.CompatibilityLevel),
colorImageResolution: psNumber(dict.ColorImageResolution),
grayImageResolution: psNumber(dict.GrayImageResolution),
monoImageResolution: psNumber(dict.MonoImageResolution),
downsampleColorImages: psBoolean(dict.DownsampleColorImages),
downsampleGrayImages: psBoolean(dict.DownsampleGrayImages),
downsampleMonoImages: psBoolean(dict.DownsampleMonoImages),
embedAllFonts: psBoolean(dict.EmbedAllFonts),
subsetFonts: psBoolean(dict.SubsetFonts),
optimize: psBoolean(dict.Optimize),
colorConversionStrategy: psName(dict.ColorConversionStrategy),
autoRotatePages: psName(dict.AutoRotatePages),
};
// PDF/A-1b and the PDF/X flavours each have their own boolean check key.
if (psBoolean(dict.PDFA1bCheck) || psBoolean(dict.PDFACompliance)) {
settings.pdfaCheck = true;
settings.standard = "PDF/A-1b";
} else if (psBoolean(dict.PDFX1aCheck)) {
settings.pdfxCheck = true;
settings.standard = "PDF/X-1a";
} else if (psBoolean(dict.PDFX3Check)) {
settings.pdfxCheck = true;
settings.standard = "PDF/X-3";
}
return settings;
}
// ---------------------------------------------------------------------------
// Mapping to Automate steps
// ---------------------------------------------------------------------------
/**
* Pick a Stirling compression level from the profile's image resolutions.
*
* Stirling's levels are implemented as Ghostscript `-dPDFSETTINGS` presets
* (see CompressController#applyGhostscriptCompression), which are the same
* presets Distiller's stock profiles are built on - so the two line up by
* construction:
*
* 1 → /prepress 2 → /printer 3 → /ebook
* 4-5 → /screen 6-7 → /screen @150dpi 8 → @100dpi 9 → @72dpi
*
* Resolution is the signal because it's the one setting every profile
* carries. Profiles that disable downsampling entirely keep their images at
* full size, which is the /prepress end of the scale.
*/
export function compressionLevelForResolution(
settings: DistillerSettings,
): number {
const downsampling =
settings.downsampleColorImages !== false ||
settings.downsampleGrayImages !== false;
if (!downsampling) return 1;
const dpi = Math.min(
settings.colorImageResolution ?? Number.POSITIVE_INFINITY,
settings.grayImageResolution ?? Number.POSITIVE_INFINITY,
);
if (!Number.isFinite(dpi)) return 3;
if (dpi >= 300) return 1;
if (dpi >= 200) return 2;
if (dpi >= 150) return 3;
if (dpi >= 110) return 6;
if (dpi >= 90) return 7;
if (dpi >= 72) return 8;
return 9;
}
export interface JobOptionsImportNote {
/** The Distiller setting this note is about, e.g. "EmbedAllFonts". */
setting: string;
message: string;
}
export interface JobOptionsImport {
name: string;
description: string;
operations: AutomationOperation[];
settings: DistillerSettings;
/** Settings that could not be carried over, for display after import. */
notes: JobOptionsImportNote[];
}
/**
* Derive a display name from the file name, since a .joboptions file carries
* no name of its own.
*/
const jobOptionsName = (fileName?: string): string => {
if (!fileName) return "Imported Distiller settings";
return (
fileName.replace(/\.joboptions$/i, "").trim() ||
"Imported Distiller settings"
);
};
/**
* Convert parsed Distiller settings into Automate operations.
*
* Produces a `compress` step, optionally preceded by `autoRotate` and
* followed by a `convert` step when the profile enforces a PDF standard.
*/
export function jobOptionsToOperations(settings: DistillerSettings): {
operations: AutomationOperation[];
notes: JobOptionsImportNote[];
} {
const operations: AutomationOperation[] = [];
const notes: JobOptionsImportNote[] = [];
// Distiller rotates during conversion; Stirling does it as its own step.
if (settings.autoRotatePages && settings.autoRotatePages !== "None") {
operations.push({ operation: "autoRotate", parameters: {} });
}
const grayscale =
settings.colorConversionStrategy === "Gray" ||
settings.colorConversionStrategy === "DeviceGray";
operations.push({
operation: "compress",
parameters: {
compressionMethod: "quality",
compressionLevel: compressionLevelForResolution(settings),
grayscale,
// Distiller's "Optimize for fast web view" is linearisation.
linearize: settings.optimize === true,
},
});
if (settings.standard === "PDF/A-1b") {
operations.push({
operation: "convert",
parameters: {
fromExtension: "pdf",
toExtension: "pdfa",
pdfaOptions: { outputFormat: "pdfa-1b", strict: false },
},
});
} else if (settings.pdfxCheck) {
operations.push({
operation: "convert",
parameters: {
fromExtension: "pdf",
toExtension: "pdfx",
pdfxOptions: { outputFormat: "pdfx" },
},
});
notes.push({
setting: "PDFXOutputIntentProfile",
message: `${settings.standard} was requested. Stirling converts to PDF/X but does not apply the profile's output intent - set that in the conversion step if you need it.`,
});
}
// Font embedding and colour management are properties of PDF generation,
// not of an existing PDF, so there is nothing to map them onto.
if (settings.embedAllFonts === false) {
notes.push({
setting: "EmbedAllFonts",
message:
"This profile does not embed all fonts. Stirling never strips fonts during compression, so the setting has no equivalent.",
});
}
if (
settings.colorConversionStrategy &&
!grayscale &&
settings.colorConversionStrategy !== "LeaveColorUnchanged"
) {
notes.push({
setting: "ColorConversionStrategy",
message: `Colour conversion to ${settings.colorConversionStrategy} is not carried over; only conversion to greyscale has an equivalent.`,
});
}
if (settings.compatibilityLevel !== undefined) {
notes.push({
setting: "CompatibilityLevel",
message: `Target PDF version ${settings.compatibilityLevel.toFixed(1)} is not enforced - Stirling preserves the input document's version.`,
});
}
return { operations, notes };
}
/**
* Full pipeline: raw .joboptions text to an importable automation.
*
* @param fileName Used for the automation name, since the format carries none.
*/
export function importJobOptions(
text: string,
fileName?: string,
): JobOptionsImport {
const dict = parseJobOptions(text);
if (Object.keys(dict).length === 0) {
throw new Error(
"No Distiller settings dictionary found. Expected a PostScript '<< ... >> setdistillerparams' block.",
);
}
const settings = readDistillerSettings(dict);
const { operations, notes } = jobOptionsToOperations(settings);
const name = jobOptionsName(fileName);
const standardSuffix = settings.standard ? `, ${settings.standard}` : "";
return {
name,
description: `Imported from Adobe Distiller job options (compression level ${compressionLevelForResolution(settings)}${standardSuffix}).`,
operations,
settings,
notes,
};
}
@@ -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;