1use crate::document::{
4 ContentLayer, ContentLayers, DoclingDocument, MarkdownExportOptions, Node, Table,
5};
6
7const MISSING_KEY_VALUE_ITEM: &str = "<!-- missing-key-value-item -->";
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
14pub enum ImageMode {
15 #[default]
17 Placeholder,
18 Embedded,
20 Referenced,
23}
24
25struct Ctx {
27 strict: bool,
28 alt_text: bool,
31 compact_tables: bool,
33 images: ImageMode,
34 artifacts_dir: String,
35 artifacts: Vec<(String, Vec<u8>)>,
37 pic_index: usize,
38 in_table_cell: bool,
42 page_break: Option<String>,
46 pending_page_break: bool,
52 emitted_any: bool,
55 layers: ContentLayers,
57 current_layer: Option<ContentLayer>,
62 traverse_pictures: bool,
64 escape_html: bool,
67 escape_underscores: bool,
69 image_placeholder: String,
71 plain: bool,
73}
74
75impl Ctx {
76 fn new(doc: &DoclingDocument, strict: bool, options: &MarkdownExportOptions) -> Self {
79 Ctx {
80 strict,
81 alt_text: strict || doc.picture_alt_text,
82 compact_tables: doc.compact_tables,
83 images: options.image_mode,
84 artifacts_dir: options.artifacts_dir.clone(),
85 artifacts: Vec::new(),
86 pic_index: 0,
87 in_table_cell: false,
88 page_break: doc.page_break_placeholder.clone(),
89 pending_page_break: false,
90 emitted_any: false,
91 layers: options.layers,
92 current_layer: None,
93 traverse_pictures: options.traverse_pictures,
94 escape_html: options.escape_html,
95 escape_underscores: options.escape_underscores,
96 image_placeholder: options.image_placeholder.clone(),
97 plain: options.plain_text,
98 }
99 }
100
101 fn layer_visible(&self) -> bool {
103 self.layers.contains(self.current_layer)
104 }
105
106 fn unescape(&self, text: &str) -> String {
113 let mut out = std::borrow::Cow::Borrowed(text);
114 if !self.escape_html
115 && (out.contains("&") || out.contains("<") || out.contains(">"))
116 {
117 out = std::borrow::Cow::Owned(
118 out.replace("<", "<")
119 .replace(">", ">")
120 .replace("&", "&"),
121 );
122 }
123 if !self.escape_underscores && out.contains("\\_") {
124 out = std::borrow::Cow::Owned(out.replace("\\_", "_"));
125 }
126 out.into_owned()
127 }
128
129 fn text(&self, text: &str) -> String {
132 let text = strict_text(text, self.strict);
133 if self.plain {
134 self.unescape(&plain_inline(&self.drop_image_markers(&text)))
135 } else {
136 self.unescape(&text)
137 }
138 }
139
140 fn caption(&self, text: &str) -> String {
144 if self.plain {
145 self.unescape(&plain_inline(text))
146 } else {
147 self.unescape(text)
148 }
149 }
150
151 fn breaks(&self, text: &str) -> String {
154 if self.plain {
155 text.to_string()
156 } else {
157 md_line_breaks(text)
158 }
159 }
160
161 fn plain_table(&self, table: &Table) -> Table {
168 let mut plain = table.clone();
169 for cell in plain.rows.iter_mut().flatten() {
170 if cell.contains(['*', '~', '`', '[', '\n', '<']) {
171 let text = self.drop_image_markers(&strip_hard_breaks(cell));
172 *cell = plain_inline(&text);
173 }
174 }
175 plain
176 }
177
178 fn drop_image_markers(&self, text: &str) -> String {
185 const MARKER: &str = "<!-- image -->";
186 if !text.contains(MARKER) && !text.contains("```") {
187 return text.to_string();
188 }
189 text.split("\n\n")
193 .filter_map(|block| {
194 let lines: Vec<String> = block
195 .split('\n')
196 .filter_map(|line| {
197 if line.trim() == MARKER || line.trim() == "```" {
200 return None;
201 }
202 Some(line.replace(MARKER, &self.image_placeholder))
203 })
204 .collect();
205 (!lines.is_empty()).then(|| lines.join("\n"))
206 })
207 .collect::<Vec<_>>()
208 .join("\n\n")
209 }
210}
211
212pub fn to_markdown(doc: &DoclingDocument, strict: bool) -> String {
218 to_markdown_images(doc, strict, ImageMode::Placeholder, "artifacts").0
219}
220
221pub fn to_markdown_images(
225 doc: &DoclingDocument,
226 strict: bool,
227 images: ImageMode,
228 artifacts_dir: &str,
229) -> (String, Vec<(String, Vec<u8>)>) {
230 to_markdown_with_options(
231 doc,
232 strict,
233 &MarkdownExportOptions {
234 image_mode: images,
235 artifacts_dir: artifacts_dir.to_string(),
236 ..MarkdownExportOptions::default()
237 },
238 )
239}
240
241pub fn to_markdown_with_options(
245 doc: &DoclingDocument,
246 strict: bool,
247 options: &MarkdownExportOptions,
248) -> (String, Vec<(String, Vec<u8>)>) {
249 let mut ctx = Ctx::new(doc, strict, options);
250 let mut blocks: Vec<String> = Vec::new();
251 render(&doc.nodes, &mut blocks, &mut ctx);
252 let mut body = blocks.join("\n\n");
253 if strict && !options.plain_text && !doc.links.is_empty() {
257 body = apply_links(&body, &doc.links);
258 }
259 let front = if doc.metadata_output.front_matter() && !options.plain_text {
262 doc.metadata.to_front_matter()
263 } else {
264 String::new()
265 };
266 let md = match (front.is_empty(), body.is_empty()) {
267 (true, true) => String::new(),
268 (true, false) => format!("{body}\n"),
269 (false, true) => front,
270 (false, false) => format!("{front}\n{body}\n"),
271 };
272 (md, ctx.artifacts)
273}
274
275pub fn to_markdown_table_cell(doc: &DoclingDocument, strict: bool) -> String {
284 let mut ctx = Ctx::new(doc, strict, &MarkdownExportOptions::default());
285 ctx.in_table_cell = true;
286 ctx.artifacts_dir = String::new();
287 ctx.page_break = None;
291 let mut blocks: Vec<String> = Vec::new();
292 render(&doc.nodes, &mut blocks, &mut ctx);
293 blocks.join("\n\n")
294}
295
296fn apply_links(body: &str, links: &[(String, String)]) -> String {
304 let mut out = body.to_string();
305 let mut cursor = 0usize;
306 for (anchor, href) in links {
307 let anchor = anchor
308 .replace('&', "&")
309 .replace('<', "<")
310 .replace('>', ">");
311 if anchor.is_empty() {
312 continue;
313 }
314 if let Some(rel) = out[cursor..].find(&anchor) {
315 let at = cursor + rel;
316 let replacement = format!("[{anchor}]({href})");
318 out.replace_range(at..at + anchor.len(), &replacement);
319 cursor = at + replacement.len();
320 }
321 }
322 out
323}
324
325fn apply_links_chunk(chunk: &str, queue: &mut Vec<(String, String)>) -> String {
336 let mut out = chunk.to_string();
337 let mut cursor = 0usize;
338 let mut carried: Vec<(String, String)> = Vec::new();
339 for (anchor_raw, href) in std::mem::take(queue) {
340 let anchor = anchor_raw
341 .replace('&', "&")
342 .replace('<', "<")
343 .replace('>', ">");
344 if anchor.is_empty() {
345 continue;
346 }
347 if let Some(rel) = out[cursor..].find(&anchor) {
348 let at = cursor + rel;
349 let replacement = format!("[{anchor}]({href})");
350 out.replace_range(at..at + anchor.len(), &replacement);
351 cursor = at + replacement.len();
352 } else {
353 carried.push((anchor_raw, href));
355 }
356 }
357 *queue = carried;
358 out
359}
360
361pub struct MarkdownStreamer {
379 strict: bool,
380 images: ImageMode,
381 compact_tables: bool,
382 picture_alt_text: bool,
384 front_matter: Option<String>,
388 emitted_any: bool,
391 links: Vec<(String, String)>,
393 artifacts_dir: String,
397 artifacts: Vec<(String, Vec<u8>)>,
398 pic_index: usize,
399 page_break: Option<String>,
403 pending_page_break: bool,
404 items_emitted: bool,
410 layers: ContentLayers,
413 traverse_pictures: bool,
414 escape_html: bool,
415 escape_underscores: bool,
416 image_placeholder: String,
417 plain: bool,
418}
419
420impl MarkdownStreamer {
421 pub fn new(strict: bool, images: ImageMode, compact_tables: bool) -> Self {
424 debug_assert!(
425 images != ImageMode::Referenced,
426 "referenced image mode needs an artifacts dir; use with_artifacts"
427 );
428 Self::with_artifacts(strict, images, compact_tables, "artifacts")
429 }
430
431 pub fn with_artifacts(
438 strict: bool,
439 images: ImageMode,
440 compact_tables: bool,
441 artifacts_dir: &str,
442 ) -> Self {
443 Self {
444 strict,
445 images,
446 compact_tables,
447 picture_alt_text: false,
448 front_matter: None,
449 emitted_any: false,
450 links: Vec::new(),
451 artifacts_dir: artifacts_dir.to_string(),
452 artifacts: Vec::new(),
453 pic_index: 0,
454 page_break: None,
455 pending_page_break: false,
456 items_emitted: false,
457 layers: ContentLayers::BODY,
458 traverse_pictures: false,
459 escape_html: true,
460 escape_underscores: true,
461 image_placeholder: "<!-- image -->".to_string(),
462 plain: false,
463 }
464 }
465
466 pub fn with_export_options(mut self, options: &MarkdownExportOptions) -> Self {
473 self.images = options.image_mode;
474 self.artifacts_dir = options.artifacts_dir.clone();
475 self.layers = options.layers;
476 self.traverse_pictures = options.traverse_pictures;
477 self.escape_html = options.escape_html;
478 self.escape_underscores = options.escape_underscores;
479 self.image_placeholder = options.image_placeholder.clone();
480 self.plain = options.plain_text;
481 self
482 }
483
484 pub fn with_page_break_placeholder(mut self, placeholder: Option<String>) -> Self {
489 self.page_break = placeholder;
490 self
491 }
492
493 pub fn with_picture_alt_text(mut self, on: bool) -> Self {
497 self.picture_alt_text = on;
498 self
499 }
500
501 pub fn with_metadata(mut self, metadata: &crate::metadata::DocumentMetadata) -> Self {
506 self.front_matter = (!self.plain)
507 .then(|| metadata.to_front_matter())
508 .filter(|f| !f.is_empty());
509 self
510 }
511
512 pub fn take_artifacts(&mut self) -> Vec<(String, Vec<u8>)> {
517 std::mem::take(&mut self.artifacts)
518 }
519
520 pub fn push(&mut self, nodes: &[Node], links: &[(String, String)]) -> String {
525 self.links.extend(links.iter().cloned());
526 let mut ctx = Ctx {
527 strict: self.strict,
528 alt_text: self.strict || self.picture_alt_text,
529 compact_tables: self.compact_tables,
530 images: self.images,
531 artifacts_dir: std::mem::take(&mut self.artifacts_dir),
532 artifacts: std::mem::take(&mut self.artifacts),
533 pic_index: self.pic_index,
534 in_table_cell: false,
535 page_break: std::mem::take(&mut self.page_break),
536 pending_page_break: self.pending_page_break,
537 emitted_any: self.items_emitted,
538 layers: self.layers,
539 current_layer: None,
540 traverse_pictures: self.traverse_pictures,
541 escape_html: self.escape_html,
542 escape_underscores: self.escape_underscores,
543 image_placeholder: std::mem::take(&mut self.image_placeholder),
544 plain: self.plain,
545 };
546 let mut blocks: Vec<String> = Vec::new();
547 render(nodes, &mut blocks, &mut ctx);
548 self.image_placeholder = std::mem::take(&mut ctx.image_placeholder);
549 self.artifacts_dir = std::mem::take(&mut ctx.artifacts_dir);
550 self.artifacts = std::mem::take(&mut ctx.artifacts);
551 self.pic_index = ctx.pic_index;
552 self.page_break = std::mem::take(&mut ctx.page_break);
553 self.pending_page_break = ctx.pending_page_break;
554 self.items_emitted = ctx.emitted_any;
555 if blocks.is_empty() {
556 return String::new();
557 }
558 let mut body = blocks.join("\n\n");
559 if self.strict && !self.plain && !self.links.is_empty() {
560 body = apply_links_chunk(&body, &mut self.links);
561 }
562 let chunk = if let Some(front) = self.front_matter.take() {
563 format!("{front}\n{body}")
564 } else if self.emitted_any {
565 format!("\n\n{body}")
566 } else {
567 body
568 };
569 self.emitted_any = true;
570 chunk
571 }
572
573 pub fn finish(self) -> String {
576 if let Some(front) = self.front_matter {
577 front
578 } else if self.emitted_any {
579 "\n".to_string()
580 } else {
581 String::new()
582 }
583 }
584}
585
586fn strict_text(text: &str, strict: bool) -> String {
594 if !strict {
595 return text.to_string();
596 }
597 text.replace("\\_", "_")
598 .replace(" ,", ",")
599 .replace(" .", ".")
600 .replace(" ;", ";")
601 .replace(" )", ")")
602 .replace("( ", "(")
603 .replace(" ]", "]")
604 .replace("[ ", "[")
605}
606
607pub(crate) fn plain_inline(text: &str) -> String {
620 if !text.contains(['*', '~', '`', '[']) {
621 return text.to_string();
622 }
623 let chars: Vec<char> = text.chars().collect();
624 let mut out = String::with_capacity(text.len());
625 plain_inline_into(&chars, &mut out);
626 out
627}
628
629fn plain_inline_into(chars: &[char], out: &mut String) {
630 let find = |open: usize, pat: &[char]| -> Option<usize> {
631 chars
632 .get(open..)?
633 .windows(pat.len())
634 .position(|w| w == pat)
635 .map(|p| open + p)
636 };
637 let span = |i: usize, pat: &[char]| -> Option<usize> {
640 if !chars[i..].starts_with(pat) {
641 return None;
642 }
643 let open = i + pat.len();
644 let end = find(open, pat)?;
645 (end > open && !chars[open].is_whitespace() && !chars[end - 1].is_whitespace())
646 .then_some(end)
647 };
648 let mut i = 0;
649 while i < chars.len() {
650 let mut matched = false;
651 for pat in [&['*', '*', '*'][..], &['*', '*'], &['~', '~'], &['*']] {
652 if let Some(end) = span(i, pat) {
653 plain_inline_into(&chars[i + pat.len()..end], out);
654 i = end + pat.len();
655 matched = true;
656 break;
657 }
658 }
659 if matched {
660 continue;
661 }
662 if chars[i] == '`' {
666 if let Some(end) = find(i + 1, &['`']).filter(|&e| {
667 e > i + 1 && !chars[i + 1].is_whitespace() && !chars[e - 1].is_whitespace()
668 }) {
669 out.extend(&chars[i + 1..end]);
670 i = end + 1;
671 continue;
672 }
673 }
674 if chars[i] == '$' {
677 if let Some(end) = find(i + 1, &['$']) {
678 out.extend(&chars[i..=end]);
679 i = end + 1;
680 continue;
681 }
682 }
683 if chars[i] == '[' {
686 if let Some(close) = closing_bracket(chars, i) {
687 if chars.get(close + 1) == Some(&'(') {
688 if let Some(endp) = crate::doclang::link_dest_end(chars, close + 2) {
689 plain_inline_into(&chars[i + 1..close], out);
690 i = endp + 1;
691 continue;
692 }
693 }
694 }
695 }
696 let c = chars[i];
699 out.push(c);
700 i += 1;
701 if matches!(c, '*' | '~') {
702 while i < chars.len() && chars[i] == c {
703 out.push(c);
704 i += 1;
705 }
706 }
707 }
708}
709
710fn closing_bracket(chars: &[char], open: usize) -> Option<usize> {
713 let mut depth = 0usize;
714 for (k, c) in chars.iter().enumerate().skip(open + 1) {
715 match c {
716 '[' => depth += 1,
717 ']' if depth == 0 => return Some(k),
718 ']' => depth -= 1,
719 _ => {}
720 }
721 }
722 None
723}
724
725fn md_line_breaks(text: &str) -> String {
733 if !text.contains('\n') {
734 return text.to_string();
735 }
736 text.split("\n\n")
737 .map(|para| para.replace('\n', " \n"))
738 .collect::<Vec<_>>()
739 .join("\n\n")
740}
741
742pub(crate) fn strip_hard_breaks(text: &str) -> String {
747 if text.contains(" \n") {
748 text.replace(" \n", "\n")
749 } else {
750 text.to_string()
751 }
752}
753
754fn heading_line_breaks(text: &str) -> String {
758 text.replace('\n', " ")
759}
760
761fn render(nodes: &[Node], blocks: &mut Vec<String>, ctx: &mut Ctx) {
762 let mut i = 0;
763 while i < nodes.len() {
764 let before = blocks.len();
765 match &nodes[i] {
766 Node::PageBreak | Node::PageInfo { .. } => {
776 if ctx.page_break.is_some() && ctx.emitted_any {
777 ctx.pending_page_break = true;
778 }
779 i += 1;
780 }
781 Node::ListItem { .. } => {
782 let start = i;
783 i += 1;
784 loop {
785 match nodes.get(i) {
786 Some(Node::ListItem { .. } | Node::ListText { .. }) => i += 1,
787 Some(Node::Paragraph { text })
791 if text.is_empty()
792 && matches!(nodes.get(i + 1), Some(Node::ListItem { .. })) =>
793 {
794 i += 1
795 }
796 _ => break,
797 }
798 }
799 render_list_run(&nodes[start..i], blocks, ctx);
800 }
801 other => {
802 render_one(other, blocks, ctx);
803 i += 1;
804 }
805 }
806 if blocks.len() > before {
807 let kept: Vec<String> = blocks.drain(before..).filter(|b| !b.is_empty()).collect();
817 blocks.extend(kept);
818 if ctx.pending_page_break {
819 if let Some(placeholder) = &ctx.page_break {
824 blocks.insert(before, placeholder.clone());
825 }
826 ctx.pending_page_break = false;
827 }
828 ctx.emitted_any = true;
829 }
830 }
831}
832
833fn render_list_run(items: &[Node], blocks: &mut Vec<String>, ctx: &Ctx) {
839 let mut lines: Vec<String> = Vec::new();
840 let mut any_top = false;
843
844 for item in items {
845 if let Node::ListText { level, text, glue } = item {
846 let text = ctx.text(text);
847 match lines.last_mut() {
848 Some(last) if *glue => last.push_str(&text),
849 _ => lines.push(format!("{}{text}", " ".repeat(*level as usize))),
850 }
851 continue;
852 }
853 let Node::ListItem {
854 ordered,
855 number,
856 first_in_list,
857 text,
858 level,
859 marker: orig_marker,
860 location: _,
861 dclx: _,
862 href: _,
863 layer,
864 } = item
865 else {
866 continue;
867 };
868 if !ctx.layers.contains(layer.or(ctx.current_layer)) {
872 continue;
873 }
874 let level = *level as usize;
875
876 if level == 0 {
885 if any_top && *first_in_list {
886 lines.push(String::new());
887 }
888 any_top = true;
889 }
890
891 let indent = " ".repeat(level);
892 let verbatim = orig_marker.as_deref().filter(|m| {
897 m.strip_suffix('.')
898 .is_some_and(|d| !d.is_empty() && d.chars().all(char::is_numeric))
899 });
900 let marker = match verbatim {
901 Some(m) if *ordered => m.to_string(),
902 _ if *ordered => format!("{number}."),
903 _ => "-".to_string(),
904 };
905 lines.push(format!("{indent}{marker} {}", list_item_text(text, ctx)));
906 }
907
908 if !lines.is_empty() {
911 blocks.push(lines.join("\n"));
912 }
913}
914
915fn list_item_text(text: &str, ctx: &Ctx) -> String {
923 let escaped = ctx.text(text);
924 if ctx.plain {
925 return escaped;
926 }
927 if let Some((own, tail)) = escaped.split_once('\n') {
928 if is_folded_child_tail(tail) {
929 return format!("{}\n{tail}", md_line_breaks(own));
930 }
931 }
932 md_line_breaks(&escaped)
933}
934
935fn is_folded_child_tail(tail: &str) -> bool {
942 const MARKER: &str = "<!-- image -->";
943 const FENCE: &str = "```";
944 let mut lines = tail.split('\n').peekable();
945 let mut any = false;
946 while let Some(line) = lines.next() {
947 let line = line.trim_start();
948 if line == MARKER {
949 any = true;
950 } else if line == FENCE {
951 loop {
953 match lines.next() {
954 Some(l) if l.trim_start() == FENCE => break,
955 Some(_) => {}
956 None => return false,
957 }
958 }
959 any = true;
960 } else if lines.next().map(str::trim_start) == Some(MARKER) {
961 any = true; } else {
963 return false;
964 }
965 }
966 any
967}
968
969fn render_one(node: &Node, blocks: &mut Vec<String>, ctx: &mut Ctx) {
974 match node {
975 Node::Group { .. }
977 | Node::Furniture { .. }
978 | Node::PageFurniture { .. }
979 | Node::FurnitureText { .. }
980 | Node::CommentSection { .. }
981 | Node::PictureChildren(_)
982 | Node::Commented { .. }
983 | Node::Located { .. }
984 | Node::Prov { .. }
985 | Node::Track { .. }
986 | Node::PageBreak
987 | Node::PageInfo { .. }
988 | Node::DoclangOnly(_) => {}
989 _ if !ctx.layer_visible() => return,
990 _ => {}
991 }
992 render_item(node, blocks, ctx)
993}
994
995fn render_item(node: &Node, blocks: &mut Vec<String>, ctx: &mut Ctx) {
996 match node {
997 Node::Heading { level, text } => {
998 if ctx.plain {
999 blocks.push(ctx.text(text));
1002 return;
1003 }
1004 let text = heading_line_breaks(&ctx.text(text));
1005 if ctx.in_table_cell {
1006 blocks.push(text);
1008 } else {
1009 let hashes = "#".repeat((*level).clamp(1, 6) as usize);
1010 blocks.push(format!("{hashes} {text}"));
1011 }
1012 }
1013 Node::Paragraph { text } if text.is_empty() => {}
1016 Node::Paragraph { text } => blocks.push(ctx.breaks(&ctx.text(text))),
1017 Node::LabeledText { .. } => render_item(&node.labeled_as_paragraph(), blocks, ctx),
1018 Node::Caption { text, .. } if text.is_empty() => {}
1021 Node::Caption { text, href } => {
1022 let body = ctx.breaks(&ctx.text(text));
1023 blocks.push(match href {
1024 Some(url) if !ctx.plain => format!("[{body}]({url})"),
1025 _ => body,
1026 });
1027 }
1028 Node::CheckboxItem { checked, text } => {
1029 let mark = if *checked { "- [x] " } else { "- [ ] " };
1030 blocks.push(ctx.breaks(&ctx.text(&format!("{mark}{text}"))));
1031 }
1032 Node::Code {
1033 language,
1034 text,
1035 pretty,
1036 ..
1037 } => {
1038 let lang = match language {
1040 Some(l) if ctx.strict => l.as_str(),
1041 _ => "",
1042 };
1043 let body = match pretty {
1046 Some(p) if ctx.strict => p.as_str(),
1047 _ => text.as_str(),
1048 };
1049 blocks.push(if ctx.plain {
1052 body.to_string()
1053 } else {
1054 format!("```{lang}\n{body}\n```")
1055 });
1056 }
1057 Node::Formula { latex, .. } => blocks.push(format!("$${latex}$$")),
1060 Node::Table(table) => {
1061 if let Some(cap) = &table.caption {
1064 if !cap.is_empty() {
1065 blocks.push(ctx.text(cap));
1066 }
1067 }
1068 let rendered = if ctx.plain {
1069 render_table(&ctx.plain_table(table), ctx.compact_tables)
1070 } else {
1071 render_table(table, ctx.compact_tables)
1072 };
1073 if !rendered.is_empty() {
1074 blocks.push(rendered);
1075 }
1076 }
1077 Node::Picture {
1086 caption,
1087 image,
1088 description,
1089 ..
1090 } => {
1091 if let Some(cap) = caption {
1092 if !cap.is_empty() {
1093 blocks.push(ctx.caption(cap));
1094 }
1095 }
1096 let alt = description
1097 .as_ref()
1098 .filter(|d| d.is_alt_text() && ctx.alt_text && !d.text.is_empty());
1099 let marker = picture_marker(image.as_ref(), ctx, alt.map(|d| d.text.as_str()));
1100 if let Some(desc) = description {
1101 let shown = !desc.is_alt_text() || (ctx.alt_text && !marker.starts_with("!["));
1102 if !desc.text.is_empty() && shown {
1103 blocks.push(ctx.breaks(&ctx.text(&desc.text)));
1104 }
1105 }
1106 blocks.push(marker);
1107 }
1108 Node::Chart {
1112 kind,
1113 table,
1114 caption,
1115 ..
1116 } => {
1117 if let Some(cap) = caption {
1118 if !cap.is_empty() {
1119 blocks.push(ctx.caption(cap));
1120 }
1121 }
1122 blocks.push(picture_marker(None, ctx, None));
1123 blocks.push(humanize_label(kind));
1124 let rendered = if ctx.plain {
1125 render_table(&ctx.plain_table(table), false)
1126 } else {
1127 render_table(table, false)
1128 };
1129 if !rendered.is_empty() {
1130 blocks.push(rendered);
1131 }
1132 }
1133 Node::DoclangOnly(_) => {}
1135 Node::Group {
1140 layer: Some(layer),
1141 children,
1142 ..
1143 } => {
1144 let outer = ctx.current_layer.replace(*layer);
1145 render(children, blocks, ctx);
1146 ctx.current_layer = outer;
1147 }
1148 Node::Group { children, .. } => render(children, blocks, ctx),
1149 Node::FieldRegion { items } => {
1150 for item in items {
1155 for part in [&item.marker, &item.key, &item.value].into_iter().flatten() {
1156 blocks.push(ctx.breaks(&ctx.text(part)));
1157 }
1158 }
1159 }
1160 Node::KeyValueGraph { .. } => blocks.push(MISSING_KEY_VALUE_ITEM.to_string()),
1163 Node::InlineGroup { md_text, .. } => blocks.push(ctx.breaks(&ctx.text(md_text))),
1166 Node::TextDump(text) => {
1168 if !text.is_empty() {
1169 blocks.push(text.clone());
1170 }
1171 }
1172 Node::Furniture { layer, inner } => {
1176 let outer = ctx.current_layer.replace(*layer);
1177 render_one(inner, blocks, ctx);
1178 ctx.current_layer = outer;
1179 }
1180 Node::PageFurniture { text, .. } | Node::FurnitureText { text, .. } => {
1184 if ctx.layers.furniture && !text.is_empty() {
1185 blocks.push(ctx.breaks(&ctx.text(text)));
1186 }
1187 }
1188 Node::PictureChildren(children) => {
1193 if ctx.traverse_pictures {
1194 render(children, blocks, ctx);
1195 }
1196 }
1197 Node::CommentSection { text, .. } => {
1202 if ctx.layers.notes && !text.is_empty() {
1203 blocks.push(ctx.breaks(&ctx.text(text)));
1204 }
1205 }
1206 Node::Commented { inner, .. } => render_one(inner, blocks, ctx),
1207 Node::Located { inner, .. } | Node::Prov { inner, .. } | Node::Track { inner, .. } => {
1209 render_one(inner, blocks, ctx)
1210 }
1211 Node::PageBreak => {}
1213 Node::PageInfo { .. } => {}
1215 Node::Anchor { .. } => {}
1217 Node::ListItem { .. } => render_list_run(std::slice::from_ref(node), blocks, ctx),
1222 Node::ListText { text, .. } if text.is_empty() => {}
1224 Node::ListText { text, .. } => blocks.push(ctx.breaks(&ctx.text(text))),
1225 }
1226}
1227
1228fn humanize_label(label: &str) -> String {
1233 let text = label.replace('_', " ");
1234 let mut chars = text.chars();
1235 match chars.next() {
1236 Some(f) => f.to_uppercase().collect::<String>() + chars.as_str(),
1237 None => text,
1238 }
1239}
1240
1241fn image_alt(alt: Option<&str>) -> String {
1244 match alt {
1245 Some(text) => text
1246 .split_whitespace()
1247 .collect::<Vec<_>>()
1248 .join(" ")
1249 .replace('\\', "\\\\")
1250 .replace(']', "\\]"),
1251 None => "Image".to_string(),
1252 }
1253}
1254
1255fn picture_marker(image: Option<&crate::PictureImage>, ctx: &mut Ctx, alt: Option<&str>) -> String {
1256 match (ctx.images, image) {
1257 (ImageMode::Embedded, Some(img)) => {
1259 format!(
1260 "",
1261 image_alt(alt),
1262 crate::pixel_digest::docling_data_uri(img).1
1263 )
1264 }
1265 (ImageMode::Referenced, Some(img)) => {
1266 let path = format!(
1267 "{}/image_{:06}.{}",
1268 ctx.artifacts_dir,
1269 ctx.pic_index,
1270 ext_for(&img.mimetype)
1271 );
1272 ctx.pic_index += 1;
1273 ctx.artifacts.push((path.clone(), img.data.clone()));
1274 format!("", image_alt(alt), escape_uri_path(&path))
1275 }
1276 _ => ctx.image_placeholder.clone(),
1278 }
1279}
1280
1281pub(crate) fn escape_uri_path(value: &str) -> String {
1294 const KEEP: &str = "/%:@+,;=~$!&'*";
1295 let s = value.replace('\\', "/");
1296 if let Some(rest) = s.strip_prefix("//") {
1297 let rest = rest.trim_start_matches('/');
1299 let (host, tail) = rest.split_once('/').unwrap_or((rest, ""));
1300 return format!("file://{host}{}", percent_quote(&format!("/{tail}"), KEEP));
1301 }
1302 let bytes = s.as_bytes();
1303 if bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'/' {
1304 return format!("file:///{}", percent_quote(&s, KEEP));
1306 }
1307 if let Some((scheme, rest)) = s.split_once(':') {
1311 let valid_scheme = scheme.len() > 1
1312 && scheme.as_bytes()[0].is_ascii_alphabetic()
1313 && scheme
1314 .bytes()
1315 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'-' | b'.'));
1316 if valid_scheme {
1317 let (authority, rest) = match rest.strip_prefix("//") {
1318 Some(r) => {
1319 let end = r.find(['/', '?', '#']).unwrap_or(r.len());
1320 (Some(&r[..end]), &r[end..])
1321 }
1322 None => (None, rest),
1323 };
1324 let (before_frag, fragment) = rest.split_once('#').unwrap_or((rest, ""));
1325 let (path, query) = before_frag.split_once('?').unwrap_or((before_frag, ""));
1326 let mut out = format!("{scheme}:");
1327 if let Some(a) = authority {
1328 out.push_str("//");
1329 out.push_str(a);
1330 }
1331 out.push_str(&percent_quote(path, KEEP));
1332 if !query.is_empty() {
1333 out.push('?');
1334 out.push_str(&percent_quote(query, KEEP));
1335 }
1336 if !fragment.is_empty() {
1337 out.push('#');
1338 out.push_str(&percent_quote(fragment, KEEP));
1339 }
1340 return out;
1341 }
1342 }
1343 percent_quote(&s, KEEP)
1345}
1346
1347fn percent_quote(s: &str, safe: &str) -> String {
1350 let mut out = String::with_capacity(s.len());
1351 for &b in s.as_bytes() {
1352 let keep = b.is_ascii_alphanumeric()
1353 || matches!(b, b'_' | b'.' | b'-' | b'~')
1354 || (b.is_ascii() && safe.contains(b as char));
1355 if keep {
1356 out.push(b as char);
1357 } else {
1358 out.push_str(&format!("%{b:02X}"));
1359 }
1360 }
1361 out
1362}
1363
1364pub(crate) fn ext_for(mimetype: &str) -> &str {
1365 match mimetype {
1366 "image/jpeg" => "jpg",
1367 "image/gif" => "gif",
1368 "image/webp" => "webp",
1369 "image/bmp" => "bmp",
1370 "image/tiff" => "tif",
1371 _ => "png",
1372 }
1373}
1374
1375fn is_number_cell(t: &str) -> bool {
1394 t.parse::<f64>().is_ok() || is_thousands_number(t)
1395}
1396
1397fn is_thousands_number(t: &str) -> bool {
1403 let b = t.as_bytes();
1404 let mut i = 0;
1405 let start = i;
1406 if i < b.len() && (b[i] == b'+' || b[i] == b'-') {
1407 i += 1;
1408 }
1409 let d0 = i;
1411 while i < b.len() && b[i].is_ascii_digit() && i - d0 < 3 {
1412 i += 1;
1413 }
1414 let has_int = i > d0;
1415 if has_int {
1416 while i + 3 < b.len() + 1
1418 && b.get(i) == Some(&b',')
1419 && b.get(i + 1).is_some_and(u8::is_ascii_digit)
1420 && b.get(i + 2).is_some_and(u8::is_ascii_digit)
1421 && b.get(i + 3).is_some_and(u8::is_ascii_digit)
1422 {
1423 i += 4;
1424 }
1425 } else {
1426 i = start;
1428 }
1429 if i < b.len() && b[i] == b'.' {
1431 i += 1;
1432 let f0 = i;
1433 while i < b.len() && b[i].is_ascii_digit() {
1434 i += 1;
1435 }
1436 if !has_int && i == f0 {
1437 return false; }
1439 } else if !has_int {
1440 return false; }
1442 i == b.len()
1443}
1444
1445fn flatten_header_rows(header_rows: &[Vec<String>], num_cols: usize) -> Vec<String> {
1454 (0..num_cols)
1455 .map(|c| {
1456 let mut parts: Vec<&str> = Vec::new();
1457 for row in header_rows {
1458 let text = row.get(c).map(String::as_str).unwrap_or("");
1459 if !text.is_empty() && parts.last() != Some(&text) {
1460 parts.push(text);
1461 }
1462 }
1463 parts.join(" - ")
1464 })
1465 .collect()
1466}
1467
1468pub(crate) fn render_table(table: &Table, compact: bool) -> String {
1469 if table.rows.is_empty() {
1470 return String::new();
1471 }
1472 let num_cols = table.rows.iter().map(Vec::len).max().unwrap_or(0);
1473 if num_cols == 0 {
1474 return String::new();
1475 }
1476
1477 let num_headers = table.header_row_count().min(table.rows.len());
1482 let escaped = |r: usize| -> Vec<String> {
1483 (0..num_cols)
1484 .map(|c| escape_cell(table.rows[r].get(c).map(String::as_str).unwrap_or("")))
1485 .collect()
1486 };
1487 let header_rows: Vec<Vec<String>> = (0..num_headers).map(escaped).collect();
1488 let header = flatten_header_rows(&header_rows, num_cols);
1489 let body: Vec<Vec<String>> = (num_headers..table.rows.len())
1490 .map(|r| {
1491 escaped(r)
1492 .into_iter()
1493 .map(|c| c.trim().to_string())
1494 .collect()
1495 })
1496 .collect();
1497
1498 if compact {
1499 let render_row = |row: &[String]| -> String { format!("| {} |", row.join(" | ")) };
1501 let mut lines = Vec::with_capacity(body.len() + 2);
1502 lines.push(render_row(&header));
1503 let sep: Vec<&str> = (0..num_cols).map(|_| "-").collect();
1504 lines.push(format!("| {} |", sep.join(" | ")));
1505 for row in &body {
1506 lines.push(render_row(row));
1507 }
1508 return lines.join("\n");
1509 }
1510
1511 let dw = |s: &str| s.chars().count();
1513
1514 let right: Vec<bool> = (0..num_cols)
1519 .map(|c| {
1520 let mut any = false;
1521 for row in &body {
1522 let t = row[c].trim();
1523 if t.is_empty() {
1524 continue;
1525 }
1526 if !is_number_cell(t) {
1527 return false;
1528 }
1529 any = true;
1530 }
1531 any
1532 })
1533 .collect();
1534
1535 let width: Vec<usize> = (0..num_cols)
1537 .map(|c| {
1538 let mut w = dw(&header[c]) + 2;
1539 for row in &body {
1540 w = w.max(dw(&row[c]));
1541 }
1542 w
1543 })
1544 .collect();
1545
1546 let fmt_cell = |s: &str, c: usize| -> String {
1547 let pad = " ".repeat(width[c].saturating_sub(dw(s)));
1548 let body = if right[c] {
1549 format!("{pad}{s}")
1550 } else {
1551 format!("{s}{pad}")
1552 };
1553 format!(" {body} ")
1554 };
1555 let render_row = |row: &[String]| -> String {
1556 let cells: Vec<String> = (0..num_cols).map(|c| fmt_cell(&row[c], c)).collect();
1557 format!("|{}|", cells.join("|"))
1558 };
1559
1560 let mut lines = Vec::with_capacity(body.len() + 2);
1561 lines.push(render_row(&header));
1562 let sep: Vec<String> = (0..num_cols).map(|c| "-".repeat(width[c] + 2)).collect();
1563 lines.push(format!("|{}|", sep.join("|")));
1564 for row in &body {
1565 lines.push(render_row(row));
1566 }
1567 lines.join("\n")
1568}
1569
1570fn escape_cell(s: &str) -> String {
1573 s.replace('\n', " ").replace('|', "|")
1574}
1575
1576#[cfg(test)]
1577mod tests {
1578 use super::*;
1579 use crate::{PictureImage, TableCell, TableStructure};
1580
1581 #[test]
1587 fn list_boundaries_come_from_the_backend_not_the_numbering() {
1588 let item = |ordered: bool, number: u64, first_in_list: bool, text: &str| Node::ListItem {
1589 ordered,
1590 number,
1591 first_in_list,
1592 text: text.into(),
1593 level: 0,
1594 marker: None,
1595 location: None,
1596 dclx: None,
1597 href: None,
1598 layer: None,
1599 };
1600 let md = |items: Vec<Node>| {
1601 let mut doc = DoclingDocument::new("t");
1602 for n in items {
1603 doc.push(n);
1604 }
1605 doc.export_to_markdown()
1606 };
1607 assert_eq!(
1609 md(vec![
1610 item(true, 1, true, "one"),
1611 item(true, 5, false, "five")
1612 ]),
1613 "1. one\n5. five\n"
1614 );
1615 assert_eq!(
1617 md(vec![
1618 item(false, 0, true, "bullet"),
1619 item(true, 1, false, "one"),
1620 item(false, 0, false, "bullet two"),
1621 ]),
1622 "- bullet\n1. one\n- bullet two\n"
1623 );
1624 assert_eq!(
1626 md(vec![
1627 item(true, 1, true, "a"),
1628 item(true, 2, false, "b"),
1629 item(true, 3, true, "new list, continuing count"),
1630 ]),
1631 "1. a\n2. b\n\n3. new list, continuing count\n"
1632 );
1633 }
1634
1635 #[test]
1636 fn renders_headings_paragraphs_and_lists() {
1637 let mut doc = DoclingDocument::new("demo");
1638 doc.add_heading(1, "Title");
1639 doc.add_paragraph("Hello world.");
1640 doc.push(Node::ListItem {
1641 ordered: false,
1642 number: 1,
1643 first_in_list: true,
1644 text: "first".into(),
1645 level: 0,
1646 marker: None,
1647 location: None,
1648 dclx: None,
1649 href: None,
1650 layer: None,
1651 });
1652 doc.push(Node::ListItem {
1653 ordered: false,
1654 number: 2,
1655 first_in_list: false,
1656 text: "second".into(),
1657 level: 0,
1658 marker: None,
1659 location: None,
1660 dclx: None,
1661 href: None,
1662 layer: None,
1663 });
1664 let md = doc.export_to_markdown();
1665 assert_eq!(md, "# Title\n\nHello world.\n\n- first\n- second\n");
1666 }
1667
1668 #[test]
1672 fn single_newlines_become_gfm_hard_line_breaks() {
1673 let mut doc = DoclingDocument::new("t");
1674 doc.push(Node::Heading {
1675 level: 1,
1676 text: "Hello\nWorld".into(),
1677 });
1678 doc.push(Node::Paragraph {
1679 text: "line one\nline two\n\npara two".into(),
1680 });
1681 doc.push(Node::ListItem {
1682 ordered: false,
1683 number: 1,
1684 first_in_list: true,
1685 text: "item\ncontinued".into(),
1686 level: 0,
1687 marker: None,
1688 location: None,
1689 dclx: None,
1690 href: None,
1691 layer: None,
1692 });
1693 doc.push(Node::TextDump("A1 B1 \n\n\nC1".into()));
1694 assert_eq!(
1695 doc.export_to_markdown(),
1696 "# Hello World\n\nline one \nline two\n\npara two\n\n- item \ncontinued\n\nA1 B1 \n\n\nC1\n"
1697 );
1698 }
1699
1700 #[test]
1703 fn table_cell_mode_and_field_regions() {
1704 let mut doc = DoclingDocument::new("t");
1705 doc.push(Node::Heading {
1706 level: 2,
1707 text: "A text".into(),
1708 });
1709 doc.push(Node::Paragraph {
1710 text: "body".into(),
1711 });
1712 assert_eq!(to_markdown_table_cell(&doc, false), "A text\n\nbody");
1713 assert_eq!(doc.export_to_markdown(), "## A text\n\nbody\n");
1714
1715 let mut doc = DoclingDocument::new("f");
1716 doc.push(Node::FieldRegion {
1717 items: vec![crate::FieldItem {
1718 marker: None,
1719 key: Some("Name:".into()),
1720 value: Some("John Doe".into()),
1721 value_kind: None,
1722 }],
1723 });
1724 assert_eq!(doc.export_to_markdown(), "Name:\n\nJohn Doe\n");
1725 }
1726
1727 #[test]
1728 fn strict_renders_recovered_links_legacy_does_not() {
1729 let mut doc = DoclingDocument::new("cv");
1730 doc.add_paragraph("Find me on LinkedIn or GitHub.");
1731 doc.links = vec![
1732 ("LinkedIn".into(), "https://www.linkedin.com/in/x/".into()),
1733 ("GitHub".into(), "https://github.com/x/".into()),
1734 ];
1735 assert_eq!(doc.export_to_markdown(), "Find me on LinkedIn or GitHub.\n");
1737 assert_eq!(
1739 doc.export_to_markdown_with(true),
1740 "Find me on [LinkedIn](https://www.linkedin.com/in/x/) or [GitHub](https://github.com/x/).\n"
1741 );
1742 }
1743
1744 #[test]
1745 fn strict_links_match_escaped_anchor_and_consume_in_order() {
1746 let mut doc = DoclingDocument::new("d");
1747 doc.add_paragraph("AI & ML here, and issues here, then issues there.");
1751 doc.links = vec![
1752 ("AI & ML".into(), "https://a/".into()),
1753 ("issues".into(), "https://first/".into()),
1754 ("issues".into(), "https://second/".into()),
1755 ];
1756 assert_eq!(
1757 doc.export_to_markdown_with(true),
1758 "[AI & ML](https://a/) here, and [issues](https://first/) here, then [issues](https://second/) there.\n"
1759 );
1760 }
1761
1762 #[test]
1766 fn referenced_image_destinations_are_escaped() {
1767 let cases = [
1768 (
1769 "doc_artifacts/image_000001_ab12.png",
1770 "doc_artifacts/image_000001_ab12.png",
1771 ),
1772 (
1773 "My Report_artifacts/img.png",
1774 "My%20Report_artifacts/img.png",
1775 ),
1776 ("artifacts/img (1).png", "artifacts/img%20%281%29.png"),
1777 ("100%_scale/a#b?c.png", "100%_scale/a%23b%3Fc.png"),
1778 ("/home/a b/img.png", "/home/a%20b/img.png"),
1779 (
1780 "My Report_artifacts\\img.png",
1781 "My%20Report_artifacts/img.png",
1782 ),
1783 (
1784 "C:/Users/me/My Docs/img.png",
1785 "file:///C:/Users/me/My%20Docs/img.png",
1786 ),
1787 ("C:\\Users\\me\\img.png", "file:///C:/Users/me/img.png"),
1788 (
1789 "//server/share/My Docs/img.png",
1790 "file://server/share/My%20Docs/img.png",
1791 ),
1792 ("\\\\server\\share\\img.png", "file://server/share/img.png"),
1793 ("file:///home/a b/img.png", "file:///home/a%20b/img.png"),
1794 (
1795 "s3://bucket/My Report_artifacts/img.png",
1796 "s3://bucket/My%20Report_artifacts/img.png",
1797 ),
1798 (
1799 "https://example.com:8080/a b.png?w=1&h=2#frag",
1800 "https://example.com:8080/a%20b.png?w=1&h=2#frag",
1801 ),
1802 (
1803 "https://example.com/img (1).png",
1804 "https://example.com/img%20%281%29.png",
1805 ),
1806 ("caf\u{e9}/im\u{e4}ge.png", "caf%C3%A9/im%C3%A4ge.png"),
1807 ];
1808 for (input, expected) in cases {
1809 assert_eq!(escape_uri_path(input), expected, "input {input:?}");
1810 assert_eq!(
1811 escape_uri_path(expected),
1812 expected,
1813 "idempotent {expected:?}"
1814 );
1815 }
1816 let mut doc = DoclingDocument::new("t");
1818 doc.push(Node::Picture {
1819 caption: None,
1820 caption_href: None,
1821 image: Some(PictureImage {
1822 dpi: PictureImage::DEFAULT_DPI,
1823 mimetype: "image/png".into(),
1824 width: 1,
1825 height: 1,
1826 data: b"x".to_vec(),
1827 display_size: None,
1828 }),
1829 classification: None,
1830 description: None,
1831 caption_parent: Default::default(),
1832 caption_location: None,
1833 });
1834 let (md, files) = doc
1835 .export_to_markdown_with_images(ImageMode::Referenced, "My Report (final)_artifacts");
1836 assert!(
1837 md.contains(""),
1838 "got:\n{md}"
1839 );
1840 assert_eq!(files[0].0, "My Report (final)_artifacts/image_000000.png");
1842 }
1843
1844 #[test]
1848 fn folded_list_item_pictures_keep_plain_newlines() {
1849 let doc = DoclingDocument::new("t");
1850 let ctx = Ctx::new(&doc, false, &MarkdownExportOptions::default());
1851 assert_eq!(
1852 list_item_text("Step\n<!-- image -->", &ctx),
1853 "Step\n<!-- image -->"
1854 );
1855 assert_eq!(
1856 list_item_text("Step\nAlt text\n<!-- image -->\n<!-- image -->", &ctx),
1857 "Step\nAlt text\n<!-- image -->\n<!-- image -->"
1858 );
1859 assert_eq!(
1860 list_item_text("line one\nline two", &ctx),
1861 "line one \nline two"
1862 );
1863 }
1864
1865 #[test]
1868 fn stacked_header_rows_flatten_into_one() {
1869 let mut t = Table {
1870 rows: vec![
1871 vec!["".into(), "% of Total".into(), "% of Total".into()],
1872 vec!["class".into(), "Train".into(), "Test".into()],
1873 vec!["Caption".into(), "2.04".into(), "1.77".into()],
1874 ],
1875 ..Default::default()
1876 };
1877 t.structure = Some(TableStructure {
1878 header_row: vec![true, true, false],
1879 col_continuation: vec![
1880 vec![false, false, true],
1881 vec![false, false, false],
1882 vec![false, false, false],
1883 ],
1884 ..Default::default()
1885 });
1886 assert_eq!(t.header_row_count(), 2);
1887 assert_eq!(
1888 render_table(&t, true),
1889 "| class | % of Total - Train | % of Total - Test |\n| - | - | - |\n| Caption | 2.04 | 1.77 |"
1890 );
1891 assert_eq!(
1893 render_table(&t, false),
1894 "| class | % of Total - Train | % of Total - Test |\n\
1895 |---------|----------------------|---------------------|\n\
1896 | Caption | 2.04 | 1.77 |"
1897 );
1898 }
1899
1900 #[test]
1903 fn vertically_spanning_header_does_not_extend_the_block() {
1904 let mut t = Table {
1905 rows: vec![
1906 vec!["Name".into(), "Value".into()],
1907 vec!["Name".into(), "1".into()],
1908 vec!["x".into(), "2".into()],
1909 ],
1910 ..Default::default()
1911 };
1912 t.structure = Some(TableStructure {
1913 col_header: vec![vec![true, true], vec![true, false], vec![false, false]],
1914 row_continuation: vec![vec![false, false], vec![true, false], vec![false, false]],
1915 ..Default::default()
1916 });
1917 assert_eq!(t.header_row_count(), 1);
1918 assert_eq!(
1919 render_table(&t, true),
1920 "| Name | Value |\n| - | - |\n| Name | 1 |\n| x | 2 |"
1921 );
1922 }
1923
1924 #[test]
1927 fn header_flags_not_on_row_zero_keep_all_rows_in_the_body() {
1928 let mut t = Table {
1929 rows: vec![
1930 vec!["1".into(), "2".into()],
1931 vec!["a".into(), "b".into()],
1932 vec!["333".into(), "4".into()],
1933 ],
1934 ..Default::default()
1935 };
1936 t.structure = Some(TableStructure {
1937 header_row: vec![false, true, false],
1938 ..Default::default()
1939 });
1940 assert_eq!(t.header_row_count(), 0);
1941 assert_eq!(
1942 render_table(&t, false),
1943 "| | |\n|-----|----|\n| 1 | 2 |\n| a | b |\n| 333 | 4 |"
1944 );
1945 }
1946
1947 #[test]
1952 fn pivot_row_headers_do_not_extend_the_header() {
1953 let mut t = Table {
1954 rows: vec![
1955 vec!["Year".into(), "Month".into()],
1956 vec!["2025".into(), "January".into()],
1957 vec!["2025".into(), "February".into()],
1958 ],
1959 ..Default::default()
1960 };
1961 t.structure = Some(TableStructure {
1962 col_header: vec![vec![true, true], vec![false, false], vec![false, false]],
1963 row_header: vec![vec![false, false], vec![true, false], vec![true, false]],
1964 row_continuation: vec![vec![false, false], vec![false, false], vec![true, false]],
1965 ..Default::default()
1966 });
1967 assert_eq!(t.header_row_count(), 1);
1968 assert_eq!(
1969 render_table(&t, true),
1970 "| Year | Month |\n| - | - |\n| 2025 | January |\n| 2025 | February |"
1971 );
1972 }
1973
1974 #[test]
1977 fn unflagged_cells_keep_row_zero_as_header() {
1978 let mut t = Table {
1979 rows: vec![vec!["h".into()], vec!["d".into()]],
1980 ..Default::default()
1981 };
1982 t.cells = Some(
1983 [(0usize, "h"), (1, "d")]
1984 .into_iter()
1985 .map(|(r, text)| TableCell {
1986 text: text.into(),
1987 bbox: None,
1988 start_row: r,
1989 start_col: 0,
1990 row_span: 1,
1991 col_span: 1,
1992 column_header: false,
1993 row_header: false,
1994 row_section: false,
1995 })
1996 .collect(),
1997 );
1998 assert_eq!(t.header_row_count(), 1);
1999 assert_eq!(render_table(&t, true), "| h |\n| - |\n| d |");
2000 }
2001
2002 fn flagged_table(rows: &[&[&str]], ched: impl Fn(usize, usize) -> bool) -> Table {
2004 let mut cells = Vec::new();
2005 for (r, row) in rows.iter().enumerate() {
2006 for (c, text) in row.iter().enumerate() {
2007 cells.push(TableCell {
2008 text: (*text).into(),
2009 bbox: None,
2010 start_row: r,
2011 start_col: c,
2012 row_span: 1,
2013 col_span: 1,
2014 column_header: ched(r, c),
2015 row_header: false,
2016 row_section: false,
2017 });
2018 }
2019 }
2020 Table {
2021 rows: rows
2022 .iter()
2023 .map(|row| row.iter().map(|t| (*t).to_string()).collect())
2024 .collect(),
2025 cells: Some(cells),
2026 ..Default::default()
2027 }
2028 }
2029
2030 #[test]
2034 fn rows_with_body_text_stop_the_header_block() {
2035 let t = flagged_table(
2037 &[
2038 &["Item", "Q1", "Q2"],
2039 &["Sales", "10", "20"],
2040 &["Costs", "5", "7"],
2041 ],
2042 |r, c| r == 0 || c == 0,
2043 );
2044 assert_eq!(t.header_row_count(), 1);
2045 assert_eq!(
2046 render_table(&t, false),
2047 "| Item | Q1 | Q2 |\n\
2048 |--------|------|------|\n\
2049 | Sales | 10 | 20 |\n\
2050 | Costs | 5 | 7 |"
2051 );
2052 let t = flagged_table(&[&["Sales", "10", "20"], &["Costs", "5", "7"]], |_, c| {
2055 c == 0
2056 });
2057 assert_eq!(t.header_row_count(), 0);
2058 assert_eq!(
2059 render_table(&t, false),
2060 "| | | |\n\
2061 |-------|----|----|\n\
2062 | Sales | 10 | 20 |\n\
2063 | Costs | 5 | 7 |"
2064 );
2065 let t = flagged_table(
2066 &[&["a", "1", "2"], &["b", "3", "4"], &["c", "5", "6"]],
2067 |r, c| c == 0 && r < 2,
2068 );
2069 assert_eq!(t.header_row_count(), 0);
2070 let t = flagged_table(&[&["a", "1", "2"], &["b", "3", "4"]], |r, c| {
2073 r == 0 && c == 0
2074 });
2075 assert_eq!(t.header_row_count(), 1);
2076 assert_eq!(
2077 render_table(&t, false),
2078 "| a | 1 | 2 |\n\
2079 |-----|-----|-----|\n\
2080 | b | 3 | 4 |"
2081 );
2082 let t = flagged_table(&[&["Item", "", ""], &["x", "1", "2"]], |_, c| c == 0);
2084 assert_eq!(t.header_row_count(), 1);
2085 assert_eq!(
2086 render_table(&t, false),
2087 "| Item | | |\n\
2088 |--------|----|----|\n\
2089 | x | 1 | 2 |"
2090 );
2091 }
2092
2093 #[test]
2094 fn renders_compact_table() {
2095 let mut doc = DoclingDocument::new("t");
2096 doc.compact_tables = true;
2099 doc.push(Node::Table(Table {
2100 rows: vec![vec!["a".into(), "b".into()], vec!["1".into(), "2".into()]],
2101 location: None,
2102 structure: None,
2103 cell_blocks: None,
2104 cells: None,
2105 caption: None,
2106 caption_parent: Default::default(),
2107 caption_location: None,
2108 }));
2109 let md = doc.export_to_markdown();
2110 assert_eq!(md, "| a | b |\n| - | - |\n| 1 | 2 |\n");
2111 }
2112
2113 #[test]
2114 fn renders_padded_github_table_by_default() {
2115 let mut doc = DoclingDocument::new("t");
2116 doc.push(Node::Table(Table {
2117 rows: vec![vec!["a".into(), "b".into()], vec!["1".into(), "2".into()]],
2118 location: None,
2119 structure: None,
2120 cell_blocks: None,
2121 cells: None,
2122 caption: None,
2123 caption_parent: Default::default(),
2124 caption_location: None,
2125 }));
2126 let md = doc.export_to_markdown();
2127 assert_eq!(md, "| a | b |\n|-----|-----|\n| 1 | 2 |\n");
2129 }
2130
2131 #[test]
2132 fn strict_unescapes_inline_underscores_legacy_keeps_them() {
2133 let mut doc = DoclingDocument::new("t");
2134 doc.add_heading(1, "a\\_b");
2135 doc.add_paragraph("x\\_y");
2136 doc.push(Node::ListItem {
2137 ordered: false,
2138 number: 1,
2139 first_in_list: true,
2140 text: "i\\_j".into(),
2141 level: 0,
2142 marker: None,
2143 location: None,
2144 dclx: None,
2145 href: None,
2146 layer: None,
2147 });
2148 assert_eq!(doc.export_to_markdown(), "# a\\_b\n\nx\\_y\n\n- i\\_j\n");
2150 assert_eq!(doc.export_to_markdown_with(true), "# a_b\n\nx_y\n\n- i_j\n");
2152 }
2153
2154 fn assert_stream_matches(
2157 doc: &DoclingDocument,
2158 strict: bool,
2159 images: ImageMode,
2160 splits: &[usize],
2161 ) {
2162 let (want, want_artifacts) = to_markdown_images(doc, strict, images, "artifacts");
2163 let mut streamer =
2164 MarkdownStreamer::with_artifacts(strict, images, doc.compact_tables, "artifacts")
2165 .with_page_break_placeholder(doc.page_break_placeholder.clone());
2166 let mut got = String::new();
2167 let mut got_artifacts = Vec::new();
2168 let mut start = 0;
2169 for &end in splits {
2170 let links = if start == 0 {
2173 doc.links.as_slice()
2174 } else {
2175 &[]
2176 };
2177 got.push_str(&streamer.push(&doc.nodes[start..end], links));
2178 got_artifacts.extend(streamer.take_artifacts());
2181 start = end;
2182 }
2183 got.push_str(&streamer.push(
2184 &doc.nodes[start..],
2185 if start == 0 {
2186 doc.links.as_slice()
2187 } else {
2188 &[]
2189 },
2190 ));
2191 got_artifacts.extend(streamer.take_artifacts());
2192 got.push_str(&streamer.finish());
2193 assert_eq!(
2194 got, want,
2195 "streamed output diverged (splits={splits:?}, strict={strict})"
2196 );
2197 assert_eq!(
2198 got_artifacts, want_artifacts,
2199 "streamed artifacts diverged (splits={splits:?}, strict={strict})"
2200 );
2201 }
2202
2203 #[test]
2204 fn streaming_is_byte_identical_to_buffered() {
2205 let mut doc = DoclingDocument::new("d");
2206 doc.add_heading(1, "Title");
2207 doc.add_paragraph("First paragraph.");
2208 doc.push(Node::ListItem {
2209 ordered: false,
2210 number: 1,
2211 first_in_list: true,
2212 text: "a".into(),
2213 level: 0,
2214 marker: None,
2215 location: None,
2216 dclx: None,
2217 href: None,
2218 layer: None,
2219 });
2220 doc.push(Node::ListItem {
2221 ordered: false,
2222 number: 2,
2223 first_in_list: false,
2224 text: "b".into(),
2225 level: 0,
2226 marker: None,
2227 location: None,
2228 dclx: None,
2229 href: None,
2230 layer: None,
2231 });
2232 doc.push(Node::Code {
2233 language: Some("rust".into()),
2234 text: "let x = 1;".into(),
2235 orig: None,
2236 pretty: None,
2237 });
2238 doc.push(Node::Table(Table {
2239 rows: vec![vec!["a".into(), "b".into()], vec!["1".into(), "2".into()]],
2240 location: None,
2241 structure: None,
2242 cell_blocks: None,
2243 cells: None,
2244 caption: None,
2245 caption_parent: Default::default(),
2246 caption_location: None,
2247 }));
2248 doc.push(Node::Picture {
2249 caption: Some("Fig 1".into()),
2250 caption_href: None,
2251 image: Some(PictureImage {
2252 dpi: PictureImage::DEFAULT_DPI,
2253 mimetype: "image/png".into(),
2254 width: 2,
2255 height: 2,
2256 data: b"png-one".to_vec(),
2257 display_size: None,
2258 }),
2259 classification: None,
2260 description: None,
2261 caption_parent: Default::default(),
2262 caption_location: None,
2263 });
2264 doc.add_paragraph("Last paragraph.");
2265 doc.push(Node::Picture {
2268 caption: None,
2269 caption_href: None,
2270 image: Some(PictureImage {
2271 dpi: PictureImage::DEFAULT_DPI,
2272 mimetype: "image/png".into(),
2273 width: 2,
2274 height: 2,
2275 data: b"png-two".to_vec(),
2276 display_size: None,
2277 }),
2278 classification: None,
2279 description: None,
2280 caption_parent: Default::default(),
2281 caption_location: None,
2282 });
2283
2284 for &strict in &[false, true] {
2287 for &images in &[
2288 ImageMode::Placeholder,
2289 ImageMode::Embedded,
2290 ImageMode::Referenced,
2291 ] {
2292 for splits in [&[][..], &[1][..], &[2][..], &[4][..], &[1, 4, 6, 7][..]] {
2293 assert_stream_matches(&doc, strict, images, splits);
2294 }
2295 }
2296 }
2297 }
2298
2299 #[test]
2300 fn streaming_applies_recovered_links_in_strict_mode() {
2301 let mut doc = DoclingDocument::new("d");
2302 doc.add_paragraph("See LinkedIn for details.");
2303 doc.add_paragraph("And GitHub too.");
2304 doc.links = vec![
2305 ("LinkedIn".into(), "https://lnkd/".into()),
2306 ("GitHub".into(), "https://gh/".into()),
2307 ];
2308 assert_stream_matches(&doc, true, ImageMode::Placeholder, &[1]);
2311 }
2312
2313 fn paged_doc() -> DoclingDocument {
2316 let mut doc = DoclingDocument::new("p");
2317 doc.push(Node::PageInfo {
2318 page_no: 1,
2319 width: 100.0,
2320 height: 100.0,
2321 });
2322 doc.add_heading(1, "Title");
2323 doc.add_paragraph("Page one.");
2324 doc.push(Node::PageBreak);
2326 doc.push(Node::PageInfo {
2327 page_no: 2,
2328 width: 100.0,
2329 height: 100.0,
2330 });
2331 doc.push(Node::PageFurniture {
2332 footer: true,
2333 location: [0, 500, 511, 511],
2334 text: "2".into(),
2335 });
2336 doc.push(Node::PageBreak);
2337 doc.push(Node::PageInfo {
2338 page_no: 3,
2339 width: 100.0,
2340 height: 100.0,
2341 });
2342 doc.add_paragraph("Page three.");
2343 doc.push(Node::PageBreak);
2345 doc
2346 }
2347
2348 #[test]
2349 fn page_break_placeholder_lands_between_pages_only() {
2350 let mut doc = paged_doc();
2351 assert_eq!(
2353 doc.export_to_markdown(),
2354 "# Title\n\nPage one.\n\nPage three.\n"
2355 );
2356 doc.page_break_placeholder = Some("<!-- page break -->".into());
2357 assert_eq!(
2361 doc.export_to_markdown(),
2362 "# Title\n\nPage one.\n\n<!-- page break -->\n\nPage three.\n"
2363 );
2364 doc.page_break_placeholder = Some(String::new());
2367 assert_eq!(
2368 doc.export_to_markdown(),
2369 "# Title\n\nPage one.\n\n\n\nPage three.\n"
2370 );
2371 }
2372
2373 #[test]
2374 fn page_break_placeholder_never_leads_a_single_page() {
2375 let mut doc = DoclingDocument::new("one");
2376 doc.page_break_placeholder = Some("---".into());
2377 doc.push(Node::PageBreak);
2378 doc.push(Node::PageInfo {
2379 page_no: 1,
2380 width: 10.0,
2381 height: 10.0,
2382 });
2383 doc.add_paragraph("Only page.");
2384 assert_eq!(doc.export_to_markdown(), "Only page.\n");
2385 doc.push(Node::PageBreak);
2387 doc.push(Node::PageBreak);
2388 doc.add_paragraph("Next.");
2389 assert_eq!(doc.export_to_markdown(), "Only page.\n\n---\n\nNext.\n");
2390 }
2391
2392 #[test]
2393 fn page_break_placeholder_streams_byte_identical() {
2394 let mut doc = paged_doc();
2395 doc.page_break_placeholder = Some("<!-- page break -->".into());
2396 for splits in [
2400 &[3usize][..],
2401 &[3, 6],
2402 &[3, 6, 8],
2403 &[1, 2, 3, 4, 5, 6, 7, 8, 9],
2404 &[8],
2405 ] {
2406 assert_stream_matches(&doc, false, ImageMode::Placeholder, splits);
2407 assert_stream_matches(&doc, true, ImageMode::Placeholder, splits);
2408 }
2409 }
2410
2411 #[test]
2412 fn strict_tightens_punctuation_spacing_legacy_keeps_it() {
2413 let mut doc = DoclingDocument::new("t");
2414 doc.add_paragraph("see [ 37 , 36 ] and ( x ) .");
2415 assert_eq!(doc.export_to_markdown(), "see [ 37 , 36 ] and ( x ) .\n");
2417 assert_eq!(doc.export_to_markdown_with(true), "see [37, 36] and (x).\n");
2419 }
2420
2421 fn options_doc() -> DoclingDocument {
2426 use crate::document::ContentLayer;
2427 let mut doc = DoclingDocument::new("opts");
2428 doc.push(Node::PageFurniture {
2429 footer: false,
2430 location: [0, 0, 0, 0],
2431 text: "FAX COVER \\_ R&D".into(),
2432 });
2433 doc.add_heading(1, "R&D \\_ report");
2434 doc.add_paragraph("Body a\\_b <tag>");
2435 doc.push(Node::Picture {
2436 caption: Some("Figure 1".into()),
2437 caption_href: None,
2438 image: None,
2439 classification: None,
2440 description: None,
2441 caption_parent: Default::default(),
2442 caption_location: None,
2443 });
2444 doc.push(Node::PictureChildren(vec![
2445 Node::Paragraph {
2446 text: "Field: Name".into(),
2447 },
2448 Node::Paragraph {
2449 text: "Field: Date".into(),
2450 },
2451 ]));
2452 doc.push(Node::CommentSection {
2453 name: "comment-1".into(),
2454 text: "[author: A]: note".into(),
2455 refs_note_text: false,
2456 grouped: true,
2457 });
2458 doc.push(Node::Group {
2459 label: "section".into(),
2460 name: Some("sheet: Hidden".into()),
2461 layer: Some(ContentLayer::Invisible),
2462 children: vec![Node::Paragraph {
2463 text: "hidden cell".into(),
2464 }],
2465 });
2466 doc.push(Node::Furniture {
2467 layer: ContentLayer::Furniture,
2468 inner: Box::new(Node::Paragraph {
2469 text: "Page 1 of 2".into(),
2470 }),
2471 });
2472 doc.push(Node::FurnitureText {
2473 label: "page_footer".into(),
2474 text: "footer line".into(),
2475 });
2476 doc
2477 }
2478
2479 #[test]
2483 fn default_options_are_the_default_export() {
2484 let doc = options_doc();
2485 let plain = doc.export_to_markdown();
2486 assert_eq!(
2487 plain,
2488 "# R&D \\_ report\n\nBody a\\_b <tag>\n\nFigure 1\n\n<!-- image -->\n"
2489 );
2490 let (with_default, artifacts) =
2491 doc.export_to_markdown_with_options(&MarkdownExportOptions::default());
2492 assert_eq!(with_default, plain);
2493 assert!(artifacts.is_empty());
2494 }
2495
2496 #[test]
2497 fn layers_render_furniture_notes_and_hidden_sheets() {
2498 use crate::document::ContentLayer;
2499 let doc = options_doc();
2500 let md = |layers: ContentLayers| {
2501 doc.export_to_markdown_with_options(&MarkdownExportOptions {
2502 layers,
2503 ..MarkdownExportOptions::default()
2504 })
2505 .0
2506 };
2507 assert_eq!(
2508 md(ContentLayers::BODY.with(ContentLayer::Furniture)),
2509 "FAX COVER \\_ R&D\n\n# R&D \\_ report\n\nBody a\\_b <tag>\n\nFigure 1\n\n<!-- image -->\n\nPage 1 of 2\n\nfooter line\n",
2510 "a page header, a furniture-wrapped paragraph and a furniture text item render as paragraphs"
2511 );
2512 assert_eq!(
2513 md(ContentLayers::BODY.with(ContentLayer::Notes)),
2514 "# R&D \\_ report\n\nBody a\\_b <tag>\n\nFigure 1\n\n<!-- image -->\n\n[author: A]: note\n"
2515 );
2516 assert_eq!(
2517 md(ContentLayers::BODY.with(ContentLayer::Invisible)),
2518 "# R&D \\_ report\n\nBody a\\_b <tag>\n\nFigure 1\n\n<!-- image -->\n\nhidden cell\n",
2519 "a hidden sheet's items sit on the sheet's layer"
2520 );
2521 assert_eq!(
2522 md(ContentLayers::NONE.with(ContentLayer::Furniture)),
2523 "FAX COVER \\_ R&D\n\nPage 1 of 2\n\nfooter line\n",
2524 "without the body layer only the furniture prints"
2525 );
2526 assert_eq!(md(ContentLayers::NONE), "");
2527 assert_eq!(
2528 md(ContentLayers::ALL),
2529 "FAX COVER \\_ R&D\n\n# R&D \\_ report\n\nBody a\\_b <tag>\n\nFigure 1\n\n<!-- image -->\n\n[author: A]: note\n\nhidden cell\n\nPage 1 of 2\n\nfooter line\n"
2530 );
2531 }
2532
2533 #[test]
2536 fn layers_apply_to_list_items() {
2537 use crate::document::ContentLayer;
2538 let mut doc = DoclingDocument::new("lists");
2539 doc.push(Node::ListItem {
2540 ordered: false,
2541 number: 1,
2542 first_in_list: true,
2543 text: "nav link".into(),
2544 level: 0,
2545 marker: None,
2546 location: None,
2547 dclx: None,
2548 href: None,
2549 layer: Some(ContentLayer::Furniture),
2550 });
2551 doc.add_paragraph("body");
2552 assert_eq!(doc.export_to_markdown(), "body\n");
2553 let (md, _) = doc.export_to_markdown_with_options(&MarkdownExportOptions {
2554 layers: ContentLayers::BODY.with(ContentLayer::Furniture),
2555 ..MarkdownExportOptions::default()
2556 });
2557 assert_eq!(md, "- nav link\n\nbody\n");
2558 }
2559
2560 #[test]
2561 fn traverse_pictures_prints_the_nested_text_after_the_picture() {
2562 let doc = options_doc();
2563 let (md, _) = doc.export_to_markdown_with_options(&MarkdownExportOptions {
2564 traverse_pictures: true,
2565 ..MarkdownExportOptions::default()
2566 });
2567 assert_eq!(
2568 md,
2569 "# R&D \\_ report\n\nBody a\\_b <tag>\n\nFigure 1\n\n<!-- image -->\n\nField: Name\n\nField: Date\n"
2570 );
2571 }
2572
2573 #[test]
2574 fn escaping_can_be_turned_off_per_kind() {
2575 let doc = options_doc();
2576 let (md, _) = doc.export_to_markdown_with_options(&MarkdownExportOptions {
2577 escape_html: false,
2578 ..MarkdownExportOptions::default()
2579 });
2580 assert_eq!(
2581 md,
2582 "# R&D \\_ report\n\nBody a\\_b <tag>\n\nFigure 1\n\n<!-- image -->\n"
2583 );
2584 let (md, _) = doc.export_to_markdown_with_options(&MarkdownExportOptions {
2585 escape_underscores: false,
2586 ..MarkdownExportOptions::default()
2587 });
2588 assert_eq!(
2589 md,
2590 "# R&D _ report\n\nBody a_b <tag>\n\nFigure 1\n\n<!-- image -->\n"
2591 );
2592 let mut doc = DoclingDocument::new("code");
2595 doc.push(Node::Code {
2596 language: None,
2597 text: "a & b \\_ c".into(),
2598 orig: None,
2599 pretty: None,
2600 });
2601 let (md, _) = doc.export_to_markdown_with_options(&MarkdownExportOptions {
2602 escape_html: false,
2603 escape_underscores: false,
2604 ..MarkdownExportOptions::default()
2605 });
2606 assert_eq!(md, "```\na & b \\_ c\n```\n");
2607 }
2608
2609 #[test]
2610 fn image_placeholder_is_configurable() {
2611 let doc = options_doc();
2612 let (md, _) = doc.export_to_markdown_with_options(&MarkdownExportOptions {
2613 image_placeholder: "[figure]".into(),
2614 ..MarkdownExportOptions::default()
2615 });
2616 assert!(md.contains("Figure 1\n\n[figure]\n"), "{md}");
2617 assert!(!md.contains("<!-- image -->"));
2618 }
2619
2620 #[test]
2626 fn empty_image_placeholder_prints_nothing() {
2627 fn picture(caption: Option<&str>) -> Node {
2628 Node::Picture {
2629 caption: caption.map(Into::into),
2630 caption_href: None,
2631 image: None,
2632 classification: None,
2633 description: None,
2634 caption_parent: Default::default(),
2635 caption_location: None,
2636 }
2637 }
2638 fn page(no: usize) -> Node {
2639 Node::PageInfo {
2640 page_no: no,
2641 width: 100.0,
2642 height: 100.0,
2643 }
2644 }
2645 fn para(text: &str) -> Node {
2646 Node::Paragraph { text: text.into() }
2647 }
2648 let options = MarkdownExportOptions {
2649 image_placeholder: String::new(),
2650 ..MarkdownExportOptions::default()
2651 };
2652 let cases: Vec<(Vec<Node>, Option<&str>, &str)> = vec![
2654 (
2655 vec![page(1), para("a"), picture(None), para("b")],
2656 None,
2657 "a\n\nb\n",
2658 ),
2659 (
2660 vec![page(1), para("a"), picture(Some("Figure 1")), para("b")],
2661 None,
2662 "a\n\nFigure 1\n\nb\n",
2663 ),
2664 (
2665 vec![
2666 page(1),
2667 para("a"),
2668 page(2),
2669 picture(None),
2670 page(3),
2671 para("b"),
2672 ],
2673 Some("<!-- pb -->"),
2674 "a\n\n<!-- pb -->\n\n<!-- pb -->\n\nb\n",
2675 ),
2676 (
2677 vec![
2678 page(1),
2679 para("a"),
2680 page(2),
2681 picture(None),
2682 page(3),
2683 para("b"),
2684 ],
2685 Some(""),
2686 "a\n\n\n\n\n\nb\n",
2687 ),
2688 (
2689 vec![page(1), picture(None), page(2), para("b")],
2690 Some("<!-- pb -->"),
2691 "<!-- pb -->\n\nb\n",
2692 ),
2693 (
2694 vec![page(1), picture(None), page(2), para("b")],
2695 Some(""),
2696 "\n\nb\n",
2697 ),
2698 (
2699 vec![page(1), para("a"), page(2), picture(None)],
2700 Some("<!-- pb -->"),
2701 "a\n\n<!-- pb -->\n",
2702 ),
2703 ];
2704 for (nodes, placeholder, expected) in cases {
2705 let mut doc = DoclingDocument::new("t");
2706 doc.page_break_placeholder = placeholder.map(Into::into);
2707 for node in nodes {
2708 doc.push(node);
2709 }
2710 let (md, _) = doc.export_to_markdown_with_options(&options);
2711 assert_eq!(md, expected, "page break {placeholder:?}");
2712 for size in 1..=doc.nodes.len() {
2715 let mut streamer = MarkdownStreamer::new(false, ImageMode::Placeholder, false)
2716 .with_export_options(&options)
2717 .with_page_break_placeholder(placeholder.map(Into::into));
2718 let mut out = String::new();
2719 for chunk in doc.nodes.chunks(size) {
2720 out.push_str(&streamer.push(chunk, &[]));
2721 }
2722 out.push_str(&streamer.finish());
2723 assert_eq!(
2724 out, expected,
2725 "chunks of {size}, page break {placeholder:?}"
2726 );
2727 }
2728 }
2729 let mut doc = DoclingDocument::new("t");
2731 doc.push(para("a"));
2732 doc.push(picture(None));
2733 doc.push(para("b"));
2734 assert_eq!(doc.export_to_markdown(), "a\n\n<!-- image -->\n\nb\n");
2735 }
2736
2737 #[test]
2740 fn streamer_matches_buffered_export_with_options() {
2741 let doc = options_doc();
2742 let options = MarkdownExportOptions {
2743 layers: ContentLayers::ALL,
2744 traverse_pictures: true,
2745 escape_html: false,
2746 escape_underscores: false,
2747 image_placeholder: "(img)".into(),
2748 ..MarkdownExportOptions::default()
2749 };
2750 let (buffered, _) = doc.export_to_markdown_with_options(&options);
2751 let mut streamer = MarkdownStreamer::new(false, ImageMode::Placeholder, false)
2752 .with_export_options(&options);
2753 let mut out = String::new();
2754 for chunk in doc.nodes.chunks(2) {
2755 out.push_str(&streamer.push(chunk, &[]));
2756 }
2757 out.push_str(&streamer.finish());
2758 assert_eq!(out, buffered);
2759 assert!(
2760 buffered.contains("(img)") && buffered.contains("R&D _ report"),
2761 "{buffered}"
2762 );
2763 }
2764
2765 #[test]
2768 fn plain_text_drops_the_markdown_decoration() {
2769 let item = |text: &str, ordered: bool, number: u64, level: u8| Node::ListItem {
2770 ordered,
2771 number,
2772 first_in_list: number == 1 && level == 0,
2773 text: text.into(),
2774 level,
2775 marker: None,
2776 location: None,
2777 dclx: None,
2778 href: None,
2779 layer: None,
2780 };
2781 let mut doc = DoclingDocument::new("plain");
2782 doc.add_heading(1, "Title with **bold**");
2783 doc.add_heading(2, "Section");
2784 doc.add_paragraph("Some **bold**, *italic*, ~~gone~~ and `code` with a [link](https://x.org).\nNext line.");
2785 doc.push(item("*one*", false, 1, 0));
2786 doc.push(item("[two](https://y.org)", false, 2, 0));
2787 doc.push(item("nested", true, 1, 1));
2788 doc.push(Node::CheckboxItem {
2789 checked: true,
2790 text: "done".into(),
2791 });
2792 doc.push(Node::Code {
2793 language: Some("rust".into()),
2794 text: "let x = **y**;".into(),
2795 orig: None,
2796 pretty: None,
2797 });
2798 doc.push(Node::Picture {
2799 caption: Some("Figure *1*".into()),
2800 caption_href: None,
2801 image: None,
2802 classification: None,
2803 description: None,
2804 caption_parent: Default::default(),
2805 caption_location: None,
2806 });
2807 doc.push(Node::Caption {
2808 text: "Linked caption".into(),
2809 href: Some("https://z.org".into()),
2810 });
2811 doc.push(Node::Table(Table {
2812 rows: vec![
2813 vec!["**n**".into(), "rich".into()],
2814 vec!["**2**".into(), "a \nb".into()],
2815 ],
2816 ..Default::default()
2817 }));
2818 doc.add_paragraph("R&D \\_ x");
2819 let text = doc.export_to_text();
2820 assert_eq!(
2821 text,
2822 "Title with bold\n\n\
2823 Section\n\n\
2824 Some bold, italic, gone and code with a link.\nNext line.\n\n\
2825 - one\n- two\n 1. nested\n\n\
2826 - [x] done\n\n\
2827 let x = **y**;\n\n\
2828 Figure 1\n\n\
2829 Linked caption\n\n\
2830 | n | rich |\n\
2831 |-----|--------|\n\
2832 | 2 | a b |\n\n\
2833 R&D _ x"
2834 );
2835
2836 let options = MarkdownExportOptions::plain_text();
2837 let (buffered, _) = doc.export_to_markdown_with_options(&options);
2838 assert_eq!(buffered, format!("{text}\n"));
2839 let mut streamer = MarkdownStreamer::new(false, ImageMode::Placeholder, false)
2840 .with_export_options(&options);
2841 let mut out = String::new();
2842 for chunk in doc.nodes.chunks(3) {
2843 out.push_str(&streamer.push(chunk, &[]));
2844 }
2845 out.push_str(&streamer.finish());
2846 assert_eq!(out, buffered);
2847 }
2848
2849 #[test]
2851 fn plain_inline_keeps_literal_markers() {
2852 for (input, want) in [
2853 ("**bold** and *it* and ***both***", "bold and it and both"),
2854 ("**[label](https://a.b/c_(d))** end", "label end"),
2855 (
2856 "grant *USE and *OBJMGT authority",
2857 "grant *USE and *OBJMGT authority",
2858 ),
2859 ("2 * 3 * 4", "2 * 3 * 4"),
2860 ("a ** b", "a ** b"),
2861 ("the `quoted’ and `x` term", "the `quoted’ and x term"),
2862 ("see [12] and [docs](u)", "see [12] and docs"),
2863 ("formula $[a](b)$ stays", "formula $[a](b)$ stays"),
2864 ("price $5 and **bold**", "price $5 and bold"),
2865 ("[outer [inner] text](u)", "outer [inner] text"),
2866 ("unclosed **bold", "unclosed **bold"),
2867 ] {
2868 assert_eq!(plain_inline(input), want, "{input}");
2869 }
2870 }
2871}