feat: discard stale embedded page thumbnails when rewriting PDF

A page may carry an optional /Thumb image XObject (ISO 32000-2 12.3.4),
a miniature rendering used only as a navigation aid. OCRmyPDF alters
page appearance (deskew, clean, rasterize, re-render) and plugins may
edit pages arbitrarily, so a retained thumbnail would be stale and no
longer match its page. Modern viewers generate thumbnails on demand, so
there is no loss of functionality.

OcrGrafter.finalize() now strips /Thumb from every page before saving,
alongside the existing search-index discard, covering both the OCR and
hOCR pipelines. Orphaned thumbnail XObjects are garbage-collected on save.
This commit is contained in:
James R. Barlow
2026-06-07 00:30:34 -07:00
parent 015b53ae30
commit 8f2bcc2c64
3 changed files with 126 additions and 0 deletions
+6
View File
@@ -13,6 +13,12 @@
would leave a stale index that returns incorrect search results in Acrobat.
Modern viewers rebuild a search index on demand, so there is no loss of
search capability.
- OCRmyPDF now discards embedded per-page thumbnail images (the optional
``/Thumb`` image XObject on a page) from its output. OCRmyPDF alters page
appearance (deskew, clean, rasterize, re-render) and plugins may edit pages
arbitrarily, so a retained thumbnail would be stale and no longer match its
page. Embedded thumbnails are a navigation aid that modern viewers generate
on demand, so there is no loss of functionality.
- Fixed a regression in OCR quality for PDFs that paint a 1-bit image mask
(stencil) with a gray or colored fill color. Previously such pages were
rasterized as 1-bit black-and-white before OCR, so Ghostscript dithered
+27
View File
@@ -241,6 +241,32 @@ def discard_text_search_index(pdf: Pdf) -> bool:
return False
def discard_page_thumbnails(pdf: Pdf) -> int:
"""Discard embedded per-page thumbnail images.
A page object may carry an optional ``/Thumb`` image XObject — a miniature
rendering of the page (ISO 32000-2, 12.3.4). It is only a navigation aid and
modern viewers generate page thumbnails on demand. OCRmyPDF alters page
appearance (deskew, clean, rasterize, re-render) and plugins may edit pages
arbitrarily, so any retained thumbnail would be stale and misrepresent its
page. We discard them; viewers rebuild thumbnails as needed. Returns the
number of thumbnails removed.
"""
removed = 0
for page in pdf.pages:
pageobj = page.obj
if Name.Thumb in pageobj:
del pageobj[Name.Thumb]
removed += 1
if removed:
log.debug(
"Discarded %d embedded page thumbnail(s) (/Thumb) because the PDF "
"was rewritten; they would otherwise be stale.",
removed,
)
return removed
class OcrGrafter:
"""Manages grafting text-only PDFs onto regular PDFs."""
@@ -362,6 +388,7 @@ class OcrGrafter:
self._render_and_graft_fpdf2_pages()
discard_text_search_index(self.pdf_base)
discard_page_thumbnails(self.pdf_base)
self.pdf_base.save(self.output_file)
self.pdf_base.close()
return self.output_file
+93
View File
@@ -0,0 +1,93 @@
# SPDX-FileCopyrightText: 2024 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
from __future__ import annotations
import logging
import pikepdf
import pytest
from pikepdf import Name
from ocrmypdf._graft import discard_page_thumbnails
from .conftest import check_ocrmypdf
# pylint: disable=redefined-outer-name
def _add_thumbnail(pdf: pikepdf.Pdf, pageindex: int = 0) -> None:
"""Attach a minimal /Thumb image XObject to a page."""
width, height = 4, 4
thumb = pikepdf.Stream(pdf, b'\x00' * (width * height))
thumb.Type = Name.XObject
thumb.Subtype = Name.Image
thumb.Width = width
thumb.Height = height
thumb.ColorSpace = Name.DeviceGray
thumb.BitsPerComponent = 8
pdf.pages[pageindex].obj.Thumb = pdf.make_indirect(thumb)
def test_discard_page_thumbnails_removes_thumbnails(resources):
with pikepdf.open(resources / 'francais.pdf') as pdf:
# No thumbnails -> nothing to do
assert discard_page_thumbnails(pdf) == 0
_add_thumbnail(pdf, 0)
assert Name.Thumb in pdf.pages[0].obj
assert discard_page_thumbnails(pdf) == 1
assert Name.Thumb not in pdf.pages[0].obj
# Idempotent: a second call finds nothing to remove
assert discard_page_thumbnails(pdf) == 0
def test_discard_page_thumbnails_counts_each_page(resources):
with pikepdf.open(resources / 'multipage.pdf') as pdf:
assert len(pdf.pages) >= 2
_add_thumbnail(pdf, 0)
_add_thumbnail(pdf, 1)
assert discard_page_thumbnails(pdf) == 2
assert all(Name.Thumb not in page.obj for page in pdf.pages)
@pytest.fixture
def pdf_with_thumbnail(resources, outdir):
out = outdir / 'with_thumbnail.pdf'
with pikepdf.open(resources / 'graph.pdf') as pdf:
_add_thumbnail(pdf, 0)
assert Name.Thumb in pdf.pages[0].obj
pdf.save(out)
return out
def test_thumbnail_discarded_end_to_end(pdf_with_thumbnail, outpdf, caplog):
caplog.set_level(logging.DEBUG)
check_ocrmypdf(
pdf_with_thumbnail,
outpdf,
'--output-type',
'pdf',
'--plugin',
'tests/plugins/tesseract_noop.py',
)
with pikepdf.open(outpdf) as pdf:
assert all(Name.Thumb not in page.obj for page in pdf.pages)
assert 'thumbnail' in caplog.text.lower()
def test_thumbnail_discarded_with_ocr_engine_none(pdf_with_thumbnail, outpdf):
# Even in pure image-processing mode, OCRmyPDF rewrites the PDF, which can
# alter page appearance, so the stale thumbnail must still be discarded.
check_ocrmypdf(
pdf_with_thumbnail,
outpdf,
'--ocr-engine',
'none',
'--output-type',
'pdf',
)
with pikepdf.open(outpdf) as pdf:
assert all(Name.Thumb not in page.obj for page in pdf.pages)