mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a3822abab | ||
|
|
c7db2e51d7 | ||
|
|
f64ae7bcdc |
@@ -2069,6 +2069,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"
|
||||
@@ -2119,21 +2121,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"
|
||||
|
||||
@@ -2069,6 +2069,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"
|
||||
@@ -2119,21 +2121,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
var oDoc = event.target;
"/>
|
||||
<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;
|
||||
Reference in New Issue
Block a user