From 43d344870925275999cb30e543daf4933603723b Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Tue, 30 Jun 2026 01:08:04 -0700 Subject: [PATCH] Make --output-type auto fall back to Ghostscript for PDF/A --- src/ocrmypdf/_pipeline.py | 70 ++++++++++++++++++++++++++++--------- tests/test_pdfa.py | 72 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 17 deletions(-) diff --git a/src/ocrmypdf/_pipeline.py b/src/ocrmypdf/_pipeline.py index 08ee2220..2da12769 100644 --- a/src/ocrmypdf/_pipeline.py +++ b/src/ocrmypdf/_pipeline.py @@ -32,12 +32,14 @@ from ocrmypdf._options import OcrOptions, ProcessingMode, TaggedPdfMode from ocrmypdf._pageboxes import log_box_repairs, repair_page_boxes from ocrmypdf._stdoutprotect import get_protected_stdout_fd from ocrmypdf.exceptions import ( + ColorConversionNeededError, DigitalSignatureError, DpiError, EncryptedPdfError, InputFileError, NonEmbeddedFontsError, PriorOcrFoundError, + SubprocessOutputError, TaggedPDFError, UnsupportedImageFormatError, ) @@ -1078,14 +1080,46 @@ def try_speculative_pdfa(input_pdf: Path, context: PdfContext) -> Path | None: return None +def _ghostscript_pdfa_fallback(input_pdf: Path, context: PdfContext) -> Path | None: + """Best-effort PDF/A conversion via Ghostscript for 'auto' output type. + + Returns the converted PDF/A path, or None if Ghostscript is unavailable, + fails, or cannot produce valid PDF/A. Never raises: 'auto' mode degrades to + a regular PDF instead of erroring or emitting corrupted output. + + Args: + input_pdf: Path to the PDF to convert. + context: The PDF context. + """ + from ocrmypdf._exec import ghostscript + + if not ghostscript.available(): + return None + try: + ps_stub = generate_postscript_stub(context) + gs_out = convert_to_pdfa(input_pdf, ps_stub, context) + except ( + SubprocessOutputError, + ColorConversionNeededError, + NonEmbeddedFontsError, + ) as e: + log.info('Auto mode: Ghostscript could not produce PDF/A (%s)', e) + return None + if not file_claims_pdfa(gs_out)['pass']: + log.info('Auto mode: Ghostscript output is not valid PDF/A') + return None + return gs_out + + def try_auto_pdfa(input_pdf: Path, context: PdfContext) -> tuple[Path, str]: """Best-effort PDF/A for 'auto' output type. - This function attempts to produce PDF/A without requiring Ghostscript: - 1. If verapdf is available, tries speculative conversion with validation - 2. Without verapdf, passes through as PDF/A if safe (input already PDF/A - or force-ocr was used) - 3. Falls back to regular PDF if neither condition is met + Order of attempts, first success wins: + 1. Non-embedded CID fonts -> regular PDF (Ghostscript would corrupt them). + 2. Speculative conversion validated by verapdf (no Ghostscript). + 3. Without verapdf, pass through if already PDF/A or rebuilt with force-ocr. + 4. Ghostscript conversion (best-effort; failures fall through). + 5. Regular PDF if none of the above produced PDF/A. Args: input_pdf: Path to the PDF to convert @@ -1112,25 +1146,27 @@ def try_auto_pdfa(input_pdf: Path, context: PdfContext) -> tuple[Path, str]: ) return (input_pdf, 'pdf') - # If verapdf available, try speculative conversion with validation + # Cheap path: speculative conversion validated by verapdf (no Ghostscript). if verapdf.available(): result = try_speculative_pdfa(input_pdf, context) if result is not None: return (result, 'pdfa') - # verapdf validation failed - fall through to regular PDF - log.info( - 'Auto mode: speculative PDF/A validation failed, outputting regular PDF' - ) - return (input_pdf, 'pdf') - - # Without verapdf, check if we can pass through as PDF/A - if _is_safe_pdfa(input_pdf, context.options): - # Pass through as-is (no modifications needed) + log.info('Auto mode: speculative PDF/A validation failed') + elif _is_safe_pdfa(input_pdf, context.options): + # No verapdf, but the input is already PDF/A or was rebuilt with + # --force-ocr, so we can pass it through without Ghostscript. log.info('Auto mode: passing through as PDF/A (input already compliant)') return (input_pdf, 'pdfa') - # Fall through to regular PDF - log.info('Auto mode: no verapdf available and input is not PDF/A, outputting PDF') + # Fall back to Ghostscript to produce real PDF/A (v16 behavior). Best-effort: + # if Ghostscript is unavailable or cannot safely produce PDF/A, keep a + # regular PDF rather than error. + gs_out = _ghostscript_pdfa_fallback(input_pdf, context) + if gs_out is not None: + log.info('Auto mode: produced PDF/A via Ghostscript') + return (gs_out, 'pdfa') + + log.info('Auto mode: could not produce PDF/A, outputting regular PDF') return (input_pdf, 'pdf') diff --git a/tests/test_pdfa.py b/tests/test_pdfa.py index a93cd361..055d14fc 100644 --- a/tests/test_pdfa.py +++ b/tests/test_pdfa.py @@ -150,6 +150,78 @@ def test_auto_downgrades_nonembedded_cid_font_to_pdf(nonembedded_cid_pdf, outpdf assert find_nonembedded_cid_fonts(pdf) +def test_auto_falls_back_to_ghostscript_for_pdfa(resources, outpdf, monkeypatch): + """Auto mode produces PDF/A via Ghostscript when the cheap path can't.""" + # Force the speculative (veraPDF) path off so the fallback is exercised. + monkeypatch.setattr('ocrmypdf._exec.verapdf.available', lambda: False) + check_ocrmypdf( + resources / 'francais.pdf', + outpdf, + '--plugin', + 'tests/plugins/tesseract_noop.py', + '--output-type', + 'auto', + ) + assert file_claims_pdfa(outpdf)['pass'] + + +def test_auto_outputs_pdf_when_ghostscript_unavailable(resources, outpdf, monkeypatch): + """With neither veraPDF nor Ghostscript, auto outputs a plain PDF.""" + monkeypatch.setattr('ocrmypdf._exec.verapdf.available', lambda: False) + monkeypatch.setattr('ocrmypdf._exec.ghostscript.available', lambda: False) + check_ocrmypdf( + resources / 'francais.pdf', + outpdf, + '--plugin', + 'tests/plugins/tesseract_noop.py', + '--output-type', + 'auto', + ) + assert not file_claims_pdfa(outpdf)['pass'] + + +def test_auto_degrades_when_ghostscript_cannot_make_pdfa( + resources, outpdf, monkeypatch +): + """If Ghostscript produces non-PDF/A output, auto keeps a plain PDF (no error).""" + monkeypatch.setattr('ocrmypdf._exec.verapdf.available', lambda: False) + exitcode = run_ocrmypdf_api( + resources / 'francais.pdf', + outpdf, + '--plugin', + 'tests/plugins/tesseract_noop.py', + '--plugin', + 'tests/plugins/gs_pdfa_failure.py', + '--output-type', + 'auto', + ) + assert exitcode == ExitCode.ok + assert outpdf.exists() + assert not file_claims_pdfa(outpdf)['pass'] + + +def test_auto_degrades_when_ghostscript_raises(resources, outpdf, monkeypatch): + """A Ghostscript conversion exception in auto mode degrades to plain PDF.""" + from ocrmypdf.exceptions import ColorConversionNeededError + + monkeypatch.setattr('ocrmypdf._exec.verapdf.available', lambda: False) + + def boom(*args, **kwargs): + raise ColorConversionNeededError() + + monkeypatch.setattr('ocrmypdf._pipeline.convert_to_pdfa', boom) + exitcode = run_ocrmypdf_api( + resources / 'francais.pdf', + outpdf, + '--plugin', + 'tests/plugins/tesseract_noop.py', + '--output-type', + 'auto', + ) + assert exitcode == ExitCode.ok + assert not file_claims_pdfa(outpdf)['pass'] + + @pytest.mark.parametrize('optimize', (0, 3)) @pytest.mark.parametrize('pdfa_level', (1, 2, 3)) def test_pdfa(resources, outpdf, optimize, pdfa_level):