mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
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:
@@ -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" && (
|
||||
|
||||
+149
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user