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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user