From df849457736d75539a3dd27ef3f81d2b285e071a Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Tue, 9 Jun 2026 01:10:26 -0700 Subject: [PATCH] feat: validate and repair malformed page boxes 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 --- docs/releasenotes/version17.md | 12 ++ src/ocrmypdf/_pageboxes.py | 253 +++++++++++++++++++++++++++++++++ src/ocrmypdf/_pipeline.py | 7 + src/ocrmypdf/pdfinfo/info.py | 19 ++- tests/test_page_boxes.py | 145 +++++++++++++++++++ 5 files changed, 430 insertions(+), 6 deletions(-) create mode 100644 src/ocrmypdf/_pageboxes.py diff --git a/docs/releasenotes/version17.md b/docs/releasenotes/version17.md index 13ff143e..47369f3f 100644 --- a/docs/releasenotes/version17.md +++ b/docs/releasenotes/version17.md @@ -5,6 +5,18 @@ ## v17.6.0 +- OCRmyPDF now validates and repairs malformed page-boundary boxes + (``/MediaBox``, ``/CropBox``, ``/TrimBox``, ``/ArtBox``, ``/BleedBox``) in its + input, following the PDF 2.0 specification. Coordinates written in invalid + exponential notation are reinterpreted ({issue}`1398`); rectangles whose + corners are given in reversed order are normalized, which previously crashed + with ``NegativeDimensionError`` ({issue}`1526`); and a crop/trim/art/bleed box + that falls outside the MediaBox is clamped to their intersection, or discarded + when that intersection is empty, which previously produced an output with a + zero-height effective page that some viewers refused to open ({issue}`1400`). + When a box is discarded, clamped, or reinterpreted, OCRmyPDF logs a warning + recommending visual inspection of the output. Thanks @ajdlinux for the initial + fix in PR #1691. - OCRmyPDF now discards an embedded Adobe full-text search index (``/Root/PieceInfo/SearchIndex``) from its output. This proprietary index, produced by Acrobat's "Embed Index" feature, is read only by Adobe Acrobat; diff --git a/src/ocrmypdf/_pageboxes.py b/src/ocrmypdf/_pageboxes.py new file mode 100644 index 00000000..76cfcc2a --- /dev/null +++ b/src/ocrmypdf/_pageboxes.py @@ -0,0 +1,253 @@ +# SPDX-FileCopyrightText: 2026 James R. Barlow +# SPDX-FileCopyrightText: 2025 ajdlinux +# SPDX-License-Identifier: MPL-2.0 + +"""Validate and repair malformed page-boundary boxes. + +A page's boundary boxes (``/MediaBox``, ``/CropBox``, ``/TrimBox``, ``/ArtBox``, +``/BleedBox``) are sometimes malformed in ways that PDF readers tolerate but +that crash or corrupt downstream processing. This module normalizes them in +place following the PDF 2.0 specification (ISO 32000-2:2020): + +- **Non-decimal coordinates** (§7.3.3): a coordinate written in exponential + notation is invalid PDF number syntax and is stored by qpdf/pikepdf as a + string. We coerce it back to a number (issue #1398). +- **Reversed corners** (§7.9.5): a rectangle is "a pair of diagonally opposite + corners"; ``[llx lly urx ury]`` is only the typical order. We normalize to + ``[min_x, min_y, max_x, max_y]`` (issue #1526). +- **Sub-box outside the MediaBox** (§14.11.2): "If the bounds of the crop, + trim, bleed or art box extends outside of the bounds of the media box, a + processor shall treat the box as its intersection with the media box." We + clamp to that intersection, or discard the sub-box (so it inherits the + MediaBox) when the intersection is empty (issue #1400). + +A rectangle is treated as empty when its width or height is ``<= 0``; PDF 2.0 +permits zero-dimension rectangles and defines no minimum page size, so no other +size floor is imposed. +""" + +from __future__ import annotations + +import logging +import math +from collections.abc import Iterable, Mapping, Sequence +from dataclasses import dataclass + +import pikepdf +from pikepdf import Name + +log = logging.getLogger(__name__) + +_SUBBOXES = ('CropBox', 'TrimBox', 'ArtBox', 'BleedBox') + + +@dataclass(frozen=True) +class BoxRepair: + """A single change made to a page box. + + Attributes: + box: The box name, e.g. ``"CropBox"``. + kind: One of ``"reordered"`` (reversed corners normalized; lossless), + ``"recoded"`` (non-numeric/exponential coordinate coerced), + ``"clamped"`` (sub-box clamped to the MediaBox), ``"discarded"`` + (sub-box removed because its MediaBox intersection was empty), or + ``"degenerate_mediabox"`` (MediaBox has zero width or height). + """ + + box: str + kind: str + + +def _read_box(values: Sequence) -> tuple[list[float], bool, bool] | None: + """Coerce a box array to floats and normalize corner order. + + Returns ``(normalized_values, recoded, reordered)`` where ``recoded`` is + True if any element needed string/exponential coercion and ``reordered`` is + True if the corners were given in non-standard order. Returns None if the + array is not four finite numbers. + """ + if len(values) != 4: + return None + nums: list[float] = [] + recoded = False + for v in values: + try: + n = float(v) + except (TypeError, ValueError): + try: + n = float(str(v)) + except (TypeError, ValueError): + return None + recoded = True + if not math.isfinite(n): + return None + nums.append(n) + x0, y0, x1, y1 = nums + normalized = [min(x0, x1), min(y0, y1), max(x0, x1), max(y0, y1)] + reordered = normalized != nums + return normalized, recoded, reordered + + +def coerce_box(values: Iterable) -> list[float]: + """Return box values coerced to floats with corner order normalized. + + Robust against exponential/string coordinates and reversed corners, so + callers that only need to read a box (e.g. dimension calculations) do not + crash on malformed input. Falls back to best-effort per-element coercion if + the array is not four numbers. + """ + values = list(values) + result = _read_box(values) + if result is not None: + return result[0] + coerced = [] + for v in values: + try: + coerced.append(float(v)) + except (TypeError, ValueError): + coerced.append(float(str(v))) + return coerced + + +def _is_empty(box: Sequence[float]) -> bool: + """A rectangle is empty when its width or height is non-positive.""" + return (box[2] - box[0]) <= 0 or (box[3] - box[1]) <= 0 + + +def repair_page_boxes(page: pikepdf.Page) -> list[BoxRepair]: + """Validate and repair the boundary boxes of a single page, in place. + + Returns the list of changes made (empty if the page was already valid). + Only boxes that actually change are written back, so valid pages are left + untouched. Performs no logging or I/O. + """ + repairs: list[BoxRepair] = [] + + # MediaBox is the reference rectangle; read it inheritance-aware. + mediabox: list[float] | None = None + try: + mb_result = _read_box(list(page.mediabox.as_list())) + except (AttributeError, KeyError, RuntimeError): + mb_result = None + if mb_result is not None: + mediabox, recoded, reordered = mb_result + if reordered: + repairs.append(BoxRepair('MediaBox', 'reordered')) + if recoded: + repairs.append(BoxRepair('MediaBox', 'recoded')) + if recoded or reordered: + page.obj.MediaBox = pikepdf.Array(mediabox) + if _is_empty(mediabox): + repairs.append(BoxRepair('MediaBox', 'degenerate_mediabox')) + mediabox = None # don't clamp against a degenerate reference + + for box in _SUBBOXES: + name = Name('/' + box) + if name not in page.obj: + continue + try: + sub_result = _read_box(list(page.obj[name])) + except (TypeError, RuntimeError): + continue + if sub_result is None: + continue + values, recoded, reordered = sub_result + if reordered: + repairs.append(BoxRepair(box, 'reordered')) + if recoded: + repairs.append(BoxRepair(box, 'recoded')) + if recoded or reordered: + page.obj[name] = pikepdf.Array(values) + + if mediabox is None: + continue + intersection = [ + max(values[0], mediabox[0]), + max(values[1], mediabox[1]), + min(values[2], mediabox[2]), + min(values[3], mediabox[3]), + ] + if _is_empty(intersection): + del page.obj[name] + repairs.append(BoxRepair(box, 'discarded')) + elif intersection != values: + page.obj[name] = pikepdf.Array(intersection) + repairs.append(BoxRepair(box, 'clamped')) + + return repairs + + +# Per-kind log severity and message template ({box} is substituted). +_KIND_MESSAGES: dict[str, tuple[int, str]] = { + 'discarded': ( + logging.WARNING, + '{box} lies outside the MediaBox and was discarded; ' + 'the full page will be shown', + ), + 'clamped': ( + logging.WARNING, + '{box} extended beyond the MediaBox and was clamped to it', + ), + 'recoded': ( + logging.WARNING, + '{box} used invalid (e.g. exponential) coordinates, which were reinterpreted', + ), + 'degenerate_mediabox': ( + logging.WARNING, + 'MediaBox has zero width or height and could not be repaired; ' + 'output may be invalid', + ), + 'reordered': ( + logging.DEBUG, + '{box} corners were reversed and have been normalized', + ), +} + +# Kinds that change page appearance and warrant manual review of the output. +_INSPECT_KINDS = frozenset({'discarded', 'clamped', 'recoded'}) +_INSPECT = ' Please visually inspect the output PDF.' + + +def _format_pages(pagenos: Iterable[int]) -> str: + """Format 0-based page numbers as a compact 1-based range string.""" + nums = sorted(p + 1 for p in pagenos) + ranges: list[tuple[int, int]] = [] + start = prev = nums[0] + for n in nums[1:]: + if n == prev + 1: + prev = n + continue + ranges.append((start, prev)) + start = prev = n + ranges.append((start, prev)) + return ', '.join(f'{a}' if a == b else f'{a}-{b}' for a, b in ranges) + + +def summarize_box_repairs( + repairs_by_page: Mapping[int, Sequence[BoxRepair]], +) -> list[tuple[int, str]]: + """Aggregate per-page repairs into ``(log_level, message)`` pairs. + + Repairs are grouped by ``(kind, box)`` so a defect shared across many pages + yields a single message listing the affected pages, rather than one message + per page. + """ + groups: dict[tuple[str, str], set[int]] = {} + for pageno, repairs in repairs_by_page.items(): + for repair in repairs: + groups.setdefault((repair.kind, repair.box), set()).add(pageno) + + messages: list[tuple[int, str]] = [] + for (kind, box), pages in sorted(groups.items()): + level, template = _KIND_MESSAGES[kind] + text = f'Page(s) {_format_pages(pages)}: {template.format(box=box)}.' + if kind in _INSPECT_KINDS: + text += _INSPECT + messages.append((level, text)) + return messages + + +def log_box_repairs(repairs_by_page: Mapping[int, Sequence[BoxRepair]]) -> None: + """Emit aggregated log messages for the repairs made across all pages.""" + for level, message in summarize_box_repairs(repairs_by_page): + log.log(level, message) diff --git a/src/ocrmypdf/_pipeline.py b/src/ocrmypdf/_pipeline.py index 6d8308a3..0e53b1d6 100644 --- a/src/ocrmypdf/_pipeline.py +++ b/src/ocrmypdf/_pipeline.py @@ -29,6 +29,7 @@ from ocrmypdf._exec import unpaper from ocrmypdf._jobcontext import PageContext, PdfContext from ocrmypdf._metadata import repair_docinfo_nuls from ocrmypdf._options import OcrOptions, ProcessingMode, TaggedPdfMode +from ocrmypdf._pageboxes import log_box_repairs, repair_page_boxes from ocrmypdf.exceptions import ( DigitalSignatureError, DpiError, @@ -174,6 +175,12 @@ def triage( ) try: with pikepdf.open(input_file) as pdf: + repairs_by_page = { + n: repairs + for n, page in enumerate(pdf.pages) + if (repairs := repair_page_boxes(page)) + } + log_box_repairs(repairs_by_page) pdf.save(output_file) except pikepdf.PdfError as e: raise InputFileError() from e diff --git a/src/ocrmypdf/pdfinfo/info.py b/src/ocrmypdf/pdfinfo/info.py index 9315dfd2..5001bd79 100644 --- a/src/ocrmypdf/pdfinfo/info.py +++ b/src/ocrmypdf/pdfinfo/info.py @@ -19,6 +19,7 @@ from pdfminer.layout import LTPage, LTTextBox from pikepdf import Name, Page, Pdf from ocrmypdf._concurrent import Executor, SerialExecutor +from ocrmypdf._pageboxes import coerce_box from ocrmypdf.exceptions import EncryptedPdfError from ocrmypdf.helpers import Resolution from ocrmypdf.pdfinfo._contentstream import TextboxInfo, TextMarker, VectorMarker @@ -34,6 +35,12 @@ from ocrmypdf.pdfinfo.layout import ( logger = logging.getLogger() +def _box_rect(values: Iterable) -> FloatRect: + """Coerce a page box to a normalized ``FloatRect`` (4-tuple).""" + b = coerce_box(values) + return (b[0], b[1], b[2], b[3]) + + def _page_has_text(text_blocks: Iterable[FloatRect], page_width, page_height) -> bool: """Smarter text detection that ignores text in margins.""" pw, ph = float(page_width), float(page_height) # pylint: disable=invalid-name @@ -140,15 +147,15 @@ class PageInfo: miner_state: PdfMinerState | None, ): page: Page = pdf.pages[pageno] - mediabox = [Decimal(d) for d in page.mediabox.as_list()] + mediabox = [Decimal(str(d)) for d in coerce_box(page.mediabox.as_list())] width_pt = mediabox[2] - mediabox[0] height_pt = mediabox[3] - mediabox[1] - self._artbox = [float(d) for d in page.artbox.as_list()] - self._bleedbox = [float(d) for d in page.bleedbox.as_list()] - self._cropbox = [float(d) for d in page.cropbox.as_list()] - self._mediabox = [float(d) for d in page.mediabox.as_list()] - self._trimbox = [float(d) for d in page.trimbox.as_list()] + self._artbox = _box_rect(page.artbox.as_list()) + self._bleedbox = _box_rect(page.bleedbox.as_list()) + self._cropbox = _box_rect(page.cropbox.as_list()) + self._mediabox = _box_rect(page.mediabox.as_list()) + self._trimbox = _box_rect(page.trimbox.as_list()) check_this_page = pageno in check_pages diff --git a/tests/test_page_boxes.py b/tests/test_page_boxes.py index c033609e..2adf8bec 100644 --- a/tests/test_page_boxes.py +++ b/tests/test_page_boxes.py @@ -7,6 +7,7 @@ import pikepdf import pytest from ocrmypdf._exec import verapdf +from ocrmypdf._pageboxes import repair_page_boxes from .conftest import check_ocrmypdf @@ -127,3 +128,147 @@ def test_crop_box( 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