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
+4
View File
@@ -126,6 +126,7 @@ TOOL_IO: dict[ToolEndpoint, ToolIOSpec] = {
)
],
),
ToolEndpoint.PDF_TO_UA: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO),
ToolEndpoint.PDF_TO_VECTOR: ToolIOSpec(
accepts=[ToolFormat.PDF],
produces=ToolFormat.IMAGE,
@@ -246,6 +247,9 @@ TOOL_IO: dict[ToolEndpoint, ToolIOSpec] = {
ToolEndpoint.SCANNER_EFFECT: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO),
ToolEndpoint.UNLOCK_PDF_FORMS: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO),
ToolEndpoint.UPDATE_METADATA: ToolIOSpec(accepts=[ToolFormat.PDF], produces=ToolFormat.PDF, arity=ToolArity.SISO),
ToolEndpoint.ACCESSIBILITY_REPORT: ToolIOSpec(
accepts=[ToolFormat.PDF], produces=ToolFormat.JSON, arity=ToolArity.SISO
),
ToolEndpoint.ADD_PASSWORD: ToolIOSpec(
accepts=[ToolFormat.PDF],
produces=ToolFormat.PDF_ENCRYPTED,
+89
View File
@@ -11,6 +11,19 @@ from pydantic import Field, RootModel, SecretStr
from stirling.models.base import ApiModel
class Profile(StrEnum):
"""
Profile to check against
"""
ua1 = "ua1"
ua2 = "ua2"
class AccessibilityReportParams(ApiModel):
profile: Profile = Field(Profile.ua1, description="Profile to check against")
class AddCommentsParams(ApiModel):
comments: str = Field(
...,
@@ -843,11 +856,18 @@ class OutputFormat1(StrEnum):
pdfa_2b = "pdfa-2b"
pdfa_3 = "pdfa-3"
pdfa_3b = "pdfa-3b"
pdfa_1a = "pdfa-1a"
pdfa_2a = "pdfa-2a"
pdfa_3a = "pdfa-3a"
pdfx = "pdfx"
class PdfToPdfaParams(ApiModel):
output_format: OutputFormat1 = Field(..., description="The output format type (PDF/A or PDF/X)")
pdf_ua: bool = Field(
False,
description="Also declare PDF/UA accessibility alongside PDF/A. Only applies to the level A formats, and the claim is written only if it validates.",
)
strict: bool | None = Field(
None, description="If true, the conversion will fail if the output is not perfectly compliant"
)
@@ -886,6 +906,65 @@ class PdfToTextParams(ApiModel):
output_format: OutputFormat3 = Field(..., description="The output Text or RTF format")
class ExistingTags(StrEnum):
"""
What to do with an existing structure tree: keep it, rebuild it, or decide automatically
"""
auto = "auto"
keep = "keep"
rebuild = "rebuild"
class FigurePolicy(StrEnum):
"""
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.
"""
require_alt = "require-alt"
mark_decorative = "mark-decorative"
class Profile1(StrEnum):
"""
PDF/UA conformance level to target
"""
ua1 = "ua1"
ua2 = "ua2"
class PdfToUaParams(ApiModel):
alt_text: str | None = Field(
None,
description='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.',
)
embed_fonts: bool = Field(
True,
description="Embed fonts the document references but does not carry. Required for conformance and needs Ghostscript.",
)
existing_tags: ExistingTags = Field(
ExistingTags.auto,
description="What to do with an existing structure tree: keep it, rebuild it, or decide automatically",
)
figure_policy: FigurePolicy = Field(
FigurePolicy.require_alt,
description="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.",
)
language: str = Field(
"en-GB",
description="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.",
)
override_language: bool = Field(
False,
description="Replace the language the document already declares. Off by default, so a document is never relabelled into a language it is not written in.",
)
profile: Profile1 = Field(Profile1.ua1, description="PDF/UA conformance level to target")
title: str | None = Field(
None, description="Document title, required by PDF/UA. Falls back to the first heading, then the filename."
)
class OutputFormat4(StrEnum):
"""
Target vector format extension
@@ -1451,6 +1530,7 @@ class Model(
| PdfToPdfaParams
| PdfToPresentationParams
| PdfToTextParams
| PdfToUaParams
| PdfToVectorParams
| PdfToWordParams
| PdfToXlsxParams
@@ -1495,6 +1575,7 @@ class Model(
| ScannerEffectParams
| UnlockPdfFormsParams
| UpdateMetadataParams
| AccessibilityReportParams
| AddPasswordParams
| AddWatermarkParams
| AutoRedactParams
@@ -1525,6 +1606,7 @@ class Model(
| PdfToPdfaParams
| PdfToPresentationParams
| PdfToTextParams
| PdfToUaParams
| PdfToVectorParams
| PdfToWordParams
| PdfToXlsxParams
@@ -1569,6 +1651,7 @@ class Model(
| ScannerEffectParams
| UnlockPdfFormsParams
| UpdateMetadataParams
| AccessibilityReportParams
| AddPasswordParams
| AddWatermarkParams
| AutoRedactParams
@@ -1600,6 +1683,7 @@ type ParamToolModel = (
| PdfToPdfaParams
| PdfToPresentationParams
| PdfToTextParams
| PdfToUaParams
| PdfToVectorParams
| PdfToWordParams
| PdfToXlsxParams
@@ -1644,6 +1728,7 @@ type ParamToolModel = (
| ScannerEffectParams
| UnlockPdfFormsParams
| UpdateMetadataParams
| AccessibilityReportParams
| AddPasswordParams
| AddWatermarkParams
| AutoRedactParams
@@ -1676,6 +1761,7 @@ class ToolEndpoint(StrEnum):
PDF_TO_PDFA = "/api/v1/convert/pdf/pdfa"
PDF_TO_PRESENTATION = "/api/v1/convert/pdf/presentation"
PDF_TO_TEXT = "/api/v1/convert/pdf/text"
PDF_TO_UA = "/api/v1/convert/pdf/ua"
PDF_TO_VECTOR = "/api/v1/convert/pdf/vector"
PDF_TO_WORD = "/api/v1/convert/pdf/word"
PDF_TO_XLSX = "/api/v1/convert/pdf/xlsx"
@@ -1720,6 +1806,7 @@ class ToolEndpoint(StrEnum):
SCANNER_EFFECT = "/api/v1/misc/scanner-effect"
UNLOCK_PDF_FORMS = "/api/v1/misc/unlock-pdf-forms"
UPDATE_METADATA = "/api/v1/misc/update-metadata"
ACCESSIBILITY_REPORT = "/api/v1/security/accessibility-report"
ADD_PASSWORD = "/api/v1/security/add-password"
ADD_WATERMARK = "/api/v1/security/add-watermark"
AUTO_REDACT = "/api/v1/security/auto-redact"
@@ -1750,6 +1837,7 @@ OPERATIONS: dict[ToolEndpoint, ParamToolModelType] = {
ToolEndpoint.PDF_TO_PDFA: PdfToPdfaParams,
ToolEndpoint.PDF_TO_PRESENTATION: PdfToPresentationParams,
ToolEndpoint.PDF_TO_TEXT: PdfToTextParams,
ToolEndpoint.PDF_TO_UA: PdfToUaParams,
ToolEndpoint.PDF_TO_VECTOR: PdfToVectorParams,
ToolEndpoint.PDF_TO_WORD: PdfToWordParams,
ToolEndpoint.PDF_TO_XLSX: PdfToXlsxParams,
@@ -1794,6 +1882,7 @@ OPERATIONS: dict[ToolEndpoint, ParamToolModelType] = {
ToolEndpoint.SCANNER_EFFECT: ScannerEffectParams,
ToolEndpoint.UNLOCK_PDF_FORMS: UnlockPdfFormsParams,
ToolEndpoint.UPDATE_METADATA: UpdateMetadataParams,
ToolEndpoint.ACCESSIBILITY_REPORT: AccessibilityReportParams,
ToolEndpoint.ADD_PASSWORD: AddPasswordParams,
ToolEndpoint.ADD_WATERMARK: AddWatermarkParams,
ToolEndpoint.AUTO_REDACT: AutoRedactParams,