Pdf ua converter testing (#7301)

# Description of Changes

Adds a PDF/UA converter, an accessibility report, and PDF/A conformance
level A.

**New: `POST /api/v1/convert/pdf/ua`** (Convert tool, "PDF/UA" target).
Tags an untagged PDF, marks
decorative content as artifacts, embeds missing fonts and applies the
document-level PDF/UA
requirements (title, language, tab order, form-field descriptions), then
validates with veraPDF. The
`pdfuaid` declaration is written only if validation passes, so a
returned file never claims more
than it delivers; response headers report whether it was declared, how
many checks still fail and
how many images still need a description.

**New: `POST /api/v1/security/accessibility-report`.** Reports what
fails, what the converter can fix
on its own, what needs a person, and lists the figures needing a
description with the keys the
conversion accepts back. Read-only; does not modify the file. Capped at
100 MB / 2000 pages and
weighted `LARGE_WEIGHT`, since it runs a full veraPDF pass plus the
converter's layout analysis over
every page.

**PDF/A level A.** `pdfa-1a`, `pdfa-2a` and `pdfa-3a` output formats on
the existing
`/api/v1/convert/pdf/pdfa` endpoint. Level A is level B plus tagging, so
the document is tagged
after Ghostscript (which discards any structure tree it is given) and
the level A claim is written
only if veraPDF agrees. Optional `pdfUa=true` additionally declares
PDF/UA alongside PDF/A, again
only if it validates.

Honesty rules the implementation holds to:

- **Never claim a level that was not reached.** If tagging fails, the
file is returned at level B and
is named `_PDFA-2b.pdf`, not `_PDFA-2a.pdf`. With `strict=true` the
request fails outright rather
than returning a level B file against a level A request, and a level B
pass no longer satisfies a
  strict level A request.
- **Never relabel a document's language.** The requested language
(default `en-GB`) is applied only
when the document declares none; a French PDF stays French unless the
caller sets
`overrideLanguage`, and ignoring a requested language is reported as a
warning.
- **Never invent alternative text.** Descriptions come from the caller.
The Convert panel can list
the images needing one (via the report endpoint) and send them back per
figure; any image left
undescribed blocks the conformance claim rather than being papered over.
- **Never certify hidden content.** Marking images decorative, or
suppressing text that could not be
tagged reliably, withdraws the claim instead of passing the checker by
hiding content.

PDF/UA-1 and PDF/UA-2 are both offered; UA-2 raises the file to PDF 2.0
and namespaces the structure
tree, and its test asserts conformance rather than merely reporting it.

Convert steps saved in Automations/Pipelines round-trip their PDF/UA
settings (profile, language,
override, title, font embedding, descriptions).

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
This commit is contained in:
Anthony Stirling
2026-08-20 12:00:03 +00:00
committed by GitHub
parent 50d34fcca5
commit 96a00cebd1
80 changed files with 10373 additions and 68 deletions
@@ -3402,6 +3402,24 @@ pdfOptions = "PDF Options"
pdfToCbr = "PDF → CBR"
pdfToCbz = "PDF → CBZ"
pdfToEpub = "PDF → EPUB"
pdfUaAltTextNotice = "Images need a written description before a document can be certified. Descriptions are never generated automatically, because an invented one passes the checker while telling a screen-reader user nothing. Any image left without one is reported, and the file comes back tagged but not certified."
pdfUaAltTextScanFailed = "The images could not be listed. Convert anyway and the response reports what is missing."
pdfUaAltTextSingleFileOnly = "Descriptions belong to one document: an image is identified by its position, which is a different image in every file. Convert these {{fileCount}} files to tag them, then convert one at a time to describe its images."
pdfUaEmbedFonts = "Embed missing fonts"
pdfUaEmbedFontsHelp = "PDF/UA requires every font to be embedded. Turning this off is faster but usually prevents conformance."
pdfUaFigureLabel = "Page {{page}} {{kind}}"
pdfUaFigurePlaceholder = "What this image tells the reader"
pdfUaFindImages = "Find images needing a description"
pdfUaLanguage = "Document language"
pdfUaLanguageHelp = "A BCP-47 tag such as en-GB. Used only when the document does not already declare its own language."
pdfUaNoImagesNeedingText = "No image is missing a description."
pdfUaOptions = "PDF/UA Options"
pdfUaOverrideLanguage = "Replace the document's own language"
pdfUaOverrideLanguageHelp = "Only tick this if the language above is right and the document's own is wrong. Relabelling a document into a language it is not written in makes a screen reader unintelligible."
pdfUaProfile = "Conformance level"
pdfUaSignatureWarning = "This PDF is digitally signed. Tagging rewrites the page content the signature covers, so the signature will stop verifying. Convert first, then re-sign."
pdfUaTitle = "Document title"
pdfUaTitleHelp = "Shown by a reader instead of the filename. Left blank, the first heading is used."
selectSourceFormatFirst = "Choose a source format first"
settings = "Settings"
single = "Single"
@@ -6053,6 +6071,11 @@ header = "PDF To PDF/A"
tags = "archive,long-term,standard,conversion,storage,preservation"
title = "PDF To PDF/A"
[pdfToPDFUA]
header = "PDF To PDF/UA"
tags = "accessibility,accessible,tagged,screen reader,wcag,eaa,section 508,conversion"
title = "PDF To PDF/UA"
[pdfToPDFX]
tags = "print,standard,conversion,production,prepress,archive"
title = "PDF To PDF/X"
@@ -20,6 +20,7 @@ import ConvertFromEmailSettings from "@app/components/tools/convert/ConvertFromE
import ConvertFromCbzSettings from "@app/components/tools/convert/ConvertFromCbzSettings";
import ConvertToCbzSettings from "@app/components/tools/convert/ConvertToCbzSettings";
import ConvertToPdfaSettings from "@app/components/tools/convert/ConvertToPdfaSettings";
import ConvertToPdfUaSettings from "@app/components/tools/convert/ConvertToPdfUaSettings";
import ConvertToPdfxSettings from "@app/components/tools/convert/ConvertToPdfxSettings";
import ConvertFromCbrSettings from "@app/components/tools/convert/ConvertFromCbrSettings";
import ConvertToCbrSettings from "@app/components/tools/convert/ConvertToCbrSettings";
@@ -456,6 +457,20 @@ const ConvertSettings = ({
</>
)}
{/* PDF to PDF/UA options */}
{parameters.fromExtension === "pdf" &&
parameters.toExtension === "pdfua" && (
<>
<Divider />
<ConvertToPdfUaSettings
parameters={parameters}
onParameterChange={onParameterChange}
selectedFiles={selectedFiles}
disabled={disabled}
/>
</>
)}
{/* PDF to PDF/X options */}
{parameters.fromExtension === "pdf" &&
parameters.toExtension === "pdfx" && (
@@ -0,0 +1,149 @@
/**
* Which document the PDF/UA descriptions belong to.
*
* A description is keyed by an image's position inside one file, so it is only meaningful for the
* file it was written against. The panel therefore offers the description fields for a single
* selection only, and forgets what was typed as soon as the selection changes.
*/
import { beforeEach, describe, expect, test, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MantineProvider } from "@mantine/core";
import ConvertToPdfUaSettings from "@app/components/tools/convert/ConvertToPdfUaSettings";
import { defaultParameters } from "@app/hooks/tools/convert/useConvertParameters";
import type { ConvertParameters } from "@app/hooks/tools/convert/useConvertParameters";
import type { StirlingFile } from "@app/types/fileContext";
// Render the English fallbacks (the test i18n instance has no loaded locale).
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, fallback?: unknown, options?: Record<string, unknown>) => {
const text = typeof fallback === "string" ? fallback : key;
return options
? text.replace(/\{\{(\w+)\}\}/g, (_, name) => String(options[name]))
: text;
},
}),
}));
const api = vi.hoisted(() => ({ post: vi.fn() }));
vi.mock("@app/services/apiClient", () => ({ default: { post: api.post } }));
// The real hook parses the PDF in a worker, which is not what this file is about.
vi.mock("@app/hooks/usePdfSignatureDetection", () => ({
usePdfSignatureDetection: () => ({
hasDigitalSignatures: false,
isChecking: false,
}),
}));
const file = (name: string, content = "%PDF-1.7") =>
new File([content], name, { type: "application/pdf" }) as StirlingFile;
const parametersWith = (altText: string): ConvertParameters => ({
...defaultParameters,
fromExtension: "pdf",
toExtension: "pdfua",
pdfUaOptions: { ...defaultParameters.pdfUaOptions, altText },
});
function renderPanel(selectedFiles: StirlingFile[], altText = "") {
const onParameterChange = vi.fn();
const view = render(
<MantineProvider>
<ConvertToPdfUaSettings
parameters={parametersWith(altText)}
onParameterChange={onParameterChange}
selectedFiles={selectedFiles}
/>
</MantineProvider>,
);
const rerenderWith = (files: StirlingFile[], text = altText) =>
view.rerender(
<MantineProvider>
<ConvertToPdfUaSettings
parameters={parametersWith(text)}
onParameterChange={onParameterChange}
selectedFiles={files}
/>
</MantineProvider>,
);
return { onParameterChange, rerenderWith };
}
beforeEach(() => {
vi.clearAllMocks();
api.post.mockResolvedValue({
data: {
figuresNeedingDescription: [{ key: "0:1", page: 1, kind: "image" }],
},
});
});
describe("PDF/UA descriptions are scoped to one document", () => {
test("one file: the images can be listed and described", async () => {
const { onParameterChange } = renderPanel([file("report.pdf")]);
await userEvent.click(screen.getByTestId("pdfua-find-figures"));
const field = await screen.findByTestId("pdfua-alt-text-0:1");
await userEvent.type(field, "B");
expect(onParameterChange).toHaveBeenCalledWith(
"pdfUaOptions",
expect.objectContaining({ altText: "0:1=B" }),
);
});
test("several files: no description fields, and a reason why", () => {
renderPanel([file("report.pdf"), file("appendix.pdf")]);
expect(
screen.getByTestId("pdfua-alt-text-single-file-only"),
).toHaveTextContent(
/Convert these 2 files to tag them, then convert one at a time/,
);
expect(screen.queryByTestId("pdfua-find-figures")).toBeNull();
});
test("several files: descriptions already typed are dropped, not carried over", async () => {
const { onParameterChange, rerenderWith } = renderPanel(
[file("report.pdf")],
"0:1=Bar chart of revenue",
);
rerenderWith([file("report.pdf"), file("appendix.pdf")]);
await waitFor(() =>
expect(onParameterChange).toHaveBeenCalledWith(
"pdfUaOptions",
expect.objectContaining({ altText: "" }),
),
);
});
test("swapping the single file clears the descriptions written for the old one", async () => {
const { onParameterChange, rerenderWith } = renderPanel(
[file("report.pdf")],
"0:1=Bar chart of revenue",
);
rerenderWith([file("other.pdf")]);
await waitFor(() =>
expect(onParameterChange).toHaveBeenCalledWith(
"pdfUaOptions",
expect.objectContaining({ altText: "" }),
),
);
});
test("mounting with stored descriptions keeps them, so an automation step survives editing", () => {
const { onParameterChange } = renderPanel(
[file("report.pdf")],
"0:1=Bar chart of revenue",
);
expect(onParameterChange).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,34 @@
import { describe, expect, test } from "vitest";
import {
formatAltText,
parseAltText,
} from "@app/components/tools/convert/ConvertToPdfUaSettings";
describe("PDF/UA alt-text wire format", () => {
test("reads the key=description lines the report's keys produce", () => {
expect(parseAltText("0:12=Bar chart\n1:3=Company logo")).toEqual({
"0:12": "Bar chart",
"1:3": "Company logo",
});
});
test("keeps a description containing an equals sign whole", () => {
expect(parseAltText("2:7=Flow: approval = sign-off")).toEqual({
"2:7": "Flow: approval = sign-off",
});
});
test("skips blank and malformed lines rather than inventing keys", () => {
expect(parseAltText("\nnot-a-pair\n3:1= \n")).toEqual({});
});
test("round-trips a half-typed description, spaces and all", () => {
// Trimming here would eat the space the moment it is typed, blocking the next word.
const typed = { "0:1": "Bar chart " };
expect(parseAltText(formatAltText(typed))).toEqual(typed);
});
test("drops a description the user cleared", () => {
expect(formatAltText({ "0:1": "Kept", "0:2": " " })).toBe("0:1=Kept");
});
});
@@ -0,0 +1,280 @@
import { useEffect, useRef, useState } from "react";
import { Stack, Text, Select, Alert, Checkbox, TextInput } from "@mantine/core";
import { useTranslation } from "react-i18next";
import apiClient from "@app/services/apiClient";
import { Button } from "@app/ui/Button";
import { ConvertParameters } from "@app/hooks/tools/convert/useConvertParameters";
import { usePdfSignatureDetection } from "@app/hooks/usePdfSignatureDetection";
import { StirlingFile } from "@app/types/fileContext";
import { Z_INDEX_AUTOMATE_DROPDOWN } from "@app/styles/zIndex";
interface ConvertToPdfUaSettingsProps {
parameters: ConvertParameters;
onParameterChange: <K extends keyof ConvertParameters>(
key: K,
value: ConvertParameters[K],
) => void;
selectedFiles: StirlingFile[];
disabled?: boolean;
}
/** One image the backend says has no description yet, keyed as the conversion expects it back. */
interface FigureNeedingDescription {
key: string;
page: number;
kind: string;
}
/**
* The wire form the endpoint parses: one `pageIndex:ordinal=description` per line. Descriptions are
* kept verbatim so that typing a space does not fight the field; the backend trims them.
*/
export const parseAltText = (raw: string): Record<string, string> => {
const parsed: Record<string, string> = {};
raw.split(/\r?\n/).forEach((line) => {
const split = line.indexOf("=");
if (split <= 0) return;
const key = line.slice(0, split).trim();
const description = line.slice(split + 1);
if (key && description.trim()) parsed[key] = description;
});
return parsed;
};
export const formatAltText = (descriptions: Record<string, string>): string =>
Object.entries(descriptions)
.filter(([, description]) => description.trim())
.map(([key, description]) => `${key}=${description}`)
.join("\n");
/** PDF/UA conversion options; copy is deliberate - conformance is not guaranteed by one click. */
const ConvertToPdfUaSettings = ({
parameters,
onParameterChange,
selectedFiles,
disabled = false,
}: ConvertToPdfUaSettingsProps) => {
const { t } = useTranslation();
const { hasDigitalSignatures } = usePdfSignatureDetection(selectedFiles);
const [figures, setFigures] = useState<FigureNeedingDescription[] | null>(
null,
);
const [isScanning, setIsScanning] = useState(false);
const [scanError, setScanError] = useState<string | null>(null);
const profileOptions = [
{ value: "ua1", label: "PDF/UA-1" },
{ value: "ua2", label: "PDF/UA-2 (PDF 2.0)" },
];
const update = (patch: Partial<ConvertParameters["pdfUaOptions"]>) =>
onParameterChange("pdfUaOptions", { ...parameters.pdfUaOptions, ...patch });
const descriptions = parseAltText(parameters.pdfUaOptions.altText);
// A key is a position inside one document, so descriptions only mean anything for one file.
const scannableFile = selectedFiles.length === 1 ? selectedFiles[0] : null;
const tooManyFiles = selectedFiles.length > 1;
const fileKey = selectedFiles
.map((file) => `${file.name}:${file.size}`)
.join("|");
const describedFileKey = useRef(fileKey);
// The same key names a different image in the next document, so descriptions must not outlive the
// selection. Mount is skipped so a stored automation step keeps the text it was saved with.
useEffect(() => {
if (describedFileKey.current === fileKey) return;
describedFileKey.current = fileKey;
setFigures(null);
if (parameters.pdfUaOptions.altText) update({ altText: "" });
}, [fileKey]);
// The keys are opaque, so they have to come from the backend's own analysis of this file.
const findFigures = async () => {
const file = scannableFile;
if (!file) return;
setIsScanning(true);
setScanError(null);
try {
const formData = new FormData();
formData.append("fileInput", file);
formData.append("profile", parameters.pdfUaOptions.profile);
const { data } = await apiClient.post<{
figuresNeedingDescription?: FigureNeedingDescription[];
}>("/api/v1/security/accessibility-report", formData);
setFigures(data.figuresNeedingDescription ?? []);
} catch {
setScanError(
t(
"convert.pdfUaAltTextScanFailed",
"The images could not be listed. Convert anyway and the response reports what is missing.",
),
);
} finally {
setIsScanning(false);
}
};
return (
<Stack gap="sm" data-testid="pdfua-settings">
<Text size="sm" fw={500}>
{t("convert.pdfUaOptions", "PDF/UA Options")}:
</Text>
{hasDigitalSignatures && (
<Alert color="yellow">
<Text size="sm">
{t(
"convert.pdfUaSignatureWarning",
"This PDF is digitally signed. Tagging rewrites the page content the signature covers, so the signature will stop verifying. Convert first, then re-sign.",
)}
</Text>
</Alert>
)}
<Stack gap="xs">
<Text size="xs" fw={500}>
{t("convert.pdfUaProfile", "Conformance level")}:
</Text>
<Select
value={parameters.pdfUaOptions.profile}
onChange={(value) => update({ profile: value || "ua1" })}
data={profileOptions}
disabled={disabled}
comboboxProps={{ zIndex: Z_INDEX_AUTOMATE_DROPDOWN }}
data-testid="pdfua-profile-select"
/>
</Stack>
<TextInput
label={t("convert.pdfUaLanguage", "Document language")}
description={t(
"convert.pdfUaLanguageHelp",
"A BCP-47 tag such as en-GB. Used only when the document does not already declare its own language.",
)}
value={parameters.pdfUaOptions.language}
onChange={(event) => update({ language: event.currentTarget.value })}
disabled={disabled}
data-testid="pdfua-language-input"
/>
<Checkbox
label={t(
"convert.pdfUaOverrideLanguage",
"Replace the document's own language",
)}
description={t(
"convert.pdfUaOverrideLanguageHelp",
"Only tick this if the language above is right and the document's own is wrong. Relabelling a document into a language it is not written in makes a screen reader unintelligible.",
)}
checked={parameters.pdfUaOptions.overrideLanguage}
onChange={(event) =>
update({ overrideLanguage: event.currentTarget.checked })
}
disabled={disabled}
data-testid="pdfua-override-language"
/>
<TextInput
label={t("convert.pdfUaTitle", "Document title")}
description={t(
"convert.pdfUaTitleHelp",
"Shown by a reader instead of the filename. Left blank, the first heading is used.",
)}
value={parameters.pdfUaOptions.title}
onChange={(event) => update({ title: event.currentTarget.value })}
disabled={disabled}
data-testid="pdfua-title-input"
/>
<Checkbox
label={t("convert.pdfUaEmbedFonts", "Embed missing fonts")}
description={t(
"convert.pdfUaEmbedFontsHelp",
"PDF/UA requires every font to be embedded. Turning this off is faster but usually prevents conformance.",
)}
checked={parameters.pdfUaOptions.embedFonts}
onChange={(event) =>
update({ embedFonts: event.currentTarget.checked })
}
disabled={disabled}
data-testid="pdfua-embed-fonts"
/>
<Alert color="blue">
<Text size="sm">
{t(
"convert.pdfUaAltTextNotice",
"Images need a written description before a document can be certified. Descriptions are never generated automatically, because an invented one passes the checker while telling a screen-reader user nothing. Any image left without one is reported, and the file comes back tagged but not certified.",
)}
</Text>
</Alert>
{tooManyFiles && (
<Alert color="yellow" data-testid="pdfua-alt-text-single-file-only">
<Text size="sm">
{t(
"convert.pdfUaAltTextSingleFileOnly",
"Descriptions belong to one document: an image is identified by its position, which is a different image in every file. Convert these {{fileCount}} files to tag them, then convert one at a time to describe its images.",
{ fileCount: selectedFiles.length },
)}
</Text>
</Alert>
)}
{!tooManyFiles && (
<Button
variant="secondary"
onClick={findFigures}
loading={isScanning}
disabled={disabled || !scannableFile}
data-testid="pdfua-find-figures"
>
{t("convert.pdfUaFindImages", "Find images needing a description")}
</Button>
)}
{scanError && (
<Alert color="yellow">
<Text size="sm">{scanError}</Text>
</Alert>
)}
{!tooManyFiles && figures?.length === 0 && (
<Text size="xs" c="dimmed" data-testid="pdfua-no-figures">
{t(
"convert.pdfUaNoImagesNeedingText",
"No image is missing a description.",
)}
</Text>
)}
{!tooManyFiles &&
figures?.map((figure) => (
<TextInput
key={figure.key}
label={t("convert.pdfUaFigureLabel", "Page {{page}} {{kind}}", {
page: figure.page,
kind: figure.kind,
})}
placeholder={t(
"convert.pdfUaFigurePlaceholder",
"What this image tells the reader",
)}
value={descriptions[figure.key] ?? ""}
onChange={(event) =>
update({
altText: formatAltText({
...descriptions,
[figure.key]: event.currentTarget.value,
}),
})
}
disabled={disabled}
data-testid={`pdfua-alt-text-${figure.key}`}
/>
))}
</Stack>
);
};
export default ConvertToPdfUaSettings;
@@ -3,6 +3,7 @@ import { Stack, Text, Select, Alert, Checkbox } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { ConvertParameters } from "@app/hooks/tools/convert/useConvertParameters";
import { usePdfSignatureDetection } from "@app/hooks/usePdfSignatureDetection";
import { useEndpointEnabled } from "@app/hooks/useEndpointConfig";
import { StirlingFile } from "@app/types/fileContext";
import { Z_INDEX_AUTOMATE_DROPDOWN } from "@app/styles/zIndex";
@@ -26,11 +27,21 @@ const ConvertToPdfaSettings = ({
const { hasDigitalSignatures, isChecking } =
usePdfSignatureDetection(selectedFiles);
const outputFormatLabelId = useId();
// Level A needs the same tagger as PDF/UA, so it stands or falls with that endpoint.
const { enabled: taggingAvailable } = useEndpointEnabled("pdf-to-ua");
const pdfaFormatOptions = [
{ value: "pdfa-1", label: "PDF/A-1b" },
{ value: "pdfa-2b", label: "PDF/A-2b" },
{ value: "pdfa-3b", label: "PDF/A-3b" },
// Level A is level B plus accessibility: it additionally tags the document.
...(taggingAvailable === false
? []
: [
{ value: "pdfa-1a", label: "PDF/A-1a (accessible)" },
{ value: "pdfa-2a", label: "PDF/A-2a (accessible)" },
{ value: "pdfa-3a", label: "PDF/A-3a (accessible)" },
]),
];
return (
@@ -31,6 +31,7 @@ export const CONVERSION_ENDPOINTS = {
"pdf-html": "/api/v1/convert/pdf/html",
"pdf-xml": "/api/v1/convert/pdf/xml",
"pdf-pdfa": "/api/v1/convert/pdf/pdfa",
"pdf-pdfua": "/api/v1/convert/pdf/ua",
"html-pdf": "/api/v1/convert/html/pdf",
"markdown-pdf": "/api/v1/convert/markdown/pdf",
"eml-pdf": "/api/v1/convert/eml/pdf",
@@ -58,6 +59,7 @@ export const ENDPOINT_NAMES = {
"pdf-html": "pdf-to-html",
"pdf-xml": "pdf-to-xml",
"pdf-pdfa": "pdf-to-pdfa",
"pdf-pdfua": "pdf-to-ua",
"html-pdf": "html-to-pdf",
"markdown-pdf": "markdown-to-pdf",
"eml-pdf": "eml-to-pdf",
@@ -84,6 +86,7 @@ export const ENDPOINT_I18N: Record<string, [string, string]> = {
"pdf-to-html": ["PDFToHTML.header", "PDF → HTML"],
"pdf-to-xml": ["PDFToXML.header", "PDF → XML"],
"pdf-to-pdfa": ["pdfToPDFA.header", "PDF → PDF/A"],
"pdf-to-ua": ["pdfToPDFUA.header", "PDF → PDF/UA (accessible)"],
"file-to-pdf": ["convert.fileToPdf", "Office/Document → PDF"],
"cbr-to-pdf": ["convert.cbrToPdf", "CBR → PDF"],
"cbz-to-pdf": ["convert.cbzToPdf", "CBZ → PDF"],
@@ -136,6 +139,7 @@ export const TO_FORMAT_OPTIONS = [
{ value: "pdf", label: "PDF", group: "Document" },
{ value: "pdfa", label: "PDF/A", group: "Document" },
{ value: "pdfx", label: "PDF/X", group: "Document" },
{ value: "pdfua", label: "PDF/UA", group: "Document" },
{ value: "docx", label: "DOCX", group: "Document" },
{ value: "odt", label: "ODT", group: "Document" },
{ value: "cbz", label: "CBZ", group: "Archive" },
@@ -183,6 +187,7 @@ export const CONVERSION_MATRIX: Record<string, string[]> = {
"xml",
"pdfa",
"pdfx",
"pdfua",
"cbz",
"cbr",
"epub",
@@ -244,6 +249,7 @@ export const EXTENSION_TO_ENDPOINT: Record<string, Record<string, string>> = {
xml: "pdf-to-xml",
pdfa: "pdf-to-pdfa",
pdfx: "pdf-to-pdfa", // PDF/X uses the same endpoint as PDF/A
pdfua: "pdf-to-ua",
cbr: "pdf-to-cbr",
cbz: "pdf-to-cbz",
epub: "pdf-to-epub",
@@ -0,0 +1,92 @@
/**
* PDF/UA alt text is keyed by an image's position within one document ("pageIndex:ordinal"), so the
* same key names a different image in the next file. A multi-file PDF/UA conversion posts one
* request per file, so an unscoped description would be attached to whatever image happens to sit
* in that position in every other document - a wrong description that still passes the checker.
*/
import { beforeEach, describe, expect, test, vi } from "vitest";
import { convertProcessor } from "@app/hooks/tools/convert/useConvertOperation";
import {
defaultParameters,
type ConvertParameters,
} from "@app/hooks/tools/convert/useConvertParameters";
const api = vi.hoisted(() => ({ post: vi.fn() }));
vi.mock("@app/services/apiClient", () => ({
default: {
post: api.post,
get: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
interceptors: { response: { use: vi.fn() } },
},
}));
const pdfUaParameters = (altText: string): ConvertParameters => ({
...defaultParameters,
fromExtension: "pdf",
toExtension: "pdfua",
pdfUaOptions: { ...defaultParameters.pdfUaOptions, altText },
});
const pdf = (name: string) =>
new File(["%PDF-1.7"], name, { type: "application/pdf" });
const bodyOf = (call: number): FormData =>
api.post.mock.calls[call][1] as FormData;
beforeEach(() => {
vi.clearAllMocks();
api.post.mockResolvedValue({
data: new Blob(["%PDF-1.7"], { type: "application/pdf" }),
headers: { "content-type": "application/pdf" },
});
});
describe("PDF/UA alt text never crosses documents", () => {
test("posts to the PDF/UA endpoint with the descriptions when one file is converted", async () => {
await convertProcessor(pdfUaParameters("0:1=Bar chart of revenue"), [
pdf("report.pdf"),
]);
expect(api.post).toHaveBeenCalledTimes(1);
expect(api.post.mock.calls[0][0]).toBe("/api/v1/convert/pdf/ua");
expect(bodyOf(0).get("altText")).toBe("0:1=Bar chart of revenue");
});
test("drops the descriptions when several files are converted, rather than mislabelling", async () => {
await convertProcessor(pdfUaParameters("0:1=Bar chart of revenue"), [
pdf("report.pdf"),
pdf("appendix.pdf"),
]);
// One request per file: without scoping, appendix.pdf's figure 0:1 would be called a bar chart.
expect(api.post).toHaveBeenCalledTimes(2);
expect(bodyOf(0).get("altText")).toBeNull();
expect(bodyOf(1).get("altText")).toBeNull();
});
test("keeps the options that really do apply to every file", async () => {
const parameters = pdfUaParameters("0:1=Bar chart of revenue");
parameters.pdfUaOptions.profile = "ua2";
parameters.pdfUaOptions.language = "fr-FR";
parameters.pdfUaOptions.overrideLanguage = true;
await convertProcessor(parameters, [pdf("a.pdf"), pdf("b.pdf")]);
for (const call of [0, 1]) {
expect(bodyOf(call).get("profile")).toBe("ua2");
expect(bodyOf(call).get("language")).toBe("fr-FR");
expect(bodyOf(call).get("overrideLanguage")).toBe("true");
}
});
test("leaves the caller's parameters untouched", async () => {
const parameters = pdfUaParameters("0:1=Bar chart of revenue");
await convertProcessor(parameters, [pdf("a.pdf"), pdf("b.pdf")]);
expect(parameters.pdfUaOptions.altText).toBe("0:1=Bar chart of revenue");
});
});
@@ -51,10 +51,9 @@ export const shouldProcessFilesSeparately = (
// PDF to image conversions (each PDF should generate its own image file)
(parameters.fromExtension === "pdf" &&
isImageFormat(parameters.toExtension)) ||
// PDF to PDF/A and PDF/X conversions (each PDF should be processed separately)
// PDF to PDF/A, PDF/X and PDF/UA conversions (each PDF should be processed separately)
(parameters.fromExtension === "pdf" &&
(parameters.toExtension === "pdfa" ||
parameters.toExtension === "pdfx")) ||
["pdfa", "pdfx", "pdfua"].includes(parameters.toExtension)) ||
// PDF to text-like/spreadsheet formats should be one output per input
(parameters.fromExtension === "pdf" &&
["txt", "rtf", "csv", "xlsx"].includes(parameters.toExtension)) ||
@@ -99,6 +98,7 @@ export const buildConvertFormData = (
htmlOptions,
emailOptions,
pdfaOptions,
pdfUaOptions,
pdfxOptions,
cbrOptions,
pdfToCbrOptions,
@@ -160,6 +160,18 @@ export const buildConvertFormData = (
} else if (fromExtension === "pdf" && toExtension === "pdfa") {
formData.append("outputFormat", pdfaOptions.outputFormat);
formData.append("strict", String(!!pdfaOptions.strict));
} else if (fromExtension === "pdf" && toExtension === "pdfua") {
formData.append("profile", pdfUaOptions.profile);
formData.append("language", pdfUaOptions.language);
formData.append("overrideLanguage", String(pdfUaOptions.overrideLanguage));
formData.append("embedFonts", String(pdfUaOptions.embedFonts));
// Sent only when set, so the backend can fall back to the first heading then the filename.
if (pdfUaOptions.title.trim()) {
formData.append("title", pdfUaOptions.title.trim());
}
if (pdfUaOptions.altText.trim()) {
formData.append("altText", pdfUaOptions.altText.trim());
}
} else if (fromExtension === "pdf" && toExtension === "pdfx") {
// Use PDF/A endpoint with PDF/X format parameter
formData.append("outputFormat", pdfxOptions?.outputFormat || "pdfx");
@@ -228,8 +240,8 @@ export const createFileFromResponse = (
): File => {
const originalName = originalFileName.split(".")[0];
// Map both pdfa and pdfx to pdf since they both result in PDF files
if (targetExtension == "pdfa" || targetExtension == "pdfx") {
// Map pdfa, pdfx and pdfua to pdf since they all result in PDF files
if (["pdfa", "pdfx", "pdfua"].includes(targetExtension)) {
targetExtension = "pdf";
}
@@ -238,11 +250,28 @@ export const createFileFromResponse = (
return createFileFromApiResponse(responseData, headers, fallbackFilename);
};
// Static processor that can be used by both the hook and automation executor
export const convertProcessor = async (
/**
* PDF/UA alt text is keyed by an image's position in one document, so reusing it across files would
* describe the wrong image - worse than no description, since the checker still passes. Dropped
* rather than misapplied; the UI offers descriptions for a single file, this guards every caller.
*/
const withoutCrossFileAltText = (
parameters: ConvertParameters,
selectedFiles: File[],
): ConvertParameters =>
selectedFiles.length > 1 && parameters.pdfUaOptions.altText
? {
...parameters,
pdfUaOptions: { ...parameters.pdfUaOptions, altText: "" },
}
: parameters;
// Static processor that can be used by both the hook and automation executor
export const convertProcessor = async (
rawParameters: ConvertParameters,
selectedFiles: File[],
): Promise<CustomProcessorResult> => {
const parameters = withoutCrossFileAltText(rawParameters, selectedFiles);
const processedFiles: File[] = [];
// Map PDF/X to use PDF/A endpoint
@@ -486,6 +515,18 @@ const CONVERT_OPTION_READERS: ConvertOptionReaders = {
strict: asFlag(body.strict),
},
},
"/api/v1/convert/pdf/ua": (body) => ({
pdfUaOptions: {
profile: body.profile ?? defaultParameters.pdfUaOptions.profile,
language: body.language ?? defaultParameters.pdfUaOptions.language,
overrideLanguage: asFlag(body.overrideLanguage),
title: body.title ?? defaultParameters.pdfUaOptions.title,
// Absent means the step predates the field, and embedding is the conforming default.
embedFonts:
body.embedFonts !== undefined ? asFlag(body.embedFonts) : true,
altText: body.altText ?? defaultParameters.pdfUaOptions.altText,
},
}),
"/api/v1/convert/cbr/pdf": (body) => ({
cbrOptions: { optimizeForEbook: asFlag(body.optimizeForEbook) },
}),
@@ -46,6 +46,15 @@ export interface ConvertParameters extends BaseParameters {
outputFormat: string;
strict?: boolean;
};
pdfUaOptions: {
profile: string;
language: string;
overrideLanguage: boolean;
title: string;
embedFonts: boolean;
/** Descriptions as `pageIndex:ordinal=text` lines, keyed as the backend hands them out. */
altText: string;
};
pdfxOptions: {
outputFormat: string;
};
@@ -108,6 +117,14 @@ export const defaultParameters: ConvertParameters = {
outputFormat: "pdfa-2b",
strict: false,
},
pdfUaOptions: {
profile: "ua1",
language: "en-GB",
overrideLanguage: false,
title: "",
embedFonts: true,
altText: "",
},
pdfxOptions: {
outputFormat: "pdfx",
},
@@ -404,6 +404,52 @@ describe("convert (format-routed custom tool)", () => {
});
});
test("round-trips a PDF -> PDF/UA step, restoring every accessibility option", () => {
const step: WorkingToolStep = {
toolId: "convert" as ToolId,
operation: "/api/v1/convert/file/pdf",
params: {
...convertDefaults,
fromExtension: "pdf",
toExtension: "pdfua",
pdfUaOptions: {
profile: "ua2",
language: "fr-FR",
overrideLanguage: true,
title: "Rapport annuel",
embedFonts: false,
altText: "0:12=Graphique des revenus",
},
},
support: "editable",
};
const api = serializeToolStep(step, convertRegistry);
expect(api.operation).toBe("/api/v1/convert/pdf/ua");
expect(api.parameters).toMatchObject({
profile: "ua2",
language: "fr-FR",
overrideLanguage: "true",
title: "Rapport annuel",
embedFonts: "false",
altText: "0:12=Graphique des revenus",
});
const back = deserializeToolStep(api, convertRegistry);
expect(back.operation).toBe("/api/v1/convert/pdf/ua");
expect(back.params).toMatchObject({
toExtension: "pdfua",
pdfUaOptions: {
profile: "ua2",
language: "fr-FR",
overrideLanguage: true,
title: "Rapport annuel",
embedFonts: false,
altText: "0:12=Graphique des revenus",
},
});
});
test("routes PDF/X through the shared PDF/A endpoint and recovers the PDF/X target", () => {
const step: WorkingToolStep = {
toolId: "convert" as ToolId,
@@ -184,6 +184,14 @@ describe("Convert Tool Integration Tests", () => {
cbzOptions: {
optimizeForEbook: false,
},
pdfUaOptions: {
profile: "ua1",
language: "en-GB",
overrideLanguage: false,
title: "",
embedFonts: true,
altText: "",
},
cbzOutputOptions: {
dpi: 150,
},
@@ -278,6 +286,14 @@ describe("Convert Tool Integration Tests", () => {
cbzOptions: {
optimizeForEbook: false,
},
pdfUaOptions: {
profile: "ua1",
language: "en-GB",
overrideLanguage: false,
title: "",
embedFonts: true,
altText: "",
},
cbzOutputOptions: {
dpi: 150,
},
@@ -341,6 +357,14 @@ describe("Convert Tool Integration Tests", () => {
cbzOptions: {
optimizeForEbook: false,
},
pdfUaOptions: {
profile: "ua1",
language: "en-GB",
overrideLanguage: false,
title: "",
embedFonts: true,
altText: "",
},
cbzOutputOptions: {
dpi: 150,
},
@@ -410,6 +434,14 @@ describe("Convert Tool Integration Tests", () => {
cbzOptions: {
optimizeForEbook: false,
},
pdfUaOptions: {
profile: "ua1",
language: "en-GB",
overrideLanguage: false,
title: "",
embedFonts: true,
altText: "",
},
cbzOutputOptions: {
dpi: 150,
},
@@ -485,6 +517,14 @@ describe("Convert Tool Integration Tests", () => {
cbzOptions: {
optimizeForEbook: false,
},
pdfUaOptions: {
profile: "ua1",
language: "en-GB",
overrideLanguage: false,
title: "",
embedFonts: true,
altText: "",
},
cbzOutputOptions: {
dpi: 150,
},
@@ -560,6 +600,14 @@ describe("Convert Tool Integration Tests", () => {
cbzOptions: {
optimizeForEbook: false,
},
pdfUaOptions: {
profile: "ua1",
language: "en-GB",
overrideLanguage: false,
title: "",
embedFonts: true,
altText: "",
},
cbzOutputOptions: {
dpi: 150,
},
@@ -638,6 +686,14 @@ describe("Convert Tool Integration Tests", () => {
cbzOptions: {
optimizeForEbook: false,
},
pdfUaOptions: {
profile: "ua1",
language: "en-GB",
overrideLanguage: false,
title: "",
embedFonts: true,
altText: "",
},
cbzOutputOptions: {
dpi: 150,
},
@@ -702,6 +758,14 @@ describe("Convert Tool Integration Tests", () => {
cbzOptions: {
optimizeForEbook: false,
},
pdfUaOptions: {
profile: "ua1",
language: "en-GB",
overrideLanguage: false,
title: "",
embedFonts: true,
altText: "",
},
cbzOutputOptions: {
dpi: 150,
},
@@ -772,6 +836,14 @@ describe("Convert Tool Integration Tests", () => {
cbzOptions: {
optimizeForEbook: false,
},
pdfUaOptions: {
profile: "ua1",
language: "en-GB",
overrideLanguage: false,
title: "",
embedFonts: true,
altText: "",
},
cbzOutputOptions: {
dpi: 150,
},
@@ -836,6 +908,14 @@ describe("Convert Tool Integration Tests", () => {
cbzOptions: {
optimizeForEbook: false,
},
pdfUaOptions: {
profile: "ua1",
language: "en-GB",
overrideLanguage: false,
title: "",
embedFonts: true,
altText: "",
},
cbzOutputOptions: {
dpi: 150,
},
@@ -905,6 +985,14 @@ describe("Convert Tool Integration Tests", () => {
cbzOptions: {
optimizeForEbook: false,
},
pdfUaOptions: {
profile: "ua1",
language: "en-GB",
overrideLanguage: false,
title: "",
embedFonts: true,
altText: "",
},
cbzOutputOptions: {
dpi: 150,
},
@@ -974,6 +1062,14 @@ describe("Convert Tool Integration Tests", () => {
cbzOptions: {
optimizeForEbook: false,
},
pdfUaOptions: {
profile: "ua1",
language: "en-GB",
overrideLanguage: false,
title: "",
embedFonts: true,
altText: "",
},
cbzOutputOptions: {
dpi: 150,
},
@@ -3,6 +3,12 @@
// (SwaggerDoc.json). Regenerate with: task frontend:tool-models
// Tools that take only a file input have no parameters; their model is Record<string, never>.
export interface AccessibilityReportRequest {
/**
* Profile to check against
*/
profile?: "ua1" | "ua2";
}
export interface AddAttachmentRequest {
/**
* The image file to be overlaid onto the PDF.
@@ -886,12 +892,53 @@ export interface PdfToPdfARequest {
| "pdfa-2b"
| "pdfa-3"
| "pdfa-3b"
| "pdfa-1a"
| "pdfa-2a"
| "pdfa-3a"
| "pdfx";
/**
* Also declare PDF/UA accessibility alongside PDF/A. Only applies to the level A formats, and the claim is written only if it validates.
*/
pdfUa?: boolean;
/**
* If true, the conversion will fail if the output is not perfectly compliant
*/
strict?: boolean;
}
export interface PdfToPdfUaRequest {
/**
* Alternative descriptions for figures, as key=text pairs separated by newlines. Keys come from the accessibility-report endpoint's figuresNeedingDescription list, for example "0:12=Bar chart of quarterly revenue". Descriptions are never invented, so without these an illustrated document cannot claim conformance.
*/
altText?: string;
/**
* Embed fonts the document references but does not carry. Required for conformance and needs Ghostscript.
*/
embedFonts?: boolean;
/**
* What to do with an existing structure tree: keep it, rebuild it, or decide automatically
*/
existingTags?: "auto" | "keep" | "rebuild";
/**
* How to treat images with no description. require-alt leaves them undescribed so the report asks for input; mark-decorative treats every image as decoration.
*/
figurePolicy?: "require-alt" | "mark-decorative";
/**
* Document language as a BCP-47 tag, for example en-GB. Applied only when the document does not already declare one, unless overrideLanguage is set.
*/
language?: string;
/**
* Replace the language the document already declares. Off by default, so a document is never relabelled into a language it is not written in.
*/
overrideLanguage?: boolean;
/**
* PDF/UA conformance level to target
*/
profile?: "ua1" | "ua2";
/**
* Document title, required by PDF/UA. Falls back to the first heading, then the filename.
*/
title?: string;
}
export interface PdfToPresentationRequest {
/**
* The output Presentation format
@@ -1458,6 +1505,7 @@ export type ToolEndpoint =
| "/api/v1/convert/pdf/text"
| "/api/v1/convert/pdf/text-editor"
| "/api/v1/convert/pdf/text-editor/metadata"
| "/api/v1/convert/pdf/ua"
| "/api/v1/convert/pdf/vector"
| "/api/v1/convert/pdf/word"
| "/api/v1/convert/pdf/xlsx"
@@ -1519,6 +1567,7 @@ export type ToolEndpoint =
| "/api/v1/misc/show-javascript"
| "/api/v1/misc/unlock-pdf-forms"
| "/api/v1/misc/update-metadata"
| "/api/v1/security/accessibility-report"
| "/api/v1/security/add-password"
| "/api/v1/security/add-watermark"
| "/api/v1/security/auto-redact"
@@ -1558,6 +1607,7 @@ export interface ToolApiParams {
"/api/v1/convert/pdf/text": PdfToTextOrRTFRequest;
"/api/v1/convert/pdf/text-editor": ConvertPdfTextEditorRequest;
"/api/v1/convert/pdf/text-editor/metadata": ConvertPdfTextEditorMetadataRequest;
"/api/v1/convert/pdf/ua": PdfToPdfUaRequest;
"/api/v1/convert/pdf/vector": PdfVectorExportRequest;
"/api/v1/convert/pdf/word": PdfToWordRequest;
"/api/v1/convert/pdf/xlsx": PDFWithPageNums;
@@ -1619,6 +1669,7 @@ export interface ToolApiParams {
"/api/v1/misc/show-javascript": MiscShowJavascriptRequest;
"/api/v1/misc/unlock-pdf-forms": MiscUnlockPdfFormsRequest;
"/api/v1/misc/update-metadata": MetadataRequest;
"/api/v1/security/accessibility-report": AccessibilityReportRequest;
"/api/v1/security/add-password": AddPasswordRequest;
"/api/v1/security/add-watermark": AddWatermarkRequest;
"/api/v1/security/auto-redact": RedactPdfRequest;
@@ -1659,6 +1710,7 @@ export const TOOL_ENDPOINTS = [
"/api/v1/convert/pdf/text",
"/api/v1/convert/pdf/text-editor",
"/api/v1/convert/pdf/text-editor/metadata",
"/api/v1/convert/pdf/ua",
"/api/v1/convert/pdf/vector",
"/api/v1/convert/pdf/word",
"/api/v1/convert/pdf/xlsx",
@@ -1720,6 +1772,7 @@ export const TOOL_ENDPOINTS = [
"/api/v1/misc/show-javascript",
"/api/v1/misc/unlock-pdf-forms",
"/api/v1/misc/update-metadata",
"/api/v1/security/accessibility-report",
"/api/v1/security/add-password",
"/api/v1/security/add-watermark",
"/api/v1/security/auto-redact",
+10
View File
@@ -209,6 +209,11 @@ export const TOOL_IO: ToolIOTable = {
},
],
},
"/api/v1/convert/pdf/ua": {
accepts: ["PDF"],
produces: "PDF",
arity: "SISO",
},
"/api/v1/convert/pdf/vector": {
accepts: ["PDF"],
produces: "IMAGE",
@@ -541,6 +546,11 @@ export const TOOL_IO: ToolIOTable = {
produces: "PDF",
arity: "SISO",
},
"/api/v1/security/accessibility-report": {
accepts: ["PDF"],
produces: "JSON",
arity: "SISO",
},
"/api/v1/security/add-password": {
accepts: ["PDF"],
produces: "PDF_ENCRYPTED",