1use crate::error::Error;
16use crate::geometry::Rect;
17use crate::layout::{Color, FontWeight, TextBlock, TextSpan};
18use crate::structure::types::{McidScope, StructChild, StructElem, StructType};
19
20#[derive(Debug, Clone)]
22pub struct Table {
23 pub rows: Vec<TableRow>,
25
26 pub has_header: bool,
28
29 pub col_count: usize,
31
32 pub bbox: Option<Rect>,
34}
35
36#[derive(Debug, Clone)]
38pub struct TableRow {
39 pub cells: Vec<TableCell>,
41
42 pub is_header: bool,
44}
45
46#[derive(Debug, Clone)]
48pub struct TableCell {
49 pub text: String,
51
52 pub spans: Vec<crate::layout::TextSpan>,
55
56 pub colspan: u32,
58
59 pub rowspan: u32,
61
62 pub mcids: Vec<u32>,
64
65 pub bbox: Option<Rect>,
67
68 pub is_header: bool,
70}
71
72impl Default for Table {
73 fn default() -> Self {
74 Self::new()
75 }
76}
77
78impl Table {
79 pub fn new() -> Self {
81 Self {
82 rows: Vec::new(),
83 has_header: false,
84 col_count: 0,
85 bbox: None,
86 }
87 }
88
89 pub fn is_real_grid(&self) -> bool {
100 if self.col_count < 2 || self.rows.len() < 2 {
101 return false;
102 }
103 let rows_with_two_or_more_filled_cells = self
104 .rows
105 .iter()
106 .filter(|r| r.cells.iter().filter(|c| !c.text.trim().is_empty()).count() >= 2)
107 .count();
108 let ratio = rows_with_two_or_more_filled_cells as f32 / self.rows.len() as f32;
109
110 if self.col_count >= 8 {
125 let min_dense = ((self.col_count as f32 * 0.6) as usize).max(2);
126 let dense_rows = self
127 .rows
128 .iter()
129 .filter(|r| r.cells.iter().filter(|c| !c.text.trim().is_empty()).count() >= min_dense)
130 .count();
131 let dense_row_ratio = dense_rows as f32 / self.rows.len() as f32;
132 if self.rows.len() >= 3 && ratio >= 0.7 && dense_row_ratio >= 0.70 {
133 return true;
134 }
135 let min_absolute_dense = (self.col_count / 2).max(3);
136 return dense_rows >= min_absolute_dense && dense_row_ratio >= 0.40;
137 }
138
139 ratio >= 0.5
140 }
141
142 pub fn render_text(&self) -> String {
144 let col_count = self.col_count;
145 if col_count == 0 || self.rows.is_empty() {
146 return String::new();
147 }
148
149 let mut col_widths = vec![0usize; col_count];
150 for row in &self.rows {
151 let mut col_idx = 0;
152 for cell in &row.cells {
153 if cell.colspan == 1 && col_idx < col_count {
154 let w = cell.text.trim().chars().count();
155 col_widths[col_idx] = col_widths[col_idx].max(w);
156 }
157 col_idx += cell.colspan as usize;
158 }
159 }
160
161 for w in &mut col_widths {
162 if *w < 2 {
163 *w = 2;
164 }
165 }
166
167 let effective_cols = {
170 let mut eff = col_widths.len();
171 while eff > 0 {
172 let col = eff - 1;
173 let all_empty = self.rows.iter().all(|row| {
174 let mut ci = 0;
175 for cell in &row.cells {
176 let span = cell.colspan as usize;
177 let covers_col = ci <= col && col < ci + span;
178 if covers_col {
179 return cell.text.trim().is_empty();
180 }
181 ci += span;
182 }
183 true
184 });
185 if all_empty {
186 eff -= 1;
187 } else {
188 break;
189 }
190 }
191 eff
192 };
193 if effective_cols == 0 {
194 return String::new();
195 }
196 let col_widths = &col_widths[..effective_cols];
197
198 let is_right_aligned: Vec<bool> = (0..effective_cols)
199 .map(|c| {
200 let mut has_content = false;
201 for row in &self.rows {
202 let mut ci = 0;
203 for cell in &row.cells {
204 if ci == c && cell.colspan == 1 {
205 let t = cell.text.trim();
206 if !t.is_empty() {
207 has_content = true;
208 let stripped: String = t
209 .chars()
210 .filter(|ch| {
211 !matches!(ch, '$' | '€' | '£' | ',' | ' ' | '%' | '+' | '-' | '(' | ')')
212 })
213 .collect();
214 if stripped.is_empty() || stripped.parse::<f64>().is_err() {
215 return false;
216 }
217 }
218 }
219 ci += cell.colspan as usize;
220 }
221 }
222 has_content
223 })
224 .collect();
225
226 let mut output = String::new();
227
228 for row in &self.rows {
229 let mut col_idx = 0;
230 let mut cells_text = Vec::new();
231 for cell in &row.cells {
232 let text = cell.text.trim();
233 if col_idx < effective_cols {
234 let width = if cell.colspan > 1 {
235 let end = (col_idx + cell.colspan as usize).min(effective_cols);
236 let base: usize = col_widths[col_idx..end].iter().sum();
237 base + (end - col_idx).saturating_sub(1) * 2
238 } else {
239 col_widths[col_idx]
240 };
241 let formatted = if cell.colspan == 1 && is_right_aligned[col_idx] {
242 format!("{:>width$}", text, width = width)
243 } else {
244 format!("{:<width$}", text, width = width)
245 };
246 cells_text.push(formatted);
247 } else {
248 cells_text.push(text.to_string());
249 }
250 col_idx += cell.colspan as usize;
251 }
252 output.push_str(cells_text.join(" ").trim_end());
253 output.push('\n');
254 }
255
256 output
257 }
258
259 pub fn add_row(&mut self, row: TableRow) {
261 if self.col_count == 0 && !row.cells.is_empty() {
262 self.col_count = row.cells.len();
263 }
264 self.rows.push(row);
265 }
266
267 pub fn is_empty(&self) -> bool {
269 self.rows.is_empty()
270 }
271}
272
273impl TableRow {
274 pub fn new(is_header: bool) -> Self {
276 Self {
277 cells: Vec::new(),
278 is_header,
279 }
280 }
281
282 pub fn has_colspan(&self) -> bool {
284 self.cells.iter().any(|c| c.colspan > 1)
285 }
286
287 pub fn add_cell(&mut self, cell: TableCell) {
289 self.cells.push(cell);
290 }
291}
292
293impl TableCell {
294 pub fn new(text: String, is_header: bool) -> Self {
296 Self {
297 text,
298 spans: Vec::new(),
299 colspan: 1,
300 rowspan: 1,
301 mcids: Vec::new(),
302 bbox: None,
303 is_header,
304 }
305 }
306
307 pub fn with_colspan(mut self, colspan: u32) -> Self {
309 self.colspan = colspan;
310 self
311 }
312
313 pub fn with_rowspan(mut self, rowspan: u32) -> Self {
315 self.rowspan = rowspan;
316 self
317 }
318
319 pub fn add_mcid(&mut self, mcid: u32) {
321 self.mcids.push(mcid);
322 }
323}
324
325pub fn find_table_elements(struct_tree: &crate::structure::types::StructTreeRoot, page_num: u32) -> Vec<&StructElem> {
338 let mut tables = Vec::new();
339 for elem in &struct_tree.root_elements {
340 collect_table_elements(elem, page_num, &mut tables);
341 }
342 tables
343}
344
345fn collect_table_elements<'a>(elem: &'a StructElem, page_num: u32, tables: &mut Vec<&'a StructElem>) {
347 if elem.struct_type == StructType::Table {
348 if element_has_page_content(elem, page_num) {
349 tables.push(elem);
350 }
351 return; }
353
354 for child in &elem.children {
355 if let StructChild::StructElem(child_elem) = child {
356 collect_table_elements(child_elem, page_num, tables);
357 }
358 }
359}
360
361pub fn find_table_elements_all_pages(
368 struct_tree: &crate::structure::types::StructTreeRoot,
369) -> std::collections::HashMap<u32, Vec<StructElem>> {
370 let mut by_page: std::collections::HashMap<u32, Vec<StructElem>> = std::collections::HashMap::new();
371 for elem in &struct_tree.root_elements {
372 collect_table_elements_all_pages(elem, &mut by_page);
373 }
374 by_page
375}
376
377fn collect_table_elements_all_pages(elem: &StructElem, by_page: &mut std::collections::HashMap<u32, Vec<StructElem>>) {
378 if elem.struct_type == StructType::Table {
379 let mut pages = std::collections::BTreeSet::new();
382 collect_content_pages(elem, &mut pages);
383 for p in pages {
384 by_page.entry(p).or_default().push(elem.clone());
385 }
386 return; }
388
389 for child in &elem.children {
390 if let StructChild::StructElem(child_elem) = child {
391 collect_table_elements_all_pages(child_elem, by_page);
392 }
393 }
394}
395
396fn collect_content_pages(elem: &StructElem, pages: &mut std::collections::BTreeSet<u32>) {
399 if let Some(p) = elem.page {
400 pages.insert(p);
401 }
402 for child in &elem.children {
403 match child {
404 StructChild::MarkedContentRef { page, .. } => {
405 pages.insert(*page);
406 }
407 StructChild::StructElem(child_elem) => {
408 collect_content_pages(child_elem, pages);
409 }
410 StructChild::ObjectRef(_, _) => {}
411 }
412 }
413}
414
415fn element_has_page_content(elem: &StructElem, page_num: u32) -> bool {
417 if elem.page == Some(page_num) {
418 return true;
419 }
420
421 for child in &elem.children {
422 match child {
423 StructChild::MarkedContentRef { page, .. } => {
424 if *page == page_num {
425 return true;
426 }
427 }
428 StructChild::StructElem(child_elem) => {
429 if element_has_page_content(child_elem, page_num) {
430 return true;
431 }
432 }
433 StructChild::ObjectRef(_, _) => {}
434 }
435 }
436
437 false
438}
439
440pub fn extract_table_from_spans(table_elem: &StructElem, spans: &[crate::layout::TextSpan]) -> Result<Table, Error> {
452 let text_blocks: Vec<TextBlock> = spans
455 .iter()
456 .filter(|s| s.mcid.is_some())
457 .map(|s| {
458 let text = span_text_for_cell(s);
459 TextBlock {
460 chars: Vec::new(),
461 bbox: s.bbox,
462 text,
463 avg_font_size: s.font_size,
464 dominant_font: s.font_name.clone(),
465 is_bold: s.font_weight.is_bold(),
466 is_italic: s.is_italic,
467 mcid: s.mcid,
468 mcid_scope: s.mcid_scope.clone(),
469 sequence: s.sequence,
470 rotation_degrees: s.rotation_degrees,
471 }
472 })
473 .collect();
474 extract_table(table_elem, &text_blocks)
475}
476
477pub(super) fn span_text_for_cell(span: &crate::layout::TextSpan) -> String {
481 let text = &span.text;
482 let dot_pos = match text.find('.') {
483 Some(p) if p > 0 && p < text.len() - 1 => p,
484 _ => return text.clone(),
485 };
486 if text[dot_pos + 1..].contains('.') {
487 return text.clone();
488 }
489 if !text[..dot_pos].chars().all(|c| c.is_ascii_digit()) {
490 return text.clone();
491 }
492 if !text[dot_pos + 1..].chars().all(|c| c.is_ascii_digit()) {
493 return text.clone();
494 }
495 let char_count = text.chars().count();
496 if !span.char_widths.is_empty() && span.char_widths.len() < char_count {
504 return format!("{} {}", &text[..dot_pos], &text[dot_pos + 1..]);
505 }
506 let expected_width = if !span.char_widths.is_empty() {
507 let cw_sum: f32 = span.char_widths.iter().sum();
508 cw_sum * (char_count as f32 / span.char_widths.len() as f32)
509 } else if span.font_size > 0.0 {
510 span.font_size * 0.50 * char_count as f32
513 } else {
514 return text.clone();
515 };
516 let gap = span.bbox.width - expected_width;
517 if span.font_size > 0.0 && gap > span.font_size * 1.0 {
518 format!("{} {}", &text[..dot_pos], &text[dot_pos + 1..])
519 } else {
520 text.clone()
521 }
522}
523
524pub fn extract_table(table_elem: &StructElem, text_blocks: &[TextBlock]) -> Result<Table, Error> {
537 let mut table = Table::new();
538
539 let has_thead = table_elem
540 .children
541 .iter()
542 .any(|child| matches!(child, StructChild::StructElem(elem) if elem.struct_type == StructType::THead));
543
544 if has_thead {
545 table.has_header = true;
546 }
547
548 for child in &table_elem.children {
549 match child {
550 StructChild::StructElem(elem) => match elem.struct_type {
551 StructType::TR => {
552 let row = extract_row(elem, text_blocks, false)?;
553 table.add_row(row);
554 }
555 StructType::THead => {
556 extract_row_group(elem, text_blocks, true, &mut table)?;
557 }
558 StructType::TBody => {
559 extract_row_group(elem, text_blocks, false, &mut table)?;
560 }
561 StructType::TFoot => {
562 extract_row_group(elem, text_blocks, false, &mut table)?;
563 }
564 _ => {}
565 },
566 StructChild::MarkedContentRef { .. } => {}
567 StructChild::ObjectRef(_, _) => {}
568 }
569 }
570
571 Ok(table)
572}
573
574fn extract_row_group(
576 group_elem: &StructElem,
577 text_blocks: &[TextBlock],
578 is_header: bool,
579 table: &mut Table,
580) -> Result<(), Error> {
581 for child in &group_elem.children {
582 match child {
583 StructChild::StructElem(elem) if elem.struct_type == StructType::TR => {
584 let row = extract_row(elem, text_blocks, is_header)?;
585 table.add_row(row);
586 }
587 _ => {}
588 }
589 }
590 Ok(())
591}
592
593fn extract_row(tr_elem: &StructElem, text_blocks: &[TextBlock], force_header: bool) -> Result<TableRow, Error> {
595 let mut row = TableRow::new(force_header);
596
597 for child in &tr_elem.children {
598 match child {
599 StructChild::StructElem(elem) => match elem.struct_type {
600 StructType::TH => {
601 let cell = extract_cell(elem, text_blocks, true)?;
602 row.add_cell(cell);
603 }
604 StructType::TD => {
605 let cell = extract_cell(elem, text_blocks, false)?;
606 row.add_cell(cell);
607 }
608 _ => {}
609 },
610 StructChild::MarkedContentRef { .. } => {}
611 StructChild::ObjectRef(_, _) => {}
612 }
613 }
614
615 Ok(row)
616}
617
618fn extract_cell(cell_elem: &StructElem, text_blocks: &[TextBlock], is_header: bool) -> Result<TableCell, Error> {
620 let mut mcids = Vec::new();
621 collect_mcids(cell_elem, &mut mcids);
622 let mut scoped_mcids = Vec::new();
623 collect_scoped_mcids(cell_elem, &mut scoped_mcids);
624
625 let mut cell_text = String::new();
631 let mut cell_spans: Vec<TextSpan> = Vec::new();
632
633 let mut blocks: Vec<&TextBlock> = text_blocks.iter().filter(|b| block_belongs(b, &scoped_mcids)).collect();
645
646 let (sin, cos) = cell_rotation_degrees(&blocks).to_radians().sin_cos();
647 let direction = if blocks.iter().any(|b| crate::text::bidi::looks_rtl(&b.text)) {
648 -1.0
649 } else {
650 1.0
651 };
652 let along = |b: &TextBlock| direction * (b.bbox.x * cos + b.bbox.y * sin);
655 let perp = |b: &TextBlock| b.bbox.x * sin - b.bbox.y * cos;
656 let advance_extent = |b: &TextBlock| b.bbox.width;
661
662 blocks.sort_by(|a, b| crate::utils::safe_float_cmp(perp(a), perp(b)));
667 let mut keyed: Vec<(usize, &TextBlock)> = Vec::with_capacity(blocks.len());
673 let mut line = 0usize;
674 let mut anchor: Option<&TextBlock> = None;
675 for b in blocks {
676 if let Some(a) = anchor {
677 let threshold = a.bbox.height.min(b.bbox.height).max(1.0) * 0.5;
678 if perp(b) - perp(a) > threshold {
679 line += 1;
680 anchor = Some(b);
681 }
682 } else {
683 anchor = Some(b);
684 }
685 keyed.push((line, b));
686 }
687 keyed.sort_by(|(line_a, a), (line_b, b)| {
688 line_a
689 .cmp(line_b)
690 .then_with(|| crate::utils::safe_float_cmp(along(a), along(b)))
691 });
692 if direction < 0.0 {
699 restore_numeric_run_order(&mut keyed);
700 }
701
702 let mut prev_block: Option<(usize, &TextBlock)> = None;
703 for (line_index, block) in keyed {
704 let mut leading_space = false;
705 if !cell_text.is_empty() {
706 let need_space = if let Some((prev_line, prev)) = prev_block {
707 let gap = along(block) - (along(prev) + advance_extent(prev));
712 let font_size = prev.avg_font_size.max(block.avg_font_size).max(1.0);
713 if line_index != prev_line {
714 true
715 } else if gap <= font_size * 0.15 {
716 false
717 } else {
718 #[inline(always)]
723 fn is_cjk(c: char) -> bool {
724 matches!(c,
725 '\u{3040}'..='\u{309F}' | '\u{30A0}'..='\u{30FF}' | '\u{4E00}'..='\u{9FFF}' | '\u{AC00}'..='\u{D7AF}' | '\u{3400}'..='\u{4DBF}' | '\u{20000}'..='\u{2A6DF}' )
732 }
733 #[inline(always)]
734 fn is_fw_math(c: char) -> bool {
735 matches!(c,
736 '\u{FF0B}' | '\u{FF0D}' |
737 '\u{FF1A}' | '\u{FF1B}' |
738 '\u{FF1C}'..='\u{FF1E}' |
739 '\u{2260}' | '\u{2248}' |
740 '\u{2264}'..='\u{2265}' |
741 '\u{00B5}' | '\u{03BC}' |
742 '\u{00B1}' | '\u{00D7}' | '\u{00F7}'
743 )
744 }
745 let p_last = prev.text.chars().next_back();
746 let b_first = block.text.chars().next();
747 let suppress = if let (Some(p), Some(b)) = (p_last, b_first) {
748 let p_cjk = is_cjk(p);
749 let b_cjk = is_cjk(b);
750 (p_cjk || is_fw_math(p)) && (b_cjk || is_fw_math(b)) && (p_cjk || b_cjk)
751 } else {
752 false
753 };
754 !suppress
755 }
756 } else {
757 !cell_text.ends_with(' ')
758 };
759 if need_space {
760 cell_text.push(' ');
761 leading_space = true;
762 }
763 }
764 cell_text.push_str(&block.text);
765 let span_text = if leading_space {
779 let mut s = String::with_capacity(block.text.len() + 1);
780 s.push(' ');
781 s.push_str(&block.text);
782 s
783 } else {
784 block.text.clone()
785 };
786 cell_spans.push(TextSpan {
787 provenance: None,
788 artifact_type: None,
789 text: span_text,
790 bbox: block.bbox,
791 font_name: block.dominant_font.clone(),
792 font_size: block.avg_font_size,
793 font_weight: if block.is_bold {
794 FontWeight::Bold
795 } else {
796 FontWeight::Normal
797 },
798 is_italic: block.is_italic,
799 is_monospace: false,
800 color: Color::black(),
801 mcid: block.mcid,
802 mcid_scope: None,
803 sequence: 0,
804 offset_semantic: false,
805 split_boundary_before: false,
806 char_spacing: 0.0,
807 word_spacing: 0.0,
808 horizontal_scaling: 100.0,
809 primary_detected: false,
810 char_widths: vec![],
811 char_x_offsets: Vec::new(),
812 heading_level: None,
813 rotation_degrees: block.rotation_degrees,
818 wmode: 0,
819 text_rise: 0.0,
820 rtl_draw_logical: false,
821 mirrored: false,
822 page_rotation_applied: 0,
823 });
824 prev_block = Some((line_index, block));
825 }
826
827 let mut cell = TableCell::new(cell_text.trim().to_string(), is_header);
828 cell.mcids = mcids;
829 cell.spans = cell_spans;
830
831 Ok(cell)
832}
833
834fn is_uax9_numeral(c: char) -> bool {
837 c.is_ascii_digit() || matches!(c, '\u{0660}'..='\u{0669}' | '\u{06F0}'..='\u{06F9}')
838}
839
840fn is_numeric_run(text: &str) -> bool {
849 let mut saw_numeral = false;
850 for c in text.trim().chars() {
851 if is_uax9_numeral(c) {
852 saw_numeral = true;
853 } else if !matches!(c, '.' | ',' | '\u{066B}' | '\u{066C}') {
854 return false;
855 }
856 }
857 saw_numeral
858}
859
860fn restore_numeric_run_order(keyed: &mut [(usize, &TextBlock)]) {
864 let mut start = 0usize;
865 while start < keyed.len() {
866 if !is_numeric_run(&keyed[start].1.text) {
867 start += 1;
868 continue;
869 }
870 let line = keyed[start].0;
871 let mut end = start + 1;
872 while end < keyed.len() && keyed[end].0 == line && is_numeric_run(&keyed[end].1.text) {
873 end += 1;
874 }
875 keyed[start..end].reverse();
876 start = end;
877 }
878}
879
880fn cell_rotation_degrees(blocks: &[&TextBlock]) -> f32 {
885 let mut counts: [usize; 4] = [0; 4];
886 for b in blocks {
887 let normalized = b.rotation_degrees.rem_euclid(360.0);
888 let quadrant = ((normalized / 90.0).round() as usize) % 4;
891 if (normalized - (quadrant as f32) * 90.0).abs() < 0.5 {
892 counts[quadrant] += 1;
893 } else {
894 counts[0] += 1;
895 }
896 }
897 let (index, _) = counts
898 .iter()
899 .enumerate()
900 .max_by_key(|&(ref index, &count)| (count, std::cmp::Reverse(*index)))
901 .expect("counts is non-empty");
902 (index as f32) * 90.0
903}
904
905fn collect_mcids(elem: &StructElem, mcids: &mut Vec<u32>) {
907 let mut scoped = Vec::new();
908 collect_scoped_mcids(elem, &mut scoped);
909 mcids.extend(scoped.into_iter().map(|(_, mcid)| mcid));
910}
911
912fn collect_scoped_mcids(elem: &StructElem, out: &mut Vec<(McidScope, u32)>) {
920 for child in &elem.children {
921 match child {
922 StructChild::MarkedContentRef { mcid, scope, .. } => {
923 out.push((scope.clone(), *mcid));
924 }
925 StructChild::StructElem(child_elem) => {
926 collect_scoped_mcids(child_elem, out);
927 }
928 StructChild::ObjectRef(_, _) => {}
929 }
930 }
931}
932
933fn block_belongs(block: &TextBlock, scoped: &[(McidScope, u32)]) -> bool {
941 let Some(mcid) = block.mcid else { return false };
942 let named: Vec<&McidScope> = scoped.iter().filter(|(_, m)| *m == mcid).map(|(s, _)| s).collect();
943 if named.is_empty() {
944 return false;
945 }
946 match &block.mcid_scope {
947 Some(scope) if named.contains(&scope) => true,
949 Some(_) => false,
952 None => true,
956 }
957}
958
959#[cfg(test)]
960mod tests {
961 use super::*;
962 use crate::structure::types::StructTreeRoot;
963
964 fn block_at(x: f32, y: f32, mcid: u32, text: &str) -> TextBlock {
968 let ch = crate::layout::TextChar {
969 char: text.chars().next().unwrap_or('x'),
970 bbox: Rect::new(x, y, 10.0, 10.0),
971 font_name: "Test".to_string(),
972 font_size: 10.0,
973 font_weight: FontWeight::default(),
974 is_italic: false,
975 is_monospace: false,
976 color: Color::default(),
977 mcid: Some(mcid),
978 origin_x: x,
979 origin_y: y,
980 rotation_degrees: 0.0,
981 advance_width: 10.0,
982 rendered_advance: 10.0,
983 ascent: 8.0,
984 descent: -2.0,
985 matrix: None,
986 };
987 let mut block = TextBlock::from_chars(vec![ch]);
988 block.bbox = Rect::new(x, y, 10.0, 10.0);
989 block.text = text.to_string();
990 block.mcid = Some(mcid);
991 block
992 }
993
994 #[test]
998 fn cell_blocks_with_non_finite_bbox_do_not_panic() {
999 const N: u32 = 24;
1003 let children: Vec<StructChild> = (0..N)
1004 .map(|mcid| StructChild::MarkedContentRef {
1005 mcid,
1006 page: 0,
1007 scope: crate::structure::types::McidScope::Page(0),
1008 })
1009 .collect();
1010 let cell = StructElem {
1011 struct_type: StructType::TD,
1012 children,
1013 page: Some(0),
1014 attributes: std::collections::HashMap::new(),
1015 alt_text: None,
1016 expansion: None,
1017 actual_text: None,
1018 source_role: None,
1019 };
1020 let blocks: Vec<TextBlock> = (0..N)
1024 .map(|i| {
1025 let x = if i == 7 || i == 17 {
1029 f32::INFINITY
1030 } else {
1031 10.0 * (i % 6) as f32
1032 };
1033 let text = ((b'a' + i as u8) as char).to_string();
1034 block_at(x, 20.0 + 40.0 * (i / 6) as f32, i, &text)
1035 })
1036 .collect();
1037
1038 let cell = extract_cell(&cell, &blocks, false).expect("cell extraction");
1041 assert!(
1042 cell.text.contains('a') && cell.text.contains('x'),
1043 "finite blocks must survive a non-finite sibling, got {:?}",
1044 cell.text
1045 );
1046 }
1047
1048 #[test]
1049 fn test_table_new() {
1050 let table = Table::new();
1051 assert!(table.is_empty());
1052 assert_eq!(table.col_count, 0);
1053 assert!(!table.has_header);
1054 assert!(table.bbox.is_none());
1055 }
1056
1057 #[test]
1058 fn test_table_bbox() {
1059 let mut table = Table::new();
1060 assert!(table.bbox.is_none());
1061
1062 table.bbox = Some(Rect::new(10.0, 20.0, 100.0, 50.0));
1063 assert!(table.bbox.is_some());
1064 let bbox = table.bbox.unwrap();
1065 assert_eq!(bbox.x, 10.0);
1066 assert_eq!(bbox.y, 20.0);
1067 assert_eq!(bbox.width, 100.0);
1068 assert_eq!(bbox.height, 50.0);
1069 }
1070
1071 #[test]
1072 fn test_table_row_new() {
1073 let header_row = TableRow::new(true);
1074 assert!(header_row.is_header);
1075 assert!(header_row.cells.is_empty());
1076
1077 let body_row = TableRow::new(false);
1078 assert!(!body_row.is_header);
1079 }
1080
1081 #[test]
1082 fn test_table_cell_new() {
1083 let cell = TableCell::new("Hello".to_string(), false);
1084 assert_eq!(cell.text, "Hello");
1085 assert!(!cell.is_header);
1086 assert_eq!(cell.colspan, 1);
1087 assert_eq!(cell.rowspan, 1);
1088 assert!(cell.mcids.is_empty());
1089 }
1090
1091 #[test]
1092 fn test_table_cell_with_spans() {
1093 let cell = TableCell::new("Data".to_string(), false)
1094 .with_colspan(2)
1095 .with_rowspan(3);
1096
1097 assert_eq!(cell.colspan, 2);
1098 assert_eq!(cell.rowspan, 3);
1099 }
1100
1101 #[test]
1102 fn test_table_cell_header() {
1103 let cell = TableCell::new("Header".to_string(), true);
1104 assert!(cell.is_header);
1105 }
1106
1107 #[test]
1108 fn test_table_row_add_cells() {
1109 let mut row = TableRow::new(false);
1110 row.add_cell(TableCell::new("Cell1".to_string(), false));
1111 row.add_cell(TableCell::new("Cell2".to_string(), false));
1112
1113 assert_eq!(row.cells.len(), 2);
1114 assert_eq!(row.cells[0].text, "Cell1");
1115 assert_eq!(row.cells[1].text, "Cell2");
1116 }
1117
1118 #[test]
1119 fn test_table_add_rows() {
1120 let mut table = Table::new();
1121 let mut row1 = TableRow::new(false);
1122 row1.add_cell(TableCell::new("A".to_string(), false));
1123 row1.add_cell(TableCell::new("B".to_string(), false));
1124
1125 table.add_row(row1);
1126 assert_eq!(table.col_count, 2);
1127 assert_eq!(table.rows.len(), 1);
1128 }
1129
1130 #[test]
1131 fn test_table_has_header() {
1132 let mut table = Table::new();
1133 assert!(!table.has_header);
1134
1135 table.has_header = true;
1136 assert!(table.has_header);
1137 }
1138
1139 fn make_table_elem(page: u32, mcids: &[u32]) -> StructElem {
1141 let mut table = StructElem::new(StructType::Table);
1142 let mut tr = StructElem::new(StructType::TR);
1143 for &mcid in mcids {
1144 let mut td = StructElem::new(StructType::TD);
1145 td.add_child(StructChild::MarkedContentRef {
1146 mcid,
1147 page,
1148 scope: crate::structure::McidScope::Page(page),
1149 });
1150 tr.add_child(StructChild::StructElem(Box::new(td)));
1151 }
1152 table.add_child(StructChild::StructElem(Box::new(tr)));
1153 table
1154 }
1155
1156 #[test]
1157 fn test_find_table_elements_finds_table_on_matching_page() {
1158 let mut tree = StructTreeRoot::new();
1159 tree.add_root_element(make_table_elem(0, &[1, 2]));
1160
1161 let tables = find_table_elements(&tree, 0);
1162 assert_eq!(tables.len(), 1);
1163 assert_eq!(tables[0].struct_type, StructType::Table);
1164 }
1165
1166 #[test]
1167 fn test_find_table_elements_skips_table_on_different_page() {
1168 let mut tree = StructTreeRoot::new();
1169 tree.add_root_element(make_table_elem(1, &[1, 2]));
1170
1171 let tables = find_table_elements(&tree, 0);
1172 assert!(tables.is_empty());
1173 }
1174
1175 #[test]
1176 fn test_find_table_elements_empty_tree() {
1177 let tree = StructTreeRoot::new();
1178 let tables = find_table_elements(&tree, 0);
1179 assert!(tables.is_empty());
1180 }
1181
1182 #[test]
1183 fn test_find_table_elements_multiple_tables() {
1184 let mut tree = StructTreeRoot::new();
1185 tree.add_root_element(make_table_elem(0, &[1, 2]));
1186 tree.add_root_element(make_table_elem(0, &[3, 4]));
1187
1188 let tables = find_table_elements(&tree, 0);
1189 assert_eq!(tables.len(), 2);
1190 }
1191
1192 #[test]
1193 fn test_find_table_elements_nested_in_section() {
1194 let mut tree = StructTreeRoot::new();
1195 let mut sect = StructElem::new(StructType::Sect);
1196 sect.add_child(StructChild::StructElem(Box::new(make_table_elem(0, &[1]))));
1197 tree.add_root_element(sect);
1198
1199 let tables = find_table_elements(&tree, 0);
1200 assert_eq!(tables.len(), 1);
1201 }
1202
1203 #[test]
1204 fn test_find_table_elements_table_with_page_attribute() {
1205 let mut tree = StructTreeRoot::new();
1206 let mut table = StructElem::new(StructType::Table);
1207 table.page = Some(2);
1208 tree.add_root_element(table);
1209
1210 let tables = find_table_elements(&tree, 2);
1211 assert_eq!(tables.len(), 1);
1212 }
1213
1214 #[test]
1215 fn test_find_table_elements_mixed_pages() {
1216 let mut tree = StructTreeRoot::new();
1217 tree.add_root_element(make_table_elem(0, &[1]));
1218 tree.add_root_element(make_table_elem(1, &[2]));
1219 tree.add_root_element(make_table_elem(0, &[3]));
1220
1221 let page0_tables = find_table_elements(&tree, 0);
1222 assert_eq!(page0_tables.len(), 2);
1223
1224 let page1_tables = find_table_elements(&tree, 1);
1225 assert_eq!(page1_tables.len(), 1);
1226 }
1227
1228 #[test]
1231 fn test_find_table_elements_all_pages_matches_per_page() {
1232 let mut tree = StructTreeRoot::new();
1233 tree.add_root_element(make_table_elem(0, &[1, 2]));
1234 let mut sect = StructElem::new(StructType::Sect);
1235 sect.add_child(StructChild::StructElem(Box::new(make_table_elem(0, &[3]))));
1236 tree.add_root_element(sect);
1237 tree.add_root_element(make_table_elem(1, &[4]));
1238 let mut page_attr_table = StructElem::new(StructType::Table);
1239 page_attr_table.page = Some(2);
1240 tree.add_root_element(page_attr_table);
1241
1242 let all = find_table_elements_all_pages(&tree);
1243 for page in 0..4u32 {
1244 let per_page = find_table_elements(&tree, page);
1245 let bucket = all.get(&page).cloned().unwrap_or_default();
1246 assert_eq!(
1247 bucket.len(),
1248 per_page.len(),
1249 "page {page}: bucket count must match per-page walk"
1250 );
1251 for (b, p) in bucket.iter().zip(per_page.iter()) {
1252 assert_eq!(b.struct_type, p.struct_type);
1254 assert_eq!(b.page, p.page);
1255 assert_eq!(b.children.len(), p.children.len());
1256 }
1257 }
1258 }
1259
1260 #[test]
1261 fn test_find_table_elements_all_pages_empty_tree() {
1262 let tree = StructTreeRoot::new();
1263 assert!(find_table_elements_all_pages(&tree).is_empty());
1264 }
1265
1266 #[test]
1267 fn test_element_has_page_content_via_mcid() {
1268 let mut elem = StructElem::new(StructType::P);
1269 elem.add_child(StructChild::MarkedContentRef {
1270 mcid: 1,
1271 page: 3,
1272 scope: crate::structure::McidScope::Page(3),
1273 });
1274
1275 assert!(element_has_page_content(&elem, 3));
1276 assert!(!element_has_page_content(&elem, 0));
1277 }
1278
1279 #[test]
1280 fn test_element_has_page_content_via_page_attribute() {
1281 let mut elem = StructElem::new(StructType::P);
1282 elem.page = Some(5);
1283
1284 assert!(element_has_page_content(&elem, 5));
1285 assert!(!element_has_page_content(&elem, 0));
1286 }
1287
1288 #[test]
1289 fn test_element_has_page_content_recursive() {
1290 let mut parent = StructElem::new(StructType::Sect);
1291 let mut child = StructElem::new(StructType::P);
1292 child.add_child(StructChild::MarkedContentRef {
1293 mcid: 1,
1294 page: 2,
1295 scope: crate::structure::McidScope::Page(2),
1296 });
1297 parent.add_child(StructChild::StructElem(Box::new(child)));
1298
1299 assert!(element_has_page_content(&parent, 2));
1300 assert!(!element_has_page_content(&parent, 0));
1301 }
1302
1303 #[test]
1304 fn test_element_has_page_content_empty() {
1305 let elem = StructElem::new(StructType::P);
1306 assert!(!element_has_page_content(&elem, 0));
1307 }
1308
1309 #[test]
1310 fn test_element_has_page_content_object_ref_ignored() {
1311 let mut elem = StructElem::new(StructType::P);
1312 elem.add_child(StructChild::ObjectRef(1, 0));
1313 assert!(!element_has_page_content(&elem, 0));
1314 }
1315
1316 fn make_text_span(text: &str, mcid: Option<u32>) -> crate::layout::TextSpan {
1317 use crate::layout::text_block::{Color, FontWeight};
1318
1319 crate::layout::TextSpan {
1320 provenance: None,
1321 text_rise: 0.0,
1322 artifact_type: None,
1323 text: text.to_string(),
1324 bbox: Rect::new(0.0, 0.0, 50.0, 12.0),
1325 font_name: "Test".to_string(),
1326 font_size: 12.0,
1327 font_weight: FontWeight::Normal,
1328 is_italic: false,
1329 is_monospace: false,
1330 color: Color::black(),
1331 mcid,
1332 mcid_scope: None,
1333 sequence: 0,
1334 split_boundary_before: false,
1335 offset_semantic: false,
1336 char_spacing: 0.0,
1337 word_spacing: 0.0,
1338 horizontal_scaling: 1.0,
1339 primary_detected: false,
1340 char_widths: vec![],
1341 char_x_offsets: Vec::new(),
1342 heading_level: None,
1343 rotation_degrees: 0.0,
1344 wmode: 0,
1345 rtl_draw_logical: false,
1346 mirrored: false,
1347 page_rotation_applied: 0,
1348 }
1349 }
1350
1351 #[test]
1352 fn test_extract_table_from_spans_basic() {
1353 let mut table_elem = StructElem::new(StructType::Table);
1354 let mut tr = StructElem::new(StructType::TR);
1355 let mut td1 = StructElem::new(StructType::TD);
1356 td1.add_child(StructChild::MarkedContentRef {
1357 mcid: 10,
1358 page: 0,
1359 scope: crate::structure::McidScope::Page(0),
1360 });
1361 let mut td2 = StructElem::new(StructType::TD);
1362 td2.add_child(StructChild::MarkedContentRef {
1363 mcid: 11,
1364 page: 0,
1365 scope: crate::structure::McidScope::Page(0),
1366 });
1367 tr.add_child(StructChild::StructElem(Box::new(td1)));
1368 tr.add_child(StructChild::StructElem(Box::new(td2)));
1369 table_elem.add_child(StructChild::StructElem(Box::new(tr)));
1370
1371 let spans = vec![
1372 make_text_span("Hello", Some(10)),
1373 make_text_span("World", Some(11)),
1374 make_text_span("Unrelated", Some(99)),
1375 ];
1376
1377 let result = extract_table_from_spans(&table_elem, &spans).unwrap();
1378 assert_eq!(result.rows.len(), 1);
1379 assert_eq!(result.rows[0].cells.len(), 2);
1380 assert_eq!(result.rows[0].cells[0].text, "Hello");
1381 assert_eq!(result.rows[0].cells[1].text, "World");
1382 }
1383
1384 #[test]
1385 fn test_extract_table_from_spans_no_matching_mcids() {
1386 let mut table_elem = StructElem::new(StructType::Table);
1387 let mut tr = StructElem::new(StructType::TR);
1388 let mut td = StructElem::new(StructType::TD);
1389 td.add_child(StructChild::MarkedContentRef {
1390 mcid: 10,
1391 page: 0,
1392 scope: crate::structure::McidScope::Page(0),
1393 });
1394 tr.add_child(StructChild::StructElem(Box::new(td)));
1395 table_elem.add_child(StructChild::StructElem(Box::new(tr)));
1396
1397 let spans = vec![make_text_span("Other", Some(99))];
1398
1399 let result = extract_table_from_spans(&table_elem, &spans).unwrap();
1400 assert_eq!(result.rows.len(), 1);
1401 assert_eq!(result.rows[0].cells[0].text, "");
1402 }
1403
1404 #[test]
1405 fn test_extract_table_from_spans_filters_no_mcid_spans() {
1406 let mut table_elem = StructElem::new(StructType::Table);
1407 let mut tr = StructElem::new(StructType::TR);
1408 let mut td = StructElem::new(StructType::TD);
1409 td.add_child(StructChild::MarkedContentRef {
1410 mcid: 5,
1411 page: 0,
1412 scope: crate::structure::McidScope::Page(0),
1413 });
1414 tr.add_child(StructChild::StructElem(Box::new(td)));
1415 table_elem.add_child(StructChild::StructElem(Box::new(tr)));
1416
1417 let spans = vec![make_text_span("No MCID", None), make_text_span("Has MCID", Some(5))];
1418
1419 let result = extract_table_from_spans(&table_elem, &spans).unwrap();
1420 assert_eq!(result.rows[0].cells[0].text, "Has MCID");
1421 }
1422
1423 #[test]
1424 fn test_extract_table_from_spans_with_thead() {
1425 let mut table_elem = StructElem::new(StructType::Table);
1426
1427 let mut thead = StructElem::new(StructType::THead);
1428 let mut hdr_tr = StructElem::new(StructType::TR);
1429 let mut th = StructElem::new(StructType::TH);
1430 th.add_child(StructChild::MarkedContentRef {
1431 mcid: 1,
1432 page: 0,
1433 scope: crate::structure::McidScope::Page(0),
1434 });
1435 hdr_tr.add_child(StructChild::StructElem(Box::new(th)));
1436 thead.add_child(StructChild::StructElem(Box::new(hdr_tr)));
1437 table_elem.add_child(StructChild::StructElem(Box::new(thead)));
1438
1439 let mut tbody = StructElem::new(StructType::TBody);
1440 let mut body_tr = StructElem::new(StructType::TR);
1441 let mut td = StructElem::new(StructType::TD);
1442 td.add_child(StructChild::MarkedContentRef {
1443 mcid: 2,
1444 page: 0,
1445 scope: crate::structure::McidScope::Page(0),
1446 });
1447 body_tr.add_child(StructChild::StructElem(Box::new(td)));
1448 tbody.add_child(StructChild::StructElem(Box::new(body_tr)));
1449 table_elem.add_child(StructChild::StructElem(Box::new(tbody)));
1450
1451 let spans = vec![make_text_span("Header", Some(1)), make_text_span("Data", Some(2))];
1452
1453 let result = extract_table_from_spans(&table_elem, &spans).unwrap();
1454 assert!(result.has_header);
1455 assert_eq!(result.rows.len(), 2);
1456 assert!(result.rows[0].is_header);
1457 assert!(!result.rows[1].is_header);
1458 assert_eq!(result.rows[0].cells[0].text, "Header");
1459 assert_eq!(result.rows[1].cells[0].text, "Data");
1460 }
1461
1462 #[test]
1463 fn test_extract_table_from_spans_empty_table() {
1464 let table_elem = StructElem::new(StructType::Table);
1465 let spans: Vec<crate::layout::TextSpan> = vec![];
1466
1467 let result = extract_table_from_spans(&table_elem, &spans).unwrap();
1468 assert!(result.is_empty());
1469 }
1470
1471 #[test]
1477 fn test_extract_cell_adjacent_mcid_spans_no_space() {
1478 use crate::layout::text_block::{Color, FontWeight};
1479
1480 let mut td = StructElem::new(StructType::TD);
1481 td.add_child(StructChild::MarkedContentRef {
1482 mcid: 1,
1483 page: 0,
1484 scope: crate::structure::McidScope::Page(0),
1485 });
1486 td.add_child(StructChild::MarkedContentRef {
1487 mcid: 2,
1488 page: 0,
1489 scope: crate::structure::McidScope::Page(0),
1490 });
1491 td.add_child(StructChild::MarkedContentRef {
1492 mcid: 3,
1493 page: 0,
1494 scope: crate::structure::McidScope::Page(0),
1495 });
1496 let mut tr = StructElem::new(StructType::TR);
1497 tr.add_child(StructChild::StructElem(Box::new(td)));
1498 let mut table_elem = StructElem::new(StructType::Table);
1499 table_elem.add_child(StructChild::StructElem(Box::new(tr)));
1500
1501 let base = crate::layout::TextSpan {
1506 provenance: None,
1507 text_rise: 0.0,
1508 artifact_type: None,
1509 text: String::new(),
1510 bbox: Rect::new(0.0, 678.0, 0.0, 10.56),
1511 font_name: "Test".to_string(),
1512 font_size: 10.56,
1513 font_weight: FontWeight::Normal,
1514 is_italic: false,
1515 is_monospace: false,
1516 color: Color::black(),
1517 mcid: None,
1518 mcid_scope: None,
1519 sequence: 0,
1520 split_boundary_before: false,
1521 offset_semantic: false,
1522 char_spacing: 0.0,
1523 word_spacing: 0.0,
1524 horizontal_scaling: 1.0,
1525 primary_detected: false,
1526 char_widths: vec![],
1527 char_x_offsets: Vec::new(),
1528 heading_level: None,
1529 rotation_degrees: 0.0,
1530 wmode: 0,
1531 rtl_draw_logical: false,
1532 mirrored: false,
1533 page_rotation_applied: 0,
1534 };
1535 let spans = vec![
1536 crate::layout::TextSpan {
1537 text: "Q".into(),
1538 bbox: Rect::new(345.79, 678.0, 8.22, 10.56),
1539 mcid: Some(1),
1540 mcid_scope: None,
1541 ..base.clone()
1542 },
1543 crate::layout::TextSpan {
1544 text: "(".into(),
1545 bbox: Rect::new(353.83, 678.0, 10.56, 10.56),
1546 mcid: Some(2),
1547 mcid_scope: None,
1548 ..base.clone()
1549 },
1550 crate::layout::TextSpan {
1551 text: "peu/d".into(),
1552 bbox: Rect::new(364.39, 678.0, 25.24, 10.56),
1553 mcid: Some(3),
1554 mcid_scope: None,
1555 ..base.clone()
1556 },
1557 ];
1558
1559 let result = extract_table_from_spans(&table_elem, &spans).unwrap();
1560 assert_eq!(
1561 result.rows[0].cells[0].text, "Q(peu/d",
1562 "adjacent MCID spans must not get a space inserted between them"
1563 );
1564 }
1565
1566 #[test]
1568 fn test_extract_cell_multiline_mcid_spans_have_space() {
1569 let mut td = StructElem::new(StructType::TD);
1570 td.add_child(StructChild::MarkedContentRef {
1571 mcid: 1,
1572 page: 0,
1573 scope: crate::structure::McidScope::Page(0),
1574 });
1575 td.add_child(StructChild::MarkedContentRef {
1576 mcid: 2,
1577 page: 0,
1578 scope: crate::structure::McidScope::Page(0),
1579 });
1580 let mut tr = StructElem::new(StructType::TR);
1581 tr.add_child(StructChild::StructElem(Box::new(td)));
1582 let mut table_elem = StructElem::new(StructType::Table);
1583 table_elem.add_child(StructChild::StructElem(Box::new(tr)));
1584
1585 let base = crate::layout::TextSpan {
1586 provenance: None,
1587 text_rise: 0.0,
1588 artifact_type: None,
1589 text: String::new(),
1590 bbox: Rect::new(0.0, 0.0, 0.0, 12.0),
1591 font_name: "Test".to_string(),
1592 font_size: 12.0,
1593 font_weight: crate::layout::text_block::FontWeight::Normal,
1594 is_italic: false,
1595 is_monospace: false,
1596 color: crate::layout::text_block::Color::black(),
1597 mcid: None,
1598 mcid_scope: None,
1599 sequence: 0,
1600 split_boundary_before: false,
1601 offset_semantic: false,
1602 char_spacing: 0.0,
1603 word_spacing: 0.0,
1604 horizontal_scaling: 1.0,
1605 primary_detected: false,
1606 char_widths: vec![],
1607 char_x_offsets: Vec::new(),
1608 heading_level: None,
1609 rotation_degrees: 0.0,
1610 wmode: 0,
1611 rtl_draw_logical: false,
1612 mirrored: false,
1613 page_rotation_applied: 0,
1614 };
1615 let spans = vec![
1618 crate::layout::TextSpan {
1619 text: "Hello".into(),
1620 bbox: Rect::new(10.0, 200.0, 90.0, 12.0),
1621 mcid: Some(1),
1622 mcid_scope: None,
1623 ..base.clone()
1624 },
1625 crate::layout::TextSpan {
1626 text: "World".into(),
1627 bbox: Rect::new(10.0, 188.0, 90.0, 12.0),
1628 mcid: Some(2),
1629 mcid_scope: None,
1630 ..base.clone()
1631 },
1632 ];
1633
1634 let result = extract_table_from_spans(&table_elem, &spans).unwrap();
1635 assert_eq!(result.rows[0].cells[0].text, "Hello World");
1636 }
1637
1638 #[test]
1645 fn test_extract_cell_spans_carry_bold_italic_and_spacing() {
1646 use crate::layout::text_block::{Color, FontWeight};
1647
1648 let mut td = StructElem::new(StructType::TD);
1649 td.add_child(StructChild::MarkedContentRef {
1650 mcid: 1,
1651 page: 0,
1652 scope: crate::structure::McidScope::Page(0),
1653 });
1654 td.add_child(StructChild::MarkedContentRef {
1655 mcid: 2,
1656 page: 0,
1657 scope: crate::structure::McidScope::Page(0),
1658 });
1659 let mut tr = StructElem::new(StructType::TR);
1660 tr.add_child(StructChild::StructElem(Box::new(td)));
1661 let mut table_elem = StructElem::new(StructType::Table);
1662 table_elem.add_child(StructChild::StructElem(Box::new(tr)));
1663
1664 let base = crate::layout::TextSpan {
1665 provenance: None,
1666 text_rise: 0.0,
1667 artifact_type: None,
1668 text: String::new(),
1669 bbox: Rect::new(0.0, 0.0, 0.0, 12.0),
1670 font_name: "Test".to_string(),
1671 font_size: 12.0,
1672 font_weight: FontWeight::Normal,
1673 is_italic: false,
1674 is_monospace: false,
1675 color: Color::black(),
1676 mcid: None,
1677 mcid_scope: None,
1678 sequence: 0,
1679 split_boundary_before: false,
1680 offset_semantic: false,
1681 char_spacing: 0.0,
1682 word_spacing: 0.0,
1683 horizontal_scaling: 1.0,
1684 primary_detected: false,
1685 char_widths: vec![],
1686 char_x_offsets: Vec::new(),
1687 heading_level: None,
1688 rotation_degrees: 0.0,
1689 wmode: 0,
1690 rtl_draw_logical: false,
1691 mirrored: false,
1692 page_rotation_applied: 0,
1693 };
1694 let spans = vec![
1695 crate::layout::TextSpan {
1696 text: "Bold".into(),
1697 bbox: Rect::new(10.0, 200.0, 40.0, 12.0),
1698 font_weight: FontWeight::Bold,
1699 mcid: Some(1),
1700 mcid_scope: None,
1701 ..base.clone()
1702 },
1703 crate::layout::TextSpan {
1704 text: "Italic".into(),
1705 bbox: Rect::new(10.0, 188.0, 40.0, 12.0),
1706 is_italic: true,
1707 mcid: Some(2),
1708 mcid_scope: None,
1709 ..base.clone()
1710 },
1711 ];
1712
1713 let result = extract_table_from_spans(&table_elem, &spans).unwrap();
1714 let cell = &result.rows[0].cells[0];
1715 assert_eq!(cell.spans.len(), 2, "both MCID blocks must yield a span");
1716 assert_eq!(cell.spans[0].text, "Bold");
1717 assert!(
1718 matches!(cell.spans[0].font_weight, FontWeight::Bold),
1719 "bold block must propagate FontWeight::Bold into the synthesized span"
1720 );
1721 assert!(!cell.spans[0].is_italic, "non-italic block must not be italic");
1722 assert!(
1723 matches!(cell.spans[1].font_weight, FontWeight::Normal),
1724 "non-bold block must stay FontWeight::Normal"
1725 );
1726 assert!(
1727 cell.spans[1].is_italic,
1728 "italic block must propagate is_italic into the synthesized span"
1729 );
1730 assert_eq!(
1731 cell.spans[1].text, " Italic",
1732 "wrapped-line span must carry the leading inter-block space (review #533)"
1733 );
1734 }
1735
1736 #[test]
1742 fn rtl_table_cell_numeric_run_keeps_ltr_order() {
1743 let cell = rtl_cell_from(&[
1749 ("تن", 5.0, 20.0),
1750 ("4", 30.0, 7.0),
1751 ("7.500", 37.0, 33.0),
1752 ("حداقل", 75.0, 35.0),
1753 ]);
1754
1755 assert!(
1756 cell.text.contains("47.500"),
1757 "the split figure must survive intact; got {:?}",
1758 cell.text
1759 );
1760 assert!(
1761 !cell.text.contains("7.5004"),
1762 "numerals must not inherit the surrounding script's reversal; got {:?}",
1763 cell.text
1764 );
1765 let arabic_first = cell.text.find("حداقل").expect("Arabic run present");
1766 let arabic_last = cell.text.find("تن").expect("Arabic run present");
1767 assert!(
1768 arabic_first < arabic_last,
1769 "the surrounding Arabic must still read right to left; got {:?}",
1770 cell.text
1771 );
1772 }
1773
1774 #[test]
1777 fn rtl_table_cell_without_numerals_is_unchanged() {
1778 let cell = rtl_cell_from(&[("تن", 5.0, 20.0), ("حداقل", 75.0, 35.0)]);
1779 assert_eq!(
1780 cell.text, "حداقل تن",
1781 "a cell with no numerals must read purely right to left"
1782 );
1783 }
1784
1785 fn rtl_cell_from(runs: &[(&str, f32, f32)]) -> crate::structure::TableCell {
1788 use crate::layout::text_block::{Color, FontWeight};
1789
1790 let mut td = StructElem::new(StructType::TD);
1791 let mut spans = Vec::new();
1792 for (index, (text, x, width)) in runs.iter().enumerate() {
1793 let mcid = index as u32 + 1;
1794 td.add_child(StructChild::MarkedContentRef {
1795 mcid,
1796 page: 0,
1797 scope: crate::structure::McidScope::Page(0),
1798 });
1799 spans.push(crate::layout::TextSpan {
1800 provenance: None,
1801 text_rise: 0.0,
1802 artifact_type: None,
1803 text: (*text).into(),
1804 bbox: Rect::new(*x, 200.0, *width, 12.0),
1805 font_name: "Test".to_string(),
1806 font_size: 12.0,
1807 font_weight: FontWeight::Normal,
1808 is_italic: false,
1809 is_monospace: false,
1810 color: Color::black(),
1811 mcid: Some(mcid),
1812 mcid_scope: None,
1813 sequence: index,
1814 split_boundary_before: false,
1815 offset_semantic: false,
1816 char_spacing: 0.0,
1817 word_spacing: 0.0,
1818 horizontal_scaling: 1.0,
1819 primary_detected: false,
1820 char_widths: vec![],
1821 char_x_offsets: Vec::new(),
1822 heading_level: None,
1823 rotation_degrees: 0.0,
1824 wmode: 0,
1825 rtl_draw_logical: false,
1826 mirrored: false,
1827 page_rotation_applied: 0,
1828 });
1829 }
1830 let mut tr = StructElem::new(StructType::TR);
1831 tr.add_child(StructChild::StructElem(Box::new(td)));
1832 let mut table_elem = StructElem::new(StructType::Table);
1833 table_elem.add_child(StructChild::StructElem(Box::new(tr)));
1834
1835 let result = extract_table_from_spans(&table_elem, &spans).unwrap();
1836 result.rows[0].cells[0].clone()
1837 }
1838
1839 #[test]
1840 fn test_extract_cell_spans_carry_rotation_degrees() {
1841 use crate::layout::text_block::{Color, FontWeight};
1842
1843 let mut td = StructElem::new(StructType::TD);
1844 td.add_child(StructChild::MarkedContentRef {
1845 mcid: 1,
1846 page: 0,
1847 scope: crate::structure::McidScope::Page(0),
1848 });
1849 let mut tr = StructElem::new(StructType::TR);
1850 tr.add_child(StructChild::StructElem(Box::new(td)));
1851 let mut table_elem = StructElem::new(StructType::Table);
1852 table_elem.add_child(StructChild::StructElem(Box::new(tr)));
1853
1854 let span = crate::layout::TextSpan {
1855 provenance: None,
1856 text_rise: 0.0,
1857 mirrored: false,
1858 page_rotation_applied: 0,
1859 artifact_type: None,
1860 text: "Rotated".into(),
1861 bbox: Rect::new(10.0, 200.0, 40.0, 12.0),
1862 font_name: "Test".to_string(),
1863 font_size: 12.0,
1864 font_weight: FontWeight::Normal,
1865 is_italic: false,
1866 is_monospace: false,
1867 color: Color::black(),
1868 mcid: Some(1),
1869 mcid_scope: None,
1870 sequence: 0,
1871 split_boundary_before: false,
1872 offset_semantic: false,
1873 char_spacing: 0.0,
1874 word_spacing: 0.0,
1875 horizontal_scaling: 1.0,
1876 primary_detected: false,
1877 char_widths: vec![],
1878 char_x_offsets: Vec::new(),
1879 heading_level: None,
1880 rotation_degrees: 90.0,
1881 wmode: 0,
1882 rtl_draw_logical: false,
1883 };
1884
1885 let result = extract_table_from_spans(&table_elem, &[span]).unwrap();
1886 let cell = &result.rows[0].cells[0];
1887 assert_eq!(cell.spans.len(), 1);
1888 assert_eq!(
1889 cell.spans[0].rotation_degrees, 90.0,
1890 "cell span re-synthesis must carry the source block's rotation through, \
1891 not silently reset it to 0.0"
1892 );
1893 }
1894
1895 #[test]
1900 fn test_extract_cell_cjk_fullwidth_gap_suppresses_space() {
1901 use crate::layout::text_block::{Color, FontWeight};
1902
1903 let mut td = StructElem::new(StructType::TD);
1907 td.add_child(StructChild::MarkedContentRef {
1908 mcid: 10,
1909 page: 0,
1910 scope: crate::structure::McidScope::Page(0),
1911 });
1912 td.add_child(StructChild::MarkedContentRef {
1913 mcid: 11,
1914 page: 0,
1915 scope: crate::structure::McidScope::Page(0),
1916 });
1917 td.add_child(StructChild::MarkedContentRef {
1918 mcid: 12,
1919 page: 0,
1920 scope: crate::structure::McidScope::Page(0),
1921 });
1922 let mut tr = StructElem::new(StructType::TR);
1923 tr.add_child(StructChild::StructElem(Box::new(td)));
1924 let mut table_elem = StructElem::new(StructType::Table);
1925 table_elem.add_child(StructChild::StructElem(Box::new(tr)));
1926
1927 let base = crate::layout::TextSpan {
1928 provenance: None,
1929 text_rise: 0.0,
1930 artifact_type: None,
1931 text: String::new(),
1932 bbox: Rect::new(0.0, 100.0, 0.0, 10.0),
1933 font_name: "Test".to_string(),
1934 font_size: 10.0,
1935 font_weight: FontWeight::Normal,
1936 is_italic: false,
1937 is_monospace: false,
1938 color: Color::black(),
1939 mcid: None,
1940 mcid_scope: None,
1941 sequence: 0,
1942 split_boundary_before: false,
1943 offset_semantic: false,
1944 char_spacing: 0.0,
1945 word_spacing: 0.0,
1946 horizontal_scaling: 1.0,
1947 primary_detected: false,
1948 char_widths: vec![],
1949 char_x_offsets: Vec::new(),
1950 heading_level: None,
1951 rotation_degrees: 0.0,
1952 wmode: 0,
1953 rtl_draw_logical: false,
1954 mirrored: false,
1955 page_rotation_applied: 0,
1956 };
1957 let spans = vec![
1961 crate::layout::TextSpan {
1962 text: "数".into(),
1963 bbox: Rect::new(10.0, 100.0, 10.0, 10.0),
1964 mcid: Some(10),
1965 mcid_scope: None,
1966 ..base.clone()
1967 },
1968 crate::layout::TextSpan {
1969 text: "≤".into(),
1970 bbox: Rect::new(23.0, 100.0, 10.0, 10.0),
1971 mcid: Some(11),
1972 mcid_scope: None,
1973 ..base.clone()
1974 },
1975 crate::layout::TextSpan {
1976 text: "量".into(),
1977 bbox: Rect::new(36.0, 100.0, 10.0, 10.0),
1978 mcid: Some(12),
1979 mcid_scope: None,
1980 ..base.clone()
1981 },
1982 ];
1983
1984 let result = extract_table_from_spans(&table_elem, &spans).unwrap();
1985 assert_eq!(
1986 result.rows[0].cells[0].text, "数≤量",
1987 "CJK + math-op + CJK with gap > 0.15em should not have spaces inserted: \
1988 got '{}'",
1989 result.rows[0].cells[0].text
1990 );
1991 }
1992
1993 #[test]
1996 fn test_extract_cell_latin_gap_inserts_space() {
1997 use crate::layout::text_block::{Color, FontWeight};
1998
1999 let mut td = StructElem::new(StructType::TD);
2000 td.add_child(StructChild::MarkedContentRef {
2001 mcid: 20,
2002 page: 0,
2003 scope: crate::structure::McidScope::Page(0),
2004 });
2005 td.add_child(StructChild::MarkedContentRef {
2006 mcid: 21,
2007 page: 0,
2008 scope: crate::structure::McidScope::Page(0),
2009 });
2010 let mut tr = StructElem::new(StructType::TR);
2011 tr.add_child(StructChild::StructElem(Box::new(td)));
2012 let mut table_elem = StructElem::new(StructType::Table);
2013 table_elem.add_child(StructChild::StructElem(Box::new(tr)));
2014
2015 let base = crate::layout::TextSpan {
2016 provenance: None,
2017 text_rise: 0.0,
2018 artifact_type: None,
2019 text: String::new(),
2020 bbox: Rect::new(0.0, 100.0, 0.0, 10.0),
2021 font_name: "Test".to_string(),
2022 font_size: 10.0,
2023 font_weight: FontWeight::Normal,
2024 is_italic: false,
2025 is_monospace: false,
2026 color: Color::black(),
2027 mcid: None,
2028 mcid_scope: None,
2029 sequence: 0,
2030 split_boundary_before: false,
2031 offset_semantic: false,
2032 char_spacing: 0.0,
2033 word_spacing: 0.0,
2034 horizontal_scaling: 1.0,
2035 primary_detected: false,
2036 char_widths: vec![],
2037 char_x_offsets: Vec::new(),
2038 heading_level: None,
2039 rotation_degrees: 0.0,
2040 wmode: 0,
2041 rtl_draw_logical: false,
2042 mirrored: false,
2043 page_rotation_applied: 0,
2044 };
2045 let spans = vec![
2048 crate::layout::TextSpan {
2049 text: "Hello".into(),
2050 bbox: Rect::new(0.0, 100.0, 50.0, 10.0),
2051 mcid: Some(20),
2052 mcid_scope: None,
2053 ..base.clone()
2054 },
2055 crate::layout::TextSpan {
2056 text: "world".into(),
2057 bbox: Rect::new(53.0, 100.0, 30.0, 10.0),
2058 mcid: Some(21),
2059 mcid_scope: None,
2060 ..base.clone()
2061 },
2062 ];
2063
2064 let result = extract_table_from_spans(&table_elem, &spans).unwrap();
2065 assert_eq!(
2066 result.rows[0].cells[0].text, "Hello world",
2067 "Latin→Latin with gap > 0.15em should insert a space: got '{}'",
2068 result.rows[0].cells[0].text
2069 );
2070 }
2071}