Merge branch 'feature/page-box-repair'

This commit is contained in:
James R. Barlow
2026-06-09 01:10:58 -07:00
5 changed files with 430 additions and 6 deletions
+12
View File
@@ -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;
+253
View File
@@ -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)
+7
View File
@@ -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
+13 -6
View File
@@ -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
+145
View File
@@ -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