Skip to content

Commit 7b26aa8

Browse files
feat: honor /ActualText replacement text of marked-content spans
/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>
1 parent 109ce27 commit 7b26aa8

6 files changed

Lines changed: 387 additions & 5 deletions

File tree

app/pybind_parse.cpp

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,7 @@ PYBIND11_MODULE(pdf_parsers, m) {
319319
max_num_lines (int): Maximum number of lines to keep (-1 means no cap) [default=-1].
320320
max_num_bitmaps (int): Maximum number of bitmaps to keep (-1 means no cap) [default=-1].
321321
min_visible_clip_extent (float): Minimum clip width/height treated as a usable image clip [default=1e-3].
322+
apply_actual_text (bool): Honor /ActualText replacement text of marked-content spans during text extraction (PDF 32000-1, 14.9.4) [default=true].
322323
keep_glyphs (bool): If true, keep GLYPH<...> fallback strings in output; if false, replace them with a space [default=false].
323324
keep_qpdf_warnings (bool): If true, QPDF warnings are emitted; if false, they are suppressed [default=false].
324325
)")
@@ -340,6 +341,7 @@ PYBIND11_MODULE(pdf_parsers, m) {
340341
.def_readwrite("line_space_width_factor_for_merge_with_space", &pdflib::decode_config::line_space_width_factor_for_merge_with_space)
341342
.def_readwrite("do_thread_safe", &pdflib::decode_config::do_thread_safe)
342343
.def_readwrite("release_native_memory_every_n_pages", &pdflib::decode_config::release_native_memory_every_n_pages)
344+
.def_readwrite("apply_actual_text", &pdflib::decode_config::apply_actual_text)
343345
.def_readwrite("keep_glyphs", &pdflib::decode_config::keep_glyphs)
344346
.def_readwrite("keep_qpdf_warnings", &pdflib::decode_config::keep_qpdf_warnings)
345347
.def_readwrite("extract_font_programs", &pdflib::decode_config::extract_font_programs)

docling_parse/pdf_parser.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,7 @@ class DecodeConfig(BaseModel):
263263
min_visible_clip_extent: float = 1e-3
264264
do_thread_safe: bool = True
265265
release_native_memory_every_n_pages: int = 0
266+
apply_actual_text: bool = True
266267
keep_glyphs: bool = False
267268
keep_qpdf_warnings: bool = False
268269

@@ -295,6 +296,7 @@ def _compile_decode_config(
295296
cpp.release_native_memory_every_n_pages = (
296297
decode_config.release_native_memory_every_n_pages
297298
)
299+
cpp.apply_actual_text = decode_config.apply_actual_text
298300
cpp.keep_glyphs = decode_config.keep_glyphs
299301
cpp.keep_qpdf_warnings = decode_config.keep_qpdf_warnings
300302
cpp.keep_char_cells = (

src/parse/config.h

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@ namespace pdflib
4848
bool do_thread_safe = true; // slight compute/memory overhead in single threaded case
4949
int release_native_memory_every_n_pages = 0; // 0 disables allocator trimming
5050

51+
// honor /ActualText marked-content replacement text (PDF 32000-1, 14.9.4):
52+
// the producer-declared exact Unicode of a glyph run (ligatures, composed
53+
// accents, hyphenation). Only substituted into spans that drew text cells.
54+
bool apply_actual_text = true;
55+
5156
// debug: in production, we dont want to have ugly GLYPH<...>
5257
bool keep_glyphs = false;
5358
bool keep_qpdf_warnings = false;
@@ -92,6 +97,8 @@ namespace pdflib
9297
j["extract_font_programs"] = extract_font_programs;
9398
j["release_native_memory_every_n_pages"] = release_native_memory_every_n_pages;
9499

100+
j["apply_actual_text"] = apply_actual_text;
101+
95102
j["keep_glyphs"] = keep_glyphs;
96103
j["keep_qpdf_warnings"] = keep_qpdf_warnings;
97104

@@ -127,6 +134,8 @@ namespace pdflib
127134
if(j.count("extract_font_programs")) { extract_font_programs = j["extract_font_programs"]; }
128135
if(j.count("release_native_memory_every_n_pages")) { release_native_memory_every_n_pages = j["release_native_memory_every_n_pages"]; }
129136

137+
if(j.count("apply_actual_text")) { apply_actual_text = j["apply_actual_text"]; }
138+
130139
if(j.count("keep_glyphs")) { keep_glyphs = j["keep_glyphs"]; }
131140
if(j.count("keep_qpdf_warnings")) { keep_qpdf_warnings = j["keep_qpdf_warnings"]; }
132141
}
@@ -183,6 +192,7 @@ namespace pdflib
183192
<< std::setw(48) << "populate_json_objects" << (populate_json_objects ? "true" : "false") << "\n"
184193
<< std::setw(48) << "extract_font_programs" << (extract_font_programs ? "true" : "false") << "\n"
185194
<< std::setw(48) << "release_native_memory_every_n_pages" << release_native_memory_every_n_pages << "\n"
195+
<< std::setw(48) << "apply_actual_text" << (apply_actual_text ? "true" : "false") << "\n"
186196
<< std::setw(48) << "keep_glyphs" << (keep_glyphs ? "true" : "false") << "\n"
187197
<< std::setw(48) << "keep_qpdf_warnings" << (keep_qpdf_warnings ? "true" : "false") << "\n";
188198

src/parse/pdf_decoders/stream.h

Lines changed: 194 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,20 @@ namespace pdflib
7272
void do_postscript(const std::string& xobj_name,
7373
const xobject_subtype_name& xobj_subtype);
7474

75+
// marked-content (BMC/BDC ... EMC) tracking, used to honor /ActualText
76+
// replacement text (PDF 32000-1, section 14.9.4)
77+
struct marked_content_entry
78+
{
79+
bool has_actual_text = false;
80+
std::string actual_text = ""; // UTF-8
81+
std::size_t cells_begin = 0; // page_cells.size() at BDC/BMC
82+
};
83+
84+
void begin_marked_content(std::vector<qpdf_stream_instruction>& parameters);
85+
void end_marked_content();
86+
87+
void apply_actual_text(const marked_content_entry& entry);
88+
7589
private:
7690

7791
const decode_config& config;
@@ -96,6 +110,12 @@ namespace pdflib
96110
std::vector<pdf_state<GLOBAL> > stack;
97111

98112
int stack_count;
113+
114+
// BDC/EMC pairs must balance within a single content stream (PDF 32000-1,
115+
// 14.6), so this stack is per-decoder; cells created by nested form
116+
// XObjects still land in the shared page_cells and are covered by the
117+
// recorded cells_begin index.
118+
std::vector<marked_content_entry> marked_content_stack;
99119
};
100120

101121
pdf_decoder<STREAM>::pdf_decoder(const decode_config& config_,
@@ -276,6 +296,13 @@ namespace pdflib
276296
parameters.push_back(inst);
277297
}
278298
}
299+
300+
if(marked_content_stack.size()>0)
301+
{
302+
LOG_S(WARNING) << "content stream ended with " << marked_content_stack.size()
303+
<< " unbalanced BMC/BDC operator(s): pending /ActualText is not applied";
304+
marked_content_stack.clear();
305+
}
279306
}
280307

