@@ -33,6 +33,62 @@ struct Cell {
3333 active : bool ,
3434 lig_carry : bool , // last_merged_cell_was_ligature
3535 font : u64 , // hash of the PDF font name+flags (for enforce_same_font)
36+ /// Sub-word segments accumulated during contraction, in final logical order.
37+ /// A word boundary is recorded wherever `merge_with` inserts a separator space
38+ /// (`delta < gap`); within a boundary the glyphs share one segment. Flattening
39+ /// these across all cells yields docling-parse's `word_cells` (item 6). The
40+ /// line path ignores this; only [`word_cells`] reads it.
41+ words : Vec < WordSeg > ,
42+ }
43+
44+ /// One word's accumulated text and native-coordinate bounding box (y up).
45+ #[ derive( Clone ) ]
46+ struct WordSeg {
47+ text : String ,
48+ l : f64 ,
49+ b : f64 ,
50+ r : f64 ,
51+ t : f64 ,
52+ }
53+
54+ impl WordSeg {
55+ fn from_glyph ( text : String , l : f64 , b : f64 , r : f64 , t : f64 ) -> Self {
56+ WordSeg { text, l, b, r, t }
57+ }
58+ /// Absorb `o` into this segment (same word): union the box, append text.
59+ fn absorb ( & mut self , o : & WordSeg ) {
60+ self . text . push_str ( & o. text ) ;
61+ self . l = self . l . min ( o. l ) ;
62+ self . b = self . b . min ( o. b ) ;
63+ self . r = self . r . max ( o. r ) ;
64+ self . t = self . t . max ( o. t ) ;
65+ }
66+ /// Extend the box to cover a single glyph (ligature recompose into one word).
67+ fn extend ( & mut self , l : f64 , b : f64 , r : f64 , t : f64 ) {
68+ self . l = self . l . min ( l) ;
69+ self . b = self . b . min ( b) ;
70+ self . r = self . r . max ( r) ;
71+ self . t = self . t . max ( t) ;
72+ }
73+ }
74+
75+ /// Concatenate two word runs (in final logical order). With a separator space
76+ /// the runs stay distinct (a word boundary); without one, `left`'s last word and
77+ /// `right`'s first word are the same word and merge. Mirrors `merge_with`'s
78+ /// space decision so word grouping tracks the line contraction exactly.
79+ fn merge_word_runs ( mut left : Vec < WordSeg > , mut right : Vec < WordSeg > , space : bool ) -> Vec < WordSeg > {
80+ if left. is_empty ( ) {
81+ return right;
82+ }
83+ if right. is_empty ( ) {
84+ return left;
85+ }
86+ if !space {
87+ let first = right. remove ( 0 ) ;
88+ left. last_mut ( ) . unwrap ( ) . absorb ( & first) ;
89+ }
90+ left. extend ( right) ;
91+ left
3692}
3793
3894impl Cell {
@@ -86,18 +142,25 @@ impl Cell {
86142 } else {
87143 other. rx0 - self . rx1
88144 } ;
145+ let space = delta < gap;
89146 if !self . ltr || !other. ltr {
90- if delta < gap {
147+ if space {
91148 self . text . insert ( 0 , ' ' ) ;
92149 }
93150 self . text = format ! ( "{}{}" , other. text, self . text) ;
94151 self . ltr = false ;
152+ // RTL: `other` is logically first, so its words precede self's. The
153+ // junction is between other's last word and self's first.
154+ self . words =
155+ merge_word_runs ( other. words . clone ( ) , std:: mem:: take ( & mut self . words ) , space) ;
95156 } else {
96- if delta < gap {
157+ if space {
97158 self . text . push ( ' ' ) ;
98159 }
99160 self . text . push_str ( & other. text ) ;
100161 self . ltr = true ;
162+ self . words =
163+ merge_word_runs ( std:: mem:: take ( & mut self . words ) , other. words . clone ( ) , space) ;
101164 }
102165 // Extend the right edge to `other`.
103166 self . rx1 = other. rx1 ;
@@ -206,8 +269,9 @@ fn contract(cells: &mut Vec<Cell>, euclidean: bool) {
206269 cells. retain ( |c| c. active ) ;
207270}
208271
209- /// Build line cells from a page's glyph stream via the docling-parse contraction.
210- pub ( crate ) fn line_cells ( glyphs : & [ Glyph ] , page_h : f32 , euclidean : bool ) -> Vec < TextCell > {
272+ /// Build per-glyph char cells from a page's glyph stream (shared by the line and
273+ /// word paths): drop degenerate spaces, recompose ligatures, init word segments.
274+ fn build_cells ( glyphs : & [ Glyph ] , euclidean : bool ) -> Vec < Cell > {
211275 let mut cells: Vec < Cell > = Vec :: new ( ) ;
212276 for g in glyphs {
213277 // Use the loose box (uniform font ascent/descent + advance) so adjacent
@@ -241,12 +305,23 @@ pub(crate) fn line_cells(glyphs: &[Glyph], page_h: f32, euclidean: bool) -> Vec<
241305 }
242306 last. text . push ( g. ch ) ;
243307 last. ltr = !is_right_to_left ( & last. text ) ;
308+ if let Some ( w) = last. words . last_mut ( ) {
309+ w. text . push ( g. ch ) ;
310+ w. extend ( g. ll as f64 , g. lb as f64 , g. lr as f64 , g. lt as f64 ) ;
311+ }
244312 continue ;
245313 }
246314 }
247315 let text = g. ch . to_string ( ) ;
248316 let ltr = !is_right_to_left ( & text) ;
249317 cells. push ( Cell {
318+ words : vec ! [ WordSeg :: from_glyph(
319+ text. clone( ) ,
320+ g. ll as f64 ,
321+ g. lb as f64 ,
322+ g. lr as f64 ,
323+ g. lt as f64 ,
324+ ) ] ,
250325 text,
251326 rx0 : g. ll as f64 ,
252327 ry0 : g. lb as f64 ,
@@ -262,6 +337,12 @@ pub(crate) fn line_cells(glyphs: &[Glyph], page_h: f32, euclidean: bool) -> Vec<
262337 font : g. font ,
263338 } ) ;
264339 }
340+ cells
341+ }
342+
343+ /// Build line cells from a page's glyph stream via the docling-parse contraction.
344+ pub ( crate ) fn line_cells ( glyphs : & [ Glyph ] , page_h : f32 , euclidean : bool ) -> Vec < TextCell > {
345+ let mut cells = build_cells ( glyphs, euclidean) ;
265346 contract ( & mut cells, euclidean) ;
266347 cells
267348 . into_iter ( )
@@ -281,6 +362,33 @@ pub(crate) fn line_cells(glyphs: &[Glyph], page_h: f32, euclidean: bool) -> Vec<
281362 . collect ( )
282363}
283364
365+ /// Build **word** cells from a page's glyph stream via the same contraction as
366+ /// [`line_cells`], then split each line into its constituent words at exactly the
367+ /// points where the contraction inserted a separator space. This reproduces
368+ /// docling-parse's `word_cells` (the per-word tokens TableFormer matches against
369+ /// table-grid cells), letting the pipeline drop pdfium's text path entirely
370+ /// (roadmap item 6). Empty words (overprint-cleared) are skipped.
371+ pub ( crate ) fn word_cells ( glyphs : & [ Glyph ] , page_h : f32 , euclidean : bool ) -> Vec < TextCell > {
372+ let mut cells = build_cells ( glyphs, euclidean) ;
373+ contract ( & mut cells, euclidean) ;
374+ let mut out = Vec :: new ( ) ;
375+ for c in cells {
376+ for w in c. words {
377+ if w. text . trim ( ) . is_empty ( ) {
378+ continue ;
379+ }
380+ out. push ( TextCell {
381+ text : w. text ,
382+ l : w. l as f32 ,
383+ t : page_h - w. t as f32 ,
384+ r : w. r as f32 ,
385+ b : page_h - w. b as f32 ,
386+ } ) ;
387+ }
388+ }
389+ out
390+ }
391+
284392fn is_rtl_char ( c : char ) -> bool {
285393 let ch = c as u32 ;
286394 ( 0x0600 ..=0x06FF ) . contains ( & ch)
0 commit comments