A page whose MediaBox has a non-zero origin (e.g. from PDF Arranger crops) was rendered blank by --force-ocr in v16.12.0, because fix_pagepdf_boxes offset the CropBox with the wrong sign, pushing it entirely outside the image-page MediaBox. The behavior was fixed in v17.0.0 but had no end-to-end test: the existing box tests only assert that ocrmypdf runs, which the blank-page bug passed (it exited 0 with a valid, empty PDF/A). Add a test that renders the output and asserts the visible content survives, parametrized over both the ghostscript and pypdfium rasterizers since the bug reproduced regardless of rasterizer.
794 lines
27 KiB
Python
794 lines
27 KiB
Python
# SPDX-FileCopyrightText: 2025 James R. Barlow
|
|
# SPDX-License-Identifier: MPL-2.0
|
|
|
|
"""Tests for the --rasterizer CLI option."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from io import BytesIO
|
|
|
|
import img2pdf
|
|
import pikepdf
|
|
import pytest
|
|
from PIL import Image
|
|
|
|
from ocrmypdf._exec import ghostscript
|
|
from ocrmypdf._options import OcrOptions
|
|
from ocrmypdf._plugin_manager import get_plugin_manager
|
|
from ocrmypdf.helpers import IMG2PDF_KWARGS, Resolution
|
|
from ocrmypdf.pluginspec import GhostscriptRasterDevice
|
|
|
|
from .conftest import check_ocrmypdf
|
|
|
|
# Check if pypdfium2 is available
|
|
try:
|
|
import pypdfium2 # noqa: F401
|
|
|
|
PYPDFIUM_AVAILABLE = True
|
|
except ImportError:
|
|
PYPDFIUM_AVAILABLE = False
|
|
|
|
|
|
class TestRasterizerOption:
|
|
"""Test the --rasterizer CLI option."""
|
|
|
|
def test_rasterizer_auto_default(self, resources, outpdf):
|
|
"""Test that --rasterizer auto (default) works."""
|
|
check_ocrmypdf(
|
|
resources / 'graph.pdf',
|
|
outpdf,
|
|
'--rasterizer',
|
|
'auto',
|
|
'--plugin',
|
|
'tests/plugins/tesseract_noop.py',
|
|
)
|
|
|
|
def test_rasterizer_ghostscript(self, resources, outpdf):
|
|
"""Test that --rasterizer ghostscript works."""
|
|
check_ocrmypdf(
|
|
resources / 'graph.pdf',
|
|
outpdf,
|
|
'--rasterizer',
|
|
'ghostscript',
|
|
'--plugin',
|
|
'tests/plugins/tesseract_noop.py',
|
|
)
|
|
|
|
@pytest.mark.skipif(not PYPDFIUM_AVAILABLE, reason="pypdfium2 not installed")
|
|
def test_rasterizer_pypdfium(self, resources, outpdf):
|
|
"""Test that --rasterizer pypdfium works when pypdfium2 is installed."""
|
|
check_ocrmypdf(
|
|
resources / 'graph.pdf',
|
|
outpdf,
|
|
'--rasterizer',
|
|
'pypdfium',
|
|
'--plugin',
|
|
'tests/plugins/tesseract_noop.py',
|
|
)
|
|
|
|
def test_rasterizer_invalid(self):
|
|
"""Test that an invalid rasterizer value is rejected."""
|
|
with pytest.raises(ValueError, match="rasterizer must be one of"):
|
|
OcrOptions(
|
|
input_file='test.pdf', output_file='out.pdf', rasterizer='invalid'
|
|
)
|
|
|
|
|
|
class TestRasterizerWithRotation:
|
|
"""Test --rasterizer interaction with --rotate-pages."""
|
|
|
|
def test_ghostscript_with_rotation(self, resources, outpdf):
|
|
"""Test Ghostscript rasterizer with page rotation."""
|
|
check_ocrmypdf(
|
|
resources / 'cardinal.pdf',
|
|
outpdf,
|
|
'--rasterizer',
|
|
'ghostscript',
|
|
'--rotate-pages',
|
|
'--rotate-pages-threshold',
|
|
'0.1',
|
|
'--plugin',
|
|
'tests/plugins/tesseract_cache.py',
|
|
)
|
|
|
|
@pytest.mark.skipif(not PYPDFIUM_AVAILABLE, reason="pypdfium2 not installed")
|
|
def test_pypdfium_with_rotation(self, resources, outpdf):
|
|
"""Test pypdfium rasterizer with page rotation."""
|
|
check_ocrmypdf(
|
|
resources / 'cardinal.pdf',
|
|
outpdf,
|
|
'--rasterizer',
|
|
'pypdfium',
|
|
'--rotate-pages',
|
|
'--rotate-pages-threshold',
|
|
'0.1',
|
|
'--plugin',
|
|
'tests/plugins/tesseract_cache.py',
|
|
)
|
|
|
|
def test_auto_with_rotation(self, resources, outpdf):
|
|
"""Test auto rasterizer with page rotation."""
|
|
check_ocrmypdf(
|
|
resources / 'cardinal.pdf',
|
|
outpdf,
|
|
'--rasterizer',
|
|
'auto',
|
|
'--rotate-pages',
|
|
'--rotate-pages-threshold',
|
|
'0.1',
|
|
'--plugin',
|
|
'tests/plugins/tesseract_cache.py',
|
|
)
|
|
|
|
|
|
class TestRasterizerHookDirect:
|
|
"""Test rasterize_pdf_page hook directly with different rasterizer options."""
|
|
|
|
def test_ghostscript_hook_respects_option(self, resources, tmp_path):
|
|
"""Test that Ghostscript hook returns None when pypdfium is requested."""
|
|
pm = get_plugin_manager([])
|
|
|
|
# Create options requesting pypdfium
|
|
options = OcrOptions(
|
|
input_file=resources / 'graph.pdf',
|
|
output_file=tmp_path / 'out.pdf',
|
|
rasterizer='pypdfium',
|
|
)
|
|
|
|
img = tmp_path / 'ghostscript_test.png'
|
|
result = pm.rasterize_pdf_page(
|
|
input_file=resources / 'graph.pdf',
|
|
output_file=img,
|
|
raster_device='pngmono',
|
|
raster_dpi=Resolution(50, 50),
|
|
page_dpi=Resolution(50, 50),
|
|
pageno=1,
|
|
rotation=0,
|
|
filter_vector=False,
|
|
stop_on_soft_error=True,
|
|
options=options,
|
|
use_cropbox=False,
|
|
)
|
|
# When pypdfium is requested:
|
|
# - If pypdfium IS available, pypdfium handles it and returns the path
|
|
# - If pypdfium is NOT available, both plugins return None
|
|
# (ghostscript returns None because pypdfium was requested,
|
|
# pypdfium returns None because it's not installed)
|
|
if PYPDFIUM_AVAILABLE:
|
|
assert result == img
|
|
else:
|
|
assert result is None
|
|
|
|
def test_pypdfium_hook_respects_option(self, resources, tmp_path):
|
|
"""Test that pypdfium hook returns None when ghostscript is requested."""
|
|
pm = get_plugin_manager([])
|
|
|
|
# Create options requesting ghostscript
|
|
options = OcrOptions(
|
|
input_file=resources / 'graph.pdf',
|
|
output_file=tmp_path / 'out.pdf',
|
|
rasterizer='ghostscript',
|
|
)
|
|
|
|
img = tmp_path / 'pypdfium_test.png'
|
|
result = pm.rasterize_pdf_page(
|
|
input_file=resources / 'graph.pdf',
|
|
output_file=img,
|
|
raster_device='pngmono',
|
|
raster_dpi=Resolution(50, 50),
|
|
page_dpi=Resolution(50, 50),
|
|
pageno=1,
|
|
rotation=0,
|
|
filter_vector=False,
|
|
stop_on_soft_error=True,
|
|
options=options,
|
|
use_cropbox=False,
|
|
)
|
|
# Ghostscript should handle it
|
|
assert result == img
|
|
assert img.exists()
|
|
|
|
def test_auto_uses_pypdfium_when_available(self, resources, tmp_path):
|
|
"""Test that auto mode uses pypdfium when available."""
|
|
pm = get_plugin_manager([])
|
|
|
|
options = OcrOptions(
|
|
input_file=resources / 'graph.pdf',
|
|
output_file=tmp_path / 'out.pdf',
|
|
rasterizer='auto',
|
|
)
|
|
|
|
img = tmp_path / 'auto_test.png'
|
|
result = pm.rasterize_pdf_page(
|
|
input_file=resources / 'graph.pdf',
|
|
output_file=img,
|
|
raster_device='pngmono',
|
|
raster_dpi=Resolution(50, 50),
|
|
page_dpi=Resolution(50, 50),
|
|
pageno=1,
|
|
rotation=0,
|
|
filter_vector=False,
|
|
stop_on_soft_error=True,
|
|
options=options,
|
|
use_cropbox=False,
|
|
)
|
|
assert result == img
|
|
assert img.exists()
|
|
|
|
@pytest.mark.skipif(not PYPDFIUM_AVAILABLE, reason="pypdfium2 not installed")
|
|
def test_pypdfium_pngmonod_produces_1bit(self, resources, tmp_path):
|
|
"""Pngmonod is treated like pngmono by pypdfium: it yields a 1-bit PNG."""
|
|
pm = get_plugin_manager([])
|
|
options = OcrOptions(
|
|
input_file=resources / 'graph.pdf',
|
|
output_file=tmp_path / 'out.pdf',
|
|
rasterizer='pypdfium',
|
|
)
|
|
|
|
img = tmp_path / 'pngmonod_test.png'
|
|
result = pm.rasterize_pdf_page(
|
|
input_file=resources / 'graph.pdf',
|
|
output_file=img,
|
|
raster_device='pngmonod',
|
|
raster_dpi=Resolution(50, 50),
|
|
page_dpi=Resolution(50, 50),
|
|
pageno=1,
|
|
rotation=0,
|
|
filter_vector=False,
|
|
stop_on_soft_error=True,
|
|
options=options,
|
|
use_cropbox=False,
|
|
)
|
|
assert result == img
|
|
with Image.open(img) as im:
|
|
assert im.mode == '1'
|
|
|
|
|
|
def _make_text_mask_pdf(path, fill: bytes):
|
|
"""Build a letter page with a large text image mask painted with ``fill``.
|
|
|
|
The mask is a 1-bit stencil; ``fill`` is the color operator sequence that
|
|
sets the paint color (e.g. ``b"0.263 0.263 0.263 rg"``). With a gray fill
|
|
this reproduces issue #1688: the text is mid-gray, which is dithered into
|
|
noise if rasterized to 1-bit but reads correctly once promoted to gray.
|
|
"""
|
|
from importlib.resources import as_file, files
|
|
|
|
from PIL import ImageDraw, ImageFont
|
|
|
|
w, h = 1700, 600
|
|
im = Image.new('1', (w, h), 1) # 1 = white = "do not paint" under Decode [0 1]
|
|
draw = ImageDraw.Draw(im)
|
|
# Use a font bundled with ocrmypdf so this test is portable across platforms;
|
|
# system fonts like DejaVu are not present on macOS/Windows CI runners.
|
|
with as_file(files('ocrmypdf.data') / 'NotoSans-Regular.ttf') as font_path:
|
|
font = ImageFont.truetype(str(font_path), 220)
|
|
draw.text((40, 120), "TESTING", fill=0, font=font)
|
|
|
|
packed = im.tobytes() # 1-bpc, rows byte-padded, MSB first
|
|
pdf = pikepdf.Pdf.new()
|
|
pdf.add_blank_page(page_size=(612, 792))
|
|
mask = pikepdf.Stream(pdf, packed)
|
|
mask.Type = pikepdf.Name.XObject
|
|
mask.Subtype = pikepdf.Name.Image
|
|
mask.Width = w
|
|
mask.Height = h
|
|
mask.ImageMask = True
|
|
mask.BitsPerComponent = 1
|
|
name = pdf.pages[0].add_resource(mask, pikepdf.Name.XObject)
|
|
pdf.pages[0].Contents = pikepdf.Stream(
|
|
pdf, b"q 560 0 0 200 26 500 cm %s %s Do Q" % (fill, bytes(name))
|
|
)
|
|
pdf.save(path)
|
|
return path
|
|
|
|
|
|
@pytest.mark.parametrize("rasterizer", ['ghostscript', 'pypdfium'])
|
|
def test_gray_mask_ocrs_to_text(tmp_path, rasterizer):
|
|
"""A gray-painted text mask OCRs to real text on both rasterizers (#1688)."""
|
|
if rasterizer == 'pypdfium' and not PYPDFIUM_AVAILABLE:
|
|
pytest.skip("pypdfium2 not installed")
|
|
|
|
src = _make_text_mask_pdf(tmp_path / 'mask.pdf', b"0.263 0.263 0.263 rg")
|
|
out = tmp_path / 'out.pdf'
|
|
sidecar = tmp_path / 'out.txt'
|
|
check_ocrmypdf(
|
|
src,
|
|
out,
|
|
'--rasterizer',
|
|
rasterizer,
|
|
'--sidecar',
|
|
str(sidecar),
|
|
'--oversample',
|
|
'300',
|
|
)
|
|
assert 'TESTING' in sidecar.read_text().upper()
|
|
|
|
|
|
def _create_gradient_image(width: int, height: int) -> Image.Image:
|
|
"""Create an image with multiple gradients to detect rasterization errors.
|
|
|
|
The image contains:
|
|
- Horizontal gradient from red to blue
|
|
- Vertical gradient overlay from green to transparent
|
|
- Diagonal bands for edge detection
|
|
"""
|
|
img = Image.new('RGB', (width, height))
|
|
pixels = img.load()
|
|
|
|
for y in range(height):
|
|
for x in range(width):
|
|
# Horizontal gradient: red to blue
|
|
r = int(255 * (1 - x / width))
|
|
b = int(255 * (x / width))
|
|
|
|
# Vertical gradient: add green component
|
|
g = int(255 * (y / height))
|
|
|
|
# Add diagonal bands for edge detection
|
|
band = ((x + y) // 20) % 2
|
|
if band:
|
|
r = min(255, r + 40)
|
|
g = min(255, g + 40)
|
|
b = min(255, b + 40)
|
|
|
|
pixels[x, y] = (r, g, b)
|
|
|
|
return img
|
|
|
|
|
|
@pytest.fixture
|
|
def pdf_with_nonstandard_boxes(tmp_path):
|
|
"""Create a PDF with nonstandard MediaBox, TrimBox and CropBox."""
|
|
# Create an image with gradients to detect rasterization errors
|
|
img = _create_gradient_image(200, 300)
|
|
img_bytes = BytesIO()
|
|
img.save(img_bytes, format='PNG')
|
|
img_bytes.seek(0)
|
|
|
|
# Convert to PDF
|
|
pdf_bytes = BytesIO()
|
|
img2pdf.convert(
|
|
img_bytes.read(),
|
|
layout_fun=img2pdf.get_fixed_dpi_layout_fun((72, 72)),
|
|
outputstream=pdf_bytes,
|
|
**IMG2PDF_KWARGS,
|
|
)
|
|
pdf_bytes.seek(0)
|
|
|
|
# Modify the PDF to have nonstandard boxes
|
|
pdf_path = tmp_path / 'nonstandard_boxes.pdf'
|
|
with pikepdf.open(pdf_bytes) as pdf:
|
|
page = pdf.pages[0]
|
|
# Set MediaBox larger than content
|
|
page.MediaBox = pikepdf.Array([0, 0, 400, 500])
|
|
# Set CropBox smaller - this is what viewers typically show
|
|
page.CropBox = pikepdf.Array([50, 50, 350, 450])
|
|
# Set TrimBox even smaller - indicates intended trim area
|
|
page.TrimBox = pikepdf.Array([75, 75, 325, 425])
|
|
pdf.save(pdf_path)
|
|
|
|
return pdf_path
|
|
|
|
|
|
@pytest.fixture
|
|
def pdf_with_negative_mediabox(tmp_path):
|
|
"""Create a PDF with MediaBox that has negative origin coordinates."""
|
|
# Create an image with gradients to detect rasterization errors
|
|
img = _create_gradient_image(200, 300)
|
|
img_bytes = BytesIO()
|
|
img.save(img_bytes, format='PNG')
|
|
img_bytes.seek(0)
|
|
|
|
pdf_bytes = BytesIO()
|
|
img2pdf.convert(
|
|
img_bytes.read(),
|
|
layout_fun=img2pdf.get_fixed_dpi_layout_fun((72, 72)),
|
|
outputstream=pdf_bytes,
|
|
**IMG2PDF_KWARGS,
|
|
)
|
|
pdf_bytes.seek(0)
|
|
|
|
pdf_path = tmp_path / 'negative_mediabox.pdf'
|
|
with pikepdf.open(pdf_bytes) as pdf:
|
|
page = pdf.pages[0]
|
|
# MediaBox with negative origin (valid PDF but unusual)
|
|
page.MediaBox = pikepdf.Array([-100, -100, 300, 400])
|
|
pdf.save(pdf_path)
|
|
|
|
return pdf_path
|
|
|
|
|
|
class TestRasterizerWithNonStandardBoxes:
|
|
"""Test rasterizers with PDFs having nonstandard MediaBox/TrimBox/CropBox."""
|
|
|
|
def test_ghostscript_nonstandard_boxes(self, pdf_with_nonstandard_boxes, outpdf):
|
|
"""Test Ghostscript handles nonstandard page boxes correctly."""
|
|
check_ocrmypdf(
|
|
pdf_with_nonstandard_boxes,
|
|
outpdf,
|
|
'--rasterizer',
|
|
'ghostscript',
|
|
'--plugin',
|
|
'tests/plugins/tesseract_noop.py',
|
|
)
|
|
|
|
@pytest.mark.skipif(not PYPDFIUM_AVAILABLE, reason="pypdfium2 not installed")
|
|
def test_pypdfium_nonstandard_boxes(self, pdf_with_nonstandard_boxes, outpdf):
|
|
"""Test pypdfium handles nonstandard page boxes correctly."""
|
|
check_ocrmypdf(
|
|
pdf_with_nonstandard_boxes,
|
|
outpdf,
|
|
'--rasterizer',
|
|
'pypdfium',
|
|
'--plugin',
|
|
'tests/plugins/tesseract_noop.py',
|
|
)
|
|
|
|
def test_ghostscript_negative_mediabox(self, pdf_with_negative_mediabox, outpdf):
|
|
"""Test Ghostscript handles negative MediaBox origin."""
|
|
check_ocrmypdf(
|
|
pdf_with_negative_mediabox,
|
|
outpdf,
|
|
'--rasterizer',
|
|
'ghostscript',
|
|
'--plugin',
|
|
'tests/plugins/tesseract_noop.py',
|
|
)
|
|
|
|
@pytest.mark.skipif(not PYPDFIUM_AVAILABLE, reason="pypdfium2 not installed")
|
|
def test_pypdfium_negative_mediabox(self, pdf_with_negative_mediabox, outpdf):
|
|
"""Test pypdfium handles negative MediaBox origin."""
|
|
check_ocrmypdf(
|
|
pdf_with_negative_mediabox,
|
|
outpdf,
|
|
'--rasterizer',
|
|
'pypdfium',
|
|
'--plugin',
|
|
'tests/plugins/tesseract_noop.py',
|
|
)
|
|
|
|
def test_compare_rasterizers_nonstandard_boxes(
|
|
self, pdf_with_nonstandard_boxes, tmp_path
|
|
):
|
|
"""Compare output dimensions between rasterizers for nonstandard boxes."""
|
|
pm = get_plugin_manager([])
|
|
|
|
options_gs = OcrOptions(
|
|
input_file=pdf_with_nonstandard_boxes,
|
|
output_file=tmp_path / 'out_gs.pdf',
|
|
rasterizer='ghostscript',
|
|
)
|
|
|
|
img_gs = tmp_path / 'gs.png'
|
|
pm.rasterize_pdf_page(
|
|
input_file=pdf_with_nonstandard_boxes,
|
|
output_file=img_gs,
|
|
raster_device='png16m',
|
|
raster_dpi=Resolution(72, 72),
|
|
page_dpi=Resolution(72, 72),
|
|
pageno=1,
|
|
rotation=0,
|
|
filter_vector=False,
|
|
stop_on_soft_error=True,
|
|
options=options_gs,
|
|
use_cropbox=False,
|
|
)
|
|
|
|
with Image.open(img_gs) as im_gs:
|
|
gs_size = im_gs.size
|
|
|
|
if PYPDFIUM_AVAILABLE:
|
|
options_pdfium = OcrOptions(
|
|
input_file=pdf_with_nonstandard_boxes,
|
|
output_file=tmp_path / 'out_pdfium.pdf',
|
|
rasterizer='pypdfium',
|
|
)
|
|
|
|
img_pdfium = tmp_path / 'pdfium.png'
|
|
pm.rasterize_pdf_page(
|
|
input_file=pdf_with_nonstandard_boxes,
|
|
output_file=img_pdfium,
|
|
raster_device='png16m',
|
|
raster_dpi=Resolution(72, 72),
|
|
page_dpi=Resolution(72, 72),
|
|
pageno=1,
|
|
rotation=0,
|
|
filter_vector=False,
|
|
stop_on_soft_error=True,
|
|
options=options_pdfium,
|
|
use_cropbox=False,
|
|
)
|
|
|
|
with Image.open(img_pdfium) as im_pdfium:
|
|
pdfium_size = im_pdfium.size
|
|
|
|
# Both rasterizers should now produce MediaBox dimensions (400x500)
|
|
# when use_cropbox=False (the default)
|
|
assert gs_size == (400, 500), f"Ghostscript size: {gs_size}"
|
|
assert pdfium_size == (400, 500), f"pypdfium size: {pdfium_size}"
|
|
|
|
|
|
@pytest.fixture
|
|
def pdf_with_offset_mediabox_origin(tmp_path):
|
|
"""Create a single-page PDF whose MediaBox has a non-zero origin.
|
|
|
|
Tools that crop/rotate non-destructively (e.g. PDF Arranger) shift the
|
|
MediaBox origin rather than re-rendering content, producing a MediaBox like
|
|
``[0, 440, 600, 800]`` with the visible content still inside the box. This
|
|
fixture reproduces that shape with a full-page gradient so the visible
|
|
region is unambiguously non-blank. Regression fixture for issue #1709.
|
|
"""
|
|
# Full-page gradient so the entire MediaBox region carries content.
|
|
img = _create_gradient_image(600, 800)
|
|
img_bytes = BytesIO()
|
|
img.save(img_bytes, format='PNG')
|
|
img_bytes.seek(0)
|
|
|
|
pdf_bytes = BytesIO()
|
|
img2pdf.convert(
|
|
img_bytes.read(),
|
|
layout_fun=img2pdf.get_fixed_dpi_layout_fun((72, 72)),
|
|
outputstream=pdf_bytes,
|
|
**IMG2PDF_KWARGS,
|
|
)
|
|
pdf_bytes.seek(0)
|
|
|
|
pdf_path = tmp_path / 'offset_mediabox_origin.pdf'
|
|
with pikepdf.open(pdf_bytes) as pdf:
|
|
page = pdf.pages[0]
|
|
# Shift the lower-left y origin so the box is [0, 440, 600, 800]: a
|
|
# 600x360 visible region whose content lies entirely within the box.
|
|
page.MediaBox = pikepdf.Array([0, 440, 600, 800])
|
|
page.CropBox = pikepdf.Array([0, 440, 600, 800])
|
|
pdf.save(pdf_path)
|
|
|
|
return pdf_path
|
|
|
|
|
|
def _nonwhite_fraction(pdf_path, png_path) -> float:
|
|
"""Rasterize page 1 of pdf_path and return the fraction of non-white pixels."""
|
|
ghostscript.rasterize_pdf(
|
|
pdf_path,
|
|
png_path,
|
|
raster_device=GhostscriptRasterDevice.PNGGRAY,
|
|
raster_dpi=Resolution(72, 72),
|
|
pageno=1,
|
|
rotation=0,
|
|
)
|
|
with Image.open(png_path) as im:
|
|
gray = im.convert('L')
|
|
histogram = gray.histogram()
|
|
total = sum(histogram)
|
|
# Treat near-white (>= 250) as background; everything else is page content.
|
|
nonwhite = sum(histogram[:250])
|
|
return nonwhite / total
|
|
|
|
|
|
class TestOffsetMediaBoxOrigin:
|
|
"""Regression tests for issue #1709.
|
|
|
|
A non-zero MediaBox origin (e.g. from PDF Arranger crops) must not cause
|
|
--force-ocr to drop the page content and emit a blank page. Both rasterizers
|
|
are covered because the bug surfaced regardless of which one rendered.
|
|
"""
|
|
|
|
@pytest.mark.parametrize(
|
|
'rasterizer',
|
|
[
|
|
'ghostscript',
|
|
pytest.param(
|
|
'pypdfium',
|
|
marks=pytest.mark.skipif(
|
|
not PYPDFIUM_AVAILABLE, reason="pypdfium2 not installed"
|
|
),
|
|
),
|
|
],
|
|
)
|
|
def test_force_ocr_preserves_offset_origin_content(
|
|
self, pdf_with_offset_mediabox_origin, rasterizer, outpdf, tmp_path
|
|
):
|
|
"""--force-ocr must preserve content when the MediaBox origin is non-zero."""
|
|
# Sanity check: the input genuinely has content in its visible region.
|
|
input_fraction = _nonwhite_fraction(
|
|
pdf_with_offset_mediabox_origin, tmp_path / 'input.png'
|
|
)
|
|
assert input_fraction > 0.5, "test fixture should have a non-blank page"
|
|
|
|
check_ocrmypdf(
|
|
pdf_with_offset_mediabox_origin,
|
|
outpdf,
|
|
'--force-ocr',
|
|
'--rasterizer',
|
|
rasterizer,
|
|
'--plugin',
|
|
'tests/plugins/tesseract_noop.py',
|
|
)
|
|
|
|
# The output page must not be blank: the visible content survives.
|
|
output_fraction = _nonwhite_fraction(outpdf, tmp_path / 'output.png')
|
|
assert output_fraction > 0.5, (
|
|
f"output page is blank (non-white fraction {output_fraction:.3f}); "
|
|
"content was dropped for a non-zero MediaBox origin (issue #1709)"
|
|
)
|
|
|
|
|
|
class TestRasterizerWithRotationAndBoxes:
|
|
"""Test rasterizer + rotation + nonstandard boxes combinations."""
|
|
|
|
# The pdf_with_nonstandard_boxes fixture creates a PDF with:
|
|
# - MediaBox: [0, 0, 400, 500] → 400x500 points
|
|
# - CropBox: [50, 50, 350, 450] → 300x400 points
|
|
# - TrimBox: [75, 75, 325, 425] → 250x350 points
|
|
#
|
|
# With use_cropbox=False (default), both rasterizers use MediaBox
|
|
MEDIABOX_WIDTH = 400
|
|
MEDIABOX_HEIGHT = 500
|
|
|
|
def _get_expected_size(self, rotation: int) -> tuple[int, int]:
|
|
"""Get expected image dimensions after rotation."""
|
|
width, height = self.MEDIABOX_WIDTH, self.MEDIABOX_HEIGHT
|
|
|
|
if rotation in (0, 180):
|
|
return (width, height)
|
|
else: # 90, 270
|
|
return (height, width)
|
|
|
|
def test_ghostscript_rotation_dimensions(
|
|
self, pdf_with_nonstandard_boxes, tmp_path
|
|
):
|
|
"""Test Ghostscript produces correct dimensions with rotation."""
|
|
pm = get_plugin_manager([])
|
|
|
|
options = OcrOptions(
|
|
input_file=pdf_with_nonstandard_boxes,
|
|
output_file=tmp_path / 'out.pdf',
|
|
rasterizer='ghostscript',
|
|
)
|
|
|
|
for rotation in [0, 90, 180, 270]:
|
|
img_path = tmp_path / f'gs_rot{rotation}.png'
|
|
pm.rasterize_pdf_page(
|
|
input_file=pdf_with_nonstandard_boxes,
|
|
output_file=img_path,
|
|
raster_device='png16m',
|
|
raster_dpi=Resolution(72, 72),
|
|
page_dpi=Resolution(72, 72),
|
|
pageno=1,
|
|
rotation=rotation,
|
|
filter_vector=False,
|
|
stop_on_soft_error=True,
|
|
options=options,
|
|
use_cropbox=False,
|
|
)
|
|
assert img_path.exists(), f"Failed to rasterize with rotation {rotation}"
|
|
|
|
with Image.open(img_path) as img:
|
|
expected = self._get_expected_size(rotation)
|
|
# Allow small tolerance for rounding
|
|
assert abs(img.size[0] - expected[0]) <= 2, (
|
|
f"Width mismatch at {rotation}°: got {img.size[0]}, "
|
|
f"expected {expected[0]}"
|
|
)
|
|
assert abs(img.size[1] - expected[1]) <= 2, (
|
|
f"Height mismatch at {rotation}°: got {img.size[1]}, "
|
|
f"expected {expected[1]}"
|
|
)
|
|
|
|
@pytest.mark.skipif(not PYPDFIUM_AVAILABLE, reason="pypdfium2 not installed")
|
|
def test_pypdfium_rotation_dimensions(self, pdf_with_nonstandard_boxes, tmp_path):
|
|
"""Test pypdfium produces correct dimensions with rotation."""
|
|
pm = get_plugin_manager([])
|
|
|
|
options = OcrOptions(
|
|
input_file=pdf_with_nonstandard_boxes,
|
|
output_file=tmp_path / 'out.pdf',
|
|
rasterizer='pypdfium',
|
|
)
|
|
|
|
for rotation in [0, 90, 180, 270]:
|
|
img_path = tmp_path / f'pdfium_rot{rotation}.png'
|
|
pm.rasterize_pdf_page(
|
|
input_file=pdf_with_nonstandard_boxes,
|
|
output_file=img_path,
|
|
raster_device='png16m',
|
|
raster_dpi=Resolution(72, 72),
|
|
page_dpi=Resolution(72, 72),
|
|
pageno=1,
|
|
rotation=rotation,
|
|
filter_vector=False,
|
|
stop_on_soft_error=True,
|
|
options=options,
|
|
use_cropbox=False,
|
|
)
|
|
assert img_path.exists(), f"Failed to rasterize with rotation {rotation}"
|
|
|
|
with Image.open(img_path) as img:
|
|
expected = self._get_expected_size(rotation)
|
|
# Allow small tolerance for rounding
|
|
assert abs(img.size[0] - expected[0]) <= 2, (
|
|
f"Width mismatch at {rotation}°: got {img.size[0]}, "
|
|
f"expected {expected[0]}"
|
|
)
|
|
assert abs(img.size[1] - expected[1]) <= 2, (
|
|
f"Height mismatch at {rotation}°: got {img.size[1]}, "
|
|
f"expected {expected[1]}"
|
|
)
|
|
|
|
@pytest.mark.skipif(not PYPDFIUM_AVAILABLE, reason="pypdfium2 not installed")
|
|
def test_rasterizers_produce_same_dimensions(
|
|
self, pdf_with_nonstandard_boxes, tmp_path
|
|
):
|
|
"""Verify ghostscript and pypdfium produce the same MediaBox dimensions.
|
|
|
|
With use_cropbox=False (the default), both rasterizers should render
|
|
to the MediaBox and produce identical dimensions.
|
|
"""
|
|
pm = get_plugin_manager([])
|
|
|
|
for rotation in [0, 90, 180, 270]:
|
|
# Rasterize with Ghostscript
|
|
gs_options = OcrOptions(
|
|
input_file=pdf_with_nonstandard_boxes,
|
|
output_file=tmp_path / 'out.pdf',
|
|
rasterizer='ghostscript',
|
|
)
|
|
gs_img_path = tmp_path / f'gs_cmp_rot{rotation}.png'
|
|
pm.rasterize_pdf_page(
|
|
input_file=pdf_with_nonstandard_boxes,
|
|
output_file=gs_img_path,
|
|
raster_device='png16m',
|
|
raster_dpi=Resolution(72, 72),
|
|
page_dpi=Resolution(72, 72),
|
|
pageno=1,
|
|
rotation=rotation,
|
|
filter_vector=False,
|
|
stop_on_soft_error=True,
|
|
options=gs_options,
|
|
use_cropbox=False,
|
|
)
|
|
|
|
# Rasterize with pypdfium
|
|
pdfium_options = OcrOptions(
|
|
input_file=pdf_with_nonstandard_boxes,
|
|
output_file=tmp_path / 'out.pdf',
|
|
rasterizer='pypdfium',
|
|
)
|
|
pdfium_img_path = tmp_path / f'pdfium_cmp_rot{rotation}.png'
|
|
pm.rasterize_pdf_page(
|
|
input_file=pdf_with_nonstandard_boxes,
|
|
output_file=pdfium_img_path,
|
|
raster_device='png16m',
|
|
raster_dpi=Resolution(72, 72),
|
|
page_dpi=Resolution(72, 72),
|
|
pageno=1,
|
|
rotation=rotation,
|
|
filter_vector=False,
|
|
stop_on_soft_error=True,
|
|
options=pdfium_options,
|
|
use_cropbox=False,
|
|
)
|
|
|
|
# Verify both produce the same MediaBox dimensions
|
|
with (
|
|
Image.open(gs_img_path) as gs_img,
|
|
Image.open(pdfium_img_path) as pdfium_img,
|
|
):
|
|
expected = self._get_expected_size(rotation)
|
|
|
|
assert abs(gs_img.size[0] - expected[0]) <= 2, (
|
|
f"GS width at {rotation}°: {gs_img.size[0]}, expected {expected[0]}"
|
|
)
|
|
assert abs(gs_img.size[1] - expected[1]) <= 2, (
|
|
f"GS height at {rotation}°: {gs_img.size[1]}, "
|
|
f"expected {expected[1]}"
|
|
)
|
|
assert abs(pdfium_img.size[0] - expected[0]) <= 2, (
|
|
f"pdfium width at {rotation}°: {pdfium_img.size[0]}, "
|
|
f"expected {expected[0]}"
|
|
)
|
|
assert abs(pdfium_img.size[1] - expected[1]) <= 2, (
|
|
f"pdfium height at {rotation}°: {pdfium_img.size[1]}, "
|
|
f"expected {expected[1]}"
|
|
)
|