@@ -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
0 commit comments