1use crate::blocks::{Block, BlockKind, ExtractOptions, PageSeparators, TableRenderMode};
122#[cfg(test)]
123use crate::table_trace::TableOrigin;
124use crate::text::{HorizontalRule, TextSpan};
125
126struct CellAt<'a> {
128 row: usize,
129 column: usize,
130 row_span: usize,
131 column_span: usize,
132 header: bool,
133 text: &'a str,
134}
135
136struct OwnedCell {
138 row: usize,
139 column: usize,
140 row_span: usize,
141 column_span: usize,
142 header: bool,
143 text: String,
144}
145
146fn cell_of(block: &Block) -> Option<CellAt<'_>> {
148 match block.kind {
149 BlockKind::TableCell {
150 row,
151 column,
152 row_span,
153 column_span,
154 header,
155 } => Some(CellAt {
156 row: row as usize,
157 column: column as usize,
158 row_span: (row_span as usize).max(1),
159 column_span: (column_span as usize).max(1),
160 header,
161 text: block.text.trim(),
162 }),
163 _ => None,
164 }
165}
166
167pub(crate) fn table_run(blocks: &[Block], from: usize) -> Option<usize> {
181 let first = cell_of(&blocks[from])?;
182 let mut last = (first.row, first.column);
183 let mut end = from + 1;
184 while end < blocks.len() && blocks[end].page == blocks[from].page {
185 match cell_of(&blocks[end]) {
186 Some(cell) if (cell.row, cell.column) > last => {
187 last = (cell.row, cell.column);
188 end += 1;
189 }
190 _ => break,
191 }
192 }
193 Some(end)
194}
195
196fn table_run_across(blocks: &[Block], from: usize) -> Option<usize> {
200 let mut end = table_run(blocks, from)?;
201 while end < blocks.len()
202 && blocks[end].continues_previous
203 && blocks[end].page != blocks[end - 1].page
204 && cell_of(&blocks[end]).is_some()
205 {
206 end = table_run(blocks, end)?;
207 }
208 Some(end)
209}
210
211fn cells_across(blocks: &[Block]) -> Vec<CellAt<'_>> {
215 let mut out: Vec<CellAt> = Vec::new();
216 let mut offset = 0usize;
217 let mut segment_head: Option<usize> = None; let head: Vec<&str> = blocks
219 .iter()
220 .filter_map(cell_of)
221 .take_while(|c| c.row == 0)
222 .map(|c| c.text)
223 .collect();
224 for (k, block) in blocks.iter().enumerate() {
225 let Some(mut cell) = cell_of(block) else {
226 continue;
227 };
228 if k > 0 && block.continues_previous && block.page != blocks[k - 1].page {
229 let rows_so_far = out.iter().map(|c| c.row + c.row_span).max().unwrap_or(0);
230 let first_row = cell.row;
231 let this_head: Vec<&str> = blocks[k..]
232 .iter()
233 .take_while(|b| b.page == block.page)
234 .filter_map(cell_of)
235 .take_while(|c| c.row == first_row)
236 .map(|c| c.text)
237 .collect();
238 segment_head = (!head.is_empty() && this_head == head).then_some(first_row);
239 offset = rows_so_far - first_row.min(rows_so_far);
240 if segment_head.is_some() {
241 offset = rows_so_far.saturating_sub(first_row + 1);
242 }
243 }
244 if segment_head == Some(cell.row) {
245 continue;
246 }
247 cell.row += offset;
248 out.push(cell);
249 }
250 out
251}
252
253fn table(blocks: &[Block], switches: crate::learned::Switches, mode: TableRenderMode) -> String {
256 let mut owned: Vec<OwnedCell> = cells_across(blocks)
257 .iter()
258 .map(|cell| OwnedCell {
259 row: cell.row,
260 column: cell.column,
261 row_span: cell.row_span,
262 column_span: cell.column_span,
263 header: cell.header,
264 text: cell.text.to_string(),
265 })
266 .collect();
267 join_currency_columns(&mut owned);
268 let mut cells: Vec<CellAt> = owned
269 .iter()
270 .map(|cell| CellAt {
271 row: cell.row,
272 column: cell.column,
273 row_span: cell.row_span,
274 column_span: cell.column_span,
275 header: cell.header,
276 text: &cell.text,
277 })
278 .collect();
279 let head = header_block(&cells, switches);
280 for cell in cells.iter_mut().filter(|cell| cell.row < head) {
281 cell.header = true;
282 }
283 let columns = cells
284 .iter()
285 .map(|cell| cell.column + cell.column_span)
286 .max()
287 .unwrap_or(0);
288 let rows = cells
289 .iter()
290 .map(|cell| cell.row + cell.row_span)
291 .max()
292 .unwrap_or(0);
293 if columns == 0 || rows == 0 {
294 return String::new();
295 }
296
297 let rectangular = cells
298 .iter()
299 .all(|cell| cell.row_span == 1 && cell.column_span == 1)
300 && (0..rows).all(|r| {
301 let mut seen: Vec<usize> = cells
302 .iter()
303 .filter(|cell| cell.row == r)
304 .map(|cell| cell.column)
305 .collect();
306 seen.sort_unstable();
307 seen == (0..columns).collect::<Vec<usize>>()
308 });
309
310 let pipe = match mode {
317 TableRenderMode::Markdown => true,
318 TableRenderMode::Html => false,
319 TableRenderMode::Auto => {
320 rectangular && head <= 1 && (head == 1 || !switches.markdown_headerless_html)
321 }
322 };
323 if pipe {
324 markdown_table(&cells, rows, columns)
325 } else {
326 html_table(&cells, rows, columns)
327 }
328}
329
330fn header_block(cells: &[CellAt], switches: crate::learned::Switches) -> usize {
360 let rows = cells.iter().map(|cell| cell.row + 1).max().unwrap_or(0);
361 if rows == 0 {
362 return 0;
363 }
364 let figures = |r: usize| {
365 if is_year_row(cells, r, switches.markdown_header_block_stub_years) {
366 return false;
367 }
368 let texts: Vec<&str> = cells
369 .iter()
370 .filter(|cell| cell.row == r)
371 .map(|cell| cell.text.trim())
372 .filter(|text| !text.is_empty())
373 .collect();
374 if texts.is_empty() {
375 return false;
376 }
377 let counted = texts
378 .iter()
379 .filter(|t| {
380 is_figure(t)
381 || (switches.markdown_header_block_figures && is_stacked_figure_cell(t))
382 })
383 .count();
384 let valued = texts
385 .iter()
386 .filter(|t| {
387 (is_figure(t)
388 || (switches.markdown_header_block_figures && is_stacked_figure_cell(t)))
389 && !is_year_cell(t)
390 })
391 .count();
392 let mixed = valued > 0 && valued < texts.len();
393 2 * counted >= texts.len() || (switches.markdown_header_block_mixed_body && mixed)
394 };
395 let mut head = (0..rows).find(|&r| figures(r)).unwrap_or(rows);
396 if head == rows && switches.markdown_header_block_word_table && rows > 1 {
397 let first_has_text = cells
398 .iter()
399 .filter(|cell| cell.row == 0)
400 .any(|cell| !cell.text.trim().is_empty());
401 if first_has_text {
402 head = 1;
403 }
404 }
405 if head == rows || head > 4 {
406 return 0;
407 }
408 if cells.iter().any(|cell| cell.row_span > 1) {
409 if !switches.markdown_header_block_rowspan_crossing {
410 return 0;
411 }
412 let crosses = cells
413 .iter()
414 .any(|cell| cell.row < head && cell.row + cell.row_span > head);
415 if crosses {
416 return 0;
417 }
418 }
419 head
420}
421
422fn is_year_row(cells: &[CellAt], row: usize, stub_years: bool) -> bool {
429 let mut all_years = true;
430 let mut years = 0usize;
431 for cell in cells.iter().filter(|cell| cell.row == row) {
432 let text = cell.text.trim();
433 if text.is_empty() {
434 continue;
435 }
436 if !is_year_cell(text) {
437 all_years = false;
438 break;
439 }
440 years += 1;
441 }
442 if all_years && years >= 2 {
443 return true;
444 }
445 if !stub_years {
446 return false;
447 }
448 let mut valued = 0usize;
449 for cell in cells
450 .iter()
451 .filter(|cell| cell.row == row && cell.column != 0)
452 {
453 let text = cell.text.trim();
454 if text.is_empty() {
455 continue;
456 }
457 if !is_year_cell(text) {
458 return false;
459 }
460 valued += 1;
461 }
462 valued >= 2
463}
464
465fn is_year_cell(text: &str) -> bool {
468 let mut rest = text.trim();
469 if rest.is_empty() {
470 return false;
471 }
472 if let Some(without_star) = rest.strip_suffix('*') {
473 rest = without_star.trim_end();
474 if rest.is_empty() {
475 return false;
476 }
477 }
478 if let Some(open) = rest.find('(') {
479 let (year, note) = rest.split_at(open);
480 if !is_year(year.trim_end()) {
481 return false;
482 }
483 let note = note.trim();
484 if !(note.starts_with('(') && note.ends_with(')')) {
485 return false;
486 }
487 let inner = note[1..note.len() - 1].trim();
488 return !inner.is_empty() && inner.bytes().all(|b| b.is_ascii_digit());
489 }
490 if rest.contains(['(', ')']) {
491 return false;
492 }
493 is_year(rest)
494}
495
496fn is_year(text: &str) -> bool {
498 text.len() == 4
499 && (text.starts_with("19") || text.starts_with("20"))
500 && text.bytes().all(|b| b.is_ascii_digit())
501}
502
503fn is_currency_symbol(text: &str) -> bool {
505 matches!(text.trim(), "$" | "€" | "£" | "¥")
506}
507
508fn contains_the_join(cell: &OwnedCell, join: usize) -> bool {
511 cell.column_span > 1 && cell.column <= join && cell.column + cell.column_span >= join + 2
512}
513
514fn find_currency_column(cells: &[OwnedCell]) -> Option<usize> {
524 let columns = cells
525 .iter()
526 .map(|cell| cell.column + cell.column_span)
527 .max()
528 .unwrap_or(0);
529 if columns < 2 {
530 return None;
531 }
532 for join in 0..columns - 1 {
533 if cells.iter().any(|cell| {
534 cell.column_span > 1
535 && cell.column < join + 2
536 && cell.column + cell.column_span > join
537 && !contains_the_join(cell, join)
538 }) {
539 continue;
540 }
541 let mut symbol = false;
542 let mut clean = true;
543 for cell in cells
544 .iter()
545 .filter(|cell| cell.column == join && !contains_the_join(cell, join))
546 {
547 let text = cell.text.trim();
548 if text.is_empty() {
549 continue;
550 }
551 if is_currency_symbol(text) {
552 symbol = true;
553 } else {
554 clean = false;
555 break;
556 }
557 }
558 if clean && symbol {
559 return Some(join);
560 }
561 }
562 None
563}
564
565fn join_currency_columns(cells: &mut Vec<OwnedCell>) {
577 while let Some(join) = find_currency_column(cells) {
578 let symbols: Vec<(usize, String, bool)> = cells
579 .iter()
580 .filter(|cell| cell.column == join)
581 .filter_map(|cell| {
582 let text = cell.text.trim();
583 if is_currency_symbol(text) {
584 Some((cell.row, text.to_string(), cell.header))
585 } else {
586 None
587 }
588 })
589 .collect();
590 for (row, symbol, header) in symbols {
591 match cells
592 .iter_mut()
593 .find(|cell| cell.row == row && cell.column == join + 1)
594 {
595 Some(right) => {
596 let text = right.text.trim();
597 right.text = if text.is_empty() {
598 symbol
599 } else {
600 format!("{symbol} {text}")
601 };
602 }
603 None => cells.push(OwnedCell {
604 row,
605 column: join + 1,
606 row_span: 1,
607 column_span: 1,
608 header,
609 text: symbol,
610 }),
611 }
612 }
613 cells.retain(|cell| cell.column != join || contains_the_join(cell, join));
614 for cell in cells
615 .iter_mut()
616 .filter(|cell| contains_the_join(cell, join))
617 {
618 cell.column_span -= 1;
619 }
620 for cell in cells.iter_mut().filter(|cell| cell.column > join) {
621 cell.column -= 1;
622 }
623 }
624}
625
626pub(crate) fn is_figure(text: &str) -> bool {
629 let t = text.trim();
630 if t.eq_ignore_ascii_case("n/a") || t.eq_ignore_ascii_case("na") {
631 return true;
632 }
633 if !t.is_empty() && t.chars().all(|c| matches!(c, '-' | '–' | '—')) {
634 return true;
635 }
636 let core = t
637 .trim_start_matches(|c: char| c.is_whitespace() || "$€£(-–—+".contains(c))
638 .trim_end_matches(|c: char| c.is_whitespace() || "%*)".contains(c));
639 !core.is_empty()
640 && core.chars().any(|c| c.is_ascii_digit())
641 && core
642 .chars()
643 .all(|c| c.is_ascii_digit() || c == '.' || c == ',')
644}
645
646fn is_stacked_figure_cell(text: &str) -> bool {
656 let mut substance = false;
657 for token in text.split_whitespace() {
658 if is_figure(token) {
659 substance = true;
660 continue;
661 }
662 let rest = token.trim_start_matches(['$', '€', '£', '¥', '¢', '₹']);
665 if rest.len() < token.len()
666 && !rest.is_empty()
667 && rest.chars().all(|c| matches!(c, '-' | '–' | '—'))
668 {
669 substance = true;
670 continue;
671 }
672 if !token.is_empty() && token.chars().all(|c| "$€£¥¢₹".contains(c)) {
674 continue;
675 }
676 return false;
677 }
678 substance
679}
680
681fn markdown_table(cells: &[CellAt], rows: usize, columns: usize) -> String {
683 let mut out = String::new();
684 for r in 0..rows {
685 out.push('|');
686 for c in 0..columns {
687 let text = cells
688 .iter()
689 .find(|cell| cell.row == r && cell.column == c)
690 .map(|cell| cell.text)
691 .unwrap_or_default();
692 out.push(' ');
693 out.push_str(&escape_cell(&cell_text(text, columns)));
694 out.push_str(" |");
695 }
696 out.push('\n');
697 if r == 0 {
698 out.push('|');
699 for _ in 0..columns {
700 out.push_str(" --- |");
701 }
702 out.push('\n');
703 }
704 }
705 out
706}
707
708fn html_table(cells: &[CellAt], rows: usize, columns: usize) -> String {
719 let mut taken = vec![vec![false; columns]; rows];
720 let mut out = String::from("<table>\n");
721 for r in 0..rows {
722 out.push_str("<tr>");
723 let mut c = 0usize;
724 while c < columns {
725 if taken[r][c] {
726 c += 1;
727 continue;
728 }
729 let Some(cell) = cells.iter().find(|cell| cell.row == r && cell.column == c) else {
730 out.push_str("<td></td>");
731 c += 1;
732 continue;
733 };
734 for row in taken.iter_mut().take((r + cell.row_span).min(rows)).skip(r) {
735 for held in row
736 .iter_mut()
737 .take((c + cell.column_span).min(columns))
738 .skip(c)
739 {
740 *held = true;
741 }
742 }
743 let tag = if cell.header { "th" } else { "td" };
744 out.push('<');
745 out.push_str(tag);
746 if cell.column_span > 1 {
747 out.push_str(&format!(" colspan=\"{}\"", cell.column_span));
748 }
749 if cell.row_span > 1 {
750 out.push_str(&format!(" rowspan=\"{}\"", cell.row_span));
751 }
752 out.push('>');
753 out.push_str(&escape_html(&cell_text(cell.text, columns)));
754 out.push_str(&format!("</{tag}>"));
755 c += cell.column_span.max(1);
756 }
757 out.push_str("</tr>\n");
758 }
759 out.push_str("</table>");
760 out
761}
762
763fn cell_text(text: &str, columns: usize) -> String {
772 if columns >= 3 {
773 without_leaders(text)
774 } else {
775 text.to_string()
776 }
777}
778
779fn without_leaders(text: &str) -> String {
790 let chars: Vec<char> = text.chars().collect();
791 let mut out = String::with_capacity(text.len());
792 let mut i = 0usize;
793 while i < chars.len() {
794 if chars[i] == '.' {
795 let mut j = i;
797 let mut dots = 0usize;
798 while j < chars.len() && chars[j] == '.' {
799 dots += 1;
800 j += 1;
801 if j < chars.len() && chars[j].is_whitespace() && chars.get(j + 1) == Some(&'.') {
802 j += 1;
803 }
804 }
805 if dots >= 4 {
806 out.push(' ');
807 i = j;
808 continue;
809 }
810 }
811 out.push(chars[i]);
812 i += 1;
813 }
814 out.split_whitespace().collect::<Vec<&str>>().join(" ")
815}
816
817fn escape_cell(text: &str) -> String {
820 let mut out = String::with_capacity(text.len());
821 for c in text.chars() {
822 match c {
823 '|' => out.push_str("\\|"),
824 '\n' | '\r' => out.push(' '),
825 _ => out.push(c),
826 }
827 }
828 out
829}
830
831fn escape_html(text: &str) -> String {
833 let mut out = String::with_capacity(text.len());
834 for c in text.chars() {
835 match c {
836 '&' => out.push_str("&"),
837 '<' => out.push_str("<"),
838 '>' => out.push_str(">"),
839 _ => out.push(c),
840 }
841 }
842 out
843}
844
845const ORDINAL_MAX_DIGITS: usize = 9;
849
850fn opens_a_marker(line: &str, marker: char) -> bool {
864 let run = if marker == '#' {
869 line.chars().take_while(|c| *c == '#').count().min(6)
870 } else {
871 marker.len_utf8()
872 };
873 let rest = &line[run..];
874 if matches!(rest.chars().next(), None | Some(' ') | Some('\t')) {
875 return true;
876 }
877 matches!(marker, '-' | '*')
878 && line.chars().filter(|c| !c.is_whitespace()).count() >= 3
879 && line.chars().all(|c| c == marker || c.is_whitespace())
880}
881
882fn escape_leading_marker(text: &str) -> String {
885 let trimmed = text.trim_start();
886 let indent = &text[..text.len() - trimmed.len()];
887 for fence in ['`', '~'] {
890 if trimmed.starts_with([fence, fence, fence].iter().collect::<String>().as_str()) {
891 return format!("{indent}\\{trimmed}");
892 }
893 }
894 let mut chars = trimmed.chars();
895 let escaped = match chars.next() {
896 Some(c @ ('>' | '|' | '`' | '~' | '<')) => format!("\\{c}{}", chars.as_str()),
899 Some(c @ ('#' | '-' | '*' | '+')) if opens_a_marker(trimmed, c) => {
905 format!("\\{c}{}", chars.as_str())
906 }
907 Some(c) if c.is_ascii_digit() => {
908 let rest = chars.as_str();
910 let digits = trimmed.chars().take_while(|c| c.is_ascii_digit()).count();
911 let after = trimmed[digits..].chars().next();
912 let delimited = matches!(after, Some('.') | Some(')'))
913 && opens_a_marker(&trimmed[digits..], after.unwrap_or('.'));
914 if digits <= ORDINAL_MAX_DIGITS && delimited {
915 format!("{}\\{}", &trimmed[..digits], &trimmed[digits..])
916 } else {
917 format!("{c}{rest}")
918 }
919 }
920 Some(c) => format!("{c}{}", chars.as_str()),
921 None => String::new(),
922 };
923 format!("{indent}{escaped}")
924}
925
926fn page_marker(page: usize) -> String {
932 format!("<!-- page {page} -->")
933}
934
935fn note_page(
947 out: &mut String,
948 open: &mut Vec<(usize, usize)>,
949 last: &mut Option<usize>,
950 page: usize,
951 separators: PageSeparators,
952) {
953 if *last == Some(page) {
954 return;
955 }
956 if last.is_some() && !out.is_empty() && separators == PageSeparators::Comment {
957 out.push_str("\n\n");
958 out.push_str(&page_marker(page));
959 open.clear();
960 }
961 *last = Some(page);
962}
963
964fn is_right_to_left(c: char) -> bool {
967 matches!(c as u32, 0x0590..=0x08FF | 0xFB1D..=0xFDFF | 0xFE70..=0xFEFF)
968}
969
970fn whole_at(text: &str, run: &str, from: usize) -> Option<usize> {
973 let mut at = from;
974 while let Some(found) = text[at..].find(run) {
975 let start = at + found;
976 let end = start + run.len();
977 let before = text[..start].chars().next_back();
978 let after = text[end..].chars().next();
979 if !before.is_some_and(char::is_alphanumeric) && !after.is_some_and(char::is_alphanumeric) {
980 return Some(start);
981 }
982 at = start + run.chars().next().map_or(1, char::len_utf8);
983 }
984 None
985}
986
987#[derive(Clone, Copy, PartialEq, Eq)]
990enum Set {
991 Bold,
992 Italic,
993 BoldItalic,
994 Sup,
995 Sub,
996}
997
998impl Set {
999 fn marks(self) -> (&'static str, &'static str) {
1000 match self {
1001 Set::Bold => ("**", "**"),
1002 Set::Italic => ("*", "*"),
1003 Set::BoldItalic => ("***", "***"),
1004 Set::Sup => ("<sup>", "</sup>"),
1005 Set::Sub => ("<sub>", "</sub>"),
1006 }
1007 }
1008}
1009
1010type CodeSpan = (usize, usize);
1012
1013type StyleMark = (usize, usize, Option<Set>, bool);
1015
1016fn body_of(block: &Block, text: &str, rules: &[HorizontalRule], code: bool) -> String {
1045 let (spans, styles) = rich_marks(block, text, rules, code);
1046 let rich = emit_rich(text, &styles, &spans);
1047 if spans.first().is_some_and(|(start, _)| *start == 0) {
1048 return rich;
1049 }
1050 let escaped = escape_leading_marker(text);
1051 if escaped != text {
1052 escaped
1053 } else {
1054 escape_leading_marker(&rich)
1055 }
1056}
1057
1058fn rich_text(block: &Block, text: &str, rules: &[HorizontalRule], code: bool) -> String {
1062 let (spans, styles) = rich_marks(block, text, rules, code);
1063 emit_rich(text, &styles, &spans)
1064}
1065
1066fn rich_marks(
1070 block: &Block,
1071 text: &str,
1072 rules: &[HorizontalRule],
1073 code: bool,
1074) -> (Vec<CodeSpan>, Vec<StyleMark>) {
1075 let spans = if code && code_here(block, text) {
1076 code_ranges(block, text)
1077 } else {
1078 Vec::new()
1079 };
1080 let mut styles = styled_ranges(block, text, rules);
1081 styles.retain(|(start, end, _, _)| !spans.iter().any(|(cs, ce)| *start < *ce && *cs < *end));
1082 (spans, styles)
1083}
1084
1085fn code_here(block: &Block, text: &str) -> bool {
1090 matches!(
1091 block.kind,
1092 BlockKind::Paragraph | BlockKind::ListItem { .. }
1093 ) && !mostly_digits(text)
1094 && !text.chars().any(is_right_to_left)
1095}
1096
1097fn mostly_digits(text: &str) -> bool {
1101 let mut total = 0usize;
1102 let mut figures = 0usize;
1103 for c in text.chars() {
1104 if c.is_whitespace() {
1105 continue;
1106 }
1107 total += 1;
1108 if c.is_ascii_digit() || c.is_ascii_punctuation() {
1109 figures += 1;
1110 }
1111 }
1112 total > 0 && 2 * figures > total
1113}
1114
1115fn code_ranges(block: &Block, text: &str) -> Vec<CodeSpan> {
1125 let mut ranges: Vec<CodeSpan> = Vec::new();
1126 let mut cursor = 0usize;
1127 for span in &block.spans {
1128 let run = span.text.trim();
1129 if run.is_empty() {
1130 continue;
1131 }
1132 let Some(found) = text[cursor..].find(run) else {
1133 continue;
1134 };
1135 let start = cursor + found;
1136 let end = start + run.len();
1137 cursor = end;
1138 if span.is_monospace != Some(true) {
1139 continue;
1140 }
1141 match ranges.last_mut() {
1142 Some(last) if text[last.1..start].trim().is_empty() => last.1 = end,
1143 _ => ranges.push((start, end)),
1144 }
1145 }
1146 if let Some(last) = ranges.last() {
1150 if last.1 == text.len() && text.ends_with('-') {
1151 ranges.pop();
1152 }
1153 }
1154 ranges
1155}
1156
1157fn longest_backtick_run(text: &str) -> usize {
1159 let mut best = 0usize;
1160 let mut run = 0usize;
1161 for c in text.chars() {
1162 if c == '`' {
1163 run += 1;
1164 best = best.max(run);
1165 } else {
1166 run = 0;
1167 }
1168 }
1169 best
1170}
1171
1172fn inline_code(content: &str) -> String {
1176 let fence = "`".repeat(longest_backtick_run(content) + 1);
1177 if content.starts_with('`') || content.ends_with('`') {
1178 format!("{fence} {content} {fence}")
1179 } else {
1180 format!("{fence}{content}{fence}")
1181 }
1182}
1183
1184fn fenced_lines(block: &Block) -> Option<Vec<String>> {
1188 if !matches!(block.kind, BlockKind::Paragraph) {
1189 return None;
1190 }
1191 if block.text.chars().any(is_right_to_left) || mostly_digits(&block.text) {
1192 return None;
1193 }
1194 if !block.spans.iter().any(|s| !s.text.trim().is_empty())
1195 || block
1196 .spans
1197 .iter()
1198 .any(|s| !s.text.trim().is_empty() && s.is_monospace != Some(true))
1199 {
1200 return None;
1201 }
1202 let lines: Vec<String> = crate::text::lines_of_block(block)
1203 .iter()
1204 .map(|line| crate::text::line_text(block, line))
1205 .map(|line| line.trim_end().to_string())
1206 .filter(|line| !line.trim().is_empty())
1207 .collect();
1208 (lines.len() >= 2).then_some(lines)
1209}
1210
1211fn fenced_block(lines: &[String]) -> String {
1214 let fence = "`".repeat(
1215 lines
1216 .iter()
1217 .map(|line| longest_backtick_run(line))
1218 .max()
1219 .unwrap_or(0)
1220 .max(2)
1221 + 1,
1222 );
1223 format!("{fence}\n{}\n{fence}", lines.join("\n"))
1224}
1225
1226fn underlined(span: &TextSpan, line: (f64, f64), rules: &[HorizontalRule]) -> bool {
1235 let size = span.font_size;
1236 if size <= 0.0 || span.width <= 0.0 {
1237 return false;
1238 }
1239 let (left, right) = (span.x, span.x + span.width);
1240 rules.iter().any(|r| {
1241 r.y <= span.y + 0.05 * size
1242 && r.y >= span.y - 0.35 * size
1243 && r.left <= left + 0.1 * span.width
1244 && r.right >= right - 0.1 * span.width
1245 && r.left >= line.0 - size
1246 && r.right <= line.1 + size
1247 })
1248}
1249
1250fn line_of(block: &Block, span: &TextSpan) -> (f64, f64) {
1252 block
1253 .spans
1254 .iter()
1255 .filter(|s| (s.y - span.y).abs() <= 0.2 * span.font_size.max(1.0))
1256 .fold((span.x, span.x + span.width), |(l, r), s| {
1257 (l.min(s.x), r.max(s.x + s.width))
1258 })
1259}
1260
1261fn page_rules<'a>(rules: &'a [Vec<HorizontalRule>], block: &Block) -> &'a [HorizontalRule] {
1262 rules.get(block.page).map_or(&[], Vec::as_slice)
1263}
1264
1265#[cfg(test)]
1268fn styled(block: &Block, text: &str, rules: &[HorizontalRule]) -> String {
1269 emit_rich(text, &styled_ranges(block, text, rules), &[])
1270}
1271
1272fn styled_ranges(block: &Block, text: &str, rules: &[HorizontalRule]) -> Vec<StyleMark> {
1276 if text.chars().any(is_right_to_left) {
1280 return Vec::new();
1281 }
1282 let mut ranges: Vec<StyleMark> = Vec::new();
1283 let mut cursor = 0usize;
1284 let mut previous: Option<&crate::text::TextSpan> = None;
1285 for (index, span) in block.spans.iter().enumerate() {
1286 let run = span.text.trim();
1287 let before = previous;
1288 previous = Some(span);
1289 if run.is_empty() || run.contains(['*', '_', '<', '>', '`', '[', ']', '\\', '~']) {
1290 continue;
1291 }
1292 let raised = before.and_then(|prev| {
1293 let small = span.font_size > 0.0
1294 && prev.font_size > 0.0
1295 && span.font_size <= 0.85 * prev.font_size
1296 && run.chars().count() <= 4;
1297 if !small {
1298 return None;
1299 }
1300 let shift = span.y - prev.y;
1301 if shift.abs() >= 0.8 * prev.font_size || span.x < prev.x {
1304 return None;
1305 }
1306 if shift >= 0.2 * prev.font_size {
1307 Some(Set::Sup)
1308 } else if shift <= -0.1 * prev.font_size {
1309 Some(Set::Sub)
1310 } else {
1311 None
1312 }
1313 });
1314 let set = raised.or(match (span.is_bold, span.is_italic) {
1315 (true, true) => Some(Set::BoldItalic),
1316 (true, false) => Some(Set::Bold),
1317 (false, true) => Some(Set::Italic),
1318 (false, false) => None,
1319 });
1320 let place = if matches!(set, Some(Set::Sup | Set::Sub)) {
1333 text[cursor..].find(run).map(|found| cursor + found)
1334 } else {
1335 let rest = &text[cursor..];
1336 let skipped = rest
1337 .find(|c: char| !c.is_whitespace())
1338 .unwrap_or(rest.len());
1339 let at = cursor + skipped;
1340 let joined = text[at..].starts_with(run)
1341 && (before.is_some_and(|prev| crate::words_join::spans_join(prev, span))
1342 || block
1343 .spans
1344 .get(index + 1)
1345 .is_some_and(|next| crate::words_join::spans_join(span, next)));
1346 if joined {
1347 Some(at)
1348 } else {
1349 whole_at(text, run, cursor)
1350 }
1351 };
1352 let Some(start) = place else {
1353 continue;
1354 };
1355 let end = start + run.len();
1356 cursor = end;
1357 let under = !rules.is_empty() && underlined(span, line_of(block, span), rules);
1358 if set.is_some() || under {
1359 match ranges.last_mut() {
1360 Some(last)
1363 if last.2 == set
1364 && last.3 == under
1365 && text[last.1..start].trim().is_empty() =>
1366 {
1367 last.1 = end
1368 }
1369 _ => ranges.push((start, end, set, under)),
1370 }
1371 }
1372 }
1373 if let Some(last) = ranges.last() {
1377 if last.1 == text.len() && text.ends_with('-') {
1378 ranges.pop();
1379 }
1380 }
1381 ranges
1382}
1383
1384fn emit_rich(text: &str, styles: &[StyleMark], code: &[CodeSpan]) -> String {
1388 if styles.is_empty() && code.is_empty() {
1389 return text.to_string();
1390 }
1391 let mut out = String::with_capacity(text.len() + styles.len() * 6 + code.len() * 2);
1392 let mut at = 0usize;
1393 let mut styles = styles;
1394 let mut code = code;
1395 loop {
1396 let take_style = match (styles.first(), code.first()) {
1397 (Some((s, _, _, _)), Some((c, _))) => *s < *c,
1398 (Some(_), None) => true,
1399 (None, Some(_)) => false,
1400 (None, None) => break,
1401 };
1402 if take_style {
1403 let (start, end, set, under) = styles[0];
1404 styles = &styles[1..];
1405 let (open, close) = set.map_or(("", ""), Set::marks);
1406 out.push_str(&text[at..start]);
1407 out.push_str(open);
1408 if under {
1409 out.push_str("<u>");
1410 }
1411 out.push_str(&text[start..end]);
1412 if under {
1413 out.push_str("</u>");
1414 }
1415 out.push_str(close);
1416 at = end;
1417 } else {
1418 let (start, end) = code[0];
1419 code = &code[1..];
1420 out.push_str(&text[at..start]);
1421 out.push_str(&inline_code(&text[start..end]));
1422 at = end;
1423 }
1424 }
1425 out.push_str(&text[at..]);
1426 out
1427}
1428
1429#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1439#[non_exhaustive]
1440pub struct MarkdownOptions {
1441 pub include_headers: bool,
1446 pub include_footers: bool,
1450 pub page_separators: PageSeparators,
1453 pub code: bool,
1458 pub include_figures: bool,
1463 pub table_render_mode: TableRenderMode,
1467}
1468
1469impl Default for MarkdownOptions {
1470 fn default() -> Self {
1473 Self {
1474 include_headers: false,
1475 include_footers: false,
1476 page_separators: PageSeparators::Comment,
1477 code: default_code(),
1478 include_figures: true,
1479 table_render_mode: TableRenderMode::Auto,
1480 }
1481 }
1482}
1483
1484impl From<&ExtractOptions> for MarkdownOptions {
1485 fn from(options: &ExtractOptions) -> Self {
1488 Self {
1489 include_headers: options.include_headers,
1490 include_footers: options.include_footers,
1491 page_separators: options.page_separators,
1492 code: options.code,
1493 include_figures: options.include_figures,
1494 table_render_mode: options.table_render_mode,
1495 }
1496 }
1497}
1498
1499pub fn to_markdown(blocks: &[Block]) -> String {
1515 to_markdown_with_options(blocks, &MarkdownOptions::default())
1516}
1517
1518pub fn to_markdown_with_code(blocks: &[Block], code: bool) -> String {
1528 let options = MarkdownOptions {
1529 code,
1530 ..MarkdownOptions::default()
1531 };
1532 to_markdown_with_options(blocks, &options)
1533}
1534
1535fn default_code() -> bool {
1541 crate::blocks::ExtractOptions::default().code
1542}
1543
1544#[doc(hidden)]
1548pub fn to_markdown_with_rules(blocks: &[Block], rules: &[Vec<HorizontalRule>]) -> String {
1549 render_markdown(
1550 blocks,
1551 rules,
1552 crate::learned::SWITCHES,
1553 &MarkdownOptions::default(),
1554 )
1555}
1556
1557#[doc(hidden)]
1561pub fn to_markdown_with_switches(blocks: &[Block], switches: crate::learned::Switches) -> String {
1562 render_markdown(blocks, &[], switches, &MarkdownOptions::default())
1563}
1564
1565pub fn to_markdown_with_options(blocks: &[Block], options: &MarkdownOptions) -> String {
1575 render_markdown(blocks, &[], crate::learned::SWITCHES, options)
1576}
1577
1578fn document_is_set_in_monospace(blocks: &[Block]) -> bool {
1583 blocks
1584 .iter()
1585 .flat_map(|block| block.spans.iter())
1586 .all(|span| span.is_monospace == Some(true))
1587}
1588
1589fn render_markdown(
1592 blocks: &[Block],
1593 rules: &[Vec<HorizontalRule>],
1594 switches: crate::learned::Switches,
1595 options: &MarkdownOptions,
1596) -> String {
1597 let code = options.code;
1601 let fence_code = code && !document_is_set_in_monospace(blocks);
1602 let mut out = String::new();
1603 let mut open: Vec<(usize, usize)> = Vec::new();
1608 let mut after_paragraph = false;
1611 let mut last_page: Option<usize> = None;
1615 let mut figure_ordinals: std::collections::BTreeMap<usize, usize> =
1619 std::collections::BTreeMap::new();
1620 let mut at = 0usize;
1621 while at < blocks.len() {
1622 if let Some(end) = table_run_across(blocks, at) {
1625 let page = blocks[at].page;
1626 let written = table(&blocks[at..end], switches, options.table_render_mode);
1627 at = end;
1628 if written.is_empty() {
1629 continue;
1630 }
1631 note_page(
1632 &mut out,
1633 &mut open,
1634 &mut last_page,
1635 page,
1636 options.page_separators,
1637 );
1638 open.clear();
1639 after_paragraph = false;
1640 if !out.is_empty() {
1641 out.push_str("\n\n");
1642 }
1643 out.push_str(written.trim_end_matches('\n'));
1644 continue;
1645 }
1646 let block = &blocks[at];
1647 at += 1;
1648 let wanted = match block.kind {
1654 BlockKind::Header => options.include_headers,
1655 BlockKind::Footer => options.include_footers,
1656 BlockKind::Figure => options.include_figures,
1657 _ => true,
1658 };
1659 if !wanted {
1660 continue;
1661 }
1662 if matches!(block.kind, BlockKind::Figure) {
1668 let ordinal = figure_ordinals.get(&block.page).copied().unwrap_or(0);
1669 let target = block.text.trim();
1670 let marker;
1671 let target = if target.is_empty() {
1672 marker = crate::markdown_images::figure_name(block.page, ordinal);
1673 marker.as_str()
1674 } else {
1675 target
1676 };
1677 figure_ordinals.insert(block.page, ordinal + 1);
1678 note_page(
1679 &mut out,
1680 &mut open,
1681 &mut last_page,
1682 block.page,
1683 options.page_separators,
1684 );
1685 if !out.is_empty() {
1686 out.push_str("\n\n");
1687 }
1688 after_paragraph = false;
1689 open.clear();
1690 out.push_str(";
1691 out.push_str(target);
1692 out.push(')');
1693 continue;
1694 }
1695 let is_furniture = matches!(block.kind, BlockKind::Header | BlockKind::Footer);
1696 let text = block.text.trim();
1697 if text.is_empty() {
1698 continue;
1699 }
1700 let fence = if fence_code {
1704 fenced_lines(block)
1705 } else {
1706 None
1707 };
1708 if block.continues_previous
1715 && after_paragraph
1716 && fence.is_none()
1717 && !out.is_empty()
1718 && !is_furniture
1719 {
1720 if crate::text::is_hyphen_wrap_candidate(&out, text) {
1725 out.pop();
1726 } else {
1727 out.push(' ');
1728 }
1729 out.push_str(&rich_text(block, text, page_rules(rules, block), code));
1730 continue;
1740 }
1741 note_page(
1742 &mut out,
1743 &mut open,
1744 &mut last_page,
1745 block.page,
1746 options.page_separators,
1747 );
1748 if !out.is_empty() {
1749 let tight = !open.is_empty() && matches!(block.kind, BlockKind::ListItem { .. });
1755 out.push_str(if tight { "\n" } else { "\n\n" });
1756 }
1757 after_paragraph = matches!(block.kind, BlockKind::Paragraph);
1758 match block.kind {
1759 BlockKind::Heading { level } => {
1760 open.clear();
1761 let level = level.clamp(1, 6) as usize;
1762 out.push_str(&"#".repeat(level));
1763 out.push(' ');
1764 out.push_str(text);
1765 }
1766 BlockKind::ListItem { level, ordinal } => {
1767 let depth = (level as usize).min(open.len());
1768 open.truncate(depth);
1769 let indent = open.last().map_or(0, |(column, marker)| column + marker);
1770 let marker = match ordinal {
1771 Some(number) => format!("{number}. "),
1772 None => "- ".to_string(),
1773 };
1774 out.push_str(&" ".repeat(indent));
1775 out.push_str(&marker);
1776 open.push((indent, marker.chars().count()));
1777 out.push_str(&body_of(block, text, page_rules(rules, block), code));
1781 }
1782 BlockKind::Caption => {
1786 open.clear();
1787 out.push('*');
1788 out.push_str(body_of(block, text, page_rules(rules, block), code).trim());
1789 out.push('*');
1790 }
1791 _ => {
1792 open.clear();
1793 if let Some(lines) = &fence {
1794 after_paragraph = false;
1798 out.push_str(&fenced_block(lines));
1799 } else {
1800 out.push_str(&body_of(block, text, page_rules(rules, block), code));
1801 }
1802 }
1803 }
1804 }
1805 if !out.is_empty() {
1806 out.push('\n');
1807 }
1808 out
1809}
1810
1811#[cfg(test)]
1812mod tests {
1813 #[test]
1814 fn a_cell_loses_its_dot_leaders_but_keeps_an_ellipsis_and_decimals() {
1815 use super::without_leaders;
1816 assert_eq!(
1817 without_leaders("United States . . . . ........ $11,772"),
1818 "United States $11,772"
1819 );
1820 assert_eq!(
1821 without_leaders("2005 Plan ......................"),
1822 "2005 Plan"
1823 );
1824 assert_eq!(without_leaders("Net sales"), "Net sales");
1825 assert_eq!(
1826 without_leaders("to be determined..."),
1827 "to be determined..."
1828 );
1829 assert_eq!(without_leaders("1.25 . 3.5"), "1.25 . 3.5");
1830 assert_eq!(without_leaders("....."), "");
1831 assert_eq!(
1832 super::cell_text("Dell . . . . . 16", 2),
1833 "Dell . . . . . 16"
1834 );
1835 assert_eq!(super::cell_text("Revenues ...... 954", 3), "Revenues 954");
1836 }
1837
1838 fn cell_at(row: usize, column: usize, text: &str) -> super::CellAt<'_> {
1839 super::CellAt {
1840 row,
1841 column,
1842 row_span: 1,
1843 column_span: 1,
1844 header: false,
1845 text,
1846 }
1847 }
1848
1849 fn off() -> crate::learned::Switches {
1852 crate::learned::Switches {
1853 markdown_header_block_figures: false,
1854 markdown_header_block_rowspan_crossing: false,
1855 markdown_header_block_word_table: false,
1856 markdown_header_block_mixed_body: false,
1857 markdown_headerless_html: false,
1858 markdown_header_block_stub_years: false,
1859 ..crate::learned::SWITCHES
1860 }
1861 }
1862
1863 #[test]
1864 fn a_two_line_header_is_marked_and_a_table_of_words_is_not() {
1865 use super::header_block;
1866 let two = [
1867 cell_at(0, 0, ""),
1868 cell_at(0, 1, "Issue"),
1869 cell_at(1, 0, "Class"),
1870 cell_at(1, 1, "Age"),
1871 cell_at(2, 0, "MN"),
1872 cell_at(2, 1, "35"),
1873 cell_at(3, 0, "MN"),
1874 cell_at(3, 1, "$1,500.00"),
1875 ];
1876 assert_eq!(header_block(&two, off()), 2);
1877 let words = [
1878 cell_at(0, 0, "Name"),
1879 cell_at(0, 1, "Role"),
1880 cell_at(1, 0, "Ann"),
1881 cell_at(1, 1, "Chair"),
1882 ];
1883 assert_eq!(header_block(&words, off()), 0);
1884 let figures_first = [
1885 cell_at(0, 0, "Total"),
1886 cell_at(0, 1, "12"),
1887 cell_at(1, 0, "Net"),
1888 cell_at(1, 1, "(3)"),
1889 ];
1890 assert_eq!(header_block(&figures_first, off()), 0);
1891 assert!(super::is_figure("(1,234.5)") && super::is_figure("12%") && super::is_figure("—"));
1892 assert!(!super::is_figure("Fair Value") && !super::is_figure("December 31,"));
1893 }
1894
1895 #[test]
1900 fn stacked_figure_cells_count_as_figures_under_the_switch() {
1901 for text in [
1902 "86,090 (4,305)",
1903 "12,000 (1,000)",
1904 "$—",
1905 "$ —",
1906 "$ 990",
1907 "€ -",
1908 "— —",
1909 "(4,305)",
1910 ] {
1911 assert!(
1912 super::is_stacked_figure_cell(text),
1913 "{text:?} reads as figures"
1914 );
1915 }
1916 for text in [
1917 "$",
1918 "",
1919 "December 31,",
1920 "Fair Value",
1921 "Contributions Distributions",
1922 "86,090 and more",
1923 ] {
1924 assert!(
1925 !super::is_stacked_figure_cell(text),
1926 "{text:?} reads as text"
1927 );
1928 }
1929 }
1930
1931 #[test]
1936 fn fused_figure_rows_end_the_header_block_under_the_switch() {
1937 use super::header_block;
1938 let cells = [
1939 cell_at(0, 0, ""),
1940 cell_at(0, 1, "Shares"),
1941 cell_at(0, 2, "Amount"),
1942 cell_at(1, 0, "Balance at December 31, 2001"),
1943 cell_at(1, 1, "— —"),
1944 cell_at(1, 2, "— —"),
1945 cell_at(2, 0, "Contributions Distributions"),
1946 cell_at(2, 1, "86,090 (4,305)"),
1947 cell_at(2, 2, "12,000 (1,000)"),
1948 cell_at(3, 0, "Net loss"),
1949 cell_at(3, 1, "(5,000)"),
1950 cell_at(3, 2, "(6,000)"),
1951 ];
1952 assert_eq!(header_block(&cells, off()), 3);
1953 assert_eq!(
1954 header_block(
1955 &cells,
1956 crate::learned::Switches {
1957 markdown_header_block_figures: true,
1958 ..off()
1959 }
1960 ),
1961 1
1962 );
1963 }
1964
1965 #[test]
1966 fn a_row_of_years_does_not_end_the_header() {
1967 use super::header_block;
1968 fn spanned(row: usize, column: usize, column_span: usize, text: &str) -> super::CellAt<'_> {
1969 super::CellAt {
1970 row,
1971 column,
1972 row_span: 1,
1973 column_span,
1974 header: false,
1975 text,
1976 }
1977 }
1978 let years = [
1979 cell_at(0, 0, ""),
1980 spanned(0, 1, 3, "Year Ended December 31,"),
1981 cell_at(1, 0, ""),
1982 cell_at(1, 1, "2012"),
1983 cell_at(1, 2, "2011"),
1984 cell_at(1, 3, "2010"),
1985 cell_at(2, 0, "Revenue"),
1986 cell_at(2, 1, "1"),
1987 cell_at(2, 2, "2"),
1988 cell_at(2, 3, "3"),
1989 ];
1990 assert_eq!(header_block(&years, off()), 2);
1991 let footnote = [
1992 cell_at(0, 0, ""),
1993 spanned(0, 1, 2, "Year Ended December 31,"),
1994 cell_at(1, 0, ""),
1995 cell_at(1, 1, "2012 (1)"),
1996 cell_at(1, 2, "2011"),
1997 cell_at(2, 0, "Revenue"),
1998 cell_at(2, 1, "1"),
1999 cell_at(2, 2, "2"),
2000 ];
2001 assert_eq!(header_block(&footnote, off()), 2);
2002 let not_all_years = [
2003 cell_at(0, 0, "Item"),
2004 cell_at(0, 1, "Value"),
2005 cell_at(0, 2, "Share"),
2006 cell_at(1, 0, "Total"),
2007 cell_at(1, 1, "2012"),
2008 cell_at(1, 2, "35.0%"),
2009 cell_at(2, 0, "Net"),
2010 cell_at(2, 1, "3"),
2011 cell_at(2, 2, "4%"),
2012 ];
2013 assert_eq!(header_block(¬_all_years, off()), 1);
2014 }
2015
2016 #[test]
2021 fn a_rowspan_clear_of_the_boundary_keeps_the_header_under_the_switch() {
2022 use super::header_block;
2023 fn tall(row: usize, column: usize, row_span: usize, text: &str) -> super::CellAt<'_> {
2024 super::CellAt {
2025 row,
2026 column,
2027 row_span,
2028 column_span: 1,
2029 header: false,
2030 text,
2031 }
2032 }
2033 let on = || crate::learned::Switches {
2034 markdown_header_block_rowspan_crossing: true,
2035 ..off()
2036 };
2037 let grouped = [
2039 cell_at(0, 0, "Region"),
2040 cell_at(0, 1, "Sales"),
2041 cell_at(1, 0, "North"),
2042 cell_at(1, 1, "Q1"),
2043 cell_at(2, 0, "East"),
2044 cell_at(2, 1, "12"),
2045 tall(3, 0, 2, "West"),
2046 cell_at(3, 1, "9"),
2047 cell_at(4, 1, "7"),
2048 ];
2049 assert_eq!(header_block(&grouped, off()), 0);
2050 assert_eq!(header_block(&grouped, on()), 2);
2051 let stub = [
2053 tall(0, 0, 2, "Test"),
2054 cell_at(0, 1, "Low"),
2055 cell_at(1, 1, "High"),
2056 cell_at(2, 0, "A"),
2057 cell_at(2, 1, "3"),
2058 ];
2059 assert_eq!(header_block(&stub, off()), 0);
2060 assert_eq!(header_block(&stub, on()), 2);
2061 let straddling = [
2064 cell_at(0, 0, "Region"),
2065 cell_at(0, 1, "Sales"),
2066 tall(1, 0, 2, "North"),
2067 cell_at(1, 1, "Q1"),
2068 cell_at(2, 1, "12"),
2069 ];
2070 assert_eq!(header_block(&straddling, off()), 0);
2071 assert_eq!(header_block(&straddling, on()), 0);
2072 }
2073
2074 #[test]
2079 fn a_word_table_heads_its_first_row_under_the_switch() {
2080 use super::header_block;
2081 let on = || crate::learned::Switches {
2082 markdown_header_block_word_table: true,
2083 ..off()
2084 };
2085 let words = [
2086 cell_at(0, 0, ""),
2087 cell_at(0, 1, "Enablers"),
2088 cell_at(0, 2, "Barriers"),
2089 cell_at(1, 0, "Hiring"),
2090 cell_at(1, 1, "Referrals"),
2091 cell_at(1, 2, "Costs"),
2092 cell_at(2, 0, "Keeping"),
2093 cell_at(2, 1, "Pay"),
2094 cell_at(2, 2, "Hours"),
2095 ];
2096 assert_eq!(header_block(&words, off()), 0);
2097 assert_eq!(header_block(&words, on()), 1);
2098 let single = [cell_at(0, 0, "Name"), cell_at(0, 1, "Role")];
2100 assert_eq!(header_block(&single, on()), 0);
2101 let blank = [
2103 cell_at(0, 0, ""),
2104 cell_at(0, 1, " "),
2105 cell_at(1, 0, "Ann"),
2106 cell_at(1, 1, "Chair"),
2107 ];
2108 assert_eq!(header_block(&blank, on()), 0);
2109 }
2110
2111 #[test]
2116 fn a_mixed_row_ends_the_header_under_the_switch() {
2117 use super::header_block;
2118 let on = || crate::learned::Switches {
2119 markdown_header_block_mixed_body: true,
2120 ..off()
2121 };
2122 let over = [
2123 cell_at(0, 0, "Region"),
2124 cell_at(0, 1, "North"),
2125 cell_at(0, 2, "South"),
2126 cell_at(0, 3, "Share"),
2127 cell_at(1, 0, "Age (years)"),
2128 cell_at(1, 1, "North"),
2129 cell_at(1, 2, "South"),
2130 cell_at(1, 3, "0.73"),
2131 cell_at(2, 0, "Female"),
2132 cell_at(2, 1, "26"),
2133 cell_at(2, 2, "20"),
2134 cell_at(2, 3, "0.22"),
2135 ];
2136 assert_eq!(header_block(&over, off()), 2);
2137 assert_eq!(header_block(&over, on()), 1);
2138 let headerless = [
2141 cell_at(0, 0, "Genome size"),
2142 cell_at(0, 1, "North"),
2143 cell_at(0, 2, "South"),
2144 cell_at(0, 3, "14.6"),
2145 cell_at(1, 0, "Assembled"),
2146 cell_at(1, 1, "13"),
2147 cell_at(1, 2, "12"),
2148 cell_at(1, 3, "11"),
2149 ];
2150 assert_eq!(header_block(&headerless, off()), 1);
2151 assert_eq!(header_block(&headerless, on()), 0);
2152 let stubbed_years = [
2156 cell_at(0, 0, "Payments due by period"),
2157 cell_at(0, 1, ""),
2158 cell_at(0, 2, ""),
2159 cell_at(1, 0, "Contractual Obligation"),
2160 cell_at(1, 1, "2008"),
2161 cell_at(1, 2, "2009 and 2010"),
2162 cell_at(2, 0, "Long-term debt"),
2163 cell_at(2, 1, "$ 629"),
2164 cell_at(2, 2, "$ 2,334"),
2165 ];
2166 assert_eq!(header_block(&stubbed_years, off()), 2);
2167 assert_eq!(header_block(&stubbed_years, on()), 2);
2168 }
2169
2170 #[test]
2175 fn a_stubbed_year_row_heads_under_the_switch() {
2176 use super::header_block;
2177 let on = || crate::learned::Switches {
2178 markdown_header_block_stub_years: true,
2179 ..off()
2180 };
2181 let stubbed = [
2182 cell_at(0, 0, "($ in millions)"),
2183 cell_at(0, 1, "2010"),
2184 cell_at(0, 2, "2009"),
2185 cell_at(1, 0, "Sales"),
2186 cell_at(1, 1, "$ 914"),
2187 cell_at(1, 2, "$ 850"),
2188 ];
2189 assert_eq!(header_block(&stubbed, off()), 0);
2190 assert_eq!(header_block(&stubbed, on()), 1);
2191 let single = [
2193 cell_at(0, 0, "As of"),
2194 cell_at(0, 1, "December"),
2195 cell_at(0, 2, "2010"),
2196 cell_at(1, 0, "Cash"),
2197 cell_at(1, 1, "1"),
2198 cell_at(1, 2, "2"),
2199 ];
2200 assert_eq!(header_block(&single, on()), 1);
2201 let plain = [
2203 cell_at(0, 0, "2010"),
2204 cell_at(0, 1, "2009"),
2205 cell_at(1, 0, "Sales"),
2206 cell_at(1, 1, "914"),
2207 ];
2208 assert_eq!(header_block(&plain, off()), 1);
2209 assert_eq!(header_block(&plain, on()), 1);
2210 }
2211
2212 use super::*;
2213
2214 fn block(kind: BlockKind, text: &str) -> Block {
2215 Block {
2216 kind,
2217 page: 0,
2218 bbox: [0.0; 4],
2219 text: text.to_string(),
2220 spans: Vec::new(),
2221 index: 0,
2222 continues_previous: false,
2223 confidence: None,
2224 grid: None,
2225 table_origin: TableOrigin::Other,
2226 trace_learned: None,
2227 }
2228 }
2229
2230 #[test]
2232 fn a_caption_is_an_italic_paragraph_of_its_own() {
2233 let md = to_markdown(&[
2234 block(BlockKind::Paragraph, "The plant runs day and night."),
2235 block(BlockKind::Caption, "Figure 1. The plant at night."),
2236 ]);
2237 assert_eq!(
2238 md,
2239 "The plant runs day and night.\n\n*Figure 1. The plant at night.*\n"
2240 );
2241 }
2242
2243 fn run(text: &str, y: f64, size: f64, bold: bool, italic: bool) -> crate::text::TextSpan {
2244 crate::text::TextSpan {
2245 text: text.to_string(),
2246 y,
2247 font_size: size,
2248 is_bold: bold,
2249 is_italic: italic,
2250 ..Default::default()
2251 }
2252 }
2253
2254 fn set_block(text: &str, spans: Vec<crate::text::TextSpan>) -> Block {
2255 Block {
2256 spans,
2257 ..block(BlockKind::Paragraph, text)
2258 }
2259 }
2260
2261 fn placed(text: &str, x: f64, width: f64, bold: bool) -> crate::text::TextSpan {
2262 crate::text::TextSpan {
2263 x,
2264 width,
2265 ..run(text, 700.0, 10.0, bold, false)
2266 }
2267 }
2268
2269 fn rule(left: f64, right: f64, y: f64) -> HorizontalRule {
2270 HorizontalRule { left, right, y }
2271 }
2272
2273 #[test]
2274 fn a_rule_under_a_run_underlines_it_and_a_border_does_not() {
2275 let b = set_block(
2276 "See the terms of service before signing.",
2277 vec![
2278 placed("See the", 100.0, 35.0, false),
2279 placed("terms of service", 140.0, 80.0, true),
2280 placed("before signing.", 225.0, 75.0, false),
2281 ],
2282 );
2283 assert_eq!(
2285 styled(&b, &b.text, &[rule(140.0, 220.0, 698.5)]),
2286 "See the **<u>terms of service</u>** before signing."
2287 );
2288 assert_eq!(
2290 styled(&b, &b.text, &[rule(40.0, 560.0, 698.5)]),
2291 "See the **terms of service** before signing."
2292 );
2293 assert_eq!(
2295 styled(&b, &b.text, &[rule(140.0, 220.0, 688.0)]),
2296 "See the **terms of service** before signing."
2297 );
2298 assert_eq!(
2300 styled(&b, &b.text, &[rule(225.0, 300.0, 699.0)]),
2301 "See the **terms of service** <u>before signing.</u>"
2302 );
2303 }
2304
2305 #[test]
2306 fn bold_and_italic_runs_are_marked_from_the_spans() {
2307 let b = set_block(
2308 "Pay the net amount within thirty days of the invoice.",
2309 vec![
2310 run("Pay the", 700.0, 10.0, false, false),
2311 run("net amount", 700.0, 10.0, true, false),
2312 run("within", 700.0, 10.0, false, false),
2313 run("thirty days", 700.0, 10.0, false, true),
2314 run("of the invoice.", 700.0, 10.0, false, false),
2315 ],
2316 );
2317 assert_eq!(
2318 to_markdown(&[b]),
2319 "Pay the **net amount** within *thirty days* of the invoice.\n"
2320 );
2321 }
2322
2323 #[test]
2324 fn sub_word_spans_the_rebuild_joined_are_marked_whole() {
2325 let b = set_block(
2328 "2025",
2329 vec![
2330 placed("2", 100.0, 3.0, true),
2331 placed("0", 103.0, 3.0, true),
2332 placed("2", 106.0, 3.0, true),
2333 placed("5", 109.0, 3.0, true),
2334 ],
2335 );
2336 assert_eq!(styled(&b, &b.text, &[]), "**2025**");
2337 }
2338
2339 #[test]
2340 fn a_hyphen_off_its_line_is_still_half_a_word() {
2341 let b = set_block(
2344 "hyphen-ated",
2345 vec![
2346 placed("hyphen-", 100.0, 35.0, true),
2347 crate::text::TextSpan {
2348 x: 100.0,
2349 width: 20.0,
2350 ..run("ated", 688.0, 10.0, false, false)
2351 },
2352 ],
2353 );
2354 assert_eq!(styled(&b, &b.text, &[]), "hyphen-ated");
2355 }
2356
2357 #[test]
2358 fn neighbouring_runs_of_one_setting_are_one_mark() {
2359 let b = set_block(
2360 "Two bold words here.",
2361 vec![
2362 run("Two", 700.0, 10.0, true, false),
2363 run("bold", 700.0, 10.0, true, false),
2364 run("words here.", 700.0, 10.0, false, false),
2365 ],
2366 );
2367 assert_eq!(to_markdown(&[b]), "**Two bold** words here.\n");
2368 }
2369
2370 #[test]
2371 fn raised_and_lowered_runs_are_sup_and_sub() {
2372 let b = set_block(
2373 "Water is H2O and the area is 4 m2 here.",
2374 vec![
2375 run("Water is H", 700.0, 10.0, false, false),
2376 run("2", 697.0, 7.0, false, false),
2377 run("O and the area is 4 m", 700.0, 10.0, false, false),
2378 run("2", 703.5, 7.0, false, false),
2379 run("here.", 700.0, 10.0, false, false),
2380 ],
2381 );
2382 assert_eq!(
2383 to_markdown(&[b]),
2384 "Water is H<sub>2</sub>O and the area is 4 m<sup>2</sup> here.\n"
2385 );
2386 }
2387
2388 #[test]
2389 fn a_line_that_opens_like_a_list_item_keeps_its_escape_and_no_style() {
2390 let b = set_block(
2391 "3. The parties agree.",
2392 vec![
2393 run("3.", 700.0, 10.0, true, false),
2394 run("The parties agree.", 700.0, 10.0, false, false),
2395 ],
2396 );
2397 assert_eq!(to_markdown(&[b]), "3\\. The parties agree.\n");
2398 }
2399
2400 #[test]
2401 fn a_small_run_opening_the_next_line_is_not_a_subscript() {
2402 let b = set_block(
2403 "the end of a line and 12 more words",
2404 vec![
2405 run("the end of a line and", 700.0, 10.0, false, false),
2406 run("12", 688.0, 8.0, false, false),
2407 run("more words", 688.0, 10.0, false, false),
2408 ],
2409 );
2410 assert_eq!(to_markdown(&[b]), "the end of a line and 12 more words\n");
2411 }
2412
2413 #[test]
2414 fn a_bold_run_ending_in_a_page_break_hyphen_still_joins() {
2415 let mut first = set_block(
2416 "the word is hyphen-",
2417 vec![
2418 run("the word is", 700.0, 10.0, false, false),
2419 run("hyphen-", 700.0, 10.0, true, false),
2420 ],
2421 );
2422 first.page = 0;
2423 let mut second = block(BlockKind::Paragraph, "ated across the page.");
2424 second.page = 1;
2425 second.continues_previous = true;
2426 assert_eq!(
2427 to_markdown(&[first, second]),
2428 "the word is hyphenated across the page.\n"
2429 );
2430 }
2431
2432 #[test]
2433 fn right_to_left_text_is_left_unmarked() {
2434 let b = set_block(
2435 "שלום עולם",
2436 vec![
2437 run("עולם", 700.0, 10.0, true, false),
2438 run("שלום", 700.0, 10.0, false, false),
2439 ],
2440 );
2441 assert_eq!(to_markdown(&[b]), "שלום עולם\n");
2442 }
2443
2444 #[test]
2445 fn a_run_that_holds_markup_or_cannot_be_found_is_left_alone() {
2446 let b = set_block(
2447 "Use a*b and the hyphen-ated word.",
2448 vec![
2449 run("Use a*b", 700.0, 10.0, true, false),
2450 run("hyphen-", 700.0, 10.0, true, false),
2451 ],
2452 );
2453 assert_eq!(to_markdown(&[b]), "Use a*b and the hyphen-ated word.\n");
2454 }
2455
2456 fn mono(text: &str, x: f64, y: f64) -> crate::text::TextSpan {
2457 crate::text::TextSpan {
2458 text: text.to_string(),
2459 x,
2460 y,
2461 width: text.len().max(1) as f64 * 6.0,
2462 font_size: 10.0,
2463 is_monospace: Some(true),
2464 ..Default::default()
2465 }
2466 }
2467
2468 fn plain(text: &str, x: f64, y: f64) -> crate::text::TextSpan {
2469 crate::text::TextSpan {
2470 text: text.to_string(),
2471 x,
2472 y,
2473 width: text.len().max(1) as f64 * 6.0,
2474 font_size: 10.0,
2475 ..Default::default()
2476 }
2477 }
2478
2479 #[test]
2480 fn a_monospace_word_in_a_sentence_is_inline_code() {
2481 let b = set_block(
2482 "Use foo here.",
2483 vec![
2484 plain("Use ", 100.0, 700.0),
2485 mono("foo", 130.0, 700.0),
2486 plain(" here.", 150.0, 700.0),
2487 ],
2488 );
2489 assert_eq!(to_markdown(&[b]), "Use `foo` here.\n");
2490 }
2491
2492 #[test]
2493 fn neighbouring_monospace_runs_are_one_span() {
2494 let b = set_block(
2495 "Run foo bar now.",
2496 vec![
2497 plain("Run ", 100.0, 700.0),
2498 mono("foo", 130.0, 700.0),
2499 mono("bar", 155.0, 700.0),
2500 plain(" now.", 180.0, 700.0),
2501 ],
2502 );
2503 assert_eq!(to_markdown(&[b]), "Run `foo bar` now.\n");
2504 }
2505
2506 #[test]
2507 fn a_backtick_in_code_lengthens_the_fence() {
2508 let b = set_block(
2509 "Use a`b here.",
2510 vec![
2511 plain("Use ", 100.0, 700.0),
2512 mono("a`b", 130.0, 700.0),
2513 plain(" here.", 155.0, 700.0),
2514 ],
2515 );
2516 assert_eq!(to_markdown(&[b]), "Use ``a`b`` here.\n");
2517 }
2518
2519 #[test]
2520 fn code_wins_over_bold_on_the_same_run() {
2521 let mut code = mono("foo", 130.0, 700.0);
2522 code.is_bold = true;
2523 let b = set_block(
2524 "Use foo here.",
2525 vec![
2526 plain("Use ", 100.0, 700.0),
2527 code,
2528 plain(" here.", 150.0, 700.0),
2529 ],
2530 );
2531 assert_eq!(to_markdown(&[b]), "Use `foo` here.\n");
2532 }
2533
2534 #[test]
2535 fn code_at_the_opening_needs_no_escape() {
2536 let b = set_block("# foo", vec![mono("# foo", 100.0, 700.0)]);
2537 assert_eq!(to_markdown(&[b]), "`# foo`\n");
2538 }
2539
2540 #[test]
2541 fn a_list_item_reads_its_monospace_inline() {
2542 let item = block(
2543 BlockKind::ListItem {
2544 level: 0,
2545 ordinal: None,
2546 },
2547 "Run foo now.",
2548 );
2549 let item = Block {
2550 spans: vec![
2551 plain("Run ", 100.0, 700.0),
2552 mono("foo", 130.0, 700.0),
2553 plain(" now.", 150.0, 700.0),
2554 ],
2555 ..item
2556 };
2557 assert_eq!(to_markdown(&[item]), "- Run `foo` now.\n");
2558 }
2559
2560 #[test]
2561 fn an_all_monospace_paragraph_over_two_lines_reads_inline() {
2562 let b = set_block(
2566 "let x = 1; let y = 2;",
2567 vec![
2568 mono("let x = 1;", 100.0, 700.0),
2569 mono("let y = 2;", 100.0, 688.0),
2570 ],
2571 );
2572 assert_eq!(to_markdown(&[b]), "`let x = 1; let y = 2;`\n");
2573 }
2574
2575 #[test]
2576 fn a_fence_inside_an_unfenced_block_stays_inline() {
2577 let b = set_block(
2580 "``` code",
2581 vec![mono("```", 100.0, 700.0), mono("code", 100.0, 688.0)],
2582 );
2583 assert_eq!(
2584 to_markdown(std::slice::from_ref(&b)),
2585 "```` ``` code ````\n"
2586 );
2587 let prose = set_block("words", vec![plain("words", 100.0, 712.0)]);
2590 assert_eq!(to_markdown(&[prose, b]), "words\n\n````\n```\ncode\n````\n");
2591 }
2592
2593 #[test]
2594 fn a_single_monospace_line_stays_inline() {
2595 let b = set_block("print(x)", vec![mono("print(x)", 100.0, 700.0)]);
2596 assert_eq!(to_markdown(&[b]), "`print(x)`\n");
2597 }
2598
2599 #[test]
2600 fn a_paragraph_only_partly_monospace_stays_inline() {
2601 let b = set_block(
2602 "code_x words here",
2603 vec![
2604 mono("code_x", 100.0, 700.0),
2605 plain("words here", 100.0, 688.0),
2606 ],
2607 );
2608 assert_eq!(to_markdown(&[b]), "`code_x` words here\n");
2609 }
2610
2611 #[test]
2612 fn monospace_figures_are_not_code() {
2613 let b = set_block(
2614 "1,234 5,678 9,012",
2615 vec![mono("1,234 5,678 9,012", 100.0, 700.0)],
2616 );
2617 assert_eq!(to_markdown(&[b]), "1,234 5,678 9,012\n");
2618 let b = set_block(
2620 "1,234 5,678",
2621 vec![mono("1,234", 100.0, 700.0), mono("5,678", 100.0, 688.0)],
2622 );
2623 assert_eq!(to_markdown(&[b]), "1,234 5,678\n");
2624 }
2625
2626 #[test]
2627 fn tables_headings_and_captions_never_carry_code() {
2628 let mut code = cell(1, 0, false, "a1");
2629 code.spans = vec![mono("a1", 100.0, 700.0)];
2630 let table = vec![
2631 cell(0, 0, true, "Code"),
2632 cell(0, 1, true, "Means"),
2633 code,
2634 cell(1, 1, false, "either"),
2635 ];
2636 assert_eq!(
2637 to_markdown(&table),
2638 "| Code | Means |\n| --- | --- |\n| a1 | either |\n"
2639 );
2640 let heading = block(BlockKind::Heading { level: 1 }, "Setup foo.");
2641 let heading = Block {
2642 spans: vec![
2643 plain("Setup ", 100.0, 700.0),
2644 mono("foo", 150.0, 700.0),
2645 plain(".", 170.0, 700.0),
2646 ],
2647 ..heading
2648 };
2649 assert_eq!(to_markdown(&[heading]), "# Setup foo.\n");
2650 let caption = block(BlockKind::Caption, "Figure 1 shows foo.");
2651 let caption = Block {
2652 spans: vec![
2653 plain("Figure 1 shows ", 100.0, 700.0),
2654 mono("foo", 200.0, 700.0),
2655 plain(".", 220.0, 700.0),
2656 ],
2657 ..caption
2658 };
2659 assert_eq!(to_markdown(&[caption]), "*Figure 1 shows foo.*\n");
2660 }
2661
2662 #[test]
2663 fn code_false_writes_todays_bytes() {
2664 let b = set_block(
2665 "Use foo here.",
2666 vec![
2667 plain("Use ", 100.0, 700.0),
2668 mono("foo", 130.0, 700.0),
2669 plain(" here.", 150.0, 700.0),
2670 ],
2671 );
2672 assert_eq!(
2673 to_markdown_with_code(std::slice::from_ref(&b), false),
2674 "Use foo here.\n"
2675 );
2676 let fenced = set_block(
2677 "let x = 1; let y = 2;",
2678 vec![
2679 mono("let x = 1;", 100.0, 700.0),
2680 mono("let y = 2;", 100.0, 688.0),
2681 ],
2682 );
2683 assert_eq!(
2684 to_markdown_with_code(&[fenced], false),
2685 "let x = 1; let y = 2;\n"
2686 );
2687 assert_eq!(to_markdown(&[b]), "Use `foo` here.\n");
2688 }
2689
2690 #[test]
2691 fn a_code_span_never_closes_after_a_page_break_hyphen() {
2692 let mut first = set_block(
2693 "the setter is config-",
2694 vec![
2695 plain("the setter is ", 100.0, 700.0),
2696 mono("config-", 190.0, 700.0),
2697 ],
2698 );
2699 first.page = 0;
2700 let mut second = block(BlockKind::Paragraph, "file on the next page.");
2701 second.page = 1;
2702 second.continues_previous = true;
2703 assert_eq!(
2704 to_markdown(&[first, second]),
2705 "the setter is configfile on the next page.\n"
2706 );
2707 }
2708
2709 #[test]
2710 fn heading_levels_become_one_to_three_hashes() {
2711 let blocks = vec![
2712 block(BlockKind::Heading { level: 1 }, "Scope"),
2713 block(BlockKind::Paragraph, "This document describes."),
2714 block(BlockKind::Heading { level: 2 }, "Terms"),
2715 block(BlockKind::Heading { level: 3 }, "Party"),
2716 ];
2717 assert_eq!(
2718 to_markdown(&blocks),
2719 "# Scope\n\nThis document describes.\n\n## Terms\n\n### Party\n"
2720 );
2721 }
2722
2723 #[test]
2724 fn a_paragraph_that_opens_like_a_marker_is_escaped() {
2725 let blocks = vec![
2726 block(BlockKind::Paragraph, "# not a heading"),
2727 block(BlockKind::Paragraph, "- not an item"),
2728 block(BlockKind::Paragraph, "1. not an item"),
2729 block(BlockKind::Paragraph, "1984 was a year"),
2730 ];
2731 assert_eq!(
2732 to_markdown(&blocks),
2733 "\\# not a heading\n\n\\- not an item\n\n1\\. not an item\n\n1984 was a year\n"
2734 );
2735 }
2736
2737 #[test]
2738 fn a_figure_at_the_head_of_a_line_is_not_escaped() {
2739 let blocks = vec![
2745 block(BlockKind::Paragraph, "-3,503 6,200"),
2746 block(BlockKind::Paragraph, "0.1 0.01 0.001"),
2747 block(BlockKind::Paragraph, "#4 of the series"),
2748 block(BlockKind::Paragraph, "+3HnN is the rate"),
2749 block(BlockKind::Paragraph, "2.94 0.98 0.88"),
2750 block(BlockKind::Paragraph, "####### not a heading"),
2752 ];
2753 assert_eq!(
2754 to_markdown(&blocks),
2755 "-3,503 6,200\n\n0.1 0.01 0.001\n\n#4 of the series\n\n\
2756 +3HnN is the rate\n\n2.94 0.98 0.88\n\n####### not a heading\n"
2757 );
2758 }
2759
2760 #[test]
2761 fn a_heading_that_opens_with_a_run_of_hashes_is_still_escaped() {
2762 let blocks = vec![
2766 block(BlockKind::Paragraph, "## Terms"),
2767 block(BlockKind::Paragraph, "###### Sixth"),
2768 block(BlockKind::Paragraph, "###"),
2769 ];
2770 assert_eq!(
2771 to_markdown(&blocks),
2772 "\\## Terms\n\n\\###### Sixth\n\n\\###\n"
2773 );
2774 }
2775
2776 #[test]
2777 fn a_rule_of_three_is_escaped_whatever_follows_it() {
2778 let blocks = vec![
2782 block(BlockKind::Paragraph, "---"),
2783 block(BlockKind::Paragraph, "***"),
2784 block(BlockKind::Paragraph, "-- an em rule"),
2785 ];
2786 assert_eq!(to_markdown(&blocks), "\\---\n\n\\***\n\n-- an em rule\n");
2787 }
2788
2789 #[test]
2790 fn a_paragraph_that_opens_a_fence_cannot_swallow_the_document() {
2791 let blocks = vec![
2792 block(BlockKind::Paragraph, "```rust"),
2793 block(BlockKind::Heading { level: 1 }, "Scope"),
2794 block(BlockKind::Paragraph, "~~~ a tilde fence"),
2795 block(BlockKind::Paragraph, "`inline code` is not a fence"),
2796 ];
2797 let markdown = to_markdown(&blocks);
2798 assert!(
2799 markdown.starts_with("\\```rust"),
2800 "the fence opens a block nothing closes: {markdown}"
2801 );
2802 assert!(
2803 markdown.contains("\n# Scope\n"),
2804 "the heading after the fence was swallowed: {markdown}"
2805 );
2806 assert!(markdown.contains("\\~~~ a tilde fence"), "{markdown}");
2807 assert!(markdown.contains("\\`inline code`"), "{markdown}");
2808 }
2809
2810 fn cell(row: u16, column: u16, header: bool, text: &str) -> Block {
2811 block(
2812 BlockKind::TableCell {
2813 row,
2814 column,
2815 row_span: 1,
2816 column_span: 1,
2817 header,
2818 },
2819 text,
2820 )
2821 }
2822
2823 #[test]
2828 fn a_headerless_rectangle_is_html_under_the_switch() {
2829 let blocks = vec![
2830 cell(0, 0, false, "Genome size"),
2831 cell(0, 1, false, "14.6"),
2832 cell(1, 0, false, "Assembled"),
2833 cell(1, 1, false, "13"),
2834 ];
2835 assert_eq!(
2836 super::to_markdown_with_switches(&blocks, off()),
2837 "| Genome size | 14.6 |\n| --- | --- |\n| Assembled | 13 |\n"
2838 );
2839 let on = crate::learned::Switches {
2840 markdown_headerless_html: true,
2841 ..off()
2842 };
2843 let html = super::to_markdown_with_switches(&blocks, on);
2844 assert!(
2845 html.starts_with("<table>"),
2846 "a headerless table is HTML, not pipes: {html:?}"
2847 );
2848 assert!(
2849 !html.contains("<th"),
2850 "no cell is promoted to a header: {html:?}"
2851 );
2852 }
2853
2854 #[test]
2855 fn a_pipe_in_a_cell_does_not_open_a_column() {
2856 let blocks = vec![
2857 cell(0, 0, true, "Code"),
2858 cell(0, 1, true, "Means"),
2859 cell(1, 0, false, "a|b"),
2860 cell(1, 1, false, "either"),
2861 ];
2862 assert_eq!(
2863 to_markdown(&blocks),
2864 "| Code | Means |\n| --- | --- |\n| a\\|b | either |\n"
2865 );
2866 }
2867
2868 #[test]
2869 fn a_cell_that_holds_markup_is_escaped_in_the_html_form() {
2870 let mut blocks = vec![
2871 block(
2872 BlockKind::TableCell {
2873 row: 0,
2874 column: 0,
2875 row_span: 1,
2876 column_span: 2,
2877 header: true,
2878 },
2879 "a < b & c",
2880 ),
2881 cell(1, 0, false, "left"),
2882 cell(1, 1, false, "right"),
2883 ];
2884 blocks.push(cell(2, 0, false, "under"));
2885 blocks.push(cell(2, 1, false, "it"));
2886 assert_eq!(
2887 to_markdown(&blocks),
2888 "<table>\n\
2889 <tr><th colspan=\"2\">a < b & c</th></tr>\n\
2890 <tr><td>left</td><td>right</td></tr>\n\
2891 <tr><td>under</td><td>it</td></tr>\n\
2892 </table>\n"
2893 );
2894 }
2895
2896 #[test]
2897 fn a_currency_column_joins_the_column_to_its_right() {
2898 let joined = vec![
2899 cell(0, 0, false, "Service cost"),
2900 cell(0, 1, false, "$"),
2901 cell(0, 2, false, "115"),
2902 cell(1, 0, false, "Interest"),
2903 cell(1, 1, false, ""),
2904 cell(1, 2, false, "82"),
2905 ];
2906 let markdown = to_markdown(&joined);
2907 assert!(markdown.contains("$ 115"), "{markdown}");
2908 assert!(markdown.starts_with("<table>"), "{markdown}");
2911 assert!(!markdown.contains("<th"), "{markdown}");
2912 assert!(!markdown.contains("<td>$</td>"), "{markdown}");
2913 let not_currency = vec![
2914 cell(0, 0, false, "A"),
2915 cell(0, 1, false, "$"),
2916 cell(0, 2, false, "1"),
2917 cell(1, 0, false, "B"),
2918 cell(1, 1, false, "12"),
2919 cell(1, 2, false, "2"),
2920 ];
2921 let markdown = to_markdown(¬_currency);
2922 assert!(markdown.contains("<td>$</td>"), "{markdown}");
2923 assert!(!markdown.contains("$ 1"), "{markdown}");
2924 let spanning = vec![
2925 cell(0, 0, false, "Service"),
2926 cell(0, 1, false, "$"),
2927 block(
2928 BlockKind::TableCell {
2929 row: 0,
2930 column: 2,
2931 row_span: 1,
2932 column_span: 2,
2933 header: false,
2934 },
2935 "115",
2936 ),
2937 cell(1, 0, false, "Interest"),
2938 cell(1, 1, false, ""),
2939 cell(1, 2, false, "82"),
2940 cell(1, 3, false, "83"),
2941 ];
2942 let markdown = to_markdown(&spanning);
2943 assert!(!markdown.contains("$ 115"), "{markdown}");
2944 }
2945
2946 #[test]
2947 fn a_header_span_over_the_join_shrinks_with_it_and_a_partial_span_blocks() {
2948 fn spanned(row: u16, column: u16, column_span: u16, text: &str) -> Block {
2949 block(
2950 BlockKind::TableCell {
2951 row,
2952 column,
2953 row_span: 1,
2954 column_span,
2955 header: false,
2956 },
2957 text,
2958 )
2959 }
2960 let under_span = vec![
2964 cell(0, 0, false, "Item"),
2965 spanned(0, 1, 6, "For the years ended December 31"),
2966 cell(1, 0, false, "Net sales"),
2967 cell(1, 1, false, "$"),
2968 cell(1, 2, false, "114,750"),
2969 cell(1, 3, false, "$"),
2970 cell(1, 4, false, "101,029"),
2971 cell(1, 5, false, "$"),
2972 cell(1, 6, false, "90,547"),
2973 cell(2, 0, false, "Cost of sales"),
2974 cell(2, 1, false, ""),
2975 cell(2, 2, false, "86,133"),
2976 cell(2, 3, false, ""),
2977 cell(2, 4, false, "74,920"),
2978 cell(2, 5, false, ""),
2979 cell(2, 6, false, "66,610"),
2980 ];
2981 let markdown = to_markdown(&under_span);
2982 assert!(
2983 markdown.contains("<th colspan=\"3\">For the years ended December 31</th>"),
2984 "the span shrinks with the three columns it covers: {markdown}"
2985 );
2986 assert!(markdown.contains("$ 114,750"), "{markdown}");
2987 assert!(
2988 !markdown.contains(">$<"),
2989 "no bare currency column survives: {markdown}"
2990 );
2991 let partial = vec![
2994 spanned(0, 0, 2, "Sales"),
2995 cell(0, 2, false, "Amount"),
2996 cell(1, 0, false, "Net"),
2997 cell(1, 1, false, "$"),
2998 cell(1, 2, false, "1"),
2999 ];
3000 let markdown = to_markdown(&partial);
3001 assert!(
3002 !markdown.contains("$ 1"),
3003 "a partial span still blocks the join: {markdown}"
3004 );
3005 assert!(
3006 markdown.contains(">$<"),
3007 "the blocked currency column survives: {markdown}"
3008 );
3009 }
3010
3011 #[test]
3014 fn a_second_table_starts_where_the_grid_starts_again() {
3015 let blocks = vec![
3016 cell(0, 0, false, "one"),
3017 cell(0, 1, false, "two"),
3018 cell(1, 0, false, "three"),
3019 cell(1, 1, false, "four"),
3020 cell(0, 0, false, "five"),
3021 cell(0, 1, false, "six"),
3022 cell(1, 0, false, "seven"),
3023 cell(1, 1, false, "eight"),
3024 ];
3025 let markdown = to_markdown(&blocks);
3026 assert_eq!(markdown.matches("| --- | --- |").count(), 2, "{markdown}");
3027 assert!(markdown.contains("| four |\n\n| five |"), "{markdown}");
3028 }
3029
3030 #[test]
3035 fn a_second_table_that_opens_with_a_hole_is_still_a_second_table() {
3036 let blocks = vec![
3037 cell(0, 0, false, "one"),
3038 cell(0, 1, false, "two"),
3039 cell(1, 0, false, "three"),
3040 cell(1, 1, false, "four"),
3041 cell(0, 1, false, "six"),
3042 cell(1, 0, false, "seven"),
3043 cell(1, 1, false, "eight"),
3044 ];
3045 let markdown = to_markdown(&blocks);
3046 assert_eq!(
3047 markdown.matches("<table>").count()
3048 + markdown
3049 .lines()
3050 .filter(|line| line.starts_with("| ---"))
3051 .count(),
3052 2,
3053 "{markdown}"
3054 );
3055 for word in ["one", "four", "six", "eight"] {
3056 assert!(markdown.contains(word), "{word} was dropped: {markdown}");
3057 }
3058 }
3059
3060 fn on_page(kind: BlockKind, text: &str, page: usize) -> Block {
3061 Block {
3062 page,
3063 ..block(kind, text)
3064 }
3065 }
3066
3067 #[test]
3071 fn a_paragraph_that_quotes_the_page_marker_is_not_one() {
3072 let blocks = vec![
3073 block(BlockKind::Paragraph, "<!-- page 7 -->"),
3074 block(BlockKind::Paragraph, "<table> opens a block too"),
3075 ];
3076 let markdown = to_markdown(&blocks);
3077 assert_eq!(
3078 markdown,
3079 "\\<!-- page 7 -->\n\n\\<table> opens a block too\n"
3080 );
3081 assert!(
3082 !markdown.lines().any(|line| line == "<!-- page 7 -->"),
3083 "document text came out as a marker: {markdown}"
3084 );
3085 }
3086
3087 #[test]
3088 fn a_page_change_is_marked_and_the_first_page_is_not() {
3089 let blocks = vec![
3090 on_page(BlockKind::Paragraph, "The first page ends here.", 0),
3091 on_page(BlockKind::Paragraph, "The second opens here.", 1),
3092 ];
3093 assert_eq!(
3094 to_markdown(&blocks),
3095 "The first page ends here.\n\n<!-- page 1 -->\n\nThe second opens here.\n"
3096 );
3097 }
3098
3099 #[test]
3103 fn the_number_in_the_marker_is_the_block_s_own_page() {
3104 let blocks = vec![
3105 on_page(BlockKind::Paragraph, "Opening.", 4),
3106 on_page(BlockKind::Paragraph, "Next.", 7),
3107 ];
3108 assert!(to_markdown(&blocks).contains("<!-- page 7 -->"));
3109 }
3110
3111 #[test]
3116 fn a_paragraph_the_break_cut_in_two_is_not_cut_again_by_the_marker() {
3117 let mut middle = on_page(BlockKind::Paragraph, "to its end.", 1);
3118 middle.continues_previous = true;
3119 let blocks = vec![
3120 on_page(BlockKind::Paragraph, "The sentence runs on", 0),
3121 middle,
3122 on_page(BlockKind::Paragraph, "A new one.", 2),
3123 ];
3124 assert_eq!(
3125 to_markdown(&blocks),
3126 "The sentence runs on to its end.\n\n<!-- page 2 -->\n\nA new one.\n"
3127 );
3128 }
3129
3130 #[test]
3135 fn the_deferred_marker_stands_after_the_joined_paragraph() {
3136 let mut middle = on_page(BlockKind::Paragraph, "to its end.", 1);
3137 middle.continues_previous = true;
3138 let blocks = vec![
3139 on_page(BlockKind::Paragraph, "The sentence runs on", 0),
3140 middle,
3141 on_page(BlockKind::Heading { level: 1 }, "Finance", 1),
3142 on_page(BlockKind::Paragraph, "Revenue was ahead of budget.", 1),
3143 ];
3144 assert_eq!(
3145 to_markdown(&blocks),
3146 "The sentence runs on to its end.\n\n<!-- page 1 -->\n\n# Finance\n\n\
3147 Revenue was ahead of budget.\n"
3148 );
3149 }
3150
3151 #[test]
3155 fn the_marker_closes_the_list_it_stands_after() {
3156 let blocks = vec![
3157 on_page(
3158 BlockKind::ListItem {
3159 level: 0,
3160 ordinal: None,
3161 },
3162 "first",
3163 0,
3164 ),
3165 on_page(
3166 BlockKind::ListItem {
3167 level: 1,
3168 ordinal: None,
3169 },
3170 "under it",
3171 1,
3172 ),
3173 ];
3174 assert_eq!(
3175 to_markdown(&blocks),
3176 "- first\n\n<!-- page 1 -->\n\n- under it\n"
3177 );
3178 }
3179
3180 #[test]
3184 fn the_running_head_does_not_mark_a_page() {
3185 let blocks = vec![
3186 on_page(BlockKind::Paragraph, "Body one.", 0),
3187 on_page(BlockKind::Paragraph, "Body two.", 1),
3188 on_page(BlockKind::Header, "Annual Report 2026", 0),
3189 on_page(BlockKind::Header, "Annual Report 2026", 1),
3190 ];
3191 assert_eq!(
3192 to_markdown(&blocks),
3193 "Body one.\n\n<!-- page 1 -->\n\nBody two.\n"
3194 );
3195 }
3196
3197 #[test]
3201 fn furniture_asked_for_is_plain_paragraphs_where_they_stand() {
3202 let blocks = vec![
3203 on_page(BlockKind::Paragraph, "Body one.", 0),
3204 on_page(BlockKind::Paragraph, "Body two.", 1),
3205 on_page(BlockKind::Header, "Annual Report 2026", 0),
3206 on_page(BlockKind::Footer, "Page 1 of 2", 0),
3207 ];
3208 let options = MarkdownOptions {
3209 include_headers: true,
3210 include_footers: true,
3211 ..Default::default()
3212 };
3213 let markdown = to_markdown_with_options(&blocks, &options);
3214 assert_eq!(
3215 markdown,
3216 "Body one.\n\n<!-- page 1 -->\n\nBody two.\n\n<!-- page 0 -->\n\n\
3217 Annual Report 2026\n\nPage 1 of 2\n"
3218 );
3219 assert!(
3220 !markdown.lines().any(|line| line.starts_with('#')),
3221 "no heading syntax for furniture:\n{markdown}"
3222 );
3223 }
3224
3225 #[test]
3228 fn headers_and_footers_are_asked_for_separately() {
3229 let blocks = vec![
3230 on_page(BlockKind::Paragraph, "Body one.", 0),
3231 on_page(BlockKind::Header, "Annual Report 2026", 0),
3232 on_page(BlockKind::Footer, "Page 1 of 2", 0),
3233 ];
3234 let heads = MarkdownOptions {
3235 include_headers: true,
3236 ..Default::default()
3237 };
3238 let heads_only = to_markdown_with_options(&blocks, &heads);
3239 assert!(
3240 heads_only.contains("Annual Report 2026"),
3241 "the head is written:\n{heads_only}"
3242 );
3243 assert!(
3244 !heads_only.contains("Page 1 of 2"),
3245 "the footer is not:\n{heads_only}"
3246 );
3247 let feet = MarkdownOptions {
3248 include_footers: true,
3249 ..Default::default()
3250 };
3251 let feet_only = to_markdown_with_options(&blocks, &feet);
3252 assert!(
3253 !feet_only.contains("Annual Report 2026"),
3254 "the head is not:\n{feet_only}"
3255 );
3256 assert!(
3257 feet_only.contains("Page 1 of 2"),
3258 "the footer is written:\n{feet_only}"
3259 );
3260 }
3261
3262 #[test]
3266 fn without_separators_pages_stand_a_blank_line_apart() {
3267 let blocks = vec![
3268 on_page(BlockKind::Paragraph, "Body one.", 0),
3269 on_page(BlockKind::Paragraph, "Body two.", 1),
3270 ];
3271 let options = MarkdownOptions {
3272 page_separators: PageSeparators::None,
3273 ..Default::default()
3274 };
3275 assert_eq!(
3276 to_markdown_with_options(&blocks, &options),
3277 "Body one.\n\nBody two.\n"
3278 );
3279 }
3280
3281 #[test]
3282 fn a_table_the_page_broke_is_two_tables_with_the_marker_between_them() {
3283 let mut blocks = vec![cell(0, 0, true, "Code"), cell(0, 1, true, "Means")];
3284 blocks.push(on_page(
3285 BlockKind::TableCell {
3286 row: 0,
3287 column: 0,
3288 row_span: 1,
3289 column_span: 1,
3290 header: true,
3291 },
3292 "Code",
3293 1,
3294 ));
3295 blocks.push(on_page(
3296 BlockKind::TableCell {
3297 row: 0,
3298 column: 1,
3299 row_span: 1,
3300 column_span: 1,
3301 header: true,
3302 },
3303 "Means",
3304 1,
3305 ));
3306 assert_eq!(
3309 to_markdown(&blocks),
3310 "<table>\n<tr><th>Code</th><th>Means</th></tr>\n</table>\n\n<!-- page 1 -->\n\n<table>\n<tr><th>Code</th><th>Means</th></tr>\n</table>\n"
3311 );
3312 }
3313
3314 #[test]
3315 fn nothing_in_means_nothing_out() {
3316 assert_eq!(to_markdown(&[]), "");
3317 assert_eq!(to_markdown(&[block(BlockKind::Paragraph, " ")]), "");
3318 }
3319}