1use std::collections::{HashMap, HashSet};
64
65use crate::layout::{layout_boxes, LayoutBox, RuntimeConfig};
66use crate::learned::features::{page_features, PageWord};
67use crate::table_trace::TableOrigin;
68use crate::{Block, BlockKind, EngineError, ExtractOptions, PdfDocument, Result, TatrOptions};
69
70#[derive(Debug, Clone, Copy)]
76struct Thresholds {
77 heading: f32,
79 list_item: f32,
81 table: f32,
83 furniture: f32,
85 default: f32,
87}
88
89const THRESHOLDS: Thresholds = Thresholds {
91 heading: 0.25,
92 list_item: 0.10,
93 table: 0.75,
94 furniture: 0.10,
95 default: 0.50,
96};
97
98const SHORT_THRESHOLD: f32 = 0.05;
101
102const SHORT_HEADING_WORDS: usize = 12;
105
106const GRID_FLOOR: f64 = 0.5;
109
110const POSITION_TABLE: f64 = 0.5;
113
114const POSITION_CELLS: f64 = 0.5;
116
117const ORPHAN_PAD: f64 = 2.0;
120
121const COLUMN_GAP_EM: f64 = 1.5;
124
125#[derive(Debug, Clone, Default)]
132pub struct BalancedOptions {
133 keep_furniture: bool,
136 tatr: Option<TatrOptions>,
140}
141
142impl BalancedOptions {
143 #[must_use]
145 pub fn keep_furniture(mut self, keep: bool) -> Self {
146 self.keep_furniture = keep;
147 self
148 }
149
150 #[must_use]
172 pub fn with_tatr(mut self, tatr: TatrOptions) -> Self {
173 self.tatr = Some(tatr);
174 self
175 }
176}
177
178pub fn balanced_page_markdown(
186 doc: &PdfDocument,
187 page: usize,
188 boxes: &[LayoutBox],
189 opts: &BalancedOptions,
190) -> Result<String> {
191 let count = doc.page_count();
192 if page >= count {
193 return Err(EngineError::PageOutOfRange { index: page, count });
194 }
195 let tatr = doc.tatr_context(opts.tatr.as_ref())?;
202 let (width, height) = doc
203 .page_geometry(page)
204 .map(|g| g.effective_dimensions())
205 .unwrap_or((0.0, 0.0));
206 let (before, _) = doc.extract_blocks_and_rules_before_learning()?;
207 let on_page: Vec<Block> = before.into_iter().filter(|b| b.page == page).collect();
208 let refs: Vec<&Block> = on_page.iter().collect();
209 let words = page_features(&refs, width, height).words;
210
211 let options = ExtractOptions::default();
212 let (blocks, rules, withdrawn) = doc.extract_blocks_rules_and_withdrawn(&options)?;
213 let (page_rulings, page_graphics) = doc.page_rulings_and_graphics(page)?;
216 let withdrawn: Vec<Vec<[f64; 4]>> = withdrawn
217 .into_iter()
218 .filter(|table| table.page == page)
219 .map(|table| table.cells)
220 .collect();
221 let engine_blocks: Vec<Block> = blocks.into_iter().filter(|b| b.page == page).collect();
222 let engine_markdown = crate::markdown::to_markdown_with_rules(&engine_blocks, &rules);
223 if words.is_empty() {
224 return Ok(engine_markdown);
225 }
226
227 let (regions, short) = regions_of(boxes, width, height, opts.keep_furniture);
228 let mut matcher = GridMatcher::new(&engine_markdown, &engine_blocks);
229 let region_table = |bbox: [f64; 4]| -> Option<RegionGrid> {
230 match doc.table_in_region(page, bbox) {
231 Ok(cells) => {
232 let grid = crate::markdown::to_markdown(&cells);
233 if grid.is_empty() || grid.trim().is_empty() {
234 None
235 } else {
236 Some(RegionGrid { html: grid, cells })
237 }
238 }
239 Err(_) => None,
240 }
241 };
242 let tatr_table = |bbox: [f64; 4]| -> Option<RegionGrid> {
243 let context = tatr.as_ref()?;
244 match doc.tatr_cells_in_region(page, bbox, context) {
245 Ok(Some(cells)) => {
246 let grid = crate::markdown::to_markdown(&cells);
247 if grid.is_empty() || grid.trim().is_empty() {
248 None
249 } else {
250 Some(RegionGrid { html: grid, cells })
251 }
252 }
253 Ok(None) | Err(_) => None,
254 }
255 };
256 let tatr_table: Option<&dyn Fn([f64; 4]) -> Option<RegionGrid>> = if tatr.is_some() {
257 Some(&tatr_table)
258 } else {
259 None
260 };
261 Ok(assemble(
262 &words,
263 on_page.len(),
264 ®ions,
265 &short,
266 &mut matcher,
267 ®ion_table,
268 tatr_table,
269 &engine_markdown,
270 page,
271 &engine_blocks,
272 &RuleAScope {
273 rulings: &page_rulings,
274 graphics: &page_graphics,
275 withdrawn: &withdrawn,
276 },
277 ))
278}
279
280struct RuleAScope<'a> {
282 rulings: &'a [crate::text::Ruling],
284 graphics: &'a [[f64; 4]],
286 withdrawn: &'a [Vec<[f64; 4]>],
289}
290
291impl RuleAScope<'_> {
292 fn keeps(&self, cells: &[Block]) -> bool {
294 let refs: Vec<&Block> = cells.iter().collect();
295 let origin = crate::table_trace::run_origin(&refs);
296 crate::table_trace::rule_a_keeps(cells, origin, self.rulings, self.graphics)
297 }
298
299 fn holds_withdrawn(&self, bbox: &[f64; 4]) -> bool {
303 self.withdrawn.iter().any(|cells| {
304 let inside = cells
305 .iter()
306 .filter(|c| area(c) > 0.0 && overlap(c, bbox) / area(c) >= POSITION_CELLS)
307 .count();
308 !cells.is_empty() && inside as f64 / cells.len() as f64 >= POSITION_TABLE
309 })
310 }
311}
312
313pub fn balanced_markdown(
320 doc: &PdfDocument,
321 config: &RuntimeConfig,
322 opts: &BalancedOptions,
323) -> Result<String> {
324 let mut pages = Vec::new();
325 for page in 0..doc.page_count() {
326 let boxes = layout_boxes(doc, page, config)?;
327 let markdown = balanced_page_markdown(doc, page, &boxes, opts)?;
328 if !markdown.is_empty() {
329 pages.push(markdown);
330 }
331 }
332 Ok(pages.join("\n\n"))
333}
334
335#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
337enum RegionKind {
338 Heading,
340 ListItem,
342 Table,
344 Furniture,
346 Paragraph,
348}
349
350#[derive(Debug, Clone)]
352struct Region {
353 kind: RegionKind,
355 score: f32,
357 bbox: [f64; 4],
359}
360
361fn class_threshold(label: &str) -> f32 {
363 match label {
364 "section_header" | "title" => THRESHOLDS.heading,
365 "list_item" => THRESHOLDS.list_item,
366 "table" => THRESHOLDS.table,
367 "page_header" | "page_footer" => THRESHOLDS.furniture,
368 _ => THRESHOLDS.default,
369 }
370}
371
372fn to_pdf_box(bbox: [f32; 4], render_w: u32, render_h: u32, page_w: f64, page_h: f64) -> [f64; 4] {
375 let (w, h) = (f64::from(render_w), f64::from(render_h));
376 let (sx, sy) = (page_w / w, page_h / h);
377 let bbox = [
378 f64::from(bbox[0]),
379 f64::from(bbox[1]),
380 f64::from(bbox[2]),
381 f64::from(bbox[3]),
382 ];
383 [
384 bbox[0] * sx,
385 page_h - bbox[3] * sy,
386 bbox[2] * sx,
387 page_h - bbox[1] * sy,
388 ]
389}
390
391fn regions_of(
397 boxes: &[LayoutBox],
398 page_w: f64,
399 page_h: f64,
400 keep_furniture: bool,
401) -> (Vec<Region>, Vec<Region>) {
402 let mut kept = Vec::new();
403 let mut short = Vec::new();
404 for b in boxes {
405 let bbox = to_pdf_box(b.bbox_px, b.render_w, b.render_h, page_w, page_h);
406 let threshold = class_threshold(&b.label);
407 if b.score >= threshold {
408 let mut kind = match b.label.as_str() {
409 "section_header" | "title" => RegionKind::Heading,
410 "list_item" => RegionKind::ListItem,
411 "table" => RegionKind::Table,
412 "page_header" | "page_footer" => RegionKind::Furniture,
413 _ => RegionKind::Paragraph,
414 };
415 if keep_furniture && kind == RegionKind::Furniture {
416 kind = RegionKind::Paragraph;
417 }
418 kept.push(Region {
419 kind,
420 score: b.score,
421 bbox,
422 });
423 } else if (b.label == "section_header" || b.label == "title") && b.score >= SHORT_THRESHOLD
424 {
425 short.push(Region {
426 kind: RegionKind::Heading,
427 score: b.score,
428 bbox,
429 });
430 }
431 }
432 (kept, short)
433}
434
435fn area(box_: &[f64; 4]) -> f64 {
437 (box_[2] - box_[0]).max(0.0) * (box_[3] - box_[1]).max(0.0)
438}
439
440fn overlap(one: &[f64; 4], other: &[f64; 4]) -> f64 {
442 (one[2].min(other[2]) - one[0].max(other[0])).max(0.0)
443 * (one[3].min(other[3]) - one[1].max(other[1])).max(0.0)
444}
445
446fn lines_of(block: usize, words: &[PageWord]) -> Vec<Vec<usize>> {
453 let mut ink: Vec<usize> = words
454 .iter()
455 .enumerate()
456 .filter(|(_, w)| w.block == block)
457 .map(|(i, _)| i)
458 .collect();
459 ink.sort_by(|&a, &b| {
461 words[b].bbox[3]
462 .total_cmp(&words[a].bbox[3])
463 .then_with(|| words[a].bbox[0].total_cmp(&words[b].bbox[0]))
464 });
465 let mut lines: Vec<Vec<usize>> = Vec::new();
466 for word in ink {
467 let box_ = words[word].bbox;
468 let mut placed = false;
469 for line in &mut lines {
470 let low = line
471 .iter()
472 .map(|&w| words[w].bbox[1])
473 .fold(f64::INFINITY, f64::min);
474 let high = line
475 .iter()
476 .map(|&w| words[w].bbox[3])
477 .fold(f64::NEG_INFINITY, f64::max);
478 let shorter = (box_[3] - box_[1]).min(high - low);
479 let shorter = if shorter == 0.0 { 1.0 } else { shorter };
480 let share = (box_[3].min(high) - box_[1].max(low)) / shorter;
481 if share > 0.5 {
482 line.push(word);
483 placed = true;
484 break;
485 }
486 }
487 if !placed {
488 lines.push(vec![word]);
489 }
490 }
491 for line in &mut lines {
492 line.sort_by(|&a, &b| words[a].bbox[0].total_cmp(&words[b].bbox[0]));
493 }
494 let pieces: Vec<Vec<usize>> = lines
495 .iter()
496 .flat_map(|line| across_a_column(line, words))
497 .collect();
498 in_reading_order(pieces, words)
499}
500
501fn across_a_column(line: &[usize], words: &[PageWord]) -> Vec<Vec<usize>> {
507 if line.len() < 2 {
508 return vec![line.to_vec()];
509 }
510 let mut heights: Vec<f64> = line
511 .iter()
512 .map(|&w| words[w].bbox[3] - words[w].bbox[1])
513 .collect();
514 heights.sort_by(f64::total_cmp);
515 let mut height = heights[heights.len() / 2];
516 if height == 0.0 {
517 height = 1.0;
518 }
519 let mut out = Vec::new();
520 let mut run = vec![line[0]];
521 for window in line.windows(2) {
522 let (before, word) = (window[0], window[1]);
523 if (words[word].bbox[0] - words[before].bbox[2]) / height >= COLUMN_GAP_EM {
524 out.push(run);
525 run = vec![word];
526 } else {
527 run.push(word);
528 }
529 }
530 out.push(run);
531 out
532}
533
534fn in_reading_order(pieces: Vec<Vec<usize>>, words: &[PageWord]) -> Vec<Vec<usize>> {
540 if pieces.len() < 2 {
541 return pieces;
542 }
543 struct Column {
544 left: f64,
545 right: f64,
546 pieces: Vec<Vec<usize>>,
547 }
548 let mut columns: Vec<Column> = Vec::new();
549 for piece in pieces {
550 let left = piece
551 .iter()
552 .map(|&w| words[w].bbox[0])
553 .fold(f64::INFINITY, f64::min);
554 let right = piece
555 .iter()
556 .map(|&w| words[w].bbox[2])
557 .fold(f64::NEG_INFINITY, f64::max);
558 let mut placed = false;
559 for column in &mut columns {
560 let width = (right - left).min(column.right - column.left);
561 let width = if width == 0.0 { 1.0 } else { width };
562 let shared = right.min(column.right) - left.max(column.left);
563 if shared / width > 0.5 {
564 column.left = column.left.min(left);
565 column.right = column.right.max(right);
566 column.pieces.push(piece.clone());
567 placed = true;
568 break;
569 }
570 }
571 if !placed {
572 columns.push(Column {
573 left,
574 right,
575 pieces: vec![piece],
576 });
577 }
578 }
579 columns.sort_by(|a, b| a.left.total_cmp(&b.left));
580 let mut out = Vec::new();
581 for column in columns {
582 let mut pieces = column.pieces;
583 pieces.sort_by(|a, b| {
584 let top = |piece: &[usize]| {
585 piece
586 .iter()
587 .map(|&w| words[w].bbox[3])
588 .fold(f64::NEG_INFINITY, f64::max)
589 };
590 top(b).total_cmp(&top(a))
591 });
592 out.extend(pieces);
593 }
594 out
595}
596
597struct PlacedLine {
600 seq: usize,
602 plain: String,
604 styled: String,
606 bbox: [f64; 4],
608 block: usize,
610}
611
612struct RegionGrid {
615 html: String,
617 cells: Vec<Block>,
619}
620
621#[allow(clippy::too_many_arguments)]
632fn assemble(
633 words: &[PageWord],
634 block_count: usize,
635 regions: &[Region],
636 short: &[Region],
637 table_grid: &mut GridMatcher,
638 region_table: &dyn Fn([f64; 4]) -> Option<RegionGrid>,
639 tatr_table: Option<&dyn Fn([f64; 4]) -> Option<RegionGrid>>,
640 engine_markdown: &str,
641 page: usize,
642 page_blocks: &[Block],
643 rule_a: &RuleAScope<'_>,
644) -> String {
645 let page_ctx = Some(crate::table_trace::PageCtx {
646 rulings: rule_a.rulings,
647 graphics: rule_a.graphics,
648 });
649 let pool: Vec<&Region> = regions.iter().chain(short.iter()).collect();
650 let n_kept = regions.len();
651 let styles = if engine_markdown.is_empty() {
652 None
653 } else {
654 Some(engine_styles_for(words, engine_markdown))
655 };
656 let mut items: HashMap<usize, Vec<PlacedLine>> = HashMap::new();
657 let mut order: Vec<usize> = Vec::new();
658 let mut loose: Vec<PlacedLine> = Vec::new();
659 let mut seq = 0usize;
660 for block in 0..block_count {
661 for piece in lines_of(block, words) {
662 if piece.is_empty() {
663 continue;
664 }
665 let mut ink: f64 = piece.iter().map(|&w| area(&words[w].bbox)).sum();
666 if ink == 0.0 {
667 ink = 1.0;
668 }
669 let mut cands: Vec<(f64, f32, usize)> = Vec::new();
670 for (ri, region) in pool.iter().enumerate() {
671 let held: f64 = piece
672 .iter()
673 .map(|&w| overlap(&words[w].bbox, ®ion.bbox))
674 .sum();
675 if held / ink >= 0.5 && (ri < n_kept || piece.len() <= SHORT_HEADING_WORDS) {
676 cands.push((area(®ion.bbox), region.score, ri));
677 }
678 }
679 cands.sort_by(|a, b| {
680 a.0.total_cmp(&b.0)
681 .then_with(|| b.1.total_cmp(&a.1))
682 .then_with(|| a.2.cmp(&b.2))
683 });
684 let pick = cands.first().map(|(_, _, ri)| *ri);
685 let plain = piece
686 .iter()
687 .map(|&w| words[w].text.as_str())
688 .collect::<Vec<_>>()
689 .join(" ");
690 let piece_words: Vec<&PageWord> = piece.iter().map(|&w| &words[w]).collect();
691 let styled = match &styles {
692 Some(styles) => styled_line_engine(&piece, &piece_words, styles),
693 None => styled_line(&piece_words),
694 };
695 let bbox = [
696 piece
697 .iter()
698 .map(|&w| words[w].bbox[0])
699 .fold(f64::INFINITY, f64::min),
700 piece
701 .iter()
702 .map(|&w| words[w].bbox[1])
703 .fold(f64::INFINITY, f64::min),
704 piece
705 .iter()
706 .map(|&w| words[w].bbox[2])
707 .fold(f64::NEG_INFINITY, f64::max),
708 piece
709 .iter()
710 .map(|&w| words[w].bbox[3])
711 .fold(f64::NEG_INFINITY, f64::max),
712 ];
713 let line = PlacedLine {
714 seq,
715 plain,
716 styled,
717 bbox,
718 block,
719 };
720 seq += 1;
721 match pick {
722 Some(ri) => {
723 if !items.contains_key(&ri) {
724 order.push(ri);
725 }
726 items.entry(ri).or_default().push(line);
727 }
728 None => loose.push(line),
729 }
730 }
731 }
732
733 let mut decided: HashMap<usize, (RegionKind, Option<String>, bool)> = HashMap::new();
737 for &ri in &order {
738 let lines = &items[&ri];
739 let text = lines
740 .iter()
741 .map(|l| l.plain.as_str())
742 .collect::<Vec<_>>()
743 .join(" ");
744 let mut kind = pool[ri].kind;
745 let mut grid: Option<String> = None;
746 let mut engine_grid = false;
747 let mut refused = kind == RegionKind::Table && rule_a.holds_withdrawn(&pool[ri].bbox);
758 if let (None, RegionKind::Table, Some(tatr), false) = (&grid, kind, tatr_table, refused) {
759 if let Some(built) = tatr(pool[ri].bbox) {
760 crate::table_trace::emit_run(page, &built.cells, page_blocks, page_ctx);
761 if rule_a.keeps(&built.cells) {
762 grid = Some(built.html);
763 engine_grid = true;
764 } else {
765 refused = true;
766 }
767 }
768 }
769 if grid.is_none() && kind == RegionKind::Table {
770 grid = table_grid.table_grid(&text, &pool[ri].bbox);
771 if grid.is_some() {
772 engine_grid = true;
773 if let Some(cells) = table_grid.picked_cells() {
774 let refs: Vec<&Block> = cells.iter().collect();
775 crate::table_trace::emit_cells(
776 page,
777 &refs,
778 page_blocks,
779 TableOrigin::EngineGridMatch,
780 page_ctx,
781 );
782 if !rule_a.keeps(cells) {
783 grid = None;
784 engine_grid = false;
785 refused = true;
786 }
787 }
788 }
789 }
790 if grid.is_none() && kind == RegionKind::Table {
791 if !refused {
792 if let Some(built) = region_table(pool[ri].bbox) {
793 let html = built.html;
794 if !html.is_empty() && !html.trim().is_empty() {
795 crate::table_trace::emit_run(page, &built.cells, page_blocks, page_ctx);
796 if rule_a.keeps(&built.cells) {
797 grid = Some(html);
798 engine_grid = true;
799 }
800 }
801 }
802 }
803 if grid.is_none() {
804 kind = RegionKind::Paragraph;
805 }
806 }
807 decided.insert(ri, (kind, grid, engine_grid));
808 }
809
810 type BlockRow = (
811 usize,
812 RegionKind,
813 String,
814 Option<String>,
815 Vec<String>,
816 Vec<String>,
817 );
818 let mut blocks: Vec<BlockRow> = Vec::new();
819 for (html, tb, oi) in table_grid.orphans() {
825 let orphan_cells = table_grid.orphan_cells(oi);
827 if !orphan_cells.is_empty() && !rule_a.keeps(orphan_cells) {
828 let refs: Vec<&Block> = orphan_cells.iter().collect();
829 crate::table_trace::emit_cells(
830 page,
831 &refs,
832 page_blocks,
833 TableOrigin::EngineGridMatch,
834 page_ctx,
835 );
836 continue;
837 }
838 let pad = [
839 tb[0] - ORPHAN_PAD,
840 tb[1] - ORPHAN_PAD,
841 tb[2] + ORPHAN_PAD,
842 tb[3] + ORPHAN_PAD,
843 ];
844 let inside = |box_: &[f64; 4]| -> bool {
845 let box_area = area(box_);
846 box_area > 0.0 && overlap(box_, &pad) / box_area >= 0.5
847 };
848 let hits: Vec<(usize, usize)> = items
849 .iter()
850 .flat_map(|(ri, lines)| {
851 lines
852 .iter()
853 .filter(|l| inside(&l.bbox))
854 .map(|l| (*ri, l.seq))
855 })
856 .collect();
857 if hits
858 .iter()
859 .any(|(ri, _)| matches!(decided.get(ri), Some((RegionKind::Table, Some(_), _))))
860 {
861 continue;
862 }
863 let lhits: Vec<usize> = loose
864 .iter()
865 .filter(|l| inside(&l.bbox))
866 .map(|l| l.seq)
867 .collect();
868 let mut kinds: HashMap<RegionKind, usize> = HashMap::new();
869 for (ri, _) in &hits {
870 *kinds.entry(decided[ri].0).or_default() += 1;
871 }
872 let n_all = hits.len() + lhits.len();
873 let heading = kinds.get(&RegionKind::Heading).copied().unwrap_or(0);
874 let list_item = kinds.get(&RegionKind::ListItem).copied().unwrap_or(0);
875 if 2 * (heading + list_item) >= n_all || list_item > 0 {
876 continue;
877 }
878 let keep_hits: HashSet<usize> = hits
879 .iter()
880 .filter(|(ri, _)| !matches!(decided[ri].0, RegionKind::Heading | RegionKind::Furniture))
881 .map(|(_, seq)| *seq)
882 .collect();
883 if keep_hits.is_empty() && lhits.is_empty() {
884 continue;
885 }
886 let mut at = usize::MAX;
887 for seq in keep_hits.iter().chain(lhits.iter()) {
888 at = at.min(*seq);
889 }
890 let gone: HashSet<usize> = keep_hits.into_iter().chain(lhits).collect();
891 for lines in items.values_mut() {
892 lines.retain(|l| !gone.contains(&l.seq));
893 }
894 items.retain(|_, lines| !lines.is_empty());
895 order.retain(|ri| items.contains_key(ri));
896 loose.retain(|l| !gone.contains(&l.seq));
897 let refs: Vec<&Block> = orphan_cells.iter().collect();
899 crate::table_trace::emit_cells(
900 page,
901 &refs,
902 page_blocks,
903 TableOrigin::EngineGridMatch,
904 page_ctx,
905 );
906 blocks.push((
907 at,
908 RegionKind::Table,
909 "table".to_string(),
910 Some(html),
911 vec![],
912 vec![],
913 ));
914 }
915
916 for &ri in &order {
917 let lines = &items[&ri];
918 let (kind, grid, engine_grid) = decided.remove(&ri).unwrap_or((pool[ri].kind, None, false));
919 let mut shown = if kind == RegionKind::Heading {
920 join_lines(
921 &lines
922 .iter()
923 .map(|l| (l.plain.as_str(), l.plain.as_str()))
924 .collect::<Vec<_>>(),
925 )
926 } else {
927 join_runs(&join_lines(
928 &lines
929 .iter()
930 .map(|l| (l.plain.as_str(), l.styled.as_str()))
931 .collect::<Vec<_>>(),
932 ))
933 };
934 if kind == RegionKind::ListItem {
935 shown = strip_bullet(&shown);
936 }
937 shown = fold_ligatures(&shown).into_owned();
938 let (mut befores, mut afters) = (Vec::new(), Vec::new());
939 let mut grid = grid;
940 if kind == RegionKind::Table && grid.is_some() && engine_grid {
941 let (before, after, held) = leftover_split(lines, grid.unwrap_or_default());
942 befores = before;
943 afters = after;
944 grid = Some(held);
945 }
946 let at = lines.iter().map(|l| l.seq).min().unwrap_or(usize::MAX);
947 blocks.push((at, kind, shown, grid, befores, afters));
948 }
949 let mut by_block: HashMap<usize, Vec<(usize, &str, &str)>> = HashMap::new();
950 let mut block_order: Vec<usize> = Vec::new();
951 for line in &loose {
952 if !by_block.contains_key(&line.block) {
953 block_order.push(line.block);
954 }
955 by_block
956 .entry(line.block)
957 .or_default()
958 .push((line.seq, &line.styled, &line.plain));
959 }
960 for bi in block_order {
961 let lines = &by_block[&bi];
962 let at = lines.iter().map(|l| l.0).min().unwrap_or(usize::MAX);
963 let pairs: Vec<(&str, &str)> = lines
964 .iter()
965 .map(|(_, styled, plain)| (*plain, *styled))
966 .collect();
967 let shown = fold_ligatures(&join_runs(&join_lines(&pairs))).into_owned();
968 blocks.push((at, RegionKind::Paragraph, shown, None, vec![], vec![]));
969 }
970 blocks.sort_by_key(|b| b.0);
971
972 let mut out: Vec<String> = Vec::new();
973 for (_, kind, text, grid, befores, afters) in blocks {
974 for para in befores {
975 let para = collapse_ws(¶);
976 if !para.is_empty() {
977 out.push(para);
978 }
979 }
980 let text = collapse_ws(&text);
981 if !text.is_empty() && kind != RegionKind::Furniture {
982 out.push(match kind {
983 RegionKind::Heading => format!("## {text}"),
984 RegionKind::ListItem => format!("- {text}"),
985 RegionKind::Table => {
986 grid.unwrap_or_else(|| format!("<table><tr><td>{text}</td></tr></table>"))
987 }
988 RegionKind::Paragraph | RegionKind::Furniture => text,
989 });
990 }
991 for para in afters {
992 let para = collapse_ws(¶);
993 if !para.is_empty() {
994 out.push(para);
995 }
996 }
997 }
998 out.join("\n\n")
999}
1000
1001fn is_py_space(ch: char) -> bool {
1004 ch.is_whitespace() || matches!(ch, '\u{1c}'..='\u{1f}')
1005}
1006
1007fn collapse_ws(text: &str) -> String {
1010 text.split(is_py_space)
1011 .filter(|w| !w.is_empty())
1012 .collect::<Vec<_>>()
1013 .join(" ")
1014}
1015
1016const SYNTAX_CHARS: [char; 8] = ['*', '_', '<', '>', '`', '[', ']', '\\'];
1019
1020const SYNTAX_TILDE: char = '~';
1022
1023fn has_syntax(text: &str) -> bool {
1025 text.chars()
1026 .any(|ch| ch == SYNTAX_TILDE || SYNTAX_CHARS.contains(&ch))
1027}
1028
1029fn marked(run: &str, bold: bool, italic: bool) -> String {
1032 if run.trim_matches(is_py_space).is_empty() || !(bold || italic) || has_syntax(run) {
1033 return run.to_string();
1034 }
1035 let mark = match (bold, italic) {
1036 (true, true) => "***",
1037 (true, false) => "**",
1038 (false, true) => "*",
1039 (false, false) => "",
1040 };
1041 format!("{mark}{run}{mark}")
1042}
1043
1044fn styled_line(piece: &[&PageWord]) -> String {
1047 let mut parts = Vec::new();
1048 let mut run: Vec<&str> = Vec::new();
1049 let mut setting = (false, false);
1050 for word in piece.iter().map(|w| Some(*w)).chain(std::iter::once(None)) {
1051 let key = word.map(|w| (w.bold, w.italic));
1052 if key != Some(setting) {
1053 parts.push(marked(&run.join(" "), setting.0, setting.1));
1054 run.clear();
1055 setting = key.unwrap_or((false, false));
1056 }
1057 if let Some(w) = word {
1058 run.push(w.text.as_str());
1059 }
1060 }
1061 parts
1062 .into_iter()
1063 .filter(|p| !p.is_empty())
1064 .collect::<Vec<_>>()
1065 .join(" ")
1066}
1067
1068type CharTags = (bool, bool, bool);
1070
1071fn ci_find(text: &str, from: usize, needle: &str) -> Option<usize> {
1073 let bytes = text.as_bytes();
1074 let pat = needle.as_bytes();
1075 if pat.is_empty() || from + pat.len() > bytes.len() {
1076 return None;
1077 }
1078 (from..=bytes.len() - pat.len()).find(|&i| {
1079 bytes[i..i + pat.len()]
1080 .iter()
1081 .zip(pat.iter())
1082 .all(|(a, b)| a.to_ascii_lowercase() == *b)
1083 })
1084}
1085
1086fn strip_md_tables(text: &str) -> String {
1089 let mut out = String::new();
1090 let mut i = 0usize;
1091 while i < text.len() {
1092 let Some(p) = ci_find(text, i, "<table") else {
1093 out.push_str(&text[i..]);
1094 break;
1095 };
1096 let Some(q) = ci_find(text, p, "</table>") else {
1097 out.push_str(&text[i..]);
1099 break;
1100 };
1101 out.push_str(&text[i..p]);
1102 out.push('\n');
1103 i = q + "</table>".len();
1104 }
1105 out
1106}
1107
1108fn style_tag_at(chars: &[char], pos: usize) -> Option<(&'static str, bool, usize)> {
1112 if chars.get(pos) != Some(&'<') {
1113 return None;
1114 }
1115 let mut at = pos + 1;
1116 let closing = chars.get(at) == Some(&'/');
1117 if closing {
1118 at += 1;
1119 }
1120 for name in ["sup", "sub", "u"] {
1121 let end = at + name.len();
1122 if end < chars.len()
1123 && chars[at..end]
1124 .iter()
1125 .zip(name.chars())
1126 .all(|(a, b)| a.to_ascii_lowercase() == b)
1127 && chars[end] == '>'
1128 {
1129 return Some((name, closing, end + 1));
1130 }
1131 }
1132 None
1133}
1134
1135fn br_end_at(chars: &[char], pos: usize) -> Option<usize> {
1138 if chars.get(pos) != Some(&'<') {
1139 return None;
1140 }
1141 let mut at = pos + 1;
1142 if chars.get(at) == Some(&'/') {
1143 at += 1;
1144 }
1145 if at + 1 >= chars.len()
1146 || !chars[at].eq_ignore_ascii_case(&'b')
1147 || !chars[at + 1].eq_ignore_ascii_case(&'r')
1148 {
1149 return None;
1150 }
1151 at += 2;
1152 while chars.get(at).is_some_and(|ch| is_py_space(*ch)) {
1153 at += 1;
1154 }
1155 if chars.get(at) == Some(&'/') {
1156 at += 1;
1157 }
1158 if chars.get(at) == Some(&'>') {
1159 Some(at + 1)
1160 } else {
1161 None
1162 }
1163}
1164
1165fn engine_style_tokens(engine_markdown: &str) -> Vec<(String, Vec<CharTags>)> {
1172 let text = strip_md_tables(engine_markdown);
1173 let mut toks = Vec::new();
1174 for line in text.split('\n') {
1175 let stripped = line.trim_matches(is_py_space);
1176 if stripped.starts_with('|') {
1177 continue;
1178 }
1179 let _heading = stripped.starts_with('#');
1182 let mut st = stripped;
1183 if let Some(rest) = st.strip_prefix("- ") {
1185 st = rest;
1186 } else if st.starts_with('#') {
1187 let hashes = st.chars().take_while(|ch| *ch == '#').count();
1188 let after: String = st.chars().skip(hashes).collect();
1189 if let Some(rest) = after.strip_prefix(' ') {
1190 st = &st[hashes + 1..];
1191 let _ = rest;
1192 }
1193 } else {
1194 let digits = st.chars().take_while(|ch| ch.is_ascii_digit()).count();
1195 let after: String = st.chars().skip(digits).collect();
1196 if digits > 0 && after.starts_with(". ") {
1197 st = &st[digits + 2..];
1198 }
1199 }
1200 let chars: Vec<char> = st.chars().collect();
1201 let mut tags = [false, false, false];
1202 let mut cur = String::new();
1203 let mut sty: Vec<CharTags> = Vec::new();
1204 let mut i = 0usize;
1205 let mut flush = |cur: &mut String, sty: &mut Vec<CharTags>| {
1206 if !cur.is_empty() {
1207 toks.push((std::mem::take(cur), std::mem::take(sty)));
1208 }
1209 };
1210 while i < chars.len() {
1211 if let Some((name, closing, end)) = style_tag_at(&chars, i) {
1212 let slot = match name {
1213 "sup" => 0,
1214 "sub" => 1,
1215 _ => 2,
1216 };
1217 tags[slot] = !closing;
1218 i = end;
1219 continue;
1220 }
1221 if chars[i] == '<' {
1222 if let Some(end) = br_end_at(&chars, i) {
1223 flush(&mut cur, &mut sty);
1224 i = end;
1225 continue;
1226 }
1227 }
1228 if chars[i] == '*' {
1229 let mut j = i;
1230 while j < chars.len() && chars[j] == '*' {
1231 j += 1;
1232 }
1233 i = j;
1236 continue;
1237 }
1238 if is_py_space(chars[i]) {
1239 flush(&mut cur, &mut sty);
1240 i += 1;
1241 continue;
1242 }
1243 cur.push(chars[i]);
1244 sty.push((tags[0], tags[1], tags[2]));
1245 i += 1;
1246 }
1247 flush(&mut cur, &mut sty);
1248 }
1249 toks
1250}
1251
1252struct EngineStyles {
1255 tags: HashMap<usize, Vec<CharTags>>,
1257 glue: HashSet<usize>,
1259}
1260
1261fn engine_styles_for(words: &[PageWord], engine_markdown: &str) -> EngineStyles {
1267 let mut out = EngineStyles {
1268 tags: HashMap::new(),
1269 glue: HashSet::new(),
1270 };
1271 let eng = engine_style_tokens(engine_markdown);
1272 if eng.is_empty() || words.is_empty() {
1273 return out;
1274 }
1275 let a: Vec<&str> = words.iter().map(|w| w.text.as_str()).collect();
1276 let b: Vec<&str> = eng.iter().map(|(t, _)| t.as_str()).collect();
1277 for opcode in SequenceMatcher::new(&a, &b).opcodes() {
1278 match opcode.tag {
1279 OpTag::Equal => {
1280 for k in 0..opcode.a_end - opcode.a_start {
1281 if eng[opcode.b_start + k].1.len() == a[opcode.a_start + k].chars().count() {
1282 out.tags
1283 .insert(opcode.a_start + k, eng[opcode.b_start + k].1.clone());
1284 }
1285 }
1286 }
1287 OpTag::Replace => {
1288 let (mut i, mut j) = (opcode.a_start, opcode.b_start);
1289 while i < opcode.a_end && j < opcode.b_end {
1290 let (tok, sty) = &eng[j];
1291 let tok_len = tok.chars().count();
1292 let (mut k, mut acc) = (i, String::new());
1293 while k < opcode.a_end
1294 && acc.chars().count() < tok_len
1295 && tok.starts_with(&format!("{acc}{}", a[k]))
1296 {
1297 acc.push_str(a[k]);
1298 k += 1;
1299 }
1300 if acc == *tok && k > i {
1301 let mut at = 0usize;
1302 for (off, word) in a[i..k].iter().enumerate() {
1303 let m = i + off;
1304 let len = word.chars().count();
1305 out.tags.insert(m, sty[at..at + len].to_vec());
1306 let raised = sty[at..at + len].iter().all(|t| t.0 || t.1);
1307 at += len;
1308 if m > i && !raised {
1309 out.glue.insert(m);
1310 }
1311 }
1312 i = k;
1313 j += 1;
1314 } else if a[i].chars().count() < tok_len && tok.starts_with(a[i]) {
1315 i += 1;
1316 } else {
1317 j += 1;
1318 }
1319 }
1320 }
1321 OpTag::Delete | OpTag::Insert => {}
1322 }
1323 }
1324 out
1325}
1326
1327fn tagged(text: &str, styles: &[CharTags]) -> String {
1329 let chars: Vec<char> = text.chars().collect();
1330 let mut parts = Vec::new();
1331 let mut run = String::new();
1332 let mut key: Option<CharTags> = None;
1333 let flush = |run: &mut String, key: Option<CharTags>, parts: &mut Vec<String>| {
1334 if run.is_empty() {
1335 return;
1336 }
1337 let mut body = std::mem::take(run);
1338 if let Some((sup, sub, u)) = key {
1339 if sup {
1340 body = format!("<sup>{body}</sup>");
1341 } else if sub {
1342 body = format!("<sub>{body}</sub>");
1343 }
1344 if u {
1345 body = format!("<u>{body}</u>");
1346 }
1347 }
1348 parts.push(body);
1349 };
1350 for (index, ch) in chars.iter().enumerate().take(styles.len()) {
1352 let next = Some(styles[index]);
1353 if next != key && !run.is_empty() {
1354 flush(&mut run, key, &mut parts);
1355 }
1356 key = next;
1357 run.push(*ch);
1358 }
1359 flush(&mut run, key, &mut parts);
1360 parts.join("")
1361}
1362
1363fn styled_line_engine(piece: &[usize], words: &[&PageWord], styles: &EngineStyles) -> String {
1367 fn close(run: &[String], plain_run: &[String], setting: (bool, bool), parts: &mut Vec<String>) {
1368 let plain = plain_run.join(" ");
1369 let tagged_text = run.join(" ");
1370 if plain.trim_matches(is_py_space).is_empty() {
1371 return;
1372 }
1373 if !(setting.0 || setting.1) || has_syntax(&plain) {
1374 parts.push(tagged_text);
1375 } else {
1376 let mark = match setting {
1377 (true, true) => "***",
1378 (true, false) => "**",
1379 (false, true) => "*",
1380 (false, false) => "",
1381 };
1382 parts.push(format!("{mark}{tagged_text}{mark}"));
1383 }
1384 }
1385
1386 let mut parts: Vec<String> = Vec::new();
1387 let mut run: Vec<String> = Vec::new();
1388 let mut plain_run: Vec<String> = Vec::new();
1389 let mut setting = (false, false);
1390 let len = piece.len();
1391 for n in 0..=len {
1392 let key = if n < len {
1393 Some((words[n].bold, words[n].italic))
1394 } else {
1395 None
1396 };
1397 if key != Some(setting) {
1398 close(&run, &plain_run, setting, &mut parts);
1399 run.clear();
1400 plain_run.clear();
1401 setting = key.unwrap_or((false, false));
1402 }
1403 if n < len {
1404 let index = piece[n];
1405 let word = words[n];
1406 let tagged_text = match styles.tags.get(&index) {
1407 Some(st) if !has_syntax(&word.text) => tagged(&word.text, st),
1408 _ => word.text.clone(),
1409 };
1410 if styles.glue.contains(&index) && !run.is_empty() {
1412 if let (Some(last), Some(plain)) = (run.last_mut(), plain_run.last_mut()) {
1413 last.push_str(&tagged_text);
1414 plain.push_str(&word.text);
1415 }
1416 } else if styles.glue.contains(&index) && !parts.is_empty() {
1417 run.push(format!("\x00{tagged_text}"));
1418 plain_run.push(word.text.clone());
1419 } else {
1420 run.push(tagged_text);
1421 plain_run.push(word.text.clone());
1422 }
1423 }
1424 }
1425 join_non_empty(&parts)
1427 .replace(" \x00", "")
1428 .replace('\x00', "")
1429}
1430
1431fn join_non_empty(parts: &[String]) -> String {
1433 parts
1434 .iter()
1435 .filter(|p| !p.is_empty())
1436 .cloned()
1437 .collect::<Vec<_>>()
1438 .join(" ")
1439}
1440
1441#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1443enum OpTag {
1444 Replace,
1446 Delete,
1448 Insert,
1450 Equal,
1452}
1453
1454struct Opcode {
1456 tag: OpTag,
1458 a_start: usize,
1460 a_end: usize,
1462 b_start: usize,
1464 b_end: usize,
1466}
1467
1468struct SequenceMatcher<'a> {
1476 a: &'a [&'a str],
1478 b: &'a [&'a str],
1480 b2j: HashMap<&'a str, Vec<usize>>,
1482}
1483
1484impl<'a> SequenceMatcher<'a> {
1485 fn new(a: &'a [&'a str], b: &'a [&'a str]) -> Self {
1487 let mut b2j: HashMap<&str, Vec<usize>> = HashMap::new();
1488 for (i, elt) in b.iter().enumerate() {
1489 b2j.entry(elt).or_default().push(i);
1490 }
1491 Self { a, b, b2j }
1492 }
1493
1494 fn find_longest_match(
1498 &self,
1499 alo: usize,
1500 ahi: usize,
1501 blo: usize,
1502 bhi: usize,
1503 ) -> (usize, usize, usize) {
1504 let (mut besti, mut bestj, mut bestsize) = (alo, blo, 0);
1505 let mut j2len: HashMap<usize, usize> = HashMap::new();
1506 for i in alo..ahi {
1507 let mut newj2len: HashMap<usize, usize> = HashMap::new();
1508 if let Some(js) = self.b2j.get(self.a[i]) {
1509 for &j in js {
1510 if j < blo {
1511 continue;
1512 }
1513 if j >= bhi {
1514 break;
1515 }
1516 let k = j2len.get(&(j.wrapping_sub(1))).copied().unwrap_or(0) + 1;
1517 newj2len.insert(j, k);
1518 if k > bestsize {
1519 besti = i + 1 - k;
1520 bestj = j + 1 - k;
1521 bestsize = k;
1522 }
1523 }
1524 }
1525 j2len = newj2len;
1526 }
1527 while besti > alo && bestj > blo && self.a[besti - 1] == self.b[bestj - 1] {
1531 besti -= 1;
1532 bestj -= 1;
1533 bestsize += 1;
1534 }
1535 while besti + bestsize < ahi
1536 && bestj + bestsize < bhi
1537 && self.a[besti + bestsize] == self.b[bestj + bestsize]
1538 {
1539 bestsize += 1;
1540 }
1541 (besti, bestj, bestsize)
1542 }
1543
1544 fn matching_blocks(&self) -> Vec<(usize, usize, usize)> {
1547 let (la, lb) = (self.a.len(), self.b.len());
1548 let mut queue = vec![(0, la, 0, lb)];
1549 let mut matching = Vec::new();
1550 while let Some((alo, ahi, blo, bhi)) = queue.pop() {
1551 let (i, j, k) = self.find_longest_match(alo, ahi, blo, bhi);
1552 if k > 0 {
1553 matching.push((i, j, k));
1554 if alo < i && blo < j {
1555 queue.push((alo, i, blo, j));
1556 }
1557 if i + k < ahi && j + k < bhi {
1558 queue.push((i + k, ahi, j + k, bhi));
1559 }
1560 }
1561 }
1562 matching.sort();
1563 let mut non_adjacent = Vec::new();
1564 let (mut i1, mut j1, mut k1) = (0, 0, 0);
1565 for (i2, j2, k2) in matching {
1566 if i1 + k1 == i2 && j1 + k1 == j2 {
1567 k1 += k2;
1568 } else {
1569 if k1 > 0 {
1570 non_adjacent.push((i1, j1, k1));
1571 }
1572 (i1, j1, k1) = (i2, j2, k2);
1573 }
1574 }
1575 if k1 > 0 {
1576 non_adjacent.push((i1, j1, k1));
1577 }
1578 non_adjacent.push((la, lb, 0));
1579 non_adjacent
1580 }
1581
1582 fn opcodes(&self) -> Vec<Opcode> {
1584 let (mut i, mut j) = (0, 0);
1585 let mut out = Vec::new();
1586 for (ai, bj, size) in self.matching_blocks() {
1587 let tag = if i < ai && j < bj {
1588 Some(OpTag::Replace)
1589 } else if i < ai {
1590 Some(OpTag::Delete)
1591 } else if j < bj {
1592 Some(OpTag::Insert)
1593 } else {
1594 None
1595 };
1596 if let Some(tag) = tag {
1597 out.push(Opcode {
1598 tag,
1599 a_start: i,
1600 a_end: ai,
1601 b_start: j,
1602 b_end: bj,
1603 });
1604 }
1605 (i, j) = (ai + size, bj + size);
1606 if size > 0 {
1607 out.push(Opcode {
1608 tag: OpTag::Equal,
1609 a_start: ai,
1610 a_end: i,
1611 b_start: bj,
1612 b_end: j,
1613 });
1614 }
1615 }
1616 out
1617 }
1618}
1619
1620fn join_runs(text: &str) -> String {
1628 let chars: Vec<char> = text.chars().collect();
1629 let mut out = String::new();
1630 let mut i = 0usize;
1631 while i < chars.len() {
1632 let mut end = None;
1633 if chars[i] == '*' && i > 0 && !is_py_space(chars[i - 1]) && chars[i - 1] != '*' {
1634 let mut run = 0usize;
1635 while i + run < chars.len() && chars[i + run] == '*' {
1636 run += 1;
1637 }
1638 for n in (1..=run.min(3)).rev() {
1639 let mid = i + n;
1640 let second = mid + 1;
1641 if chars.get(mid) == Some(&' ')
1642 && second + n <= chars.len()
1643 && chars[second..second + n].iter().all(|ch| *ch == '*')
1644 && chars
1645 .get(second + n)
1646 .is_some_and(|ch| !is_py_space(*ch) && *ch != '*')
1647 {
1648 end = Some(second + n);
1649 break;
1650 }
1651 }
1652 }
1653 match end {
1654 Some(stop) => {
1655 out.push(' ');
1656 i = stop;
1657 }
1658 None => {
1659 out.push(chars[i]);
1660 i += 1;
1661 }
1662 }
1663 }
1664 out.replace("</u> <u>", " ")
1665}
1666
1667fn fold_ligatures(text: &str) -> std::borrow::Cow<'_, str> {
1670 crate::ligatures::fold(text)
1671}
1672
1673fn hyphen_wrap(prev: &str, nxt: &str) -> bool {
1677 let trimmed: String = nxt.chars().skip_while(|ch| is_py_space(*ch)).collect();
1678 crate::text::is_hyphen_wrap_candidate(prev, &trimmed)
1679}
1680
1681fn join_lines(lines: &[(&str, &str)]) -> String {
1685 let mut out = String::new();
1686 let mut prev_plain: Option<&str> = None;
1687 for (plain, shown) in lines {
1688 let wrap = prev_plain.is_some_and(|prev| hyphen_wrap(prev, plain))
1689 && out.ends_with('-')
1690 && shown.chars().next().is_some_and(|ch| ch.is_lowercase());
1691 if prev_plain.is_none() {
1692 out.push_str(shown);
1693 } else if wrap {
1694 out.pop();
1695 out.push_str(shown);
1696 } else {
1697 out.push(' ');
1698 out.push_str(shown);
1699 }
1700 prev_plain = Some(plain);
1701 }
1702 out
1703}
1704
1705fn strip_bullet(text: &str) -> String {
1708 let trimmed: String = text.chars().skip_while(|ch| is_py_space(*ch)).collect();
1709 let mut chars = trimmed.chars();
1710 match (chars.next(), chars.next()) {
1711 (Some(first), Some(' ')) if crate::lists::BULLET_CHARS.contains(&first) => trimmed
1712 [trimmed
1713 .char_indices()
1714 .nth(2)
1715 .map(|(i, _)| i)
1716 .unwrap_or(trimmed.len())..]
1717 .trim_start_matches(is_py_space)
1718 .to_string(),
1719 _ => text.to_string(),
1720 }
1721}
1722
1723fn is_word_char(ch: char) -> bool {
1726 ch.is_alphanumeric() || ch == '_'
1727}
1728
1729fn strip_inline_tags(line: &str) -> String {
1731 const TAGS: [&str; 9] = ["sub", "sup", "b", "i", "em", "strong", "s", "del", "u"];
1733 let chars: Vec<char> = line.chars().collect();
1734 let mut out = String::new();
1735 let mut i = 0usize;
1736 while i < chars.len() {
1737 let mut end = None;
1738 if chars[i] == '<' {
1739 let mut at = i + 1;
1740 if chars.get(at) == Some(&'/') {
1741 at += 1;
1742 }
1743 for name in TAGS {
1744 let stop = at + name.len();
1745 if stop < chars.len()
1746 && chars[at..stop]
1747 .iter()
1748 .zip(name.chars())
1749 .all(|(a, b)| a.to_ascii_lowercase() == b)
1750 && chars[stop] == '>'
1751 {
1752 end = Some(stop + 1);
1753 break;
1754 }
1755 }
1756 }
1757 match end {
1758 Some(stop) => i = stop,
1759 None => {
1760 out.push(chars[i]);
1761 i += 1;
1762 }
1763 }
1764 }
1765 out
1766}
1767
1768fn delimited_at(chars: &[char], i: usize, delim: &[char]) -> Option<(usize, usize, usize)> {
1774 if !chars[i..].starts_with(delim) {
1775 return None;
1776 }
1777 let p = i + delim.len();
1778 if chars.get(p).is_none_or(|ch| is_py_space(*ch)) {
1779 return None;
1780 }
1781 let mut at = p + 1;
1782 while at < chars.len() {
1783 if !is_py_space(chars[at]) && chars[at + 1..].starts_with(delim) {
1784 return Some((p, at + 1, at + 1 + delim.len()));
1785 }
1786 at += 1;
1787 }
1788 if chars[p + 1..].starts_with(delim) {
1789 return Some((p, p + 1, p + 1 + delim.len()));
1790 }
1791 None
1792}
1793
1794fn strip_strong(line: &str) -> String {
1799 let chars: Vec<char> = line.chars().collect();
1800 let triple: Vec<char> = "***".chars().collect();
1801 let double: Vec<char> = "**".chars().collect();
1802 let mut out = String::new();
1803 let mut i = 0usize;
1804 while i < chars.len() {
1805 let matched = delimited_at(&chars, i, &triple).or_else(|| delimited_at(&chars, i, &double));
1806 match matched {
1807 Some((from, to, stop)) => {
1808 out.extend(chars[from..to].iter());
1809 i = stop;
1810 }
1811 None => {
1812 out.push(chars[i]);
1813 i += 1;
1814 }
1815 }
1816 }
1817 out
1818}
1819
1820fn strip_strike(line: &str) -> String {
1823 let chars: Vec<char> = line.chars().collect();
1824 let wide: Vec<char> = "~~".chars().collect();
1825 let mut out = String::new();
1826 let mut i = 0usize;
1827 while i < chars.len() {
1828 match delimited_at(&chars, i, &wide) {
1829 Some((from, to, stop)) => {
1830 out.extend(chars[from..to].iter());
1831 i = stop;
1832 }
1833 None => {
1834 out.push(chars[i]);
1835 i += 1;
1836 }
1837 }
1838 }
1839 out
1840}
1841
1842fn strip_em(line: &str) -> String {
1847 let chars: Vec<char> = line.chars().collect();
1848 let closed = |after: usize| -> bool {
1849 chars.get(after) == Some(&'*')
1850 && chars
1851 .get(after + 1)
1852 .is_none_or(|ch| !is_word_char(*ch) && *ch != '*')
1853 };
1854 let mut out = String::new();
1855 let mut i = 0usize;
1856 while i < chars.len() {
1857 let mut matched: Option<(usize, usize)> = None;
1858 if chars[i] == '*' && (i == 0 || (!is_word_char(chars[i - 1]) && chars[i - 1] != '*')) {
1859 let p = i + 1;
1860 if chars.get(p).is_some_and(|ch| !is_py_space(*ch)) {
1861 let mut at = p + 1;
1862 while at < chars.len() && chars[p + 1..at].iter().all(|ch| *ch != '*') {
1865 if !is_py_space(chars[at]) && closed(at + 1) {
1866 matched = Some((p, at + 1));
1867 break;
1868 }
1869 at += 1;
1870 }
1871 if matched.is_none() && closed(p + 1) {
1872 matched = Some((p, p + 1));
1873 }
1874 }
1875 }
1876 match matched {
1877 Some((from, to)) => {
1878 out.extend(chars[from..to].iter());
1879 i = to + 1;
1880 }
1881 None => {
1882 out.push(chars[i]);
1883 i += 1;
1884 }
1885 }
1886 }
1887 out
1888}
1889
1890fn unmarked(line: &str) -> String {
1894 let line = strip_inline_tags(line);
1895 let line = strip_strong(&line);
1896 let line = strip_em(&line);
1897 strip_strike(&line)
1898}
1899
1900fn strip_comments(text: &str) -> String {
1902 let mut out = String::new();
1903 let mut i = 0usize;
1904 while i < text.len() {
1905 let Some(p) = ci_find(text, i, "<!--") else {
1906 out.push_str(&text[i..]);
1907 break;
1908 };
1909 let Some(q) = text[p..].find("-->").map(|k| p + k) else {
1911 out.push_str(&text[i..]);
1912 break;
1913 };
1914 out.push_str(&text[i..p]);
1915 out.push(' ');
1916 i = q + "-->".len();
1917 }
1918 out
1919}
1920
1921fn strip_images(text: &str) -> String {
1923 let chars: Vec<char> = text.chars().collect();
1924 let mut out = String::new();
1925 let mut i = 0usize;
1926 while i < chars.len() {
1927 let mut end = None;
1928 if chars[i] == '!' && chars.get(i + 1) == Some(&'[') {
1929 let mut j = i + 2;
1930 while j < chars.len() && chars[j] != ']' && chars[j] != '\n' {
1931 j += 1;
1932 }
1933 if chars.get(j) == Some(&']') && chars.get(j + 1) == Some(&'(') {
1934 let mut k = j + 2;
1935 while k < chars.len() && chars[k] != ')' && !is_py_space(chars[k]) {
1936 k += 1;
1937 }
1938 let mut title_end = None;
1940 let mut s = k;
1941 while s < chars.len() && is_py_space(chars[s]) {
1942 s += 1;
1943 }
1944 if s > k && chars.get(s) == Some(&'"') {
1945 let mut quotes = Vec::new();
1947 let mut q = s + 1;
1948 while q < chars.len() {
1949 if chars[q] == '"' {
1950 quotes.push(q);
1951 }
1952 q += 1;
1953 }
1954 for q in quotes.iter().rev() {
1955 if chars.get(q + 1) == Some(&')') {
1956 title_end = Some(q + 2);
1957 break;
1958 }
1959 }
1960 }
1961 if let Some(stop) = title_end {
1962 end = Some(stop);
1963 } else if chars.get(k) == Some(&')') {
1964 end = Some(k + 1);
1965 }
1966 }
1967 }
1968 match end {
1969 Some(stop) => {
1970 out.push(' ');
1971 i = stop;
1972 }
1973 None => {
1974 out.push(chars[i]);
1975 i += 1;
1976 }
1977 }
1978 }
1979 out
1980}
1981
1982fn strip_fences(text: &str) -> String {
1986 text.split('\n')
1987 .map(|line| {
1988 let fenced = line
1989 .trim_matches([' ', '\t'])
1990 .strip_prefix("```")
1991 .is_some_and(|rest| {
1992 rest.chars()
1993 .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '+' || ch == '-')
1994 });
1995 if fenced {
1996 ""
1997 } else {
1998 line
1999 }
2000 })
2001 .collect::<Vec<_>>()
2002 .join("\n")
2003}
2004
2005fn is_table_row(line: &str) -> bool {
2007 let trimmed = line.trim_matches(is_py_space);
2008 let mut chars = trimmed.chars();
2009 match (chars.next(), trimmed.chars().count()) {
2010 (Some('|'), n) if n >= 2 => trimmed.ends_with('|'),
2011 _ => false,
2012 }
2013}
2014
2015fn is_table_rule(line: &str) -> bool {
2017 let trimmed = line.trim_matches(is_py_space);
2018 let chars: Vec<char> = trimmed.chars().collect();
2019 chars.len() >= 3
2020 && chars[0] == '|'
2021 && chars[chars.len() - 1] == '|'
2022 && chars[1..chars.len() - 1]
2023 .iter()
2024 .all(|ch| is_py_space(*ch) || matches!(*ch, ':' | '|' | '-'))
2025}
2026
2027fn pipe_to_text(rows: &[String]) -> String {
2029 let mut cells = Vec::new();
2030 for row in rows {
2031 for cell in row.trim_matches(is_py_space).trim_matches('|').split('|') {
2032 let cell = cell.trim_matches(is_py_space);
2033 if !cell.is_empty() {
2034 cells.push(cell);
2035 }
2036 }
2037 }
2038 cells.join(" ")
2039}
2040
2041fn pipe_to_html(rows: &[String], header: usize) -> String {
2044 let mut out = String::from("<table>");
2045 for (index, row) in rows.iter().enumerate() {
2046 let cells: Vec<&str> = row
2047 .trim_matches(is_py_space)
2048 .trim_matches('|')
2049 .split('|')
2050 .map(|c| c.trim_matches(is_py_space))
2051 .collect();
2052 let tag = if index < header { "th" } else { "td" };
2053 out.push_str("<tr>");
2054 for cell in cells {
2055 out.push_str(&format!("<{tag}>{cell}</{tag}>"));
2056 }
2057 out.push_str("</tr>");
2058 }
2059 out.push_str("</table>");
2060 out
2061}
2062
2063fn strip_tags_plus(text: &str) -> String {
2065 let chars: Vec<char> = text.chars().collect();
2066 let mut out = String::new();
2067 let mut i = 0usize;
2068 while i < chars.len() {
2069 if chars[i] == '<' {
2070 let mut j = i + 1;
2071 while j < chars.len() && chars[j] != '>' {
2072 j += 1;
2073 }
2074 if j < chars.len() && j > i + 1 {
2075 out.push(' ');
2076 i = j + 1;
2077 continue;
2078 }
2079 }
2080 out.push(chars[i]);
2081 i += 1;
2082 }
2083 out
2084}
2085
2086fn engine_tables_of_markdown(markdown: &str) -> Vec<(String, String)> {
2092 let prepared = strip_fences(&strip_images(&strip_comments(markdown)));
2093 let mut tables = Vec::new();
2094 let mut rows: Vec<String> = Vec::new();
2095 let mut header_rows: Vec<usize> = Vec::new();
2096 let mut inside_html: Vec<String> = Vec::new();
2097 let flush_table = |rows: &mut Vec<String>,
2098 header_rows: &mut Vec<usize>,
2099 tables: &mut Vec<(String, String)>| {
2100 if !rows.is_empty() {
2101 let header = header_rows.first().copied().unwrap_or(0);
2102 tables.push((pipe_to_text(rows), pipe_to_html(rows, header)));
2103 rows.clear();
2104 header_rows.clear();
2105 }
2106 };
2107 for line in prepared.split('\n') {
2108 let line = unmarked(line);
2109 if !inside_html.is_empty() || line.to_lowercase().contains("<table") {
2110 inside_html.push(line.clone());
2111 if line.to_lowercase().contains("</table>") {
2112 let html = inside_html.join("\n");
2113 tables.push((collapse_ws(&strip_tags_plus(&html)), html));
2114 inside_html.clear();
2115 }
2116 continue;
2117 }
2118 if is_table_row(&line) {
2119 if is_table_rule(&line) {
2120 if header_rows.is_empty() {
2121 header_rows.push(rows.len());
2122 }
2123 } else {
2124 rows.push(line);
2125 }
2126 continue;
2127 }
2128 flush_table(&mut rows, &mut header_rows, &mut tables);
2129 }
2130 flush_table(&mut rows, &mut header_rows, &mut tables);
2131 tables
2132}
2133
2134fn edit_distance(left: &str, right: &str) -> usize {
2136 if left == right {
2137 return 0;
2138 }
2139 let left: Vec<char> = left.chars().collect();
2140 let right: Vec<char> = right.chars().collect();
2141 if left.is_empty() {
2142 return right.len();
2143 }
2144 if right.is_empty() {
2145 return left.len();
2146 }
2147 let (long, short) = if left.len() >= right.len() {
2148 (left, right)
2149 } else {
2150 (right, left)
2151 };
2152 let mut prev: Vec<usize> = (0..=short.len()).collect();
2153 let mut cur = vec![0usize; short.len() + 1];
2154 for (i, lc) in long.iter().enumerate() {
2155 cur[0] = i + 1;
2156 for (j, rc) in short.iter().enumerate() {
2157 cur[j + 1] = (prev[j + 1] + 1)
2158 .min(cur[j] + 1)
2159 .min(prev[j] + usize::from(lc != rc));
2160 }
2161 std::mem::swap(&mut prev, &mut cur);
2162 }
2163 prev[short.len()]
2164}
2165
2166fn text_similarity(left: &str, right: &str) -> f64 {
2169 if left.is_empty() && right.is_empty() {
2170 return 1.0;
2171 }
2172 let max_len = left.chars().count().max(right.chars().count());
2173 1.0 - edit_distance(left, right) as f64 / max_len as f64
2174}
2175
2176struct GridMatcher {
2180 tables: Vec<(String, String)>,
2182 geos: Vec<Vec<[f64; 4]>>,
2186 snaps: Vec<Vec<Block>>,
2189 used: HashSet<usize>,
2191 last_pick: Option<usize>,
2193}
2194
2195impl GridMatcher {
2196 fn new(engine_markdown: &str, blocks: &[Block]) -> Self {
2198 let tables = engine_tables_of_markdown(engine_markdown);
2199 let mut geos = engine_tables_of(blocks);
2200 let mut snaps = engine_table_cells(blocks);
2201 if geos.len() != tables.len() {
2202 geos = Vec::new();
2203 snaps = Vec::new();
2204 } else if !snaps.is_empty() && snaps.len() != geos.len() {
2205 snaps = Vec::new();
2208 }
2209 Self {
2210 tables,
2211 geos,
2212 snaps,
2213 used: HashSet::new(),
2214 last_pick: None,
2215 }
2216 }
2217
2218 fn picked_cells(&self) -> Option<&[Block]> {
2221 self.last_pick
2222 .and_then(|i| self.snaps.get(i))
2223 .map(Vec::as_slice)
2224 }
2225
2226 fn orphan_cells(&self, index: usize) -> &[Block] {
2228 self.snaps.get(index).map(Vec::as_slice).unwrap_or(&[])
2229 }
2230
2231 fn table_grid(&mut self, text: &str, bbox: &[f64; 4]) -> Option<String> {
2236 self.last_pick = None;
2237 if !self.geos.is_empty() {
2238 let mut eligible: Vec<(f64, f64, usize, usize)> = Vec::new();
2239 for (i, cells) in self.geos.iter().enumerate() {
2240 if self.used.contains(&i) || cells.is_empty() {
2241 continue;
2242 }
2243 let inside = cells
2244 .iter()
2245 .filter(|c| area(c) > 0.0 && overlap(c, bbox) / area(c) >= POSITION_CELLS)
2246 .count();
2247 let score = inside as f64 / cells.len() as f64;
2248 if score < POSITION_TABLE {
2249 continue;
2250 }
2251 eligible.push((text_similarity(text, &self.tables[i].0), score, inside, i));
2252 }
2253 if !eligible.is_empty() {
2254 let mut pick = 0usize;
2257 let mut best: Option<(f64, f64, usize)> = None;
2258 for (sim, pos, inside, i) in &eligible {
2259 let key = (*sim, *pos, *inside);
2260 let better = match &best {
2261 None => true,
2262 Some(b) => {
2263 key.0 > b.0
2264 || (key.0 == b.0 && (key.1 > b.1 || (key.1 == b.1 && key.2 > b.2)))
2265 }
2266 };
2267 if better {
2268 best = Some(key);
2269 pick = *i;
2270 }
2271 }
2272 self.used.insert(pick);
2273 self.last_pick = Some(pick);
2274 return Some(collapse_ws(&self.tables[pick].1));
2275 }
2276 }
2277 let mut best: Option<(f64, usize)> = None;
2279 for (i, table) in self.tables.iter().enumerate() {
2280 if self.used.contains(&i) {
2281 continue;
2282 }
2283 let sim = text_similarity(text, &table.0);
2284 let better = match &best {
2285 None => true,
2286 Some((s, j)) => sim > *s || (sim == *s && i > *j),
2287 };
2288 if better {
2289 best = Some((sim, i));
2290 }
2291 }
2292 match best {
2293 Some((sim, i)) if sim >= GRID_FLOOR => {
2294 self.used.insert(i);
2295 self.last_pick = Some(i);
2296 Some(collapse_ws(&self.tables[i].1))
2297 }
2298 _ => None,
2299 }
2300 }
2301
2302 fn orphans(&self) -> Vec<(String, [f64; 4], usize)> {
2305 let mut out = Vec::new();
2306 for (i, cells) in self.geos.iter().enumerate() {
2307 if self.used.contains(&i) || cells.is_empty() {
2308 continue;
2309 }
2310 let box_ = [
2311 cells.iter().map(|c| c[0]).fold(f64::INFINITY, f64::min),
2312 cells.iter().map(|c| c[1]).fold(f64::INFINITY, f64::min),
2313 cells.iter().map(|c| c[2]).fold(f64::NEG_INFINITY, f64::max),
2314 cells.iter().map(|c| c[3]).fold(f64::NEG_INFINITY, f64::max),
2315 ];
2316 out.push((collapse_ws(&self.tables[i].1), box_, i));
2317 }
2318 out
2319 }
2320}
2321
2322fn engine_table_cells(blocks: &[Block]) -> Vec<Vec<Block>> {
2327 let mut tables = Vec::new();
2328 let mut cells: Vec<Block> = Vec::new();
2329 let mut last: Option<(u16, u16)> = None;
2330 for block in blocks {
2331 match block.kind {
2332 BlockKind::TableCell { row, column, .. } => {
2333 let at = (row, column);
2334 if last.is_none_or(|prev| at <= prev) && !cells.is_empty() {
2335 tables.push(std::mem::take(&mut cells));
2336 }
2337 cells.push(block.clone());
2338 last = Some(at);
2339 }
2340 _ => {
2341 if !cells.is_empty() {
2342 tables.push(std::mem::take(&mut cells));
2343 }
2344 last = None;
2345 }
2346 }
2347 }
2348 if !cells.is_empty() {
2349 tables.push(std::mem::take(&mut cells));
2350 }
2351 tables
2352}
2353
2354fn engine_tables_of(blocks: &[Block]) -> Vec<Vec<[f64; 4]>> {
2359 let mut tables = Vec::new();
2360 let mut cells: Vec<[f64; 4]> = Vec::new();
2361 let mut last: Option<(u16, u16)> = None;
2362 let flush = |cells: &mut Vec<[f64; 4]>, tables: &mut Vec<Vec<[f64; 4]>>| {
2363 if !cells.is_empty() {
2364 tables.push(std::mem::take(cells));
2365 }
2366 };
2367 for block in blocks {
2368 match block.kind {
2369 BlockKind::TableCell { row, column, .. } => {
2370 let at = (row, column);
2371 if last.is_none_or(|prev| at <= prev) {
2372 flush(&mut cells, &mut tables);
2373 }
2374 cells.push(block.bbox);
2375 last = Some(at);
2376 }
2377 _ => {
2378 flush(&mut cells, &mut tables);
2379 last = None;
2380 }
2381 }
2382 }
2383 flush(&mut cells, &mut tables);
2384 tables
2385}
2386
2387fn norm_word(word: &str) -> String {
2390 word.trim_matches(|ch: char| ch.is_ascii_punctuation())
2391 .to_lowercase()
2392}
2393
2394fn grid_words(grid: &str) -> HashSet<String> {
2396 strip_tags_star(grid)
2397 .split(is_py_space)
2398 .map(norm_word)
2399 .filter(|w| !w.is_empty())
2400 .collect()
2401}
2402
2403fn strip_tags_star(text: &str) -> String {
2405 let chars: Vec<char> = text.chars().collect();
2406 let mut out = String::new();
2407 let mut i = 0usize;
2408 while i < chars.len() {
2409 if chars[i] == '<' {
2410 let mut j = i + 1;
2411 while j < chars.len() && chars[j] != '>' {
2412 j += 1;
2413 }
2414 if j < chars.len() {
2415 out.push(' ');
2416 i = j + 1;
2417 continue;
2418 }
2419 }
2420 out.push(chars[i]);
2421 i += 1;
2422 }
2423 out
2424}
2425
2426fn safe_para(text: &str) -> String {
2429 let collapsed = collapse_ws(text);
2431 let dashes = collapsed.len() >= 3 && collapsed.chars().all(|ch| ch == '-');
2432 let stars = !collapsed.is_empty() && collapsed.chars().all(|ch| ch == '*');
2433 if dashes || stars {
2434 format!("\\{collapsed}")
2435 } else {
2436 collapsed
2437 }
2438}
2439
2440fn leftover_split(lines: &[PlacedLine], grid: String) -> (Vec<String>, Vec<String>, String) {
2446 let vocab = grid_words(&grid);
2447 let held = |line: &PlacedLine| -> bool {
2448 let words: Vec<String> = line
2449 .plain
2450 .split(is_py_space)
2451 .map(norm_word)
2452 .filter(|w| !w.is_empty())
2453 .collect();
2454 !words.is_empty() && words.iter().all(|w| vocab.contains(w))
2455 };
2456 let flags: Vec<bool> = lines.iter().map(held).collect();
2457 let left: Vec<&PlacedLine> = lines
2458 .iter()
2459 .zip(flags.iter())
2460 .filter(|(_, held)| !**held)
2461 .map(|(line, _)| line)
2462 .collect();
2463 if left.is_empty() {
2464 return (Vec::new(), Vec::new(), grid);
2465 }
2466 let kept: Vec<&PlacedLine> = lines
2467 .iter()
2468 .zip(flags.iter())
2469 .filter(|(_, held)| **held)
2470 .map(|(line, _)| line)
2471 .collect();
2472 let (before, after): (Vec<&PlacedLine>, Vec<&PlacedLine>) = if kept.is_empty() {
2473 (Vec::new(), left)
2474 } else {
2475 let top = kept
2476 .iter()
2477 .map(|line| line.bbox[3])
2478 .fold(f64::NEG_INFINITY, f64::max);
2479 left.into_iter().partition(|line| line.bbox[1] >= top)
2480 };
2481 (
2482 before.iter().map(|line| safe_para(&line.styled)).collect(),
2483 after.iter().map(|line| safe_para(&line.styled)).collect(),
2484 grid,
2485 )
2486}
2487
2488#[cfg(test)]
2489mod tests {
2490 use super::*;
2491
2492 fn opcode_strings(a: &[&str], b: &[&str]) -> Vec<(String, usize, usize, usize, usize)> {
2494 SequenceMatcher::new(a, b)
2495 .opcodes()
2496 .iter()
2497 .map(|o| {
2498 let tag = match o.tag {
2499 OpTag::Replace => "replace",
2500 OpTag::Delete => "delete",
2501 OpTag::Insert => "insert",
2502 OpTag::Equal => "equal",
2503 };
2504 (tag.to_string(), o.a_start, o.a_end, o.b_start, o.b_end)
2505 })
2506 .collect()
2507 }
2508
2509 #[test]
2510 fn sequence_matcher_ports_difflib() {
2511 let opcodes = |a: &[&str], b: &[&str]| opcode_strings(a, b);
2513 assert_eq!(
2514 opcodes(&["a", "b", "x", "c", "d"], &["a", "b", "c", "d"]),
2515 [
2516 ("equal".to_string(), 0, 2, 0, 2),
2517 ("delete".to_string(), 2, 3, 2, 2),
2518 ("equal".to_string(), 3, 5, 2, 4),
2519 ]
2520 );
2521 assert_eq!(
2522 opcodes(
2523 &["q", "a", "b", "x", "c", "d"],
2524 &["a", "b", "y", "c", "d", "f"]
2525 ),
2526 [
2527 ("delete".to_string(), 0, 1, 0, 0),
2528 ("equal".to_string(), 1, 3, 0, 2),
2529 ("replace".to_string(), 3, 4, 2, 3),
2530 ("equal".to_string(), 4, 6, 3, 5),
2531 ("insert".to_string(), 6, 6, 5, 6),
2532 ]
2533 );
2534 assert_eq!(
2535 opcodes(&["x", "x", "x", "x"], &["x", "x", "x", "x"]),
2536 [("equal".to_string(), 0, 4, 0, 4)]
2537 );
2538 assert_eq!(opcodes(&["a"], &[]), [("delete".to_string(), 0, 1, 0, 0)]);
2539 assert_eq!(
2540 opcodes(&[], &["a", "b"]),
2541 [("insert".to_string(), 0, 0, 0, 2)]
2542 );
2543 assert_eq!(
2546 opcodes(&["c"], &["c", "c", "b"]),
2547 [
2548 ("equal".to_string(), 0, 1, 0, 1),
2549 ("insert".to_string(), 1, 1, 1, 3),
2550 ]
2551 );
2552 assert_eq!(
2554 opcodes(&["a", "a", "b", "a"], &["a", "b", "a", "a"]),
2555 [
2556 ("delete".to_string(), 0, 1, 0, 0),
2557 ("equal".to_string(), 1, 4, 0, 3),
2558 ("insert".to_string(), 4, 4, 3, 4),
2559 ]
2560 );
2561 }
2562
2563 #[test]
2564 fn unmarked_unwraps_tags_strong_em_and_strike_in_order() {
2565 assert_eq!(unmarked("**a** ***a***"), "a** *a***");
2568 assert_eq!(unmarked("H<sub>2</sub>O and **net**"), "H2O and net");
2569 assert_eq!(unmarked("*days* gone"), "days gone");
2570 assert_eq!(unmarked("~~gone~~"), "gone");
2571 assert_eq!(unmarked("***x***"), "x");
2572 assert_eq!(unmarked("**a**b"), "ab");
2573 assert_eq!(unmarked("a ** b"), "a ** b");
2574 assert_eq!(unmarked("*a**"), "a*");
2575 assert_eq!(unmarked("**"), "**");
2576 assert_eq!(unmarked("text"), "text");
2577 }
2578
2579 #[test]
2580 fn join_runs_merges_only_true_seams() {
2581 assert_eq!(join_runs("**a** **b**"), "**a b**");
2582 assert_eq!(join_runs("*a* *b*"), "*a b*");
2583 assert_eq!(join_runs("***a*** ***b***"), "***a b***");
2584 assert_eq!(join_runs("keep </u> <u> one"), "keep one");
2586 assert_eq!(join_runs("**a** b"), "**a** b");
2588 assert_eq!(join_runs("**a**"), "**a**");
2589 assert_eq!(join_runs("a** **b"), "a b");
2590 assert_eq!(join_runs("** **"), "** **");
2591 assert_eq!(join_runs("plain"), "plain");
2592 }
2593
2594 #[test]
2595 fn join_lines_joins_only_hyphen_wraps() {
2596 let plain = |lines: &[(&str, &str)]| join_lines(lines);
2597 assert_eq!(
2598 plain(&[("docu-", "docu-"), ("ments", "ments")]),
2599 "documents"
2600 );
2601 assert_eq!(
2602 plain(&[("docu-", "**docu-**"), ("ments", "**ments**")]),
2603 "**docu-** **ments**"
2604 );
2605 assert_eq!(plain(&[("docu-", "docu-"), ("Flow", "Flow")]), "docu- Flow");
2607 assert_eq!(plain(&[("docu-", "docu-"), ("me", "me")]), "docu- me");
2608 assert_eq!(plain(&[("co-", "co-"), ("op", "op")]), "co- op");
2610 assert_eq!(
2611 plain(&[("plain", "plain"), ("words", "words")]),
2612 "plain words"
2613 );
2614 }
2615
2616 #[test]
2617 fn strip_bullet_drops_only_a_spaced_bullet() {
2618 assert_eq!(strip_bullet("\u{2022} item"), "item");
2619 assert_eq!(strip_bullet("- item"), "item");
2620 assert_eq!(strip_bullet(" \u{00b7} spaced"), "spaced");
2621 assert_eq!(strip_bullet("-x"), "-x");
2622 assert_eq!(strip_bullet("-- x"), "-- x");
2623 assert_eq!(strip_bullet("plain"), "plain");
2624 assert_eq!(strip_bullet(""), "");
2625 }
2626
2627 #[test]
2628 fn safe_para_escapes_only_bare_rule_lines() {
2629 assert_eq!(safe_para("---"), "\\---");
2630 assert_eq!(safe_para("***"), "\\***");
2631 assert_eq!(safe_para(" *** "), "\\***");
2632 assert_eq!(safe_para("-"), "-");
2633 assert_eq!(safe_para("*"), "\\*");
2634 assert_eq!(safe_para("--- x"), "--- x");
2635 assert_eq!(safe_para("words"), "words");
2636 }
2637
2638 #[test]
2639 fn grid_words_reads_the_tagless_vocab() {
2640 let words = grid_words("<table><tr><td>Hello, World!</td></tr></table>");
2641 assert_eq!(
2642 words,
2643 HashSet::from(["hello".to_string(), "world".to_string()])
2644 );
2645 assert!(grid_words("<>").is_empty());
2646 assert_eq!(
2648 grid_words("A&B"),
2649 HashSet::from(["a&b".to_string()])
2650 );
2651 }
2652
2653 #[test]
2654 fn text_similarity_is_one_minus_normalised_distance() {
2655 assert_eq!(text_similarity("same", "same"), 1.0);
2656 assert_eq!(text_similarity("", ""), 1.0);
2657 assert_eq!(text_similarity("abc", ""), 0.0);
2658 assert_eq!(edit_distance("kitten", "sitting"), 3);
2659 let sim = text_similarity("kitten", "sitting");
2660 assert!((sim - (1.0 - 3.0 / 7.0)).abs() < 1e-12, "got {sim}");
2661 }
2662
2663 #[test]
2664 fn engine_tables_of_markdown_reads_pipe_and_html() {
2665 let tables = engine_tables_of_markdown(
2666 "# Title\n\n| Name | Songs |\n| --- | --- |\n| **Abba** | *Waterloo* |\n",
2667 );
2668 assert_eq!(tables.len(), 1);
2669 assert_eq!(tables[0].0, "Name Songs Abba Waterloo");
2670 assert_eq!(
2671 tables[0].1,
2672 "<table><tr><th>Name</th><th>Songs</th></tr>\
2673 <tr><td>Abba</td><td>Waterloo</td></tr></table>"
2674 );
2675 let tables = engine_tables_of_markdown(
2676 "<!-- page 1 -->\n\n<table>\n<tr><td>H<sub>2</sub>O</td></tr>\n</table>\n",
2677 );
2678 assert_eq!(tables.len(), 1);
2679 assert_eq!(tables[0].0, "H2O");
2680 assert!(tables[0].1.contains("<td>H2O</td>"), "got {}", tables[0].1);
2681 let tables = engine_tables_of_markdown("```text\n\n```\n");
2683 assert!(tables.is_empty());
2684 }
2685
2686 fn word(text: &str, bbox: [f64; 4], block: usize, bold: bool, italic: bool) -> PageWord {
2688 PageWord {
2689 text: text.to_string(),
2690 bbox,
2691 block,
2692 span: 0,
2693 size: 10.0,
2694 bold,
2695 italic,
2696 font: None,
2697 color: None,
2698 }
2699 }
2700
2701 #[test]
2702 fn lines_of_groups_splits_and_orders() {
2703 let words = [
2706 word("left-one", [72.0, 700.0, 130.0, 710.0], 0, false, false),
2707 word("right-one", [360.0, 700.0, 430.0, 710.0], 0, false, false),
2708 word("left-two", [72.0, 686.0, 130.0, 696.0], 0, false, false),
2709 word("right-two", [360.0, 686.0, 430.0, 696.0], 0, false, false),
2710 ];
2711 let lines = lines_of(0, &words);
2712 let texts: Vec<Vec<&str>> = lines
2713 .iter()
2714 .map(|line| line.iter().map(|&w| words[w].text.as_str()).collect())
2715 .collect();
2716 assert_eq!(
2717 texts,
2718 vec![
2719 vec!["left-one"],
2720 vec!["left-two"],
2721 vec!["right-one"],
2722 vec!["right-two"],
2723 ]
2724 );
2725 let words = [
2727 word("body", [72.0, 700.0, 110.0, 710.0], 0, false, false),
2728 word("mark", [112.0, 706.0, 130.0, 712.0], 0, false, false),
2729 ];
2730 assert_eq!(lines_of(0, &words).len(), 1);
2731 }
2732
2733 #[test]
2734 fn regions_of_applies_thresholds_furniture_and_short() {
2735 fn box_of(label: &str, score: f32) -> LayoutBox {
2736 LayoutBox {
2737 label: label.to_string(),
2738 score,
2739 bbox_px: [0.0, 0.0, 100.0, 100.0],
2740 render_w: 612,
2741 render_h: 792,
2742 }
2743 }
2744 let (kept, short) = regions_of(
2747 &[
2748 box_of("table", 0.75),
2749 box_of("table", 0.74),
2750 box_of("list_item", 0.10),
2751 box_of("list_item", 0.09),
2752 box_of("section_header", 0.25),
2753 box_of("title", 0.10),
2754 box_of("title", 0.04),
2755 box_of("text", 0.50),
2756 ],
2757 612.0,
2758 792.0,
2759 false,
2760 );
2761 assert_eq!(kept.len(), 4);
2762 assert_eq!(kept[0].kind, RegionKind::Table);
2763 assert_eq!(kept[1].kind, RegionKind::ListItem);
2764 assert_eq!(kept[2].kind, RegionKind::Heading);
2765 assert_eq!(kept[3].kind, RegionKind::Paragraph);
2766 assert_eq!(short.len(), 1);
2767 assert_eq!(short[0].kind, RegionKind::Heading);
2768 let (kept, _) = regions_of(&[box_of("page_header", 0.5)], 612.0, 792.0, false);
2770 assert_eq!(kept[0].kind, RegionKind::Furniture);
2771 let (kept, _) = regions_of(&[box_of("page_footer", 0.5)], 612.0, 792.0, true);
2772 assert_eq!(kept[0].kind, RegionKind::Paragraph);
2773 }
2774
2775 struct Piece {
2780 text: String,
2782 font: String,
2784 size: f64,
2786 x: f64,
2788 y: f64,
2790 }
2791
2792 fn pdf_of(pieces: &[Piece]) -> Vec<u8> {
2794 use lopdf::content::{Content, Operation};
2795 use lopdf::{dictionary, Document, Object, Stream, StringFormat};
2796
2797 fn encode(text: &str) -> Vec<u8> {
2799 let mut out = Vec::new();
2800 for ch in text.chars() {
2801 if ch == '\u{2022}' {
2802 out.push(0x95);
2803 } else if ch.is_ascii() {
2804 out.push(ch as u8);
2805 } else {
2806 out.push(b'?');
2807 }
2808 }
2809 out
2810 }
2811
2812 let mut doc = Document::with_version("1.7");
2813 let mut fonts: std::collections::HashMap<String, lopdf::ObjectId> =
2814 std::collections::HashMap::new();
2815 for piece in pieces {
2816 fonts.entry(piece.font.clone()).or_insert_with(|| {
2817 doc.add_object(Object::Dictionary(dictionary! {
2818 "Type" => Object::Name(b"Font".to_vec()),
2819 "Subtype" => Object::Name(b"Type1".to_vec()),
2820 "BaseFont" => Object::Name(piece.font.as_bytes().to_vec()),
2821 "Encoding" => Object::Name(b"WinAnsiEncoding".to_vec()),
2822 }))
2823 });
2824 }
2825 let mut names: Vec<String> = fonts.keys().cloned().collect();
2826 names.sort();
2827 let mut operations = Vec::new();
2828 for piece in pieces {
2829 let index = names.iter().position(|n| n == &piece.font).unwrap_or(0);
2830 let name = format!("F{}", index + 1);
2831 operations.push(Operation::new("BT", vec![]));
2832 operations.push(Operation::new(
2833 "Tf",
2834 vec![Object::Name(name.as_bytes().to_vec()), piece.size.into()],
2835 ));
2836 operations.push(Operation::new(
2837 "Tm",
2838 vec![
2839 1.into(),
2840 0.into(),
2841 0.into(),
2842 1.into(),
2843 piece.x.into(),
2844 piece.y.into(),
2845 ],
2846 ));
2847 operations.push(Operation::new(
2848 "Tj",
2849 vec![Object::String(encode(&piece.text), StringFormat::Literal)],
2850 ));
2851 operations.push(Operation::new("ET", vec![]));
2852 }
2853 let content = Content { operations }.encode().expect("encode content");
2854 let content_id = doc.add_object(Object::Stream(Stream::new(dictionary! {}, content)));
2855 let mut fontdict = dictionary! {};
2856 for (i, name) in names.iter().enumerate() {
2857 fontdict.set(format!("F{}", i + 1), Object::Reference(fonts[name]));
2858 }
2859 let page_id = doc.add_object(Object::Dictionary(dictionary! {
2860 "Type" => Object::Name(b"Page".to_vec()),
2861 "MediaBox" => Object::Array(vec![0.into(), 0.into(), 612.into(), 792.into()]),
2862 "Contents" => Object::Reference(content_id),
2863 "Resources" => Object::Dictionary(dictionary! {
2864 "Font" => Object::Dictionary(fontdict),
2865 }),
2866 }));
2867 let pages_id = doc.add_object(Object::Dictionary(dictionary! {
2868 "Type" => Object::Name(b"Pages".to_vec()),
2869 "Kids" => Object::Array(vec![Object::Reference(page_id)]),
2870 "Count" => Object::Integer(1),
2871 }));
2872 if let Ok(Object::Dictionary(page)) = doc.get_object_mut(page_id) {
2873 page.set("Parent", Object::Reference(pages_id));
2874 }
2875 let catalog = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id });
2876 doc.trailer.set("Root", catalog);
2877 let mut bytes = Vec::new();
2878 doc.save_to(&mut bytes).expect("save");
2879 bytes
2880 }
2881
2882 fn at(text: &str, font: &str, size: f64, x: f64, y: f64) -> Piece {
2884 Piece {
2885 text: text.to_string(),
2886 font: font.to_string(),
2887 size,
2888 x,
2889 y,
2890 }
2891 }
2892
2893 fn layout_box(label: &str, score: f32, bbox: [f64; 4]) -> LayoutBox {
2896 LayoutBox {
2897 label: label.to_string(),
2898 score,
2899 bbox_px: [
2900 bbox[0] as f32,
2901 (792.0 - bbox[3]) as f32,
2902 bbox[2] as f32,
2903 (792.0 - bbox[1]) as f32,
2904 ],
2905 render_w: 612,
2906 render_h: 792,
2907 }
2908 }
2909
2910 fn page_of(bytes: Vec<u8>, boxes: &[LayoutBox], opts: &BalancedOptions) -> String {
2912 let doc = PdfDocument::open(bytes).expect("open");
2913 balanced_page_markdown(&doc, 0, boxes, opts).expect("balanced")
2914 }
2915
2916 #[test]
2917 fn tightest_covering_box_wins() {
2918 let bytes = pdf_of(&[
2919 at("Opening line here", "Helvetica", 10.0, 72.0, 740.0),
2920 at("Following line here", "Helvetica", 10.0, 72.0, 726.0),
2921 ]);
2922 let boxes = [
2925 layout_box("text", 0.9, [60.0, 700.0, 540.0, 760.0]),
2926 layout_box("section_header", 0.3, [60.0, 735.0, 540.0, 755.0]),
2927 ];
2928 assert_eq!(
2929 page_of(bytes, &boxes, &BalancedOptions::default()),
2930 "## Opening line here\n\nFollowing line here"
2931 );
2932 }
2933
2934 #[test]
2935 fn below_threshold_boxes_are_ignored() {
2936 let bytes = pdf_of(&[
2937 at(
2938 "A first paragraph line with more than twelve words in it here yes",
2939 "Helvetica",
2940 10.0,
2941 72.0,
2942 740.0,
2943 ),
2944 at(
2945 "A second paragraph line with more than twelve words in it here too",
2946 "Helvetica",
2947 10.0,
2948 72.0,
2949 726.0,
2950 ),
2951 at(
2952 "\u{2022} A bullet line here with more than twelve words in it too yes",
2953 "Helvetica",
2954 10.0,
2955 72.0,
2956 700.0,
2957 ),
2958 ]);
2959 let boxes = [
2963 layout_box("table", 0.74, [60.0, 690.0, 540.0, 760.0]),
2964 layout_box("section_header", 0.2, [60.0, 690.0, 540.0, 760.0]),
2965 layout_box("list_item", 0.09, [60.0, 690.0, 540.0, 710.0]),
2966 ];
2967 let opts = BalancedOptions::default();
2968 assert_eq!(
2969 page_of(bytes, &boxes, &opts),
2970 "A first paragraph line with more than twelve words in it here yes \
2971 A second paragraph line with more than twelve words in it here too\n\n\u{2022} A bullet line here with more than twelve words in it too yes"
2972 );
2973 }
2974
2975 fn table_pieces() -> Vec<Piece> {
2978 let rows = [
2979 ("Revenue", "100", "200", "300"),
2980 ("Costs", "10", "20", "30"),
2981 ("Profit", "90", "180", "270"),
2982 ("Margin", "9", "18", "27"),
2983 ];
2984 let mut pieces = Vec::new();
2985 for (i, (a, b, c, d)) in rows.iter().enumerate() {
2986 let y = 700.0 - i as f64 * 14.0;
2987 pieces.push(at(a, "Helvetica", 9.0, 72.0, y));
2988 pieces.push(at(b, "Helvetica", 9.0, 360.0, y));
2989 pieces.push(at(c, "Helvetica", 9.0, 430.0, y));
2990 pieces.push(at(d, "Helvetica", 9.0, 500.0, y));
2991 }
2992 pieces
2993 }
2994
2995 const TABLE_GRID: &str =
3000 "<table> <tr><td>Revenue</td><td>100</td><td>200</td><td>300</td></tr> \
3001 <tr><td>Costs</td><td>10</td><td>20</td><td>30</td></tr> \
3002 <tr><td>Profit</td><td>90</td><td>180</td><td>270</td></tr> \
3003 <tr><td>Margin</td><td>9</td><td>18</td><td>27</td></tr> </table>";
3004
3005 #[test]
3006 fn table_region_takes_the_engine_grid() {
3007 let bytes = pdf_of(&table_pieces());
3008 let boxes = [layout_box("table", 0.8, [60.0, 640.0, 540.0, 720.0])];
3009 assert_eq!(
3010 page_of(bytes, &boxes, &BalancedOptions::default()),
3011 TABLE_GRID
3012 );
3013 }
3014
3015 #[test]
3016 fn table_region_without_a_grid_is_paragraphs() {
3017 let bytes = pdf_of(&[at(
3018 "Only prose on this page",
3019 "Helvetica",
3020 10.0,
3021 72.0,
3022 740.0,
3023 )]);
3024 let boxes = [layout_box("table", 0.9, [60.0, 700.0, 540.0, 760.0])];
3027 assert_eq!(
3028 page_of(bytes, &boxes, &BalancedOptions::default()),
3029 "Only prose on this page"
3030 );
3031 }
3032
3033 #[test]
3034 fn orphan_tables_come_from_the_engine() {
3035 let bytes = pdf_of(&table_pieces());
3038 assert_eq!(page_of(bytes, &[], &BalancedOptions::default()), TABLE_GRID);
3039 }
3040
3041 #[test]
3042 fn wordless_page_is_the_engine_page() {
3043 use lopdf::{dictionary, Document, Object, Stream};
3044 let mut doc = Document::with_version("1.7");
3046 let img = doc.add_object(Object::Stream(Stream::new(
3047 dictionary! {
3048 "Type" => Object::Name(b"XObject".to_vec()),
3049 "Subtype" => Object::Name(b"Image".to_vec()),
3050 "Width" => 64,
3051 "Height" => 64,
3052 "ColorSpace" => Object::Name(b"DeviceGray".to_vec()),
3053 "BitsPerComponent" => 8,
3054 },
3055 vec![128u8; 64 * 64],
3056 )));
3057 let content = doc.add_object(Stream::new(
3058 dictionary! {},
3059 b"q 100 0 0 100 72 600 cm /Im1 Do Q".to_vec(),
3060 ));
3061 let page = doc.add_object(dictionary! {
3062 "Type" => "Page",
3063 "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
3064 "Contents" => content,
3065 "Resources" => dictionary! { "XObject" => dictionary! { "Im1" => img } },
3066 });
3067 let pages = doc.new_object_id();
3068 doc.objects.insert(
3069 pages,
3070 Object::Dictionary(dictionary! {
3071 "Type" => "Pages",
3072 "Kids" => vec![page.into()],
3073 "Count" => 1,
3074 }),
3075 );
3076 if let Ok(Object::Dictionary(p)) = doc.get_object_mut(page) {
3077 p.set("Parent", Object::Reference(pages));
3078 }
3079 let catalog = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages });
3080 doc.trailer.set("Root", catalog);
3081 let mut bytes = Vec::new();
3082 doc.save_to(&mut bytes).expect("save");
3083 let engine = crate::structured_batch::markdown_of_bytes(bytes.clone()).expect("engine");
3084 assert!(!engine.is_empty(), "the engine marks the figure");
3085 assert_eq!(
3086 page_of(bytes, &[], &BalancedOptions::default()),
3087 engine,
3088 "R2 returns the engine page verbatim"
3089 );
3090 }
3091
3092 #[test]
3093 fn bold_and_italic_runs_carry_markers() {
3094 let bytes = pdf_of(&[
3095 at("The quick ", "Helvetica", 10.0, 72.0, 700.0),
3096 at("brown fox", "Helvetica-Bold", 10.0, 130.0, 700.0),
3097 at(" jumps", "Helvetica", 10.0, 190.0, 700.0),
3098 at("over the", "Helvetica", 10.0, 72.0, 686.0),
3099 at("lazy dog", "Helvetica-Oblique", 10.0, 125.0, 686.0),
3100 ]);
3101 assert_eq!(
3102 page_of(bytes, &[], &BalancedOptions::default()),
3103 "The quick **brown fox** jumps over the *lazy dog*"
3104 );
3105 }
3106
3107 #[test]
3108 fn headings_stay_plain() {
3109 let bytes = pdf_of(&[at("Title Words Here", "Helvetica-Bold", 14.0, 72.0, 740.0)]);
3110 let boxes = [layout_box("title", 0.9, [60.0, 730.0, 540.0, 760.0])];
3113 assert_eq!(
3114 page_of(bytes, &boxes, &BalancedOptions::default()),
3115 "## Title Words Here"
3116 );
3117 }
3118
3119 #[test]
3120 fn leftover_label_lines_stay_paragraphs() {
3121 let mut pieces = vec![at("Annual figures", "Helvetica", 10.0, 72.0, 724.0)];
3122 pieces.extend(table_pieces());
3123 let bytes = pdf_of(&pieces);
3124 let boxes = [layout_box("table", 0.8, [60.0, 640.0, 540.0, 740.0])];
3127 assert_eq!(
3128 page_of(bytes, &boxes, &BalancedOptions::default()),
3129 format!("Annual figures\n\n{TABLE_GRID}")
3130 );
3131 }
3132
3133 #[test]
3134 fn short_line_on_weak_signal_is_a_heading() {
3135 let bytes = pdf_of(&[
3136 at("A short line", "Helvetica", 10.0, 72.0, 740.0),
3137 at(
3138 "A much longer paragraph line with more than twelve words in it here",
3139 "Helvetica",
3140 10.0,
3141 72.0,
3142 726.0,
3143 ),
3144 ]);
3145 let boxes = [
3147 layout_box("section_header", 0.1, [60.0, 735.0, 540.0, 755.0]),
3148 layout_box("section_header", 0.1, [60.0, 715.0, 540.0, 735.0]),
3149 ];
3150 assert_eq!(
3151 page_of(bytes, &boxes, &BalancedOptions::default()),
3152 "## A short line\n\nA much longer paragraph line with more than twelve words in it here"
3153 );
3154 }
3155
3156 #[test]
3157 fn orphan_engine_table_is_written_at_its_lines() {
3158 let mut pieces = vec![at("Opening words first", "Helvetica", 10.0, 72.0, 740.0)];
3159 pieces.extend(table_pieces());
3160 pieces.push(at("Closing words last", "Helvetica", 10.0, 72.0, 620.0));
3161 let bytes = pdf_of(&pieces);
3162 assert_eq!(
3165 page_of(bytes, &[], &BalancedOptions::default()),
3166 format!("Opening words first\n\n{TABLE_GRID}\n\nClosing words last")
3167 );
3168 }
3169
3170 #[test]
3171 fn aligned_words_take_engine_tags() {
3172 let bytes = pdf_of(&[
3174 at("See the documents.", "Helvetica", 10.0, 72.0, 700.0),
3175 at("2", "Helvetica", 7.0, 168.0, 705.0),
3176 at("for details here.", "Helvetica", 10.0, 176.0, 700.0),
3177 ]);
3178 assert_eq!(
3179 page_of(bytes, &[], &BalancedOptions::default()),
3180 "See the documents. <sup>2</sup> for details here."
3181 );
3182 let bytes = pdf_of(&[
3184 at("See documents.", "Helvetica", 10.0, 72.0, 700.0),
3185 at("2", "Helvetica", 7.0, 145.0, 705.0),
3186 at("here follows.", "Helvetica", 10.0, 150.0, 700.0),
3187 ]);
3188 assert_eq!(
3191 page_of(bytes, &[], &BalancedOptions::default()),
3192 "See documents. <sup>2</sup>here follows."
3193 );
3194 }
3195
3196 #[test]
3197 fn style_run_merges_across_lines() {
3198 let bytes = pdf_of(&[
3199 at("The brown fox", "Helvetica-Bold", 10.0, 72.0, 700.0),
3200 at("jumps high", "Helvetica-Bold", 10.0, 72.0, 686.0),
3201 ]);
3202 assert_eq!(
3203 page_of(bytes, &[], &BalancedOptions::default()),
3204 "**The brown fox jumps high**"
3205 );
3206 }
3207
3208 #[test]
3209 fn hyphen_wrap_joins_and_bullet_drops() {
3210 let bytes = pdf_of(&[
3211 at("A docu-", "Helvetica", 10.0, 72.0, 700.0),
3212 at("ments word", "Helvetica", 10.0, 72.0, 686.0),
3213 at("\u{2022} Item one text", "Helvetica", 10.0, 72.0, 660.0),
3214 ]);
3215 let boxes = [layout_box("list_item", 0.9, [60.0, 650.0, 540.0, 670.0])];
3216 assert_eq!(
3217 page_of(bytes, &boxes, &BalancedOptions::default()),
3218 "A documents word\n\n- Item one text"
3219 );
3220 }
3221
3222 #[test]
3223 fn ligatures_fold_when_the_engine_keeps_them() {
3224 use lopdf::{dictionary, Document, Object, Stream};
3225 let mut doc = Document::with_version("1.7");
3228 let font = doc.add_object(Object::Dictionary(dictionary! {
3229 "Type" => Object::Name(b"Font".to_vec()),
3230 "Subtype" => Object::Name(b"Type1".to_vec()),
3231 "BaseFont" => Object::Name(b"Helvetica".to_vec()),
3232 "Encoding" => Object::Name(b"MacRomanEncoding".to_vec()),
3233 }));
3234 let content = doc.add_object(Stream::new(
3235 dictionary! {},
3236 b"BT /F1 10 Tf 72 700 Td <6FDE6365> Tj ET".to_vec(),
3237 ));
3238 let page = doc.add_object(dictionary! {
3239 "Type" => "Page",
3240 "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
3241 "Contents" => content,
3242 "Resources" => dictionary! { "Font" => dictionary! { "F1" => font } },
3243 });
3244 let pages = doc.new_object_id();
3245 doc.objects.insert(
3246 pages,
3247 Object::Dictionary(dictionary! {
3248 "Type" => "Pages",
3249 "Kids" => vec![page.into()],
3250 "Count" => 1,
3251 }),
3252 );
3253 if let Ok(Object::Dictionary(p)) = doc.get_object_mut(page) {
3254 p.set("Parent", Object::Reference(pages));
3255 }
3256 let catalog = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages });
3257 doc.trailer.set("Root", catalog);
3258 let mut bytes = Vec::new();
3259 doc.save_to(&mut bytes).expect("save");
3260 let mut doc = PdfDocument::open(bytes).expect("open");
3261 doc.set_keep_ligatures(true);
3262 assert_eq!(
3263 balanced_page_markdown(&doc, 0, &[], &BalancedOptions::default()).expect("balanced"),
3264 "ofice"
3265 );
3266 }
3267
3268 #[test]
3269 fn furniture_drops_unless_kept() {
3270 let bytes = pdf_of(&[
3271 at("Running head words", "Helvetica", 9.0, 72.0, 760.0),
3272 at("Body words here", "Helvetica", 10.0, 72.0, 700.0),
3273 ]);
3274 let boxes = [layout_box("page_header", 0.9, [60.0, 750.0, 540.0, 775.0])];
3275 assert_eq!(
3276 page_of(bytes.clone(), &boxes, &BalancedOptions::default()),
3277 "Body words here"
3278 );
3279 assert_eq!(
3280 page_of(
3281 bytes,
3282 &boxes,
3283 &BalancedOptions::default().keep_furniture(true)
3284 ),
3285 "Running head words\n\nBody words here"
3286 );
3287 }
3288
3289 #[test]
3290 fn page_out_of_range_is_an_error() {
3291 let bytes = pdf_of(&[at("words", "Helvetica", 10.0, 72.0, 700.0)]);
3292 let doc = PdfDocument::open(bytes).expect("open");
3293 let err = balanced_page_markdown(&doc, 3, &[], &BalancedOptions::default())
3294 .expect_err("page 3 of one");
3295 assert!(matches!(
3296 err,
3297 crate::EngineError::PageOutOfRange { index: 3, count: 1 }
3298 ));
3299 }
3300
3301 #[test]
3302 fn balanced_markdown_needs_the_model() {
3303 let bytes = pdf_of(&[at("words", "Helvetica", 10.0, 72.0, 700.0)]);
3304 let doc = PdfDocument::open(bytes).expect("open");
3305 let mut dir = std::env::temp_dir();
3306 dir.push("pdfluent-balanced-no-model");
3307 let config = RuntimeConfig::new().with_model_dir(dir);
3308 let err =
3309 balanced_markdown(&doc, &config, &BalancedOptions::default()).expect_err("no model");
3310 assert!(
3311 matches!(err, crate::EngineError::Io(_)),
3312 "a model failure rides Io: {err:?}"
3313 );
3314 }
3315}