Add verapdf integration for speculative PDF/A conversion

Introduce a fast path for PDF/A conversion that uses pikepdf to add
PDF/A structures directly (sRGB ICC profile and XMP metadata), then
validates with verapdf. If validation passes, skip Ghostscript entirely.
If validation fails or verapdf is unavailable, fall back to the existing
Ghostscript conversion path.

New files:
- src/ocrmypdf/_exec/verapdf.py: CLI wrapper for verapdf validator
- tests/test_verapdf.py: Test suite for new functionality

Modified:
- pdfa.py: Add speculative_pdfa_conversion() and helpers
- _pipeline.py: Add try_speculative_pdfa() function
- _pipelines/_common.py: Integrate speculative path into postprocess()
This commit is contained in:
James R. Barlow
2026-01-08 10:58:01 -08:00
parent f5617ce44e
commit 900a60fd10
5 changed files with 449 additions and 4 deletions
+108
View File
@@ -0,0 +1,108 @@
# SPDX-FileCopyrightText: 2024 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""Interface to verapdf executable."""
from __future__ import annotations
import json
import logging
from pathlib import Path
from subprocess import PIPE
from typing import NamedTuple
from packaging.version import Version
from ocrmypdf.exceptions import MissingDependencyError
from ocrmypdf.subprocess import get_version, run
log = logging.getLogger(__name__)
class ValidationResult(NamedTuple):
"""Result of PDF/A validation."""
valid: bool
failed_rules: int
message: str
def version() -> Version:
"""Get verapdf version."""
return Version(get_version('verapdf', regex=r'veraPDF (\d+(\.\d+)*)'))
def available() -> bool:
"""Check if verapdf is available."""
try:
version()
except MissingDependencyError:
return False
return True
def output_type_to_flavour(output_type: str) -> str:
"""Map OCRmyPDF output_type to verapdf flavour.
Args:
output_type: One of 'pdfa', 'pdfa-1', 'pdfa-2', 'pdfa-3'
Returns:
verapdf flavour string like '1b', '2b', '3b'
"""
mapping = {
'pdfa': '2b',
'pdfa-1': '1b',
'pdfa-2': '2b',
'pdfa-3': '3b',
}
return mapping.get(output_type, '2b')
def validate(input_file: Path, flavour: str) -> ValidationResult:
"""Validate a PDF against a PDF/A profile.
Args:
input_file: Path to PDF file to validate
flavour: verapdf flavour (1a, 1b, 2a, 2b, 2u, 3a, 3b, 3u)
Returns:
ValidationResult with validation status
"""
args = [
'verapdf',
'--format',
'json',
'--flavour',
flavour,
str(input_file),
]
try:
proc = run(args, stdout=PIPE, stderr=PIPE, check=False)
except FileNotFoundError as e:
raise MissingDependencyError('verapdf') from e
try:
result = json.loads(proc.stdout)
jobs = result.get('report', {}).get('jobs', [])
if not jobs:
return ValidationResult(False, -1, 'No validation jobs in result')
validation_results = jobs[0].get('validationResult', [])
if not validation_results:
return ValidationResult(False, -1, 'No validation result in output')
validation_result = validation_results[0]
details = validation_result.get('details', {})
failed_rules = details.get('failedRules', 0)
if failed_rules == 0:
return ValidationResult(True, 0, 'PDF/A validation passed')
else:
return ValidationResult(
False,
failed_rules,
f'PDF/A validation failed with {failed_rules} rule violations',
)
except (json.JSONDecodeError, KeyError, TypeError) as e:
log.debug('Failed to parse verapdf output: %s', e)
return ValidationResult(False, -1, f'Failed to parse verapdf output: {e}')
+47 -1
View File
@@ -36,7 +36,7 @@ from ocrmypdf.exceptions import (
UnsupportedImageFormatError,
)
from ocrmypdf.helpers import IMG2PDF_KWARGS, Resolution, safe_symlink
from ocrmypdf.pdfa import generate_pdfa_ps
from ocrmypdf.pdfa import generate_pdfa_ps, speculative_pdfa_conversion
from ocrmypdf.pdfinfo import Colorspace, Encoding, FloatRect, PageInfo, PdfInfo
from ocrmypdf.pluginspec import OrientationConfidence
@@ -932,6 +932,52 @@ def convert_to_pdfa(input_pdf: Path, input_ps_stub: Path, context: PdfContext) -
return output_file
def try_speculative_pdfa(input_pdf: Path, context: PdfContext) -> Path | None:
"""Try speculative PDF/A conversion with verapdf validation.
This attempts a fast PDF/A conversion by adding PDF/A structures
directly with pikepdf, then validating with verapdf. If validation
passes, returns the converted file. If it fails or verapdf is not
available, returns None to signal that Ghostscript should be used.
Args:
input_pdf: Path to the PDF to convert
context: The PDF context
Returns:
Path to valid PDF/A file, or None if speculative conversion failed
"""
from ocrmypdf._exec import verapdf
if not verapdf.available():
log.debug('verapdf not available, skipping speculative PDF/A conversion')
return None
options = context.options
output_file = context.get_path('speculative_pdfa.pdf')
try:
speculative_pdfa_conversion(input_pdf, output_file, options.output_type)
flavour = verapdf.output_type_to_flavour(options.output_type)
result = verapdf.validate(output_file, flavour)
if result.valid:
log.info('Speculative PDF/A conversion succeeded - skipping Ghostscript')
return output_file
else:
log.debug(
'Speculative PDF/A validation failed (%d rule violations), '
'falling back to Ghostscript',
result.failed_rules,
)
return None
except Exception as e:
log.debug('Speculative PDF/A conversion failed: %s', e)
return None
def should_linearize(working_file: Path, context: PdfContext) -> bool:
"""Determine whether the PDF should be linearized.
+9 -2
View File
@@ -45,6 +45,7 @@ from ocrmypdf._pipeline import (
rasterize_preview,
should_linearize,
should_visible_page_image_use_jpg,
try_speculative_pdfa,
)
from ocrmypdf._plugin_manager import OcrmypdfPluginManager
from ocrmypdf._validation import (
@@ -469,8 +470,14 @@ def postprocess(
else:
pdf_out = pdf_file
if context.options.output_type.startswith('pdfa'):
ps_stub_out = generate_postscript_stub(context)
pdf_out = convert_to_pdfa(pdf_out, ps_stub_out, context)
# Try speculative PDF/A conversion first (fast path using pikepdf + verapdf)
speculative_result = try_speculative_pdfa(pdf_out, context)
if speculative_result is not None:
pdf_out = speculative_result
else:
# Fall back to Ghostscript conversion
ps_stub_out = generate_postscript_stub(context)
pdf_out = convert_to_pdfa(pdf_out, ps_stub_out, context)
optimizing = context.plugin_manager.is_optimization_enabled(context=context)
save_settings = get_pdf_save_settings(context.options.output_type)
+117 -1
View File
@@ -1,16 +1,20 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""Utilities for PDF/A production and confirmation with Ghostspcript."""
"""Utilities for PDF/A production and confirmation with Ghostscript."""
from __future__ import annotations
import base64
import logging
from collections.abc import Iterator
from importlib.resources import files as package_files
from pathlib import Path
import pikepdf
from pikepdf import Array, Dictionary, Name, Pdf, Stream
log = logging.getLogger(__name__)
SRGB_ICC_PROFILE_NAME = 'sRGB.icc'
@@ -131,3 +135,115 @@ def file_claims_pdfa(filename: Path):
pdfa_dict['output'] = 'pdfa'
pdfa_dict['conformance'] = conformance
return pdfa_dict
def _load_srgb_icc_profile() -> bytes:
"""Load the sRGB ICC profile from package data."""
return (package_files('ocrmypdf.data') / SRGB_ICC_PROFILE_NAME).read_bytes()
def _pdfa_part_conformance(output_type: str) -> tuple[str, str]:
"""Extract PDF/A part and conformance from output_type.
Args:
output_type: One of 'pdfa', 'pdfa-1', 'pdfa-2', 'pdfa-3'
Returns:
Tuple of (part, conformance) e.g., ('2', 'B')
"""
mapping = {
'pdfa': ('2', 'B'),
'pdfa-1': ('1', 'B'),
'pdfa-2': ('2', 'B'),
'pdfa-3': ('3', 'B'),
}
return mapping.get(output_type, ('2', 'B'))
def add_pdfa_metadata(pdf: Pdf, part: str, conformance: str) -> None:
"""Add PDF/A XMP metadata declaration to a PDF.
Args:
pdf: An open pikepdf.Pdf object
part: PDF/A part number ('1', '2', or '3')
conformance: Conformance level ('A', 'B', or 'U')
"""
with pdf.open_metadata() as meta:
meta['pdfaid:part'] = part
meta['pdfaid:conformance'] = conformance
def add_srgb_output_intent(pdf: Pdf) -> None:
"""Add sRGB ICC profile as OutputIntent to PDF catalog.
This creates the required PDF/A OutputIntent structure with:
- An ICC profile stream containing sRGB profile
- An OutputIntent dictionary pointing to that profile
- Updates the Catalog's OutputIntents array
Args:
pdf: An open pikepdf.Pdf object
"""
icc_data = _load_srgb_icc_profile()
# Create ICC profile stream
icc_stream = Stream(pdf, icc_data)
icc_stream[Name.N] = 3 # RGB has 3 components
# Create OutputIntent dictionary
output_intent = Dictionary({
'/Type': Name.OutputIntent,
'/S': Name('/GTS_PDFA1'),
'/OutputConditionIdentifier': 'sRGB',
'/DestOutputProfile': icc_stream,
})
# Add to catalog's OutputIntents array
if Name.OutputIntents not in pdf.Root:
pdf.Root[Name.OutputIntents] = Array([])
# Check if sRGB OutputIntent already exists
for intent in pdf.Root.OutputIntents: # type: ignore[attr-defined]
if str(intent.get(Name.OutputConditionIdentifier)) == 'sRGB':
log.debug('sRGB OutputIntent already exists, skipping')
return
pdf.Root.OutputIntents.append(output_intent)
def speculative_pdfa_conversion(
input_file: Path,
output_file: Path,
output_type: str,
) -> Path:
"""Attempt to convert a PDF to PDF/A by adding required structures.
This function creates a copy of the input PDF and adds:
1. sRGB ICC profile as OutputIntent
2. XMP metadata declaring PDF/A conformance
This approach works for PDFs that are already mostly PDF/A compliant
but lack the formal declarations. It does NOT perform color conversion,
font embedding, or other transformations that Ghostscript does.
Args:
input_file: Path to input PDF
output_file: Path where output PDF should be written
output_type: One of 'pdfa', 'pdfa-1', 'pdfa-2', 'pdfa-3'
Returns:
Path to the output file
Raises:
pikepdf.PdfError: If the PDF cannot be opened or modified
"""
part, conformance = _pdfa_part_conformance(output_type)
with Pdf.open(input_file) as pdf:
add_srgb_output_intent(pdf)
add_pdfa_metadata(pdf, part, conformance)
pdf.save(output_file)
log.debug('Speculative PDF/A conversion complete: %s', output_file)
return output_file
+168
View File
@@ -0,0 +1,168 @@
# SPDX-FileCopyrightText: 2024 James R. Barlow
# SPDX-License-Identifier: CC-BY-SA-4.0
"""Tests for verapdf wrapper and speculative PDF/A conversion."""
from __future__ import annotations
import pikepdf
import pytest
from pikepdf import Name
from ocrmypdf._exec import verapdf
from ocrmypdf.pdfa import (
_pdfa_part_conformance,
add_pdfa_metadata,
add_srgb_output_intent,
speculative_pdfa_conversion,
)
class TestVerapdfModule:
"""Tests for verapdf wrapper module."""
def test_output_type_to_flavour(self):
assert verapdf.output_type_to_flavour('pdfa') == '2b'
assert verapdf.output_type_to_flavour('pdfa-1') == '1b'
assert verapdf.output_type_to_flavour('pdfa-2') == '2b'
assert verapdf.output_type_to_flavour('pdfa-3') == '3b'
# Unknown should default to 2b
assert verapdf.output_type_to_flavour('unknown') == '2b'
@pytest.mark.skipif(not verapdf.available(), reason='verapdf not installed')
def test_version(self):
ver = verapdf.version()
assert ver.major >= 1
@pytest.mark.skipif(not verapdf.available(), reason='verapdf not installed')
def test_validate_non_pdfa(self, tmp_path):
"""Test validation of a non-PDF/A file returns invalid."""
test_pdf = tmp_path / 'test.pdf'
with pikepdf.new() as pdf:
pdf.add_blank_page()
pdf.save(test_pdf)
result = verapdf.validate(test_pdf, '2b')
assert not result.valid
assert result.failed_rules > 0
class TestPdfaPartConformance:
"""Tests for _pdfa_part_conformance helper."""
def test_pdfa_part_conformance(self):
assert _pdfa_part_conformance('pdfa') == ('2', 'B')
assert _pdfa_part_conformance('pdfa-1') == ('1', 'B')
assert _pdfa_part_conformance('pdfa-2') == ('2', 'B')
assert _pdfa_part_conformance('pdfa-3') == ('3', 'B')
# Unknown should default to 2B
assert _pdfa_part_conformance('unknown') == ('2', 'B')
class TestAddPdfaMetadata:
"""Tests for add_pdfa_metadata function."""
def test_add_pdfa_metadata(self, tmp_path):
"""Test adding PDF/A XMP metadata."""
test_pdf = tmp_path / 'test.pdf'
with pikepdf.new() as pdf:
pdf.add_blank_page()
pdf.save(test_pdf)
with pikepdf.open(test_pdf, allow_overwriting_input=True) as pdf:
add_pdfa_metadata(pdf, '2', 'B')
with pdf.open_metadata() as meta:
assert meta.pdfa_status == '2B'
pdf.save(test_pdf)
# Verify it persists after save
with pikepdf.open(test_pdf) as pdf:
with pdf.open_metadata() as meta:
assert meta.pdfa_status == '2B'
class TestAddSrgbOutputIntent:
"""Tests for add_srgb_output_intent function."""
def test_add_srgb_output_intent(self, tmp_path):
"""Test adding sRGB OutputIntent to a PDF."""
test_pdf = tmp_path / 'test.pdf'
with pikepdf.new() as pdf:
pdf.add_blank_page()
pdf.save(test_pdf)
with pikepdf.open(test_pdf, allow_overwriting_input=True) as pdf:
add_srgb_output_intent(pdf)
assert Name.OutputIntents in pdf.Root
assert len(pdf.Root.OutputIntents) == 1
intent = pdf.Root.OutputIntents[0]
assert str(intent.get(Name.OutputConditionIdentifier)) == 'sRGB'
pdf.save(test_pdf)
def test_add_srgb_output_intent_idempotent(self, tmp_path):
"""Test that adding OutputIntent twice doesn't duplicate."""
test_pdf = tmp_path / 'test.pdf'
with pikepdf.new() as pdf:
pdf.add_blank_page()
pdf.save(test_pdf)
with pikepdf.open(test_pdf, allow_overwriting_input=True) as pdf:
add_srgb_output_intent(pdf)
add_srgb_output_intent(pdf) # Second call should be a no-op
assert len(pdf.Root.OutputIntents) == 1
pdf.save(test_pdf)
class TestSpeculativePdfaConversion:
"""Tests for speculative PDF/A conversion."""
def test_speculative_conversion_creates_pdfa_structures(self, tmp_path, resources):
"""Test that speculative conversion adds PDF/A structures."""
input_pdf = resources / 'graph.pdf'
output_pdf = tmp_path / 'output.pdf'
result = speculative_pdfa_conversion(input_pdf, output_pdf, 'pdfa-2')
assert result.exists()
with pikepdf.open(result) as pdf:
assert Name.OutputIntents in pdf.Root
with pdf.open_metadata() as meta:
assert meta.pdfa_status == '2B'
def test_speculative_conversion_different_parts(self, tmp_path, resources):
"""Test speculative conversion with different PDF/A parts."""
input_pdf = resources / 'graph.pdf'
for output_type, expected_status in [
('pdfa-1', '1B'),
('pdfa-2', '2B'),
('pdfa-3', '3B'),
]:
output_pdf = tmp_path / f'output_{output_type}.pdf'
speculative_pdfa_conversion(input_pdf, output_pdf, output_type)
with pikepdf.open(output_pdf) as pdf:
with pdf.open_metadata() as meta:
assert meta.pdfa_status == expected_status
@pytest.mark.skipif(not verapdf.available(), reason='verapdf not installed')
class TestVerapdfIntegration:
"""Integration tests requiring verapdf."""
def test_speculative_conversion_validation(self, tmp_path, resources):
"""Test that speculative conversion can be validated by verapdf.
Note: Most test PDFs will fail validation because they have issues
that require Ghostscript to fix (fonts, colorspaces, etc.). This test
verifies the validation pipeline works, not that all PDFs pass.
"""
input_pdf = resources / 'graph.pdf'
output_pdf = tmp_path / 'output.pdf'
speculative_pdfa_conversion(input_pdf, output_pdf, 'pdfa-2')
# The converted file can be validated (even if it fails)
result = verapdf.validate(output_pdf, '2b')
assert isinstance(result.valid, bool)
assert isinstance(result.failed_rules, int)