feat: discard stale structure tree when re-OCRing tagged PDFs
A tagged/structured PDF carries a logical structure tree (/Root/StructTreeRoot, /MarkInfo) that maps marked page content to semantic elements via MCIDs. When --force-ocr rasterizes pages or --redo-ocr rewrites the text layer, those MCIDs are destroyed or renumbered and the tree is left dangling. We cannot rebuild it to match the new text, so discard it for force/redo modes, following the same pattern as the thumbnail and search-index discards. --skip-text leaves text pages untouched, so their structure is preserved. Also broaden the default-mode "looks born-digital" stop signal to fire on /StructTreeRoot, not just /MarkInfo/Marked, so structure-tree-only PDFs are no longer silently OCR'd. The existing --tagged-pdf-mode ignore escape hatch is unchanged.
This commit is contained in:
@@ -121,6 +121,20 @@ representation. This is useful for redoing OCR, for fixing OCR text
|
||||
with a damaged character map (text is selectable but not searchable),
|
||||
and destroying redacted information.
|
||||
|
||||
### Tagged PDFs and structural markup
|
||||
|
||||
Some PDFs carry a logical structure tree (`/StructTreeRoot`), the markup that
|
||||
makes a "Tagged PDF" — typically the result of layout analysis or a born-digital
|
||||
export. By default OCRmyPDF treats this as a signal that the document may not need
|
||||
OCR and exits, in the same way it stops on PDFs that already contain text. Use
|
||||
`--tagged-pdf-mode ignore`, or one of `--mode skip`/`redo`/`force`, to process
|
||||
such a file anyway.
|
||||
|
||||
OCRmyPDF cannot rebuild a structure tree to match newly recognized text. When
|
||||
`--force-ocr` rasterizes pages, or `--redo-ocr` strips and rewrites the text layer,
|
||||
the structure tree no longer corresponds to the page content, so it is discarded.
|
||||
`--mode skip` leaves text pages untouched, so their structural markup is preserved.
|
||||
|
||||
### Time and image size limits
|
||||
|
||||
By default, OCRmyPDF permits tesseract to run for three minutes (180
|
||||
|
||||
@@ -267,6 +267,39 @@ def discard_page_thumbnails(pdf: Pdf) -> int:
|
||||
return removed
|
||||
|
||||
|
||||
def discard_structure_tree(pdf: Pdf) -> bool:
|
||||
"""Discard the logical structure (tagged-PDF) tree from the document.
|
||||
|
||||
The structure tree (``/Root/StructTreeRoot``, ``/Root/MarkInfo``) maps
|
||||
marked content in the page content streams to semantic elements via MCIDs.
|
||||
When OCRmyPDF rasterizes pages (force) or strips and rewrites the text layer
|
||||
(redo), those MCIDs are destroyed or renumbered, leaving the tree dangling
|
||||
and inconsistent with the new content. We cannot rebuild it to match, so we
|
||||
discard it; the page-level ``/StructParents`` keys go too. Returns True if
|
||||
the catalog was modified.
|
||||
"""
|
||||
modified = False
|
||||
try:
|
||||
if Name.StructTreeRoot in pdf.Root:
|
||||
del pdf.Root.StructTreeRoot
|
||||
modified = True
|
||||
if Name.MarkInfo in pdf.Root:
|
||||
del pdf.Root.MarkInfo
|
||||
modified = True
|
||||
for page in pdf.pages:
|
||||
if Name.StructParents in page.obj:
|
||||
del page.obj[Name.StructParents]
|
||||
modified = True
|
||||
except (KeyError, TypeError, AttributeError):
|
||||
return modified
|
||||
if modified:
|
||||
log.debug(
|
||||
"Discarded the logical structure tree (/Root/StructTreeRoot) "
|
||||
"because the PDF was re-OCR'd; it would otherwise be stale."
|
||||
)
|
||||
return modified
|
||||
|
||||
|
||||
class OcrGrafter:
|
||||
"""Manages grafting text-only PDFs onto regular PDFs."""
|
||||
|
||||
@@ -389,6 +422,8 @@ class OcrGrafter:
|
||||
|
||||
discard_text_search_index(self.pdf_base)
|
||||
discard_page_thumbnails(self.pdf_base)
|
||||
if self.context.options.mode in (ProcessingMode.force, ProcessingMode.redo):
|
||||
discard_structure_tree(self.pdf_base)
|
||||
self.pdf_base.save(self.output_file)
|
||||
self.pdf_base.close()
|
||||
return self.output_file
|
||||
|
||||
@@ -116,8 +116,7 @@ def triage_image_file(input_file: Path, output_file: Path, options: OcrOptions)
|
||||
|
||||
if im.mode in ('RGBA', 'LA'):
|
||||
raise UnsupportedImageFormatError(
|
||||
"The input image has an alpha channel. Remove the alpha "
|
||||
"channel first."
|
||||
"The input image has an alpha channel. Remove the alpha channel first."
|
||||
)
|
||||
|
||||
if 'iccprofile' not in im.info:
|
||||
@@ -250,12 +249,15 @@ def validate_pdfinfo_options(context: PdfContext) -> None:
|
||||
"image of the form and all filled form fields. The output PDF "
|
||||
"will be 'flattened' and will no longer be fillable."
|
||||
)
|
||||
if pdfinfo.is_tagged:
|
||||
if pdfinfo.is_tagged or pdfinfo.has_structure_tree:
|
||||
log.warning(
|
||||
"This PDF is marked as a Tagged PDF. This often indicates "
|
||||
"that the PDF was generated from an office document and does "
|
||||
"not need OCR. PDF pages processed by OCRmyPDF may not be "
|
||||
"tagged correctly."
|
||||
"This PDF contains structural markup (it is a Tagged PDF or "
|
||||
"carries a logical structure tree). This often indicates that the "
|
||||
"PDF was generated from an office document or is otherwise born "
|
||||
"digital, and does not need OCR. OCRmyPDF cannot rebuild this "
|
||||
"structure to match new text, so any page it re-OCRs with "
|
||||
"--force-ocr or --redo-ocr will have its structural markup "
|
||||
"discarded."
|
||||
)
|
||||
if (
|
||||
options.tagged_pdf_mode == TaggedPdfMode.default
|
||||
|
||||
@@ -398,6 +398,7 @@ class PdfInfo:
|
||||
_has_acroform: bool = False
|
||||
_has_signature: bool = False
|
||||
_needs_rendering: bool = False
|
||||
_has_structure_tree: bool = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -449,6 +450,7 @@ class PdfInfo:
|
||||
self._is_tagged = bool(
|
||||
pdf.Root.get(Name.MarkInfo, {}).get(Name.Marked, False)
|
||||
)
|
||||
self._has_structure_tree = Name.StructTreeRoot in pdf.Root
|
||||
|
||||
@property
|
||||
def pages(self) -> list[PageInfo | None]:
|
||||
@@ -481,6 +483,11 @@ class PdfInfo:
|
||||
"""Return True if the document catalog indicates this is a Tagged PDF."""
|
||||
return self._is_tagged
|
||||
|
||||
@property
|
||||
def has_structure_tree(self) -> bool:
|
||||
"""Return True if the document catalog has a logical structure tree."""
|
||||
return self._has_structure_tree
|
||||
|
||||
@property
|
||||
def filename(self) -> str | Path:
|
||||
"""Return filename of PDF."""
|
||||
|
||||
+48
-5
@@ -3,9 +3,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pikepdf
|
||||
import pytest
|
||||
from pikepdf import Name
|
||||
|
||||
import ocrmypdf
|
||||
from ocrmypdf.pdfinfo import PdfInfo
|
||||
|
||||
|
||||
def test_block_tagged(resources):
|
||||
@@ -13,6 +16,25 @@ def test_block_tagged(resources):
|
||||
ocrmypdf.ocr(resources / 'tagged.pdf', '_.pdf')
|
||||
|
||||
|
||||
def test_detect_structure_tree(resources):
|
||||
assert PdfInfo(resources / 'tagged.pdf').has_structure_tree is True
|
||||
|
||||
|
||||
def test_structure_tree_without_markinfo_blocks(resources, tmp_path):
|
||||
"""A PDF with a structure tree but no /MarkInfo flag is still blocked."""
|
||||
untagged = tmp_path / 'struct_only.pdf'
|
||||
with pikepdf.open(resources / 'tagged.pdf') as pdf:
|
||||
del pdf.Root.MarkInfo
|
||||
pdf.save(untagged)
|
||||
|
||||
info = PdfInfo(untagged)
|
||||
assert info.is_tagged is False
|
||||
assert info.has_structure_tree is True
|
||||
|
||||
with pytest.raises(ocrmypdf.exceptions.TaggedPDFError):
|
||||
ocrmypdf.ocr(untagged, '_.pdf')
|
||||
|
||||
|
||||
def test_force_tagged_warns(resources, outpdf, caplog):
|
||||
caplog.set_level('WARNING')
|
||||
ocrmypdf.ocr(
|
||||
@@ -21,11 +43,11 @@ def test_force_tagged_warns(resources, outpdf, caplog):
|
||||
force_ocr=True,
|
||||
plugins=['tests/plugins/tesseract_noop.py'],
|
||||
)
|
||||
assert 'marked as a Tagged PDF' in caplog.text
|
||||
assert 'structural markup' in caplog.text
|
||||
|
||||
|
||||
def test_tagged_pdf_mode_ignore_with_skip_text(resources, outpdf, caplog):
|
||||
"""Ignore tagged_pdf_mode should warn but not error."""
|
||||
"""Ignore tagged_pdf_mode should warn but not error, and keep structure."""
|
||||
caplog.set_level('WARNING')
|
||||
ocrmypdf.ocr(
|
||||
resources / 'tagged.pdf',
|
||||
@@ -34,11 +56,14 @@ def test_tagged_pdf_mode_ignore_with_skip_text(resources, outpdf, caplog):
|
||||
skip_text=True, # Tagged PDF has text, so skip pages with text
|
||||
plugins=['tests/plugins/tesseract_noop.py'],
|
||||
)
|
||||
assert 'marked as a Tagged PDF' in caplog.text
|
||||
assert 'structural markup' in caplog.text
|
||||
# skip-text leaves the text pages untouched, so the structure tree remains valid
|
||||
with pikepdf.open(outpdf) as pdf:
|
||||
assert Name.StructTreeRoot in pdf.Root
|
||||
|
||||
|
||||
def test_tagged_pdf_mode_ignore_with_force(resources, outpdf, caplog):
|
||||
"""Ignore tagged_pdf_mode with force mode should warn."""
|
||||
"""Ignore tagged_pdf_mode with force mode should warn and discard structure."""
|
||||
caplog.set_level('WARNING')
|
||||
ocrmypdf.ocr(
|
||||
resources / 'tagged.pdf',
|
||||
@@ -47,4 +72,22 @@ def test_tagged_pdf_mode_ignore_with_force(resources, outpdf, caplog):
|
||||
force_ocr=True,
|
||||
plugins=['tests/plugins/tesseract_noop.py'],
|
||||
)
|
||||
assert 'marked as a Tagged PDF' in caplog.text
|
||||
assert 'structural markup' in caplog.text
|
||||
# force-ocr rasterizes every page, destroying the MCIDs the tree relies on
|
||||
with pikepdf.open(outpdf) as pdf:
|
||||
assert Name.StructTreeRoot not in pdf.Root
|
||||
assert Name.MarkInfo not in pdf.Root
|
||||
|
||||
|
||||
def test_tagged_pdf_mode_ignore_with_redo(resources, outpdf):
|
||||
"""Redo mode rewrites the text layer, so structure is discarded."""
|
||||
ocrmypdf.ocr(
|
||||
resources / 'tagged.pdf',
|
||||
outpdf,
|
||||
tagged_pdf_mode='ignore',
|
||||
redo_ocr=True,
|
||||
plugins=['tests/plugins/tesseract_noop.py'],
|
||||
)
|
||||
with pikepdf.open(outpdf) as pdf:
|
||||
assert Name.StructTreeRoot not in pdf.Root
|
||||
assert Name.MarkInfo not in pdf.Root
|
||||
|
||||
Reference in New Issue
Block a user