1mod base14;
6mod clip_boundaries;
7pub(crate) mod content_decode;
8pub(crate) mod content_stream;
9pub(crate) mod display_frame;
10pub(crate) mod fonts;
11pub(crate) mod geometry;
12mod layout;
13mod links;
14pub(crate) mod page_box;
15mod reading_order;
16mod scripts;
17mod text_paint;
18mod type1;
19pub(crate) mod underline;
20pub(crate) mod word_gaps;
21mod xobjects;
22
23use crate::text_utils::{is_cjk_char, is_rtl_text};
24use crate::tounicode::FontCMaps;
25use crate::types::{CMapCoverageByFont, PageExtraction, PdfLine, PdfRect, RunCoverage, TextItem};
26use crate::PdfError;
27use log::debug;
28use lopdf::{Document, Object, ObjectId};
29use std::borrow::Cow;
30use std::collections::{HashMap, HashSet};
31use std::path::Path;
32
33use content_stream::extract_page_text_items_with_options;
34pub(crate) use content_stream::TextExtractionOptions;
35pub(crate) use display_frame::DisplayPage;
36pub use display_frame::PositionFrame;
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54#[non_exhaustive]
55pub struct PositionOptions {
56 pub frame: PositionFrame,
59 pub bold_from_weight: bool,
71 pub bold_weight_threshold: u16,
76}
77
78impl Default for PositionOptions {
79 fn default() -> Self {
80 Self {
81 frame: PositionFrame::default(),
82 bold_from_weight: false,
83 bold_weight_threshold: content_stream::DEFAULT_BOLD_WEIGHT_THRESHOLD,
84 }
85 }
86}
87
88impl PositionOptions {
89 pub fn new() -> Self {
92 Self::default()
93 }
94
95 pub fn frame(mut self, frame: PositionFrame) -> Self {
97 self.frame = frame;
98 self
99 }
100
101 pub fn bold_from_weight(mut self, bold_from_weight: bool) -> Self {
103 self.bold_from_weight = bold_from_weight;
104 self
105 }
106
107 pub fn bold_weight_threshold(mut self, threshold: u16) -> Self {
110 self.bold_weight_threshold = threshold;
111 self
112 }
113
114 pub(crate) fn text_extraction(self, include_invisible: bool) -> TextExtractionOptions {
116 TextExtractionOptions {
117 include_invisible,
118 bold_from_weight: self.bold_from_weight,
119 bold_weight_threshold: self.bold_weight_threshold.clamp(100, 900),
120 cmap_coverage: false,
122 }
123 }
124}
125use links::{extract_form_fields, extract_page_links};
126pub(crate) use page_box::{visible_page_box, PageBox};
127
128pub use crate::text_utils::{is_bold_font, is_italic_font};
130pub use crate::types::{ItemType, TextLine};
131pub(crate) use fonts::FontStyleCache;
132pub(crate) use layout::detect_columns;
133#[cfg(test)]
134use layout::filter_markdown_page_numbers;
135pub(crate) use layout::filter_markdown_page_numbers_with_removed_pages;
136pub(crate) use layout::group_into_lines_with_thresholds;
137pub(crate) use layout::group_prefiltered_items_into_lines_with_thresholds_and_charts;
138pub(crate) use layout::group_prefiltered_items_into_lines_with_thresholds_and_regions;
139pub(crate) use layout::is_newspaper_layout;
140pub(crate) use layout::ColumnRegion;
141pub use layout::{group_into_lines, group_into_lines_preserving_all_text};
142pub(crate) use scripts::merge_subscript_items;
143pub(crate) use xobjects::FormWalkBudget;
144
145pub(crate) fn trace_text_preview(text: &str, max_chars: usize) -> &str {
150 match text.char_indices().nth(max_chars) {
151 Some((idx, _)) => &text[..idx],
152 None => text,
153 }
154}
155
156pub fn extract_text<P: AsRef<Path>>(path: P) -> Result<String, PdfError> {
158 crate::validate_pdf_file(&path)?;
159 let (doc, _) = crate::load_document_from_path(&path)?;
160 extract_text_from_doc(&doc)
161}
162
163pub fn extract_text_mem(buffer: &[u8]) -> Result<String, PdfError> {
165 crate::validate_pdf_bytes(buffer)?;
166 let (doc, _) = crate::load_document_from_mem(buffer)?;
167 extract_text_from_doc(&doc)
168}
169
170fn extract_text_from_doc(doc: &Document) -> Result<String, PdfError> {
172 let pages = doc.get_pages();
173 let page_nums: Vec<u32> = pages.keys().cloned().collect();
174
175 doc.extract_text(&page_nums)
176 .map_err(|e| PdfError::Parse(e.to_string()))
177}
178
179pub fn extract_text_with_positions<P: AsRef<Path>>(path: P) -> Result<Vec<TextItem>, PdfError> {
195 extract_text_with_positions_pages(path, None)
196}
197
198pub fn extract_text_with_positions_pages<P: AsRef<Path>>(
204 path: P,
205 page_filter: Option<&HashSet<u32>>,
206) -> Result<Vec<TextItem>, PdfError> {
207 let (items, _rects, _lines) =
208 extract_text_with_positions_and_rects_with_password(path, page_filter, None)?;
209 Ok(items)
210}
211
212pub fn extract_text_with_positions_pages_with_password<P: AsRef<Path>>(
219 path: P,
220 page_filter: Option<&HashSet<u32>>,
221 password: Option<&str>,
222) -> Result<Vec<TextItem>, PdfError> {
223 let (items, _rects, _lines) =
224 extract_text_with_positions_and_rects_with_password(path, page_filter, password)?;
225 Ok(items)
226}
227
228pub(crate) fn extract_text_with_positions_and_rects_with_password<P: AsRef<Path>>(
229 path: P,
230 page_filter: Option<&HashSet<u32>>,
231 password: Option<&str>,
232) -> Result<PageExtraction, PdfError> {
233 crate::validate_pdf_file(&path)?;
234 let (doc, _) = crate::load_document_from_path_with_password(&path, password)?;
235 let font_cmaps = FontCMaps::from_doc(&doc);
236 let (extraction, _thresholds, _gid_pages, _page_rotations, _cmap_coverage) =
237 extract_positioned_text_from_doc_in_page_box(
238 &doc,
239 &font_cmaps,
240 page_filter,
241 PositionOptions::default(),
242 )?;
243 Ok(extraction)
244}
245
246pub fn extract_text_with_positions_mem(buffer: &[u8]) -> Result<Vec<TextItem>, PdfError> {
249 extract_text_with_positions_mem_pages(buffer, None)
250}
251
252pub fn extract_text_with_positions_mem_pages(
255 buffer: &[u8],
256 page_filter: Option<&HashSet<u32>>,
257) -> Result<Vec<TextItem>, PdfError> {
258 extract_text_with_positions_mem_in_frame(buffer, page_filter, PositionFrame::Sheet)
259}
260
261pub fn extract_text_with_positions_mem_in_frame(
274 buffer: &[u8],
275 page_filter: Option<&HashSet<u32>>,
276 frame: PositionFrame,
277) -> Result<Vec<TextItem>, PdfError> {
278 extract_text_with_positions_mem_with_options(
279 buffer,
280 page_filter,
281 PositionOptions::new().frame(frame),
282 )
283}
284
285pub fn extract_text_with_positions_mem_with_options(
291 buffer: &[u8],
292 page_filter: Option<&HashSet<u32>>,
293 options: PositionOptions,
294) -> Result<Vec<TextItem>, PdfError> {
295 let (items, _page_rotations) =
296 extract_text_with_positions_and_rotations_mem_with_options(buffer, page_filter, options)?;
297 Ok(items)
298}
299
300pub fn extract_text_with_positions_and_rotations_mem(
310 buffer: &[u8],
311) -> Result<(Vec<TextItem>, HashMap<u32, geometry::PageRotation>), PdfError> {
312 extract_text_with_positions_and_rotations_mem_in_frame(buffer, None, PositionFrame::Sheet)
313}
314
315pub fn extract_text_with_positions_and_rotations_mem_in_frame(
323 buffer: &[u8],
324 page_filter: Option<&HashSet<u32>>,
325 frame: PositionFrame,
326) -> Result<(Vec<TextItem>, HashMap<u32, geometry::PageRotation>), PdfError> {
327 extract_text_with_positions_and_rotations_mem_with_options(
328 buffer,
329 page_filter,
330 PositionOptions::new().frame(frame),
331 )
332}
333
334pub fn extract_text_with_positions_and_rotations_mem_with_options(
338 buffer: &[u8],
339 page_filter: Option<&HashSet<u32>>,
340 options: PositionOptions,
341) -> Result<(Vec<TextItem>, HashMap<u32, geometry::PageRotation>), PdfError> {
342 crate::validate_pdf_bytes(buffer)?;
343 let (doc, _) = crate::load_document_from_mem(buffer)?;
344 let font_cmaps = FontCMaps::from_doc(&doc);
345 let ((mut items, _rects, _lines), _thresholds, _gid_pages, page_rotations, _cmap_coverage) =
346 extract_positioned_text_from_doc_in_page_box(&doc, &font_cmaps, page_filter, options)?;
347 if options.frame == PositionFrame::Display {
348 display_frame::document_items_to_display_frame(&doc, &mut items, &page_rotations);
349 }
350 Ok((items, page_rotations))
351}
352
353pub(crate) struct PageBoxExtraction {
356 pub(crate) items: Vec<TextItem>,
357 pub(crate) rects: Vec<PdfRect>,
358 pub(crate) lines: Vec<PdfLine>,
359 pub(crate) has_gid_fonts: bool,
361 pub(crate) coords_rotated: geometry::PageRotation,
365 pub(crate) skipped_invisible: bool,
368 pub(crate) page_box: PageBox,
371}
372
373pub(crate) fn extract_page_text_items_in_page_box(
378 doc: &Document,
379 page_id: ObjectId,
380 page_num: u32,
381 font_cmaps: &FontCMaps,
382 include_invisible: bool,
383 style_cache: &mut FontStyleCache,
384 form_budget: &mut FormWalkBudget,
385) -> Result<PageBoxExtraction, PdfError> {
386 extract_page_text_items_in_page_box_with_options(
387 doc,
388 page_id,
389 page_num,
390 font_cmaps,
391 TextExtractionOptions {
392 include_invisible,
393 ..TextExtractionOptions::default()
394 },
395 style_cache,
396 form_budget,
397 )
398}
399
400pub(crate) fn extract_page_text_items_in_page_box_with_options(
403 doc: &Document,
404 page_id: ObjectId,
405 page_num: u32,
406 font_cmaps: &FontCMaps,
407 options: TextExtractionOptions,
408 style_cache: &mut FontStyleCache,
409 form_budget: &mut FormWalkBudget,
410) -> Result<PageBoxExtraction, PdfError> {
411 let page_box = visible_page_box(doc, page_id).unwrap_or(PageBox::LETTER);
412 let (
414 (mut items, mut rects, mut lines),
415 has_gid_fonts,
416 coords_rotated,
417 skipped_invisible,
418 _run_coverage,
419 ) = extract_page_text_items_with_options(
420 doc,
421 page_id,
422 page_num,
423 font_cmaps,
424 options,
425 style_cache,
426 form_budget,
427 )?;
428 page_box.translate_page(&mut items, &mut rects, &mut lines, coords_rotated);
429 Ok(PageBoxExtraction {
430 items,
431 rects,
432 lines,
433 has_gid_fonts,
434 coords_rotated,
435 skipped_invisible,
436 page_box,
437 })
438}
439
440pub(crate) type PageThresholds = HashMap<u32, f32>;
446
447pub(crate) type PageRotations = HashMap<u32, geometry::PageRotation>;
451
452pub(crate) type DocumentExtraction = (
457 PageExtraction,
458 PageThresholds,
459 HashSet<u32>,
460 PageRotations,
461 CMapCoverageByFont,
462);
463
464pub(crate) fn extract_positioned_text_from_doc(
468 doc: &Document,
469 font_cmaps: &FontCMaps,
470 page_filter: Option<&HashSet<u32>>,
471) -> Result<DocumentExtraction, PdfError> {
472 extract_positioned_text_impl(
473 doc,
474 font_cmaps,
475 page_filter,
476 TextExtractionOptions::default(),
477 None,
478 CoordinateFrame::UserSpace,
479 )
480}
481
482pub(crate) fn extract_positioned_text_from_doc_in_page_box(
488 doc: &Document,
489 font_cmaps: &FontCMaps,
490 page_filter: Option<&HashSet<u32>>,
491 options: PositionOptions,
492) -> Result<DocumentExtraction, PdfError> {
493 extract_positioned_text_impl(
494 doc,
495 font_cmaps,
496 page_filter,
497 options.text_extraction(false),
498 None,
499 CoordinateFrame::VisiblePageBox,
500 )
501}
502
503#[derive(Debug, Clone, Copy, PartialEq, Eq)]
505pub(crate) enum CoordinateFrame {
506 UserSpace,
509 VisiblePageBox,
513}
514
515pub(crate) fn extract_positioned_text_with_folio_context(
519 doc: &Document,
520 font_cmaps: &FontCMaps,
521 page_filter: Option<&HashSet<u32>>,
522) -> Result<DocumentExtraction, PdfError> {
523 extract_positioned_text_with_folio_context_impl(doc, font_cmaps, page_filter, false)
524}
525
526pub(crate) fn extract_positioned_text_include_invisible_with_folio_context(
528 doc: &Document,
529 font_cmaps: &FontCMaps,
530 page_filter: Option<&HashSet<u32>>,
531) -> Result<DocumentExtraction, PdfError> {
532 extract_positioned_text_with_folio_context_impl(doc, font_cmaps, page_filter, true)
533}
534
535fn extract_positioned_text_with_folio_context_impl(
536 doc: &Document,
537 font_cmaps: &FontCMaps,
538 page_filter: Option<&HashSet<u32>>,
539 include_invisible: bool,
540) -> Result<DocumentExtraction, PdfError> {
541 let options = TextExtractionOptions {
544 include_invisible,
545 cmap_coverage: true,
546 ..TextExtractionOptions::default()
547 };
548 let Some(required_pages) = page_filter else {
549 return extract_positioned_text_impl(
550 doc,
551 font_cmaps,
552 None,
553 options,
554 None,
555 CoordinateFrame::UserSpace,
556 );
557 };
558
559 let (
560 (mut selected_items, mut selected_rects, mut selected_lines),
561 mut page_thresholds,
562 mut gid_encoded_pages,
563 mut page_rotations,
564 cmap_coverage,
565 ) = extract_positioned_text_impl(
566 doc,
567 font_cmaps,
568 Some(required_pages),
569 options,
570 None,
571 CoordinateFrame::UserSpace,
572 )?;
573 if !layout::needs_document_page_number_context(&selected_items, doc.get_pages().len()) {
574 return Ok((
575 (selected_items, selected_rects, selected_lines),
576 page_thresholds,
577 gid_encoded_pages,
578 page_rotations,
579 cmap_coverage,
580 ));
581 }
582
583 let context_pages: HashSet<u32> = doc
584 .get_pages()
585 .keys()
586 .copied()
587 .filter(|page| !required_pages.contains(page))
588 .collect();
589 let (
593 (context_items, context_rects, context_lines),
594 context_thresholds,
595 context_gid_pages,
596 context_rotations,
597 _context_coverage,
598 ) = extract_positioned_text_impl(
599 doc,
600 font_cmaps,
601 Some(&context_pages),
602 TextExtractionOptions {
603 cmap_coverage: false,
604 ..options
605 },
606 Some(required_pages),
607 CoordinateFrame::UserSpace,
608 )?;
609 selected_items.extend(context_items);
610 selected_rects.extend(context_rects);
611 selected_lines.extend(context_lines);
612 page_thresholds.extend(context_thresholds);
613 gid_encoded_pages.extend(context_gid_pages);
614 page_rotations.extend(context_rotations);
615 Ok((
616 (selected_items, selected_rects, selected_lines),
617 page_thresholds,
618 gid_encoded_pages,
619 page_rotations,
620 cmap_coverage,
621 ))
622}
623
624pub(crate) fn extract_positioned_text_for_document_analysis(
627 doc: &Document,
628 font_cmaps: &FontCMaps,
629 required_pages: &HashSet<u32>,
630) -> Result<DocumentExtraction, PdfError> {
631 extract_positioned_text_impl(
632 doc,
633 font_cmaps,
634 None,
635 TextExtractionOptions::default(),
636 Some(required_pages),
637 CoordinateFrame::UserSpace,
638 )
639}
640
641fn extract_positioned_text_impl(
642 doc: &Document,
643 font_cmaps: &FontCMaps,
644 page_filter: Option<&HashSet<u32>>,
645 options: TextExtractionOptions,
646 required_pages: Option<&HashSet<u32>>,
647 frame: CoordinateFrame,
648) -> Result<DocumentExtraction, PdfError> {
649 let pages = doc.get_pages();
650 let mut all_items = Vec::new();
651 let mut all_rects = Vec::new();
652 let mut all_lines = Vec::new();
653 let mut page_thresholds: PageThresholds = HashMap::new();
654 let mut gid_encoded_pages: HashSet<u32> = HashSet::new();
655 let mut cmap_coverage = CMapCoverageByFont::new();
657 let mut style_cache = FontStyleCache::new();
660 let mut page_rotations: PageRotations = HashMap::new();
663 let mut page_boxes: HashMap<u32, PageBox> = HashMap::new();
665
666 let page_id_to_num: HashMap<ObjectId, u32> =
668 pages.iter().map(|(num, &id)| (id, *num)).collect();
669
670 for (page_num, &page_id) in pages.iter() {
671 if let Some(filter) = page_filter {
672 if !filter.contains(page_num) {
673 continue;
674 }
675 }
676 let page_result = extract_page_text_items_with_options(
677 doc,
678 page_id,
679 *page_num,
680 font_cmaps,
681 options,
682 &mut style_cache,
683 &mut FormWalkBudget::new(),
684 );
685 let (
686 (mut items, mut rects, mut lines),
687 has_gid_fonts,
688 coords_rotated,
689 _skipped_invisible,
690 mut run_coverage,
691 ) = match page_result {
692 Ok(extraction) => extraction,
693 Err(error) if required_pages.is_some_and(|required| !required.contains(page_num)) => {
694 debug!(
695 "page {}: skipping context-only extraction error: {}",
696 page_num, error
697 );
698 continue;
699 }
700 Err(error) => return Err(error),
701 };
702 if coords_rotated != geometry::PageRotation::Upright {
703 page_rotations.insert(*page_num, coords_rotated);
704 }
705 let page_box = visible_page_box(doc, page_id);
711 let mut clipped_box: Option<(f32, f32, f32, f32)> = None;
712 let clip_box = page_box.map(|b| {
713 let (mut x, mut y, mut w, mut h) = (b.x0, b.y0, b.x1 - b.x0, b.y1 - b.y0);
714 coords_rotated.rotate_box(&mut x, &mut y, &mut w, &mut h);
715 (x, y, x + w, y + h)
716 });
717 if let Some((bx0, by0, bx1, by1)) = clip_box {
718 const TOL: f32 = 6.0;
719 let outside = |it: &TextItem| {
720 let cx = it.x + it.width / 2.0;
721 !(cx >= bx0 - TOL && cx <= bx1 + TOL && it.y >= by0 - TOL && it.y <= by1 + TOL)
722 };
723 let off: Vec<&TextItem> = items.iter().filter(|it| outside(it)).collect();
728 let total_chars: usize = off.iter().map(|it| it.text.trim().chars().count()).sum();
732 let wordy_chars: usize = off
733 .iter()
734 .map(|it| it.text.trim().chars().count())
735 .filter(|&n| n >= 4)
736 .sum();
737 let straddles = off.iter().any(|o| {
742 items.iter().any(|i| {
743 !outside(i) && (i.y - o.y).abs() <= 2.0 && (o.x - (i.x + i.width)).abs() <= 10.0
744 })
745 });
746 let coherent = off.len() >= 10 && wordy_chars * 2 >= total_chars.max(1) && !straddles;
747 if bx1 - bx0 >= 72.0 && by1 - by0 >= 72.0 && coherent {
748 let before = items.len();
749 items.retain(|it| !outside(it));
750 run_coverage.retain(|run| match run.position {
753 Some((x, y, width)) => {
754 let cx = x + width / 2.0;
755 cx >= bx0 - TOL && cx <= bx1 + TOL && y >= by0 - TOL && y <= by1 + TOL
756 }
757 None => true,
758 });
759 if items.len() < before {
760 debug!(
761 "page {}: clipped {} items outside page box ({:.0},{:.0})-({:.0},{:.0})",
762 page_num,
763 before - items.len(),
764 bx0,
765 by0,
766 bx1,
767 by1
768 );
769 let overlaps = |x: f32, y: f32, w: f32, h: f32| {
772 let (x0, x1) = if w < 0.0 { (x + w, x) } else { (x, x + w) };
773 let (y0, y1) = if h < 0.0 { (y + h, y) } else { (y, y + h) };
774 x0 < bx1 + TOL && x1 > bx0 - TOL && y0 < by1 + TOL && y1 > by0 - TOL
775 };
776 rects.retain(|r| overlaps(r.x, r.y, r.width, r.height));
777 clipped_box = Some((bx0, by0, bx1, by1));
778 lines.retain(|l| {
779 overlaps(
780 l.x1.min(l.x2),
781 l.y1.min(l.y2),
782 (l.x2 - l.x1).abs(),
783 (l.y2 - l.y1).abs(),
784 )
785 });
786 }
787 }
788 }
789 if has_gid_fonts {
790 gid_encoded_pages.insert(*page_num);
791 }
792 for RunCoverage { font, stats, .. } in run_coverage {
793 match cmap_coverage.get_mut(&*font) {
794 Some(total) => total.add(stats),
795 None => {
796 cmap_coverage.insert(font.to_string(), stats);
797 }
798 }
799 }
800 let threshold = crate::text_utils::fix_letterspaced_items(&mut items);
801 if threshold > 0.10 {
802 page_thresholds.insert(*page_num, threshold);
803 }
804 suppress_table_underlines(&mut items, &rects, &lines, *page_num);
805 debug!(
806 "page {}: {} text items, {} rects, {} lines{}",
807 page_num,
808 items.len(),
809 rects.len(),
810 lines.len(),
811 if has_gid_fonts {
812 " [gid-encoded fonts]"
813 } else {
814 ""
815 }
816 );
817 if log::log_enabled!(log::Level::Trace) {
818 for item in &items {
819 log::trace!(
820 " p={} x={:7.1} y={:7.1} w={:7.1} fs={:5.1} font={:6} {:?}",
821 page_num,
822 item.x,
823 item.y,
824 item.width,
825 item.font_size,
826 item.font,
827 trace_text_preview(&item.text, 80)
828 );
829 }
830 }
831 let frame_box = page_box.unwrap_or(PageBox::LETTER);
834 if frame == CoordinateFrame::VisiblePageBox {
835 frame_box.translate_page(&mut items, &mut rects, &mut lines, coords_rotated);
836 }
837 page_boxes.insert(*page_num, frame_box);
838 all_items.extend(items);
839 all_rects.extend(rects);
840 all_lines.extend(lines);
841
842 let mut links = extract_page_links(doc, page_id, *page_num);
846 for link in &mut links {
847 coords_rotated.rotate_box(&mut link.x, &mut link.y, &mut link.width, &mut link.height);
848 }
849 if let Some((bx0, by0, bx1, by1)) = clipped_box {
851 links.retain(|it| {
852 let cx = it.x + it.width / 2.0;
853 let cy = it.y + it.height / 2.0;
857 cx >= bx0 - 6.0 && cx <= bx1 + 6.0 && cy >= by0 - 6.0 && cy <= by1 + 6.0
858 });
859 }
860 if frame == CoordinateFrame::VisiblePageBox {
861 frame_box.translate_items(&mut links, coords_rotated);
864 }
865 all_items.extend(links);
866 }
867
868 let form_items: Vec<TextItem> = extract_form_fields(doc, &page_id_to_num)
872 .into_iter()
873 .filter(|item| page_filter.is_none_or(|filter| filter.contains(&item.page)))
874 .map(|mut item| {
875 let rotation = page_rotations
876 .get(&item.page)
877 .copied()
878 .unwrap_or(geometry::PageRotation::Upright);
879 rotation.rotate_box(&mut item.x, &mut item.y, &mut item.width, &mut item.height);
880 if frame == CoordinateFrame::VisiblePageBox {
881 let frame_box = page_boxes.get(&item.page).copied().unwrap_or_else(|| {
882 pages
883 .get(&item.page)
884 .and_then(|&id| visible_page_box(doc, id))
885 .unwrap_or(PageBox::LETTER)
886 });
887 frame_box.translate_items(std::slice::from_mut(&mut item), rotation);
888 }
889 item
890 })
891 .collect();
892 all_items.extend(form_items);
893
894 Ok((
895 (all_items, all_rects, all_lines),
896 page_thresholds,
897 gid_encoded_pages,
898 page_rotations,
899 cmap_coverage,
900 ))
901}
902
903fn suppress_table_underlines(
904 items: &mut [TextItem],
905 rects: &[PdfRect],
906 lines: &[PdfLine],
907 page: u32,
908) {
909 if !items
910 .iter()
911 .any(|item| item.is_underline || item.is_strikeout)
912 {
913 return;
914 }
915
916 let mut table_item_indices: HashSet<usize> = HashSet::new();
917 let plausible = |table: &crate::tables::Table| {
924 let lens: Vec<usize> = table
932 .cells
933 .iter()
934 .flatten()
935 .filter(|cell| !cell.trim().is_empty())
936 .map(|cell| cell.chars().count())
937 .collect();
938 if lens.is_empty() {
939 return false;
940 }
941 let long = lens.iter().filter(|&&n| n > 100).count();
942 (long as f32) < (lens.len() as f32) * 0.3
943 };
944
945 if !rects.is_empty() {
946 let (rect_tables, _) = crate::tables::detect_tables_from_rects(items, rects, page);
947 for table in rect_tables.iter().filter(|t| plausible(t)) {
948 table_item_indices.extend(table.item_indices.iter().copied());
949 }
950 }
951
952 if !lines.is_empty() {
953 for table in crate::tables::detect_tables_from_lines(items, lines, page)
954 .iter()
955 .filter(|t| plausible(t))
956 {
957 table_item_indices.extend(table.item_indices.iter().copied());
958 }
959 }
960
961 for index in table_item_indices {
962 if let Some(item) = items.get_mut(index) {
963 item.is_underline = false;
964 item.is_strikeout = false;
965 }
966 }
967}
968
969pub(crate) fn is_text_layout_item(item: &crate::types::TextItem) -> bool {
983 !matches!(item.item_type, crate::types::ItemType::Image)
984}
985
986fn apply_ctm_point(ctm: &[f32; 6], u: f32, v: f32) -> (f32, f32) {
990 (
991 u * ctm[0] + v * ctm[2] + ctm[4],
992 u * ctm[1] + v * ctm[3] + ctm[5],
993 )
994}
995
996pub(crate) fn image_bbox_from_ctm(ctm: &[f32; 6]) -> (f32, f32, f32, f32) {
1011 let corners = [
1012 apply_ctm_point(ctm, 0.0, 0.0),
1013 apply_ctm_point(ctm, 1.0, 0.0),
1014 apply_ctm_point(ctm, 1.0, 1.0),
1015 apply_ctm_point(ctm, 0.0, 1.0),
1016 ];
1017 let (mut x_min, mut x_max) = (corners[0].0, corners[0].0);
1018 let (mut y_min, mut y_max) = (corners[0].1, corners[0].1);
1019 for (cx, cy) in corners.iter().skip(1) {
1020 if *cx < x_min {
1021 x_min = *cx;
1022 }
1023 if *cx > x_max {
1024 x_max = *cx;
1025 }
1026 if *cy < y_min {
1027 y_min = *cy;
1028 }
1029 if *cy > y_max {
1030 y_max = *cy;
1031 }
1032 }
1033 (x_min, y_min, x_max - x_min, y_max - y_min)
1034}
1035
1036pub(crate) fn multiply_matrices(m1: &[f32; 6], m2: &[f32; 6]) -> [f32; 6] {
1042 [
1043 m1[0] * m2[0] + m1[1] * m2[2],
1044 m1[0] * m2[1] + m1[1] * m2[3],
1045 m1[2] * m2[0] + m1[3] * m2[2],
1046 m1[2] * m2[1] + m1[3] * m2[3],
1047 m1[4] * m2[0] + m1[5] * m2[2] + m2[4],
1048 m1[4] * m2[1] + m1[5] * m2[3] + m2[5],
1049 ]
1050}
1051
1052fn order_extent(item: &TextItem) -> f32 {
1073 if item.advance_known {
1074 effective_merge_width(item)
1075 } else {
1076 item.width
1077 }
1078}
1079
1080fn effective_merge_width(item: &TextItem) -> f32 {
1081 use crate::text_utils::is_cjk_char;
1082
1083 if !item.advance_known {
1087 return item.width;
1088 }
1089 if item.width <= 0.0 || item.font_size <= 0.0 {
1090 return item.width;
1091 }
1092 if !item.text.contains(' ') {
1094 return item.width;
1095 }
1096 if item.text.chars().any(is_cjk_char) {
1098 return item.width;
1099 }
1100 let char_count = item.text.chars().count();
1101 if char_count == 0 {
1102 return item.width;
1103 }
1104 let avg = item.width / char_count as f32;
1105 if avg > item.font_size * 0.85 {
1108 let capped = char_count as f32 * item.font_size * 0.6;
1109 capped.min(item.width)
1110 } else {
1111 item.width
1112 }
1113}
1114
1115const ZERO_WIDTH_MARK_EM: f32 = 0.01;
1118
1119const ZERO_WIDTH_MARK_MAX_CHARS: usize = 2;
1123
1124fn is_zero_width_mark(item: &TextItem) -> bool {
1130 let text = item.text.trim();
1131 if !(1..=ZERO_WIDTH_MARK_MAX_CHARS).contains(&text.chars().count())
1132 || !matches!(item.item_type, crate::types::ItemType::Text)
1133 {
1134 return false;
1135 }
1136 (item.advance_known && item.width.abs() <= item.font_size.abs() * ZERO_WIDTH_MARK_EM)
1137 || text.chars().all(crate::bidi::is_combining_mark)
1138}
1139
1140fn inside_advance(item: &TextItem, x: f32) -> bool {
1143 let end = item.x + item.width;
1144 x >= item.x.min(end) && x < item.x.max(end)
1145}
1146
1147fn sort_along_x_keeping_marks(group: &mut [&TextItem]) {
1154 let mut keys: Vec<f32> = Vec::with_capacity(group.len());
1155 let mut previous_base: Option<usize> = None;
1158 for (index, item) in group.iter().enumerate() {
1159 let base = previous_base
1160 .filter(|&base| is_zero_width_mark(item) && inside_advance(group[base], item.x));
1161 keys.push(base.map_or(item.x, |base| keys[base]));
1162 previous_base = Some(base.unwrap_or(index));
1163 }
1164 let mut order: Vec<usize> = (0..group.len()).collect();
1165 order.sort_by(|&a, &b| keys[a].total_cmp(&keys[b]));
1166 let sorted: Vec<&TextItem> = order.iter().map(|&index| group[index]).collect();
1167 group.copy_from_slice(&sorted);
1168}
1169
1170fn is_standalone_bullet_text(text: &str) -> bool {
1171 matches!(text.trim(), "•" | "○" | "●" | "◦")
1172}
1173
1174fn first_text_char(text: &str) -> Option<char> {
1175 text.trim_start().chars().next()
1176}
1177
1178fn is_short_alpha_fragment(text: &str) -> bool {
1179 let trimmed = text.trim();
1180 let char_count = trimmed.chars().count();
1181 (1..=4).contains(&char_count) && trimmed.chars().all(char::is_alphabetic)
1182}
1183
1184fn has_phrase_continuation_shape(text: &str) -> bool {
1185 let trimmed = text.trim_start();
1186 trimmed
1187 .chars()
1188 .take(24)
1189 .any(|ch| ch.is_whitespace() || matches!(ch, '-'))
1190}
1191
1192const LINE_BASELINE_TOLERANCE_EM: f32 = 0.6;
1195
1196const LINE_BASELINE_TOLERANCE_MAX_PT: f32 = 5.0;
1201
1202const LINE_BASELINE_LEGACY_EM: f32 = LINE_BASELINE_TOLERANCE_MAX_PT / LINE_BASELINE_TOLERANCE_EM;
1205
1206fn line_em(item: &TextItem) -> f32 {
1212 let em = item.cross_extent();
1213 if matches!(item.item_type, crate::types::ItemType::Image) {
1214 LINE_BASELINE_LEGACY_EM
1215 } else if em > 0.0 {
1216 em
1217 } else if item.font_size > 0.0 {
1218 item.font_size
1219 } else {
1220 LINE_BASELINE_LEGACY_EM
1221 }
1222}
1223
1224fn baseline_tolerance(a: f32, b: f32) -> f32 {
1240 (LINE_BASELINE_TOLERANCE_EM * a.max(b))
1241 .min(a.min(b))
1242 .min(LINE_BASELINE_TOLERANCE_MAX_PT)
1243}
1244
1245const LINE_BASELINE_RECORD_SPACING: f32 = 0.05;
1248
1249const LINE_BASELINES_MAX: usize =
1255 (2.0 * LINE_BASELINE_TOLERANCE_MAX_PT / LINE_BASELINE_RECORD_SPACING) as usize;
1256
1257struct FragmentLine {
1264 page: u32,
1265 y: f32,
1266 baselines: Vec<(f32, f32)>,
1267 fragments: Vec<usize>,
1269}
1270
1271impl FragmentLine {
1272 fn admits(&self, page: u32, y: f32, em: f32) -> bool {
1283 page == self.page
1284 && (y - self.y).abs() < LINE_BASELINE_TOLERANCE_MAX_PT
1285 && self
1286 .baselines
1287 .iter()
1288 .any(|&(line_y, line_em)| (y - line_y).abs() < baseline_tolerance(em, line_em))
1289 }
1290
1291 fn push(&mut self, index: usize, item: &TextItem, em: f32) {
1292 let known = self
1293 .baselines
1294 .iter()
1295 .position(|&(line_y, _)| (line_y - item.y).abs() < LINE_BASELINE_RECORD_SPACING);
1296 match known {
1297 Some(i) => self.baselines[i].1 = self.baselines[i].1.max(em),
1298 None if self.baselines.len() < LINE_BASELINES_MAX => {
1299 self.baselines.push((item.y, em));
1300 }
1301 None => {}
1302 }
1303 self.fragments.push(index);
1304 }
1305}
1306
1307fn group_indices_into_lines(items: &[TextItem]) -> Vec<Vec<usize>> {
1317 let mut lines: Vec<FragmentLine> = Vec::new();
1318 for (index, item) in items.iter().enumerate() {
1319 let em = line_em(item);
1320 match lines
1321 .iter_mut()
1322 .find(|line| line.admits(item.page, item.y, em))
1323 {
1324 Some(line) => line.push(index, item, em),
1325 None => lines.push(FragmentLine {
1326 page: item.page,
1327 y: item.y,
1328 baselines: vec![(item.y, em)],
1329 fragments: vec![index],
1330 }),
1331 }
1332 }
1333 lines.into_iter().map(|line| line.fragments).collect()
1334}
1335
1336fn group_fragments_into_lines(items: &[TextItem]) -> Vec<(u32, f32, Vec<&TextItem>)> {
1339 group_indices_into_lines(items)
1340 .into_iter()
1341 .map(|indices| {
1342 let first = &items[indices[0]];
1343 (
1344 first.page,
1345 first.y,
1346 indices.iter().map(|&i| &items[i]).collect(),
1347 )
1348 })
1349 .collect()
1350}
1351
1352fn should_preserve_overlapping_stream_order(group: &[&TextItem]) -> bool {
1353 if group.len() < 3 {
1354 return false;
1355 }
1356
1357 let Some(first) = group.iter().find(|item| !item.text.trim().is_empty()) else {
1358 return false;
1359 };
1360 if group.iter().all(|item| item.mcid.is_none()) {
1361 return false;
1362 }
1363
1364 let mut nonempty_count = 0;
1365 let mut saw_backtrack = false;
1366 let mut nonspace_chars = 0;
1367 let mut math_symbol_chars = 0;
1368 let mut max_font_size = first.font_size;
1369
1370 for item in group {
1371 if !item.text.trim().is_empty() {
1372 nonempty_count += 1;
1373 }
1374 if (item.font_size - first.font_size).abs() > first.font_size * 0.25 {
1375 return false;
1376 }
1377 max_font_size = max_font_size.max(item.font_size);
1378 for ch in item.text.chars().filter(|ch| !ch.is_whitespace()) {
1379 nonspace_chars += 1;
1380 if matches!(
1381 ch,
1382 '*' | 'ˆ' | '^' | '=' | '+' | '_' | '[' | ']' | '{' | '}' | '|' | '<' | '>'
1383 ) {
1384 math_symbol_chars += 1;
1385 }
1386 }
1387 }
1388
1389 if nonempty_count < 2 {
1390 return false;
1391 }
1392 if nonspace_chars > 0 && math_symbol_chars * 4 > nonspace_chars {
1393 return false;
1394 }
1395
1396 let mut sorted_by_x = group.to_vec();
1397 sorted_by_x.sort_by(|a, b| a.x.total_cmp(&b.x));
1398 let cluster_start = sorted_by_x[0].x;
1399 let mut cluster_end = cluster_start + order_extent(sorted_by_x[0]);
1400 for item in sorted_by_x.iter().skip(1) {
1401 let gap = item.x - cluster_end;
1402 if gap > max_font_size * 2.5 {
1403 return false;
1404 }
1405 cluster_end = cluster_end.max(item.x + order_extent(item));
1406 }
1407 if cluster_end - cluster_start > max_font_size * 36.0 {
1408 return false;
1409 }
1410
1411 for index in 0..group.len() - 1 {
1412 let previous = group[index];
1413 let next = group[index + 1];
1414 let font_size = previous.font_size.max(next.font_size);
1415 let backtrack_threshold = font_size * 0.25;
1416 let previous_start = previous.x;
1417 let next_start = next.x;
1418 let next_end = next.x + order_extent(next);
1419 if next_start < previous_start - backtrack_threshold
1420 && next_end > previous_start + backtrack_threshold
1421 {
1422 let has_near_prefix = group[..=index].iter().rev().take(4).any(|item| {
1423 is_short_alpha_fragment(&item.text)
1424 && item.x >= next_start - font_size * 0.5
1425 && item.x <= next_start + font_size * 4.0
1426 });
1427 let starts_lowercase = first_text_char(&next.text).is_some_and(char::is_lowercase);
1428 let phrase_continuation = has_phrase_continuation_shape(&next.text);
1429 let has_near_bullet = group[..=index]
1430 .iter()
1431 .position(|item| {
1432 is_standalone_bullet_text(&item.text) && next_start <= item.x + font_size * 3.0
1433 })
1434 .is_some_and(|bullet_index| {
1435 if bullet_index >= index {
1436 return false;
1437 }
1438 group[bullet_index + 1..=index]
1439 .iter()
1440 .rev()
1441 .find(|item| !item.text.trim().is_empty())
1442 .is_some_and(|item| {
1443 item.text.trim().chars().count() <= 8
1444 && has_phrase_continuation_shape(&next.text)
1445 })
1446 });
1447 if (has_near_prefix && starts_lowercase && phrase_continuation) || has_near_bullet {
1448 saw_backtrack = true;
1449 break;
1450 }
1451 }
1452 }
1453
1454 saw_backtrack
1455}
1456
1457pub(crate) fn is_spaceless_cjk(c: char) -> bool {
1473 matches!(c,
1474 '\u{3000}'..='\u{303F}' | '\u{3040}'..='\u{309F}' | '\u{30A0}'..='\u{30FF}' | '\u{4E00}'..='\u{9FFF}' | '\u{F900}'..='\u{FAFF}' | '\u{FF00}'..='\u{FFEF}' )
1481}
1482
1483fn tracked_run_space_floor(group: &[&TextItem], start: usize) -> Option<(usize, f32)> {
1484 const MIN_GAPS: usize = 4;
1485 let first = group[start];
1486 if first.text.trim().chars().count() != 1 {
1487 return None;
1488 }
1489 let fs = first.font_size;
1490 if fs <= 0.0 {
1491 return None;
1492 }
1493
1494 let mut gaps: Vec<f32> = Vec::new();
1497 let mut end_x = first.x + effective_merge_width(first);
1498 let mut end = start;
1499 for (offset, next) in group[start + 1..].iter().enumerate() {
1500 if (next.font_size - fs).abs() > fs * 0.20 {
1501 break;
1502 }
1503 if next.is_bold != first.is_bold
1504 || next.is_italic != first.is_italic
1505 || next.is_underline != first.is_underline
1506 || next.is_strikeout != first.is_strikeout
1507 {
1508 break;
1509 }
1510 let next_end = next.x + effective_merge_width(next);
1511 if is_zero_width_mark(next) {
1516 end_x = end_x.max(next_end);
1517 end = start + 1 + offset;
1518 continue;
1519 }
1520 if next.text.trim().chars().count() != 1 {
1521 break;
1522 }
1523 let gap = next.x - end_x;
1524 if gap > fs * 0.5 || gap < -fs * 0.5 {
1525 break;
1526 }
1527 gaps.push(gap / fs);
1528 end_x = next_end;
1529 end = start + 1 + offset;
1530 }
1531 if gaps.len() < 2 {
1532 return None;
1533 }
1534
1535 let mut sorted = gaps.clone();
1542 sorted.sort_by(|a, b| a.total_cmp(b));
1543 let median = sorted[sorted.len() / 2];
1544 let run_chars = || {
1550 group[start..=end]
1551 .iter()
1552 .flat_map(|it| it.text.trim().chars())
1553 };
1554 let spaceless_cjk = run_chars().all(|c| is_spaceless_cjk(c) || !c.is_alphanumeric())
1555 && run_chars().any(is_spaceless_cjk);
1556 let all_caps = run_chars().all(|c| c.is_uppercase() || is_cjk_char(c) || !c.is_alphabetic());
1557 if !(spaceless_cjk || all_caps) {
1558 return None;
1559 }
1560
1561 if gaps.len() >= MIN_GAPS {
1562 if median <= 0.075 {
1563 return None;
1564 }
1565 } else {
1566 let uniform = sorted[sorted.len() - 1] <= sorted[0].max(0.01) * 1.4;
1567 if median < 0.09 || !uniform {
1568 return None;
1569 }
1570 }
1571
1572 if spaceless_cjk {
1576 return Some((end, f32::INFINITY));
1577 }
1578
1579 let mut best_jump = 1.0f32;
1582 let mut floor = f32::INFINITY;
1583 for pair in sorted.windows(2) {
1584 let (lo, hi) = (pair[0].max(0.01), pair[1].max(0.01));
1585 let jump = hi / lo;
1586 if jump > best_jump {
1587 best_jump = jump;
1588 floor = (lo + hi) / 2.0;
1589 }
1590 }
1591 if best_jump < 1.4 {
1592 floor = f32::INFINITY;
1593 }
1594 Some((end, floor * fs))
1595}
1596
1597const MERGE_FONT_SIZE_BAND: f32 = 0.20;
1601
1602fn is_small_caps_continuation(
1620 text_so_far: &str,
1621 first: &TextItem,
1622 next: &TextItem,
1623 gap: f32,
1624) -> bool {
1625 if first.font_size <= 0.0 || next.font_size >= first.font_size {
1628 return false;
1629 }
1630 if (next.font_size - first.font_size).abs() <= first.font_size * MERGE_FONT_SIZE_BAND {
1634 return false;
1635 }
1636 if next.font_size / first.font_size < 0.55 {
1637 return false;
1638 }
1639 if !(-first.font_size * 0.2..=first.font_size * 0.15).contains(&gap) {
1642 return false;
1643 }
1644 let mut saw_letter = false;
1647 for ch in next.text.chars() {
1648 if ch.is_alphabetic() {
1649 saw_letter = true;
1650 if !ch.is_uppercase() {
1651 return false;
1652 }
1653 } else if ch.is_numeric() {
1654 return false;
1655 }
1656 }
1657 if !saw_letter {
1658 return false;
1659 }
1660 let trimmed = text_so_far.trim_end();
1665 if trimmed.chars().last().is_some_and(|c| c.is_numeric()) {
1666 return matches!(trimmed_suffix(next), "TH" | "ST" | "ND" | "RD");
1670 }
1671 trimmed
1672 .chars()
1673 .rev()
1674 .find(|c| c.is_alphabetic())
1675 .is_some_and(|c| c.is_uppercase())
1676}
1677
1678fn trimmed_suffix(next: &TextItem) -> &str {
1680 next.text.trim()
1681}
1682
1683fn combining_mark_of_spacing_accent(c: char) -> Option<char> {
1689 Some(match c {
1690 '\u{00AF}' => '\u{0304}', '\u{00B4}' => '\u{0301}', '\u{0060}' => '\u{0300}', '\u{02C6}' => '\u{0302}', '\u{02DC}' => '\u{0303}', '\u{00A8}' => '\u{0308}', '\u{02C7}' => '\u{030C}', '\u{02D8}' => '\u{0306}', '\u{02DA}' => '\u{030A}', '\u{00B8}' => '\u{0327}', '\u{02DB}' => '\u{0328}', '\u{02D9}' => '\u{0307}', '\u{02DD}' => '\u{030B}', _ => return None,
1704 })
1705}
1706
1707fn lone_spacing_accent(item: &TextItem) -> Option<char> {
1710 let mut chars = item.text.chars();
1711 let accent = chars.next()?;
1712 if chars.next().is_some() {
1713 return None;
1714 }
1715 combining_mark_of_spacing_accent(accent)
1716}
1717
1718#[derive(Clone, Copy)]
1721enum RunEnd {
1722 First,
1723 Last,
1724}
1725
1726fn end_glyph(text: &str, end: RunEnd) -> Option<(char, std::ops::Range<usize>, usize)> {
1730 let found = match end {
1731 RunEnd::First => text
1732 .char_indices()
1733 .enumerate()
1734 .find(|(_, (_, c))| !c.is_whitespace()),
1735 RunEnd::Last => text
1736 .char_indices()
1737 .rev()
1738 .enumerate()
1739 .find(|(_, (_, c))| !c.is_whitespace()),
1740 };
1741 found.map(|(skipped, (offset, c))| (c, offset..offset + c.len_utf8(), skipped))
1742}
1743
1744fn accent_overlap(accent: &TextItem, run: &TextItem, end: RunEnd) -> Option<f32> {
1767 let level_text = |item: &TextItem| {
1768 matches!(item.item_type, crate::types::ItemType::Text)
1769 && item.rotation == 0.0
1770 && item.advance_known
1771 && item.width > 0.0
1772 && !item.text.chars().any(crate::text_utils::is_rtl_char)
1773 };
1774 if run.page != accent.page
1775 || !level_text(run)
1776 || !level_text(accent)
1777 || run.font_size <= 0.0
1778 || (accent.y - run.y).abs() > run.font_size * 0.3
1779 {
1780 return None;
1781 }
1782 let (_, _, skipped) = end_glyph(&run.text, end)?;
1783 let em = run.font_size;
1784 let chars = run.text.chars().count();
1785 let spaces = run.text.chars().filter(|c| c.is_whitespace()).count();
1786 let space_advance = if run.fixed_pitch == Some(true) {
1787 run.width / chars as f32
1788 } else {
1789 em * 0.28
1790 };
1791 let ink = run.width - space_advance * spaces as f32;
1792 if ink <= 0.0 {
1793 return None;
1794 }
1795 let advance = ink / (chars - spaces) as f32;
1797 let window = advance.max(em * 0.6);
1798 let (left, right) = match end {
1799 RunEnd::First => {
1800 let left = run.x + space_advance * skipped as f32;
1801 (left, left + window)
1802 }
1803 RunEnd::Last => {
1804 let right = run.x + run.width - space_advance * skipped as f32;
1805 (right - window, right)
1806 }
1807 };
1808 let centre = accent.x + accent.width / 2.0;
1809 let play = accent.width * 0.25;
1810 if centre < left - play || centre > right + play {
1811 return None;
1812 }
1813 Some(((accent.x + accent.width).min(right) - accent.x.max(left)).max(0.0))
1814}
1815
1816fn compose_detached_spacing_accents<'a>(
1852 mut items: Vec<TextItem>,
1853 clips: &'a [Option<clip_boundaries::ClipRect>],
1854 replaced_text: &[bool],
1855) -> (Vec<TextItem>, Cow<'a, [Option<clip_boundaries::ClipRect>]>) {
1856 let replaced = |index: usize| replaced_text.get(index).copied().unwrap_or(false);
1857 let mut dropped: Vec<usize> = Vec::new();
1858 for i in 0..items.len() {
1859 if replaced(i) {
1860 continue;
1861 }
1862 let Some(mark) = lone_spacing_accent(&items[i]) else {
1863 continue;
1864 };
1865 let neighbours = [
1866 (i.checked_sub(1), RunEnd::Last),
1867 (
1868 Some(i + 1).filter(|&next| next < items.len()),
1869 RunEnd::First,
1870 ),
1871 ];
1872 let mut best: Option<(usize, std::ops::Range<usize>, char, f32)> = None;
1878 for (index, end) in neighbours {
1879 let Some(index) = index else {
1880 continue;
1881 };
1882 if replaced(index) || lone_spacing_accent(&items[index]).is_some() {
1883 continue;
1884 }
1885 let Some(overlap) = accent_overlap(&items[i], &items[index], end) else {
1886 continue;
1887 };
1888 let Some((glyph, range, _)) = end_glyph(&items[index].text, end) else {
1889 continue;
1890 };
1891 let base = match glyph {
1895 '\u{0131}' => 'i',
1896 '\u{0237}' => 'j',
1897 other => other,
1898 };
1899 let Some(composed) = unicode_normalization::char::compose(base, mark) else {
1900 continue;
1901 };
1902 if best
1903 .as_ref()
1904 .is_none_or(|(_, _, _, other)| overlap > *other)
1905 {
1906 best = Some((index, range, composed, overlap));
1907 }
1908 }
1909 let Some((index, range, composed, _)) = best else {
1910 continue;
1911 };
1912 items[index]
1913 .text
1914 .replace_range(range, composed.encode_utf8(&mut [0; 4]));
1915 dropped.push(i);
1916 }
1917 if dropped.is_empty() {
1918 return (items, Cow::Borrowed(clips));
1919 }
1920 let mut kept = vec![true; items.len()];
1921 for &index in &dropped {
1922 kept[index] = false;
1923 }
1924 let clips: Vec<Option<clip_boundaries::ClipRect>> = clips
1925 .iter()
1926 .zip(&kept)
1927 .filter(|(_, &keep)| keep)
1928 .map(|(clip, _)| *clip)
1929 .collect();
1930 let mut index = 0;
1931 items.retain(|_| {
1932 let keep = kept[index];
1933 index += 1;
1934 keep
1935 });
1936 (items, Cow::Owned(clips))
1937}
1938
1939#[cfg(test)]
1940pub(crate) fn merge_text_items(items: Vec<TextItem>) -> Vec<TextItem> {
1941 merge_text_items_with_clips(items, &[], false, &[])
1942}
1943
1944fn is_number_separator(c: char) -> bool {
1947 matches!(c, '.' | ',' | ':' | '/' | '\u{066B}' | '\u{066C}')
1948}
1949
1950fn numeric_fragment(text: &str) -> bool {
1953 let text = text.trim();
1954 text.chars().any(char::is_numeric)
1955 && text
1956 .chars()
1957 .all(|c| c.is_numeric() || is_number_separator(c))
1958}
1959
1960fn separator_fragment(text: &str) -> bool {
1963 let text = text.trim();
1964 !text.is_empty() && text.chars().all(is_number_separator)
1965}
1966
1967fn inside_number(a: &str, b: &str) -> bool {
1972 let material = |text: &str| numeric_fragment(text) || separator_fragment(text);
1973 material(a) && material(b) && (numeric_fragment(a) || numeric_fragment(b))
1974}
1975
1976fn glyph_run_word_gap_floor(gaps: &[f32]) -> Option<f32> {
1994 if gaps.len() < 3 {
1995 return None;
1996 }
1997 let mut sorted: Vec<f32> = gaps.to_vec();
1998 sorted.sort_by(|a, b| a.total_cmp(b));
1999 let total: f32 = sorted.iter().sum();
2000 let n = sorted.len() as f32;
2001 let mut best: Option<(f32, f32, f32)> = None; let mut low_sum = 0.0f32;
2003 for (k, &value) in sorted.iter().enumerate().take(sorted.len() - 1) {
2004 low_sum += value;
2005 let low_n = (k + 1) as f32;
2006 let high_n = n - low_n;
2007 let low_mean = low_sum / low_n;
2008 let high_mean = (total - low_sum) / high_n;
2009 let variance = low_n * high_n * (high_mean - low_mean).powi(2);
2010 if best.is_none_or(|(v, _, _)| variance > v) {
2011 best = Some((variance, low_mean, high_mean));
2012 }
2013 }
2014 let upper_quartile = sorted[sorted.len() * 3 / 4];
2015 match best {
2016 Some((_, low_mean, high_mean)) if high_mean - low_mean >= 0.12 && high_mean >= 0.2 => {
2017 Some((low_mean + high_mean) / 2.0)
2018 }
2019 _ if upper_quartile <= 0.13 => Some(f32::INFINITY),
2020 _ => None,
2021 }
2022}
2023
2024fn merge_text_items_with_clips(
2035 items: Vec<TextItem>,
2036 clips: &[Option<clip_boundaries::ClipRect>],
2037 visual_rtl: bool,
2038 replaced_text: &[bool],
2039) -> Vec<TextItem> {
2040 if items.is_empty() {
2041 return items;
2042 }
2043
2044 let (items, owned_clips) = compose_detached_spacing_accents(items, clips, replaced_text);
2048 let clips: &[Option<clip_boundaries::ClipRect>] = &owned_clips;
2049
2050 let clip_by_item: HashMap<*const TextItem, clip_boundaries::ClipRect> = items
2053 .iter()
2054 .zip(clips)
2055 .filter_map(|(item, clip)| clip.map(|rect| (item as *const TextItem, rect)))
2056 .collect();
2057
2058 let line_groups = group_fragments_into_lines(&items);
2059
2060 let page_rtl: HashMap<u32, bool> = items
2064 .iter()
2065 .map(|item| item.page)
2066 .collect::<std::collections::HashSet<u32>>()
2067 .into_iter()
2068 .map(|page| {
2069 let rtl = is_rtl_text(items.iter().filter(|i| i.page == page).map(|i| &i.text));
2070 (page, rtl)
2071 })
2072 .collect();
2073
2074 struct LineGroup<'a> {
2076 page: u32,
2077 y: f32,
2078 group: Vec<&'a TextItem>,
2079 texts: Vec<Cow<'a, str>>,
2082 preserve_stream_order: bool,
2083 bidi: bool,
2085 display_index: Vec<usize>,
2087 display_gaps: Vec<f32>,
2091 glyph_floor: Option<f32>,
2095 }
2096 let mut ordered_line_groups: Vec<LineGroup<'_>> = Vec::new();
2097
2098 for (page, y, mut group) in line_groups {
2101 let bidi = group
2104 .iter()
2105 .any(|i| i.text.chars().any(crate::text_utils::is_rtl_char));
2106 let preserve_stream_order = !bidi && should_preserve_overlapping_stream_order(&group);
2107 let texts: Vec<Cow<'_, str>>;
2108 let mut display_index: Vec<usize> = Vec::new();
2109 let mut display_gaps: Vec<f32> = Vec::new();
2110 let mut glyph_floor: Option<f32> = None;
2111 if bidi {
2112 let rtl_base = crate::text_utils::rtl_line_base(
2119 &group,
2120 |i| *i,
2121 page_rtl.get(&page).copied().unwrap_or(false),
2122 );
2123 sort_along_x_keeping_marks(&mut group);
2126 display_gaps = Vec::with_capacity(group.len().saturating_sub(1));
2133 let mut right_edge: Option<f32> = None;
2134 for item in &group {
2135 let end = item.x + effective_merge_width(item);
2136 if let Some(edge) = right_edge {
2137 display_gaps.push(item.x - edge);
2138 }
2139 right_edge = Some(match right_edge {
2140 Some(edge) if is_zero_width_mark(item) => edge.max(end),
2141 _ => end,
2142 });
2143 }
2144 let single_glyphs = group
2154 .iter()
2155 .filter(|i| i.text.trim().chars().count() == 1)
2156 .count();
2157 if group.len() >= 4 && single_glyphs * 10 >= group.len() * 7 {
2158 let gaps: Vec<f32> = group
2159 .windows(2)
2160 .zip(&display_gaps)
2161 .filter(|(pair, _)| {
2162 !inside_number(&pair[0].text, &pair[1].text)
2163 && !is_zero_width_mark(pair[0])
2164 && !is_zero_width_mark(pair[1])
2165 })
2166 .map(|(pair, gap)| gap / pair[0].font_size.min(pair[1].font_size).max(1.0))
2167 .collect();
2168 glyph_floor = glyph_run_word_gap_floor(&gaps);
2169 }
2170 let order = crate::bidi::logical_line_order(
2171 &group,
2172 |i| i.text.as_str(),
2173 |i| (i.x, i.width),
2174 |i| i.font_size,
2175 |_| visual_rtl,
2176 glyph_floor,
2177 rtl_base,
2178 );
2179 let reordered: Vec<&TextItem> = order.iter().map(|&(index, _)| group[index]).collect();
2180 display_index = order.iter().map(|&(index, _)| index).collect();
2181 texts = order
2182 .into_iter()
2183 .map(|(index, logical)| {
2184 if visual_rtl {
2185 Cow::Owned(logical)
2186 } else {
2187 Cow::Borrowed(group[index].text.as_str())
2188 }
2189 })
2190 .collect();
2191 group = reordered;
2192 } else {
2193 if !preserve_stream_order {
2194 sort_along_x_keeping_marks(&mut group);
2195 }
2196 texts = group
2197 .iter()
2198 .map(|i| Cow::Borrowed(i.text.as_str()))
2199 .collect();
2200 }
2201 ordered_line_groups.push(LineGroup {
2202 page,
2203 y,
2204 group,
2205 texts,
2206 preserve_stream_order,
2207 bidi,
2208 display_index,
2209 display_gaps,
2210 glyph_floor,
2211 });
2212 }
2213
2214 ordered_line_groups.sort_by(|a, b| a.page.cmp(&b.page).then_with(|| b.y.total_cmp(&a.y)));
2216
2217 let mut merged = Vec::new();
2218
2219 for line in &ordered_line_groups {
2220 let LineGroup {
2221 group,
2222 texts,
2223 preserve_stream_order,
2224 bidi,
2225 display_index,
2226 display_gaps,
2227 glyph_floor,
2228 ..
2229 } = line;
2230 let junction_gap = |from: usize, to: usize| -> f32 {
2240 let (a, b) = (display_index[from], display_index[to]);
2241 if a.abs_diff(b) == 1 {
2242 return display_gaps[a.min(b)];
2243 }
2244 let towards = |at: usize, other: usize| {
2245 if other > at {
2246 display_gaps.get(at).copied()
2247 } else {
2248 at.checked_sub(1).and_then(|k| display_gaps.get(k).copied())
2249 }
2250 };
2251 match (towards(a, b), towards(b, a)) {
2252 (Some(x), Some(y)) => x.max(y),
2253 (Some(x), None) | (None, Some(x)) => x,
2254 (None, None) => 0.0,
2255 }
2256 };
2257 let glyph_floor = *glyph_floor;
2258 let mut i = 0;
2259 while i < group.len() {
2260 let first = group[i];
2261 let mut text = texts[i].to_string();
2262 let mut legacy_symbol_rewrite = first.legacy_symbol_rewrite;
2263 let mut end_x = first.x + effective_merge_width(first);
2264 let mut box_right = first.x + first.width;
2265 let mut box_left = first.x;
2266
2267 let tracked = if *preserve_stream_order || *bidi {
2270 None
2271 } else {
2272 tracked_run_space_floor(group, i)
2273 };
2274
2275 let mut j = i + 1;
2276 while j < group.len() {
2277 let next = group[j];
2278 let next_text: &str = &texts[j];
2279 let gap = if *bidi {
2280 junction_gap(j - 1, j)
2281 } else {
2282 next.x - end_x
2283 };
2284 let small_caps_join = is_small_caps_continuation(&text, first, next, gap);
2287 if (next.font_size - first.font_size).abs() > first.font_size * MERGE_FONT_SIZE_BAND
2291 && !small_caps_join
2292 {
2293 break;
2294 }
2295 if next.is_italic != first.is_italic
2298 || next.is_underline != first.is_underline
2299 || next.is_strikeout != first.is_strikeout
2300 {
2301 break;
2302 }
2303 if !first.is_upright() || !next.is_upright() {
2310 break;
2311 }
2312 if next.advance_known != first.advance_known {
2315 break;
2316 }
2317 let x_gap_max = if *preserve_stream_order && is_standalone_bullet_text(&text) {
2318 first.font_size * 1.2
2319 } else {
2320 first.font_size * 0.5
2321 };
2322 if gap > x_gap_max {
2323 break;
2324 }
2325 if gap < -first.font_size * 0.5
2328 && !preserve_stream_order
2329 && !is_zero_width_mark(next)
2330 {
2331 break;
2332 }
2333 let previous = group[j - 1];
2334 if clip_boundaries::separated_runs(
2335 previous,
2336 clip_by_item.get(&(previous as *const TextItem)),
2337 next,
2338 clip_by_item.get(&(next as *const TextItem)),
2339 ) {
2340 break;
2341 }
2342 let digits = |t: &str| !t.is_empty() && t.chars().all(char::is_numeric);
2349 if !preserve_stream_order
2350 && (next.y - first.y).abs() > first.font_size * 0.3
2351 && gap < -effective_merge_width(next) * 0.5
2352 && digits(text.trim())
2353 && digits(next_text.trim())
2354 {
2355 break;
2356 }
2357 let threshold = {
2362 let prev_last = text.trim_end().chars().last();
2363 let next_first = next_text.trim_start().chars().next();
2364 if next_first.is_some_and(|c| matches!(c, '.' | ',' | ';' | ')' | ']' | '}')) {
2366 first.font_size * 0.25
2367 } else if prev_last.is_some_and(|c| c.is_lowercase())
2368 && next_first.is_some_and(|c| c.is_lowercase())
2369 {
2370 first.font_size * 0.13
2372 } else if prev_last.is_some_and(crate::text_utils::is_rtl_char)
2373 && next_first.is_some_and(crate::text_utils::is_rtl_char)
2374 {
2375 first.font_size * 0.13
2379 } else {
2380 first.font_size * 0.08
2381 }
2382 };
2383 let needs_bullet_space = *preserve_stream_order
2384 && is_standalone_bullet_text(&text)
2385 && !next_text.trim().is_empty();
2386 let effective_threshold = match (tracked, glyph_floor) {
2387 (Some((run_end, floor)), _) if j <= run_end => floor,
2388 (_, Some(floor_em)) => {
2389 floor_em * group[j - 1].font_size.min(next.font_size).max(1.0)
2390 }
2391 _ => threshold,
2392 };
2393 let bold_boundary = next.is_bold != first.is_bold;
2394 let explicit_bold_space = bold_boundary
2395 && (text.ends_with(char::is_whitespace)
2396 || next_text.starts_with(char::is_whitespace));
2397 let numeric_boundary = bold_boundary
2401 && match (text.chars().last(), next_text.chars().next()) {
2402 (Some(p), Some(c)) if p.is_ascii_digit() => {
2403 c.is_ascii_digit() || matches!(c, '.' | ',' | '%')
2404 }
2405 (Some('.' | ','), Some(c)) if c.is_ascii_digit() => {
2406 let prefix = &text[..text.len() - 1];
2407 prefix.trim().is_empty()
2410 || prefix.chars().last().is_some_and(|c| c.is_ascii_digit())
2411 }
2412 (Some('+' | '-'), Some(c)) => c.is_ascii_digit(),
2413 _ => false,
2414 };
2415 if !small_caps_join
2416 && (needs_bullet_space || (gap > effective_threshold && !numeric_boundary))
2417 && !explicit_bold_space
2418 && !text.ends_with(char::is_whitespace)
2419 {
2420 text.push(' ');
2421 }
2422 if bold_boundary {
2427 break;
2428 }
2429 text.push_str(next_text);
2430 legacy_symbol_rewrite |= next.legacy_symbol_rewrite;
2431 box_right = box_right.max(next.x + next.width);
2432 box_left = box_left.min(next.x);
2433 let next_end = next.x + effective_merge_width(next);
2434 end_x = if *preserve_stream_order || is_zero_width_mark(next) {
2439 end_x.max(next_end)
2440 } else {
2441 next_end
2442 };
2443 j += 1;
2444 }
2445
2446 let mut text = crate::bidi::normalize_presentation_forms(&text).into_owned();
2452 crate::bidi::strip_glyph_joiners(&mut text);
2453
2454 merged.push(TextItem {
2455 text,
2456 x: if first.advance_known && !*bidi {
2461 first.x
2462 } else {
2463 box_left
2464 },
2465 y: first.y,
2466 width: if first.advance_known && !*bidi {
2467 end_x - first.x
2468 } else {
2469 box_right - box_left
2470 },
2471 height: first.height,
2472 font: first.font.clone(),
2473 font_tag: first.font_tag.clone(),
2474 legacy_symbol_rewrite,
2475 font_size: first.font_size,
2476 page: first.page,
2477 is_bold: first.is_bold,
2478 is_italic: first.is_italic,
2479 font_weight: first.font_weight,
2480 bold_source: first.bold_source,
2481 fixed_pitch: first.fixed_pitch,
2482 fill_color: first.fill_color,
2483 stroke_color: first.stroke_color,
2484 render_mode: first.render_mode,
2485 is_underline: first.is_underline,
2486 is_strikeout: first.is_strikeout,
2487 rotation: first.rotation,
2488 advance_known: first.advance_known,
2489 item_type: first.item_type.clone(),
2490 mcid: first.mcid,
2491 baseline_shift: 0.0,
2492 });
2493
2494 i = j;
2495 }
2496 }
2497
2498 merged
2499}
2500
2501pub(crate) fn get_number(obj: &Object) -> Option<f32> {
2503 match obj {
2504 Object::Integer(i) => Some(*i as f32),
2505 Object::Real(r) => Some(*r),
2506 _ => None,
2507 }
2508}
2509
2510#[cfg(test)]
2511mod tests {
2512 use super::*;
2513 use crate::text_utils::{is_cjk_char, is_rtl_char, is_rtl_text, sort_line_items};
2514 use crate::types::{ItemType, PdfLine, TextLine};
2515 use layout::{detect_columns, is_newspaper_layout, ColumnRegion};
2516
2517 #[test]
2519 fn numeric_fragments_are_digits_of_any_script_with_their_separators() {
2520 assert!(numeric_fragment("12"));
2521 assert!(numeric_fragment(" 1,234.5 "));
2522 assert!(numeric_fragment("\u{0662}\u{0664}"));
2523 assert!(numeric_fragment("\u{0663}\u{066B}\u{0665}"));
2524 assert!(!numeric_fragment(""));
2525 assert!(!numeric_fragment("a1"));
2526 assert!(!numeric_fragment("\u{05D0}"));
2527 assert!(!numeric_fragment("-"));
2528 assert!(!numeric_fragment(","));
2531 assert!(separator_fragment(","));
2532 assert!(inside_number("21", ","));
2533 assert!(inside_number(",", "847"));
2534 assert!(inside_number("1", "2"));
2535 assert!(!inside_number(".", "."));
2536 assert!(!inside_number("a", "1"));
2537 assert!(!inside_number("1", "\u{05D0}"));
2538 }
2539
2540 #[test]
2541 fn glyph_run_word_gap_floor_splits_letter_gaps_from_word_gaps() {
2542 let gaps = [0.06, 0.08, 0.07, 0.34, 0.09, 0.06, 0.32, 0.08];
2545 let floor = glyph_run_word_gap_floor(&gaps).expect("two classes");
2546 assert!(floor > 0.09 && floor < 0.32, "{floor}");
2547 }
2548
2549 #[test]
2550 fn glyph_run_word_gap_floor_holds_one_word_together() {
2551 let gaps = [0.06, 0.09, 0.11, 0.07, 0.10];
2553 assert_eq!(glyph_run_word_gap_floor(&gaps), Some(f32::INFINITY));
2554 }
2555
2556 #[test]
2557 fn glyph_run_word_gap_floor_leaves_uniform_wide_gaps_to_the_thresholds() {
2558 let gaps = [0.24, 0.26, 0.25, 0.25];
2561 assert_eq!(glyph_run_word_gap_floor(&gaps), None);
2562 assert_eq!(glyph_run_word_gap_floor(&[0.05, 0.3]), None);
2564 }
2565
2566 fn glyph_run(chars: &str, start_x: f32, glyph_w: f32, gap: f32) -> Vec<TextItem> {
2567 let mut x = start_x;
2568 let mut out = Vec::new();
2569 for c in chars.chars() {
2570 out.push(make_merge_item(&c.to_string(), x, glyph_w));
2571 x += glyph_w + gap;
2572 }
2573 out
2574 }
2575
2576 #[test]
2577 fn tracked_caps_run_collapses_to_word() {
2578 let items = glyph_run("HOW", 100.0, 10.0, 2.3);
2581 let merged = merge_text_items(items);
2582 assert_eq!(merged.len(), 1);
2583 assert_eq!(merged[0].text, "HOW");
2584 }
2585
2586 #[test]
2587 fn tracked_run_keeps_word_gaps_bimodal() {
2588 let mut items = glyph_run("ITISOK", 100.0, 8.0, 2.3);
2592 for i in 2..6 {
2593 items[i].x += 2.8; }
2595 for i in 4..6 {
2596 items[i].x += 2.8; }
2598 let merged = merge_text_items(items);
2599 assert_eq!(merged.len(), 1);
2600 assert_eq!(merged[0].text, "IT IS OK");
2601 }
2602
2603 #[test]
2604 fn lowercase_spaced_singles_stay_words() {
2605 let items = glyph_run("xyz", 100.0, 6.0, 2.3);
2608 let merged = merge_text_items(items);
2609 assert_eq!(merged.len(), 1);
2610 assert_eq!(merged[0].text, "x y z");
2611 }
2612
2613 #[test]
2614 fn kerned_singles_unaffected() {
2615 let items = glyph_run("WORD", 100.0, 8.0, 0.3);
2617 let merged = merge_text_items(items);
2618 assert_eq!(merged.len(), 1);
2619 assert_eq!(merged[0].text, "WORD");
2620 }
2621
2622 #[test]
2623 fn long_lowercase_spaced_singles_keep_boundaries() {
2624 let items = glyph_run("abcde", 100.0, 6.0, 2.3);
2628 let merged = merge_text_items(items);
2629 assert_eq!(merged.len(), 1);
2630 assert_eq!(merged[0].text, "a b c d e");
2631 }
2632
2633 #[test]
2634 fn han_run_with_nonuniform_gaps_never_gains_spaces() {
2635 let mut items = glyph_run("北京时事快报", 100.0, 12.0, 1.4);
2638 for item in items.iter_mut().skip(3) {
2639 item.x += 3.0; }
2641 let merged = merge_text_items(items);
2642 assert_eq!(merged.len(), 1);
2643 assert_eq!(merged[0].text, "北京时事快报");
2644 }
2645
2646 #[test]
2647 fn uppercase_leading_spaced_singles_keep_boundaries() {
2648 let items = glyph_run("Abcde", 100.0, 7.0, 2.3);
2651 let merged = merge_text_items(items);
2652 assert_eq!(merged.len(), 1);
2653 assert_eq!(merged[0].text, "A b c d e");
2654 }
2655
2656 #[test]
2657 fn cjk_glyph_run_collapses_without_spaces() {
2658 let items = glyph_run("北京时事", 100.0, 12.0, 1.4);
2661 let merged = merge_text_items(items);
2662 assert_eq!(merged.len(), 1);
2663 assert_eq!(merged[0].text, "北京时事");
2664 }
2665
2666 fn make_merge_item(text: &str, x: f32, width: f32) -> TextItem {
2667 TextItem {
2668 text: text.into(),
2669 x,
2670 y: 700.0,
2671 width,
2672 height: 12.0,
2673 font: "F1".into(),
2674 font_tag: String::new(),
2675 legacy_symbol_rewrite: false,
2676 font_size: 12.0,
2677 page: 1,
2678 is_bold: false,
2679 is_italic: false,
2680 font_weight: None,
2681 bold_source: None,
2682 fixed_pitch: None,
2683 fill_color: None,
2684 stroke_color: None,
2685 render_mode: None,
2686 is_underline: false,
2687 is_strikeout: false,
2688 rotation: 0.0,
2689 advance_known: true,
2690 item_type: ItemType::Text,
2691 mcid: None,
2692 baseline_shift: 0.0,
2693 }
2694 }
2695
2696 #[test]
2697 fn explicit_trailing_space_is_not_doubled_across_a_word_gap() {
2698 let items = vec![
2701 make_merge_item("for ", 100.0, 21.6),
2702 make_merge_item("the", 125.6, 21.6),
2703 ];
2704 let merged = merge_text_items(items);
2705 assert_eq!(merged.len(), 1);
2706 assert_eq!(merged[0].text, "for the");
2707 }
2708
2709 fn with_mcid(mut item: TextItem) -> TextItem {
2710 item.mcid = Some(1);
2711 item
2712 }
2713
2714 fn make_line(x1: f32, y1: f32, x2: f32, y2: f32) -> PdfLine {
2715 PdfLine {
2716 x1,
2717 y1,
2718 x2,
2719 y2,
2720 page: 1,
2721 }
2722 }
2723
2724 #[test]
2725 fn trace_text_preview_truncates_on_char_boundary() {
2726 let text = format!("{}{}tail", "a".repeat(79), '\u{FFFD}');
2727 let preview = trace_text_preview(&text, 80);
2728
2729 assert_eq!(preview.chars().count(), 80);
2730 assert!(text.is_char_boundary(preview.len()));
2731 assert!(preview.ends_with('\u{FFFD}'));
2732 }
2733
2734 fn glyph_fragments(text: &str, x0: f32, y: f32, size: f32) -> Vec<TextItem> {
2737 let advance = |ch: char| -> f32 {
2738 let units = match ch {
2739 'A' | 'U' => 722.0,
2740 'E' | 'P' | 'S' => 667.0,
2741 'M' => 833.0,
2742 'O' => 778.0,
2743 'W' => 944.0,
2744 'd' | 'g' | 'n' | 'o' | 'p' => 611.0,
2745 'i' | 'l' => 278.0,
2746 'r' => 389.0,
2747 't' => 333.0,
2748 _ => 556.0,
2749 };
2750 units * size / 1000.0
2751 };
2752 let mut items = Vec::new();
2753 let mut x = x0;
2754 for ch in text.chars() {
2755 let w = advance(ch);
2756 if ch != ' ' {
2757 let mut item = make_merge_item(&ch.to_string(), x, w);
2758 item.y = y;
2759 item.height = size;
2760 item.font_size = size;
2761 items.push(item);
2762 }
2763 x += if ch == ' ' { 278.0 * size / 1000.0 } else { w };
2764 }
2765 items
2766 }
2767
2768 fn letters_by_baseline(merged: &[TextItem]) -> Vec<(f32, String)> {
2771 let mut lines: Vec<(f32, Vec<&TextItem>)> = Vec::new();
2772 for item in merged {
2773 match lines.iter_mut().find(|(y, _)| (*y - item.y).abs() < 0.01) {
2774 Some((_, line)) => line.push(item),
2775 None => lines.push((item.y, vec![item])),
2776 }
2777 }
2778 lines.sort_by(|a, b| b.0.total_cmp(&a.0));
2779 lines
2780 .into_iter()
2781 .map(|(y, mut line)| {
2782 line.sort_by(|a, b| a.x.total_cmp(&b.x));
2783 (y, line.iter().map(|i| i.text.replace(' ', "")).collect())
2784 })
2785 .collect()
2786 }
2787
2788 fn sized_item(text: &str, x: f32, width: f32, y: f32, size: f32) -> TextItem {
2789 let mut item = make_merge_item(text, x, width);
2790 item.y = y;
2791 item.height = size;
2792 item.font_size = size;
2793 item
2794 }
2795
2796 #[test]
2800 fn small_lines_under_five_points_apart_keep_their_own_lines() {
2801 let mut items = glyph_fragments("Apples Picked", 100.0, 700.0, 4.7);
2802 items.extend(glyph_fragments("Oranges Sold", 100.6, 695.5, 4.7));
2803 assert_eq!(
2804 letters_by_baseline(&merge_text_items(items)),
2805 vec![
2806 (700.0, "ApplesPicked".to_string()),
2807 (695.5, "OrangesSold".to_string())
2808 ]
2809 );
2810 let mut items = glyph_fragments("Water Usage", 100.0, 650.0, 4.7);
2811 items.extend(glyph_fragments("Energy Mix", 101.2, 644.0, 4.7));
2812 assert_eq!(
2813 letters_by_baseline(&merge_text_items(items)),
2814 vec![
2815 (650.0, "WaterUsage".to_string()),
2816 (644.0, "EnergyMix".to_string())
2817 ]
2818 );
2819 }
2820
2821 #[test]
2831 fn the_line_window_follows_the_type_size() {
2832 let name = sized_item("Huo", 100.0, 20.0, 700.0, 11.96);
2834 let marker = sized_item("1", 120.5, 4.0, 704.3, 7.97);
2835 assert_eq!(group_fragments_into_lines(&[name, marker]).len(), 1);
2836 let word = sized_item("word", 100.0, 16.0, 700.0, 8.0);
2838 let mark = sized_item("2", 116.2, 3.0, 703.0, 5.5);
2839 assert_eq!(group_fragments_into_lines(&[word, mark]).len(), 1);
2840 let base = sized_item("base", 100.0, 24.0, 500.0, 12.0);
2843 let raised = sized_item("super", 124.0, 30.0, 505.0, 12.0);
2844 assert_eq!(group_fragments_into_lines(&[base, raised]).len(), 2);
2845 let word = sized_item("word", 100.0, 24.0, 700.0, 12.0);
2847 let mark = sized_item("1", 124.2, 3.5, 704.0, 7.0);
2848 assert_eq!(group_fragments_into_lines(&[word, mark]).len(), 1);
2849 let heading = sized_item("Title", 100.0, 100.0, 700.0, 40.0);
2854 let body = sized_item("body", 100.0, 20.0, 684.0, 10.0);
2855 assert_eq!(group_fragments_into_lines(&[heading, body]).len(), 2);
2856 let line = sized_item("line", 100.0, 14.0, 700.0, 7.0);
2857 let tiny = sized_item("tiny", 100.0, 8.0, 695.5, 4.0);
2858 assert_eq!(group_fragments_into_lines(&[line, tiny]).len(), 2);
2859 let line = sized_item("line", 100.0, 24.0, 700.0, 12.0);
2860 let tiny = sized_item("tiny", 100.0, 8.0, 695.5, 4.0);
2861 assert_eq!(group_fragments_into_lines(&[line, tiny]).len(), 2);
2862 let sloped: Vec<TextItem> = (0..40)
2865 .map(|i| {
2866 sized_item(
2867 "g",
2868 100.0 + 6.0 * i as f32,
2869 6.0,
2870 700.0 + 0.1 * i as f32,
2871 10.0,
2872 )
2873 })
2874 .collect();
2875 assert_eq!(group_fragments_into_lines(&sloped).len(), 1);
2876 let text = sized_item("text", 100.0, 20.0, 700.0, 10.0);
2881 let mut rule = sized_item("[Image]", 100.0, 200.0, 697.0, 2.0);
2882 rule.item_type = ItemType::Image;
2883 rule.font_size = 0.0;
2884 assert_eq!(group_fragments_into_lines(&[text, rule]).len(), 1);
2885 let mut figure = sized_item("[Image]", 28.7, 564.0, 435.1, 317.5);
2886 figure.item_type = ItemType::Image;
2887 figure.font_size = 0.0;
2888 let upper = sized_item("materials", 87.9, 20.0, 434.68, 0.55);
2889 let lower = sized_item("respective", 29.4, 20.0, 434.27, 0.55);
2890 let beside_figure = [figure, upper, lower];
2891 let lines = group_fragments_into_lines(&beside_figure);
2892 assert_eq!(lines.len(), 2);
2893 assert_eq!(lines[1].2.len(), 1);
2894 let bullet = sized_item("•", 90.0, 3.0, 700.0, 3.0);
2898 let text = sized_item("text", 100.0, 20.0, 700.0, 10.0);
2899 let marker = sized_item("2", 120.2, 3.0, 703.5, 6.0);
2900 assert_eq!(group_fragments_into_lines(&[bullet, text, marker]).len(), 1);
2901 let text = sized_item("word", 100.0, 24.0, 700.0, 12.0);
2905 let hanging = sized_item("n", 125.0, 2.0, 701.0, 4.0);
2906 let mark = sized_item("1", 124.2, 2.0, 703.8, 4.0);
2907 assert_eq!(group_fragments_into_lines(&[text, hanging, mark]).len(), 1);
2908 let sloped: Vec<TextItem> = (0..48)
2912 .map(|i| {
2913 sized_item(
2914 "g",
2915 100.0 + 3.0 * i as f32,
2916 3.0,
2917 700.0 + 0.1 * i as f32,
2918 4.7,
2919 )
2920 })
2921 .collect();
2922 assert_eq!(group_fragments_into_lines(&sloped).len(), 1);
2923 let a = sized_item("a", 100.0, 3.0, 700.0, 4.7);
2925 let b = sized_item("b", 100.0, 3.0, 695.5, 4.7);
2926 let c = sized_item("c", 100.0, 3.0, 694.0, 4.7);
2927 assert_eq!(group_fragments_into_lines(&[a.clone(), b]).len(), 2);
2928 assert_eq!(group_fragments_into_lines(&[a, c]).len(), 2);
2929 let mut a = sized_item("Apples Picked", 100.0, 100.0, 700.0, 4.7);
2932 let mut b = sized_item("Oranges Sold", 100.0, 100.0, 695.5, 4.7);
2933 for run in [&mut a, &mut b] {
2934 run.rotation = 3.0;
2935 run.height = 10.0;
2936 }
2937 assert_eq!(group_fragments_into_lines(&[a, b]).len(), 2);
2938 let first = sized_item("and time horizons", 56.7, 56.0, 332.92, 7.0);
2942 let base = sized_item("tons of CO", 133.2, 130.0, 330.92, 7.0);
2943 let sub = sized_item("2", 262.7, 2.2, 328.63, 4.06);
2944 assert_eq!(group_fragments_into_lines(&[first, base, sub]).len(), 1);
2945 let p = sized_item("p", 100.0, 5.0, 700.0, 0.0);
2947 let q = sized_item("q", 110.0, 5.0, 695.1, 0.0);
2948 let r = sized_item("r", 100.0, 5.0, 694.9, 0.0);
2949 assert_eq!(group_fragments_into_lines(&[p.clone(), q]).len(), 1);
2950 assert_eq!(group_fragments_into_lines(&[p, r]).len(), 2);
2951 }
2952
2953 #[test]
2954 fn merge_items_breaks_at_style_boundaries() {
2955 let mut italic = make_merge_item("emphasis", 150.0, 40.0);
2959 italic.is_italic = true;
2960 let mut underlined = make_merge_item("term", 195.0, 20.0);
2961 underlined.is_underline = true;
2962 let items = vec![
2963 make_merge_item("plain lead", 100.0, 48.0),
2964 italic,
2965 underlined,
2966 make_merge_item("plain tail", 218.0, 45.0),
2967 ];
2968 let merged = merge_text_items(items);
2969 assert_eq!(merged.len(), 4);
2970 assert!(merged[1].is_italic && !merged[1].is_underline);
2971 assert!(merged[2].is_underline && !merged[2].is_italic);
2972 assert!(!merged[3].is_underline && !merged[3].is_italic);
2973 }
2974
2975 #[test]
2976 fn merge_follows_the_bold_verdict_not_the_weight_class() {
2977 use crate::types::BoldSource;
2978
2979 let mut label = make_merge_item("Label:", 100.0, 36.0);
2983 label.font_weight = Some(500);
2984 let mut body = make_merge_item("body", 137.2, 24.0);
2985 body.font_weight = Some(300);
2986 let mut more = make_merge_item("text", 163.6, 24.0);
2987 more.font_weight = Some(300);
2988 let items = vec![label.clone(), body.clone(), more.clone()];
2989 let merged = merge_text_items_with_clips(items, &[], false, &[]);
2990 assert_eq!(merged.len(), 1);
2991 assert_eq!(merged[0].text, "Label: body text");
2992 assert_eq!(merged[0].font_weight, Some(500));
2993
2994 let mut items = vec![label, body, more];
3000 super::content_stream::read_bold_from_weight(&mut items, 500);
3001 let apart = merge_text_items_with_clips(items, &[], false, &[]);
3002 assert_eq!(apart.len(), 2);
3003 assert_eq!(apart[0].text, "Label: ");
3004 assert!(apart[0].is_bold);
3005 assert_eq!(apart[0].bold_source, Some(BoldSource::WeightClass));
3006 assert_eq!(apart[1].text, "body text");
3007 assert!(!apart[1].is_bold);
3008 assert_eq!(apart[1].bold_source, None);
3009
3010 let mut heavy = make_merge_item("Heavy", 100.0, 30.0);
3014 heavy.font_weight = Some(700);
3015 let mut named = make_merge_item("named", 132.0, 30.0);
3016 named.font_weight = Some(400);
3017 named.is_bold = true;
3018 named.bold_source = Some(BoldSource::FontName);
3019 let mut items = vec![heavy, named];
3020 super::content_stream::read_bold_from_weight(&mut items, 600);
3021 let merged = merge_text_items_with_clips(items, &[], false, &[]);
3022 assert_eq!(merged.len(), 1);
3023 assert_eq!(merged[0].text, "Heavy named");
3024 assert_eq!(merged[0].font_weight, Some(700));
3025 assert_eq!(merged[0].bold_source, Some(BoldSource::WeightClass));
3026
3027 let unknown = vec![
3029 make_merge_item("no", 100.0, 12.0),
3030 make_merge_item("weight", 113.2, 36.0),
3031 ];
3032 assert_eq!(
3033 merge_text_items_with_clips(unknown, &[], false, &[]).len(),
3034 1
3035 );
3036 }
3037
3038 #[test]
3039 fn read_bold_from_weight_credits_the_weight_class_after_name_and_flags() {
3040 use crate::types::BoldSource;
3041
3042 let mut named = make_merge_item("named", 0.0, 10.0);
3043 named.font_weight = Some(700);
3044 named.is_bold = true;
3045 named.bold_source = Some(BoldSource::FontName);
3046 let mut painted = make_merge_item("painted", 20.0, 10.0);
3047 painted.font_weight = Some(650);
3048 painted.is_bold = true;
3049 painted.bold_source = Some(BoldSource::Painted);
3050 let mut light = make_merge_item("light", 40.0, 10.0);
3051 light.font_weight = Some(300);
3052 let unknown = make_merge_item("unknown", 60.0, 10.0);
3053 let mut items = vec![named, painted, light, unknown];
3054 super::content_stream::read_bold_from_weight(&mut items, 600);
3055 assert_eq!(items[0].bold_source, Some(BoldSource::FontName));
3058 assert_eq!(items[1].bold_source, Some(BoldSource::WeightClass));
3059 assert!(!items[2].is_bold && items[2].bold_source.is_none());
3060 assert!(!items[3].is_bold && items[3].bold_source.is_none());
3061
3062 let mut items = vec![make_merge_item("x", 0.0, 10.0)];
3064 items[0].font_weight = Some(500);
3065 super::content_stream::read_bold_from_weight(&mut items, 500);
3066 assert!(items[0].is_bold);
3067 assert_eq!(items[0].bold_source, Some(BoldSource::WeightClass));
3068 }
3069
3070 #[test]
3071 fn recovered_bold_preserves_word_spacing_at_style_boundary() {
3072 let plain = vec![
3076 make_merge_item("KEY", 100.0, 24.0),
3077 make_merge_item("Body", 125.2, 24.0),
3078 ];
3079 let original = merge_text_items(plain.clone());
3080 let mut styled = plain;
3081 styled[0].is_bold = true;
3082 let recovered = merge_text_items(styled);
3083 assert_eq!(original[0].text, "KEY Body");
3084 assert_eq!(recovered.len(), 2);
3085 assert!(recovered[0].is_bold && !recovered[1].is_bold);
3086 assert_eq!(recovered[0].text, "KEY ");
3087 assert_eq!(recovered[1].text, "Body");
3088 assert_eq!(
3089 recovered
3090 .iter()
3091 .map(|item| item.text.as_str())
3092 .collect::<String>(),
3093 original[0].text
3094 );
3095 let line = TextLine {
3096 items: recovered,
3097 y: 100.0,
3098 page: 1,
3099 adaptive_threshold: 0.1,
3100 };
3101 assert_eq!(line.text(), "KEY Body");
3102 assert_eq!(
3103 line.text_with_formatting(true, false, false),
3104 "**KEY** Body"
3105 );
3106 }
3107
3108 #[test]
3109 fn recovered_bold_keeps_zero_gap_word_fragments_joined() {
3110 let plain = vec![
3111 make_merge_item("un", 100.0, 12.0),
3112 make_merge_item("known", 112.0, 30.0),
3113 ];
3114 let original = merge_text_items(plain.clone());
3115 let mut styled = plain;
3116 styled[0].is_bold = true;
3117 let recovered = merge_text_items(styled);
3118 assert_eq!(original[0].text, "unknown");
3119 assert_eq!(recovered.len(), 2);
3120 assert!(recovered[0].is_bold && !recovered[1].is_bold);
3121 assert_eq!(
3122 recovered
3123 .iter()
3124 .map(|item| item.text.as_str())
3125 .collect::<String>(),
3126 original[0].text
3127 );
3128 let line = TextLine {
3129 items: recovered,
3130 y: 100.0,
3131 page: 1,
3132 adaptive_threshold: 0.1,
3133 };
3134 assert_eq!(line.text(), "unknown");
3135 assert_eq!(line.text_with_formatting(true, false, false), "**un**known");
3136 }
3137
3138 #[test]
3139 fn bold_numeric_fragments_keep_line_assembly_spacing() {
3140 for (parts, gap, expected) in [
3141 (
3142 vec![("0", false), (".", true), ("86", false)],
3143 1.2,
3144 "0**.**86",
3145 ),
3146 (vec![("0.", true), ("86", false)], 1.2, "**0.**86"),
3147 (vec![(".", true), ("42", false)], 1.2, "**.**42"),
3148 (
3149 vec![("12 ", false), (".", true), ("55", false)],
3150 1.2,
3151 "12 **.**55",
3152 ),
3153 (
3154 vec![("12", false), (" .", true), ("55", false)],
3155 1.2,
3156 "12 **.**55",
3157 ),
3158 (vec![("1", false), ("23", true)], 1.2, "1**23**"),
3159 (vec![("12", true), ("%", false)], 1.2, "**12**%"),
3160 (vec![("+", true), ("12", false)], 1.2, "**+**12"),
3161 (
3162 vec![("1", false), (",", true), ("25", false)],
3163 1.2,
3164 "1**,**25",
3165 ),
3166 (vec![("Done.", true), ("2", false)], 1.2, "**Done.** 2"),
3167 (vec![("0. ", true), ("86", false)], 1.2, "**0.** 86"),
3168 (vec![("0.", true), ("86", false)], 4.8, "**0.** 86"),
3169 (vec![("KEY ", true), ("Body", false)], 1.2, "**KEY** Body"),
3170 (vec![("KEY", true), (" Body", false)], 1.2, "**KEY** Body"),
3171 ] {
3172 let mut x = 100.0;
3173 let items = parts
3174 .into_iter()
3175 .map(|(text, bold)| {
3176 let width = text.len() as f32 * 6.0;
3177 let mut item = make_merge_item(text, x, width);
3178 item.is_bold = bold;
3179 x += width + gap;
3180 item
3181 })
3182 .collect();
3183 let line = TextLine {
3184 items: merge_text_items(items),
3185 y: 100.0,
3186 page: 1,
3187 adaptive_threshold: 0.1,
3188 };
3189 assert_eq!(line.text_with_formatting(true, false, false), expected);
3190 assert_eq!(line.text(), expected.replace("**", ""));
3191 }
3192 }
3193
3194 #[test]
3195 fn non_bold_style_boundaries_keep_existing_spacing() {
3196 for style in 0..3 {
3197 let mut first = make_merge_item("KEY", 100.0, 24.0);
3198 match style {
3199 0 => first.is_italic = true,
3200 1 => first.is_underline = true,
3201 _ => first.is_strikeout = true,
3202 }
3203 let merged = merge_text_items(vec![first, make_merge_item("Body", 125.2, 24.0)]);
3204 assert_eq!(merged.len(), 2);
3205 assert_eq!(merged[0].text, "KEY");
3206 assert_eq!(merged[1].text, "Body");
3207 }
3208 }
3209
3210 #[test]
3211 fn merge_items_no_space_before_period() {
3212 let items = vec![
3215 make_merge_item("date", 227.25, 89.25), make_merge_item(".", 318.00, 3.0), ];
3218 let merged = merge_text_items(items);
3219 assert_eq!(merged.len(), 1);
3220 assert_eq!(merged[0].text, "date.");
3221 }
3222
3223 #[test]
3224 fn merge_items_lowercase_join_with_tc() {
3225 let items = vec![
3228 make_merge_item("deve", 100.0, 30.0), make_merge_item("lopers", 131.44, 40.0), ];
3231 let merged = merge_text_items(items);
3232 assert_eq!(merged.len(), 1);
3233 assert_eq!(merged[0].text, "developers");
3234 }
3235
3236 #[test]
3237 fn merge_items_space_at_word_boundary() {
3238 let items = vec![
3240 make_merge_item("hello", 100.0, 30.0),
3241 make_merge_item("world", 132.0, 30.0), ];
3243 let merged = merge_text_items(items);
3244 assert_eq!(merged.len(), 1);
3245 assert_eq!(merged[0].text, "hello world");
3246 }
3247
3248 #[test]
3249 fn merge_items_preserves_underline_from_later_fragment() {
3250 let mut items = vec![
3255 make_merge_item("pre", 100.0, 18.0),
3256 make_merge_item("fix", 119.0, 18.0),
3257 ];
3258 items[1].is_underline = true;
3259
3260 let merged = merge_text_items(items);
3261
3262 assert_eq!(merged.len(), 2);
3263 assert_eq!(merged[0].text, "pre");
3264 assert!(!merged[0].is_underline);
3265 assert_eq!(merged[1].text, "fix");
3266 assert!(merged[1].is_underline);
3267 }
3268
3269 #[test]
3270 fn merge_items_preserves_stream_order_for_backtracking_heading() {
3271 let items = vec![
3274 with_mcid(make_merge_item("F", 79.4, 4.5)),
3275 with_mcid(make_merge_item("r", 83.9, 3.3)),
3276 with_mcid(make_merge_item("om tables to data-", 79.4, 89.7)),
3277 with_mcid(make_merge_item("", 168.9, 33.9)),
3278 with_mcid(make_merge_item("analytics-", 168.9, 75.5)),
3279 with_mcid(make_merge_item("ready content", 210.5, 60.8)),
3280 ];
3281
3282 let merged = merge_text_items(items);
3283
3284 assert_eq!(merged.len(), 1);
3285 assert_eq!(
3286 merged[0].text,
3287 "From tables to data-analytics-ready content"
3288 );
3289 }
3290
3291 #[test]
3292 fn merge_items_preserves_stream_order_for_reset_word_prefix() {
3293 let items = vec![
3294 with_mcid(make_merge_item("N", 68.0, 7.0)),
3295 with_mcid(make_merge_item("e", 75.1, 4.0)),
3296 with_mcid(make_merge_item("w fields created", 68.0, 82.0)),
3297 ];
3298
3299 let merged = merge_text_items(items);
3300
3301 assert_eq!(merged.len(), 1);
3302 assert_eq!(merged[0].text, "New fields created");
3303 }
3304
3305 #[test]
3306 fn merge_items_uses_x_order_for_untagged_backtracking_text() {
3307 let items = vec![
3308 make_merge_item("N", 68.0, 7.0),
3309 make_merge_item("e", 75.1, 4.0),
3310 make_merge_item("w fields created", 68.2, 82.0),
3311 ];
3312
3313 let merged = merge_text_items(items);
3314
3315 let texts: Vec<_> = merged.iter().map(|item| item.text.as_str()).collect();
3316 assert_eq!(texts, vec!["N", "w fields created", "e"]);
3317 }
3318
3319 #[test]
3320 fn merge_items_preserves_bullet_stream_order_with_backtracking() {
3321 let items = vec![
3322 with_mcid(make_merge_item("•", 79.4, 5.0)),
3323 with_mcid(make_merge_item("The MS", 91.0, 32.6)),
3324 with_mcid(make_merge_item("A LoS project", 84.4, 70.0)),
3325 ];
3326
3327 let merged = merge_text_items(items);
3328
3329 assert_eq!(merged.len(), 1);
3330 assert_eq!(merged[0].text, "• The MSA LoS project");
3331 }
3332
3333 #[test]
3334 fn merge_items_keeps_normal_bullet_gap_limit_without_stream_order() {
3335 let items = vec![
3336 make_merge_item("•", 79.4, 5.0),
3337 make_merge_item("Distant item", 91.0, 60.0),
3338 ];
3339
3340 let merged = merge_text_items(items);
3341
3342 let texts: Vec<_> = merged.iter().map(|item| item.text.as_str()).collect();
3343 assert_eq!(texts, vec!["•", "Distant item"]);
3344 }
3345
3346 #[test]
3347 fn suppress_table_underlines_clears_line_detected_table_items() {
3348 let mut items = vec![
3349 make_merge_item("H1", 125.0, 20.0),
3350 make_merge_item("H2", 225.0, 20.0),
3351 make_merge_item("A", 125.0, 20.0),
3352 make_merge_item("B", 225.0, 20.0),
3353 ];
3354 items[0].y = 490.0;
3355 items[1].y = 490.0;
3356 items[2].y = 470.0;
3357 items[3].y = 470.0;
3358 for item in &mut items {
3359 item.is_underline = true;
3360 item.is_strikeout = true;
3361 }
3362 let lines = vec![
3363 make_line(100.0, 500.0, 300.0, 500.0),
3364 make_line(100.0, 480.0, 300.0, 480.0),
3365 make_line(100.0, 460.0, 300.0, 460.0),
3366 make_line(100.0, 460.0, 100.0, 500.0),
3367 make_line(200.0, 460.0, 200.0, 500.0),
3368 make_line(300.0, 460.0, 300.0, 500.0),
3369 ];
3370
3371 suppress_table_underlines(&mut items, &[], &lines, 1);
3372
3373 assert!(items.iter().all(|item| !item.is_underline));
3374 assert!(items.iter().all(|item| !item.is_strikeout));
3375 }
3376
3377 #[test]
3378 fn test_group_into_lines() {
3379 let items = vec![
3380 TextItem {
3381 text: "Hello".into(),
3382 x: 100.0,
3383 y: 700.0,
3384 width: 50.0,
3385 height: 12.0,
3386 font: "F1".into(),
3387 font_tag: String::new(),
3388 legacy_symbol_rewrite: false,
3389 font_size: 12.0,
3390 page: 1,
3391 is_bold: false,
3392 is_italic: false,
3393 font_weight: None,
3394 bold_source: None,
3395 fixed_pitch: None,
3396 fill_color: None,
3397 stroke_color: None,
3398 render_mode: None,
3399 is_underline: false,
3400 is_strikeout: false,
3401 rotation: 0.0,
3402 advance_known: true,
3403 item_type: ItemType::Text,
3404 mcid: None,
3405 baseline_shift: 0.0,
3406 },
3407 TextItem {
3408 text: "World".into(),
3409 x: 160.0,
3410 y: 700.0,
3411 width: 50.0,
3412 height: 12.0,
3413 font: "F1".into(),
3414 font_tag: String::new(),
3415 legacy_symbol_rewrite: false,
3416 font_size: 12.0,
3417 page: 1,
3418 is_bold: false,
3419 is_italic: false,
3420 font_weight: None,
3421 bold_source: None,
3422 fixed_pitch: None,
3423 fill_color: None,
3424 stroke_color: None,
3425 render_mode: None,
3426 is_underline: false,
3427 is_strikeout: false,
3428 rotation: 0.0,
3429 advance_known: true,
3430 item_type: ItemType::Text,
3431 mcid: None,
3432 baseline_shift: 0.0,
3433 },
3434 TextItem {
3435 text: "Next line".into(),
3436 x: 100.0,
3437 y: 680.0,
3438 width: 80.0,
3439 height: 12.0,
3440 font: "F1".into(),
3441 font_tag: String::new(),
3442 legacy_symbol_rewrite: false,
3443 font_size: 12.0,
3444 page: 1,
3445 is_bold: false,
3446 is_italic: false,
3447 font_weight: None,
3448 bold_source: None,
3449 fixed_pitch: None,
3450 fill_color: None,
3451 stroke_color: None,
3452 render_mode: None,
3453 is_underline: false,
3454 is_strikeout: false,
3455 rotation: 0.0,
3456 advance_known: true,
3457 item_type: ItemType::Text,
3458 mcid: None,
3459 baseline_shift: 0.0,
3460 },
3461 ];
3462
3463 let lines = group_into_lines(items);
3464 assert_eq!(lines.len(), 2);
3465 assert_eq!(lines[0].text(), "Hello World");
3466 assert_eq!(lines[1].text(), "Next line");
3467 }
3468
3469 #[test]
3470 fn preserving_all_text_keeps_numeric_page_footer() {
3471 let mut page_number = make_merge_item("42", 100.0, 12.0);
3472 page_number.y = 50.0;
3473
3474 assert!(group_into_lines(vec![page_number.clone()]).is_empty());
3475
3476 let lines = group_into_lines_preserving_all_text(vec![page_number]);
3477 assert_eq!(lines.len(), 1);
3478 assert_eq!(lines[0].text(), "42");
3479 }
3480
3481 #[test]
3482 fn inline_numeric_run_near_page_edge_is_not_removed() {
3483 let mut items = vec![
3484 make_merge_item("Total", 100.0, 30.0),
3485 make_merge_item("730", 136.0, 18.0),
3486 make_merge_item("seats", 160.0, 30.0),
3487 ];
3488 for item in &mut items {
3489 item.y = 780.0;
3490 }
3491
3492 let lines = group_into_lines(items);
3493
3494 assert_eq!(lines.len(), 1);
3495 assert_eq!(lines[0].text(), "Total 730 seats");
3496 }
3497
3498 #[test]
3499 fn numeric_page_footer_separated_from_label_is_removed() {
3500 let mut page_number = make_merge_item("42", 25.0, 12.0);
3501 page_number.y = 50.0;
3502 let mut footer_label = make_merge_item("DOCUMENT FOOTER", 60.0, 100.0);
3503 footer_label.y = 50.0;
3504
3505 let lines = group_into_lines(vec![page_number, footer_label]);
3506
3507 assert_eq!(lines.len(), 1);
3508 assert_eq!(lines[0].text(), "DOCUMENT FOOTER");
3509 }
3510
3511 #[test]
3512 fn decorative_marker_does_not_contextualize_numeric_page_footer() {
3513 let mut marker = make_merge_item("•", 19.0, 6.0);
3514 marker.y = 30.0;
3515 let mut page_number = make_merge_item("42", 37.0, 10.0);
3516 page_number.y = 30.0;
3517 let mut footer_label = make_merge_item("Company report footer", 68.0, 120.0);
3518 footer_label.y = 30.0;
3519
3520 let lines = group_into_lines(vec![marker, page_number, footer_label]);
3521
3522 assert!(lines.iter().all(|line| !line.text().contains("42")));
3523 assert!(lines
3524 .iter()
3525 .any(|line| line.text().contains("Company report footer")));
3526 }
3527
3528 #[test]
3529 fn labeled_page_number_is_removed_in_a_short_document() {
3530 let mut label = make_merge_item("Page", 25.0, 28.0);
3531 label.y = 50.0;
3532 let mut page_number = make_merge_item("42", 57.0, 12.0);
3533 page_number.y = 50.0;
3534
3535 let lines = group_into_lines(vec![label, page_number]);
3536
3537 assert_eq!(lines.len(), 1);
3538 assert_eq!(lines[0].text(), "Page");
3539 }
3540
3541 #[test]
3542 fn labeled_page_number_with_running_header_suffix_is_removed() {
3543 let mut items = vec![
3544 make_merge_item("Page", 25.0, 28.0),
3545 make_merge_item("42", 57.0, 12.0),
3546 make_merge_item("of", 73.0, 12.0),
3547 make_merge_item("100", 89.0, 18.0),
3548 make_merge_item("Report header", 111.0, 78.0),
3549 ];
3550 for item in &mut items {
3551 item.y = 50.0;
3552 }
3553
3554 let lines = group_into_lines(items);
3555
3556 assert_eq!(lines.len(), 1);
3557 assert_eq!(lines[0].text(), "Report header");
3558 }
3559
3560 #[test]
3561 fn page_of_total_expression_is_removed_without_leaving_fragments() {
3562 let mut items = vec![
3563 make_merge_item("Page", 482.0, 27.0),
3564 make_merge_item("1", 513.0, 6.0),
3565 make_merge_item("of", 523.0, 10.0),
3566 make_merge_item("15", 537.0, 12.0),
3567 ];
3568 for item in &mut items {
3569 item.y = 46.0;
3570 }
3571
3572 let lines = group_into_lines(items);
3573
3574 assert!(lines.is_empty());
3575 }
3576
3577 #[test]
3578 fn document_folio_filter_survives_per_page_layout_splitting() {
3579 let mut items = Vec::new();
3580 for (page, value) in [(1, "42"), (2, "43"), (3, "44")] {
3581 let mut label = make_merge_item("Page", 25.0, 28.0);
3582 label.page = page;
3583 label.y = 50.0;
3584 let mut page_number = make_merge_item(value, 57.0, 12.0);
3585 page_number.page = page;
3586 page_number.y = 50.0;
3587 items.extend([label, page_number]);
3588 }
3589
3590 let filtered = filter_markdown_page_numbers(items, 3);
3591 assert!(filtered
3592 .iter()
3593 .all(|item| !matches!(item.text.as_str(), "42" | "43" | "44")));
3594 let mut lines = Vec::new();
3595 for page in 1..=3 {
3596 let page_items = filtered
3597 .iter()
3598 .filter(|item| item.page == page)
3599 .cloned()
3600 .collect();
3601 lines.extend(
3602 group_prefiltered_items_into_lines_with_thresholds_and_charts(
3603 page_items,
3604 &HashMap::new(),
3605 &HashSet::new(),
3606 &HashMap::new(),
3607 ),
3608 );
3609 }
3610
3611 assert_eq!(lines.len(), 3);
3612 assert!(lines.iter().all(|line| line.text() == "Page"));
3613 }
3614
3615 #[test]
3616 fn numeric_only_page_edge_runs_do_not_contextualize_folios() {
3617 let mut page_number = make_merge_item("42", 25.0, 12.0);
3618 page_number.y = 50.0;
3619 let mut long_number = make_merge_item("12345", 43.0, 30.0);
3620 long_number.y = 50.0;
3621
3622 let lines = group_into_lines(vec![page_number, long_number]);
3623
3624 assert_eq!(lines.len(), 1);
3625 assert_eq!(lines[0].text(), "12345");
3626 }
3627
3628 #[test]
3629 fn structured_and_dense_numeric_page_edge_runs_are_preserved() {
3630 let mut list_marker = make_merge_item("11)", 25.0, 18.0);
3631 list_marker.y = 50.0;
3632 let mut chapter = make_merge_item("13", 47.0, 12.0);
3633 chapter.y = 50.0;
3634
3635 let mut isbn_prefix = make_merge_item("9", 25.0, 6.0);
3636 isbn_prefix.page = 2;
3637 isbn_prefix.y = 50.0;
3638 let mut isbn_mid = make_merge_item("780113", 35.0, 36.0);
3639 isbn_mid.page = 2;
3640 isbn_mid.y = 50.0;
3641 let mut isbn_end = make_merge_item("227426", 75.0, 36.0);
3642 isbn_end.page = 2;
3643 isbn_end.y = 50.0;
3644
3645 let lines = group_into_lines(vec![list_marker, chapter, isbn_prefix, isbn_mid, isbn_end]);
3646
3647 assert_eq!(lines.len(), 2);
3648 assert_eq!(lines[0].text(), "11) 13");
3649 assert_eq!(lines[1].text(), "9 780113 227426");
3650 }
3651
3652 #[test]
3653 fn incrementing_numeric_body_column_is_not_treated_as_a_folio() {
3654 let mut items = Vec::new();
3655 for (page, value) in [(1, "13"), (2, "14"), (3, "15")] {
3656 let mut row_number = make_merge_item(value, 72.0, 12.0);
3657 row_number.page = page;
3658 row_number.y = 730.0;
3659 let mut name = make_merge_item("Person", 90.0, 42.0);
3660 name.page = page;
3661 name.y = 730.0;
3662 items.extend([row_number, name]);
3663 }
3664
3665 let lines = group_into_lines(items);
3666
3667 assert_eq!(lines.len(), 3);
3668 assert_eq!(lines[0].text(), "13 Person");
3669 assert_eq!(lines[1].text(), "14 Person");
3670 assert_eq!(lines[2].text(), "15 Person");
3671 }
3672
3673 #[test]
3674 fn advancing_number_in_repeated_deep_margin_footer_is_removed() {
3675 let mut items = Vec::new();
3676 for (page, value) in [(1, "2"), (2, "4"), (3, "6"), (4, "8")] {
3677 let mut page_number = make_merge_item(value, 25.0, 12.0);
3678 page_number.page = page;
3679 page_number.y = 30.0;
3680 let mut footer = make_merge_item("Company report footer", 41.0, 120.0);
3681 footer.page = page;
3682 footer.y = 30.0;
3683 items.extend([page_number, footer]);
3684 }
3685
3686 let lines = group_into_lines(items);
3687
3688 assert_eq!(lines.len(), 4);
3689 assert!(lines
3690 .iter()
3691 .all(|line| line.text() == "Company report footer"));
3692 }
3693
3694 #[test]
3695 fn repeated_substantive_page_number_prose_is_preserved() {
3696 let mut items = Vec::new();
3697 for (page, value) in [(1, "42"), (2, "43"), (3, "44"), (4, "45")] {
3698 let mut page_label = make_merge_item("Page", 25.0, 28.0);
3699 page_label.page = page;
3700 page_label.y = 30.0;
3701 let mut number = make_merge_item(value, 57.0, 12.0);
3702 number.page = page;
3703 number.y = 30.0;
3704 let mut explanation = make_merge_item("explains the result", 73.0, 108.0);
3705 explanation.page = page;
3706 explanation.y = 30.0;
3707 items.extend([page_label, number, explanation]);
3708 }
3709
3710 let lines = group_into_lines(items);
3711
3712 assert_eq!(lines.len(), 4);
3713 for (line, value) in lines.iter().zip(["42", "43", "44", "45"]) {
3714 assert_eq!(line.text(), format!("Page {value} explains the result"));
3715 }
3716 }
3717
3718 #[test]
3719 fn numeric_candidates_do_not_bridge_lexical_context() {
3720 let mut report = make_merge_item("Report", 25.0, 40.0);
3721 report.y = 30.0;
3722 let mut year = make_merge_item("2026", 69.0, 24.0);
3723 year.y = 30.0;
3724 let mut folio = make_merge_item("42", 97.0, 12.0);
3725 folio.y = 30.0;
3726
3727 let lines = group_into_lines(vec![report, year, folio]);
3728
3729 assert_eq!(lines.len(), 1);
3730 assert_eq!(lines[0].text(), "Report 2026");
3731 }
3732
3733 #[test]
3734 fn centered_folio_delimiters_are_removed_with_the_number() {
3735 let mut left = make_merge_item("-", 270.0, 6.0);
3736 left.y = 30.0;
3737 let mut number = make_merge_item("42", 280.0, 12.0);
3738 number.y = 30.0;
3739 let mut right = make_merge_item("-", 296.0, 6.0);
3740 right.y = 30.0;
3741
3742 let lines = group_into_lines(vec![left, number, right]);
3743
3744 assert!(lines.is_empty());
3745 }
3746
3747 #[test]
3748 fn centered_delimiters_inside_substantive_text_are_preserved() {
3749 let mut items = vec![
3750 make_merge_item("Result", 240.0, 36.0),
3751 make_merge_item("-", 280.0, 6.0),
3752 make_merge_item("42", 290.0, 12.0),
3753 make_merge_item("-", 306.0, 6.0),
3754 make_merge_item("approved", 316.0, 48.0),
3755 ];
3756 for item in &mut items {
3757 item.y = 30.0;
3758 }
3759
3760 let lines = group_into_lines(items);
3761
3762 assert_eq!(lines.len(), 1);
3763 assert_eq!(lines[0].text(), "Result-42-approved");
3764 }
3765
3766 #[test]
3767 fn changing_year_in_repeated_deep_margin_header_is_preserved() {
3768 let mut items = Vec::new();
3769 for (page, year) in [(1, "2020"), (2, "2021"), (3, "2022"), (4, "2023")] {
3770 let mut year = make_merge_item(year, 25.0, 24.0);
3771 year.page = page;
3772 year.y = 780.0;
3773 let mut header = make_merge_item("Annual report", 53.0, 78.0);
3774 header.page = page;
3775 header.y = 780.0;
3776 items.extend([year, header]);
3777 }
3778
3779 let lines = group_into_lines(items);
3780
3781 assert_eq!(lines.len(), 4);
3782 assert_eq!(lines[0].text(), "2020 Annual report");
3783 assert_eq!(lines[3].text(), "2023 Annual report");
3784 }
3785
3786 #[test]
3787 fn sparse_repeated_margin_numbers_do_not_meet_the_folio_evidence_floor() {
3788 let mut items = Vec::new();
3789 for page in 1..=4 {
3790 if page <= 2 {
3791 let value = if page == 1 { "2" } else { "4" };
3792 let mut number = make_merge_item(value, 25.0, 12.0);
3793 number.page = page;
3794 number.y = 30.0;
3795 let mut footer = make_merge_item("Company report footer", 41.0, 120.0);
3796 footer.page = page;
3797 footer.y = 30.0;
3798 items.extend([number, footer]);
3799 } else {
3800 let mut body = make_merge_item("Body text", 72.0, 54.0);
3801 body.page = page;
3802 body.y = 400.0;
3803 items.push(body);
3804 }
3805 }
3806
3807 let lines = group_into_lines(items);
3808
3809 assert!(lines
3810 .iter()
3811 .any(|line| line.text() == "2 Company report footer"));
3812 assert!(lines
3813 .iter()
3814 .any(|line| line.text() == "4 Company report footer"));
3815 }
3816
3817 #[test]
3818 fn sparse_document_pages_count_toward_repeated_folio_coverage() {
3819 let mut items = Vec::new();
3820 for (page, value) in [(1, "1"), (10, "10"), (19, "19"), (28, "28")] {
3821 let mut number = make_merge_item(value, 25.0, 12.0);
3822 number.page = page;
3823 number.y = 30.0;
3824 let mut footer = make_merge_item("Company report footer", 41.0, 120.0);
3825 footer.page = page;
3826 footer.y = 30.0;
3827 items.extend([number, footer]);
3828 }
3829
3830 let lines = group_into_lines(items);
3831
3832 assert!(lines
3833 .iter()
3834 .any(|line| line.text() == "1 Company report footer"));
3835 assert!(lines
3836 .iter()
3837 .any(|line| line.text() == "28 Company report footer"));
3838 }
3839
3840 #[test]
3841 fn trailing_blank_pages_count_toward_repeated_folio_coverage() {
3842 let mut items = Vec::new();
3843 for (page, value) in [(1, "1"), (2, "2"), (3, "3"), (4, "4")] {
3844 let mut number = make_merge_item(value, 25.0, 12.0);
3845 number.page = page;
3846 number.y = 30.0;
3847 let mut footer = make_merge_item("Company report footer", 41.0, 120.0);
3848 footer.page = page;
3849 footer.y = 30.0;
3850 items.extend([number, footer]);
3851 }
3852
3853 let filtered = filter_markdown_page_numbers(items, 20);
3854
3855 assert!(filtered.iter().any(|item| item.text == "1"));
3856 assert!(filtered.iter().any(|item| item.text == "4"));
3857 }
3858
3859 #[test]
3860 fn prefiltered_contextual_number_survives_layout_partitioning() {
3861 let mut items = vec![
3862 make_merge_item("Total", 100.0, 30.0),
3863 make_merge_item("730", 136.0, 18.0),
3864 make_merge_item("seats", 160.0, 30.0),
3865 ];
3866 for item in &mut items {
3867 item.y = 780.0;
3868 }
3869
3870 let filtered = filter_markdown_page_numbers(items, 1);
3871 let partitioned_number: Vec<TextItem> = filtered
3872 .into_iter()
3873 .filter(|item| item.text == "730")
3874 .collect();
3875 let lines = group_prefiltered_items_into_lines_with_thresholds_and_charts(
3876 partitioned_number,
3877 &HashMap::new(),
3878 &HashSet::new(),
3879 &HashMap::new(),
3880 );
3881
3882 assert_eq!(lines.len(), 1);
3883 assert_eq!(lines[0].text(), "730");
3884 }
3885
3886 #[test]
3887 fn numeric_only_partition_does_not_define_columns() {
3888 let mut items = Vec::new();
3889 for row in 0..20 {
3890 let y = 90.0 - row as f32 * 4.0;
3891 let mut left = make_merge_item(&(row + 1).to_string(), 50.0, 20.0);
3892 left.y = y;
3893 let mut right = make_merge_item(&(row + 101).to_string(), 350.0, 20.0);
3894 right.y = y;
3895 items.extend([left, right]);
3896 }
3897 assert_eq!(detect_columns(&items, 1, false).len(), 2);
3898
3899 let lines = group_prefiltered_items_into_lines_with_thresholds_and_charts(
3900 items,
3901 &HashMap::new(),
3902 &HashSet::new(),
3903 &HashMap::new(),
3904 );
3905
3906 assert_eq!(lines.len(), 20);
3907 assert!(lines.iter().all(|line| line.items.len() == 2));
3908 }
3909
3910 #[test]
3911 fn separated_content_is_not_treated_as_a_spread_folio_pair() {
3912 let mut value = make_merge_item("12", 100.0, 12.0);
3913 value.y = 30.0;
3914 let mut label = make_merge_item("Total", 116.0, 30.0);
3915 label.y = 30.0;
3916 let mut unrelated_number = make_merge_item("13", 300.0, 12.0);
3917 unrelated_number.y = 30.0;
3918
3919 let lines = group_into_lines(vec![value, label, unrelated_number]);
3920
3921 assert_eq!(lines.len(), 1);
3922 assert_eq!(lines[0].text(), "12 Total");
3923 }
3924
3925 #[test]
3926 fn repeated_folio_uses_the_full_page_edge_band() {
3927 let mut items = Vec::new();
3928 for (page, value) in [(1, "2"), (2, "4"), (3, "6"), (4, "8")] {
3929 let mut page_number = make_merge_item(value, 25.0, 12.0);
3930 page_number.page = page;
3931 page_number.y = 80.0;
3932 let mut footer = make_merge_item("Company report footer", 41.0, 120.0);
3933 footer.page = page;
3934 footer.y = 80.0;
3935 items.extend([page_number, footer]);
3936 }
3937
3938 let lines = group_into_lines(items);
3939
3940 assert_eq!(lines.len(), 4);
3941 assert!(lines
3942 .iter()
3943 .all(|line| line.text() == "Company report footer"));
3944 }
3945
3946 #[test]
3947 fn contextual_folio_on_facing_page_spread_is_removed() {
3948 let mut marker = make_merge_item("•", 19.0, 6.0);
3949 marker.y = 30.0;
3950 let mut left_folio = make_merge_item("326", 35.0, 17.0);
3951 left_folio.y = 30.0;
3952 let mut footer = make_merge_item("Company report footer", 61.0, 120.0);
3953 footer.y = 30.0;
3954 let mut right_folio = make_merge_item("327", 1148.0, 17.0);
3955 right_folio.y = 30.0;
3956
3957 let lines = group_into_lines(vec![marker, left_folio, footer, right_folio]);
3958
3959 assert!(lines
3960 .iter()
3961 .all(|line| !line.text().contains("326") && !line.text().contains("327")));
3962 assert!(lines
3963 .iter()
3964 .any(|line| line.text().contains("Company report footer")));
3965 }
3966
3967 #[test]
3968 fn contextual_folios_alternating_across_pages_are_removed() {
3969 let headers = [
3970 "Letter to shareholders",
3971 "Corporate governance report",
3972 "Business environment overview",
3973 "Consolidated financial statements",
3974 ];
3975 let mut items = Vec::new();
3976 for page in 1..=8 {
3977 let mut body = make_merge_item("Body text", 50.0, 500.0);
3978 body.page = page;
3979 body.y = 400.0;
3980 items.push(body);
3981
3982 let mut folio = make_merge_item(&(page + 22).to_string(), 0.0, 14.0);
3983 folio.page = page;
3984 folio.y = 780.0;
3985 if page % 2 == 0 {
3986 folio.x = 50.0;
3987 items.push(folio);
3988 } else {
3989 let mut header = make_merge_item(headers[(page / 2) as usize], 350.0, 180.0);
3990 header.page = page;
3991 header.y = 780.0;
3992 folio.x = 536.0;
3993 items.extend([header, folio]);
3994 }
3995 }
3996
3997 let filtered = filter_markdown_page_numbers(items, 8);
3998
3999 assert!(filtered.iter().all(|item| {
4000 !matches!(
4001 item.text.as_str(),
4002 "23" | "24" | "25" | "26" | "27" | "28" | "29" | "30"
4003 )
4004 }));
4005 assert!(headers
4006 .iter()
4007 .all(|header| filtered.iter().any(|item| item.text == *header)));
4008 }
4009
4010 #[test]
4011 fn one_isolated_neighbor_does_not_remove_contextual_number() {
4012 let mut body_one = make_merge_item("Body text", 50.0, 500.0);
4013 body_one.y = 400.0;
4014 let mut label = make_merge_item("Report", 450.0, 70.0);
4015 label.y = 780.0;
4016 let mut contextual = make_merge_item("1", 526.0, 7.0);
4017 contextual.y = 780.0;
4018
4019 let mut body_two = body_one.clone();
4020 body_two.page = 2;
4021 let mut isolated = make_merge_item("2", 50.0, 7.0);
4022 isolated.page = 2;
4023 isolated.y = 780.0;
4024
4025 let filtered =
4026 filter_markdown_page_numbers(vec![body_one, label, contextual, body_two, isolated], 2);
4027
4028 assert!(filtered.iter().any(|item| item.text == "Report"));
4029 assert!(filtered.iter().any(|item| item.text == "1"));
4030 assert!(filtered.iter().all(|item| item.text != "2"));
4031 }
4032
4033 #[test]
4034 fn narrow_content_span_does_not_establish_adjacent_page_edges() {
4035 let mut body_one = make_merge_item("Body text", 100.0, 120.0);
4036 body_one.y = 400.0;
4037 let mut label = make_merge_item("Report", 170.0, 60.0);
4038 label.y = 780.0;
4039 let mut contextual = make_merge_item("1", 235.0, 7.0);
4040 contextual.y = 780.0;
4041
4042 let mut body_two = body_one.clone();
4043 body_two.page = 2;
4044 let mut isolated_two = make_merge_item("2", 100.0, 7.0);
4045 isolated_two.page = 2;
4046 isolated_two.y = 780.0;
4047
4048 let mut body_four = body_one.clone();
4049 body_four.page = 4;
4050 let mut isolated_four = make_merge_item("4", 100.0, 7.0);
4051 isolated_four.page = 4;
4052 isolated_four.y = 780.0;
4053
4054 let filtered = filter_markdown_page_numbers(
4055 vec![
4056 body_one,
4057 label,
4058 contextual,
4059 body_two,
4060 isolated_two,
4061 body_four,
4062 isolated_four,
4063 ],
4064 4,
4065 );
4066
4067 assert!(filtered.iter().any(|item| item.text == "Report"));
4068 assert!(filtered.iter().any(|item| item.text == "1"));
4069 }
4070
4071 #[test]
4072 fn same_edge_number_on_an_adjacent_page_is_not_folio_evidence() {
4073 let mut body_one = make_merge_item("Body text", 50.0, 500.0);
4074 body_one.y = 400.0;
4075 let mut isolated = make_merge_item("42", 50.0, 14.0);
4076 isolated.y = 780.0;
4077
4078 let mut body_two = body_one.clone();
4079 body_two.page = 2;
4080 let mut contextual = make_merge_item("43", 50.0, 14.0);
4081 contextual.page = 2;
4082 contextual.y = 780.0;
4083 let mut label = make_merge_item("cases reviewed", 70.0, 90.0);
4084 label.page = 2;
4085 label.y = 780.0;
4086
4087 let filtered =
4088 filter_markdown_page_numbers(vec![body_one, isolated, body_two, contextual, label], 2);
4089
4090 assert!(filtered.iter().any(|item| item.text == "43"));
4091 assert!(filtered.iter().any(|item| item.text == "cases reviewed"));
4092 }
4093
4094 #[test]
4095 fn constant_number_in_repeated_deep_margin_header_is_preserved() {
4096 let mut items = Vec::new();
4097 for page in 1..=4 {
4098 let mut year = make_merge_item("2026", 25.0, 24.0);
4099 year.page = page;
4100 year.y = 780.0;
4101 let mut header = make_merge_item("Annual report", 53.0, 78.0);
4102 header.page = page;
4103 header.y = 780.0;
4104 items.extend([year, header]);
4105 }
4106
4107 let lines = group_into_lines(items);
4108
4109 assert_eq!(lines.len(), 4);
4110 assert!(lines.iter().all(|line| line.text() == "2026 Annual report"));
4111 }
4112
4113 #[test]
4114 fn page_number_prefix_does_not_remove_substantive_text_during_layout() {
4115 let mut items = vec![
4116 make_merge_item("Page", 25.0, 28.0),
4117 make_merge_item("42", 57.0, 12.0),
4118 make_merge_item("explains", 73.0, 44.0),
4119 make_merge_item("the result", 121.0, 55.0),
4120 ];
4121 for item in &mut items {
4122 item.y = 50.0;
4123 }
4124
4125 let lines = group_into_lines(items);
4126
4127 assert_eq!(lines.len(), 1);
4128 assert_eq!(lines[0].text(), "Page 42 explains the result");
4129 }
4130
4131 #[test]
4132 fn repeated_page_number_prefix_with_substantive_text_is_preserved_during_layout() {
4133 let mut items = Vec::new();
4134 for (page, value, chapter) in [(1, "42", "Chapter 1"), (2, "43", "Chapter 2")] {
4135 let mut label = make_merge_item("Page", 25.0, 28.0);
4136 label.page = page;
4137 label.y = 50.0;
4138 let mut page_number = make_merge_item(value, 57.0, 12.0);
4139 page_number.page = page;
4140 page_number.y = 50.0;
4141 let mut suffix = make_merge_item(chapter, 73.0, 58.0);
4142 suffix.page = page;
4143 suffix.y = 50.0;
4144 items.extend([label, page_number, suffix]);
4145 }
4146
4147 let lines = group_into_lines(items);
4148
4149 assert_eq!(lines.len(), 2);
4150 assert_eq!(lines[0].text(), "Page 42 Chapter 1");
4151 assert_eq!(lines[1].text(), "Page 43 Chapter 2");
4152 }
4153
4154 #[test]
4155 fn page_number_phrase_in_the_page_body_is_preserved() {
4156 let items = vec![
4157 make_merge_item("Page", 25.0, 28.0),
4158 make_merge_item("42", 57.0, 12.0),
4159 ];
4160
4161 let lines = group_into_lines(items);
4162
4163 assert_eq!(lines.len(), 1);
4164 assert_eq!(lines[0].text(), "Page 42");
4165 }
4166
4167 #[test]
4168 fn short_numeric_context_near_page_edge_is_preserved() {
4169 let mut chapter = make_merge_item("Chapter", 100.0, 45.0);
4170 chapter.y = 760.0;
4171 let mut chapter_number = make_merge_item("1", 151.0, 6.0);
4172 chapter_number.y = 760.0;
4173
4174 let chapter_lines = group_into_lines(vec![chapter, chapter_number]);
4175 assert_eq!(chapter_lines.len(), 1);
4176 assert_eq!(chapter_lines[0].text(), "Chapter 1");
4177
4178 let mut year = make_merge_item("2026", 100.0, 24.0);
4179 year.y = 760.0;
4180 let mut report = make_merge_item("Report", 130.0, 36.0);
4181 report.y = 760.0;
4182
4183 let report_lines = group_into_lines(vec![year, report]);
4184 assert_eq!(report_lines.len(), 1);
4185 assert_eq!(report_lines[0].text(), "2026 Report");
4186
4187 let mut chapter = make_merge_item("Chapter", 100.0, 45.0);
4188 chapter.y = 760.0;
4189 let mut chapter_number = make_merge_item("1", 151.0, 6.0);
4190 chapter_number.y = 760.0;
4191 let mut edition_year = make_merge_item("2026", 163.0, 24.0);
4192 edition_year.y = 760.0;
4193
4194 let chained_lines = group_into_lines(vec![chapter, chapter_number, edition_year]);
4195 assert_eq!(chained_lines.len(), 1);
4196 assert_eq!(chained_lines[0].text(), "Chapter 1 2026");
4197 }
4198
4199 #[test]
4200 fn test_bold_italic_detection() {
4201 assert!(is_bold_font("Arial-Bold"));
4203 assert!(is_bold_font("TimesNewRoman-Bold"));
4204 assert!(is_bold_font("Helvetica-BoldOblique"));
4205 assert!(is_bold_font("ABCDEF+ArialMT-Bold"));
4206 assert!(is_bold_font("NotoSans-Black"));
4207 assert!(is_bold_font("Roboto-SemiBold"));
4208 assert!(!is_bold_font("Arial"));
4209 assert!(!is_bold_font("TimesNewRoman-Italic"));
4210
4211 assert!(is_italic_font("Arial-Italic"));
4213 assert!(is_italic_font("TimesNewRoman-Italic"));
4214 assert!(is_italic_font("Helvetica-Oblique"));
4215 assert!(is_italic_font("ABCDEF+ArialMT-Italic"));
4216 assert!(is_italic_font("Helvetica-BoldOblique"));
4217 assert!(!is_italic_font("Arial"));
4218 assert!(!is_italic_font("TimesNewRoman-Bold"));
4219
4220 assert!(is_bold_font("Arial-BoldItalic"));
4222 assert!(is_italic_font("Arial-BoldItalic"));
4223 assert!(is_bold_font("Helvetica-BoldOblique"));
4224 assert!(is_italic_font("Helvetica-BoldOblique"));
4225 }
4226
4227 #[test]
4228 fn test_word_level_items_get_spaces() {
4229 let items = vec![
4231 TextItem {
4232 text: "the".into(),
4233 x: 100.0,
4234 y: 500.0,
4235 width: 19.5,
4236 height: 12.0,
4237 font: "C2_0".into(),
4238 font_tag: String::new(),
4239 legacy_symbol_rewrite: false,
4240 font_size: 12.0,
4241 page: 1,
4242 is_bold: false,
4243 is_italic: false,
4244 font_weight: None,
4245 bold_source: None,
4246 fixed_pitch: None,
4247 fill_color: None,
4248 stroke_color: None,
4249 render_mode: None,
4250 is_underline: false,
4251 is_strikeout: false,
4252 rotation: 0.0,
4253 advance_known: true,
4254 item_type: ItemType::Text,
4255 mcid: None,
4256 baseline_shift: 0.0,
4257 },
4258 TextItem {
4259 text: "Prague".into(),
4260 x: 119.5,
4261 y: 500.0,
4262 width: 42.0,
4263 height: 12.0,
4264 font: "C2_0".into(),
4265 font_tag: String::new(),
4266 legacy_symbol_rewrite: false,
4267 font_size: 12.0,
4268 page: 1,
4269 is_bold: false,
4270 is_italic: false,
4271 font_weight: None,
4272 bold_source: None,
4273 fixed_pitch: None,
4274 fill_color: None,
4275 stroke_color: None,
4276 render_mode: None,
4277 is_underline: false,
4278 is_strikeout: false,
4279 rotation: 0.0,
4280 advance_known: true,
4281 item_type: ItemType::Text,
4282 mcid: None,
4283 baseline_shift: 0.0,
4284 },
4285 TextItem {
4286 text: "Rules".into(),
4287 x: 161.5,
4288 y: 500.0,
4289 width: 35.0,
4290 height: 12.0,
4291 font: "C2_0".into(),
4292 font_tag: String::new(),
4293 legacy_symbol_rewrite: false,
4294 font_size: 12.0,
4295 page: 1,
4296 is_bold: false,
4297 is_italic: false,
4298 font_weight: None,
4299 bold_source: None,
4300 fixed_pitch: None,
4301 fill_color: None,
4302 stroke_color: None,
4303 render_mode: None,
4304 is_underline: false,
4305 is_strikeout: false,
4306 rotation: 0.0,
4307 advance_known: true,
4308 item_type: ItemType::Text,
4309 mcid: None,
4310 baseline_shift: 0.0,
4311 },
4312 ];
4313
4314 let lines = group_into_lines(items);
4315 assert_eq!(lines.len(), 1);
4316 assert_eq!(lines[0].text(), "the Prague Rules");
4317 }
4318
4319 #[test]
4320 fn test_single_char_items_still_join() {
4321 let items = vec![
4323 TextItem {
4324 text: "N".into(),
4325 x: 100.0,
4326 y: 500.0,
4327 width: 8.0,
4328 height: 12.0,
4329 font: "F1".into(),
4330 font_tag: String::new(),
4331 legacy_symbol_rewrite: false,
4332 font_size: 12.0,
4333 page: 1,
4334 is_bold: false,
4335 is_italic: false,
4336 font_weight: None,
4337 bold_source: None,
4338 fixed_pitch: None,
4339 fill_color: None,
4340 stroke_color: None,
4341 render_mode: None,
4342 is_underline: false,
4343 is_strikeout: false,
4344 rotation: 0.0,
4345 advance_known: true,
4346 item_type: ItemType::Text,
4347 mcid: None,
4348 baseline_shift: 0.0,
4349 },
4350 TextItem {
4351 text: "A".into(),
4352 x: 108.0,
4353 y: 500.0,
4354 width: 8.0,
4355 height: 12.0,
4356 font: "F1".into(),
4357 font_tag: String::new(),
4358 legacy_symbol_rewrite: false,
4359 font_size: 12.0,
4360 page: 1,
4361 is_bold: false,
4362 is_italic: false,
4363 font_weight: None,
4364 bold_source: None,
4365 fixed_pitch: None,
4366 fill_color: None,
4367 stroke_color: None,
4368 render_mode: None,
4369 is_underline: false,
4370 is_strikeout: false,
4371 rotation: 0.0,
4372 advance_known: true,
4373 item_type: ItemType::Text,
4374 mcid: None,
4375 baseline_shift: 0.0,
4376 },
4377 TextItem {
4378 text: "V".into(),
4379 x: 116.0,
4380 y: 500.0,
4381 width: 8.0,
4382 height: 12.0,
4383 font: "F1".into(),
4384 font_tag: String::new(),
4385 legacy_symbol_rewrite: false,
4386 font_size: 12.0,
4387 page: 1,
4388 is_bold: false,
4389 is_italic: false,
4390 font_weight: None,
4391 bold_source: None,
4392 fixed_pitch: None,
4393 fill_color: None,
4394 stroke_color: None,
4395 render_mode: None,
4396 is_underline: false,
4397 is_strikeout: false,
4398 rotation: 0.0,
4399 advance_known: true,
4400 item_type: ItemType::Text,
4401 mcid: None,
4402 baseline_shift: 0.0,
4403 },
4404 ];
4405
4406 let lines = group_into_lines(items);
4407 assert_eq!(lines.len(), 1);
4408 assert_eq!(lines[0].text(), "NAV");
4409 }
4410
4411 #[test]
4412 fn test_per_glyph_word_boundaries() {
4413 fn char_item(ch: &str, x: f32, width: f32) -> TextItem {
4417 TextItem {
4418 text: ch.into(),
4419 x,
4420 y: 719.3,
4421 width,
4422 height: 13.3,
4423 font: "F4".into(),
4424 font_tag: String::new(),
4425 legacy_symbol_rewrite: false,
4426 font_size: 13.3,
4427 page: 1,
4428 is_bold: true,
4429 is_italic: false,
4430 font_weight: None,
4431 bold_source: Some(crate::types::BoldSource::FontName),
4432 fixed_pitch: None,
4433 fill_color: None,
4434 stroke_color: None,
4435 render_mode: None,
4436 is_underline: false,
4437 is_strikeout: false,
4438 rotation: 0.0,
4439 advance_known: true,
4440 item_type: ItemType::Text,
4441 mcid: None,
4442 baseline_shift: 0.0,
4443 }
4444 }
4445
4446 let items = vec![
4448 char_item("I", 24.3, 3.1),
4449 char_item("t", 27.5, 2.7),
4450 char_item("e", 30.1, 3.5),
4451 char_item("m", 33.7, 6.7),
4452 char_item("2", 42.3, 4.0), ];
4454
4455 let lines = group_into_lines(items);
4456 assert_eq!(lines.len(), 1);
4457 assert_eq!(lines[0].text(), "Item 2");
4458 }
4459
4460 #[test]
4461 fn test_per_glyph_words_not_merged() {
4462 fn char_item(ch: &str, x: f32, width: f32) -> TextItem {
4464 TextItem {
4465 text: ch.into(),
4466 x,
4467 y: 705.5,
4468 width,
4469 height: 13.3,
4470 font: "F5".into(),
4471 font_tag: String::new(),
4472 legacy_symbol_rewrite: false,
4473 font_size: 13.3,
4474 page: 1,
4475 is_bold: false,
4476 is_italic: false,
4477 font_weight: None,
4478 bold_source: None,
4479 fixed_pitch: None,
4480 fill_color: None,
4481 stroke_color: None,
4482 render_mode: None,
4483 is_underline: false,
4484 is_strikeout: false,
4485 rotation: 0.0,
4486 advance_known: true,
4487 item_type: ItemType::Text,
4488 mcid: None,
4489 baseline_shift: 0.0,
4490 }
4491 }
4492
4493 let items = vec![
4495 char_item("o", 100.0, 4.0),
4496 char_item("f", 104.0, 2.7),
4497 char_item("t", 110.7, 2.7),
4499 char_item("h", 113.4, 4.4),
4500 char_item("e", 117.8, 3.5),
4501 ];
4502
4503 let lines = group_into_lines(items);
4504 assert_eq!(lines.len(), 1);
4505 assert_eq!(lines[0].text(), "of the");
4506 }
4507
4508 #[test]
4509 fn test_cjk_items_join_without_spaces() {
4510 let items = vec![
4512 TextItem {
4513 text: "である".into(),
4514 x: 100.0,
4515 y: 500.0,
4516 width: 24.0,
4517 height: 12.0,
4518 font: "C2_0".into(),
4519 font_tag: String::new(),
4520 legacy_symbol_rewrite: false,
4521 font_size: 12.0,
4522 page: 1,
4523 is_bold: false,
4524 is_italic: false,
4525 font_weight: None,
4526 bold_source: None,
4527 fixed_pitch: None,
4528 fill_color: None,
4529 stroke_color: None,
4530 render_mode: None,
4531 is_underline: false,
4532 is_strikeout: false,
4533 rotation: 0.0,
4534 advance_known: true,
4535 item_type: ItemType::Text,
4536 mcid: None,
4537 baseline_shift: 0.0,
4538 },
4539 TextItem {
4540 text: "履行義務".into(),
4541 x: 124.0,
4542 y: 500.0,
4543 width: 32.0,
4544 height: 12.0,
4545 font: "C2_0".into(),
4546 font_tag: String::new(),
4547 legacy_symbol_rewrite: false,
4548 font_size: 12.0,
4549 page: 1,
4550 is_bold: false,
4551 is_italic: false,
4552 font_weight: None,
4553 bold_source: None,
4554 fixed_pitch: None,
4555 fill_color: None,
4556 stroke_color: None,
4557 render_mode: None,
4558 is_underline: false,
4559 is_strikeout: false,
4560 rotation: 0.0,
4561 advance_known: true,
4562 item_type: ItemType::Text,
4563 mcid: None,
4564 baseline_shift: 0.0,
4565 },
4566 TextItem {
4567 text: "を識別す".into(),
4568 x: 156.0,
4569 y: 500.0,
4570 width: 32.0,
4571 height: 12.0,
4572 font: "C2_0".into(),
4573 font_tag: String::new(),
4574 legacy_symbol_rewrite: false,
4575 font_size: 12.0,
4576 page: 1,
4577 is_bold: false,
4578 is_italic: false,
4579 font_weight: None,
4580 bold_source: None,
4581 fixed_pitch: None,
4582 fill_color: None,
4583 stroke_color: None,
4584 render_mode: None,
4585 is_underline: false,
4586 is_strikeout: false,
4587 rotation: 0.0,
4588 advance_known: true,
4589 item_type: ItemType::Text,
4590 mcid: None,
4591 baseline_shift: 0.0,
4592 },
4593 ];
4594
4595 let lines = group_into_lines(items);
4596 assert_eq!(lines.len(), 1);
4597 assert_eq!(lines[0].text(), "である履行義務を識別す");
4598 }
4599
4600 fn make_item(text: &str, x: f32, y: f32, width: f32) -> TextItem {
4601 TextItem {
4602 text: text.into(),
4603 x,
4604 y,
4605 width,
4606 height: 12.0,
4607 font: "F1".into(),
4608 font_tag: String::new(),
4609 legacy_symbol_rewrite: false,
4610 font_size: 12.0,
4611 page: 1,
4612 is_bold: false,
4613 is_italic: false,
4614 font_weight: None,
4615 bold_source: None,
4616 fixed_pitch: None,
4617 fill_color: None,
4618 stroke_color: None,
4619 render_mode: None,
4620 is_underline: false,
4621 is_strikeout: false,
4622 rotation: 0.0,
4623 advance_known: true,
4624 item_type: ItemType::Text,
4625 mcid: None,
4626 baseline_shift: 0.0,
4627 }
4628 }
4629
4630 #[test]
4631 fn test_detect_two_columns() {
4632 let mut items = Vec::new();
4633 for i in 0..30 {
4635 let y = 700.0 - (i as f32) * 14.0;
4636 items.push(make_item("Left text here", 72.0, y, 200.0));
4637 items.push(make_item("Right text here", 350.0, y, 200.0));
4638 }
4639 let cols = detect_columns(&items, 1, false);
4640 assert_eq!(cols.len(), 2, "Expected 2 columns, got {:?}", cols);
4641 assert!(cols[0].x_min < cols[1].x_min);
4642 }
4643
4644 #[test]
4645 fn test_detect_three_columns() {
4646 let mut items = Vec::new();
4647 for i in 0..30 {
4649 let y = 700.0 - (i as f32) * 14.0;
4650 items.push(make_item("Col one", 50.0, y, 140.0));
4651 items.push(make_item("Col two", 220.0, y, 140.0));
4652 items.push(make_item("Col three", 390.0, y, 140.0));
4653 }
4654 let cols = detect_columns(&items, 1, false);
4655 assert_eq!(cols.len(), 3, "Expected 3 columns, got {:?}", cols);
4656 }
4657
4658 #[test]
4659 fn test_width_bleed_tolerance() {
4660 let mut items = Vec::new();
4661 for i in 0..30 {
4663 let y = 700.0 - (i as f32) * 14.0;
4664 items.push(make_item("Left text", 72.0, y, 200.0));
4665 items.push(make_item("Right text", 350.0, y, 200.0));
4666 }
4667 for i in 0..3 {
4669 let y = 700.0 - (i as f32) * 14.0;
4670 items.push(make_item("wide", 72.0, y, 320.0));
4671 }
4672 let cols = detect_columns(&items, 1, false);
4673 assert!(
4674 cols.len() >= 2,
4675 "Width bleed should not prevent column detection, got {:?}",
4676 cols
4677 );
4678 }
4679
4680 #[test]
4681 fn test_single_column_no_false_split() {
4682 let mut items = Vec::new();
4683 for i in 0..30 {
4685 let y = 700.0 - (i as f32) * 14.0;
4686 items.push(make_item(
4687 "This is a full-width paragraph of text",
4688 72.0,
4689 y,
4690 468.0,
4691 ));
4692 }
4693 let cols = detect_columns(&items, 1, false);
4694 assert!(
4695 cols.len() <= 1,
4696 "Full-width text should not be split into columns, got {:?}",
4697 cols
4698 );
4699 }
4700
4701 #[test]
4702 fn test_is_rtl_char() {
4703 assert!(is_rtl_char('\u{05D0}'));
4705 assert!(is_rtl_char('\u{0627}'));
4707 assert!(!is_rtl_char('A'));
4709 assert!(!is_rtl_char('\u{4E00}'));
4711 }
4712
4713 #[test]
4714 fn test_is_rtl_text() {
4715 assert!(is_rtl_text(["\u{05E9}\u{05DC}\u{05D5}\u{05DD} 123"].iter()));
4717 assert!(!is_rtl_text(["Hello world"].iter()));
4719 assert!(!is_rtl_text(std::iter::empty::<&str>()));
4721 }
4722
4723 #[test]
4724 fn test_is_rtl_text_weak_chars_do_not_vote() {
4725 assert!(!is_rtl_text(["\u{0661}\u{0662}\u{0663}"].iter()));
4728 assert!(!is_rtl_text(["\u{06F1}\u{06F2}\u{06F3}"].iter()));
4730 assert!(!is_rtl_text(
4732 ["\u{0661}\u{066B}\u{0662}\u{0663}\u{066C}\u{0664}"].iter()
4733 ));
4734 assert!(is_rtl_text(
4736 ["\u{0645}\u{0631}\u{062D}\u{0628}\u{0627} \u{0661}\u{0662}"].iter()
4737 ));
4738 assert!(is_rtl_text(["\u{0645}\u{064E}\u{0631}\u{064D}"].iter()));
4740 assert!(!is_rtl_text(["\u{064E}\u{064F}\u{0650}\u{0651}"].iter()));
4743 assert!(!is_rtl_text(["\u{05B8}\u{05B4}\u{05BC}"].iter()));
4744 assert!(!is_rtl_text(["\u{0661}\u{064E}\u{0662}"].iter()));
4746 }
4747
4748 #[test]
4749 fn test_rtl_line_sorting() {
4750 let mut items = vec![
4751 TextItem {
4752 text: "\u{05D0}".into(), x: 100.0,
4754 y: 700.0,
4755 width: 10.0,
4756 height: 12.0,
4757 font: "F1".into(),
4758 font_tag: String::new(),
4759 legacy_symbol_rewrite: false,
4760 font_size: 12.0,
4761 page: 1,
4762 is_bold: false,
4763 is_italic: false,
4764 font_weight: None,
4765 bold_source: None,
4766 fixed_pitch: None,
4767 fill_color: None,
4768 stroke_color: None,
4769 render_mode: None,
4770 is_underline: false,
4771 is_strikeout: false,
4772 rotation: 0.0,
4773 advance_known: true,
4774 item_type: ItemType::Text,
4775 mcid: None,
4776 baseline_shift: 0.0,
4777 },
4778 TextItem {
4779 text: "\u{05D1}".into(), x: 200.0,
4781 y: 700.0,
4782 width: 10.0,
4783 height: 12.0,
4784 font: "F1".into(),
4785 font_tag: String::new(),
4786 legacy_symbol_rewrite: false,
4787 font_size: 12.0,
4788 page: 1,
4789 is_bold: false,
4790 is_italic: false,
4791 font_weight: None,
4792 bold_source: None,
4793 fixed_pitch: None,
4794 fill_color: None,
4795 stroke_color: None,
4796 render_mode: None,
4797 is_underline: false,
4798 is_strikeout: false,
4799 rotation: 0.0,
4800 advance_known: true,
4801 item_type: ItemType::Text,
4802 mcid: None,
4803 baseline_shift: 0.0,
4804 },
4805 ];
4806 sort_line_items(&mut items, false);
4807 assert_eq!(items[0].x, 200.0);
4809 assert_eq!(items[1].x, 100.0);
4810 }
4811
4812 #[test]
4813 fn test_ltr_unaffected() {
4814 let mut items = vec![
4815 TextItem {
4816 text: "Hello".into(),
4817 x: 100.0,
4818 y: 700.0,
4819 width: 50.0,
4820 height: 12.0,
4821 font: "F1".into(),
4822 font_tag: String::new(),
4823 legacy_symbol_rewrite: false,
4824 font_size: 12.0,
4825 page: 1,
4826 is_bold: false,
4827 is_italic: false,
4828 font_weight: None,
4829 bold_source: None,
4830 fixed_pitch: None,
4831 fill_color: None,
4832 stroke_color: None,
4833 render_mode: None,
4834 is_underline: false,
4835 is_strikeout: false,
4836 rotation: 0.0,
4837 advance_known: true,
4838 item_type: ItemType::Text,
4839 mcid: None,
4840 baseline_shift: 0.0,
4841 },
4842 TextItem {
4843 text: "World".into(),
4844 x: 200.0,
4845 y: 700.0,
4846 width: 50.0,
4847 height: 12.0,
4848 font: "F1".into(),
4849 font_tag: String::new(),
4850 legacy_symbol_rewrite: false,
4851 font_size: 12.0,
4852 page: 1,
4853 is_bold: false,
4854 is_italic: false,
4855 font_weight: None,
4856 bold_source: None,
4857 fixed_pitch: None,
4858 fill_color: None,
4859 stroke_color: None,
4860 render_mode: None,
4861 is_underline: false,
4862 is_strikeout: false,
4863 rotation: 0.0,
4864 advance_known: true,
4865 item_type: ItemType::Text,
4866 mcid: None,
4867 baseline_shift: 0.0,
4868 },
4869 ];
4870 sort_line_items(&mut items, false);
4871 assert_eq!(items[0].x, 100.0);
4873 assert_eq!(items[1].x, 200.0);
4874 }
4875
4876 #[test]
4877 fn test_hangul_is_cjk() {
4878 assert!(is_cjk_char('\u{1100}'));
4880 assert!(is_cjk_char('\u{3131}'));
4882 assert!(is_cjk_char('\u{AC00}'));
4884 assert!(!is_cjk_char('A'));
4886 }
4887
4888 #[test]
4889 fn test_newspaper_layout_detection() {
4890 let make_line = |y: f32, x: f32, page: u32| TextLine {
4892 y,
4893 page,
4894 adaptive_threshold: 0.10,
4895 items: vec![TextItem {
4896 text: "text".into(),
4897 x,
4898 y,
4899 width: 100.0,
4900 height: 12.0,
4901 font: "F1".into(),
4902 font_tag: String::new(),
4903 legacy_symbol_rewrite: false,
4904 font_size: 12.0,
4905 page,
4906 is_bold: false,
4907 is_italic: false,
4908 font_weight: None,
4909 bold_source: None,
4910 fixed_pitch: None,
4911 fill_color: None,
4912 stroke_color: None,
4913 render_mode: None,
4914 is_underline: false,
4915 is_strikeout: false,
4916 rotation: 0.0,
4917 advance_known: true,
4918 item_type: ItemType::Text,
4919 mcid: None,
4920 baseline_shift: 0.0,
4921 }],
4922 };
4923
4924 let col1: Vec<TextLine> = (0..20)
4925 .map(|i| make_line(700.0 - i as f32 * 14.0, 50.0, 1))
4926 .collect();
4927 let col2: Vec<TextLine> = (0..20)
4928 .map(|i| make_line(700.0 - i as f32 * 14.0, 350.0, 1))
4929 .collect();
4930
4931 let cols = vec![
4932 ColumnRegion {
4933 x_min: 0.0,
4934 x_max: 300.0,
4935 },
4936 ColumnRegion {
4937 x_min: 300.0,
4938 x_max: 600.0,
4939 },
4940 ];
4941 assert!(is_newspaper_layout(&[col1, col2], &cols));
4942 }
4943
4944 #[test]
4945 fn test_newspaper_layout_misaligned_baselines() {
4946 let make_line = |y: f32, x: f32, page: u32| TextLine {
4949 y,
4950 page,
4951 adaptive_threshold: 0.10,
4952 items: vec![TextItem {
4953 text: "text".into(),
4954 x,
4955 y,
4956 width: 100.0,
4957 height: 12.0,
4958 font: "F1".into(),
4959 font_tag: String::new(),
4960 legacy_symbol_rewrite: false,
4961 font_size: 12.0,
4962 page,
4963 is_bold: false,
4964 is_italic: false,
4965 font_weight: None,
4966 bold_source: None,
4967 fixed_pitch: None,
4968 fill_color: None,
4969 stroke_color: None,
4970 render_mode: None,
4971 is_underline: false,
4972 is_strikeout: false,
4973 rotation: 0.0,
4974 advance_known: true,
4975 item_type: ItemType::Text,
4976 mcid: None,
4977 baseline_shift: 0.0,
4978 }],
4979 };
4980
4981 let col1: Vec<TextLine> = (0..20)
4983 .map(|i| make_line(700.0 - i as f32 * 14.0, 50.0, 1))
4984 .collect();
4985 let col2: Vec<TextLine> = (0..20)
4986 .map(|i| make_line(685.0 - i as f32 * 14.0, 350.0, 1))
4987 .collect();
4988
4989 let cols = vec![
4990 ColumnRegion {
4991 x_min: 0.0,
4992 x_max: 300.0,
4993 },
4994 ColumnRegion {
4995 x_min: 300.0,
4996 x_max: 600.0,
4997 },
4998 ];
4999 assert!(is_newspaper_layout(&[col1, col2], &cols));
5000 }
5001
5002 #[test]
5003 fn test_tabular_layout_detection() {
5004 let make_line = |y: f32, x: f32, page: u32| TextLine {
5006 y,
5007 page,
5008 adaptive_threshold: 0.10,
5009 items: vec![TextItem {
5010 text: "text".into(),
5011 x,
5012 y,
5013 width: 100.0,
5014 height: 12.0,
5015 font: "F1".into(),
5016 font_tag: String::new(),
5017 legacy_symbol_rewrite: false,
5018 font_size: 12.0,
5019 page,
5020 is_bold: false,
5021 is_italic: false,
5022 font_weight: None,
5023 bold_source: None,
5024 fixed_pitch: None,
5025 fill_color: None,
5026 stroke_color: None,
5027 render_mode: None,
5028 is_underline: false,
5029 is_strikeout: false,
5030 rotation: 0.0,
5031 advance_known: true,
5032 item_type: ItemType::Text,
5033 mcid: None,
5034 baseline_shift: 0.0,
5035 }],
5036 };
5037
5038 let col1: Vec<TextLine> = (0..5)
5039 .map(|i| make_line(700.0 - i as f32 * 14.0, 50.0, 1))
5040 .collect();
5041 let col2: Vec<TextLine> = (0..5)
5042 .map(|i| make_line(700.0 - i as f32 * 14.0, 350.0, 1))
5043 .collect();
5044
5045 let cols = vec![
5046 ColumnRegion {
5047 x_min: 0.0,
5048 x_max: 300.0,
5049 },
5050 ColumnRegion {
5051 x_min: 300.0,
5052 x_max: 600.0,
5053 },
5054 ];
5055 assert!(!is_newspaper_layout(&[col1, col2], &cols));
5056 }
5057
5058 fn make_item_fs(text: &str, x: f32, y: f32, width: f32, font_size: f32) -> TextItem {
5059 TextItem {
5060 text: text.into(),
5061 x,
5062 y,
5063 width,
5064 height: font_size,
5065 font: "F1".into(),
5066 font_tag: String::new(),
5067 legacy_symbol_rewrite: false,
5068 font_size,
5069 page: 1,
5070 is_bold: false,
5071 is_italic: false,
5072 font_weight: None,
5073 bold_source: None,
5074 fixed_pitch: None,
5075 fill_color: None,
5076 stroke_color: None,
5077 render_mode: None,
5078 is_underline: false,
5079 is_strikeout: false,
5080 rotation: 0.0,
5081 advance_known: true,
5082 item_type: ItemType::Text,
5083 mcid: None,
5084 baseline_shift: 0.0,
5085 }
5086 }
5087
5088 #[test]
5089 fn stacked_fraction_glyphs_are_not_merged_into_one_run() {
5090 let items = vec![
5094 make_item_fs("about 3", 91.9, 511.3, 103.2, 10.0),
5095 make_item_fs("1", 196.3, 515.2, 3.7, 7.4),
5096 make_item_fs("3", 196.3, 507.3, 3.7, 7.4),
5097 make_item_fs("bits", 201.5, 511.3, 18.0, 10.0),
5098 ];
5099 let merged = merge_text_items(items);
5100 let texts: Vec<&str> = merged.iter().map(|i| i.text.as_str()).collect();
5101 assert!(texts.contains(&"1") && texts.contains(&"3"), "{texts:?}");
5102 let items = vec![
5104 make_item_fs("Freon", 100.0, 500.0, 26.0, 10.0),
5105 make_item_fs("®", 126.0, 502.0, 6.0, 10.0),
5106 ];
5107 let merged = merge_text_items(items);
5108 assert_eq!(merged.len(), 1);
5109 assert_eq!(merged[0].text, "Freon®");
5110 let items = vec![
5113 make_item_fs("x", 91.9, 511.3, 5.0, 10.0),
5114 make_item_fs("a", 96.9, 515.2, 3.7, 7.4),
5115 make_item_fs("r", 96.9, 507.3, 3.7, 7.4),
5116 ];
5117 let merged = merge_text_items(items);
5118 assert!(
5119 merged.iter().any(|i| i.text.contains("ar")),
5120 "{:?}",
5121 merged.iter().map(|i| i.text.as_str()).collect::<Vec<_>>()
5122 );
5123 }
5124
5125 #[test]
5129 fn small_caps_run_merges_into_one_word() {
5130 let items = vec![
5131 make_item_fs("R", 144.36, 581.84, 7.20, 9.98),
5132 make_item_fs("OLANDO", 151.56, 581.84, 30.56, 6.74),
5133 make_item_fs("T. A", 185.45, 581.84, 17.58, 9.98),
5134 make_item_fs("COSTA", 203.94, 581.84, 23.15, 6.74),
5135 make_item_fs(", P.J.", 227.09, 581.84, 22.56, 9.98),
5136 ];
5137 let merged = merge_text_items(items);
5138 assert_eq!(merged.len(), 1, "got {:?}", merged);
5139 assert_eq!(merged[0].text, "ROLANDO T. ACOSTA, P.J.");
5140 }
5141
5142 #[test]
5145 fn small_caps_merge_does_not_swallow_a_second_column() {
5146 let items = vec![
5147 make_item_fs("R", 144.36, 581.84, 7.20, 9.98),
5149 make_item_fs("OLANDO", 151.56, 581.84, 30.56, 6.74),
5150 make_item_fs("T. A", 185.45, 581.84, 17.58, 9.98),
5151 make_item_fs("COSTA", 203.94, 581.84, 23.15, 6.74),
5152 make_item_fs(", P.J.", 227.09, 581.84, 22.56, 9.98),
5153 make_item_fs("A", 321.96, 581.84, 7.20, 9.98),
5155 make_item_fs("NIL", 329.17, 581.84, 12.72, 6.74),
5156 make_item_fs("C. S", 345.04, 581.84, 19.59, 9.98),
5157 make_item_fs("INGH", 364.62, 581.84, 19.08, 6.74),
5158 ];
5159 let merged = merge_text_items(items);
5160 let texts: Vec<&str> = merged.iter().map(|i| i.text.as_str()).collect();
5161 assert_eq!(
5162 texts,
5163 vec!["ROLANDO T. ACOSTA, P.J.", "ANIL C. SINGH"],
5164 "column gap should keep the two names apart"
5165 );
5166 }
5167
5168 #[test]
5169 fn small_caps_merge_keeps_word_space_between_same_size_capitals() {
5170 let items = vec![
5174 make_item_fs("SEE", 100.0, 500.0, 18.0, 9.98),
5175 make_item_fs("ALSO", 119.2, 500.0, 24.0, 9.0),
5176 ];
5177 let merged = merge_text_items(items);
5178 assert_eq!(merged.len(), 1, "got {:?}", merged);
5179 assert_eq!(merged[0].text, "SEE ALSO");
5180 }
5181
5182 #[test]
5183 fn trailing_digit_is_not_a_capital_awaiting_small_caps() {
5184 assert!(!is_small_caps_continuation(
5187 "ANGELA M. MAZZARELLI1",
5188 &make_item_fs("ANGELA", 100.0, 500.0, 40.0, 9.98),
5189 &make_item_fs("SHULMAN", 140.0, 500.0, 30.0, 6.74),
5190 0.0,
5191 ));
5192 }
5193
5194 #[test]
5195 fn ordinal_suffix_after_a_digit_still_merges() {
5196 for suffix in ["TH", "ST", "ND", "RD"] {
5199 assert!(
5200 is_small_caps_continuation(
5201 "TUESDAY, JULY 4",
5202 &make_item_fs("JULY", 100.0, 500.0, 30.0, 12.0),
5203 &make_item_fs(suffix, 130.0, 500.0, 8.0, 8.0),
5204 0.0,
5205 ),
5206 "{suffix} should merge after a digit"
5207 );
5208 }
5209 }
5210
5211 #[test]
5212 fn superscript_footnote_marker_is_not_a_small_caps_continuation() {
5213 assert!(!is_small_caps_continuation(
5216 "MAZZARELLI",
5217 &make_item_fs("MAZZARELLI", 100.0, 500.0, 50.0, 9.98),
5218 &make_item_fs("1", 150.0, 503.0, 3.0, 6.74),
5219 0.0,
5220 ));
5221 }
5222
5223 #[test]
5224 fn drop_cap_is_not_a_small_caps_continuation() {
5225 assert!(!is_small_caps_continuation(
5228 "T",
5229 &make_item_fs("T", 100.0, 500.0, 20.0, 30.0),
5230 &make_item_fs("he court held", 120.0, 500.0, 60.0, 10.0),
5231 0.0,
5232 ));
5233 }
5234
5235 #[test]
5236 fn separate_word_is_not_a_small_caps_continuation() {
5237 let first = make_item_fs("SEE", 100.0, 500.0, 20.0, 9.98);
5239 let next = make_item_fs("ALSO", 128.0, 500.0, 25.0, 6.74);
5240 assert!(!is_small_caps_continuation("SEE", &first, &next, 8.0));
5241 }
5242
5243 #[test]
5244 fn lowercase_continuation_is_not_small_caps() {
5245 assert!(!is_small_caps_continuation(
5246 "SMALL",
5247 &make_item_fs("SMALL", 100.0, 500.0, 30.0, 9.98),
5248 &make_item_fs("caps", 130.0, 500.0, 20.0, 6.74),
5249 0.0,
5250 ));
5251 }
5252
5253 #[test]
5254 fn too_small_a_ratio_is_not_small_caps() {
5255 assert!(!is_small_caps_continuation(
5257 "A",
5258 &make_item_fs("A", 100.0, 500.0, 7.0, 10.0),
5259 &make_item_fs("BC", 107.0, 500.0, 8.0, 4.0),
5260 0.0,
5261 ));
5262 }
5263
5264 #[test]
5265 fn vertical_run_never_merges_with_body_text_sharing_its_baseline() {
5266 let mut stamp = make_merge_item("arXiv:2301.00001", 12.0, 12.0);
5271 stamp.height = 120.0;
5272 stamp.rotation = 90.0;
5273 let body = make_merge_item("Body text", 26.0, 54.0);
5274
5275 let merged = merge_text_items(vec![stamp.clone(), body.clone()]);
5276 assert_eq!(merged.len(), 2, "{merged:?}");
5277 assert_eq!(merged[0].text, "arXiv:2301.00001");
5278 assert_eq!(merged[1].text, "Body text");
5279
5280 stamp.height = 12.0;
5281 stamp.rotation = 0.0;
5282 stamp.width = 96.0;
5283 let body_after_upright_stamp = make_merge_item("Body text", 110.0, 54.0);
5284 let merged = merge_text_items(vec![stamp, body_after_upright_stamp]);
5285 assert_eq!(merged.len(), 1);
5286 assert_eq!(merged[0].text, "arXiv:2301.00001 Body text");
5287 }
5288
5289 #[test]
5290 fn link_annotations_turn_with_a_rotated_page() {
5291 use crate::tounicode::FontCMaps;
5292 use lopdf::{dictionary, Object, Stream};
5293
5294 let mut doc = lopdf::Document::new();
5298 let widths: Vec<Object> = (0..=255).map(|_| 600.into()).collect();
5299 let font_id = doc.add_object(dictionary! {
5300 "Type" => "Font",
5301 "Subtype" => "Type1",
5302 "BaseFont" => "Helvetica",
5303 "FirstChar" => 0,
5304 "LastChar" => 255,
5305 "Widths" => Object::Array(widths),
5306 });
5307 let content_id = doc.add_object(Object::Stream(Stream::new(
5308 dictionary! {},
5309 b"BT /F1 12 Tf 0 1 -1 0 200 100 Tm (HELLO) Tj ET
5310BT /F1 12 Tf 0 1 -1 0 240 100 Tm (WORLD) Tj ET"
5311 .to_vec(),
5312 )));
5313 let link_id = doc.add_object(dictionary! {
5314 "Type" => "Annot",
5315 "Subtype" => "Link",
5316 "Rect" => vec![188.into(), 100.into(), 200.into(), 140.into()],
5317 "A" => dictionary! {
5318 "S" => "URI",
5319 "URI" => Object::string_literal("https://example.com/"),
5320 },
5321 });
5322 let widget_id = doc.add_object(dictionary! {
5325 "Type" => "Annot",
5326 "Subtype" => "Widget",
5327 "FT" => "Tx",
5328 "T" => Object::string_literal("field"),
5329 "V" => Object::string_literal("value"),
5330 "Rect" => vec![228.into(), 100.into(), 240.into(), 150.into()],
5331 });
5332 let page_id = doc.add_object(dictionary! {
5333 "Type" => "Page",
5334 "Contents" => Object::Reference(content_id),
5335 "Annots" => vec![Object::Reference(link_id), Object::Reference(widget_id)],
5336 "Resources" => dictionary! {
5337 "Font" => dictionary! { "F1" => Object::Reference(font_id) },
5338 },
5339 "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
5340 });
5341 let pages_id = doc.add_object(dictionary! {
5342 "Type" => "Pages",
5343 "Count" => Object::Integer(1),
5344 "Kids" => vec![Object::Reference(page_id)],
5345 });
5346 let catalog_id = doc.add_object(dictionary! {
5347 "Type" => "Catalog",
5348 "Pages" => Object::Reference(pages_id),
5349 "AcroForm" => dictionary! {
5350 "Fields" => vec![Object::Reference(widget_id)],
5351 },
5352 });
5353 doc.trailer.set("Root", Object::Reference(catalog_id));
5354
5355 let font_cmaps = FontCMaps::from_doc(&doc);
5356 let ((items, _, _), _, _, page_rotations, _) =
5357 extract_positioned_text_from_doc(&doc, &font_cmaps, None).unwrap();
5358 assert_eq!(page_rotations.get(&1), Some(&geometry::PageRotation::Ccw));
5359 let field = items
5360 .iter()
5361 .find(|i| matches!(i.item_type, ItemType::FormField))
5362 .expect("form field item");
5363 assert_eq!(
5364 (field.x, field.y, field.width, field.height),
5365 (100.0, -240.0, 50.0, 12.0)
5366 );
5367 let hello = items.iter().find(|i| i.text == "HELLO").unwrap();
5368 assert_eq!(hello.rotation, 0.0);
5369 assert!((hello.x - 100.0).abs() < 0.01 && (hello.y + 200.0).abs() < 0.01);
5370 let link = items
5371 .iter()
5372 .find(|i| matches!(i.item_type, ItemType::Link(_)))
5373 .expect("link item");
5374 assert_eq!(
5375 (link.x, link.y, link.width, link.height),
5376 (100.0, -200.0, 40.0, 12.0)
5377 );
5378 assert_eq!(link.rotation, 0.0);
5379 }
5380
5381 #[test]
5382 fn upside_down_fragments_are_never_concatenated_reversed() {
5383 let mut hello = make_merge_item("HELLO", 310.0, 50.0);
5387 hello.rotation = 180.0;
5388 let mut world = make_merge_item("WORLD", 258.0, 50.0);
5389 world.rotation = 180.0;
5390 let merged = merge_text_items(vec![hello, world]);
5391 assert_eq!(merged.len(), 2, "{merged:?}");
5392 assert!(merged.iter().any(|i| i.text == "HELLO"));
5393 assert!(merged.iter().any(|i| i.text == "WORLD"));
5394 }
5395
5396 #[test]
5397 fn measured_and_estimated_runs_never_merge() {
5398 let measured = make_merge_item("known", 100.0, 30.0);
5402 let mut estimated = make_merge_item("guess", 132.0, 30.0);
5403 estimated.advance_known = false;
5404 assert_eq!(
5405 merge_text_items(vec![measured.clone(), estimated.clone()]).len(),
5406 2
5407 );
5408 assert_eq!(merge_text_items(vec![estimated, measured]).len(), 2);
5409 }
5410
5411 #[test]
5412 fn merged_estimated_runs_keep_the_union_of_their_boxes() {
5413 let mut first = make_merge_item("ab", 100.0, 12.0);
5417 first.advance_known = false;
5418 let mut second = make_merge_item("cdefg", 112.0, 30.0);
5419 second.advance_known = false;
5420 let merged = merge_text_items(vec![first, second]);
5421 assert_eq!(merged.len(), 1, "{merged:?}");
5422 assert_eq!(merged[0].text, "abcdefg");
5423 assert!(!merged[0].advance_known);
5424 assert!((merged[0].x - 100.0).abs() < 1e-3, "x = {}", merged[0].x);
5425 assert!(
5426 (merged[0].width - 42.0).abs() < 1e-3,
5427 "width = {}",
5428 merged[0].width
5429 );
5430 }
5431
5432 #[test]
5433 fn estimated_fragments_that_backtrack_keep_the_union_box() {
5434 let mut a = make_merge_item("abc", 100.0, 18.0);
5438 let mut b = make_merge_item("de", 94.0, 10.0);
5439 let mut c = make_merge_item("fg", 118.0, 12.0);
5440 for item in [&mut a, &mut b, &mut c] {
5441 item.advance_known = false;
5442 item.mcid = Some(1);
5443 }
5444 let merged = merge_text_items(vec![a, b, c]);
5445 assert_eq!(merged.len(), 1, "{merged:?}");
5446 assert!(!merged[0].advance_known);
5447 assert!((merged[0].x - 94.0).abs() < 1e-3, "x = {}", merged[0].x);
5448 assert!(
5449 (merged[0].width - 36.0).abs() < 1e-3,
5450 "width = {}",
5451 merged[0].width
5452 );
5453 }
5454
5455 #[test]
5456 fn side_by_side_vertical_runs_stay_separate_lines() {
5457 let mut first = make_merge_item("line one", 12.0, 12.0);
5460 first.height = 80.0;
5461 first.rotation = 90.0;
5462 let mut second = make_merge_item("line two", 24.0, 12.0);
5463 second.height = 80.0;
5464 second.rotation = 90.0;
5465 let merged = merge_text_items(vec![first, second]);
5466 assert_eq!(merged.len(), 2, "{merged:?}");
5467 }
5468
5469 #[test]
5470 fn detached_accent_before_its_letter_composes_with_the_next_run() {
5471 let mut accent = make_merge_item("\u{00AF}", 108.6, 4.0);
5478 accent.y = 700.2;
5479 let items = vec![
5480 make_merge_item("T", 100.0, 7.3),
5481 accent,
5482 make_merge_item("ohoku", 107.3, 33.0),
5483 ];
5484 let merged = merge_text_items(items);
5485 assert_eq!(merged.len(), 1, "{merged:?}");
5486 assert_eq!(merged[0].text, "T\u{014D}hoku");
5487 }
5488
5489 #[test]
5490 fn detached_accent_after_its_letter_composes_with_the_previous_run() {
5491 let mut accent = make_merge_item("\u{00AF}", 111.3, 4.0);
5494 accent.y = 700.2;
5495 let items = vec![
5496 make_merge_item("m", 100.0, 10.0),
5497 make_merge_item("a", 110.0, 6.7),
5498 accent,
5499 make_merge_item("jas", 116.7, 15.0),
5500 ];
5501 let merged = merge_text_items(items);
5502 assert_eq!(merged.len(), 1, "{merged:?}");
5503 assert_eq!(merged[0].text, "m\u{0101}jas");
5504 }
5505
5506 #[test]
5507 fn detached_accent_over_the_last_glyph_of_a_longer_run_composes_with_it() {
5508 let mut accent = make_merge_item("\u{00AF}", 113.0, 4.0);
5511 accent.y = 700.2;
5512 let items = vec![
5513 make_merge_item("sta", 100.0, 18.0),
5514 accent,
5515 make_merge_item("tus", 118.0, 16.0),
5516 ];
5517 let merged = merge_text_items(items);
5518 assert_eq!(merged.len(), 1, "{merged:?}");
5519 assert_eq!(merged[0].text, "st\u{0101}tus");
5520 }
5521
5522 #[test]
5523 fn spacing_accent_over_no_letter_stays_a_character_of_its_own() {
5524 let items = vec![
5528 make_merge_item("code", 100.0, 24.0),
5529 make_merge_item("\u{0060}", 128.0, 4.0),
5530 make_merge_item("more", 136.0, 24.0),
5531 ];
5532 let merged = merge_text_items(items);
5533 assert_eq!(merged.len(), 1, "{merged:?}");
5534 assert_eq!(merged[0].text, "code \u{0060} more");
5535 }
5536
5537 #[test]
5538 fn spacing_accent_shown_beside_a_letter_is_not_composed() {
5539 let items = vec![
5543 make_merge_item("a", 100.0, 6.7),
5544 make_merge_item("\u{02C6}", 106.7, 4.0),
5545 make_merge_item("b", 110.7, 6.7),
5546 ];
5547 let merged = merge_text_items(items);
5548 assert_eq!(merged.len(), 1, "{merged:?}");
5549 assert_eq!(merged[0].text, "a\u{02C6}b");
5550 }
5551
5552 #[test]
5553 fn accent_over_two_glyphs_composes_with_the_one_that_can() {
5554 let mut accent = make_merge_item("\u{00AF}", 104.5, 4.0);
5557 accent.y = 700.2;
5558 let items = vec![
5559 make_merge_item("t", 100.0, 7.3),
5560 accent,
5561 make_merge_item("ohoku", 107.3, 33.0),
5562 ];
5563 let merged = merge_text_items(items);
5564 assert_eq!(merged.len(), 1, "{merged:?}");
5565 assert_eq!(merged[0].text, "t\u{014D}hoku");
5566 }
5567
5568 #[test]
5569 fn accent_whose_pair_has_no_composed_character_is_left_as_shown() {
5570 let mut accent = make_merge_item("\u{00AF}", 107.0, 4.0);
5573 accent.y = 700.2;
5574 let items = vec![
5575 make_merge_item("u", 100.0, 6.7),
5576 accent,
5577 make_merge_item("tter", 106.7, 16.0),
5578 ];
5579 let merged = merge_text_items(items);
5580 assert!(merged.iter().any(|item| item.text == "utter"), "{merged:?}");
5581 assert!(
5582 merged.iter().any(|item| item.text == "\u{00AF}"),
5583 "{merged:?}"
5584 );
5585 }
5586
5587 #[test]
5588 fn accent_on_another_baseline_is_not_composed() {
5589 let mut accent = make_merge_item("\u{00AF}", 108.6, 4.0);
5592 accent.y = 704.0;
5593 let items = vec![
5594 make_merge_item("T", 100.0, 7.3),
5595 accent,
5596 make_merge_item("ohoku", 107.3, 33.0),
5597 ];
5598 let merged = merge_text_items(items);
5599 assert!(
5600 merged.iter().any(|item| item.text == "Tohoku"),
5601 "{merged:?}"
5602 );
5603 assert!(
5604 merged.iter().any(|item| item.text == "\u{00AF}"),
5605 "{merged:?}"
5606 );
5607 }
5608
5609 #[test]
5610 fn detached_accent_over_a_dotless_i_composes_as_the_dotted_letter() {
5611 let mut accent = make_merge_item("\u{00B4}", 123.4, 4.0);
5614 accent.y = 700.2;
5615 let items = vec![
5616 make_merge_item("Garc", 100.0, 24.0),
5617 accent,
5618 make_merge_item("\u{0131}a", 124.0, 9.0),
5619 ];
5620 let merged = merge_text_items(items);
5621 assert_eq!(merged.len(), 1, "{merged:?}");
5622 assert_eq!(merged[0].text, "Garc\u{00ED}a");
5623 }
5624
5625 #[test]
5626 fn composing_an_accent_drops_its_clip_entry_with_it() {
5627 let mut accent = make_merge_item("\u{00AF}", 108.6, 4.0);
5628 accent.y = 700.2;
5629 let items = vec![
5630 make_merge_item("T", 100.0, 7.3),
5631 accent,
5632 make_merge_item("ohoku", 107.3, 33.0),
5633 ];
5634 let (items, clips) = compose_detached_spacing_accents(items, &[None, None, None], &[]);
5635 assert_eq!(items.len(), 2, "{items:?}");
5636 assert_eq!(clips.len(), 2);
5637 assert_eq!(items[1].text, "\u{014D}hoku");
5638 }
5639
5640 #[test]
5641 fn detached_accent_over_a_wide_capital_composes_with_it() {
5642 let mut accent = make_merge_item("\u{02C6}", 130.37, 4.0);
5646 accent.y = 700.2;
5647 let items = vec![
5648 make_merge_item("Herr", 100.0, 23.34),
5649 accent,
5650 make_merge_item("Willi", 126.7, 22.01),
5651 ];
5652 let merged = merge_text_items(items);
5653 assert_eq!(merged.len(), 1, "{merged:?}");
5654 assert_eq!(merged[0].text, "Herr \u{0174}illi");
5655 }
5656
5657 #[test]
5658 fn detached_accent_over_the_last_letter_before_trailing_spaces_composes() {
5659 let mut accent = make_merge_item("\u{02C7}", 128.34, 4.0);
5664 accent.y = 700.2;
5665 let items = vec![
5666 make_merge_item("Kovac ", 100.0, 40.06),
5667 accent,
5668 make_merge_item("and", 140.06, 20.0),
5669 ];
5670 let merged = merge_text_items(items);
5671 assert_eq!(merged.len(), 1, "{merged:?}");
5672 assert_eq!(merged[0].text, "Kova\u{010D} and");
5673 }
5674
5675 #[test]
5676 fn accent_beside_a_replacement_text_run_is_not_composed() {
5677 let mut accent = make_merge_item("\u{00B4}", 108.6, 4.0);
5681 accent.y = 700.2;
5682 let items = vec![
5683 make_merge_item("T", 100.0, 7.3),
5684 accent,
5685 make_merge_item("eal", 107.3, 20.0),
5686 ];
5687 let merged = merge_text_items_with_clips(items, &[], false, &[false, false, true]);
5688 assert!(
5689 merged.iter().any(|item| item.text.contains('\u{00B4}')),
5690 "{merged:?}"
5691 );
5692 assert!(
5693 !merged.iter().any(|item| item.text.contains('\u{00E9}')),
5694 "{merged:?}"
5695 );
5696 }
5697
5698 #[test]
5699 fn accent_beside_a_run_with_right_to_left_letters_is_not_composed() {
5700 let mut accent = make_merge_item("\u{00B4}", 108.6, 4.0);
5705 accent.y = 700.2;
5706 let items = vec![
5707 make_merge_item("T", 100.0, 7.3),
5708 accent,
5709 make_merge_item("a\u{05D0}\u{05D1}", 107.3, 20.0),
5710 ];
5711 let merged = merge_text_items(items);
5712 assert!(
5713 merged.iter().any(|item| item.text.contains('\u{00B4}')),
5714 "{merged:?}"
5715 );
5716 assert!(
5717 !merged.iter().any(|item| item.text.contains('\u{00E1}')),
5718 "{merged:?}"
5719 );
5720 }
5721
5722 #[test]
5723 fn accent_over_an_oblique_run_is_not_composed() {
5724 for skewed in 0..2 {
5728 let mut accent = make_merge_item("\u{00AF}", 108.6, 4.0);
5729 accent.y = 700.2;
5730 let mut run = make_merge_item("ohoku", 107.3, 33.0);
5731 if skewed == 0 {
5732 accent.rotation = 1.5;
5733 } else {
5734 run.rotation = 1.5;
5735 }
5736 let items = vec![make_merge_item("T", 100.0, 7.3), accent, run];
5737 let merged = merge_text_items(items);
5738 assert!(
5739 merged.iter().any(|item| item.text.contains('\u{00AF}')),
5740 "{merged:?}"
5741 );
5742 assert!(
5743 !merged.iter().any(|item| item.text.contains('\u{014D}')),
5744 "{merged:?}"
5745 );
5746 }
5747 }
5748
5749 fn signed_word_glyphs() -> Vec<TextItem> {
5754 vec![
5755 make_merge_item("\u{1789}\u{17D2}", 20.0, 11.496),
5756 make_merge_item("\u{1789}", 28.82, 0.0),
5757 make_merge_item("\u{179C}", 31.496, 4.128),
5758 make_merge_item("\u{178F}\u{17D2}", 35.624, 9.9),
5759 make_merge_item("\u{1790}", 42.572, 0.0),
5760 make_merge_item("\u{17BB}", 45.524, 3.312),
5761 ]
5762 }
5763
5764 #[test]
5765 fn signs_behind_the_pen_open_no_gap_before_the_next_glyph() {
5766 let merged = merge_text_items(signed_word_glyphs());
5770 assert_eq!(merged.len(), 1);
5771 assert_eq!(
5772 merged[0].text,
5773 "\u{1789}\u{17D2}\u{1789}\u{179C}\u{178F}\u{17D2}\u{1790}\u{17BB}"
5774 );
5775 assert!((merged[0].x + merged[0].width - 48.836).abs() < 0.01);
5776 }
5777
5778 #[test]
5779 fn a_word_gap_after_a_sign_is_still_a_space() {
5780 let mut items = signed_word_glyphs();
5783 for item in &mut items[3..] {
5784 item.x += 4.0;
5785 }
5786 let merged = merge_text_items(items);
5787 assert_eq!(merged.len(), 1);
5788 assert_eq!(
5789 merged[0].text,
5790 "\u{1789}\u{17D2}\u{1789}\u{179C} \u{178F}\u{17D2}\u{1790}\u{17BB}"
5791 );
5792 }
5793
5794 #[test]
5795 fn a_sign_within_its_glyphs_advance_keeps_its_place_past_a_kerned_glyph() {
5796 let items = vec![
5800 make_merge_item("\u{1780}", 20.0, 11.496),
5801 make_merge_item("\u{17BB}", 31.3, 0.0),
5802 make_merge_item("\u{1781}", 31.0, 4.128),
5803 ];
5804 let merged = merge_text_items(items);
5805 assert_eq!(merged.len(), 1);
5806 assert_eq!(merged[0].text, "\u{1780}\u{17BB}\u{1781}");
5807
5808 let items = vec![
5811 make_merge_item("\u{1780}", 20.0, 11.496),
5812 make_merge_item("\u{17BB}", 40.0, 0.0),
5813 make_merge_item("\u{1781}", 31.496, 4.128),
5814 make_merge_item("\u{1782}", 35.624, 9.9),
5815 ];
5816 let merged = merge_text_items(items);
5817 assert_eq!(merged.len(), 1);
5818 assert_eq!(merged[0].text, "\u{1780}\u{1781}\u{1782}\u{17BB}");
5819 }
5820
5821 #[test]
5822 fn a_two_character_sign_inside_a_tracked_run_keeps_the_run_tracked() {
5823 let mut items = glyph_run("HOW", 100.0, 10.0, 2.3);
5828 items.insert(2, make_merge_item("\u{0301}\u{0300}", 118.0, 0.0));
5829 let merged = merge_text_items(items);
5830 assert_eq!(merged.len(), 1);
5831 assert_eq!(merged[0].text, "HO\u{0301}\u{0300}W");
5832 }
5833
5834 fn pointed_rtl_word() -> Vec<TextItem> {
5838 vec![
5839 make_merge_item("\u{05D1}", 130.0, 6.0),
5840 make_merge_item("\u{05D0}", 124.0, 6.0),
5841 make_merge_item("\u{05B8}", 126.4, 0.0),
5842 make_merge_item("\u{05DC}", 118.0, 6.0),
5843 ]
5844 }
5845
5846 #[test]
5847 fn a_sign_on_a_right_to_left_line_follows_its_letter_in_the_reading() {
5848 for visual in [false, true] {
5852 let merged = merge_text_items_with_clips(pointed_rtl_word(), &[], visual, &[]);
5853 assert_eq!(merged.len(), 1, "visual={visual}: {merged:?}");
5854 assert_eq!(
5855 merged[0].text, "\u{05D1}\u{05D0}\u{05B8}\u{05DC}",
5856 "visual={visual}"
5857 );
5858 }
5859 let mut items = pointed_rtl_word();
5862 items[0].x = 129.7;
5863 items[2].x = 129.8;
5864 let merged = merge_text_items(items);
5865 assert_eq!(merged.len(), 1, "{merged:?}");
5866 assert_eq!(merged[0].text, "\u{05D1}\u{05D0}\u{05B8}\u{05DC}");
5867 let mut items = pointed_rtl_word();
5870 items[3].x = 115.0;
5871 let merged = merge_text_items(items);
5872 assert_eq!(merged.len(), 1, "{merged:?}");
5873 assert_eq!(merged[0].text, "\u{05D1}\u{05D0}\u{05B8} \u{05DC}");
5874 }
5875
5876 #[test]
5877 fn a_longer_run_without_advance_is_hidden_text_not_a_sign() {
5878 let mut number = make_merge_item("24", 20.0, 120.0);
5883 number.font_size = 148.0;
5884 let mut hidden = make_merge_item("++3737++33", 139.0, 0.0);
5885 hidden.font_size = 148.0;
5886 let body = make_merge_item("tank with", 100.0, 60.0);
5887 let merged = merge_text_items(vec![number, hidden, body]);
5888 let texts: Vec<&str> = merged.iter().map(|item| item.text.as_str()).collect();
5889 assert_eq!(texts, ["24", "tank with", "++3737++33"]);
5890 }
5891
5892 #[test]
5893 fn a_sign_deep_behind_the_pen_stays_with_its_glyph() {
5894 let items = vec![
5898 make_merge_item("\u{1780}", 20.0, 14.4),
5899 make_merge_item("\u{17BB}", 27.8, 0.0),
5900 make_merge_item("\u{1781}", 34.4, 4.128),
5901 ];
5902 let merged = merge_text_items(items);
5903 assert_eq!(merged.len(), 1);
5904 assert_eq!(merged[0].text, "\u{1780}\u{17BB}\u{1781}");
5905 }
5906}