281308
// get current global state
@@ -508,6 +535,171 @@ namespace pdflib
508535
LOG_S(WARNING) << "unsupported xobject subtype (PostScript) with name " << xobj_name;
509536
}
510537

538+
// BMC has one operand (tag), BDC has two (tag + properties). The properties
539+
// operand of BDC is either an inline dictionary or a name referring to the
540+
// /Properties resource dictionary; only inline dictionaries are inspected
541+
// for /ActualText here (the named form is rare for /ActualText, which is
542+
// content-specific by nature).
543+
void pdf_decoder<STREAM>::begin_marked_content(std::vector<qpdf_stream_instruction>& parameters)
544+
{
545+
marked_content_entry entry;
546+
entry.cells_begin = page_cells.size();
547+
548+
if(config.apply_actual_text and parameters.size()>=2)
549+
{
550+
qpdf_stream_instruction& props = parameters.back();
551+
552+
if(props.is_dict())
553+
{
554+
QPDFObjectHandle dict = props.obj;
555+
556+
if(dict.hasKey("/ActualText") and dict.getKey("/ActualText").isString())
557+
{
558+
entry.has_actual_text = true;
559+
// getUTF8Value decodes UTF-16BE (BOM) and PDFDoc strings
560+
entry.actual_text = dict.getKey("/ActualText").getUTF8Value();
561+
562+
LOG_S(INFO) << "BDC with /ActualText: '" << entry.actual_text << "'";
563+
}
564+
}
565+
else if(props.obj.isName())
566+
{
567+
LOG_S(INFO) << "BDC with named properties " << props.obj.getName()
568+
<< ": not resolved against /Properties, /ActualText (if any) is ignored";
569+
}
570+
}
571+
572+
marked_content_stack.push_back(entry);
573+
}
574+
575+
void pdf_decoder<STREAM>::end_marked_content()
576+
{
577+
if(marked_content_stack.empty())
578+
{
579+
LOG_S(WARNING) << "EMC without matching BMC/BDC: ignoring";
580+
return;
581+
}
582+
583+
marked_content_entry entry = marked_content_stack.back();
584+
marked_content_stack.pop_back();
585+
586+
if(entry.has_actual_text)
587+
{
588+
apply_actual_text(entry);
589+
}
590+
}
591+
592+
// Substitute the /ActualText replacement string into the text cells created
593+
// inside the marked-content span. Substitution requires the span to have
594+
// drawn text cells: /ActualText over pure graphics (the alternate-text-like
595+
// usage) has no anchor cell and is skipped, and a replacement string that is
596+
// implausibly long for the glyph run (descriptive misuse, /Alt semantics in
597+
// the wrong key) is rejected.
598+
void pdf_decoder<STREAM>::apply_actual_text(const marked_content_entry& entry)
599+
{
600+
std::size_t begin = entry.cells_begin;
601+
std::size_t end = page_cells.size();
602+
603+
if(begin>end)
604+
{
605+
LOG_S(ERROR) << "invalid marked-content cell range [" << begin << ", " << end << ")";
606+
return;
607+
}
608+
609+
std::size_t num_cells = end-begin;
610+
611+
if(num_cells==0)
612+
{
613+
LOG_S(WARNING) << "skipping /ActualText ('" << entry.actual_text
614+
<< "'): no text cells in marked-content span (non-text content)";
615+
return;
616+
}
617+
618+
std::vector<std::string> chars = utils::string::split_unicode_characters(entry.actual_text);
619+
620+
if(chars.size() > std::max<std::size_t>(8, 4*num_cells))
621+
{
622+
LOG_S(WARNING) << "ignoring /ActualText ('" << entry.actual_text
623+
<< "'): " << chars.size() << " character(s) is implausible for a span of "
624+
<< num_cells << " cell(s)";
625+
return;
626+
}
627+
628+
if(chars.size()==0)
629+
{
630+
// an empty /ActualText declares the span to be non-textual content
631+
// (eg a purely visual line-break hyphen)
632+
LOG_S(INFO) << "empty /ActualText: removing " << num_cells << " cell(s)";
633+
page_cells.erase(page_cells.begin()+begin, page_cells.begin()+end);
634+
return;
635+
}
636+
637+
if(chars.size()==num_cells)
638+
{
639+
// preserve the per-glyph geometry
640+
for(std::size_t i=0; i<num_cells; i++)
641+
{
642+
page_item<PAGE_CELL>& cell = page_cells[begin+i];
643+
644+
if(cell.text!=chars.at(i))
645+
{
646+
LOG_S(INFO) << "/ActualText substitution: '" << cell.text
647+
<< "' -> '" << chars.at(i) << "'";
648+
649+
cell.text = chars.at(i);
650+
cell.left_to_right = (not utils::string::is_right_to_left(cell.text));
651+
}
652+
}
653+
return;
654+
}
655+
656+
// glyph count and character count differ (composed accents, ligatures,
657+
// hyphenation): the first cell carries the full replacement string and the
658+
// union of the span geometry, the remaining cells are removed
659+
LOG_S(INFO) << "/ActualText substitution: " << num_cells << " cell(s) -> '"
660+
<< entry.actual_text << "'";
661+
662+
page_item<PAGE_CELL>& first = page_cells[begin];
663+
664+
// baseline direction of the span, taken from the first cell (r_0 -> r_1)
665+
const double dir_x = first.r_x1-first.r_x0;
666+
const double dir_y = first.r_y1-first.r_y0;
667+
668+
double max_proj = first.r_x1*dir_x + first.r_y1*dir_y;
669+
670+
for(std::size_t i=begin+1; i<end; i++)
671+
{
672+
page_item<PAGE_CELL>& cell = page_cells[i];
673+
674+
first.x0 = std::min(first.x0, cell.x0);
675+
first.y0 = std::min(first.y0, cell.y0);
676+
first.x1 = std::max(first.x1, cell.x1);
677+
first.y1 = std::max(first.y1, cell.y1);
678+
679+
// extend the baseline rect along the text flow: keep the leading edge
680+
// (r_0/r_3) of the first cell, take the trailing edge (r_1/r_2) of the
681+
// cell that reaches furthest along the baseline. Not simply the last
682+
// cell: zero-advance overlay glyphs (composed accents) trail *behind*
683+
// the base letter. Projection onto the baseline keeps rotated text
684+
// oriented correctly.
685+
double proj = cell.r_x1*dir_x + cell.r_y1*dir_y;
686+
if(proj>max_proj)
687+
{
688+
max_proj = proj;
689+
690+
first.r_x1 = cell.r_x1;
691+
first.r_y1 = cell.r_y1;
692+
first.r_x2 = cell.r_x2;
693+
first.r_y2 = cell.r_y2;
694+
}
695+
}
696+
697+
first.text = entry.actual_text;
698+
first.left_to_right = (not utils::string::is_right_to_left(first.text));
699+
700+
page_cells.erase(page_cells.begin()+(begin+1), page_cells.begin()+end);
701+
}
702+
511703
void pdf_decoder<STREAM>::execute_operator(qpdf_stream_instruction op,
512704
std::vector<qpdf_stream_instruction>& parameters)
513705
{
@@ -759,20 +951,17 @@ namespace pdflib
759951
break;
760952

761953
case pdf_operator::BMC:
762-
{
763-
LOG_S(INFO) << "executing " << to_string(name);
764-
}
765-
break;
766-
767954
case pdf_operator::BDC:
768955
{
769956
LOG_S(INFO) << "executing " << to_string(name);
957+
begin_marked_content(parameters);
770958
}
771959
break;
772960

773961
case pdf_operator::EMC:
774962
{
775963
LOG_S(INFO) << "executing " << to_string(name);
964+
end_marked_content();
776965
}
777966
break;
778967

src/parse/utils/string.h

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,38 @@ namespace utils
6060
return count;
6161
}
6262

63+
std::vector<std::string> split_unicode_characters(const std::string& utf8_string)
64+
{
65+
std::vector<std::string> chars;
66+
67+
std::size_t i = 0;
68+
while (i < utf8_string.size())
69+
{
70+
unsigned char c = static_cast<unsigned char>(utf8_string[i]);
71+
72+
std::size_t len = 1;
73+
if ((c & 0x80) == 0x00) { len = 1; }
74+
else if ((c & 0xE0) == 0xC0) { len = 2; }
75+
else if ((c & 0xF0) == 0xE0) { len = 3; }
76+
else if ((c & 0xF8) == 0xF0) { len = 4; }
77+
else
78+
{
79+
// Invalid UTF-8 lead byte: keep it as a single-byte character
80+
len = 1;
81+
}
82+
83+
if (i + len > utf8_string.size())
84+
{
85+
len = utf8_string.size() - i;
86+
}
87+
88+
chars.push_back(utf8_string.substr(i, len));
89+
i += len;
90+
}
91+
92+
return chars;
93+
}
94+
6395
bool is_integer(const std::string & s)
6496
{
6597
return std::regex_match(s, std::regex("(-)?[0-9]+"));

0 commit comments

Comments
 (0)