diff --git a/docs/releasenotes/version17.md b/docs/releasenotes/version17.md index db6f24d4..13ff143e 100644 --- a/docs/releasenotes/version17.md +++ b/docs/releasenotes/version17.md @@ -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 diff --git a/src/ocrmypdf/_graft.py b/src/ocrmypdf/_graft.py index e45d9cb4..0a0641ae 100644 --- a/src/ocrmypdf/_graft.py +++ b/src/ocrmypdf/_graft.py @@ -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 diff --git a/tests/test_thumbnails.py b/tests/test_thumbnails.py new file mode 100644 index 00000000..550c04ae --- /dev/null +++ b/tests/test_thumbnails.py @@ -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)