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:
James R. Barlow
2026-06-08 15:47:53 -07:00
parent 32013f4294
commit b5a6a9f9f1
5 changed files with 113 additions and 12 deletions
+14
View File
@@ -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
+35
View File
@@ -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
+9 -7
View 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
+7
View File
@@ -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
View File
@@ -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