mirror of
https://github.com/ocrmypdf/OCRmyPDF.git
synced 2026-08-02 00:19:21 -04:00
Validate and repair the page-boundary boxes (MediaBox, CropBox, TrimBox, ArtBox, BleedBox) of input PDFs in triage(), following PDF 2.0: - Coerce coordinates written in invalid exponential notation, stored by qpdf/pikepdf as strings (#1398). - Normalize rectangles whose corners are given in reversed order, which previously crashed with NegativeDimensionError (#1526). - Clamp a crop/trim/art/bleed box that extends outside the MediaBox to their intersection, or discard it when the intersection is empty, which previously produced a zero-height effective page some viewers rejected (#1400). When a box is discarded, clamped, or reinterpreted, a warning recommends visual inspection of the output. pdfinfo box reading shares the same coercion helper so PdfInfo no longer crashes on malformed boxes. Supersedes PR #1691. Thanks @ajdlinux. Closes #1398, #1526, #1400
275 lines
8.5 KiB
Python
275 lines
8.5 KiB
Python
# SPDX-FileCopyrightText: 2025 James R. Barlow
|
|
# SPDX-License-Identifier: MPL-2.0
|
|
|
|
from __future__ import annotations
|
|
|
|
import pikepdf
|
|
import pytest
|
|
|
|
from ocrmypdf._exec import verapdf
|
|
from ocrmypdf._pageboxes import repair_page_boxes
|
|
|
|
from .conftest import check_ocrmypdf
|
|
|
|
page_rect = [0, 0, 612, 792]
|
|
inset_rect = [200, 200, 612, 792]
|
|
wh_rect = [0, 0, 412, 592]
|
|
|
|
neg_rect = [-100, -100, 512, 692]
|
|
|
|
# When speculative PDF/A succeeds (verapdf available), MediaBox is preserved.
|
|
# Ghostscript would normalize MediaBox to start at origin, but speculative
|
|
# conversion bypasses Ghostscript.
|
|
_pdfa_inset_expected = inset_rect if verapdf.available() else wh_rect
|
|
|
|
mediabox_testdata = [
|
|
('fpdf2', 'pdfa', 'ccitt.pdf', None, inset_rect, _pdfa_inset_expected),
|
|
('sandwich', 'pdfa', 'ccitt.pdf', None, inset_rect, _pdfa_inset_expected),
|
|
('fpdf2', 'pdf', 'ccitt.pdf', None, inset_rect, inset_rect),
|
|
('sandwich', 'pdf', 'ccitt.pdf', None, inset_rect, inset_rect),
|
|
(
|
|
'fpdf2',
|
|
'pdfa',
|
|
'ccitt.pdf',
|
|
'--force-ocr',
|
|
inset_rect,
|
|
wh_rect,
|
|
),
|
|
(
|
|
'fpdf2',
|
|
'pdf',
|
|
'ccitt.pdf',
|
|
'--force-ocr',
|
|
inset_rect,
|
|
wh_rect,
|
|
),
|
|
('fpdf2', 'pdfa', 'ccitt.pdf', '--force-ocr', neg_rect, page_rect),
|
|
('fpdf2', 'pdf', 'ccitt.pdf', '--force-ocr', neg_rect, page_rect),
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
'renderer, output_type, in_pdf, mode, crop_to, crop_expected', mediabox_testdata
|
|
)
|
|
def test_media_box(
|
|
resources, outdir, renderer, output_type, in_pdf, mode, crop_to, crop_expected
|
|
):
|
|
with pikepdf.open(resources / in_pdf) as pdf:
|
|
page = pdf.pages[0]
|
|
page.MediaBox = crop_to
|
|
pdf.save(outdir / 'cropped.pdf')
|
|
args = [
|
|
'--jobs',
|
|
'1',
|
|
'--pdf-renderer',
|
|
renderer,
|
|
'--output-type',
|
|
output_type,
|
|
]
|
|
if mode:
|
|
args.append(mode)
|
|
|
|
check_ocrmypdf(outdir / 'cropped.pdf', outdir / 'processed.pdf', *args)
|
|
|
|
with pikepdf.open(outdir / 'processed.pdf') as pdf:
|
|
page = pdf.pages[0]
|
|
assert [float(x) for x in page.mediabox] == crop_expected
|
|
|
|
|
|
cropbox_testdata = [
|
|
('fpdf2', 'pdfa', 'ccitt.pdf', None, inset_rect, inset_rect),
|
|
('sandwich', 'pdfa', 'ccitt.pdf', None, inset_rect, inset_rect),
|
|
('fpdf2', 'pdf', 'ccitt.pdf', None, inset_rect, inset_rect),
|
|
('sandwich', 'pdf', 'ccitt.pdf', None, inset_rect, inset_rect),
|
|
(
|
|
'fpdf2',
|
|
'pdfa',
|
|
'ccitt.pdf',
|
|
'--force-ocr',
|
|
inset_rect,
|
|
inset_rect,
|
|
),
|
|
(
|
|
'fpdf2',
|
|
'pdf',
|
|
'ccitt.pdf',
|
|
'--force-ocr',
|
|
inset_rect,
|
|
inset_rect,
|
|
),
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
'renderer, output_type, in_pdf, mode, crop_to, crop_expected', cropbox_testdata
|
|
)
|
|
def test_crop_box(
|
|
resources, outdir, renderer, output_type, in_pdf, mode, crop_to, crop_expected
|
|
):
|
|
with pikepdf.open(resources / in_pdf) as pdf:
|
|
page = pdf.pages[0]
|
|
page.CropBox = crop_to
|
|
pdf.save(outdir / 'cropped.pdf')
|
|
args = [
|
|
'--jobs',
|
|
'1',
|
|
'--pdf-renderer',
|
|
renderer,
|
|
'--output-type',
|
|
output_type,
|
|
'--optimize',
|
|
'0',
|
|
]
|
|
if mode:
|
|
args.append(mode)
|
|
|
|
check_ocrmypdf(outdir / 'cropped.pdf', outdir / 'processed.pdf', *args)
|
|
|
|
with pikepdf.open(outdir / 'processed.pdf') as pdf:
|
|
page = pdf.pages[0]
|
|
assert [float(x) for x in page.cropbox] == crop_expected
|
|
|
|
|
|
# --- Unit tests for repair_page_boxes (issues #1398, #1526, #1400) ---
|
|
|
|
|
|
def _is_numeric(obj) -> bool:
|
|
try:
|
|
float(obj)
|
|
return True
|
|
except (TypeError, ValueError):
|
|
return False
|
|
|
|
|
|
def _one_page_pdf(**boxes):
|
|
"""Build a one-page PDF, setting the named boxes to the given arrays."""
|
|
pdf = pikepdf.new()
|
|
page = pdf.add_blank_page(page_size=(612, 792))
|
|
for name, rect in boxes.items():
|
|
setattr(page.obj, name, pikepdf.Array(rect))
|
|
return pdf, page
|
|
|
|
|
|
def test_repair_reversed_mediabox_is_normalized():
|
|
# #1526: diagonally-opposite corners given in reversed order
|
|
_pdf, page = _one_page_pdf(MediaBox=[0, 792, 612, 0])
|
|
repairs = repair_page_boxes(page)
|
|
assert [float(x) for x in page.obj.MediaBox] == [0, 0, 612, 792]
|
|
assert any(r.box == 'MediaBox' and r.kind == 'reordered' for r in repairs)
|
|
|
|
|
|
def test_repair_cropbox_entirely_outside_mediabox_is_discarded():
|
|
# #1400: CropBox lies entirely outside the MediaBox -> empty intersection
|
|
_pdf, page = _one_page_pdf(
|
|
MediaBox=[0, 0, 612, 792], CropBox=[1000, 1000, 1500, 1500]
|
|
)
|
|
repairs = repair_page_boxes(page)
|
|
assert '/CropBox' not in page.obj
|
|
assert any(r.box == 'CropBox' and r.kind == 'discarded' for r in repairs)
|
|
|
|
|
|
def test_repair_cropbox_partially_outside_mediabox_is_clamped():
|
|
_pdf, page = _one_page_pdf(MediaBox=[0, 0, 612, 792], CropBox=[200, 200, 800, 900])
|
|
repairs = repair_page_boxes(page)
|
|
assert [float(x) for x in page.obj.CropBox] == [200, 200, 612, 792]
|
|
assert any(r.box == 'CropBox' and r.kind == 'clamped' for r in repairs)
|
|
|
|
|
|
def test_repair_exponential_coordinate_is_coerced():
|
|
# #1398: a coordinate stored as a string in exponential notation
|
|
_pdf, page = _one_page_pdf(
|
|
MediaBox=[0, 0, 612, 792],
|
|
TrimBox=[pikepdf.String('3.05175781e-005'), 0, 612, 792],
|
|
)
|
|
repairs = repair_page_boxes(page)
|
|
trim = page.obj.TrimBox
|
|
assert all(_is_numeric(x) for x in trim)
|
|
assert float(trim[0]) == pytest.approx(3.05175781e-5, abs=1e-4)
|
|
assert any(r.box == 'TrimBox' and r.kind == 'recoded' for r in repairs)
|
|
|
|
|
|
def test_repair_degenerate_mediabox_is_reported():
|
|
_pdf, page = _one_page_pdf(MediaBox=[0, 0, 0, 792]) # zero width
|
|
repairs = repair_page_boxes(page)
|
|
assert any(r.box == 'MediaBox' and r.kind == 'degenerate_mediabox' for r in repairs)
|
|
|
|
|
|
def test_repair_valid_page_makes_no_changes():
|
|
_pdf, page = _one_page_pdf(MediaBox=[0, 0, 612, 792], CropBox=[10, 10, 600, 780])
|
|
repairs = repair_page_boxes(page)
|
|
assert repairs == []
|
|
assert [float(x) for x in page.obj.MediaBox] == [0, 0, 612, 792]
|
|
assert [float(x) for x in page.obj.CropBox] == [10, 10, 600, 780]
|
|
|
|
|
|
def test_summarize_box_repairs_aggregates_and_sets_severity():
|
|
import logging
|
|
|
|
from ocrmypdf._pageboxes import BoxRepair, summarize_box_repairs
|
|
|
|
repairs_by_page = {
|
|
0: [BoxRepair('CropBox', 'discarded')],
|
|
2: [BoxRepair('CropBox', 'discarded')],
|
|
3: [BoxRepair('CropBox', 'discarded')],
|
|
1: [BoxRepair('MediaBox', 'reordered')],
|
|
}
|
|
messages = summarize_box_repairs(repairs_by_page)
|
|
|
|
discard = [(lvl, m) for lvl, m in messages if 'discarded' in m]
|
|
assert len(discard) == 1
|
|
level, text = discard[0]
|
|
assert level == logging.WARNING
|
|
assert 'Page(s) 1, 3-4' in text # 0-based keys shown 1-based, ranges compacted
|
|
assert 'visually inspect' in text
|
|
|
|
reordered = [(lvl, m) for lvl, m in messages if 'reversed' in m]
|
|
assert len(reordered) == 1
|
|
assert reordered[0][0] == logging.DEBUG
|
|
assert 'visually inspect' not in reordered[0][1]
|
|
|
|
|
|
def test_cropbox_outside_mediabox_yields_valid_output(resources, outdir):
|
|
# #1400: a CropBox entirely outside the MediaBox produces an effective
|
|
# page of N x 0 pt; the pipeline must repair it to valid output.
|
|
with pikepdf.open(resources / 'ccitt.pdf') as pdf:
|
|
page = pdf.pages[0]
|
|
mb = [float(x) for x in page.mediabox]
|
|
page.CropBox = [mb[2] + 100, mb[3] + 100, mb[2] + 200, mb[3] + 200]
|
|
pdf.save(outdir / 'badcrop.pdf')
|
|
|
|
check_ocrmypdf(
|
|
outdir / 'badcrop.pdf',
|
|
outdir / 'out.pdf',
|
|
'--output-type',
|
|
'pdf',
|
|
'--optimize',
|
|
'0',
|
|
)
|
|
|
|
with pikepdf.open(outdir / 'out.pdf') as pdf:
|
|
cb = [float(x) for x in pdf.pages[0].cropbox] # resolves to MediaBox
|
|
assert (cb[2] - cb[0]) > 0 and (cb[3] - cb[1]) > 0
|
|
|
|
|
|
def test_reversed_mediabox_does_not_crash(resources, outdir):
|
|
# #1526: reversed MediaBox corners previously raised NegativeDimensionError.
|
|
with pikepdf.open(resources / 'ccitt.pdf') as pdf:
|
|
page = pdf.pages[0]
|
|
mb = [float(x) for x in page.mediabox]
|
|
page.MediaBox = [mb[0], mb[3], mb[2], mb[1]] # swap y corners
|
|
pdf.save(outdir / 'reversed.pdf')
|
|
|
|
check_ocrmypdf(
|
|
outdir / 'reversed.pdf',
|
|
outdir / 'out.pdf',
|
|
'--force-ocr',
|
|
'--output-type',
|
|
'pdf',
|
|
'--optimize',
|
|
'0',
|
|
)
|
|
|
|
with pikepdf.open(outdir / 'out.pdf') as pdf:
|
|
mb = [float(x) for x in pdf.pages[0].mediabox]
|
|
assert (mb[2] - mb[0]) > 0 and (mb[3] - mb[1]) > 0
|