From 11de13ecfe80ddd7f9f06c6ab9f1b5ae8e32db34 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Wed, 10 Jun 2026 23:39:03 -0700 Subject: [PATCH] feat: anti-alias Ghostscript rasterization to improve OCR quality Ghostscript 10.x renders aliased glyphs that OCR frequently misreads as extra word breaks or substituted characters. Enable text and graphics anti-aliasing (-dTextAlphaBits=4 -dGraphicsAlphaBits=4) for the contone raster devices, which empirically improves OCR accuracy on the Ghostscript path, especially for small fonts at moderate DPI. The 1-bit mono devices are excluded, since older Ghostscript rejects alpha bits on them and pngmonod performs its own anti-aliased downscaling. Also log which rasterizer rendered each page at debug verbosity and clarify the --rasterizer help text, so quality reports are easier to diagnose. The default rasterizer (auto) already prefers pypdfium2, which anti-aliases; this primarily benefits --rasterizer ghostscript and installs without pypdfium2. Closes #1439. --- docs/releasenotes/version17.md | 14 ++++++ src/ocrmypdf/_exec/ghostscript.py | 14 ++++++ src/ocrmypdf/builtin_plugins/ghostscript.py | 2 + src/ocrmypdf/builtin_plugins/pypdfium.py | 2 + src/ocrmypdf/cli.py | 10 ++-- tests/test_ghostscript.py | 56 +++++++++++++++++++++ 6 files changed, 95 insertions(+), 3 deletions(-) diff --git a/docs/releasenotes/version17.md b/docs/releasenotes/version17.md index dbbf8c72..546e966d 100644 --- a/docs/releasenotes/version17.md +++ b/docs/releasenotes/version17.md @@ -62,6 +62,20 @@ better input for OCR on faint or anti-aliased scans at negligible cost and no change to output file size, since the rasterized image is an intermediate that is discarded after OCR. +- When rasterizing pages with Ghostscript, OCRmyPDF now enables text and + graphics anti-aliasing (``-dTextAlphaBits=4 -dGraphicsAlphaBits=4``) for the + grayscale and color raster devices. Ghostscript 10.x renders aliased glyphs + that OCR frequently misreads as extra word breaks or substituted characters; + anti-aliasing materially improves OCR accuracy on the Ghostscript + rasterization path, especially for small fonts at moderate resolution. The + 1-bit monochrome devices are unaffected, since they perform their own + anti-aliased downscaling and older Ghostscript versions reject alpha-bit + options on them. Note that the default rasterizer (``--rasterizer auto``) + prefers pypdfium2, which already anti-aliases; this change benefits users who + select ``--rasterizer ghostscript`` or do not have pypdfium2 installed. + OCRmyPDF now also logs which rasterizer rendered each page at debug verbosity + (``-v 1``), and the ``--rasterizer`` help text explains the OCR-quality + trade-off, to make such reports easier to diagnose. {issue}`1439` ## v17.5.0 diff --git a/src/ocrmypdf/_exec/ghostscript.py b/src/ocrmypdf/_exec/ghostscript.py index e645743a..d9fc9dc1 100644 --- a/src/ocrmypdf/_exec/ghostscript.py +++ b/src/ocrmypdf/_exec/ghostscript.py @@ -150,6 +150,19 @@ def rasterize_pdf( else: effective_dpi = raster_dpi + # Anti-alias text and vector graphics when rendering to a contone device. + # Ghostscript 10.x renders aliased glyphs that OCR frequently misreads as + # extra word breaks; anti-aliasing empirically improves OCR accuracy on the + # Ghostscript path, especially for small fonts at moderate DPI (#1439). + # The 1-bit mono devices do not accept alpha bits (older Ghostscript + # rejects them) and pngmonod performs its own anti-aliased downscaling. + mono_devices = (GhostscriptRasterDevice.PNGMONO, GhostscriptRasterDevice.PNGMONOD) + antialias_args = ( + [] + if raster_device in mono_devices + else ['-dTextAlphaBits=4', '-dGraphicsAlphaBits=4'] + ) + args_gs = ( [ GS, @@ -162,6 +175,7 @@ def rasterize_pdf( f'-dLastPage={pageno}', f'-r{effective_dpi.x:f}x{effective_dpi.y:f}', ] + + antialias_args + (['-dUseCropBox'] if use_cropbox else []) + (['-dFILTERVECTOR'] if filter_vector else []) + (['-dPDFSTOPONERROR'] if stop_on_error else []) diff --git a/src/ocrmypdf/builtin_plugins/ghostscript.py b/src/ocrmypdf/builtin_plugins/ghostscript.py index a46002ce..d2f12313 100644 --- a/src/ocrmypdf/builtin_plugins/ghostscript.py +++ b/src/ocrmypdf/builtin_plugins/ghostscript.py @@ -256,6 +256,8 @@ def rasterize_pdf_page( # Let pypdfium handle it (it will error in check_options if unavailable) return None + log.debug("Rasterizing page %d with the Ghostscript rasterizer", pageno) + ghostscript.rasterize_pdf( input_file, output_file, diff --git a/src/ocrmypdf/builtin_plugins/pypdfium.py b/src/ocrmypdf/builtin_plugins/pypdfium.py index 973b40c9..58764a6d 100644 --- a/src/ocrmypdf/builtin_plugins/pypdfium.py +++ b/src/ocrmypdf/builtin_plugins/pypdfium.py @@ -255,6 +255,8 @@ def rasterize_pdf_page( if pdfium is None: return None # Fall back to Ghostscript + log.debug("Rasterizing page %d with the pypdfium2 rasterizer", pageno) + # Acquire lock to ensure thread-safe access to pypdfium2 with ( _pdfium_lock, diff --git a/src/ocrmypdf/cli.py b/src/ocrmypdf/cli.py index c544fd01..31756b4c 100644 --- a/src/ocrmypdf/cli.py +++ b/src/ocrmypdf/cli.py @@ -423,9 +423,13 @@ Online documentation is located at: '--rasterizer', choices=['auto', 'ghostscript', 'pypdfium'], default='auto', - help="Choose PDF page rasterizer. 'auto' prefers pypdfium when available, " - "falling back to Ghostscript. 'pypdfium' is faster but requires the " - "pypdfium2 package. 'ghostscript' uses the traditional Ghostscript rasterizer.", + help="Choose PDF page rasterizer. 'auto' (the default) prefers pypdfium2 " + "when the pypdfium2 package is installed, falling back to Ghostscript " + "otherwise. pypdfium2 anti-aliases page content and generally produces " + "better input for OCR than Ghostscript 10.x, which can render aliased " + "glyphs that OCR misreads as extra word breaks. 'pypdfium' forces the " + "pypdfium2 rasterizer (requires the pypdfium2 package); 'ghostscript' " + "forces the traditional Ghostscript rasterizer.", ) advanced.add_argument( '--rotate-pages-threshold', diff --git a/tests/test_ghostscript.py b/tests/test_ghostscript.py index 7878ffe1..3cd8cd7a 100644 --- a/tests/test_ghostscript.py +++ b/tests/test_ghostscript.py @@ -141,6 +141,62 @@ def test_rasterize_low_dpi_one_axis(francais, outdir): assert im.info['dpi'] == forced_dpi +def _capture_rasterize_args(resources, outdir, raster_device): + """Run rasterize_pdf with the gs subprocess mocked; return the gs argv.""" + out = outdir / 'out.png' + captured = {} + + def fake_run(args, **kwargs): + captured['args'] = list(args) + # Produce a valid PNG so rasterize_pdf's post-processing succeeds. + Image.new('RGB', (2, 2)).save(out) + return subprocess.CompletedProcess(args, returncode=0, stdout=b'', stderr=b'') + + with patch('ocrmypdf._exec.ghostscript.run', side_effect=fake_run): + rasterize_pdf( + resources / 'francais.pdf', + out, + raster_device=raster_device, + raster_dpi=Resolution(150.0, 150.0), + ) + return captured['args'] + + +@pytest.mark.parametrize( + 'raster_device', + [ + GhostscriptRasterDevice.PNGGRAY, + GhostscriptRasterDevice.PNG256, + GhostscriptRasterDevice.PNG16M, + ], +) +def test_rasterize_antialiases_contone_devices(resources, outdir, raster_device): + """Contone raster devices receive anti-aliasing flags to aid OCR. + + Ghostscript 10.x renders aliased glyphs that OCR misreads as extra word + breaks; -dTextAlphaBits/-dGraphicsAlphaBits markedly improve accuracy, + especially for small fonts at moderate DPI (see issue #1439). + """ + args = _capture_rasterize_args(resources, outdir, raster_device) + assert '-dTextAlphaBits=4' in args + assert '-dGraphicsAlphaBits=4' in args + + +@pytest.mark.parametrize( + 'raster_device', + [GhostscriptRasterDevice.PNGMONO, GhostscriptRasterDevice.PNGMONOD], +) +def test_rasterize_no_antialias_on_mono_devices(resources, outdir, raster_device): + """1-bit mono devices must not receive alpha-bit flags. + + Older Ghostscript versions reject -dTextAlphaBits on 1-bit devices, and + pngmonod performs its own anti-aliased downscaling. + """ + args = _capture_rasterize_args(resources, outdir, raster_device) + assert not any(a.startswith('-dTextAlphaBits') for a in args) + assert not any(a.startswith('-dGraphicsAlphaBits') for a in args) + + def test_generate_pdfa_default_jpeg_quality(outdir): """When jpeg_quality is None, Ghostscript receives -dJPEGQ=95 (default).""" with (