Skip to content

feat: honor /ActualText replacement text of marked-content spans#300

Open
wittjeff wants to merge 1 commit into
docling-project:mainfrom
wittjeff:feat/actualtext-replacement
Open

feat: honor /ActualText replacement text of marked-content spans#300
wittjeff wants to merge 1 commit into
docling-project:mainfrom
wittjeff:feat/actualtext-replacement

Conversation

@wittjeff

Copy link
Copy Markdown
Contributor

Fixes docling-project/docling#3783.

Problem

/ActualText marked-content spans (PDF 32000-1, §14.9.4) were parsed but ignored: BMC/BDC/EMC were no-ops in pdf_decoder<STREAM>. When a producer wraps a glyph run in /ActualText to declare its true Unicode — composed accents, ligatures, discretionary hyphens — docling-parse decoded the raw glyph codes instead.

The character-level corruption in docling#3783: a zero-advance caron drawn at code 0x4D (ASCII M) over an s, wrapped in /Span <</ActualText <FEFF0161>>> (š), extracts as sM instead of šOrešič becomes OresMi. The declared WinAnsiEncoding legitimately maps 0x4D → M, so only the /ActualText span can recover the intended text; pypdfium2, poppler, and Acrobat all honor it and extract Oreši.

Fix

pdf_decoder<STREAM> now tracks a marked-content stack:

  • BMC/BDC push an entry recording page_cells.size(); BDC inspects an inline properties dictionary for /ActualText (qpdf's getUTF8Value() handles the UTF-16BE/PDFDoc string decoding).
  • EMC pops and substitutes the replacement text into the cells created inside the span:
    • character count == cell count → 1:1 substitution, preserving per-glyph geometry;
    • counts differ (composed accents, ligatures) → the first cell carries the full string and the union of the span geometry (trailing rect edge chosen by baseline projection, so zero-advance overlay glyphs and rotated text stay correct), remaining cells are removed;
    • empty /ActualText (purely visual content, e.g. a discretionary hyphen) → span cells are removed.

The stack is per-decoder, matching the spec requirement that BDC/EMC balance within a single content stream; cells drawn by nested form XObjects land in the shared page_cells and are covered by the recorded start index.

Guardrails (trust boundaries)

/ActualText is replacement text by definition, but to keep misuse (e.g. /Alt-style descriptions in the wrong key) from injecting text:

  • substitution requires the span to have drawn text cells/ActualText over pure graphics (the alternate-text-like usage) has no anchor cell and is skipped with a log line;
  • replacement strings implausibly long for the glyph run (> max(8, 4 × cells)) are rejected with a warning;
  • BDC properties given as a named resource (/Properties) are not resolved in this PR (rare for /ActualText, which is content-specific by nature) — logged at INFO;
  • the whole behavior sits behind a new apply_actual_text config flag (default true), exposed in DecodePageConfig (pybind) and DecodeConfig (Python), so the raw glyph decoding remains one switch away.

Render-layer text instructions are intentionally untouched: rendering shows the glyphs as drawn.

Tests

tests/test_actual_text.py builds the PDFs in memory (base-14 Helvetica + a non-embedded zero-advance "caron" TrueType font, no new test assets):

  • caron composition merge (Oreši, the docling#3783 case) and the apply_actual_text=False escape hatch (OresMi);
  • 1:1 substitution path;
  • empty /ActualText removes the span's cells;
  • oversized (descriptive) /ActualText is rejected, glyphs kept;
  • span over non-text content is skipped;
  • named-properties BDC decodes gracefully;
  • nested spans: outer replacement wins.

Verified against the full synthetic reproducer from docling#3783: docling-parse now extracts Oreši, byte-identical to the pypdfium2 control. Full test suite passes.

Groundtruth impact (needs a regression-dataset revision bump)

7 documents in docling-project/regression-dataset-for-docling-parse contain /ActualText spans and change extraction output (verified individually against a pypdfium2 control; all are the document's own declared replacement text):

document change
fillable_form.pdf small-caps fonts now extract their declared mixed case: conSigneeconsignee, SIGNATURE & STATUS OF AUThORIzED PERSONSignature & Status of Authorized Person
cropbox_versus_mediabox_01.pdf junk control bytes (\x08) from icon-font codes replaced by the declared tab/space (matches pdfium)
complex_invisible_fonts_04.pdf, complex_invisible_fonts_05.pdf declared non-breaking spaces (U+00A0) and soft hyphens (U+00AD) now surface as declared
type3_fonts.pdf line text unchanged; char/word cell granularity shifts on ligature-style merged spans
font_11.pdf (p2), indexed_device_n.pdf declared tab (U+0009) over space-like glyphs (char-level dumps only; sanitized lines unchanged)

Regenerating with pytest --update-groundtruth on this branch turns the suite green (3 previously-failing reference tests pass, 89 others unchanged). Happy to provide the regenerated fixture files, or they can be regenerated directly from this branch, following the flow used in #296.

@github-actions

github-actions Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

DCO Check Passed

Thanks @wittjeff, all your commits are properly signed off. 🎉

@mergify

mergify Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Merge Protections

🟢 Merge protection satisfied — ready to merge.

Show 1 satisfied protection

🟢 Enforce conventional commit

Make sure that we follow https://www.conventionalcommits.org/en/v1.0.0/

  • title ~= ^(fix|feat|docs|style|refactor|perf|test|build|ci|chore|revert)(?:\(.+\))?(!)?:

/ActualText (PDF 32000-1, 14.9.4) declares the exact Unicode of a glyph
run whose glyph->Unicode mapping cannot be recovered from the font
encoding alone: composed accents, ligatures, discretionary hyphens,
small-caps fonts. BMC/BDC/EMC were no-ops, so the declared text was
discarded and the raw glyph codes decoded instead - a zero-advance caron
at code 0x4D turned 'Orešič' into 'OresMi' (docling#3783).

The stream decoder now tracks a marked-content stack: BDC captures
/ActualText from inline property dictionaries, EMC substitutes it into
the cells drawn inside the span (1:1 when character and cell counts
match, merge-with-union-geometry otherwise, cell removal for empty
replacement text). Substitution is anchored to drawn text cells - spans
over non-text content are skipped - and implausibly long replacement
strings (descriptive misuse of the key) are rejected. The behavior sits
behind a new apply_actual_text config flag, default on.

Fixes docling-project/docling#3783

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Jeff Witt <152964771+witt3rd@users.noreply.github.com>
@wittjeff
wittjeff force-pushed the feat/actualtext-replacement branch from 9ed7de6 to 7b26aa8 Compare July 14, 2026 19:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

docling_parse ignores /ActualText and byte-decodes unmapped accent glyphs, corrupting text (e.g. "Orešič" → "OresMi")

2 participants