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()
109 lines
2.9 KiB
Python
109 lines
2.9 KiB
Python
# 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}')
|