1use kurbo::{Affine, BezPath, PathEl, Point, Shape};
10use pdf_render::pdf_interpret::cmap::BfString;
11use pdf_render::pdf_interpret::font::Glyph;
12use pdf_render::pdf_interpret::{
13 BlendMode, ClipPath, Device, GlyphDrawMode, Image, Paint, PathDrawMode, SoftMask,
14};
15use std::borrow::Cow;
16use std::cmp::Ordering;
17
18const BAND_Y_TOLERANCE: f64 = 5.0;
22const BAND_Y_FRACTION: f64 = 0.30;
27const PARAGRAPH_BREAK_LINE_SPACING_MULTIPLIER: f64 = 1.8;
31
32const TJ_SPACE_THRESHOLD_UNITS: f32 = 100.0;
46const TJ_SIGNAL_WEIGHT: f64 = 0.95;
48const GAP_SIGNAL_WEIGHT: f64 = 0.80;
50const HEURISTIC_SIGNAL_WEIGHT: f64 = 0.60;
52const SPACE_CONSENSUS_THRESHOLD: f64 = 0.75;
54const GAP_TO_MEDIAN_CHAR_FRACTION: f64 = 0.30;
57const GAP_TO_FONT_SIZE_FALLBACK_FRACTION: f64 = 0.15;
60
61const COLUMN_GAP_THRESHOLD_MIN: f64 = 10.0;
63const COLUMN_GAP_THRESHOLD_MAX: f64 = 40.0;
65const COLUMN_GAP_MEDIAN_MULTIPLIER: f64 = 3.0;
67const COLUMN_GAP_THRESHOLD_FALLBACK: f64 = 20.0;
69const COLUMN_GAP_MATCH_TOLERANCE: f64 = 12.0;
71const MIN_COLUMN_GAPPED_BANDS: usize = 3;
73const MIN_COLUMN_GAP_SUPPORT: f64 = 0.80;
75const MIN_DENSE_SLICE_RATIO: f64 = 0.35;
77
78const CELL_EDGE_TOLERANCE: f64 = 2.0;
82const WRAPPED_CELL_LINE_GAP_FACTOR: f64 = 1.8;
86const TABLE_GRID_MAX_LOOKBACK: usize = 4;
90const WRAPPED_CELL_MAX_PITCH_FRACTION: f64 = 0.9;
94const TABLE_GRID_MIN_COLUMNS: usize = 2;
98const RULE_MAX_THICKNESS: f64 = 3.0;
102const RULE_MIN_LENGTH: f64 = 24.0;
105const MAX_RECORDED_RULES: usize = 512;
109const RULE_SPAN_FRACTION: f64 = 0.9;
113const MIN_RULED_ROW_SEPARATORS: usize = 2;
120const MIN_RULED_GAP_SUPPORT: f64 = 0.5;
124
125const TABLE_ROW_MIN_CELLS: usize = 3;
136
137#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
139#[cfg_attr(feature = "serde", derive(serde::Serialize))]
140pub enum WidthSource {
141 Metric,
143 #[default]
145 Estimate,
146}
147
148impl WidthSource {
149 pub fn as_str(&self) -> &'static str {
153 match self {
154 WidthSource::Metric => "Metric",
155 WidthSource::Estimate => "Estimate",
156 }
157 }
158}
159
160#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
162pub enum GeometryMode {
163 #[default]
165 Basic,
166 RichGeometry,
168}
169
170#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
172pub enum BoundsSource {
173 #[default]
175 Advance,
176 Tight,
178 Estimate,
180}
181
182#[derive(Debug, Clone, Copy, PartialEq, Default)]
188#[cfg_attr(feature = "serde", derive(serde::Serialize))]
189pub struct FontMetrics {
190 pub ascent: f64,
192 pub descent: f64,
194 #[cfg_attr(
196 feature = "serde",
197 serde(rename = "capHeight", skip_serializing_if = "Option::is_none")
198 )]
199 pub cap_height: Option<f64>,
200 #[cfg_attr(
202 feature = "serde",
203 serde(rename = "xHeight", skip_serializing_if = "Option::is_none")
204 )]
205 pub x_height: Option<f64>,
206}
207
208#[derive(Debug, Clone, Default)]
210pub struct TextSpan {
211 pub text: String,
213 pub x: f64,
215 pub y: f64,
217 pub width: f64,
219 pub height: f64,
221 pub font_size: f64,
223
224 pub font_name: Option<String>,
230 pub is_bold: bool,
234 pub is_italic: bool,
238 pub color: Option<[u8; 4]>,
242
243 pub width_source: WidthSource,
246 pub char_bounds: Vec<[f64; 4]>,
249
250 #[doc(hidden)]
252 pub geometry_mode: GeometryMode,
253 #[doc(hidden)]
254 pub bounds_source: BoundsSource,
255 #[doc(hidden)]
256 pub tight_char_bounds: Vec<[f64; 4]>,
257 #[doc(hidden)]
258 pub glyph_advances: Vec<f64>,
259 #[doc(hidden)]
260 pub glyph_bounds_sources: Vec<BoundsSource>,
261
262 pub transform: Option<[f64; 6]>,
270 pub font_weight: Option<u16>,
273 pub is_serif: Option<bool>,
276 pub is_monospace: Option<bool>,
279 pub render_mode: Option<u8>,
285
286 pub font_metrics: Option<FontMetrics>,
290}
291
292impl TextSpan {
293 fn right(&self) -> f64 {
296 self.x + self.width.max(self.estimated_width())
297 }
298
299 fn measured_right(&self) -> f64 {
301 self.x + self.width
302 }
303
304 fn estimated_width(&self) -> f64 {
305 let char_count = self.text.chars().count() as f64;
306 if char_count <= 0.0 {
307 self.font_size * 0.5
308 } else {
309 self.font_size * 0.5 * char_count
310 }
311 }
312}
313
314#[derive(Debug, Clone)]
316pub struct TextBlock {
317 pub spans: Vec<TextSpan>,
319}
320
321impl TextBlock {
322 pub fn text(&self) -> String {
337 crate::bidi::logical_order(&self.visual_text()).into_owned()
338 }
339
340 fn visual_text(&self) -> String {
343 if self.spans.is_empty() {
344 return String::new();
345 }
346 let mut result = self.spans[0].text.clone();
347 let mut anchor = &self.spans[0];
355 for pair in self.spans.windows(2) {
356 let prev = &pair[0];
357 let curr = &pair[1];
358 if !crate::bidi::is_only_marks(&prev.text) {
359 anchor = prev;
360 }
361 let direction = span_direction(anchor);
366 let (curr_along, _) = baseline_coordinates(curr, direction);
367 let gap = curr_along - baseline_end(anchor, direction);
368 if gap <= prev.font_size * 0.12 && second_run_carries_on_from_the_first(prev, curr) {
369 if let Some(trimmed) = trim_overlapping_word_prefix(&prev.text, &curr.text) {
370 result.push_str(&trimmed);
371 continue;
372 }
373 }
374 if gap > prev.font_size * 0.25 {
375 result.push(' ');
376 }
377 result.push_str(&curr.text);
378 }
379 result
380 }
381}
382
383#[derive(Debug, Clone)]
384struct TextBand {
385 y: f64,
386 spans: Vec<TextSpan>,
387}
388
389impl TextBand {
390 fn new(span: TextSpan) -> Self {
391 Self {
392 y: span.y,
393 spans: vec![span],
394 }
395 }
396
397 fn sort_spans(&mut self) {
398 self.spans.sort_by(|a, b| {
399 a.x.partial_cmp(&b.x)
400 .unwrap_or(Ordering::Equal)
401 .then_with(|| b.y.partial_cmp(&a.y).unwrap_or(Ordering::Equal))
402 });
403 collapse_overprinted_spans(&mut self.spans);
404 }
405
406 fn row_block(&self) -> TextBlock {
407 let mut spans = self.spans.clone();
408 spans.sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap_or(Ordering::Equal));
409 TextBlock { spans }
410 }
411
412 fn left(&self) -> f64 {
413 self.spans
414 .iter()
415 .map(|span| span.x)
416 .fold(f64::INFINITY, f64::min)
417 }
418
419 fn right(&self) -> f64 {
420 self.spans
421 .iter()
422 .map(TextSpan::right)
423 .fold(f64::NEG_INFINITY, f64::max)
424 }
425
426 fn width(&self) -> f64 {
427 (self.right() - self.left()).max(0.0)
428 }
429
430 fn gap_midpoints(&self, column_gap_threshold: f64) -> Vec<f64> {
431 self.gaps(column_gap_threshold)
432 .into_iter()
433 .map(|gap| (gap.start + gap.end) * 0.5)
434 .collect()
435 }
436
437 fn gaps(&self, column_gap_threshold: f64) -> Vec<BandGap> {
438 if self.spans.len() < 2 {
439 return Vec::new();
440 }
441
442 let mut spans = self.spans.clone();
443 spans.sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap_or(Ordering::Equal));
444
445 let mut gaps = Vec::new();
446 let mut prev_right = spans[0].right();
447 for span in spans.iter().skip(1) {
448 let gap = span.x - prev_right;
449 if gap >= column_gap_threshold {
450 gaps.push(BandGap {
451 start: prev_right,
452 end: span.x,
453 });
454 }
455 prev_right = prev_right.max(span.right());
456 }
457
458 gaps
459 }
460
461 fn split_by_boundaries(&self, boundaries: &[f64]) -> Vec<Vec<TextSpan>> {
462 let mut columns = vec![Vec::new(); boundaries.len() + 1];
463 for span in &self.spans {
464 let center_x = span.x + span.width.max(span.estimated_width()) * 0.5;
465 let column_idx = boundaries
466 .iter()
467 .position(|boundary| center_x < *boundary)
468 .unwrap_or(boundaries.len());
469 columns[column_idx].push(span.clone());
470 }
471
472 for spans in &mut columns {
473 spans.sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap_or(Ordering::Equal));
474 }
475
476 columns
477 }
478
479 fn fits_single_column(
480 &self,
481 boundaries: &[f64],
482 region_left: f64,
483 region_right: f64,
484 ) -> Option<usize> {
485 let mut column_idx: Option<usize> = None;
486 for span in &self.spans {
487 let left = span.x;
488 let right = span.right();
489 if boundaries
490 .iter()
491 .any(|boundary| left < *boundary && right > *boundary)
492 {
493 return None;
494 }
495
496 let center_x = left + (right - left) * 0.5;
497 let idx = boundaries
498 .iter()
499 .position(|boundary| center_x < *boundary)
500 .unwrap_or(boundaries.len());
501 match column_idx {
502 Some(existing) if existing != idx => return None,
503 Some(_) => {}
504 None => column_idx = Some(idx),
505 }
506 }
507 let idx = column_idx?;
508 let mut edges = Vec::with_capacity(boundaries.len() + 2);
509 edges.push(region_left);
510 edges.extend_from_slice(boundaries);
511 edges.push(region_right);
512
513 let column_width = (edges[idx + 1] - edges[idx]).max(0.0);
514 if column_width <= 0.0 || self.width() > column_width * 0.8 {
515 return None;
516 }
517
518 Some(idx)
519 }
520}
521
522#[derive(Debug, Clone, Copy)]
523struct BandGap {
524 start: f64,
525 end: f64,
526}
527
528fn element_points(element: &PathEl) -> impl Iterator<Item = Point> {
532 let mut points = [Point::ZERO; 3];
533 let count = match *element {
534 PathEl::MoveTo(p) | PathEl::LineTo(p) => {
535 points[0] = p;
536 1
537 }
538 PathEl::QuadTo(a, b) => {
539 points[0] = a;
540 points[1] = b;
541 2
542 }
543 PathEl::CurveTo(a, b, c) => {
544 points[0] = a;
545 points[1] = b;
546 points[2] = c;
547 3
548 }
549 PathEl::ClosePath => 0,
550 };
551 points.into_iter().take(count)
552}
553
554#[derive(Clone, Copy, Debug)]
564struct HorizontalRule {
565 left: f64,
566 right: f64,
567 y: f64,
568}
569
570#[doc(hidden)]
592pub struct TextExtractionDevice {
593 spans: Vec<TextSpan>,
594 last_y: f64,
595 last_end_x: f64,
596 pending_tj_offset: f32,
600 glyph_widths: Vec<f64>,
604 cached_median_char_width: f64,
608 geometry_mode: GeometryMode,
610 deferred_rich_glyphs: Vec<DeferredGlyph>,
614 keep_ligatures: bool,
618 rules: Vec<HorizontalRule>,
620 clip_stack: Vec<kurbo::Rect>,
624 marked_content: Vec<MarkedContent>,
630 open_actual_texts: usize,
634}
635
636#[derive(Clone, Debug)]
638struct MarkedContent {
639 actual: Option<ActualText>,
642 content_spoken_for: bool,
654}
655
656#[derive(Clone, Debug)]
671struct ActualText {
672 text: String,
673 spent: bool,
674}
675
676#[derive(Clone)]
679struct DeferredGlyph {
680 coeffs: [f64; 6],
682 font_size: f64,
684 glyph_width: f64,
686 needs_exact: bool,
688 outline: Option<BezPath>,
693 font_bbox: Option<kurbo::Rect>,
696}
697
698const MEDIAN_REFRESH: usize = 32;
699
700impl Default for TextExtractionDevice {
701 fn default() -> Self {
702 Self::new()
703 }
704}
705
706impl TextExtractionDevice {
707 pub fn new() -> Self {
709 Self::with_mode(GeometryMode::Basic)
710 }
711
712 pub fn with_mode(geometry_mode: GeometryMode) -> Self {
714 Self {
715 spans: Vec::new(),
716 last_y: f64::NEG_INFINITY,
717 last_end_x: f64::NEG_INFINITY,
718 pending_tj_offset: 0.0,
719 glyph_widths: Vec::new(),
720 cached_median_char_width: 0.0,
721 geometry_mode,
722 deferred_rich_glyphs: Vec::new(),
723 keep_ligatures: false,
724 rules: Vec::new(),
725 clip_stack: Vec::new(),
726 marked_content: Vec::new(),
727 open_actual_texts: 0,
728 }
729 }
730
731 fn take_actual_text(&mut self) -> Option<String> {
737 let actual = self
738 .marked_content
739 .iter_mut()
740 .rev()
741 .find_map(|entry| entry.actual.as_mut())?;
742 if actual.spent {
743 return Some(String::new());
744 }
745 actual.spent = true;
746 Some(actual.text.clone())
747 }
748
749 fn place_for_glyphless_text(&self) -> Option<kurbo::Rect> {
763 if let Some(clip) = self.clip_stack.last() {
764 return Some(*clip);
765 }
766
767 let last = self.spans.last()?;
768
769 if !self.last_end_x.is_finite() || !self.last_y.is_finite() {
770 return None;
771 }
772
773 let height = last.font_size.max(1.0);
774
775 Some(kurbo::Rect::new(
776 self.last_end_x,
777 self.last_y,
778 self.last_end_x,
779 self.last_y + height,
780 ))
781 }
782
783 fn push_placed_span(&mut self, text: &str, rect: kurbo::Rect) {
791 if text.is_empty() {
792 return;
793 }
794
795 let height = if rect.height().is_finite() && rect.height() > 0.0 {
796 rect.height()
797 } else {
798 0.0
799 };
800 let width = if rect.width().is_finite() && rect.width() > 0.0 {
801 rect.width()
802 } else {
803 0.0
804 };
805
806 self.spans.push(TextSpan {
807 text: text.to_string(),
808 x: rect.x0,
809 y: rect.y0,
810 width,
811 height,
812 font_size: height,
813 font_name: None,
814 is_bold: false,
815 is_italic: false,
816 color: None,
817 width_source: WidthSource::Estimate,
818 char_bounds: Vec::new(),
819 transform: Some([height / 1000.0, 0.0, 0.0, height / 1000.0, rect.x0, rect.y0]),
825 font_weight: None,
826 is_serif: None,
827 is_monospace: None,
828 render_mode: Some(0),
830 font_metrics: None,
831 geometry_mode: self.geometry_mode,
832 bounds_source: BoundsSource::Estimate,
833 tight_char_bounds: Vec::new(),
834 glyph_advances: Vec::new(),
835 glyph_bounds_sources: Vec::new(),
836 });
837
838 self.last_y = rect.y0;
842 self.last_end_x = rect.x1;
843 self.pending_tj_offset = 0.0;
844 }
845
846 #[must_use]
857 pub fn keep_ligatures(mut self, keep: bool) -> Self {
858 self.keep_ligatures = keep;
859 self
860 }
861
862 fn refresh_median_char_width(&mut self) {
865 if self.glyph_widths.is_empty() {
866 self.cached_median_char_width = 0.0;
867 return;
868 }
869 let mut sorted = self.glyph_widths.clone();
870 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
871 self.cached_median_char_width = sorted[sorted.len() / 2];
872 }
873
874 fn advance_pen(&mut self, text: &str, glyph_end_x: f64) {
884 if !crate::bidi::is_only_marks(text) {
885 self.last_end_x = glyph_end_x;
886 }
887 }
888
889 fn evaluate_space_consensus(
892 &self,
893 gap: f64,
894 font_size: f64,
895 prev_text: &str,
896 next_text: &str,
897 ) -> bool {
898 let mut confidence = 0.0;
899
900 if self.pending_tj_offset.abs() >= TJ_SPACE_THRESHOLD_UNITS {
903 confidence += TJ_SIGNAL_WEIGHT;
904 }
905
906 let gap_reference = if self.cached_median_char_width > 0.0 {
910 self.cached_median_char_width * GAP_TO_MEDIAN_CHAR_FRACTION
911 } else {
912 font_size * GAP_TO_FONT_SIZE_FALLBACK_FRACTION
913 };
914 if gap > gap_reference {
915 confidence += GAP_SIGNAL_WEIGHT;
916 }
917
918 if let (Some(prev_last), Some(next_first)) =
922 (prev_text.chars().last(), next_text.chars().next())
923 {
924 let camel = prev_last.is_lowercase() && next_first.is_uppercase();
925 let digit_to_letter = prev_last.is_ascii_digit() && next_first.is_alphabetic();
926 let letter_to_digit = prev_last.is_alphabetic() && next_first.is_ascii_digit();
927 if camel || digit_to_letter || letter_to_digit {
928 confidence += HEURISTIC_SIGNAL_WEIGHT;
929 }
930 }
931
932 confidence >= SPACE_CONSENSUS_THRESHOLD
933 }
934
935 fn record_rule(&mut self, path: &BezPath, transform: Affine) {
950 if self.rules.len() >= MAX_RECORDED_RULES {
955 return;
956 }
957 let clip = self.clip_stack.last().copied();
972 let mut lo = (f64::INFINITY, f64::INFINITY);
973 let mut hi = (f64::NEG_INFINITY, f64::NEG_INFINITY);
974 let flush = |lo: &mut (f64, f64), hi: &mut (f64, f64), rules: &mut Vec<HorizontalRule>| {
975 let (mut left, mut right) = (lo.0, hi.0);
976 let (mut bottom, mut top) = (lo.1, hi.1);
977 if let Some(clip) = clip {
978 left = left.max(clip.x0);
979 right = right.min(clip.x1);
980 bottom = bottom.max(clip.y0);
981 top = top.min(clip.y1);
982 }
983 if lo.0.is_finite()
984 && rules.len() < MAX_RECORDED_RULES
985 && bottom <= top
986 && hi.1 - lo.1 <= RULE_MAX_THICKNESS
987 && right - left >= RULE_MIN_LENGTH
988 {
989 rules.push(HorizontalRule {
990 left,
991 right,
992 y: (lo.1 + hi.1) * 0.5,
993 });
994 }
995 *lo = (f64::INFINITY, f64::INFINITY);
996 *hi = (f64::NEG_INFINITY, f64::NEG_INFINITY);
997 };
998
999 for element in path.elements() {
1000 if matches!(element, PathEl::MoveTo(_) | PathEl::ClosePath) {
1001 flush(&mut lo, &mut hi, &mut self.rules);
1002 }
1003 for point in element_points(element) {
1004 let p = transform * point;
1005 lo = (lo.0.min(p.x), lo.1.min(p.y));
1006 hi = (hi.0.max(p.x), hi.1.max(p.y));
1007 }
1008 }
1009 flush(&mut lo, &mut hi, &mut self.rules);
1010 }
1011
1012 pub fn into_text(mut self) -> String {
1014 if self.geometry_mode == GeometryMode::RichGeometry {
1015 self.compute_tight_bounds();
1016 }
1017 let keep_ligatures = self.keep_ligatures;
1018 let rules = std::mem::take(&mut self.rules);
1019 let blocks = group_spans_into_blocks(self.spans, &rules);
1020 let lines: Vec<String> = blocks.iter().map(|b| b.text()).collect();
1021 let stitched = stitch_hyphenated_lines(&lines);
1022 let text = normalize_text_output(&stitched);
1023 if keep_ligatures {
1024 return text;
1025 }
1026 match crate::ligatures::fold(&text) {
1029 Cow::Borrowed(_) => text,
1030 Cow::Owned(folded) => folded,
1031 }
1032 }
1033
1034 pub fn into_blocks(mut self) -> Vec<TextBlock> {
1036 if self.geometry_mode == GeometryMode::RichGeometry {
1037 self.compute_tight_bounds();
1038 }
1039 let rules = std::mem::take(&mut self.rules);
1040 group_spans_into_blocks(self.spans, &rules)
1041 }
1042
1043 #[allow(dead_code)]
1045 pub(crate) fn into_spans(mut self) -> Vec<TextSpan> {
1046 if self.geometry_mode == GeometryMode::RichGeometry {
1047 self.compute_tight_bounds();
1048 }
1049 self.spans
1050 }
1051
1052 fn record_deferred_glyph(
1055 &mut self,
1056 glyph: &Glyph<'_>,
1057 composed: &Affine,
1058 font_size: f64,
1059 glyph_width: f64,
1060 ) {
1061 let coeffs = composed.as_coeffs();
1062 let is_rotated = coeffs[1].abs() > ROTATION_EPSILON || coeffs[2].abs() > ROTATION_EPSILON;
1063 let is_sheared = (coeffs[0] - coeffs[3]).abs() > SHEAR_EPSILON;
1064 let needs_exact = is_rotated || is_sheared;
1065
1066 let (outline, font_bbox) = match glyph {
1072 Glyph::Outline(o) => {
1073 let path = o.outline();
1074 let bb = path.bounding_box();
1075 if bb.width() <= 0.0 && bb.height() <= 0.0 {
1076 (None, None)
1077 } else if needs_exact {
1078 (Some(path), Some(bb))
1079 } else {
1080 (None, Some(bb))
1081 }
1082 }
1083 Glyph::Type3(_) => (None, None),
1084 };
1085
1086 self.deferred_rich_glyphs.push(DeferredGlyph {
1087 coeffs,
1088 font_size,
1089 glyph_width,
1090 needs_exact,
1091 outline,
1092 font_bbox,
1093 });
1094 }
1095
1096 fn compute_tight_bounds(&mut self) {
1099 let deferred = std::mem::take(&mut self.deferred_rich_glyphs);
1100 if deferred.is_empty() {
1101 return;
1102 }
1103
1104 let mut idx = 0usize;
1105
1106 for span in &mut self.spans {
1107 let n = span.char_bounds.len();
1108 if n == 0 {
1109 continue;
1110 }
1111
1112 let mut span_bounds_source = BoundsSource::Advance;
1113
1114 for _gi in 0..n {
1115 if idx >= deferred.len() {
1116 break;
1117 }
1118 let dg = &deferred[idx];
1119 idx += 1;
1120
1121 let composed = Affine::new(dg.coeffs);
1122 let (tight_bound, source) = if let Some(font_bbox) = dg.font_bbox {
1123 if dg.needs_exact {
1129 let page_bbox = match dg.outline {
1134 Some(ref path) => (composed * path.clone()).bounding_box(),
1135 None => {
1136 let raw = [font_bbox.x0, font_bbox.y0, font_bbox.x1, font_bbox.y1];
1137 let b = transform_bbox_corners(&raw, &composed);
1138 kurbo::Rect::new(b[0], b[1], b[2], b[3])
1139 }
1140 };
1141 (
1142 [page_bbox.x0, page_bbox.y0, page_bbox.x1, page_bbox.y1],
1143 BoundsSource::Tight,
1144 )
1145 } else {
1146 let raw = [font_bbox.x0, font_bbox.y0, font_bbox.x1, font_bbox.y1];
1149 (transform_bbox_corners(&raw, &composed), BoundsSource::Tight)
1150 }
1151 } else {
1152 let x = dg.coeffs[4];
1156 let y = dg.coeffs[5];
1157 (
1158 [x, y, x + dg.glyph_width, y + dg.font_size],
1159 BoundsSource::Estimate,
1160 )
1161 };
1162
1163 span.tight_char_bounds.push(tight_bound);
1164 span.glyph_advances.push(dg.glyph_width);
1165 span.glyph_bounds_sources.push(source);
1166
1167 if source == BoundsSource::Tight && span_bounds_source != BoundsSource::Estimate {
1168 span_bounds_source = BoundsSource::Tight;
1169 } else if source == BoundsSource::Estimate {
1170 span_bounds_source = BoundsSource::Estimate;
1171 }
1172 }
1173
1174 span.bounds_source = span_bounds_source;
1175 }
1176 }
1177}
1178
1179impl Device<'_> for TextExtractionDevice {
1180 fn set_soft_mask(&mut self, _: Option<SoftMask<'_>>) {}
1181 fn set_blend_mode(&mut self, _: BlendMode) {}
1182 fn draw_path(&mut self, path: &BezPath, transform: Affine, _: &Paint<'_>, _: &PathDrawMode) {
1183 self.record_rule(path, transform);
1184 }
1185 fn push_clip_path(&mut self, clip_path: &ClipPath) {
1186 let box_of_clip = clip_path.path.bounding_box();
1193 let effective = match self.clip_stack.last() {
1194 Some(outer) => outer.intersect(box_of_clip),
1195 None => box_of_clip,
1196 };
1197 self.clip_stack.push(effective);
1198 }
1199 fn push_transparency_group(&mut self, _: f32, _: Option<SoftMask<'_>>, _: BlendMode) {}
1200 fn draw_image(&mut self, _: Image<'_, '_>, _: Affine) {}
1201 fn pop_clip_path(&mut self) {
1202 self.clip_stack.pop();
1203 }
1204 fn pop_transparency_group(&mut self) {}
1205 fn begin_marked_content(&mut self, _tag: &[u8], _mcid: Option<i32>, actual_text: Option<&str>) {
1206 let actual = actual_text.map(|text| ActualText {
1207 text: text.to_string(),
1208 spent: false,
1209 });
1210
1211 if actual.is_some() {
1212 self.open_actual_texts += 1;
1213 }
1214
1215 self.marked_content.push(MarkedContent {
1216 actual,
1217 content_spoken_for: false,
1218 });
1219 }
1220 fn end_marked_content(&mut self) {
1221 let Some(entry) = self.marked_content.pop() else {
1225 return;
1226 };
1227
1228 let Some(actual) = entry.actual else {
1229 return;
1230 };
1231
1232 self.open_actual_texts -= 1;
1233
1234 if actual.spent || entry.content_spoken_for {
1238 return;
1239 }
1240
1241 for open in &mut self.marked_content {
1247 open.content_spoken_for = true;
1248 }
1249
1250 if actual.text.is_empty() {
1253 return;
1254 }
1255
1256 if let Some(rect) = self.place_for_glyphless_text() {
1257 self.push_placed_span(&actual.text, rect);
1258 }
1259 }
1260
1261 fn draw_placed_text(&mut self, text: &str, rect: kurbo::Rect) {
1262 self.push_placed_span(text, rect);
1263 }
1264
1265 fn draw_glyph(
1266 &mut self,
1267 glyph: &Glyph<'_>,
1268 transform: Affine,
1269 glyph_transform: Affine,
1270 paint: &Paint<'_>,
1271 draw_mode: &GlyphDrawMode,
1272 ) {
1273 if self.open_actual_texts > 0 {
1278 for entry in &mut self.marked_content {
1279 entry.content_spoken_for = true;
1280 }
1281 }
1282
1283 let own_text = || match glyph.as_unicode() {
1284 Some(BfString::Char(c)) => Some(c.to_string()),
1285 Some(BfString::String(s)) => Some(s),
1286 None => None,
1287 };
1288
1289 let (text, suppressed) = match self.take_actual_text() {
1302 Some(replacement) if replacement.is_empty() => (own_text().unwrap_or_default(), true),
1303 Some(replacement) => (replacement, false),
1304 None => match own_text() {
1305 Some(text) => (text, false),
1306 None => return,
1307 },
1308 };
1309
1310 let composed = transform * glyph_transform;
1311 let coeffs = composed.as_coeffs();
1312 let x = coeffs[4];
1313 let y = coeffs[5];
1314 let glyph_scale = (coeffs[0].powi(2) + coeffs[1].powi(2)).sqrt().abs();
1315 let font_size = glyph_scale * 1000.0;
1316
1317 let (glyph_width, glyph_ws) = glyph_width_and_source(glyph, font_size);
1319 let glyph_end_x = x + glyph_width;
1320 let glyph_bound = [x, y, glyph_end_x, y + font_size];
1321
1322 let style = derive_glyph_style(glyph);
1323 let color = paint_to_rgba(paint);
1324
1325 if self.geometry_mode == GeometryMode::RichGeometry {
1327 self.record_deferred_glyph(glyph, &composed, font_size, glyph_width);
1328 }
1329
1330 if self.glyph_widths.len() < 4096 {
1334 self.glyph_widths.push(glyph_width);
1335 if self.glyph_widths.len().is_multiple_of(MEDIAN_REFRESH) {
1336 self.refresh_median_char_width();
1337 }
1338 }
1339
1340 if suppressed {
1343 self.last_y = y;
1344 self.advance_pen(&text, glyph_end_x);
1345 self.pending_tj_offset = 0.0;
1346 return;
1347 }
1348
1349 let same_line = (y - self.last_y).abs() <= font_size.max(BAND_Y_TOLERANCE) * 0.35;
1350 let gap = x - self.last_end_x;
1351 let adjacent = same_line && gap >= -font_size * 0.25 && gap < font_size * 0.5;
1352
1353 let style_matches = self
1357 .spans
1358 .last()
1359 .map(|last| {
1360 last.font_name == style.font_name
1361 && last.is_bold == style.is_bold
1362 && last.is_italic == style.is_italic
1363 && last.color == color
1364 })
1365 .unwrap_or(false);
1366
1367 if adjacent && !self.spans.is_empty() && style_matches {
1368 let want_space = {
1376 let last = self.spans.last().expect("checked non-empty");
1377 !last.text.ends_with(' ')
1378 && !text.starts_with(' ')
1379 && self.evaluate_space_consensus(gap, font_size, &last.text, &text)
1380 };
1381 let last = self.spans.last_mut().expect("checked non-empty");
1382 if want_space {
1383 last.text.push(' ');
1384 }
1385 last.text.push_str(&text);
1386 last.width = last.width.max(glyph_end_x - last.x);
1387 last.height = last.height.max(font_size);
1388 last.char_bounds.push(glyph_bound);
1390 if glyph_ws == WidthSource::Estimate {
1391 last.width_source = WidthSource::Estimate;
1392 }
1393 self.last_y = y;
1394 self.advance_pen(&text, glyph_end_x);
1395 self.pending_tj_offset = 0.0;
1398 return;
1399 }
1400
1401 self.last_y = y;
1402 self.advance_pen(&text, glyph_end_x);
1403 self.pending_tj_offset = 0.0;
1407
1408 let span = TextSpan {
1409 text,
1410 x,
1411 y,
1412 width: glyph_width,
1413 height: font_size,
1414 font_size,
1415 font_name: style.font_name,
1416 is_bold: style.is_bold,
1417 is_italic: style.is_italic,
1418 color,
1419 width_source: glyph_ws,
1420 char_bounds: vec![glyph_bound],
1421 transform: Some(coeffs),
1422 font_weight: style.font_weight,
1423 is_serif: style.is_serif,
1424 is_monospace: style.is_monospace,
1425 render_mode: Some(render_mode_from_draw_mode(draw_mode)),
1426 font_metrics: style.font_metrics,
1427 geometry_mode: self.geometry_mode,
1428 bounds_source: BoundsSource::Advance,
1429 tight_char_bounds: Vec::new(),
1430 glyph_advances: Vec::new(),
1431 glyph_bounds_sources: Vec::new(),
1432 };
1433
1434 self.spans.push(span);
1435 }
1436
1437 fn text_adjustment(&mut self, amount: f32) {
1442 self.pending_tj_offset += amount;
1443 }
1444}
1445
1446#[derive(Debug, Default, Clone)]
1448struct GlyphStyle {
1449 font_name: Option<String>,
1450 is_bold: bool,
1451 is_italic: bool,
1452 font_weight: Option<u16>,
1454 is_serif: Option<bool>,
1456 is_monospace: Option<bool>,
1458 font_metrics: Option<FontMetrics>,
1460}
1461
1462fn strip_subset_prefix(name: &str) -> &str {
1464 match name.split_once('+') {
1465 Some((prefix, rest)) if prefix.len() == 6 => rest,
1466 _ => name,
1467 }
1468}
1469
1470fn name_style_hints(name: &str) -> (bool, bool) {
1474 let lower = name.to_ascii_lowercase();
1475 let italic = lower.contains("italic") || lower.contains("oblique") || lower.contains("slant");
1476 let bold = lower.contains("bold")
1477 || lower.contains("demi")
1478 || lower.contains("semibold")
1479 || lower.contains("heavy")
1480 || lower.contains("black");
1481 (bold, italic)
1482}
1483
1484fn derive_glyph_style(glyph: &Glyph<'_>) -> GlyphStyle {
1485 match glyph {
1486 Glyph::Outline(outline) => {
1487 if let Some(data) = outline.font_data() {
1488 let raw = data.postscript_name.as_deref().unwrap_or("");
1489 let name = strip_subset_prefix(raw).to_string();
1490 let weight_bold = data.weight.is_some_and(|w| w >= 700);
1491 let (name_bold, name_italic) = name_style_hints(&name);
1492 GlyphStyle {
1493 font_name: if name.is_empty() { None } else { Some(name) },
1494 is_bold: weight_bold || name_bold,
1495 is_italic: data.is_italic || name_italic,
1496 font_weight: data.weight.map(|w| w.clamp(1, 1000) as u16),
1497 is_serif: Some(data.is_serif),
1498 is_monospace: Some(data.is_monospace),
1499 font_metrics: match (data.ascent, data.descent) {
1500 (Some(ascent), Some(descent)) => Some(FontMetrics {
1501 ascent,
1502 descent,
1503 cap_height: data.cap_height,
1504 x_height: data.x_height,
1505 }),
1506 _ => None,
1507 },
1508 }
1509 } else {
1510 let raw = outline.postscript_name().unwrap_or_default();
1516 let name = strip_subset_prefix(&raw).to_string();
1517 let (name_bold, name_italic) = name_style_hints(&name);
1518 let metrics = outline.font_metrics().map(|(a, d, c, x)| FontMetrics {
1519 ascent: a,
1520 descent: d,
1521 cap_height: c,
1522 x_height: x,
1523 });
1524 GlyphStyle {
1525 font_name: if name.is_empty() { None } else { Some(name) },
1526 is_bold: name_bold,
1527 is_italic: name_italic,
1528 font_weight: None,
1530 is_serif: None,
1531 is_monospace: None,
1532 font_metrics: metrics,
1533 }
1534 }
1535 }
1536 Glyph::Type3(_) => GlyphStyle::default(),
1537 }
1538}
1539
1540fn paint_to_rgba(paint: &Paint<'_>) -> Option<[u8; 4]> {
1541 match paint {
1542 Paint::Color(c) => Some(c.to_rgba().to_rgba8()),
1543 Paint::Pattern(_) => None,
1544 }
1545}
1546
1547fn render_mode_from_draw_mode(mode: &GlyphDrawMode) -> u8 {
1556 match mode {
1557 GlyphDrawMode::Fill => 0,
1558 GlyphDrawMode::Stroke(_) => 1,
1559 GlyphDrawMode::Invisible => 3,
1560 }
1561}
1562
1563#[cfg(test)]
1564mod render_mode_tests {
1565 use super::render_mode_from_draw_mode;
1566 use pdf_render::pdf_interpret::{GlyphDrawMode, StrokeProps};
1567
1568 #[test]
1569 fn render_mode_is_only_zero_one_three() {
1570 assert_eq!(render_mode_from_draw_mode(&GlyphDrawMode::Fill), 0);
1571 assert_eq!(
1572 render_mode_from_draw_mode(&GlyphDrawMode::Stroke(StrokeProps::default())),
1573 1
1574 );
1575 assert_eq!(render_mode_from_draw_mode(&GlyphDrawMode::Invisible), 3);
1576 for m in [
1578 GlyphDrawMode::Fill,
1579 GlyphDrawMode::Stroke(StrokeProps::default()),
1580 GlyphDrawMode::Invisible,
1581 ] {
1582 assert!(matches!(render_mode_from_draw_mode(&m), 0 | 1 | 3));
1583 }
1584 }
1585}
1586
1587fn glyph_width_and_source(glyph: &Glyph<'_>, font_size: f64) -> (f64, WidthSource) {
1594 match glyph {
1595 Glyph::Outline(outline) => {
1596 if let Some(w) = outline.advance_width() {
1597 let advance = (w as f64 / 1000.0 * font_size).max(font_size * 0.25);
1598 (advance, WidthSource::Metric)
1599 } else {
1600 (font_size * 0.5, WidthSource::Estimate)
1601 }
1602 }
1603 Glyph::Type3(_) => (font_size * 0.5, WidthSource::Estimate),
1604 }
1605}
1606
1607const DIRECTION_TOLERANCE_RAD: f64 = 0.008_727; type Direction = (f64, f64);
1616
1617type PlacedSpan = (usize, TextSpan);
1620
1621fn baseline_direction(coeffs: &[f64; 6]) -> Direction {
1630 let (a, b) = (coeffs[0], coeffs[1]);
1631 let length = (a * a + b * b).sqrt();
1632 if length <= f64::EPSILON {
1633 (1.0, 0.0)
1634 } else {
1635 (a / length, b / length)
1636 }
1637}
1638
1639fn span_direction(span: &TextSpan) -> Direction {
1642 span.transform
1643 .as_ref()
1644 .map(baseline_direction)
1645 .unwrap_or((1.0, 0.0))
1646}
1647
1648fn direction_gap(lhs: Direction, rhs: Direction) -> f64 {
1650 let cross = lhs.0 * rhs.1 - lhs.1 * rhs.0;
1651 let dot = lhs.0 * rhs.0 + lhs.1 * rhs.1;
1652 cross.atan2(dot).abs()
1653}
1654
1655fn directions_agree(lhs: Direction, rhs: Direction) -> bool {
1656 direction_gap(lhs, rhs) <= DIRECTION_TOLERANCE_RAD
1657}
1658
1659fn baseline_coordinates(span: &TextSpan, direction: Direction) -> (f64, f64) {
1666 (
1667 span.x * direction.0 + span.y * direction.1,
1668 -span.x * direction.1 + span.y * direction.0,
1669 )
1670}
1671
1672fn baseline_end(span: &TextSpan, direction: Direction) -> f64 {
1684 let start = baseline_coordinates(span, direction).0;
1685 let from_width = start + span.width;
1686 match span.char_bounds.last() {
1687 Some(bound) => {
1688 let last = bound[0] * direction.0 + bound[1] * direction.1;
1689 let advance = bound[2] - bound[0];
1690 (last + advance).max(from_width)
1691 }
1692 None => from_width,
1693 }
1694}
1695
1696const ROTATION_EPSILON: f64 = 1e-6;
1700const SHEAR_EPSILON: f64 = 1e-3;
1701
1702fn transform_bbox_corners(local_bbox: &[f64; 4], affine: &Affine) -> [f64; 4] {
1705 use kurbo::Point;
1706 let corners = [
1707 *affine * Point::new(local_bbox[0], local_bbox[1]),
1708 *affine * Point::new(local_bbox[2], local_bbox[1]),
1709 *affine * Point::new(local_bbox[2], local_bbox[3]),
1710 *affine * Point::new(local_bbox[0], local_bbox[3]),
1711 ];
1712 let x0 = corners.iter().map(|p| p.x).fold(f64::INFINITY, f64::min);
1713 let y0 = corners.iter().map(|p| p.y).fold(f64::INFINITY, f64::min);
1714 let x1 = corners
1715 .iter()
1716 .map(|p| p.x)
1717 .fold(f64::NEG_INFINITY, f64::max);
1718 let y1 = corners
1719 .iter()
1720 .map(|p| p.y)
1721 .fold(f64::NEG_INFINITY, f64::max);
1722 [x0, y0, x1, y1]
1723}
1724
1725fn collapse_overprinted_spans(spans: &mut Vec<TextSpan>) {
1731 if spans.len() < 2 {
1732 return;
1733 }
1734
1735 let mut deduped: Vec<TextSpan> = Vec::with_capacity(spans.len());
1736 for span in spans.drain(..) {
1737 if let Some(last) = deduped.last_mut() {
1738 if spans_are_overprint_duplicates(last, &span) {
1739 let choose_incoming = span.text.chars().count() > last.text.chars().count()
1740 || (span.text.chars().count() == last.text.chars().count()
1741 && span.width > last.width);
1742 let preferred_text = if choose_incoming {
1743 span.text.clone()
1744 } else {
1745 last.text.clone()
1746 };
1747 let left = last.x.min(span.x);
1748 let right = last.right().max(span.right());
1749 last.x = left;
1750 last.y = (last.y + span.y) * 0.5;
1751 last.width = (right - left).max(last.width).max(span.width);
1752 last.height = last.height.max(span.height);
1753 last.font_size = last.font_size.max(span.font_size);
1754 last.text = preferred_text;
1755 continue;
1756 }
1757 }
1758
1759 deduped.push(span);
1760 }
1761
1762 *spans = deduped;
1763}
1764
1765fn spans_are_overprint_duplicates(lhs: &TextSpan, rhs: &TextSpan) -> bool {
1766 let lhs_text = lhs.text.trim();
1767 let rhs_text = rhs.text.trim();
1768 if lhs_text.is_empty() || rhs_text.is_empty() {
1769 return false;
1770 }
1771
1772 let same_baseline = (lhs.y - rhs.y).abs() <= lhs.font_size.max(rhs.font_size) * 0.12;
1773 if !same_baseline {
1774 return false;
1775 }
1776
1777 let lhs_left = lhs.x;
1778 let lhs_right = lhs.right();
1779 let rhs_left = rhs.x;
1780 let rhs_right = rhs.right();
1781 let overlap = (lhs_right.min(rhs_right) - lhs_left.max(rhs_left)).max(0.0);
1782 let min_width = (lhs_right - lhs_left).min(rhs_right - rhs_left).max(1.0);
1783 let heavily_overlaps = overlap / min_width >= 0.85;
1784 if !heavily_overlaps {
1785 return false;
1786 }
1787
1788 lhs_text == rhs_text || lhs_text.starts_with(rhs_text) || rhs_text.starts_with(lhs_text)
1789}
1790
1791fn second_run_carries_on_from_the_first(prev: &TextSpan, curr: &TextSpan) -> bool {
1817 let glyphs = if curr.char_bounds.is_empty() {
1818 curr.text.chars().count()
1819 } else {
1820 curr.char_bounds.len()
1821 };
1822 if glyphs == 0 {
1823 return false;
1824 }
1825
1826 let mean_advance = curr.width / glyphs as f64;
1827 let direction = span_direction(prev);
1830 let (curr_along, _) = baseline_coordinates(curr, direction);
1831 baseline_end(prev, direction) - curr_along <= mean_advance
1832}
1833
1834fn trim_overlapping_word_prefix(prev: &str, curr: &str) -> Option<String> {
1835 let prev_chars: Vec<char> = prev.trim_end().chars().collect();
1836 let curr_chars: Vec<char> = curr.trim_start().chars().collect();
1837 let max = prev_chars.len().min(curr_chars.len());
1838
1839 for len in (4..=max).rev() {
1840 let prev_start = prev_chars.len() - len;
1841 if prev_chars[prev_start..] != curr_chars[..len] {
1842 continue;
1843 }
1844
1845 if !curr_chars[..len].iter().all(|ch| ch.is_alphanumeric()) {
1846 continue;
1847 }
1848
1849 let prev_boundary = prev_start == 0 || !prev_chars[prev_start - 1].is_alphanumeric();
1850 let curr_boundary = len == curr_chars.len() || !curr_chars[len].is_alphanumeric();
1851 if !prev_boundary || !curr_boundary {
1852 continue;
1853 }
1854
1855 return Some(curr_chars[len..].iter().collect());
1856 }
1857
1858 None
1859}
1860
1861fn compute_adaptive_column_gap(bands: &[TextBand]) -> f64 {
1868 let mut all_gaps: Vec<f64> = Vec::new();
1869
1870 for band in bands {
1871 if band.spans.len() < 2 {
1872 continue;
1873 }
1874 let mut sorted = band.spans.clone();
1875 sorted.sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap_or(Ordering::Equal));
1876 let mut prev_right = sorted[0].right();
1877 for span in sorted.iter().skip(1) {
1878 let gap = span.x - prev_right;
1879 if gap > 0.0 {
1880 all_gaps.push(gap);
1881 }
1882 prev_right = prev_right.max(span.right());
1883 }
1884 }
1885
1886 if all_gaps.is_empty() {
1887 return COLUMN_GAP_THRESHOLD_FALLBACK;
1888 }
1889
1890 all_gaps.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
1891
1892 let min_gap = all_gaps[0];
1893
1894 if min_gap > COLUMN_GAP_THRESHOLD_MIN {
1899 return (min_gap * 0.75).clamp(COLUMN_GAP_THRESHOLD_MIN, COLUMN_GAP_THRESHOLD_MAX);
1900 }
1901
1902 let mut best_break_threshold = 0.0f64;
1905 let mut best_ratio = 1.5f64; for pair in all_gaps.windows(2) {
1907 if pair[0] > 0.5 {
1908 let ratio = pair[1] / pair[0];
1909 if ratio > best_ratio {
1910 best_ratio = ratio;
1911 best_break_threshold = (pair[0] + pair[1]) * 0.5;
1912 }
1913 }
1914 }
1915
1916 if best_break_threshold > 0.0 {
1917 return best_break_threshold.clamp(COLUMN_GAP_THRESHOLD_MIN, COLUMN_GAP_THRESHOLD_MAX);
1918 }
1919
1920 let mid = all_gaps.len() / 2;
1922 let median = if all_gaps.len().is_multiple_of(2) {
1923 (all_gaps[mid - 1] + all_gaps[mid]) * 0.5
1924 } else {
1925 all_gaps[mid]
1926 };
1927
1928 (median * COLUMN_GAP_MEDIAN_MULTIPLIER)
1929 .clamp(COLUMN_GAP_THRESHOLD_MIN, COLUMN_GAP_THRESHOLD_MAX)
1930}
1931
1932#[derive(Debug, Clone, Copy)]
1940struct PageStats {
1941 median_font_size: f64,
1943 #[allow(dead_code)]
1948 median_char_width: f64,
1949 median_line_spacing: f64,
1955}
1956
1957impl PageStats {
1958 fn from_spans(spans: &[TextSpan]) -> Self {
1959 if spans.is_empty() {
1960 return Self {
1961 median_font_size: 12.0,
1962 median_char_width: 6.0,
1963 median_line_spacing: 0.0,
1964 };
1965 }
1966
1967 let mut sizes: Vec<f64> = spans.iter().map(|s| s.font_size).collect();
1969 sizes.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
1970 let median_font_size = sizes[sizes.len() / 2];
1971
1972 let mut char_widths: Vec<f64> = spans
1974 .iter()
1975 .filter_map(|s| {
1976 let chars = s.text.chars().count();
1977 if chars > 0 && s.width > 0.0 {
1978 Some(s.width / chars as f64)
1979 } else {
1980 None
1981 }
1982 })
1983 .collect();
1984 let median_char_width = if char_widths.is_empty() {
1985 median_font_size * 0.5
1986 } else {
1987 char_widths.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
1988 char_widths[char_widths.len() / 2]
1989 };
1990
1991 let band_tolerance = (median_font_size * BAND_Y_FRACTION).max(BAND_Y_TOLERANCE);
1994 let mut ys: Vec<f64> = spans.iter().map(|s| s.y).collect();
1995 ys.sort_by(|a, b| b.partial_cmp(a).unwrap_or(Ordering::Equal));
1996 let mut band_ys: Vec<f64> = Vec::new();
1997 for y in ys {
1998 if band_ys
1999 .last()
2000 .map(|prev: &f64| (prev - y).abs() > band_tolerance)
2001 .unwrap_or(true)
2002 {
2003 band_ys.push(y);
2004 }
2005 }
2006 let median_line_spacing = if band_ys.len() < 2 {
2015 0.0
2016 } else {
2017 let mut spacings: Vec<f64> = band_ys
2018 .windows(2)
2019 .map(|pair| (pair[0] - pair[1]).abs())
2020 .collect();
2021 spacings.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
2022 let q1_index = spacings.len() / 4;
2023 spacings[q1_index]
2024 };
2025
2026 Self {
2027 median_font_size,
2028 median_char_width,
2029 median_line_spacing,
2030 }
2031 }
2032}
2033
2034const XY_CUT_MAX_DEPTH: usize = 12;
2039const XY_CUT_VERTICAL_GAP_FLOOR: f64 = 10.0;
2059const XY_CUT_HORIZONTAL_GAP_FONT_MULTIPLIER: f64 = 1.8;
2062const XY_CUT_MIN_SPANS_PER_COLUMN: usize = 2;
2066const XY_CUT_MIN_CHARS_PER_BAND: f64 = 8.0;
2069
2070fn group_spans_into_blocks(spans: Vec<TextSpan>, rules: &[HorizontalRule]) -> Vec<TextBlock> {
2078 if spans.is_empty() {
2079 return Vec::new();
2080 }
2081 let turn = PageTurn::of(&spans);
2084 let spans = turn.into_frame(spans);
2085 let stats = PageStats::from_spans(&spans);
2086 let rules = turn.rules_in_frame(rules);
2087 let blocks = xy_cut_recursive(spans, 0, &stats, &rules);
2088 let blocks = reassemble_cross_direction_runs(blocks);
2089 turn.back(blocks)
2090}
2091
2092#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2120enum PageTurn {
2121 None,
2123 Quarter,
2125 Half,
2127 ThreeQuarter,
2129}
2130
2131impl PageTurn {
2132 fn of(spans: &[TextSpan]) -> Self {
2158 let page_total: usize = spans.iter().map(glyph_weight).sum();
2159 if page_total == 0 {
2160 return Self::None;
2161 }
2162 let turned: Vec<&TextSpan> = spans
2163 .iter()
2164 .filter(|span| is_a_proper_rotation(span))
2165 .collect();
2166 let turned_total: usize = turned.iter().copied().map(glyph_weight).sum();
2167 let Some((page, share_of_turned)) = heaviest_direction(turned.into_iter()) else {
2168 return Self::None;
2169 };
2170 let share = share_of_turned * turned_total as f64 / page_total as f64;
2174 if share <= 0.5 {
2175 return Self::None;
2176 }
2177 [Self::Quarter, Self::Half, Self::ThreeQuarter]
2178 .into_iter()
2179 .find(|turn| directions_agree(page, turn.direction()))
2180 .unwrap_or(Self::None)
2181 }
2182
2183 fn direction(self) -> Direction {
2185 match self {
2186 Self::None => (1.0, 0.0),
2187 Self::Quarter => (0.0, 1.0),
2188 Self::Half => (-1.0, 0.0),
2189 Self::ThreeQuarter => (0.0, -1.0),
2190 }
2191 }
2192
2193 fn rotation(self) -> (f64, f64) {
2196 match self {
2197 Self::None => (1.0, 0.0),
2198 Self::Quarter => (0.0, -1.0),
2199 Self::Half => (-1.0, 0.0),
2200 Self::ThreeQuarter => (0.0, 1.0),
2201 }
2202 }
2203
2204 fn into_frame(self, mut spans: Vec<TextSpan>) -> Vec<TextSpan> {
2206 if self == Self::None {
2207 return spans;
2208 }
2209 let rotation = self.rotation();
2210 for span in &mut spans {
2211 let along = baseline_extent(span);
2219 turn_span(span, rotation);
2220 span.width = along;
2221 }
2222 spans
2223 }
2224
2225 fn rules_in_frame(self, rules: &[HorizontalRule]) -> Cow<'_, [HorizontalRule]> {
2236 let rotation = self.rotation();
2237 match self {
2238 Self::None => Cow::Borrowed(rules),
2239 Self::Half => Cow::Owned(
2240 rules
2241 .iter()
2242 .map(|rule| {
2243 let (left, y) = turn_point(rule.left, rule.y, rotation);
2244 let (right, _) = turn_point(rule.right, rule.y, rotation);
2245 HorizontalRule {
2246 left: left.min(right),
2247 right: left.max(right),
2248 y,
2249 }
2250 })
2251 .collect(),
2252 ),
2253 Self::Quarter | Self::ThreeQuarter => Cow::Owned(Vec::new()),
2254 }
2255 }
2256
2257 fn back(self, mut blocks: Vec<TextBlock>) -> Vec<TextBlock> {
2260 if self == Self::None {
2261 return blocks;
2262 }
2263 let (cos, sin) = self.rotation();
2264 for span in blocks.iter_mut().flat_map(|block| block.spans.iter_mut()) {
2265 turn_span(span, (cos, -sin));
2266 span.width = page_space_width(span);
2267 }
2268 blocks
2269 }
2270}
2271
2272fn baseline_extent(span: &TextSpan) -> f64 {
2274 let direction = span_direction(span);
2275 baseline_end(span, direction) - baseline_coordinates(span, direction).0
2276}
2277
2278fn page_space_width(span: &TextSpan) -> f64 {
2288 span.char_bounds
2289 .iter()
2290 .map(|bound| bound[2] - span.x)
2291 .reduce(f64::max)
2292 .unwrap_or(span.width)
2293}
2294
2295fn turn_span(span: &mut TextSpan, rotation: (f64, f64)) {
2313 let (x, y) = turn_point(span.x, span.y, rotation);
2314 span.x = x;
2315 span.y = y;
2316 if let Some(coeffs) = span.transform.as_mut() {
2317 let (a, b) = turn_point(coeffs[0], coeffs[1], rotation);
2318 let (c, d) = turn_point(coeffs[2], coeffs[3], rotation);
2319 let (e, f) = turn_point(coeffs[4], coeffs[5], rotation);
2320 *coeffs = [a, b, c, d, e, f];
2321 }
2322 for bound in &mut span.char_bounds {
2323 let (advance, size) = (bound[2] - bound[0], bound[3] - bound[1]);
2324 let (x0, y0) = turn_point(bound[0], bound[1], rotation);
2325 *bound = [x0, y0, x0 + advance, y0 + size];
2326 }
2327}
2328
2329fn turn_point(x: f64, y: f64, (cos, sin): (f64, f64)) -> (f64, f64) {
2331 (x * cos - y * sin, x * sin + y * cos)
2332}
2333
2334fn reassemble_cross_direction_runs(blocks: Vec<TextBlock>) -> Vec<TextBlock> {
2362 let Some(page) = page_direction(&blocks) else {
2363 return blocks;
2364 };
2365
2366 let mut kept: Vec<TextBlock> = Vec::with_capacity(blocks.len());
2367 let mut cross: Vec<PlacedSpan> = Vec::new();
2370 for block in blocks {
2371 let at = kept.len();
2372 let mut page_spans = Vec::with_capacity(block.spans.len());
2373 for span in block.spans {
2374 if directions_agree(page, span_direction(&span)) {
2375 page_spans.push(span);
2376 } else {
2377 cross.push((at, span));
2378 }
2379 }
2380 if !page_spans.is_empty() {
2381 kept.push(TextBlock { spans: page_spans });
2382 }
2383 }
2384
2385 if cross.is_empty() {
2386 return kept;
2387 }
2388
2389 let runs = lines_along_their_own_baselines(cross);
2396 for (at, run) in runs.into_iter().rev() {
2397 let at = at.min(kept.len());
2398 kept.insert(at, run);
2399 }
2400 kept
2401}
2402
2403fn page_direction(blocks: &[TextBlock]) -> Option<Direction> {
2406 dominant_direction(blocks.iter().flat_map(|block| block.spans.iter()))
2407}
2408
2409fn dominant_direction<'a>(spans: impl Iterator<Item = &'a TextSpan>) -> Option<Direction> {
2422 heaviest_direction(spans).map(|(direction, _)| direction)
2423}
2424
2425fn glyph_weight(span: &TextSpan) -> usize {
2429 if span.char_bounds.is_empty() {
2430 span.text.chars().count().max(1)
2431 } else {
2432 span.char_bounds.len()
2433 }
2434}
2435
2436fn is_a_proper_rotation(span: &TextSpan) -> bool {
2447 match span.transform {
2448 None => true,
2449 Some([a, b, c, d, _, _]) => a * d - b * c > 0.0,
2450 }
2451}
2452
2453fn heaviest_direction<'a>(spans: impl Iterator<Item = &'a TextSpan>) -> Option<(Direction, f64)> {
2456 let mut directions: Vec<(Direction, usize)> = Vec::new();
2457 for span in spans {
2458 let direction = span_direction(span);
2459 let weight = glyph_weight(span);
2460 match directions
2461 .iter_mut()
2462 .find(|(known, _)| directions_agree(*known, direction))
2463 {
2464 Some(entry) => entry.1 += weight,
2465 None => directions.push((direction, weight)),
2466 }
2467 }
2468 let total: usize = directions.iter().map(|(_, weight)| *weight).sum();
2469 let mut winner: Option<(Direction, usize)> = None;
2473 for (direction, weight) in directions {
2474 if winner.map(|(_, best)| weight > best).unwrap_or(true) {
2475 winner = Some((direction, weight));
2476 }
2477 }
2478 winner.map(|(direction, weight)| (direction, weight as f64 / total as f64))
2479}
2480
2481fn lines_along_their_own_baselines(spans: Vec<PlacedSpan>) -> Vec<(usize, TextBlock)> {
2489 let mut groups: Vec<(Direction, Vec<PlacedSpan>)> = Vec::new();
2492 for (at, span) in spans {
2493 let direction = span_direction(&span);
2494 match groups
2495 .iter_mut()
2496 .find(|(known, _)| directions_agree(*known, direction))
2497 {
2498 Some((_, group)) => group.push((at, span)),
2499 None => groups.push((direction, vec![(at, span)])),
2500 }
2501 }
2502
2503 let mut lines: Vec<(usize, TextBlock)> = Vec::new();
2504 for (direction, group) in groups {
2505 let mut rows: Vec<(f64, usize, Vec<TextSpan>)> = Vec::new();
2506 for (at, span) in group {
2507 let across = baseline_coordinates(&span, direction).1;
2508 let tolerance = (span.height * BAND_Y_FRACTION).max(BAND_Y_TOLERANCE);
2509 match rows
2510 .iter_mut()
2511 .find(|(row_across, _, _)| (row_across - across).abs() <= tolerance)
2512 {
2513 Some((_, first, row)) => {
2514 *first = (*first).min(at);
2515 row.push(span);
2516 }
2517 None => rows.push((across, at, vec![span])),
2518 }
2519 }
2520
2521 rows.sort_by(|a, b| {
2525 a.1.cmp(&b.1)
2526 .then_with(|| b.0.partial_cmp(&a.0).unwrap_or(Ordering::Equal))
2527 });
2528 for (_, at, mut row) in rows {
2529 row.sort_by(|a, b| {
2530 baseline_coordinates(a, direction)
2531 .0
2532 .partial_cmp(&baseline_coordinates(b, direction).0)
2533 .unwrap_or(Ordering::Equal)
2534 });
2535 lines.push((at, TextBlock { spans: row }));
2536 }
2537 }
2538 lines.sort_by(|a, b| a.0.cmp(&b.0));
2541 lines
2542}
2543
2544fn xy_cut_recursive(
2545 spans: Vec<TextSpan>,
2546 depth: usize,
2547 stats: &PageStats,
2548 rules: &[HorizontalRule],
2549) -> Vec<TextBlock> {
2550 if spans.is_empty() {
2551 return Vec::new();
2552 }
2553 if depth >= XY_CUT_MAX_DEPTH {
2554 return band_based_blocks(spans, stats, rules);
2555 }
2556
2557 let vcut = try_vertical_cut(&spans, stats, rules);
2564 let hcut = try_horizontal_cut(&spans, stats);
2565
2566 let (chosen, _) = match (vcut, hcut) {
2567 (Some((v_groups, v_gap)), Some((h_groups, h_gap))) => {
2568 if v_gap >= h_gap {
2569 (Some(v_groups), v_gap)
2570 } else {
2571 (Some(h_groups), h_gap)
2572 }
2573 }
2574 (Some((v_groups, v_gap)), None) => (Some(v_groups), v_gap),
2575 (None, Some((h_groups, h_gap))) => (Some(h_groups), h_gap),
2576 (None, None) => (None, 0.0),
2577 };
2578
2579 if let Some(groups) = chosen {
2580 let mut out = Vec::new();
2581 for group in groups {
2582 out.extend(xy_cut_recursive(group, depth + 1, stats, rules));
2583 }
2584 return out;
2585 }
2586
2587 band_based_blocks(spans, stats, rules)
2588}
2589
2590fn band_based_blocks(
2594 spans: Vec<TextSpan>,
2595 stats: &PageStats,
2596 rules: &[HorizontalRule],
2597) -> Vec<TextBlock> {
2598 group_spans_into_blocks_legacy_with_stats(spans, stats, rules)
2603}
2604
2605#[allow(dead_code)]
2608fn median_font_size(spans: &[TextSpan]) -> f64 {
2609 if spans.is_empty() {
2610 return 12.0;
2611 }
2612 let mut sizes: Vec<f64> = spans.iter().map(|s| s.font_size).collect();
2613 sizes.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
2614 sizes[sizes.len() / 2]
2615}
2616
2617fn try_vertical_cut(
2634 spans: &[TextSpan],
2635 stats: &PageStats,
2636 rules: &[HorizontalRule],
2637) -> Option<(Vec<Vec<TextSpan>>, f64)> {
2638 if spans.len() < 2 * XY_CUT_MIN_SPANS_PER_COLUMN {
2639 return None;
2640 }
2641
2642 let region_left = spans.iter().map(|s| s.x).fold(f64::INFINITY, f64::min);
2643 let region_right = spans
2644 .iter()
2645 .map(TextSpan::right)
2646 .fold(f64::NEG_INFINITY, f64::max);
2647 let region_width = region_right - region_left;
2648 if region_width <= 0.0 {
2649 return None;
2650 }
2651
2652 let bands = group_spans_into_bands_with_stats(spans.to_vec(), stats);
2661 let all_bands: Vec<usize> = (0..bands.len()).collect();
2667 if region_is_ruled(&bands, &all_bands, rules) {
2668 return None;
2669 }
2670 let adaptive = compute_adaptive_column_gap(&bands);
2671 let floor = stats.median_font_size.max(XY_CUT_VERTICAL_GAP_FLOOR);
2672 let min_gap = adaptive.min(floor).max(XY_CUT_VERTICAL_GAP_FLOOR);
2673
2674 let mut intervals: Vec<(f64, f64)> = spans
2684 .iter()
2685 .map(|s| {
2686 let r = if s.width > 0.0 {
2687 s.measured_right()
2688 } else {
2689 s.right()
2690 };
2691 (s.x, r.max(s.x + 0.001))
2692 })
2693 .collect();
2694 intervals.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(Ordering::Equal));
2695
2696 let mut cursor = intervals[0].1;
2697 let mut best_gap: Option<(f64, f64)> = None; for (left, right) in intervals.iter().skip(1) {
2699 if *left > cursor {
2700 let gap = *left - cursor;
2701 if gap >= min_gap {
2702 match best_gap {
2703 Some((best, _)) if best >= gap => {}
2704 _ => {
2705 let cut_x = (cursor + *left) * 0.5;
2706 best_gap = Some((gap, cut_x));
2707 }
2708 }
2709 }
2710 }
2711 cursor = cursor.max(*right);
2712 }
2713
2714 let (gap_size, cut_x) = best_gap?;
2715
2716 let mut left_group = Vec::new();
2719 let mut right_group = Vec::new();
2720 for span in spans {
2721 let midpoint = span.x + (span.right() - span.x) * 0.5;
2722 if midpoint < cut_x {
2723 left_group.push(span.clone());
2724 } else {
2725 right_group.push(span.clone());
2726 }
2727 }
2728
2729 if !columns_are_dense(&left_group, &right_group, stats) {
2730 return None;
2731 }
2732 if !columns_are_band_aligned(spans, cut_x, region_left, region_right, stats) {
2733 return None;
2734 }
2735
2736 Some((vec![left_group, right_group], gap_size))
2737}
2738
2739fn columns_are_band_aligned(
2745 spans: &[TextSpan],
2746 cut_x: f64,
2747 region_left: f64,
2748 region_right: f64,
2749 stats: &PageStats,
2750) -> bool {
2751 let left_width = (cut_x - region_left).max(1.0);
2752 let right_width = (region_right - cut_x).max(1.0);
2753
2754 const MAX_SINGLE_SIDE_FRACTION: f64 = 0.70;
2759
2760 let _ = (left_width, right_width, MAX_SINGLE_SIDE_FRACTION);
2761 for span in spans {
2775 if span.x < cut_x && span.measured_right() > cut_x {
2776 return false;
2777 }
2778 }
2779
2780 const OUTLIER: f64 = 1.3;
2796 let bands = group_spans_into_bands_with_stats(spans.to_vec(), stats);
2802 let mut widths: (Vec<f64>, Vec<f64>) = (Vec::new(), Vec::new());
2803 let mut single: Vec<(bool, f64)> = Vec::new();
2804 for band in &bands {
2805 let mut left_lo = f64::INFINITY;
2806 let mut left_hi = f64::NEG_INFINITY;
2807 let mut right_lo = f64::INFINITY;
2808 let mut right_hi = f64::NEG_INFINITY;
2809 for span in &band.spans {
2810 let r = span.measured_right();
2811 if span.x + (r - span.x) * 0.5 < cut_x {
2812 left_lo = left_lo.min(span.x);
2813 left_hi = left_hi.max(r);
2814 } else {
2815 right_lo = right_lo.min(span.x);
2816 right_hi = right_hi.max(r);
2817 }
2818 }
2819 let has_left = left_hi > left_lo || left_lo.is_finite();
2820 let has_right = right_hi > right_lo || right_lo.is_finite();
2821 if has_left {
2822 widths.0.push((left_hi - left_lo).max(0.0));
2823 }
2824 if has_right {
2825 widths.1.push((right_hi - right_lo).max(0.0));
2826 }
2827 match (has_left, has_right) {
2828 (true, false) => single.push((true, (left_hi - left_lo).max(0.0))),
2829 (false, true) => single.push((false, (right_hi - right_lo).max(0.0))),
2830 _ => {}
2831 }
2832 }
2833 let median = |v: &mut Vec<f64>| -> Option<f64> {
2834 if v.is_empty() {
2835 return None;
2836 }
2837 v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
2838 Some(v[v.len() / 2])
2839 };
2840 let left_median = median(&mut widths.0);
2841 let right_median = median(&mut widths.1);
2842 for (is_left, w) in single {
2843 let m = if is_left { left_median } else { right_median };
2844 if let Some(m) = m {
2845 if m > 0.0 && w > m * OUTLIER {
2846 return false;
2847 }
2848 }
2849 }
2850 true
2851}
2852
2853fn columns_are_dense(left: &[TextSpan], right: &[TextSpan], stats: &PageStats) -> bool {
2858 for col in [left, right] {
2859 if col.len() < XY_CUT_MIN_SPANS_PER_COLUMN {
2860 return false;
2861 }
2862 let bands = group_spans_into_bands_with_stats(col.to_vec(), stats);
2863 if bands.is_empty() {
2864 return false;
2865 }
2866 let total_chars: usize = col.iter().map(|s| s.text.chars().count()).sum();
2867 let chars_per_band = total_chars as f64 / bands.len() as f64;
2868 if chars_per_band < XY_CUT_MIN_CHARS_PER_BAND {
2869 return false;
2870 }
2871 }
2872 true
2873}
2874
2875fn try_horizontal_cut(spans: &[TextSpan], stats: &PageStats) -> Option<(Vec<Vec<TextSpan>>, f64)> {
2879 if spans.len() < 2 {
2880 return None;
2881 }
2882 let mut sorted = spans.to_vec();
2884 sorted.sort_by(|a, b| {
2885 b.y.partial_cmp(&a.y)
2886 .unwrap_or(Ordering::Equal)
2887 .then_with(|| a.x.partial_cmp(&b.x).unwrap_or(Ordering::Equal))
2888 });
2889
2890 let min_gap = if stats.median_line_spacing > 0.0 {
2896 stats.median_line_spacing * PARAGRAPH_BREAK_LINE_SPACING_MULTIPLIER
2897 } else {
2898 stats.median_font_size * XY_CUT_HORIZONTAL_GAP_FONT_MULTIPLIER
2899 };
2900
2901 let mut best: Option<(f64, f64)> = None; let tolerance = stats.median_font_size * BAND_Y_FRACTION;
2904 let mut band_bottom = sorted[0].y;
2905
2906 for span in sorted.iter().skip(1) {
2907 if (band_bottom - span.y).abs() <= tolerance {
2908 band_bottom = band_bottom.min(span.y);
2909 continue;
2910 }
2911 let gap = band_bottom - span.y;
2912 if gap >= min_gap {
2913 let cut_y = (band_bottom + span.y) * 0.5;
2914 match best {
2915 Some((best_gap, _)) if best_gap >= gap => {}
2916 _ => best = Some((gap, cut_y)),
2917 }
2918 }
2919 band_bottom = span.y;
2920 }
2921
2922 let (gap_size, cut_y) = best?;
2923
2924 let mut top_group = Vec::new();
2925 let mut bottom_group = Vec::new();
2926 for span in spans {
2927 if span.y > cut_y {
2928 top_group.push(span.clone());
2929 } else {
2930 bottom_group.push(span.clone());
2931 }
2932 }
2933 if top_group.is_empty() || bottom_group.is_empty() {
2934 return None;
2935 }
2936 Some((vec![top_group, bottom_group], gap_size))
2937}
2938
2939#[allow(dead_code)]
2943fn group_spans_into_blocks_legacy(spans: Vec<TextSpan>) -> Vec<TextBlock> {
2944 let bands = group_spans_into_bands(spans);
2945 group_spans_into_blocks_legacy_from_bands(bands, &[])
2946}
2947
2948fn group_spans_into_blocks_legacy_with_stats(
2949 spans: Vec<TextSpan>,
2950 stats: &PageStats,
2951 rules: &[HorizontalRule],
2952) -> Vec<TextBlock> {
2953 let bands = group_spans_into_bands_with_stats(spans, stats);
2954 group_spans_into_blocks_legacy_from_bands(bands, rules)
2955}
2956
2957fn group_spans_into_blocks_legacy_from_bands(
2958 bands: Vec<TextBand>,
2959 rules: &[HorizontalRule],
2960) -> Vec<TextBlock> {
2961 if bands.is_empty() {
2962 return Vec::new();
2963 }
2964
2965 let column_gap_threshold = compute_adaptive_column_gap(&bands);
2966
2967 let mut blocks = Vec::new();
2968 let mut idx = 0;
2969
2970 while idx < bands.len() {
2971 let gap_midpoints = bands[idx].gap_midpoints(column_gap_threshold);
2972 if gap_midpoints.is_empty() {
2973 idx = push_row(&bands, idx, &mut blocks);
2974 continue;
2975 }
2976
2977 let mut boundaries = gap_midpoints.clone();
2978 let mut band_indices = vec![idx];
2979 let mut gapped_band_count = 1usize;
2980 let mut region_left = bands[idx].left();
2981 let mut region_right = bands[idx].right();
2982 let mut next_idx = idx + 1;
2983
2984 while next_idx < bands.len() {
2985 let next_band = &bands[next_idx];
2986 let next_gap_midpoints = next_band.gap_midpoints(column_gap_threshold);
2987 if next_gap_midpoints.is_empty() {
2988 if next_band
2989 .fits_single_column(&boundaries, region_left, region_right)
2990 .is_some()
2991 {
2992 band_indices.push(next_idx);
2993 next_idx += 1;
2994 continue;
2995 }
2996 break;
2997 }
2998
2999 if !boundaries_match(&boundaries, &next_gap_midpoints, column_gap_threshold) {
3000 break;
3001 }
3002
3003 update_boundaries(&mut boundaries, &next_gap_midpoints, gapped_band_count);
3004 gapped_band_count += 1;
3005 band_indices.push(next_idx);
3006 region_left = region_left.min(next_band.left());
3007 region_right = region_right.max(next_band.right());
3008 next_idx += 1;
3009 }
3010
3011 if region_is_columnar(&bands, &band_indices, &boundaries, gapped_band_count)
3012 && !region_is_ruled(&bands, &band_indices, rules)
3013 {
3014 append_column_region_blocks(&bands, &band_indices, &boundaries, &mut blocks);
3015 idx = next_idx;
3016 } else {
3017 idx = push_row(&bands, idx, &mut blocks);
3018 }
3019 }
3020
3021 blocks
3022}
3023
3024fn push_row(bands: &[TextBand], idx: usize, blocks: &mut Vec<TextBlock>) -> usize {
3032 if let Some(next) = append_wrapped_table_row(bands, idx, blocks) {
3033 return next;
3034 }
3035 blocks.push(bands[idx].row_block());
3036 idx + 1
3037}
3038
3039fn cell_edges(band: &TextBand) -> Vec<f64> {
3048 let mut edges: Vec<f64> = band.spans.iter().map(|span| span.x).collect();
3049 edges.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
3050 edges.dedup_by(|a, b| (*a - *b).abs() <= CELL_EDGE_TOLERANCE);
3051 edges
3052}
3053
3054fn cell_of(span: &TextSpan, edges: &[f64]) -> Option<usize> {
3063 let idx = edges
3064 .iter()
3065 .rposition(|edge| span.x >= edge - CELL_EDGE_TOLERANCE)?;
3066 if (span.x - edges[idx]).abs() > CELL_EDGE_TOLERANCE {
3067 return None;
3068 }
3069 if let Some(next_edge) = edges.get(idx + 1) {
3070 if span.measured_right() > next_edge - CELL_EDGE_TOLERANCE {
3071 return None;
3072 }
3073 }
3074 Some(idx)
3075}
3076
3077fn band_line_height(band: &TextBand) -> f64 {
3080 band.spans
3081 .iter()
3082 .map(|span| span.height.max(span.font_size))
3083 .fold(0.0_f64, f64::max)
3084}
3085
3086fn band_fills_one_cell(band: &TextBand, edges: &[f64]) -> Option<usize> {
3099 let mut filled: Option<usize> = None;
3100 for span in &band.spans {
3101 let cell = cell_of(span, edges)?;
3102 match filled {
3103 Some(seen) if seen != cell => return None,
3104 _ => filled = Some(cell),
3105 }
3106 }
3107 filled
3108}
3109
3110fn band_matches_grid(band: &TextBand, edges: &[f64]) -> bool {
3111 let mut occupied = vec![false; edges.len()];
3112 for span in &band.spans {
3113 match cell_of(span, edges) {
3114 Some(cell) => occupied[cell] = true,
3115 None => return false,
3116 }
3117 }
3118 occupied.iter().filter(|seen| **seen).count() >= TABLE_GRID_MIN_COLUMNS
3119}
3120
3121fn append_wrapped_table_row(
3152 bands: &[TextBand],
3153 idx: usize,
3154 blocks: &mut Vec<TextBlock>,
3155) -> Option<usize> {
3156 let head = &bands[idx];
3157 if head.spans.len() < TABLE_ROW_MIN_CELLS {
3158 return None;
3159 }
3160 let edges = cell_edges(head);
3161 if edges.len() < TABLE_ROW_MIN_CELLS {
3162 return None;
3163 }
3164 let line_height = band_line_height(head);
3165 if line_height <= 0.0 {
3166 return None;
3167 }
3168
3169 let head_cells: Vec<usize> = head
3170 .spans
3171 .iter()
3172 .map(|span| cell_of(span, &edges))
3173 .collect::<Option<Vec<usize>>>()?;
3174
3175 let above = idx.checked_sub(1)?;
3179 let row_pitch = bands[above].y - head.y;
3180 if row_pitch <= 0.0 {
3181 return None;
3182 }
3183 let max_line_gap = (line_height * WRAPPED_CELL_LINE_GAP_FACTOR)
3184 .min(row_pitch * WRAPPED_CELL_MAX_PITCH_FRACTION);
3185
3186 let mut corroborator = above;
3191 loop {
3192 if band_matches_grid(&bands[corroborator], &edges) {
3193 break;
3194 }
3195 if band_fills_one_cell(&bands[corroborator], &edges).is_none()
3196 || idx - corroborator >= TABLE_GRID_MAX_LOOKBACK
3197 || corroborator == 0
3198 {
3199 return None;
3200 }
3201 corroborator -= 1;
3202 }
3203
3204 let mut continuations: Vec<(&TextBand, usize)> = Vec::new();
3215 let mut last_line_in_cell = vec![head.y; edges.len()];
3216 let mut next_idx = idx + 1;
3217 while next_idx < bands.len() {
3218 let band = &bands[next_idx];
3219 if band.spans.len() >= head.spans.len() {
3222 break;
3223 }
3224 let Some(cell) = band_fills_one_cell(band, &edges) else {
3225 break;
3226 };
3227 if !head_cells.contains(&cell) {
3228 break;
3229 }
3230 let line_gap = last_line_in_cell[cell] - band.y;
3231 if line_gap <= 0.0 || line_gap > max_line_gap {
3232 break;
3233 }
3234 last_line_in_cell[cell] = band.y;
3235 continuations.push((band, cell));
3236 next_idx += 1;
3237 }
3238 if continuations.is_empty() {
3239 return None;
3240 }
3241
3242 let wrapped_cells: Vec<usize> = continuations.iter().map(|(_, cell)| *cell).collect();
3243 if head_cells.iter().all(|cell| wrapped_cells.contains(cell)) {
3244 return None;
3245 }
3246
3247 for cell in 0..edges.len() {
3248 let row_spans: Vec<TextSpan> = head
3249 .spans
3250 .iter()
3251 .zip(&head_cells)
3252 .filter(|(_, span_cell)| **span_cell == cell)
3253 .map(|(span, _)| span.clone())
3254 .collect();
3255 if !row_spans.is_empty() {
3256 blocks.push(TextBlock { spans: row_spans });
3257 }
3258 for (band, wrapped_cell) in &continuations {
3259 if *wrapped_cell == cell {
3260 blocks.push(band.row_block());
3261 }
3262 }
3263 }
3264
3265 Some(next_idx)
3266}
3267
3268fn group_spans_into_bands(spans: Vec<TextSpan>) -> Vec<TextBand> {
3272 let stats = PageStats::from_spans(&spans);
3273 group_spans_into_bands_with_stats(spans, &stats)
3274}
3275
3276fn group_spans_into_bands_with_stats(mut spans: Vec<TextSpan>, stats: &PageStats) -> Vec<TextBand> {
3277 if spans.is_empty() {
3278 return Vec::new();
3279 }
3280
3281 spans.sort_by(|a, b| {
3282 b.y.partial_cmp(&a.y)
3283 .unwrap_or(Ordering::Equal)
3284 .then_with(|| a.x.partial_cmp(&b.x).unwrap_or(Ordering::Equal))
3285 });
3286
3287 let page_tolerance = (stats.median_font_size * BAND_Y_FRACTION).max(BAND_Y_TOLERANCE);
3293
3294 let mut bands: Vec<TextBand> = Vec::new();
3295
3296 for span in spans {
3297 let tolerance = (span.height * BAND_Y_FRACTION)
3298 .max(page_tolerance)
3299 .max(BAND_Y_TOLERANCE);
3300 if let Some(band) = bands
3301 .iter_mut()
3302 .find(|band| (band.y - span.y).abs() <= tolerance)
3303 {
3304 let span_count = band.spans.len() as f64;
3305 band.y = (band.y * span_count + span.y) / (span_count + 1.0);
3306 band.spans.push(span);
3307 } else {
3308 bands.push(TextBand::new(span));
3309 }
3310 }
3311
3312 for band in &mut bands {
3313 band.sort_spans();
3314 }
3315
3316 bands.sort_by(|a, b| b.y.partial_cmp(&a.y).unwrap_or(Ordering::Equal));
3317 bands
3318}
3319
3320fn boundaries_match(boundaries: &[f64], gap_midpoints: &[f64], column_gap_threshold: f64) -> bool {
3321 let tolerance = (column_gap_threshold * 1.5).clamp(COLUMN_GAP_MATCH_TOLERANCE, 60.0);
3322 boundaries.len() == gap_midpoints.len()
3323 && boundaries
3324 .iter()
3325 .zip(gap_midpoints)
3326 .all(|(lhs, rhs)| (lhs - rhs).abs() <= tolerance)
3327}
3328
3329fn update_boundaries(boundaries: &mut [f64], gap_midpoints: &[f64], seen_gapped_bands: usize) {
3330 for (boundary, midpoint) in boundaries.iter_mut().zip(gap_midpoints) {
3331 *boundary =
3332 (*boundary * seen_gapped_bands as f64 + midpoint) / (seen_gapped_bands as f64 + 1.0);
3333 }
3334}
3335
3336fn region_is_columnar(
3337 bands: &[TextBand],
3338 band_indices: &[usize],
3339 boundaries: &[f64],
3340 gapped_band_count: usize,
3341) -> bool {
3342 if boundaries.is_empty()
3343 || gapped_band_count < MIN_COLUMN_GAPPED_BANDS
3344 || band_indices.is_empty()
3345 || (gapped_band_count as f64 / band_indices.len() as f64) < MIN_COLUMN_GAP_SUPPORT
3346 {
3347 return false;
3348 }
3349
3350 let mut non_empty_slices = 0usize;
3351 let mut dense_slices = 0usize;
3352 let mut slices_per_column = vec![0usize; boundaries.len() + 1];
3353
3354 for &band_idx in band_indices {
3355 let slices = bands[band_idx].split_by_boundaries(boundaries);
3356 for (column_idx, slice) in slices.iter().enumerate() {
3357 if slice.is_empty() {
3358 continue;
3359 }
3360
3361 non_empty_slices += 1;
3362 slices_per_column[column_idx] += 1;
3363
3364 let char_count = slice
3365 .iter()
3366 .map(|span| span.text.chars().count())
3367 .sum::<usize>();
3368 if slice.len() >= 2 || char_count >= 8 {
3369 dense_slices += 1;
3370 }
3371 }
3372 }
3373
3374 if non_empty_slices < boundaries.len() + 2 {
3375 return false;
3376 }
3377
3378 if slices_per_column.contains(&0) {
3379 return false;
3380 }
3381
3382 (dense_slices as f64 / non_empty_slices as f64) >= MIN_DENSE_SLICE_RATIO
3383}
3384
3385fn region_is_ruled(bands: &[TextBand], band_indices: &[usize], rules: &[HorizontalRule]) -> bool {
3401 if rules.is_empty() || band_indices.len() < MIN_RULED_ROW_SEPARATORS + 1 {
3402 return false;
3403 }
3404
3405 let mut left = f64::INFINITY;
3406 let mut right = f64::NEG_INFINITY;
3407 for &band_idx in band_indices {
3408 for span in &bands[band_idx].spans {
3409 left = left.min(span.x);
3410 right = right.max(span.measured_right());
3411 }
3412 }
3413 let width = right - left;
3414 if width <= 0.0 {
3415 return false;
3416 }
3417
3418 let mut gaps = 0usize;
3419 let mut ruled = 0usize;
3420 for pair in band_indices.windows(2) {
3421 let upper = bands[pair[0]].y;
3422 let lower = bands[pair[1]].y;
3423 if upper <= lower {
3424 continue;
3425 }
3426 gaps += 1;
3427 let crossed = rules.iter().any(|rule| {
3428 rule.y > lower
3429 && rule.y < upper
3430 && (rule.right.min(right) - rule.left.max(left)) >= width * RULE_SPAN_FRACTION
3431 });
3432 if crossed {
3433 ruled += 1;
3434 }
3435 }
3436
3437 gaps > 0
3438 && ruled >= MIN_RULED_ROW_SEPARATORS
3439 && (ruled as f64 / gaps as f64) >= MIN_RULED_GAP_SUPPORT
3440}
3441
3442fn append_column_region_blocks(
3443 bands: &[TextBand],
3444 band_indices: &[usize],
3445 boundaries: &[f64],
3446 blocks: &mut Vec<TextBlock>,
3447) {
3448 let column_count = boundaries.len() + 1;
3449 let mut column_bands = vec![Vec::<TextSpan>::new(); column_count];
3450
3451 for &band_idx in band_indices {
3452 let slices = bands[band_idx].split_by_boundaries(boundaries);
3453 for (column_idx, slice) in slices.into_iter().enumerate() {
3454 if slice.is_empty() {
3455 continue;
3456 }
3457 column_bands[column_idx].push(TextSpan::default());
3458 let marker_idx = column_bands[column_idx].len() - 1;
3459 column_bands[column_idx][marker_idx] = TextSpan {
3460 x: f64::NEG_INFINITY,
3461 y: bands[band_idx].y,
3462 ..TextSpan::default()
3463 };
3464 column_bands[column_idx].extend(slice);
3465 }
3466 }
3467
3468 for spans in column_bands {
3469 let mut current: Vec<TextSpan> = Vec::new();
3470 for span in spans {
3471 if span.x == f64::NEG_INFINITY {
3472 if !current.is_empty() {
3473 current.sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap_or(Ordering::Equal));
3474 blocks.push(TextBlock {
3475 spans: std::mem::take(&mut current),
3476 });
3477 }
3478 continue;
3479 }
3480 current.push(span);
3481 }
3482 if !current.is_empty() {
3483 current.sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap_or(Ordering::Equal));
3484 blocks.push(TextBlock { spans: current });
3485 }
3486 }
3487}
3488
3489fn stitch_hyphenated_lines(lines: &[String]) -> String {
3504 let mut out = String::new();
3505 for (idx, line) in lines.iter().enumerate() {
3506 if idx == 0 {
3507 out.push_str(line);
3508 continue;
3509 }
3510
3511 let next_trimmed = line.trim_start();
3512
3513 let should_merge = is_hyphen_wrap_candidate(&out, next_trimmed);
3515
3516 if should_merge {
3517 out.pop(); out.push_str(next_trimmed);
3519 } else {
3520 out.push('\n');
3521 out.push_str(line);
3522 }
3523 }
3524 out
3525}
3526
3527fn is_hyphen_wrap_candidate(accumulated: &str, next_trimmed: &str) -> bool {
3530 if !accumulated.ends_with('-') {
3532 return false;
3533 }
3534
3535 let before_hyphen = accumulated.chars().rev().nth(1);
3537 if !before_hyphen.is_some_and(|c| c.is_alphabetic()) {
3538 return false;
3539 }
3540
3541 let alpha_prefix_len = accumulated
3543 .chars()
3544 .rev()
3545 .skip(1) .take_while(|c| c.is_alphabetic())
3547 .count();
3548 if alpha_prefix_len < 3 {
3549 return false;
3550 }
3551
3552 let first_next = next_trimmed.chars().next();
3554 if !first_next.is_some_and(|c| c.is_ascii_lowercase()) {
3555 return false;
3556 }
3557
3558 let next_alpha_len = next_trimmed
3560 .chars()
3561 .take_while(|c| c.is_ascii_lowercase())
3562 .count();
3563 if next_alpha_len < 3 {
3564 return false;
3565 }
3566
3567 true
3568}
3569
3570pub(crate) fn normalize_text_output(text: &str) -> String {
3577 if text.is_empty() {
3578 return String::new();
3579 }
3580
3581 let mut lines: Vec<&str> = Vec::new();
3582 for line in text.split('\n') {
3583 lines.push(line.trim_end());
3584 }
3585
3586 while lines.last() == Some(&"") {
3588 lines.pop();
3589 }
3590
3591 if lines.is_empty() {
3592 return String::new();
3593 }
3594
3595 let mut result = String::with_capacity(text.len());
3596 let mut consecutive_empty = 0u32;
3597
3598 for (i, line) in lines.iter().enumerate() {
3599 if line.is_empty() || *line == "\x0C" {
3600 if line.is_empty() {
3601 consecutive_empty += 1;
3602 if consecutive_empty <= 2 {
3604 result.push('\n');
3605 }
3606 } else {
3607 consecutive_empty = 0;
3609 result.push_str(line);
3610 if i + 1 < lines.len() {
3611 result.push('\n');
3612 }
3613 }
3614 } else {
3615 consecutive_empty = 0;
3617 result.push_str(line);
3618 if i + 1 < lines.len() {
3619 result.push('\n');
3620 }
3621 }
3622 }
3623
3624 if !result.is_empty() && !result.ends_with('\n') {
3626 result.push('\n');
3627 }
3628
3629 result
3630}
3631
3632#[cfg(test)]
3633mod tests {
3634 use super::*;
3635
3636 fn span(text: &str, x: f64, y: f64, width: f64) -> TextSpan {
3637 TextSpan {
3638 text: text.into(),
3639 x,
3640 y,
3641 width,
3642 height: 12.0,
3643 font_size: 12.0,
3644 ..TextSpan::default()
3645 }
3646 }
3647
3648 fn two_column_page() -> Vec<TextSpan> {
3661 const LEFT_X: f64 = 40.0;
3662 const RIGHT_X: f64 = 320.0;
3663 const COLUMN_WIDTH: f64 = 250.0;
3664 const TOP: f64 = 700.0;
3665 const LEADING: f64 = 14.0;
3666 const LINES: usize = 20;
3667
3668 let mut out = vec![span(
3669 "A Trace-based Just-in-Time Compiler",
3670 40.0,
3671 740.0,
3672 530.0,
3673 )];
3674 for i in 0..LINES {
3675 out.push(span(
3676 &format!("left line {i:02} of the two-column academic body text"),
3677 LEFT_X,
3678 TOP - (i as f64) * LEADING,
3679 COLUMN_WIDTH,
3680 ));
3681 }
3682 for i in 0..(LINES - 1) {
3688 out.push(span(
3689 &format!("right line {i:02} of the two-column academic body text"),
3690 RIGHT_X,
3691 TOP - (i as f64) * LEADING,
3692 COLUMN_WIDTH,
3693 ));
3694 }
3695 out
3696 }
3697
3698 #[test]
3706 fn a_two_column_body_is_read_column_by_column() {
3707 let spans = two_column_page();
3708 let expected: Vec<String> = spans.iter().map(|s| s.text.clone()).collect();
3709
3710 let produced: Vec<String> = group_spans_into_blocks(spans, &[])
3711 .into_iter()
3712 .flat_map(|b| b.spans.into_iter().map(|s| s.text))
3713 .collect();
3714
3715 assert_eq!(produced, expected);
3716 }
3717
3718 fn narrow_gutter_page() -> Vec<TextSpan> {
3729 narrow_gutter_page_with_gutter(13.2)
3730 }
3731
3732 fn narrow_gutter_page_with_gutter(gutter: f64) -> Vec<TextSpan> {
3735 const LEFT_X: f64 = 54.0;
3736 const COLUMN_WIDTH: f64 = 240.0;
3737 const FONT_SIZE: f64 = 9.0;
3738 const TOP: f64 = 700.0;
3739 const LEADING: f64 = 11.0;
3740 const LINES: usize = 20;
3741
3742 let right_x = LEFT_X + COLUMN_WIDTH + gutter;
3743 let line = |text: &str, x: f64, y: f64| TextSpan {
3744 text: text.into(),
3745 x,
3746 y,
3747 width: COLUMN_WIDTH,
3748 height: FONT_SIZE,
3749 font_size: FONT_SIZE,
3750 ..TextSpan::default()
3751 };
3752
3753 let mut out = vec![TextSpan {
3754 text: "Trace-based Just-in-Time Type Specialization".into(),
3755 x: LEFT_X,
3756 y: 740.0,
3757 width: 490.0,
3758 height: 14.0,
3759 font_size: 14.0,
3760 ..TextSpan::default()
3761 }];
3762 for i in 0..LINES {
3768 out.push(line(
3769 &format!("left line {i:02} of the two-column academic body of this paper"),
3770 LEFT_X,
3771 TOP - (i as f64) * LEADING,
3772 ));
3773 }
3774 for i in 0..(LINES - 1) {
3775 out.push(line(
3776 &format!("right line {i:02} of the two-column academic body of this paper"),
3777 right_x,
3778 TOP - (i as f64) * LEADING,
3779 ));
3780 }
3781 out
3782 }
3783
3784 #[test]
3789 fn a_thirteen_point_gutter_in_nine_point_type_is_a_gutter() {
3790 let spans = narrow_gutter_page();
3791 let body: Vec<TextSpan> = spans.iter().filter(|s| s.y < 720.0).cloned().collect();
3792 let stats = PageStats::from_spans(&body);
3793
3794 let (groups, gap) = try_vertical_cut(&body, &stats, &[])
3795 .expect("no column cut found on a 13.2 pt gutter in 9 pt type");
3796
3797 assert_eq!(groups.len(), 2);
3798 assert!(
3799 (gap - 13.2).abs() < 0.5,
3800 "the cut was taken on a gap of {gap}, not on the gutter"
3801 );
3802 assert!(
3803 groups[0].iter().all(|s| s.text.starts_with("left")),
3804 "the left group holds text from the right column"
3805 );
3806 assert!(
3807 groups[1].iter().all(|s| s.text.starts_with("right")),
3808 "the right group holds text from the left column"
3809 );
3810 }
3811
3812 #[test]
3817 fn the_narrow_gutter_page_reads_column_by_column() {
3818 let spans = narrow_gutter_page();
3819 let expected: Vec<String> = spans.iter().map(|s| s.text.clone()).collect();
3820
3821 let produced: Vec<String> = group_spans_into_blocks(spans, &[])
3822 .into_iter()
3823 .flat_map(|b| b.spans.into_iter().map(|s| s.text))
3824 .collect();
3825
3826 assert_eq!(produced, expected);
3827 }
3828
3829 #[test]
3833 fn a_gap_narrower_than_the_type_is_not_a_gutter() {
3834 let spans = narrow_gutter_page_with_gutter(6.0);
3835 let body: Vec<TextSpan> = spans.iter().filter(|s| s.y < 720.0).cloned().collect();
3836 let stats = PageStats::from_spans(&body);
3837
3838 assert!(
3839 try_vertical_cut(&body, &stats, &[]).is_none(),
3840 "a six point gap in nine point type was taken for a column gutter"
3841 );
3842 }
3843
3844 #[test]
3849 fn a_column_gap_is_found_on_measured_edges_not_estimated_ones() {
3850 let spans = two_column_page();
3851 let body: Vec<TextSpan> = spans.iter().filter(|s| s.y < 720.0).cloned().collect();
3852 let stats = PageStats::from_spans(&body);
3853
3854 let overshoots = body.iter().any(|s| s.right() > s.measured_right() + 1.0);
3855 assert!(
3856 overshoots,
3857 "fixture no longer exercises the overshoot; right() must exceed measured_right() for this test to mean anything"
3858 );
3859
3860 let cut = try_vertical_cut(&body, &stats, &[]);
3861 assert!(
3862 cut.is_some(),
3863 "no column cut found on a clean two-column body"
3864 );
3865 }
3866
3867 fn block_texts(spans: Vec<TextSpan>) -> Vec<String> {
3868 block_texts_with_rules(spans, &[])
3869 }
3870
3871 fn block_texts_with_rules(spans: Vec<TextSpan>, rules: &[HorizontalRule]) -> Vec<String> {
3873 group_spans_into_blocks(spans, rules)
3874 .into_iter()
3875 .map(|block| block.text())
3876 .collect()
3877 }
3878
3879 fn leaf_texts(spans: Vec<TextSpan>) -> Vec<String> {
3886 let bands = group_spans_into_bands(spans);
3887 group_spans_into_blocks_legacy_from_bands(bands, &[])
3888 .iter()
3889 .map(TextBlock::text)
3890 .collect()
3891 }
3892
3893 #[test]
3894 fn empty_device_produces_empty_text() {
3895 let dev = TextExtractionDevice::new();
3896 assert!(dev.into_text().is_empty());
3897 }
3898
3899 #[test]
3900 fn single_column_stays_row_major() {
3901 let texts = block_texts(vec![
3902 span("Single Column Line 1", 40.0, 700.0, 140.0),
3903 span("Single Column Line 2", 40.0, 684.0, 140.0),
3904 span("Single Column Line 3", 40.0, 668.0, 140.0),
3905 ]);
3906
3907 assert_eq!(
3908 texts,
3909 vec![
3910 "Single Column Line 1",
3911 "Single Column Line 2",
3912 "Single Column Line 3",
3913 ]
3914 );
3915 }
3916
3917 fn two_column_region_spans() -> Vec<TextSpan> {
3921 vec![
3922 span("Header", 200.0, 740.0, 80.0),
3923 span("Left column line one", 40.0, 700.0, 115.0),
3924 span("Right column line one", 320.0, 700.0, 120.0),
3925 span("Left column line two", 40.0, 684.0, 115.0),
3926 span("Right column line two", 320.0, 684.0, 120.0),
3927 span("Left column line three", 40.0, 668.0, 125.0),
3928 span("Right column line three", 320.0, 668.0, 130.0),
3929 span("Footer", 200.0, 620.0, 80.0),
3930 ]
3931 }
3932
3933 #[test]
3934 fn two_column_region_reads_column_major() {
3935 let texts = block_texts(two_column_region_spans());
3936
3937 assert_eq!(
3938 texts,
3939 vec![
3940 "Header",
3941 "Left column line one",
3942 "Left column line two",
3943 "Left column line three",
3944 "Right column line one",
3945 "Right column line two",
3946 "Right column line three",
3947 "Footer",
3948 ]
3949 );
3950 }
3951
3952 fn rules_between_the_rows() -> Vec<HorizontalRule> {
3955 [692.0, 676.0]
3956 .into_iter()
3957 .map(|y| HorizontalRule {
3958 left: 35.0,
3959 right: 445.0,
3960 y,
3961 })
3962 .collect()
3963 }
3964
3965 #[test]
3970 fn a_ruled_two_column_region_is_read_row_by_row() {
3971 let texts = block_texts_with_rules(two_column_region_spans(), &rules_between_the_rows());
3972
3973 assert_eq!(
3974 texts,
3975 vec![
3976 "Header",
3977 "Left column line one Right column line one",
3978 "Left column line two Right column line two",
3979 "Left column line three Right column line three",
3980 "Footer",
3981 ]
3982 );
3983 }
3984
3985 #[test]
3989 fn a_rule_that_crosses_only_one_column_leaves_the_columns_alone() {
3990 let short: Vec<HorizontalRule> = rules_between_the_rows()
3991 .into_iter()
3992 .map(|rule| HorizontalRule {
3993 right: 160.0,
3994 ..rule
3995 })
3996 .collect();
3997
3998 let texts = block_texts_with_rules(two_column_region_spans(), &short);
3999
4000 assert_eq!(
4001 texts,
4002 vec![
4003 "Header",
4004 "Left column line one",
4005 "Left column line two",
4006 "Left column line three",
4007 "Right column line one",
4008 "Right column line two",
4009 "Right column line three",
4010 "Footer",
4011 ]
4012 );
4013 }
4014
4015 #[test]
4024 fn a_single_rule_does_not_make_a_region_a_table() {
4025 let bands = group_spans_into_bands(two_column_region_spans());
4026 let rows: Vec<usize> = bands
4027 .iter()
4028 .enumerate()
4029 .filter(|(_, band)| band.y < 720.0 && band.y > 640.0)
4030 .map(|(idx, _)| idx)
4031 .collect();
4032 assert_eq!(
4033 rows.len(),
4034 3,
4035 "the fixture's three rows are the region here"
4036 );
4037
4038 let both = rules_between_the_rows();
4039 assert!(
4040 region_is_ruled(&bands, &rows, &both),
4041 "a rule in every gap of the region is a ruled table"
4042 );
4043 assert!(
4044 !region_is_ruled(&bands, &rows, &both[..1]),
4045 "one rule is not a table"
4046 );
4047 }
4048
4049 #[test]
4054 fn one_path_of_many_rules_still_stops_at_the_cap() {
4055 let mut path = BezPath::new();
4056 for row in 0..(MAX_RECORDED_RULES * 2) {
4057 let y = 700.0 - row as f64;
4058 path.move_to((40.0, y));
4059 path.line_to((440.0, y));
4060 path.close_path();
4061 }
4062
4063 let mut device = TextExtractionDevice::new();
4064 device.record_rule(&path, Affine::IDENTITY);
4065
4066 assert_eq!(device.rules.len(), MAX_RECORDED_RULES);
4067 }
4068
4069 #[test]
4074 fn a_rule_is_recorded_at_the_width_the_clip_leaves_it() {
4075 let mut wide = BezPath::new();
4076 wide.move_to((40.0, 600.0));
4077 wide.line_to((440.0, 600.0));
4078
4079 let mut device = TextExtractionDevice::new();
4080 device.push_clip_path(&ClipPath {
4081 path: kurbo::Rect::new(40.0, 500.0, 160.0, 700.0).to_path(0.1),
4082 fill: pdf_render::pdf_interpret::FillRule::NonZero,
4083 });
4084 device.record_rule(&wide, Affine::IDENTITY);
4085
4086 assert_eq!(device.rules.len(), 1, "the visible part is still a rule");
4087 assert!(
4088 device.rules[0].right < 161.0,
4089 "the rule was recorded past its clip: {:?}",
4090 device.rules[0]
4091 );
4092
4093 let mut above = BezPath::new();
4094 above.move_to((40.0, 800.0));
4095 above.line_to((440.0, 800.0));
4096 device.record_rule(&above, Affine::IDENTITY);
4097 assert_eq!(
4098 device.rules.len(),
4099 1,
4100 "a rule the clip cuts away entirely was recorded: {:?}",
4101 device.rules
4102 );
4103
4104 device.pop_clip_path();
4105 device.record_rule(&wide, Affine::IDENTITY);
4106 assert_eq!(
4107 device.rules[1].right, 440.0,
4108 "the clip outlived the pop: {:?}",
4109 device.rules[1]
4110 );
4111 }
4112
4113 #[test]
4118 fn a_region_with_a_rule_in_a_minority_of_its_gaps_is_not_a_table() {
4119 let mut spans = Vec::new();
4120 for row in 0..6 {
4121 let y = 700.0 - row as f64 * 16.0;
4122 spans.push(span("Left column line", 40.0, y, 115.0));
4123 spans.push(span("Right column line", 320.0, y, 120.0));
4124 }
4125 let bands = group_spans_into_bands(spans);
4126 let rows: Vec<usize> = (0..bands.len()).collect();
4127 assert_eq!(bands.len(), 6, "six rows, so five gaps between them");
4128
4129 let rule_at = |y: f64| HorizontalRule {
4130 left: 35.0,
4131 right: 445.0,
4132 y,
4133 };
4134 let two = vec![rule_at(692.0), rule_at(676.0)];
4135 let three = vec![rule_at(692.0), rule_at(676.0), rule_at(660.0)];
4136
4137 assert!(
4138 !region_is_ruled(&bands, &rows, &two),
4139 "two of five is prose"
4140 );
4141 assert!(
4142 region_is_ruled(&bands, &rows, &three),
4143 "three of five is a table"
4144 );
4145 }
4146
4147 #[test]
4148 fn mixed_single_and_multi_column_regions_preserve_shared_bands() {
4149 let texts = block_texts(vec![
4150 span("Intro paragraph", 40.0, 740.0, 180.0),
4151 span("L1 words here", 40.0, 700.0, 110.0),
4152 span("R1 words here", 320.0, 700.0, 110.0),
4153 span("L2 words here", 40.0, 684.0, 110.0),
4154 span("R2 words here", 320.0, 684.0, 110.0),
4155 span("L3 words here", 40.0, 668.0, 110.0),
4156 span("R3 words here", 320.0, 668.0, 110.0),
4157 span("Outro paragraph", 40.0, 620.0, 180.0),
4158 ]);
4159
4160 assert_eq!(
4161 texts,
4162 vec![
4163 "Intro paragraph",
4164 "L1 words here",
4165 "L2 words here",
4166 "L3 words here",
4167 "R1 words here",
4168 "R2 words here",
4169 "R3 words here",
4170 "Outro paragraph",
4171 ]
4172 );
4173 }
4174
4175 #[test]
4176 fn short_table_like_rows_fall_back_to_row_major() {
4177 let texts = block_texts(vec![
4178 span("Name", 40.0, 700.0, 30.0),
4179 span("Age", 320.0, 700.0, 20.0),
4180 span("Alice", 40.0, 684.0, 35.0),
4181 span("30", 320.0, 684.0, 15.0),
4182 span("Bob", 40.0, 668.0, 24.0),
4183 span("25", 320.0, 668.0, 15.0),
4184 ]);
4185
4186 assert_eq!(texts, vec!["Name Age", "Alice 30", "Bob 25"]);
4187 }
4188
4189 #[test]
4193 fn a_wrapped_cell_is_emitted_with_its_own_cell() {
4194 let texts = block_texts(vec![
4195 span("Index", 60.0, 700.0, 30.0),
4196 span("Failure condition", 200.0, 700.0, 100.0),
4197 span("Section", 400.0, 700.0, 42.0),
4198 span("15-003", 60.0, 680.0, 36.0),
4199 span("In a table not organized", 200.0, 680.0, 144.0),
4200 span("UA1:7.5-2", 400.0, 680.0, 54.0),
4201 span("a cell without a Scope", 200.0, 668.0, 132.0),
4202 ]);
4203
4204 assert_eq!(
4205 texts,
4206 vec![
4207 "Index Failure condition Section",
4208 "15-003",
4209 "In a table not organized",
4210 "a cell without a Scope",
4211 "UA1:7.5-2",
4212 ]
4213 );
4214 }
4215
4216 #[test]
4227 fn a_row_whose_every_cell_wraps_stays_row_major() {
4228 let texts = leaf_texts(vec![
4229 span("Index", 60.0, 700.0, 30.0),
4230 span("Failure condition", 200.0, 700.0, 100.0),
4231 span("Section", 400.0, 700.0, 42.0),
4232 span("15-003", 60.0, 680.0, 36.0),
4233 span("In a table not organized", 200.0, 680.0, 144.0),
4234 span("UA1:7.5-2", 400.0, 680.0, 54.0),
4235 span("is the index", 60.0, 674.0, 72.0),
4236 span("a cell without a Scope", 200.0, 668.0, 132.0),
4237 span("of the suite", 400.0, 662.0, 72.0),
4238 ]);
4239
4240 assert_eq!(
4241 texts,
4242 vec![
4243 "Index Failure condition Section",
4244 "15-003 In a table not organized UA1:7.5-2",
4245 "is the index",
4246 "a cell without a Scope",
4247 "of the suite",
4248 ]
4249 );
4250 }
4251
4252 #[test]
4259 fn a_row_with_an_empty_cell_is_a_row_and_not_a_wrap() {
4260 let texts = block_texts(vec![
4261 span("Index", 60.0, 700.0, 30.0),
4262 span("Type", 200.0, 700.0, 24.0),
4263 span("How", 400.0, 700.0, 18.0),
4264 span("15-003", 60.0, 680.0, 36.0),
4265 span("Object", 200.0, 680.0, 36.0),
4266 span("Machine", 400.0, 680.0, 42.0),
4267 span("15-004", 60.0, 666.0, 36.0),
4268 span("Rope", 400.0, 666.0, 24.0),
4269 ]);
4270
4271 assert_eq!(
4272 texts,
4273 vec!["Index Type How", "15-003 Object Machine", "15-004 Rope"]
4274 );
4275 }
4276
4277 #[test]
4282 fn a_single_cell_row_under_a_wrapped_line_is_still_a_row() {
4283 let texts = block_texts(vec![
4284 span("Index", 60.0, 700.0, 30.0),
4285 span("Type", 200.0, 700.0, 24.0),
4286 span("How", 400.0, 700.0, 18.0),
4287 span("15-003", 60.0, 680.0, 36.0),
4288 span("In a table not organized", 200.0, 680.0, 144.0),
4289 span("UA1:7.5-2", 400.0, 680.0, 54.0),
4290 span("a cell without a Scope", 200.0, 668.0, 132.0),
4291 span("15-004", 60.0, 656.0, 36.0),
4292 ]);
4293
4294 assert_eq!(
4295 texts,
4296 vec![
4297 "Index Type How",
4298 "15-003",
4299 "In a table not organized",
4300 "a cell without a Scope",
4301 "UA1:7.5-2",
4302 "15-004",
4303 ]
4304 );
4305 }
4306
4307 #[test]
4310 fn a_line_a_whole_row_pitch_down_is_a_row_and_not_a_wrap() {
4311 let texts = block_texts(vec![
4312 span("Index", 60.0, 700.0, 30.0),
4313 span("Type", 200.0, 700.0, 24.0),
4314 span("How", 400.0, 700.0, 18.0),
4315 span("15-003", 60.0, 680.0, 36.0),
4316 span("Object", 200.0, 680.0, 36.0),
4317 span("Machine", 400.0, 680.0, 42.0),
4318 span("Rope", 200.0, 660.0, 24.0),
4319 ]);
4320
4321 assert_eq!(
4322 texts,
4323 vec!["Index Type How", "15-003 Object Machine", "Rope"]
4324 );
4325 }
4326
4327 #[test]
4328 fn three_column_regions_are_supported() {
4329 let texts = block_texts(vec![
4330 span("Column one line one", 40.0, 700.0, 105.0),
4331 span("Column two line one", 220.0, 700.0, 105.0),
4332 span("Column three line one", 400.0, 700.0, 120.0),
4333 span("Column one line two", 40.0, 684.0, 105.0),
4334 span("Column two line two", 220.0, 684.0, 105.0),
4335 span("Column three line two", 400.0, 684.0, 120.0),
4336 span("Column one line three", 40.0, 668.0, 120.0),
4337 span("Column two line three", 220.0, 668.0, 120.0),
4338 span("Column three line three", 400.0, 668.0, 135.0),
4339 ]);
4340
4341 assert_eq!(
4342 texts,
4343 vec![
4344 "Column one line one",
4345 "Column one line two",
4346 "Column one line three",
4347 "Column two line one",
4348 "Column two line two",
4349 "Column two line three",
4350 "Column three line one",
4351 "Column three line two",
4352 "Column three line three",
4353 ]
4354 );
4355 }
4356
4357 #[test]
4358 fn text_block_concatenation_spaced() {
4359 let block = TextBlock {
4360 spans: vec![span("A", 0.0, 0.0, 6.0), span("B", 20.0, 0.0, 6.0)],
4361 };
4362 assert_eq!(block.text(), "A B");
4363 }
4364
4365 #[test]
4366 fn adaptive_column_gap_fallback_for_no_gaps() {
4367 let bands = vec![
4369 TextBand::new(span("Hello", 40.0, 700.0, 80.0)),
4370 TextBand::new(span("World", 40.0, 684.0, 80.0)),
4371 ];
4372 let threshold = compute_adaptive_column_gap(&bands);
4373 assert!((threshold - COLUMN_GAP_THRESHOLD_FALLBACK).abs() < 0.01);
4374 }
4375
4376 #[test]
4377 fn adaptive_column_gap_uses_median() {
4378 let mut bands = Vec::new();
4380 for y in [700.0, 684.0, 668.0] {
4381 let mut band = TextBand::new(span("word1", 40.0, y, 30.0));
4382 band.spans.push(span("word2", 74.0, y, 30.0)); band.spans.push(span("word3", 108.0, y, 30.0)); bands.push(band);
4385 }
4386 let threshold = compute_adaptive_column_gap(&bands);
4387 assert!(
4389 (10.0..=14.0).contains(&threshold),
4390 "expected ~12, got {threshold}"
4391 );
4392 }
4393
4394 #[test]
4395 fn adaptive_column_gap_clamps_to_min() {
4396 let mut bands = Vec::new();
4398 for y in [700.0, 684.0, 668.0, 652.0] {
4399 let mut band = TextBand::new(span("abc", 0.0, y, 18.0));
4400 band.spans.push(span("def", 20.0, y, 18.0));
4402 bands.push(band);
4403 }
4404 let threshold = compute_adaptive_column_gap(&bands);
4405 assert!(
4406 (threshold - COLUMN_GAP_THRESHOLD_MIN).abs() < 0.01,
4407 "expected {COLUMN_GAP_THRESHOLD_MIN}, got {threshold}"
4408 );
4409 }
4410
4411 #[test]
4412 fn adaptive_column_gap_all_large_gaps_uses_fraction_of_min() {
4413 let mut band = TextBand::new(span("Left", 0.0, 700.0, 30.0));
4415 band.spans.push(span("Right", 80.0, 700.0, 30.0)); let bands = vec![band];
4417 let threshold = compute_adaptive_column_gap(&bands);
4418 assert!(
4419 (threshold - 37.5).abs() < 0.01,
4420 "expected 37.5 (0.75×50), got {threshold}"
4421 );
4422 }
4423
4424 #[test]
4425 fn normalize_trims_trailing_whitespace_per_line() {
4426 assert_eq!(
4427 normalize_text_output("hello \nworld \n"),
4428 "hello\nworld\n"
4429 );
4430 }
4431
4432 #[test]
4433 fn normalize_collapses_excess_newlines() {
4434 assert_eq!(
4436 normalize_text_output("hello\n\n\n\n\nworld\n"),
4437 "hello\n\n\nworld\n"
4438 );
4439 }
4440
4441 #[test]
4442 fn normalize_preserves_double_newline() {
4443 assert_eq!(
4444 normalize_text_output("paragraph one\n\nparagraph two\n"),
4445 "paragraph one\n\nparagraph two\n"
4446 );
4447 }
4448
4449 #[test]
4450 fn normalize_preserves_form_feed() {
4451 assert_eq!(
4452 normalize_text_output("page1\n\n\x0Cpage2\n"),
4453 "page1\n\n\x0Cpage2\n"
4454 );
4455 }
4456
4457 #[test]
4458 fn normalize_adds_trailing_newline() {
4459 assert_eq!(normalize_text_output("hello"), "hello\n");
4460 }
4461
4462 #[test]
4463 fn normalize_empty_input() {
4464 assert_eq!(normalize_text_output(""), "");
4465 }
4466
4467 #[test]
4468 fn normalize_only_whitespace() {
4469 assert_eq!(normalize_text_output(" \n \n"), "");
4470 }
4471
4472 #[test]
4475 fn hyphen_stitch_joins_wrapped_word() {
4476 let lines = vec!["the aver-".into(), "age rainfall".into()];
4477 assert_eq!(stitch_hyphenated_lines(&lines), "the average rainfall");
4478 }
4479
4480 #[test]
4481 fn hyphen_stitch_handles_leading_whitespace() {
4482 let lines = vec!["pre-".into(), " dict the outcome".into()];
4483 assert_eq!(stitch_hyphenated_lines(&lines), "predict the outcome");
4485 }
4486
4487 #[test]
4488 fn hyphen_stitch_capital_continuation_not_stitched() {
4489 let lines = vec!["Section three-".into(), "Summary here".into()];
4490 assert_eq!(
4491 stitch_hyphenated_lines(&lines),
4492 "Section three-\nSummary here"
4493 );
4494 }
4495
4496 #[test]
4497 fn hyphen_stitch_bullet_dash_not_stitched() {
4498 let lines = vec!["Items:".into(), "-".into(), "milk".into()];
4500 assert_eq!(stitch_hyphenated_lines(&lines), "Items:\n-\nmilk");
4501 }
4502
4503 #[test]
4504 fn hyphen_stitch_numeric_range_not_stitched() {
4505 let lines = vec!["page 42-".into(), "seventy".into()];
4507 assert_eq!(stitch_hyphenated_lines(&lines), "page 42-\nseventy");
4508 }
4509
4510 #[test]
4511 fn hyphen_stitch_short_prefix_not_stitched() {
4512 let lines = vec!["re-".into(), "organize".into()];
4514 assert_eq!(stitch_hyphenated_lines(&lines), "re-\norganize");
4515 }
4516
4517 #[test]
4518 fn hyphen_stitch_short_continuation_not_stitched() {
4519 let lines = vec!["counter-".into(), "an example".into()];
4521 assert_eq!(stitch_hyphenated_lines(&lines), "counter-\nan example");
4522 }
4523
4524 #[test]
4525 fn hyphen_stitch_compound_word_midline_preserved() {
4526 let lines = vec!["real-time system".into()];
4529 assert_eq!(stitch_hyphenated_lines(&lines), "real-time system");
4530 }
4531
4532 #[test]
4533 fn hyphen_stitch_single_line_unchanged() {
4534 let lines = vec!["only line".into()];
4535 assert_eq!(stitch_hyphenated_lines(&lines), "only line");
4536 }
4537
4538 #[test]
4539 fn hyphen_stitch_empty_input() {
4540 let lines: Vec<String> = vec![];
4541 assert_eq!(stitch_hyphenated_lines(&lines), "");
4542 }
4543
4544 fn make_device_with_median(median: f64) -> TextExtractionDevice {
4547 let mut dev = TextExtractionDevice::new();
4548 for _ in 0..MEDIAN_REFRESH {
4550 dev.glyph_widths.push(median);
4551 }
4552 dev.refresh_median_char_width();
4553 assert!((dev.cached_median_char_width - median).abs() < 1e-9);
4554 dev
4555 }
4556
4557 #[test]
4558 fn consensus_inserts_space_on_strong_tj_offset_alone() {
4559 let mut dev = make_device_with_median(6.0);
4562 dev.pending_tj_offset = 250.0; assert!(dev.evaluate_space_consensus(0.5, 12.0, "Hello", "World"));
4564 }
4565
4566 #[test]
4567 fn consensus_inserts_space_on_geometric_gap_alone() {
4568 let dev = make_device_with_median(6.0);
4570 assert!(dev.evaluate_space_consensus(2.5, 12.0, "hello", "world"));
4573 }
4574
4575 #[test]
4576 fn consensus_no_space_on_kerning_gap() {
4577 let dev = make_device_with_median(6.0);
4581 assert!(!dev.evaluate_space_consensus(0.5, 12.0, "fi", "lm"));
4582 }
4583
4584 #[test]
4585 fn consensus_inserts_space_on_camel_case_plus_gap() {
4586 let dev = make_device_with_median(6.0);
4590 assert!(dev.evaluate_space_consensus(2.5, 12.0, "helloWorld", "Inc"));
4591 }
4592
4593 #[test]
4594 fn consensus_inserts_space_on_digit_letter_transition_with_gap() {
4595 let dev = make_device_with_median(6.0);
4596 assert!(dev.evaluate_space_consensus(2.5, 12.0, "123", "abc"));
4597 }
4598
4599 #[test]
4600 fn consensus_heuristic_alone_is_insufficient() {
4601 let dev = make_device_with_median(6.0);
4606 assert!(!dev.evaluate_space_consensus(0.5, 12.0, "camel", "Case"));
4607 }
4608
4609 #[test]
4610 fn consensus_falls_back_to_font_size_when_no_median() {
4611 let dev = TextExtractionDevice::new();
4613 assert!(dev.evaluate_space_consensus(1.9, 12.0, "a", "b"));
4615 assert!(!dev.evaluate_space_consensus(1.5, 12.0, "a", "b"));
4617 }
4618
4619 #[test]
4620 fn consensus_ignores_tiny_tj_offsets() {
4621 let mut dev = make_device_with_median(6.0);
4623 dev.pending_tj_offset = 50.0;
4624 assert!(!dev.evaluate_space_consensus(0.5, 12.0, "Hello", "World"));
4625 }
4626
4627 #[test]
4628 fn consensus_accepts_negative_tj_offsets() {
4629 let mut dev = make_device_with_median(6.0);
4632 dev.pending_tj_offset = -250.0;
4633 assert!(dev.evaluate_space_consensus(0.5, 12.0, "Hello", "World"));
4634 }
4635
4636 #[test]
4637 fn text_adjustment_accumulates_until_glyph() {
4638 let mut dev = TextExtractionDevice::new();
4639 dev.text_adjustment(120.0);
4640 dev.text_adjustment(140.0);
4641 assert!((dev.pending_tj_offset - 260.0).abs() < 1e-6);
4642 }
4643
4644 #[test]
4647 fn xy_cut_header_body_footer_with_two_columns() {
4648 let texts = block_texts(vec![
4654 span("HEADLINE TITLE", 180.0, 760.0, 120.0),
4655 span("Left col line A", 40.0, 700.0, 110.0),
4656 span("Right col line A", 320.0, 700.0, 115.0),
4657 span("Left col line B", 40.0, 684.0, 110.0),
4658 span("Right col line B", 320.0, 684.0, 115.0),
4659 span("Left col line C", 40.0, 668.0, 110.0),
4660 span("Right col line C", 320.0, 668.0, 115.0),
4661 span("FOOTER LINE TEXT", 180.0, 600.0, 120.0),
4662 ]);
4663 assert_eq!(texts.first().map(String::as_str), Some("HEADLINE TITLE"));
4664 assert_eq!(texts.last().map(String::as_str), Some("FOOTER LINE TEXT"));
4665 let left_c_idx = texts.iter().position(|s| s == "Left col line C").unwrap();
4667 let right_a_idx = texts.iter().position(|s| s == "Right col line A").unwrap();
4668 assert!(
4669 left_c_idx < right_a_idx,
4670 "expected column-major ordering in body: {texts:?}"
4671 );
4672 }
4673
4674 #[test]
4675 fn xy_cut_rejects_column_split_on_table_rows() {
4676 let texts = block_texts(vec![
4679 span("Name", 40.0, 700.0, 30.0),
4680 span("Age", 320.0, 700.0, 20.0),
4681 span("Alice", 40.0, 684.0, 35.0),
4682 span("30", 320.0, 684.0, 15.0),
4683 ]);
4684 assert_eq!(texts, vec!["Name Age", "Alice 30"]);
4685 }
4686
4687 #[test]
4688 fn xy_cut_rejects_column_split_when_one_band_is_full_width() {
4689 let texts = block_texts(vec![
4693 span(
4694 "Full width intro spanning both columns here",
4695 40.0,
4696 740.0,
4697 360.0,
4698 ),
4699 span("Left A", 40.0, 700.0, 50.0),
4700 span("Right A", 320.0, 700.0, 50.0),
4701 span("Left B", 40.0, 684.0, 50.0),
4702 span("Right B", 320.0, 684.0, 50.0),
4703 ]);
4704 assert!(
4705 texts[0].contains("Full width intro"),
4706 "expected full-width intro first: {texts:?}"
4707 );
4708 }
4709
4710 #[test]
4711 fn xy_cut_horizontal_split_for_zone_boundaries() {
4712 let texts = block_texts(vec![
4716 span("First paragraph body text", 40.0, 740.0, 200.0),
4717 span("Second paragraph body", 40.0, 680.0, 180.0),
4718 ]);
4719 assert_eq!(texts.len(), 2);
4720 assert!(texts[0].starts_with("First"));
4721 assert!(texts[1].starts_with("Second"));
4722 }
4723
4724 #[test]
4725 fn xy_cut_recursion_terminates_with_single_span() {
4726 let texts = block_texts(vec![span("Only one span on the page", 40.0, 700.0, 180.0)]);
4727 assert_eq!(texts, vec!["Only one span on the page"]);
4728 }
4729
4730 #[test]
4731 fn median_font_size_handles_mixed_sizes() {
4732 let spans = vec![
4733 TextSpan {
4734 text: "small".into(),
4735 width: 10.0,
4736 height: 8.0,
4737 font_size: 8.0,
4738 ..TextSpan::default()
4739 },
4740 TextSpan {
4741 text: "medium".into(),
4742 width: 10.0,
4743 height: 12.0,
4744 font_size: 12.0,
4745 ..TextSpan::default()
4746 },
4747 TextSpan {
4748 text: "large".into(),
4749 width: 10.0,
4750 height: 24.0,
4751 font_size: 24.0,
4752 ..TextSpan::default()
4753 },
4754 ];
4755 assert!((median_font_size(&spans) - 12.0).abs() < 1e-9);
4756 }
4757
4758 #[test]
4759 fn columns_band_aligned_accepts_aligned_columns() {
4760 let spans = vec![
4761 span("L1", 40.0, 700.0, 60.0),
4762 span("R1", 300.0, 700.0, 60.0),
4763 span("L2", 40.0, 684.0, 60.0),
4764 span("R2", 300.0, 684.0, 60.0),
4765 ];
4766 let stats = PageStats::from_spans(&spans);
4767 assert!(columns_are_band_aligned(&spans, 200.0, 40.0, 360.0, &stats));
4769 }
4770
4771 #[test]
4772 fn columns_band_aligned_rejects_wide_single_side_band() {
4773 let spans = vec![
4774 span("Wide banner line across top", 40.0, 740.0, 280.0),
4775 span("L1", 40.0, 700.0, 60.0),
4776 span("R1", 300.0, 700.0, 60.0),
4777 ];
4778 let stats = PageStats::from_spans(&spans);
4779 assert!(!columns_are_band_aligned(
4782 &spans, 200.0, 40.0, 360.0, &stats
4783 ));
4784 }
4785
4786 #[test]
4787 fn page_stats_computes_median_values() {
4788 let spans = vec![
4789 span("one", 40.0, 700.0, 30.0),
4790 span("two", 40.0, 680.0, 30.0),
4791 span("three", 40.0, 660.0, 50.0),
4792 ];
4793 let stats = PageStats::from_spans(&spans);
4794 assert!((stats.median_font_size - 12.0).abs() < 1e-9);
4795 assert!((stats.median_char_width - 10.0).abs() < 1e-9);
4797 assert!((stats.median_line_spacing - 20.0).abs() < 1e-9);
4799 }
4800
4801 #[test]
4802 fn page_stats_handles_empty_input() {
4803 let stats = PageStats::from_spans(&[]);
4804 assert!((stats.median_font_size - 12.0).abs() < 1e-9);
4805 assert!((stats.median_char_width - 6.0).abs() < 1e-9);
4806 assert_eq!(stats.median_line_spacing, 0.0);
4807 }
4808
4809 #[test]
4810 fn narrow_gutter_detected_with_adaptive_threshold() {
4811 let mut spans = Vec::new();
4815 for y in [700.0, 684.0, 668.0] {
4816 spans.push(span("Lorem ipsum", 40.0, y, 100.0));
4818 spans.push(span("dolor sit", 144.0, y, 80.0));
4819 spans.push(span("amet consec", 236.0, y, 100.0));
4821 spans.push(span("tetur adipi", 340.0, y, 80.0));
4822 }
4823 let texts = block_texts(spans);
4824 assert!(
4826 texts.len() >= 6,
4827 "expected column-major output, got {texts:?}"
4828 );
4829 assert!(
4831 texts[0].contains("Lorem"),
4832 "first block should be left column: {texts:?}"
4833 );
4834 }
4835
4836 #[test]
4837 fn xy_cut_leaf_falls_back_to_legacy_columns_for_header_plus_three_columns() {
4838 let texts = block_texts(vec![
4839 span("73022", 45.0, 750.0, 70.0),
4840 span("Federal Register banner", 125.6, 750.0, 260.0),
4841 span("Left column line one", 45.0, 725.0, 140.0),
4842 span("Middle column line one", 222.0, 725.0, 140.0),
4843 span("Right column line one", 399.0, 725.0, 120.0),
4844 span("Left column line two", 45.0, 715.0, 140.0),
4845 span("Middle column line two", 210.0, 715.0, 152.0),
4846 span("Right column line two", 388.0, 715.0, 132.0),
4847 span("Left column line three", 45.0, 705.0, 140.0),
4848 span("Middle column line three", 235.0, 705.0, 135.0),
4849 span("Right column line three", 408.0, 705.0, 118.0),
4850 ]);
4851
4852 assert_eq!(
4853 texts,
4854 vec![
4855 "73022 Federal Register banner",
4856 "Left column line one",
4857 "Left column line two",
4858 "Left column line three",
4859 "Middle column line one",
4860 "Middle column line two",
4861 "Middle column line three",
4862 "Right column line one",
4863 "Right column line two",
4864 "Right column line three",
4865 ]
4866 );
4867 }
4868
4869 #[test]
4870 fn overlapping_fake_bold_spans_collapse_to_single_copy() {
4871 let texts = block_texts(vec![
4872 span("1 This is fakebold text.", 25.9, 785.3, 320.0),
4873 span("1 This is fakebold text.", 26.2, 785.3, 320.0),
4874 span("1 This is fakebold text.", 26.4, 785.3, 320.0),
4875 span("1 This is fakebold text.", 26.7, 785.3, 320.0),
4876 span("2 This is a fakebold", 27.0, 714.8, 142.0),
4877 span(" fakebold", 169.8, 714.8, 70.0),
4878 span(" fakebold", 170.1, 714.8, 70.0),
4879 span(" fakebold word.", 170.4, 714.8, 110.0),
4880 ]);
4881
4882 assert_eq!(
4883 texts,
4884 vec!["1 This is fakebold text.", "2 This is a fakebold word.",]
4885 );
4886 }
4887
4888 fn turned_span(text: &str, degrees: f64, x: f64, y: f64, width: f64) -> TextSpan {
4891 let (sin, cos) = degrees.to_radians().sin_cos();
4892 let scale = 12.0 / 1000.0;
4893 TextSpan {
4894 transform: Some([cos * scale, sin * scale, -sin * scale, cos * scale, x, y]),
4895 ..span(text, x, y, width)
4896 }
4897 }
4898
4899 #[test]
4905 fn two_lines_at_one_place_come_back_in_reading_order() {
4906 let mut spans = vec![span("the page itself", 100.0, 700.0, 80.0)];
4910 for (index, text) in ["first", "second"].iter().enumerate() {
4911 let x = 60.0 + 14.0 * index as f64;
4912 spans.push(turned_span(text, 90.0, x, 200.0, 30.0));
4913 }
4914 let blocks = vec![TextBlock { spans }];
4915
4916 let out = reassemble_cross_direction_runs(blocks);
4917
4918 let lines: Vec<String> = out.iter().map(|block| block.text()).collect();
4919 assert_eq!(
4920 lines,
4921 vec!["first", "second", "the page itself"],
4922 "a rotated block came back with its lines the wrong way round"
4923 );
4924 }
4925
4926 #[test]
4932 fn the_pages_direction_is_weighed_in_glyphs_and_not_characters() {
4933 let mut upright = span("fifffifl", 72.0, 700.0, 24.0);
4935 upright.char_bounds = vec![[0.0; 4]; 2];
4936 let mut turned = turned_span("TURNS", 90.0, 300.0, 300.0, 30.0);
4938 turned.char_bounds = vec![[0.0; 4]; 5];
4939
4940 let direction = page_direction(&[TextBlock {
4941 spans: vec![upright, turned],
4942 }])
4943 .expect("a direction");
4944
4945 assert!(
4946 (direction.1).abs() > 0.5,
4947 "two ligature glyphs outvoted five drawn ones, so the count is of characters"
4948 );
4949 }
4950
4951 #[test]
4958 fn a_turned_run_ends_where_its_last_glyph_ends() {
4959 const DEGREES: f64 = 30.0;
4960 const ADVANCE: f64 = 7.2; let (sin, cos) = DEGREES.to_radians().sin_cos();
4962
4963 let mut hello = turned_span("HELLO", DEGREES, 100.0, 400.0, 0.0);
4967 for index in 0..5 {
4968 let x = 100.0 + cos * ADVANCE * index as f64;
4969 let y = 400.0 + sin * ADVANCE * index as f64;
4970 hello.char_bounds.push([x, y, x + ADVANCE, y + 12.0]);
4971 hello.width = hello.width.max(x + ADVANCE - 100.0);
4972 }
4973 let world = turned_span(
4975 "WORLD",
4976 DEGREES,
4977 100.0 + cos * ADVANCE * 5.0,
4978 400.0 + sin * ADVANCE * 5.0,
4979 ADVANCE * 5.0,
4980 );
4981
4982 let block = TextBlock {
4983 spans: vec![hello, world],
4984 };
4985 assert_eq!(
4986 block.text(),
4987 "HELLOWORLD",
4988 "a space was invented at a seam the document draws closed"
4989 );
4990 }
4991
4992 #[test]
4997 fn a_page_written_entirely_at_an_angle_keeps_its_own_bands() {
4998 let blocks = vec![
4999 TextBlock {
5000 spans: vec![
5001 turned_span("first line", 90.0, 100.0, 100.0, 60.0),
5002 turned_span("carried on", 90.0, 100.0, 160.0, 60.0),
5003 ],
5004 },
5005 TextBlock {
5006 spans: vec![turned_span("second line", 90.0, 120.0, 100.0, 66.0)],
5007 },
5008 ];
5009
5010 let out = reassemble_cross_direction_runs(blocks.clone());
5011
5012 let shape: Vec<Vec<&str>> = out
5013 .iter()
5014 .map(|b| b.spans.iter().map(|s| s.text.as_str()).collect())
5015 .collect();
5016 assert_eq!(
5017 shape,
5018 vec![vec!["first line", "carried on"], vec!["second line"]],
5019 "a page in one direction was rearranged by a rule that is about two"
5020 );
5021 }
5022
5023 #[test]
5027 fn the_pages_direction_is_the_heaviest_and_a_tie_goes_to_the_first() {
5028 let upright = TextBlock {
5029 spans: vec![span("eight ch", 0.0, 700.0, 48.0)],
5030 };
5031 let turned = TextBlock {
5032 spans: vec![turned_span("ninety deg", 90.0, 300.0, 300.0, 60.0)],
5033 };
5034
5035 let heavier = page_direction(&[upright.clone(), turned.clone()]).expect("a direction");
5036 assert!(
5037 (heavier.1).abs() > 0.5,
5038 "ten glyphs at ninety degrees outweigh eight upright ones, and did not"
5039 );
5040
5041 let tie = TextBlock {
5042 spans: vec![span("ninety deg", 0.0, 700.0, 60.0)],
5043 };
5044 let first = page_direction(&[tie, turned]).expect("a direction");
5045 assert!(
5046 (first.1).abs() < 0.5,
5047 "a tie went to the direction that was not drawn first"
5048 );
5049 }
5050
5051 #[test]
5055 fn a_turned_page_is_read_in_the_frame_it_was_written_in() {
5056 let spans: Vec<TextSpan> = ["the first line", "the second line"]
5060 .iter()
5061 .enumerate()
5062 .map(|(index, text)| turned_span(text, 90.0, 100.0 + 16.0 * index as f64, 100.0, 80.0))
5063 .collect();
5064
5065 let lines = block_texts(spans);
5066
5067 assert_eq!(
5068 lines,
5069 vec!["the first line", "the second line"],
5070 "the lines of a turned page were read in the bands of the frame it is drawn in"
5071 );
5072 }
5073
5074 #[test]
5079 fn the_turn_into_the_frame_and_back_leaves_every_number_as_it_was() {
5080 const ADVANCE: f64 = 7.2;
5082 const X: f64 = 123.75;
5083 const Y: f64 = 456.5;
5084 let mut original = turned_span("TURNED", 90.0, X, Y, (X + ADVANCE) - X);
5090 for index in 0..6 {
5091 let y = Y + ADVANCE * index as f64;
5092 original.char_bounds.push([X, y, X + ADVANCE, y + 12.0]);
5093 }
5094
5095 for turn in [PageTurn::Quarter, PageTurn::Half, PageTurn::ThreeQuarter] {
5096 let spans = turn.into_frame(vec![original.clone()]);
5097 let blocks = turn.back(vec![TextBlock { spans }]);
5098 let span = &blocks[0].spans[0];
5099
5100 assert_eq!(
5101 (span.x, span.y),
5102 (original.x, original.y),
5103 "{turn:?} moved the span's origin"
5104 );
5105 assert_eq!(
5106 span.transform, original.transform,
5107 "{turn:?} left the baseline turned"
5108 );
5109 assert_eq!(
5110 span.char_bounds, original.char_bounds,
5111 "{turn:?} moved a glyph bound"
5112 );
5113 assert_eq!(
5114 span.width, original.width,
5115 "{turn:?} left a frame width behind"
5116 );
5117 }
5118 }
5119
5120 #[test]
5124 fn a_page_is_turned_by_a_quarter_turn_or_by_nothing() {
5125 let cases = [
5126 (0.0, PageTurn::None),
5127 (90.0, PageTurn::Quarter),
5128 (180.0, PageTurn::Half),
5129 (270.0, PageTurn::ThreeQuarter),
5130 (45.0, PageTurn::None),
5131 (-52.0, PageTurn::None),
5132 ];
5133 for (degrees, expected) in cases {
5134 let spans = vec![turned_span("a line of it", degrees, 100.0, 100.0, 72.0)];
5135 assert_eq!(
5136 PageTurn::of(&spans),
5137 expected,
5138 "a page written at {degrees} degrees"
5139 );
5140 }
5141
5142 let page = vec![
5144 span(
5145 "the body of the page is written upright",
5146 72.0,
5147 700.0,
5148 230.0,
5149 ),
5150 turned_span("SANDBOX", 90.0, 300.0, 200.0, 42.0),
5151 ];
5152 assert_eq!(
5153 PageTurn::of(&page),
5154 PageTurn::None,
5155 "a turned watermark turned the page it is drawn on"
5156 );
5157 }
5158
5159 #[test]
5164 fn only_a_half_turn_keeps_the_pages_ruling_lines() {
5165 let rule = HorizontalRule {
5166 left: 72.0,
5167 right: 540.0,
5168 y: 400.0,
5169 };
5170
5171 let drawn = [rule];
5172 let upright = PageTurn::None.rules_in_frame(&drawn);
5173 assert_eq!(
5174 (
5175 upright.len(),
5176 upright[0].left,
5177 upright[0].right,
5178 upright[0].y
5179 ),
5180 (1, rule.left, rule.right, rule.y),
5181 "an upright page's rules were touched at all"
5182 );
5183
5184 let turned = PageTurn::Half.rules_in_frame(&drawn);
5185 let rotation = PageTurn::Half.rotation();
5186 let (left, y) = turn_point(rule.right, rule.y, rotation);
5187 let (right, _) = turn_point(rule.left, rule.y, rotation);
5188 assert_eq!(
5189 (turned.len(), turned[0].left, turned[0].right, turned[0].y),
5190 (1, left, right, y),
5191 "a half turn lost the page's rules, or left them where they were drawn"
5192 );
5193 assert!(
5194 turned[0].left < turned[0].right,
5195 "the ends of a mirrored rule did not swap, so it reads as empty"
5196 );
5197
5198 for turn in [PageTurn::Quarter, PageTurn::ThreeQuarter] {
5199 assert!(
5200 turn.rules_in_frame(&drawn).is_empty(),
5201 "{turn:?} kept a rule that runs across its reading direction"
5202 );
5203 }
5204 }
5205
5206 #[test]
5212 fn a_page_with_no_majority_direction_is_not_turned() {
5213 let quarter_each = vec![
5214 span("rotation zero", 72.0, 700.0, 78.0),
5215 turned_span("rotation ninety", 90.0, 200.0, 200.0, 90.0),
5216 turned_span("rotation oneeighty", 180.0, 400.0, 400.0, 108.0),
5217 turned_span("rotation twoseventy", 270.0, 500.0, 600.0, 114.0),
5218 ];
5219 assert_eq!(
5220 PageTurn::of(&quarter_each),
5221 PageTurn::None,
5222 "a plurality of a quarter of the glyphs turned the whole page"
5223 );
5224
5225 let mut majority = quarter_each;
5228 majority[1] = turned_span(
5229 "a line of ninety degree text that is most of this page by a street",
5230 90.0,
5231 200.0,
5232 200.0,
5233 400.0,
5234 );
5235 assert_eq!(
5236 PageTurn::of(&majority),
5237 PageTurn::Quarter,
5238 "a direction carrying most of the page did not turn it"
5239 );
5240 }
5241
5242 fn mirrored_span(text: &str, x: f64, y: f64, width: f64) -> TextSpan {
5246 let scale = 12.0 / 1000.0;
5247 TextSpan {
5248 transform: Some([-scale, 0.0, 0.0, scale, x, y]),
5249 ..span(text, x, y, width)
5250 }
5251 }
5252
5253 #[test]
5259 fn a_mirrored_page_is_not_a_half_turned_page() {
5260 let mirrored = vec![
5261 mirrored_span("the first line of a mirrored page", 400.0, 700.0, 300.0),
5262 mirrored_span("and the second line below it", 400.0, 686.0, 260.0),
5263 ];
5264 assert_eq!(
5265 PageTurn::of(&mirrored),
5266 PageTurn::None,
5267 "a reflection was read as a half turn, and turning it reverses the page"
5268 );
5269
5270 let half_turned: Vec<TextSpan> = vec![
5273 turned_span(
5274 "the first line of a turned page",
5275 180.0,
5276 400.0,
5277 700.0,
5278 300.0,
5279 ),
5280 turned_span("and the second line below it", 180.0, 400.0, 686.0, 260.0),
5281 ];
5282 assert_eq!(
5283 PageTurn::of(&half_turned),
5284 PageTurn::Half,
5285 "a page genuinely drawn at 180 degrees is no longer turned into its frame"
5286 );
5287
5288 let mut mostly_mirrored = mirrored;
5292 mostly_mirrored.push(span("a short upright caption", 72.0, 100.0, 40.0));
5293 assert_eq!(
5294 PageTurn::of(&mostly_mirrored),
5295 PageTurn::None,
5296 "an upright minority carried the vote on a mirrored page"
5297 );
5298 }
5299
5300 #[test]
5307 fn the_seam_allowance_counts_glyphs_and_not_characters() {
5308 let prev = span("first", 0.0, 700.0, 30.0);
5309 let mut curr = span("office", 24.5, 700.0, 30.0);
5310 curr.char_bounds = vec![[0.0; 4]; 5];
5311
5312 assert!(
5313 second_run_carries_on_from_the_first(&prev, &curr),
5314 "a ligature shrank the seam allowance to the character count"
5315 );
5316
5317 curr.char_bounds.clear();
5318 assert!(
5319 !second_run_carries_on_from_the_first(&prev, &curr),
5320 "the character count is the fallback, so it has to be the stricter one here"
5321 );
5322 }
5323
5324 #[test]
5327 fn g1_default_text_span_has_empty_metadata() {
5328 let s = TextSpan::default();
5329 assert_eq!(s.font_name, None);
5330 assert!(!s.is_bold);
5331 assert!(!s.is_italic);
5332 assert_eq!(s.color, None);
5333 }
5334
5335 #[test]
5336 fn g1_strip_subset_prefix_handles_six_char_prefix() {
5337 assert_eq!(strip_subset_prefix("AAAAAA+Helvetica"), "Helvetica");
5338 assert_eq!(strip_subset_prefix("ABC+Helvetica"), "ABC+Helvetica");
5340 assert_eq!(strip_subset_prefix("Helvetica-Bold"), "Helvetica-Bold");
5342 }
5343
5344 #[test]
5345 fn g1_name_style_hints_match_pdf_interpret_rules() {
5346 assert_eq!(name_style_hints("Helvetica-Bold"), (true, false));
5347 assert_eq!(name_style_hints("Times-Italic"), (false, true));
5348 assert_eq!(name_style_hints("MyFont-BoldOblique"), (true, true));
5349 assert_eq!(name_style_hints("Helvetica"), (false, false));
5350 assert_eq!(name_style_hints("Roboto-DemiBold"), (true, false));
5352 assert_eq!(name_style_hints("Roboto-Black"), (true, false));
5353 assert_eq!(name_style_hints("Roboto-Oblique"), (false, true));
5355 assert_eq!(name_style_hints("MyFont-Slanted"), (false, true));
5356 }
5357
5358 #[test]
5361 fn g2_default_text_span_has_estimate_width_source() {
5362 let s = TextSpan::default();
5363 assert_eq!(s.width_source, WidthSource::Estimate);
5364 assert!(s.char_bounds.is_empty());
5365 }
5366
5367 #[test]
5370 fn g2_single_glyph_span_has_one_char_bound() {
5371 let s = TextSpan {
5372 text: "A".into(),
5373 x: 10.0,
5374 y: 100.0,
5375 width: 7.22,
5376 height: 10.0,
5377 font_size: 10.0,
5378 width_source: WidthSource::Metric,
5379 char_bounds: vec![[10.0, 100.0, 17.22, 110.0]],
5380 ..Default::default()
5381 };
5382
5383 assert_eq!(s.char_bounds.len(), 1);
5384 let [x0, y0, x1, y1] = s.char_bounds[0];
5385 assert!((x0 - 10.0).abs() < 0.001);
5386 assert!((x1 - 17.22).abs() < 0.001);
5387 assert!((y1 - y0 - s.font_size).abs() < 0.001);
5388 }
5389
5390 #[test]
5393 fn g2_merged_span_degrades_width_source_on_estimate() {
5394 let mut s = TextSpan {
5395 width_source: WidthSource::Metric,
5396 char_bounds: vec![[0.0, 0.0, 7.0, 10.0]],
5397 ..Default::default()
5398 };
5399
5400 s.char_bounds.push([7.0, 0.0, 12.0, 10.0]);
5402 s.width_source = WidthSource::Estimate; assert_eq!(s.width_source, WidthSource::Estimate);
5405 assert_eq!(s.char_bounds.len(), 2);
5406 }
5407
5408 #[test]
5410 fn g2_width_source_variants_are_correct() {
5411 assert_eq!(format!("{:?}", WidthSource::Metric), "Metric");
5412 assert_eq!(format!("{:?}", WidthSource::Estimate), "Estimate");
5413 assert_ne!(WidthSource::Metric, WidthSource::Estimate);
5414 assert_eq!(WidthSource::default(), WidthSource::Estimate);
5415 }
5416
5417 #[test]
5420 fn m3_default_text_span_has_basic_mode() {
5421 let s = TextSpan::default();
5422 assert_eq!(s.geometry_mode, GeometryMode::Basic);
5423 assert_eq!(s.bounds_source, BoundsSource::Advance);
5424 assert!(s.tight_char_bounds.is_empty());
5425 assert!(s.glyph_advances.is_empty());
5426 }
5427
5428 #[test]
5429 fn m3_geometry_mode_default_is_basic() {
5430 assert_eq!(GeometryMode::default(), GeometryMode::Basic);
5431 }
5432
5433 #[test]
5434 fn m3_bounds_source_default_is_advance() {
5435 assert_eq!(BoundsSource::default(), BoundsSource::Advance);
5436 }
5437
5438 #[test]
5439 fn m3_geometry_mode_variants_are_distinct() {
5440 assert_ne!(GeometryMode::Basic, GeometryMode::RichGeometry);
5441 assert_eq!(format!("{:?}", GeometryMode::Basic), "Basic");
5442 assert_eq!(format!("{:?}", GeometryMode::RichGeometry), "RichGeometry");
5443 }
5444
5445 #[test]
5446 fn m3_bounds_source_variants_are_distinct() {
5447 assert_ne!(BoundsSource::Advance, BoundsSource::Tight);
5448 assert_ne!(BoundsSource::Tight, BoundsSource::Estimate);
5449 assert_eq!(format!("{:?}", BoundsSource::Advance), "Advance");
5450 assert_eq!(format!("{:?}", BoundsSource::Tight), "Tight");
5451 assert_eq!(format!("{:?}", BoundsSource::Estimate), "Estimate");
5452 }
5453
5454 #[test]
5455 fn m3_text_span_rich_geometry_preserves_existing_fields() {
5456 let s = TextSpan {
5457 text: "Hello".into(),
5458 x: 10.0,
5459 y: 100.0,
5460 width: 30.0,
5461 height: 12.0,
5462 font_size: 12.0,
5463 font_name: Some("Helvetica".into()),
5464 is_bold: true,
5465 is_italic: false,
5466 color: Some([0, 0, 0, 255]),
5467 width_source: WidthSource::Metric,
5468 char_bounds: vec![[10.0, 100.0, 40.0, 112.0]],
5469 geometry_mode: GeometryMode::RichGeometry,
5470 bounds_source: BoundsSource::Tight,
5471 tight_char_bounds: vec![[8.5, 98.5, 41.5, 114.0]],
5472 glyph_advances: vec![30.0],
5473 glyph_bounds_sources: vec![BoundsSource::Tight],
5474 ..Default::default()
5475 };
5476
5477 assert_eq!(s.text, "Hello");
5479 assert!((s.x - 10.0).abs() < 0.001);
5480 assert!((s.y - 100.0).abs() < 0.001);
5481 assert_eq!(s.width_source, WidthSource::Metric);
5482 assert_eq!(s.char_bounds.len(), 1);
5483
5484 assert_eq!(s.geometry_mode, GeometryMode::RichGeometry);
5486 assert_eq!(s.bounds_source, BoundsSource::Tight);
5487 assert_eq!(s.tight_char_bounds.len(), 1);
5488 assert_eq!(s.glyph_advances.len(), 1);
5489 assert!((s.glyph_advances[0] - 30.0).abs() < 0.001);
5490 }
5491
5492 #[test]
5493 fn m3_text_span_basic_mode_does_not_populate_tight_fields() {
5494 let s = TextSpan {
5495 text: "A".into(),
5496 x: 0.0,
5497 y: 0.0,
5498 width: 7.0,
5499 height: 10.0,
5500 font_size: 10.0,
5501 geometry_mode: GeometryMode::Basic,
5502 bounds_source: BoundsSource::Advance,
5503 ..Default::default()
5504 };
5505
5506 assert!(s.tight_char_bounds.is_empty());
5507 assert!(s.glyph_advances.is_empty());
5508 }
5509
5510 #[test]
5511 fn m3_tight_char_bounds_are_optional_in_basic_mode() {
5512 let s = TextSpan {
5514 text: "test".into(),
5515 x: 0.0,
5516 y: 0.0,
5517 width: 40.0,
5518 height: 12.0,
5519 font_size: 12.0,
5520 geometry_mode: GeometryMode::Basic,
5521 ..Default::default()
5522 };
5523 assert!(s.tight_char_bounds.is_empty());
5524 assert!(s.glyph_advances.is_empty());
5525 }
5526
5527 #[test]
5528 fn m3_test_span_constructor_preserves_all_fields() {
5529 let s = span("hello", 10.0, 100.0, 30.0);
5531 assert_eq!(s.geometry_mode, GeometryMode::Basic);
5532 assert_eq!(s.bounds_source, BoundsSource::Advance);
5533 assert!(s.tight_char_bounds.is_empty());
5534 }
5535
5536 #[test]
5537 fn m3_width_source_unchanged_by_m3() {
5538 assert_eq!(WidthSource::Metric, WidthSource::Metric);
5540 assert_eq!(WidthSource::Estimate, WidthSource::Estimate);
5541 assert_eq!(WidthSource::default(), WidthSource::Estimate);
5542 }
5543
5544 #[test]
5545 fn m3_transform_bbox_corners_identity() {
5546 let bbox = [0.0, 0.0, 10.0, 12.0];
5547 let identity = kurbo::Affine::IDENTITY;
5548 let result = transform_bbox_corners(&bbox, &identity);
5549 assert!((result[0] - 0.0).abs() < 0.001);
5550 assert!((result[1] - 0.0).abs() < 0.001);
5551 assert!((result[2] - 10.0).abs() < 0.001);
5552 assert!((result[3] - 12.0).abs() < 0.001);
5553 }
5554
5555 #[test]
5556 fn m3_transform_bbox_corners_translation() {
5557 let bbox = [0.0, 0.0, 10.0, 12.0];
5558 let t = kurbo::Affine::translate((50.0, 100.0));
5559 let result = transform_bbox_corners(&bbox, &t);
5560 assert!((result[0] - 50.0).abs() < 0.001);
5561 assert!((result[1] - 100.0).abs() < 0.001);
5562 assert!((result[2] - 60.0).abs() < 0.001);
5563 assert!((result[3] - 112.0).abs() < 0.001);
5564 }
5565
5566 #[test]
5567 fn m3_transform_bbox_corners_scale() {
5568 let bbox = [0.0, 0.0, 10.0, 12.0];
5569 let s = kurbo::Affine::scale(2.0);
5570 let result = transform_bbox_corners(&bbox, &s);
5571 assert!((result[0] - 0.0).abs() < 0.001);
5572 assert!((result[1] - 0.0).abs() < 0.001);
5573 assert!((result[2] - 20.0).abs() < 0.001);
5574 assert!((result[3] - 24.0).abs() < 0.001);
5575 }
5576
5577 #[test]
5578 fn m3_transform_bbox_corners_with_negative_bbox() {
5579 let bbox = [-5.0, -200.0, 15.0, 800.0];
5581 let s = kurbo::Affine::scale(0.012); let result = transform_bbox_corners(&bbox, &s);
5583 assert!((result[0] - (-0.06)).abs() < 0.01);
5584 assert!((result[1] - (-2.4)).abs() < 0.01);
5585 assert!((result[2] - 0.18).abs() < 0.01);
5586 assert!((result[3] - 9.6).abs() < 0.01);
5587 }
5588
5589 #[test]
5590 fn m3_tight_char_bounds_descender_detection() {
5591 let s = TextSpan {
5593 text: "g".into(),
5594 x: 100.0,
5595 y: 200.0, width: 8.0,
5597 height: 12.0,
5598 font_size: 12.0,
5599 geometry_mode: GeometryMode::RichGeometry,
5600 bounds_source: BoundsSource::Tight,
5601 tight_char_bounds: vec![[99.0, 197.5, 109.0, 210.0]],
5602 char_bounds: vec![[100.0, 200.0, 108.0, 212.0]],
5603 glyph_advances: vec![8.0],
5604 ..Default::default()
5605 };
5606
5607 assert!(
5609 s.tight_char_bounds[0][1] < s.y,
5610 "descender should extend below baseline"
5611 );
5612 }
5613
5614 #[test]
5615 fn m3_tight_char_bounds_ascender_detection() {
5616 let s = TextSpan {
5618 text: "f".into(),
5619 x: 100.0,
5620 y: 200.0,
5621 width: 7.0,
5622 height: 12.0,
5623 font_size: 12.0,
5624 geometry_mode: GeometryMode::RichGeometry,
5625 bounds_source: BoundsSource::Tight,
5626 tight_char_bounds: vec![[95.0, 200.0, 108.0, 214.0]],
5627 char_bounds: vec![[100.0, 200.0, 107.0, 212.0]],
5628 glyph_advances: vec![7.0],
5629 ..Default::default()
5630 };
5631
5632 let advance_y1 = s.y + s.height;
5634 assert!(
5635 s.tight_char_bounds[0][3] > advance_y1,
5636 "ascender should extend above advance bounds"
5637 );
5638 }
5639
5640 #[test]
5641 fn m3_tight_char_bounds_differ_from_advance() {
5642 let s = TextSpan {
5644 text: "A".into(),
5645 x: 10.0,
5646 y: 100.0,
5647 width: 7.22,
5648 height: 10.0,
5649 font_size: 10.0,
5650 geometry_mode: GeometryMode::RichGeometry,
5651 bounds_source: BoundsSource::Tight,
5652 char_bounds: vec![[10.0, 100.0, 17.22, 110.0]], tight_char_bounds: vec![[9.5, 100.2, 17.5, 109.5]], glyph_advances: vec![7.22],
5655 ..Default::default()
5656 };
5657
5658 let ac = s.char_bounds[0];
5659 let tc = s.tight_char_bounds[0];
5660 let differs = (ac[0] - tc[0]).abs() > 0.01
5661 || (ac[1] - tc[1]).abs() > 0.01
5662 || (ac[2] - tc[2]).abs() > 0.01
5663 || (ac[3] - tc[3]).abs() > 0.01;
5664 assert!(differs, "tight bounds should differ from advance bounds");
5665 }
5666
5667 #[test]
5668 fn m3_device_new_uses_basic_mode() {
5669 let dev = TextExtractionDevice::new();
5670 assert_eq!(dev.geometry_mode, GeometryMode::Basic);
5671 assert!(dev.deferred_rich_glyphs.is_empty());
5672 }
5673
5674 #[test]
5675 fn m3_device_with_mode_rich_geometry() {
5676 let dev = TextExtractionDevice::with_mode(GeometryMode::RichGeometry);
5677 assert_eq!(dev.geometry_mode, GeometryMode::RichGeometry);
5678 assert!(dev.deferred_rich_glyphs.is_empty());
5679 }
5680
5681 #[cfg(test)]
5688 fn synthetic_glyph_rect() -> (BezPath, kurbo::Rect) {
5689 let mut path = BezPath::new();
5690 path.move_to((100.0, 0.0));
5691 path.line_to((600.0, 0.0));
5692 path.line_to((600.0, 700.0));
5693 path.line_to((100.0, 700.0));
5694 path.close_path();
5695 let bbox = path.bounding_box();
5696 (path, bbox)
5697 }
5698
5699 #[test]
5700 fn m3_basic_mode_skips_tight_bounds_computation() {
5701 let mut dev = TextExtractionDevice::new();
5703 dev.spans.push(TextSpan {
5704 text: "A".into(),
5705 char_bounds: vec![[0.0, 0.0, 7.0, 10.0]],
5706 ..Default::default()
5707 });
5708 let spans = dev.into_spans();
5709 assert_eq!(spans.len(), 1);
5710 assert!(spans[0].tight_char_bounds.is_empty());
5711 assert!(spans[0].glyph_bounds_sources.is_empty());
5712 assert_eq!(spans[0].bounds_source, BoundsSource::Advance);
5713 }
5714
5715 #[test]
5716 fn m3_rich_geometry_tight_bounds_match_rendered_outline() {
5717 let (path, bbox) = synthetic_glyph_rect();
5722 let composed = Affine::scale(0.01);
5723 let oracle = (composed * path.clone()).bounding_box();
5724
5725 let mut dev = TextExtractionDevice::with_mode(GeometryMode::RichGeometry);
5726 dev.spans.push(TextSpan {
5727 text: "A".into(),
5728 char_bounds: vec![[0.0, 0.0, 7.0, 10.0]],
5729 geometry_mode: GeometryMode::RichGeometry,
5730 ..Default::default()
5731 });
5732 dev.deferred_rich_glyphs.push(DeferredGlyph {
5733 coeffs: composed.as_coeffs(),
5734 font_size: 10.0,
5735 glyph_width: 7.0,
5736 needs_exact: false,
5737 outline: Some(path),
5738 font_bbox: Some(bbox),
5739 });
5740
5741 let spans = dev.into_spans();
5742 assert_eq!(spans[0].glyph_bounds_sources, vec![BoundsSource::Tight]);
5743 assert_eq!(spans[0].bounds_source, BoundsSource::Tight);
5744 let tb = spans[0].tight_char_bounds[0];
5745 assert!(
5746 (tb[0] - oracle.x0).abs() < 1e-6,
5747 "x0: got {} want {}",
5748 tb[0],
5749 oracle.x0
5750 );
5751 assert!(
5752 (tb[1] - oracle.y0).abs() < 1e-6,
5753 "y0: got {} want {}",
5754 tb[1],
5755 oracle.y0
5756 );
5757 assert!(
5758 (tb[2] - oracle.x1).abs() < 1e-6,
5759 "x1: got {} want {}",
5760 tb[2],
5761 oracle.x1
5762 );
5763 assert!(
5764 (tb[3] - oracle.y1).abs() < 1e-6,
5765 "y1: got {} want {}",
5766 tb[3],
5767 oracle.y1
5768 );
5769 assert!(
5772 tb[3] - tb[1] > 1.0,
5773 "tight height must be commensurate with font size"
5774 );
5775 }
5776
5777 #[test]
5778 fn m3_rich_geometry_tight_bounds_rotation_is_exact() {
5779 let (path, bbox) = synthetic_glyph_rect();
5782 let composed = Affine::scale(0.01) * Affine::rotate(std::f64::consts::FRAC_PI_2);
5783 let oracle = (composed * path.clone()).bounding_box();
5784
5785 let mut dev = TextExtractionDevice::with_mode(GeometryMode::RichGeometry);
5786 dev.spans.push(TextSpan {
5787 text: "A".into(),
5788 char_bounds: vec![[0.0, 0.0, 7.0, 10.0]],
5789 geometry_mode: GeometryMode::RichGeometry,
5790 ..Default::default()
5791 });
5792 dev.deferred_rich_glyphs.push(DeferredGlyph {
5793 coeffs: composed.as_coeffs(),
5794 font_size: 10.0,
5795 glyph_width: 7.0,
5796 needs_exact: true,
5797 outline: Some(path),
5798 font_bbox: Some(bbox),
5799 });
5800
5801 let spans = dev.into_spans();
5802 assert_eq!(spans[0].glyph_bounds_sources, vec![BoundsSource::Tight]);
5803 let tb = spans[0].tight_char_bounds[0];
5804 assert!(
5805 (tb[0] - oracle.x0).abs() < 1e-6,
5806 "x0: got {} want {}",
5807 tb[0],
5808 oracle.x0
5809 );
5810 assert!(
5811 (tb[1] - oracle.y0).abs() < 1e-6,
5812 "y0: got {} want {}",
5813 tb[1],
5814 oracle.y0
5815 );
5816 assert!(
5817 (tb[2] - oracle.x1).abs() < 1e-6,
5818 "x1: got {} want {}",
5819 tb[2],
5820 oracle.x1
5821 );
5822 assert!(
5823 (tb[3] - oracle.y1).abs() < 1e-6,
5824 "y1: got {} want {}",
5825 tb[3],
5826 oracle.y1
5827 );
5828 }
5829
5830 #[test]
5831 fn m3_empty_device_rich_geometry_no_panic() {
5832 let dev = TextExtractionDevice::with_mode(GeometryMode::RichGeometry);
5833 let text = dev.into_text();
5834 assert!(text.is_empty());
5835 }
5836
5837 #[test]
5838 fn m3_bounds_source_estimate_fallback() {
5839 let s = TextSpan {
5841 geometry_mode: GeometryMode::RichGeometry,
5842 bounds_source: BoundsSource::Estimate,
5843 ..Default::default()
5844 };
5845 assert_eq!(s.bounds_source, BoundsSource::Estimate);
5846 assert_eq!(s.geometry_mode, GeometryMode::RichGeometry);
5847 assert!(s.tight_char_bounds.is_empty());
5849 }
5850
5851 #[test]
5852 fn m3_multiple_glyphs_tight_bounds_consistent_count() {
5853 let s = TextSpan {
5856 text: "abc".into(),
5857 x: 0.0,
5858 y: 0.0,
5859 width: 25.0,
5860 height: 12.0,
5861 font_size: 12.0,
5862 geometry_mode: GeometryMode::RichGeometry,
5863 bounds_source: BoundsSource::Tight,
5864 char_bounds: vec![[0., 0., 8., 12.], [8., 0., 16., 12.], [16., 0., 25., 12.]],
5865 tight_char_bounds: vec![
5866 [-1., -2., 9., 14.],
5867 [7., -2., 17., 14.],
5868 [15., -2., 26., 14.],
5869 ],
5870 glyph_advances: vec![8.0, 8.0, 9.0],
5871 ..Default::default()
5872 };
5873
5874 assert_eq!(s.tight_char_bounds.len(), 3);
5875 assert_eq!(s.glyph_advances.len(), 3);
5876 assert_eq!(s.char_bounds.len(), 3);
5877 for &adv in &s.glyph_advances {
5879 assert!(adv >= 0.0, "glyph advance must be non-negative");
5880 }
5881 }
5882}