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 || is_fence_line(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 = crate::hyperlink::markdown_link(&anchor, href);
319 out.replace_range(at..at + anchor.len(), &replacement);
320 cursor = at + replacement.len();
321 }
322 }
323 out
324}
325
326fn apply_links_chunk(chunk: &str, queue: &mut Vec<(String, String)>) -> String {
337 let mut out = chunk.to_string();
338 let mut cursor = 0usize;
339 let mut carried: Vec<(String, String)> = Vec::new();
340 for (anchor_raw, href) in std::mem::take(queue) {
341 let anchor = anchor_raw
342 .replace('&', "&")
343 .replace('<', "<")
344 .replace('>', ">");
345 if anchor.is_empty() {
346 continue;
347 }
348 if let Some(rel) = out[cursor..].find(&anchor) {
349 let at = cursor + rel;
350 let replacement = crate::hyperlink::markdown_link(&anchor, &href);
351 out.replace_range(at..at + anchor.len(), &replacement);
352 cursor = at + replacement.len();
353 } else {
354 carried.push((anchor_raw, href));
356 }
357 }
358 *queue = carried;
359 out
360}
361
362pub struct MarkdownStreamer {
380 strict: bool,
381 images: ImageMode,
382 compact_tables: bool,
383 picture_alt_text: bool,
385 front_matter: Option<String>,
389 emitted_any: bool,
392 links: Vec<(String, String)>,
394 artifacts_dir: String,
398 artifacts: Vec<(String, Vec<u8>)>,
399 pic_index: usize,
400 page_break: Option<String>,
404 pending_page_break: bool,
405 items_emitted: bool,
411 held: Option<Node>,
416 layers: ContentLayers,
419 traverse_pictures: bool,
420 escape_html: bool,
421 escape_underscores: bool,
422 image_placeholder: String,
423 plain: bool,
424}
425
426impl MarkdownStreamer {
427 pub fn new(strict: bool, images: ImageMode, compact_tables: bool) -> Self {
430 debug_assert!(
431 images != ImageMode::Referenced,
432 "referenced image mode needs an artifacts dir; use with_artifacts"
433 );
434 Self::with_artifacts(strict, images, compact_tables, "artifacts")
435 }
436
437 pub fn with_artifacts(
444 strict: bool,
445 images: ImageMode,
446 compact_tables: bool,
447 artifacts_dir: &str,
448 ) -> Self {
449 Self {
450 strict,
451 images,
452 compact_tables,
453 picture_alt_text: false,
454 front_matter: None,
455 emitted_any: false,
456 links: Vec::new(),
457 artifacts_dir: artifacts_dir.to_string(),
458 artifacts: Vec::new(),
459 pic_index: 0,
460 page_break: None,
461 pending_page_break: false,
462 items_emitted: false,
463 held: None,
464 layers: ContentLayers::BODY,
465 traverse_pictures: false,
466 escape_html: true,
467 escape_underscores: true,
468 image_placeholder: "<!-- image -->".to_string(),
469 plain: false,
470 }
471 }
472
473 pub fn with_export_options(mut self, options: &MarkdownExportOptions) -> Self {
480 self.images = options.image_mode;
481 self.artifacts_dir = options.artifacts_dir.clone();
482 self.layers = options.layers;
483 self.traverse_pictures = options.traverse_pictures;
484 self.escape_html = options.escape_html;
485 self.escape_underscores = options.escape_underscores;
486 self.image_placeholder = options.image_placeholder.clone();
487 self.plain = options.plain_text;
488 self
489 }
490
491 pub fn with_page_break_placeholder(mut self, placeholder: Option<String>) -> Self {
496 self.page_break = placeholder;
497 self
498 }
499
500 pub fn with_picture_alt_text(mut self, on: bool) -> Self {
504 self.picture_alt_text = on;
505 self
506 }
507
508 pub fn with_metadata(mut self, metadata: &crate::metadata::DocumentMetadata) -> Self {
513 self.front_matter = (!self.plain)
514 .then(|| metadata.to_front_matter())
515 .filter(|f| !f.is_empty());
516 self
517 }
518
519 pub fn take_artifacts(&mut self) -> Vec<(String, Vec<u8>)> {
524 std::mem::take(&mut self.artifacts)
525 }
526
527 pub fn push(&mut self, nodes: &[Node], links: &[(String, String)]) -> String {
532 self.push_holding(nodes, links, true)
533 }
534
535 fn push_holding(&mut self, nodes: &[Node], links: &[(String, String)], hold: bool) -> String {
538 self.links.extend(links.iter().cloned());
539 let images = self.images;
543 let ends_on_picture = |nodes: &[Node]| {
544 matches!(images, ImageMode::Placeholder)
545 && matches!(nodes.last(), Some(Node::Picture { .. }))
546 };
547 let mut owned: Vec<Node> = Vec::new();
548 let mut nodes = nodes;
549 if self.held.is_some() || (hold && ends_on_picture(nodes)) {
550 owned.extend(self.held.take());
551 owned.extend(nodes.iter().cloned());
552 if hold && ends_on_picture(&owned) {
553 self.held = owned.pop();
554 }
555 nodes = &owned;
556 }
557 let mut ctx = Ctx {
558 strict: self.strict,
559 alt_text: self.strict || self.picture_alt_text,
560 compact_tables: self.compact_tables,
561 images: self.images,
562 artifacts_dir: std::mem::take(&mut self.artifacts_dir),
563 artifacts: std::mem::take(&mut self.artifacts),
564 pic_index: self.pic_index,
565 in_table_cell: false,
566 page_break: std::mem::take(&mut self.page_break),
567 pending_page_break: self.pending_page_break,
568 emitted_any: self.items_emitted,
569 layers: self.layers,
570 current_layer: None,
571 traverse_pictures: self.traverse_pictures,
572 escape_html: self.escape_html,
573 escape_underscores: self.escape_underscores,
574 image_placeholder: std::mem::take(&mut self.image_placeholder),
575 plain: self.plain,
576 };
577 let mut blocks: Vec<String> = Vec::new();
578 render(nodes, &mut blocks, &mut ctx);
579 self.image_placeholder = std::mem::take(&mut ctx.image_placeholder);
580 self.artifacts_dir = std::mem::take(&mut ctx.artifacts_dir);
581 self.artifacts = std::mem::take(&mut ctx.artifacts);
582 self.pic_index = ctx.pic_index;
583 self.page_break = std::mem::take(&mut ctx.page_break);
584 self.pending_page_break = ctx.pending_page_break;
585 self.items_emitted = ctx.emitted_any;
586 if blocks.is_empty() {
587 return String::new();
588 }
589 let mut body = blocks.join("\n\n");
590 if self.strict && !self.plain && !self.links.is_empty() {
591 body = apply_links_chunk(&body, &mut self.links);
592 }
593 let chunk = if let Some(front) = self.front_matter.take() {
594 format!("{front}\n{body}")
595 } else if self.emitted_any {
596 format!("\n\n{body}")
597 } else {
598 body
599 };
600 self.emitted_any = true;
601 chunk
602 }
603
604 pub fn finish(mut self) -> String {
607 let mut out = String::new();
608 if self.held.is_some() {
609 out.push_str(&self.push_holding(&[], &[], false));
610 }
611 if let Some(front) = self.front_matter {
612 out.push_str(&front);
613 } else if self.emitted_any {
614 out.push('\n');
615 }
616 out
617 }
618}
619
620fn strict_text(text: &str, strict: bool) -> String {
628 if !strict {
629 return text.to_string();
630 }
631 text.replace("\\_", "_")
632 .replace(" ,", ",")
633 .replace(" .", ".")
634 .replace(" ;", ";")
635 .replace(" )", ")")
636 .replace("( ", "(")
637 .replace(" ]", "]")
638 .replace("[ ", "[")
639}
640
641pub fn longest_backtick_run(text: &str) -> usize {
644 let (mut best, mut run) = (0, 0);
645 for c in text.chars() {
646 if c == '`' {
647 run += 1;
648 best = best.max(run);
649 } else {
650 run = 0;
651 }
652 }
653 best
654}
655
656pub fn code_span(text: &str) -> String {
662 let ticks = "`".repeat(longest_backtick_run(text) + 1);
663 let pad = if text.starts_with('`') || text.ends_with('`') {
664 " "
665 } else {
666 ""
667 };
668 format!("{ticks}{pad}{text}{pad}{ticks}")
669}
670
671pub fn code_fence(language: &str, body: &str) -> String {
676 let fence = "`".repeat(longest_backtick_run(body).max(2) + 1);
677 format!("{fence}{language}\n{body}\n{fence}")
678}
679
680pub(crate) fn code_span_bounds(chars: &[char], i: usize) -> Option<(usize, usize, usize)> {
688 let n = chars[i..].iter().take_while(|&&c| c == '`').count();
689 if n == 0 {
690 return None;
691 }
692 let mut k = i + n;
693 while k < chars.len() {
694 if chars[k] != '`' {
695 k += 1;
696 continue;
697 }
698 let run = chars[k..].iter().take_while(|&&c| c == '`').count();
699 if run == n {
700 let (mut s, mut e) = (i + n, k);
701 if n > 1
702 && e - s >= 2
703 && chars[s] == ' '
704 && chars[e - 1] == ' '
705 && chars[s..e].iter().any(|&c| c != ' ')
706 {
707 s += 1;
708 e -= 1;
709 }
710 return Some((s, e, k + run));
711 }
712 k += run;
713 }
714 None
715}
716
717fn is_fence_line(line: &str) -> bool {
719 line.len() >= 3 && line.bytes().all(|b| b == b'`')
720}
721
722pub(crate) fn plain_inline(text: &str) -> String {
735 if !text.contains(['*', '~', '`', '[']) {
736 return text.to_string();
737 }
738 let chars: Vec<char> = text.chars().collect();
739 let mut out = String::with_capacity(text.len());
740 plain_inline_into(&chars, &mut out);
741 out
742}
743
744fn plain_inline_into(chars: &[char], out: &mut String) {
745 let find = |open: usize, pat: &[char]| -> Option<usize> {
746 chars
747 .get(open..)?
748 .windows(pat.len())
749 .position(|w| w == pat)
750 .map(|p| open + p)
751 };
752 let span = |i: usize, pat: &[char]| -> Option<usize> {
755 if !chars[i..].starts_with(pat) {
756 return None;
757 }
758 let open = i + pat.len();
759 let end = find(open, pat)?;
760 (end > open && !chars[open].is_whitespace() && !chars[end - 1].is_whitespace())
761 .then_some(end)
762 };
763 let mut i = 0;
764 while i < chars.len() {
765 let mut matched = false;
766 for pat in [&['*', '*', '*'][..], &['*', '*'], &['~', '~'], &['*']] {
767 if let Some(end) = span(i, pat) {
768 plain_inline_into(&chars[i + pat.len()..end], out);
769 i = end + pat.len();
770 matched = true;
771 break;
772 }
773 }
774 if matched {
775 continue;
776 }
777 if chars[i] == '`' {
781 if let Some((s, e, after)) = code_span_bounds(chars, i).filter(|&(s, e, _)| {
782 e > s && !chars[s].is_whitespace() && !chars[e - 1].is_whitespace()
783 }) {
784 out.extend(&chars[s..e]);
785 i = after;
786 continue;
787 }
788 }
789 if chars[i] == '$' {
792 if let Some(end) = find(i + 1, &['$']) {
793 out.extend(&chars[i..=end]);
794 i = end + 1;
795 continue;
796 }
797 }
798 if chars[i] == '[' {
801 if let Some(close) = closing_bracket(chars, i) {
802 if chars.get(close + 1) == Some(&'(') {
803 if let Some(endp) = crate::doclang::link_dest_end(chars, close + 2) {
804 plain_inline_into(&chars[i + 1..close], out);
805 i = endp + 1;
806 continue;
807 }
808 }
809 }
810 }
811 let c = chars[i];
814 out.push(c);
815 i += 1;
816 if matches!(c, '*' | '~') {
817 while i < chars.len() && chars[i] == c {
818 out.push(c);
819 i += 1;
820 }
821 }
822 }
823}
824
825fn closing_bracket(chars: &[char], open: usize) -> Option<usize> {
828 let mut depth = 0usize;
829 for (k, c) in chars.iter().enumerate().skip(open + 1) {
830 match c {
831 '[' => depth += 1,
832 ']' if depth == 0 => return Some(k),
833 ']' => depth -= 1,
834 _ => {}
835 }
836 }
837 None
838}
839
840fn md_line_breaks(text: &str) -> String {
848 if !text.contains('\n') {
849 return text.to_string();
850 }
851 text.split("\n\n")
852 .map(|para| para.replace('\n', " \n"))
853 .collect::<Vec<_>>()
854 .join("\n\n")
855}
856
857pub(crate) fn strip_hard_breaks(text: &str) -> String {
862 if text.contains(" \n") {
863 text.replace(" \n", "\n")
864 } else {
865 text.to_string()
866 }
867}
868
869fn heading_line_breaks(text: &str) -> String {
873 text.replace('\n', " ")
874}
875
876fn render(nodes: &[Node], blocks: &mut Vec<String>, ctx: &mut Ctx) {
877 let mut i = 0;
878 while i < nodes.len() {
879 let before = blocks.len();
880 match &nodes[i] {
881 Node::PageBreak | Node::PageInfo { .. } => {
891 if ctx.page_break.is_some() && ctx.emitted_any {
892 ctx.pending_page_break = true;
893 }
894 i += 1;
895 }
896 Node::ListItem { .. } => {
897 let start = i;
898 i += 1;
899 loop {
900 match nodes.get(i) {
901 Some(Node::ListItem { .. } | Node::ListText { .. }) => i += 1,
902 Some(Node::Paragraph { text })
906 if text.is_empty()
907 && matches!(nodes.get(i + 1), Some(Node::ListItem { .. })) =>
908 {
909 i += 1
910 }
911 _ => break,
912 }
913 }
914 render_list_run(&nodes[start..i], blocks, ctx);
915 }
916 other => {
917 let before_item = blocks.len();
918 render_one(other, blocks, ctx);
919 if matches!(other, Node::Picture { .. })
925 && matches!(ctx.images, ImageMode::Placeholder)
926 && blocks.len() > before_item
927 && matches!(
928 nodes.get(i + 1),
929 Some(Node::Code { language: Some(l), .. }) if l == "mermaid"
930 )
931 {
932 blocks.pop();
933 }
934 i += 1;
935 }
936 }
937 if blocks.len() > before {
938 let kept: Vec<String> = blocks.drain(before..).filter(|b| !b.is_empty()).collect();
948 blocks.extend(kept);
949 if ctx.pending_page_break {
950 if let Some(placeholder) = &ctx.page_break {
955 blocks.insert(before, placeholder.clone());
956 }
957 ctx.pending_page_break = false;
958 }
959 ctx.emitted_any = true;
960 }
961 }
962}
963
964fn render_list_run(items: &[Node], blocks: &mut Vec<String>, ctx: &Ctx) {
970 let mut lines: Vec<String> = Vec::new();
971 let mut any_top = false;
974
975 for item in items {
976 if let Node::ListText { level, text, glue } = item {
977 let text = ctx.text(text);
978 match lines.last_mut() {
979 Some(last) if *glue => last.push_str(&text),
980 _ => lines.push(format!("{}{text}", " ".repeat(*level as usize))),
981 }
982 continue;
983 }
984 let Node::ListItem {
985 ordered,
986 number,
987 first_in_list,
988 text,
989 level,
990 marker: orig_marker,
991 location: _,
992 dclx: _,
993 href: _,
994 layer,
995 } = item
996 else {
997 continue;
998 };
999 if !ctx.layers.contains(layer.or(ctx.current_layer)) {
1003 continue;
1004 }
1005 let level = *level as usize;
1006
1007 if level == 0 {
1016 if any_top && *first_in_list {
1017 lines.push(String::new());
1018 }
1019 any_top = true;
1020 }
1021
1022 let indent = " ".repeat(level);
1023 lines.push(format!(
1024 "{indent}{} {}",
1025 list_item_marker(orig_marker.as_deref().unwrap_or(""), *ordered, *number),
1026 list_item_text(text, ctx)
1027 ));
1028 }
1029
1030 if !lines.is_empty() {
1033 blocks.push(lines.join("\n"));
1034 }
1035}
1036
1037fn list_item_marker(marker: &str, ordered: bool, number: u64) -> String {
1046 let already_valid = matches!(marker, "-" | "*" | "+")
1047 || marker
1048 .strip_suffix('.')
1049 .is_some_and(|d| !d.is_empty() && d.chars().all(char::is_numeric));
1050 if already_valid {
1051 return marker.to_string();
1052 }
1053 let outer = if ordered && marker.is_empty() {
1054 format!("{number}.")
1055 } else {
1056 "-".to_string()
1057 };
1058 if marker.chars().any(char::is_alphanumeric) {
1059 format!("{outer} {marker}")
1060 } else {
1061 outer
1062 }
1063}
1064
1065fn list_item_text(text: &str, ctx: &Ctx) -> String {
1073 let escaped = ctx.text(text);
1074 if ctx.plain {
1075 return escaped;
1076 }
1077 if let Some((own, tail)) = escaped.split_once('\n') {
1078 if is_folded_child_tail(tail) {
1079 return format!("{}\n{tail}", md_line_breaks(own));
1080 }
1081 }
1082 md_line_breaks(&escaped)
1083}
1084
1085fn is_folded_child_tail(tail: &str) -> bool {
1092 const MARKER: &str = "<!-- image -->";
1093 let mut lines = tail.split('\n').peekable();
1094 let mut any = false;
1095 while let Some(line) = lines.next() {
1096 let line = line.trim_start();
1097 if line == MARKER {
1098 any = true;
1099 } else if is_fence_line(line) {
1100 loop {
1103 match lines.next() {
1104 Some(l) if l.trim_start() == line => break,
1105 Some(_) => {}
1106 None => return false,
1107 }
1108 }
1109 any = true;
1110 } else if lines.next().map(str::trim_start) == Some(MARKER) {
1111 any = true; } else {
1113 return false;
1114 }
1115 }
1116 any
1117}
1118
1119fn render_one(node: &Node, blocks: &mut Vec<String>, ctx: &mut Ctx) {
1124 match node {
1125 Node::Group { .. }
1127 | Node::Furniture { .. }
1128 | Node::PageFurniture { .. }
1129 | Node::FurnitureText { .. }
1130 | Node::CommentSection { .. }
1131 | Node::PictureChildren(_)
1132 | Node::Commented { .. }
1133 | Node::Located { .. }
1134 | Node::Prov { .. }
1135 | Node::Track { .. }
1136 | Node::PageBreak
1137 | Node::PageInfo { .. }
1138 | Node::DoclangOnly(_) => {}
1139 _ if !ctx.layer_visible() => return,
1140 _ => {}
1141 }
1142 render_item(node, blocks, ctx)
1143}
1144
1145fn render_item(node: &Node, blocks: &mut Vec<String>, ctx: &mut Ctx) {
1146 match node {
1147 Node::Heading { level, text } => {
1148 if ctx.plain {
1149 blocks.push(ctx.text(text));
1152 return;
1153 }
1154 let text = heading_line_breaks(&ctx.text(text));
1155 if ctx.in_table_cell {
1156 blocks.push(text);
1158 } else {
1159 let hashes = "#".repeat((*level).clamp(1, 6) as usize);
1160 blocks.push(format!("{hashes} {text}"));
1161 }
1162 }
1163 Node::Paragraph { text } if text.is_empty() => {}
1166 Node::Paragraph { text } => blocks.push(ctx.breaks(&ctx.text(text))),
1167 Node::LabeledText { .. } => render_item(&node.labeled_as_paragraph(), blocks, ctx),
1168 Node::Caption { text, .. } if text.is_empty() => {}
1171 Node::Caption { text, href } => {
1172 let body = ctx.breaks(&ctx.text(text));
1173 blocks.push(match href {
1174 Some(url) if !ctx.plain => crate::hyperlink::markdown_link(&body, url),
1175 _ => body,
1176 });
1177 }
1178 Node::CheckboxItem { checked, text } => {
1179 let mark = if *checked { "- [x] " } else { "- [ ] " };
1180 blocks.push(ctx.breaks(&ctx.text(&format!("{mark}{text}"))));
1181 }
1182 Node::Code {
1183 language,
1184 text,
1185 pretty,
1186 ..
1187 } => {
1188 let lang = match language {
1193 Some(l) if ctx.strict || l == "mermaid" => l.as_str(),
1194 _ => "",
1195 };
1196 let body = match pretty {
1199 Some(p) if ctx.strict => p.as_str(),
1200 _ => text.as_str(),
1201 };
1202 blocks.push(if ctx.plain {
1205 body.to_string()
1206 } else {
1207 code_fence(lang, body)
1208 });
1209 }
1210 Node::Formula { latex, .. } => blocks.push(format!("$${latex}$$")),
1213 Node::Table(table) => {
1214 if let Some(cap) = &table.caption {
1217 if !cap.is_empty() {
1218 blocks.push(ctx.text(cap));
1219 }
1220 }
1221 let rendered = if ctx.plain {
1222 render_table(&ctx.plain_table(table), ctx.compact_tables)
1223 } else {
1224 render_table(table, ctx.compact_tables)
1225 };
1226 if !rendered.is_empty() {
1227 blocks.push(rendered);
1228 }
1229 }
1230 Node::Picture {
1239 caption,
1240 image,
1241 description,
1242 ..
1243 } => {
1244 if let Some(cap) = caption {
1245 if !cap.is_empty() {
1246 blocks.push(ctx.caption(cap));
1247 }
1248 }
1249 let alt = description
1250 .as_ref()
1251 .filter(|d| d.is_alt_text() && ctx.alt_text && !d.text.is_empty());
1252 let marker = picture_marker(image.as_ref(), ctx, alt.map(|d| d.text.as_str()));
1253 if let Some(desc) = description {
1254 let shown = !desc.is_alt_text() || (ctx.alt_text && !marker.starts_with("!["));
1255 if !desc.text.is_empty() && shown {
1256 blocks.push(ctx.breaks(&ctx.text(&desc.text)));
1257 }
1258 }
1259 blocks.push(marker);
1260 }
1261 Node::Chart {
1265 kind,
1266 table,
1267 caption,
1268 ..
1269 } => {
1270 if let Some(cap) = caption {
1271 if !cap.is_empty() {
1272 blocks.push(ctx.caption(cap));
1273 }
1274 }
1275 blocks.push(picture_marker(None, ctx, None));
1276 blocks.push(humanize_label(kind));
1277 let rendered = if ctx.plain {
1278 render_table(&ctx.plain_table(table), false)
1279 } else {
1280 render_table(table, false)
1281 };
1282 if !rendered.is_empty() {
1283 blocks.push(rendered);
1284 }
1285 }
1286 Node::DoclangOnly(_) => {}
1288 Node::Group {
1293 layer: Some(layer),
1294 children,
1295 ..
1296 } => {
1297 let outer = ctx.current_layer.replace(*layer);
1298 render(children, blocks, ctx);
1299 ctx.current_layer = outer;
1300 }
1301 Node::Group { children, .. } => render(children, blocks, ctx),
1302 Node::FieldRegion { items } => {
1303 for item in items {
1308 for part in [&item.marker, &item.key, &item.value].into_iter().flatten() {
1309 blocks.push(ctx.breaks(&ctx.text(part)));
1310 }
1311 }
1312 }
1313 Node::KeyValueGraph { .. } => blocks.push(MISSING_KEY_VALUE_ITEM.to_string()),
1316 Node::InlineGroup { md_text, .. } => blocks.push(ctx.breaks(&ctx.text(md_text))),
1319 Node::TextDump(text) => {
1321 if !text.is_empty() {
1322 blocks.push(text.clone());
1323 }
1324 }
1325 Node::Furniture { layer, inner } => {
1329 let outer = ctx.current_layer.replace(*layer);
1330 render_one(inner, blocks, ctx);
1331 ctx.current_layer = outer;
1332 }
1333 Node::PageFurniture { text, .. } | Node::FurnitureText { text, .. } => {
1337 if ctx.layers.furniture && !text.is_empty() {
1338 blocks.push(ctx.breaks(&ctx.text(text)));
1339 }
1340 }
1341 Node::PictureChildren(children) => {
1346 if ctx.traverse_pictures {
1347 render(children, blocks, ctx);
1348 }
1349 }
1350 Node::CommentSection { text, .. } => {
1355 if ctx.layers.notes && !text.is_empty() {
1356 blocks.push(ctx.breaks(&ctx.text(text)));
1357 }
1358 }
1359 Node::Commented { inner, .. } => render_one(inner, blocks, ctx),
1360 Node::Located { inner, .. } | Node::Prov { inner, .. } | Node::Track { inner, .. } => {
1362 render_one(inner, blocks, ctx)
1363 }
1364 Node::PageBreak => {}
1366 Node::PageInfo { .. } => {}
1368 Node::Anchor { .. } => {}
1370 Node::ListItem { .. } => render_list_run(std::slice::from_ref(node), blocks, ctx),
1375 Node::ListText { text, .. } if text.is_empty() => {}
1377 Node::ListText { text, .. } => blocks.push(ctx.breaks(&ctx.text(text))),
1378 }
1379}
1380
1381fn humanize_label(label: &str) -> String {
1386 let text = label.replace('_', " ");
1387 let mut chars = text.chars();
1388 match chars.next() {
1389 Some(f) => f.to_uppercase().collect::<String>() + chars.as_str(),
1390 None => text,
1391 }
1392}
1393
1394fn image_alt(alt: Option<&str>) -> String {
1397 match alt {
1398 Some(text) => text
1399 .split_whitespace()
1400 .collect::<Vec<_>>()
1401 .join(" ")
1402 .replace('\\', "\\\\")
1403 .replace(']', "\\]"),
1404 None => "Image".to_string(),
1405 }
1406}
1407
1408fn picture_marker(image: Option<&crate::PictureImage>, ctx: &mut Ctx, alt: Option<&str>) -> String {
1409 match (ctx.images, image) {
1410 (ImageMode::Embedded, Some(img)) => {
1412 format!(
1413 "",
1414 image_alt(alt),
1415 crate::pixel_digest::docling_data_uri(img).1
1416 )
1417 }
1418 (ImageMode::Referenced, Some(img)) => {
1419 let path = format!(
1420 "{}/image_{:06}.{}",
1421 ctx.artifacts_dir,
1422 ctx.pic_index,
1423 ext_for(&img.mimetype)
1424 );
1425 ctx.pic_index += 1;
1426 ctx.artifacts.push((path.clone(), img.data.clone()));
1427 format!("", image_alt(alt), escape_uri_path(&path))
1428 }
1429 _ => ctx.image_placeholder.clone(),
1431 }
1432}
1433
1434pub(crate) fn escape_uri_path(value: &str) -> String {
1447 const KEEP: &str = "/%:@+,;=~$!&'*";
1448 let s = value.replace('\\', "/");
1449 if let Some(rest) = s.strip_prefix("//") {
1450 let rest = rest.trim_start_matches('/');
1452 let (host, tail) = rest.split_once('/').unwrap_or((rest, ""));
1453 return format!("file://{host}{}", percent_quote(&format!("/{tail}"), KEEP));
1454 }
1455 let bytes = s.as_bytes();
1456 if bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'/' {
1457 return format!("file:///{}", percent_quote(&s, KEEP));
1459 }
1460 if let Some((scheme, rest)) = s.split_once(':') {
1464 let valid_scheme = scheme.len() > 1
1465 && scheme.as_bytes()[0].is_ascii_alphabetic()
1466 && scheme
1467 .bytes()
1468 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'-' | b'.'));
1469 if valid_scheme {
1470 let (authority, rest) = match rest.strip_prefix("//") {
1471 Some(r) => {
1472 let end = r.find(['/', '?', '#']).unwrap_or(r.len());
1473 (Some(&r[..end]), &r[end..])
1474 }
1475 None => (None, rest),
1476 };
1477 let (before_frag, fragment) = rest.split_once('#').unwrap_or((rest, ""));
1478 let (path, query) = before_frag.split_once('?').unwrap_or((before_frag, ""));
1479 let mut out = format!("{scheme}:");
1480 if let Some(a) = authority {
1481 out.push_str("//");
1482 out.push_str(a);
1483 }
1484 out.push_str(&percent_quote(path, KEEP));
1485 if !query.is_empty() {
1486 out.push('?');
1487 out.push_str(&percent_quote(query, KEEP));
1488 }
1489 if !fragment.is_empty() {
1490 out.push('#');
1491 out.push_str(&percent_quote(fragment, KEEP));
1492 }
1493 return out;
1494 }
1495 }
1496 percent_quote(&s, KEEP)
1498}
1499
1500fn percent_quote(s: &str, safe: &str) -> String {
1503 let mut out = String::with_capacity(s.len());
1504 for &b in s.as_bytes() {
1505 let keep = b.is_ascii_alphanumeric()
1506 || matches!(b, b'_' | b'.' | b'-' | b'~')
1507 || (b.is_ascii() && safe.contains(b as char));
1508 if keep {
1509 out.push(b as char);
1510 } else {
1511 out.push_str(&format!("%{b:02X}"));
1512 }
1513 }
1514 out
1515}
1516
1517pub(crate) fn ext_for(mimetype: &str) -> &str {
1518 match mimetype {
1519 "image/jpeg" => "jpg",
1520 "image/gif" => "gif",
1521 "image/webp" => "webp",
1522 "image/bmp" => "bmp",
1523 "image/tiff" => "tif",
1524 "image/svg+xml" => "svg",
1525 _ => "png",
1526 }
1527}
1528
1529fn is_number_cell(t: &str) -> bool {
1548 t.parse::<f64>().is_ok() || is_thousands_number(t)
1549}
1550
1551fn is_thousands_number(t: &str) -> bool {
1557 let b = t.as_bytes();
1558 let mut i = 0;
1559 let start = i;
1560 if i < b.len() && (b[i] == b'+' || b[i] == b'-') {
1561 i += 1;
1562 }
1563 let d0 = i;
1565 while i < b.len() && b[i].is_ascii_digit() && i - d0 < 3 {
1566 i += 1;
1567 }
1568 let has_int = i > d0;
1569 if has_int {
1570 while i + 3 < b.len() + 1
1572 && b.get(i) == Some(&b',')
1573 && b.get(i + 1).is_some_and(u8::is_ascii_digit)
1574 && b.get(i + 2).is_some_and(u8::is_ascii_digit)
1575 && b.get(i + 3).is_some_and(u8::is_ascii_digit)
1576 {
1577 i += 4;
1578 }
1579 } else {
1580 i = start;
1582 }
1583 if i < b.len() && b[i] == b'.' {
1585 i += 1;
1586 let f0 = i;
1587 while i < b.len() && b[i].is_ascii_digit() {
1588 i += 1;
1589 }
1590 if !has_int && i == f0 {
1591 return false; }
1593 } else if !has_int {
1594 return false; }
1596 i == b.len()
1597}
1598
1599fn flatten_header_rows(header_rows: &[Vec<String>], num_cols: usize) -> Vec<String> {
1608 (0..num_cols)
1609 .map(|c| {
1610 let mut parts: Vec<&str> = Vec::new();
1611 for row in header_rows {
1612 let text = row.get(c).map(String::as_str).unwrap_or("");
1613 if !text.is_empty() && parts.last() != Some(&text) {
1614 parts.push(text);
1615 }
1616 }
1617 parts.join(" - ")
1618 })
1619 .collect()
1620}
1621
1622pub(crate) fn render_table(table: &Table, compact: bool) -> String {
1623 if table.rows.is_empty() {
1624 return String::new();
1625 }
1626 let num_cols = table.rows.iter().map(Vec::len).max().unwrap_or(0);
1627 if num_cols == 0 {
1628 return String::new();
1629 }
1630
1631 let num_headers = table.header_row_count().min(table.rows.len());
1636 let escaped = |r: usize| -> Vec<String> {
1637 (0..num_cols)
1638 .map(|c| escape_cell(table.rows[r].get(c).map(String::as_str).unwrap_or("")))
1639 .collect()
1640 };
1641 let header_rows: Vec<Vec<String>> = (0..num_headers).map(escaped).collect();
1642 let header = flatten_header_rows(&header_rows, num_cols);
1643 let body: Vec<Vec<String>> = (num_headers..table.rows.len())
1644 .map(|r| {
1645 escaped(r)
1646 .into_iter()
1647 .map(|c| c.trim().to_string())
1648 .collect()
1649 })
1650 .collect();
1651
1652 if compact {
1653 let render_row = |row: &[String]| -> String { format!("| {} |", row.join(" | ")) };
1655 let mut lines = Vec::with_capacity(body.len() + 2);
1656 lines.push(render_row(&header));
1657 let sep: Vec<&str> = (0..num_cols).map(|_| "-").collect();
1658 lines.push(format!("| {} |", sep.join(" | ")));
1659 for row in &body {
1660 lines.push(render_row(row));
1661 }
1662 return lines.join("\n");
1663 }
1664
1665 let dw = |s: &str| s.chars().count();
1667
1668 let right: Vec<bool> = (0..num_cols)
1673 .map(|c| {
1674 let mut any = false;
1675 for row in &body {
1676 let t = row[c].trim();
1677 if t.is_empty() {
1678 continue;
1679 }
1680 if !is_number_cell(t) {
1681 return false;
1682 }
1683 any = true;
1684 }
1685 any
1686 })
1687 .collect();
1688
1689 let width: Vec<usize> = (0..num_cols)
1691 .map(|c| {
1692 let mut w = dw(&header[c]) + 2;
1693 for row in &body {
1694 w = w.max(dw(&row[c]));
1695 }
1696 w
1697 })
1698 .collect();
1699
1700 let fmt_cell = |s: &str, c: usize| -> String {
1701 let pad = " ".repeat(width[c].saturating_sub(dw(s)));
1702 let body = if right[c] {
1703 format!("{pad}{s}")
1704 } else {
1705 format!("{s}{pad}")
1706 };
1707 format!(" {body} ")
1708 };
1709 let render_row = |row: &[String]| -> String {
1710 let cells: Vec<String> = (0..num_cols).map(|c| fmt_cell(&row[c], c)).collect();
1711 format!("|{}|", cells.join("|"))
1712 };
1713
1714 let mut lines = Vec::with_capacity(body.len() + 2);
1715 lines.push(render_row(&header));
1716 let sep: Vec<String> = (0..num_cols).map(|c| "-".repeat(width[c] + 2)).collect();
1717 lines.push(format!("|{}|", sep.join("|")));
1718 for row in &body {
1719 lines.push(render_row(row));
1720 }
1721 lines.join("\n")
1722}
1723
1724fn escape_cell(s: &str) -> String {
1727 s.replace('\n', " ").replace('|', "|")
1728}
1729
1730#[cfg(test)]
1731mod tests {
1732 use super::*;
1733 use crate::{PictureImage, TableCell, TableStructure};
1734
1735 #[test]
1741 fn list_boundaries_come_from_the_backend_not_the_numbering() {
1742 let item = |ordered: bool, number: u64, first_in_list: bool, text: &str| Node::ListItem {
1743 ordered,
1744 number,
1745 first_in_list,
1746 text: text.into(),
1747 level: 0,
1748 marker: None,
1749 location: None,
1750 dclx: None,
1751 href: None,
1752 layer: None,
1753 };
1754 let md = |items: Vec<Node>| {
1755 let mut doc = DoclingDocument::new("t");
1756 for n in items {
1757 doc.push(n);
1758 }
1759 doc.export_to_markdown()
1760 };
1761 assert_eq!(
1763 md(vec![
1764 item(true, 1, true, "one"),
1765 item(true, 5, false, "five")
1766 ]),
1767 "1. one\n5. five\n"
1768 );
1769 assert_eq!(
1771 md(vec![
1772 item(false, 0, true, "bullet"),
1773 item(true, 1, false, "one"),
1774 item(false, 0, false, "bullet two"),
1775 ]),
1776 "- bullet\n1. one\n- bullet two\n"
1777 );
1778 assert_eq!(
1780 md(vec![
1781 item(true, 1, true, "a"),
1782 item(true, 2, false, "b"),
1783 item(true, 3, true, "new list, continuing count"),
1784 ]),
1785 "1. a\n2. b\n\n3. new list, continuing count\n"
1786 );
1787 }
1788
1789 #[test]
1790 fn renders_headings_paragraphs_and_lists() {
1791 let mut doc = DoclingDocument::new("demo");
1792 doc.add_heading(1, "Title");
1793 doc.add_paragraph("Hello world.");
1794 doc.push(Node::ListItem {
1795 ordered: false,
1796 number: 1,
1797 first_in_list: true,
1798 text: "first".into(),
1799 level: 0,
1800 marker: None,
1801 location: None,
1802 dclx: None,
1803 href: None,
1804 layer: None,
1805 });
1806 doc.push(Node::ListItem {
1807 ordered: false,
1808 number: 2,
1809 first_in_list: false,
1810 text: "second".into(),
1811 level: 0,
1812 marker: None,
1813 location: None,
1814 dclx: None,
1815 href: None,
1816 layer: None,
1817 });
1818 let md = doc.export_to_markdown();
1819 assert_eq!(md, "# Title\n\nHello world.\n\n- first\n- second\n");
1820 }
1821
1822 #[test]
1826 fn single_newlines_become_gfm_hard_line_breaks() {
1827 let mut doc = DoclingDocument::new("t");
1828 doc.push(Node::Heading {
1829 level: 1,
1830 text: "Hello\nWorld".into(),
1831 });
1832 doc.push(Node::Paragraph {
1833 text: "line one\nline two\n\npara two".into(),
1834 });
1835 doc.push(Node::ListItem {
1836 ordered: false,
1837 number: 1,
1838 first_in_list: true,
1839 text: "item\ncontinued".into(),
1840 level: 0,
1841 marker: None,
1842 location: None,
1843 dclx: None,
1844 href: None,
1845 layer: None,
1846 });
1847 doc.push(Node::TextDump("A1 B1 \n\n\nC1".into()));
1848 assert_eq!(
1849 doc.export_to_markdown(),
1850 "# Hello World\n\nline one \nline two\n\npara two\n\n- item \ncontinued\n\nA1 B1 \n\n\nC1\n"
1851 );
1852 }
1853
1854 #[test]
1857 fn table_cell_mode_and_field_regions() {
1858 let mut doc = DoclingDocument::new("t");
1859 doc.push(Node::Heading {
1860 level: 2,
1861 text: "A text".into(),
1862 });
1863 doc.push(Node::Paragraph {
1864 text: "body".into(),
1865 });
1866 assert_eq!(to_markdown_table_cell(&doc, false), "A text\n\nbody");
1867 assert_eq!(doc.export_to_markdown(), "## A text\n\nbody\n");
1868
1869 let mut doc = DoclingDocument::new("f");
1870 doc.push(Node::FieldRegion {
1871 items: vec![crate::FieldItem {
1872 marker: None,
1873 key: Some("Name:".into()),
1874 value: Some("John Doe".into()),
1875 value_kind: None,
1876 }],
1877 });
1878 assert_eq!(doc.export_to_markdown(), "Name:\n\nJohn Doe\n");
1879 }
1880
1881 #[test]
1882 fn strict_renders_recovered_links_legacy_does_not() {
1883 let mut doc = DoclingDocument::new("cv");
1884 doc.add_paragraph("Find me on LinkedIn or GitHub.");
1885 doc.links = vec![
1886 ("LinkedIn".into(), "https://www.linkedin.com/in/x/".into()),
1887 ("GitHub".into(), "https://github.com/x/".into()),
1888 ];
1889 assert_eq!(doc.export_to_markdown(), "Find me on LinkedIn or GitHub.\n");
1891 assert_eq!(
1893 doc.export_to_markdown_with(true),
1894 "Find me on [LinkedIn](https://www.linkedin.com/in/x/) or [GitHub](https://github.com/x/).\n"
1895 );
1896 }
1897
1898 #[test]
1899 fn strict_links_match_escaped_anchor_and_consume_in_order() {
1900 let mut doc = DoclingDocument::new("d");
1901 doc.add_paragraph("AI & ML here, and issues here, then issues there.");
1905 doc.links = vec![
1906 ("AI & ML".into(), "https://a/".into()),
1907 ("issues".into(), "https://first/".into()),
1908 ("issues".into(), "https://second/".into()),
1909 ];
1910 assert_eq!(
1911 doc.export_to_markdown_with(true),
1912 "[AI & ML](https://a/) here, and [issues](https://first/) here, then [issues](https://second/) there.\n"
1913 );
1914 }
1915
1916 #[test]
1920 fn referenced_image_destinations_are_escaped() {
1921 let cases = [
1922 (
1923 "doc_artifacts/image_000001_ab12.png",
1924 "doc_artifacts/image_000001_ab12.png",
1925 ),
1926 (
1927 "My Report_artifacts/img.png",
1928 "My%20Report_artifacts/img.png",
1929 ),
1930 ("artifacts/img (1).png", "artifacts/img%20%281%29.png"),
1931 ("100%_scale/a#b?c.png", "100%_scale/a%23b%3Fc.png"),
1932 ("/home/a b/img.png", "/home/a%20b/img.png"),
1933 (
1934 "My Report_artifacts\\img.png",
1935 "My%20Report_artifacts/img.png",
1936 ),
1937 (
1938 "C:/Users/me/My Docs/img.png",
1939 "file:///C:/Users/me/My%20Docs/img.png",
1940 ),
1941 ("C:\\Users\\me\\img.png", "file:///C:/Users/me/img.png"),
1942 (
1943 "//server/share/My Docs/img.png",
1944 "file://server/share/My%20Docs/img.png",
1945 ),
1946 ("\\\\server\\share\\img.png", "file://server/share/img.png"),
1947 ("file:///home/a b/img.png", "file:///home/a%20b/img.png"),
1948 (
1949 "s3://bucket/My Report_artifacts/img.png",
1950 "s3://bucket/My%20Report_artifacts/img.png",
1951 ),
1952 (
1953 "https://example.com:8080/a b.png?w=1&h=2#frag",
1954 "https://example.com:8080/a%20b.png?w=1&h=2#frag",
1955 ),
1956 (
1957 "https://example.com/img (1).png",
1958 "https://example.com/img%20%281%29.png",
1959 ),
1960 ("caf\u{e9}/im\u{e4}ge.png", "caf%C3%A9/im%C3%A4ge.png"),
1961 ];
1962 for (input, expected) in cases {
1963 assert_eq!(escape_uri_path(input), expected, "input {input:?}");
1964 assert_eq!(
1965 escape_uri_path(expected),
1966 expected,
1967 "idempotent {expected:?}"
1968 );
1969 }
1970 let mut doc = DoclingDocument::new("t");
1972 doc.push(Node::Picture {
1973 caption: None,
1974 caption_href: None,
1975 image: Some(PictureImage {
1976 dpi: PictureImage::DEFAULT_DPI,
1977 mimetype: "image/png".into(),
1978 width: 1,
1979 height: 1,
1980 data: b"x".to_vec(),
1981 display_size: None,
1982 }),
1983 classification: None,
1984 description: None,
1985 caption_parent: Default::default(),
1986 caption_location: None,
1987 });
1988 let (md, files) = doc
1989 .export_to_markdown_with_images(ImageMode::Referenced, "My Report (final)_artifacts");
1990 assert!(
1991 md.contains(""),
1992 "got:\n{md}"
1993 );
1994 assert_eq!(files[0].0, "My Report (final)_artifacts/image_000000.png");
1996 }
1997
1998 #[test]
2002 fn folded_list_item_pictures_keep_plain_newlines() {
2003 let doc = DoclingDocument::new("t");
2004 let ctx = Ctx::new(&doc, false, &MarkdownExportOptions::default());
2005 assert_eq!(
2006 list_item_text("Step\n<!-- image -->", &ctx),
2007 "Step\n<!-- image -->"
2008 );
2009 assert_eq!(
2010 list_item_text("Step\nAlt text\n<!-- image -->\n<!-- image -->", &ctx),
2011 "Step\nAlt text\n<!-- image -->\n<!-- image -->"
2012 );
2013 assert_eq!(
2014 list_item_text("line one\nline two", &ctx),
2015 "line one \nline two"
2016 );
2017 }
2018
2019 #[test]
2022 fn stacked_header_rows_flatten_into_one() {
2023 let mut t = Table {
2024 rows: vec![
2025 vec!["".into(), "% of Total".into(), "% of Total".into()],
2026 vec!["class".into(), "Train".into(), "Test".into()],
2027 vec!["Caption".into(), "2.04".into(), "1.77".into()],
2028 ],
2029 ..Default::default()
2030 };
2031 t.structure = Some(TableStructure {
2032 header_row: vec![true, true, false],
2033 col_continuation: vec![
2034 vec![false, false, true],
2035 vec![false, false, false],
2036 vec![false, false, false],
2037 ],
2038 ..Default::default()
2039 });
2040 assert_eq!(t.header_row_count(), 2);
2041 assert_eq!(
2042 render_table(&t, true),
2043 "| class | % of Total - Train | % of Total - Test |\n| - | - | - |\n| Caption | 2.04 | 1.77 |"
2044 );
2045 assert_eq!(
2047 render_table(&t, false),
2048 "| class | % of Total - Train | % of Total - Test |\n\
2049 |---------|----------------------|---------------------|\n\
2050 | Caption | 2.04 | 1.77 |"
2051 );
2052 }
2053
2054 #[test]
2057 fn vertically_spanning_header_does_not_extend_the_block() {
2058 let mut t = Table {
2059 rows: vec![
2060 vec!["Name".into(), "Value".into()],
2061 vec!["Name".into(), "1".into()],
2062 vec!["x".into(), "2".into()],
2063 ],
2064 ..Default::default()
2065 };
2066 t.structure = Some(TableStructure {
2067 col_header: vec![vec![true, true], vec![true, false], vec![false, false]],
2068 row_continuation: vec![vec![false, false], vec![true, false], vec![false, false]],
2069 ..Default::default()
2070 });
2071 assert_eq!(t.header_row_count(), 1);
2072 assert_eq!(
2073 render_table(&t, true),
2074 "| Name | Value |\n| - | - |\n| Name | 1 |\n| x | 2 |"
2075 );
2076 }
2077
2078 #[test]
2081 fn header_flags_not_on_row_zero_keep_all_rows_in_the_body() {
2082 let mut t = Table {
2083 rows: vec![
2084 vec!["1".into(), "2".into()],
2085 vec!["a".into(), "b".into()],
2086 vec!["333".into(), "4".into()],
2087 ],
2088 ..Default::default()
2089 };
2090 t.structure = Some(TableStructure {
2091 header_row: vec![false, true, false],
2092 ..Default::default()
2093 });
2094 assert_eq!(t.header_row_count(), 0);
2095 assert_eq!(
2096 render_table(&t, false),
2097 "| | |\n|-----|----|\n| 1 | 2 |\n| a | b |\n| 333 | 4 |"
2098 );
2099 }
2100
2101 #[test]
2106 fn pivot_row_headers_do_not_extend_the_header() {
2107 let mut t = Table {
2108 rows: vec![
2109 vec!["Year".into(), "Month".into()],
2110 vec!["2025".into(), "January".into()],
2111 vec!["2025".into(), "February".into()],
2112 ],
2113 ..Default::default()
2114 };
2115 t.structure = Some(TableStructure {
2116 col_header: vec![vec![true, true], vec![false, false], vec![false, false]],
2117 row_header: vec![vec![false, false], vec![true, false], vec![true, false]],
2118 row_continuation: vec![vec![false, false], vec![false, false], vec![true, false]],
2119 ..Default::default()
2120 });
2121 assert_eq!(t.header_row_count(), 1);
2122 assert_eq!(
2123 render_table(&t, true),
2124 "| Year | Month |\n| - | - |\n| 2025 | January |\n| 2025 | February |"
2125 );
2126 }
2127
2128 #[test]
2131 fn unflagged_cells_keep_row_zero_as_header() {
2132 let mut t = Table {
2133 rows: vec![vec!["h".into()], vec!["d".into()]],
2134 ..Default::default()
2135 };
2136 t.cells = Some(
2137 [(0usize, "h"), (1, "d")]
2138 .into_iter()
2139 .map(|(r, text)| TableCell {
2140 text: text.into(),
2141 bbox: None,
2142 start_row: r,
2143 start_col: 0,
2144 row_span: 1,
2145 col_span: 1,
2146 column_header: false,
2147 row_header: false,
2148 row_section: false,
2149 })
2150 .collect(),
2151 );
2152 assert_eq!(t.header_row_count(), 1);
2153 assert_eq!(render_table(&t, true), "| h |\n| - |\n| d |");
2154 }
2155
2156 fn flagged_table(rows: &[&[&str]], ched: impl Fn(usize, usize) -> bool) -> Table {
2158 let mut cells = Vec::new();
2159 for (r, row) in rows.iter().enumerate() {
2160 for (c, text) in row.iter().enumerate() {
2161 cells.push(TableCell {
2162 text: (*text).into(),
2163 bbox: None,
2164 start_row: r,
2165 start_col: c,
2166 row_span: 1,
2167 col_span: 1,
2168 column_header: ched(r, c),
2169 row_header: false,
2170 row_section: false,
2171 });
2172 }
2173 }
2174 Table {
2175 rows: rows
2176 .iter()
2177 .map(|row| row.iter().map(|t| (*t).to_string()).collect())
2178 .collect(),
2179 cells: Some(cells),
2180 ..Default::default()
2181 }
2182 }
2183
2184 #[test]
2188 fn rows_with_body_text_stop_the_header_block() {
2189 let t = flagged_table(
2191 &[
2192 &["Item", "Q1", "Q2"],
2193 &["Sales", "10", "20"],
2194 &["Costs", "5", "7"],
2195 ],
2196 |r, c| r == 0 || c == 0,
2197 );
2198 assert_eq!(t.header_row_count(), 1);
2199 assert_eq!(
2200 render_table(&t, false),
2201 "| Item | Q1 | Q2 |\n\
2202 |--------|------|------|\n\
2203 | Sales | 10 | 20 |\n\
2204 | Costs | 5 | 7 |"
2205 );
2206 let t = flagged_table(&[&["Sales", "10", "20"], &["Costs", "5", "7"]], |_, c| {
2209 c == 0
2210 });
2211 assert_eq!(t.header_row_count(), 0);
2212 assert_eq!(
2213 render_table(&t, false),
2214 "| | | |\n\
2215 |-------|----|----|\n\
2216 | Sales | 10 | 20 |\n\
2217 | Costs | 5 | 7 |"
2218 );
2219 let t = flagged_table(
2220 &[&["a", "1", "2"], &["b", "3", "4"], &["c", "5", "6"]],
2221 |r, c| c == 0 && r < 2,
2222 );
2223 assert_eq!(t.header_row_count(), 0);
2224 let t = flagged_table(&[&["a", "1", "2"], &["b", "3", "4"]], |r, c| {
2227 r == 0 && c == 0
2228 });
2229 assert_eq!(t.header_row_count(), 1);
2230 assert_eq!(
2231 render_table(&t, false),
2232 "| a | 1 | 2 |\n\
2233 |-----|-----|-----|\n\
2234 | b | 3 | 4 |"
2235 );
2236 let t = flagged_table(&[&["Item", "", ""], &["x", "1", "2"]], |_, c| c == 0);
2238 assert_eq!(t.header_row_count(), 1);
2239 assert_eq!(
2240 render_table(&t, false),
2241 "| Item | | |\n\
2242 |--------|----|----|\n\
2243 | x | 1 | 2 |"
2244 );
2245 }
2246
2247 #[test]
2248 fn renders_compact_table() {
2249 let mut doc = DoclingDocument::new("t");
2250 doc.compact_tables = true;
2253 doc.push(Node::Table(Table {
2254 rows: vec![vec!["a".into(), "b".into()], vec!["1".into(), "2".into()]],
2255 location: None,
2256 structure: None,
2257 cell_blocks: None,
2258 cells: None,
2259 caption: None,
2260 caption_parent: Default::default(),
2261 caption_location: None,
2262 }));
2263 let md = doc.export_to_markdown();
2264 assert_eq!(md, "| a | b |\n| - | - |\n| 1 | 2 |\n");
2265 }
2266
2267 #[test]
2268 fn renders_padded_github_table_by_default() {
2269 let mut doc = DoclingDocument::new("t");
2270 doc.push(Node::Table(Table {
2271 rows: vec![vec!["a".into(), "b".into()], vec!["1".into(), "2".into()]],
2272 location: None,
2273 structure: None,
2274 cell_blocks: None,
2275 cells: None,
2276 caption: None,
2277 caption_parent: Default::default(),
2278 caption_location: None,
2279 }));
2280 let md = doc.export_to_markdown();
2281 assert_eq!(md, "| a | b |\n|-----|-----|\n| 1 | 2 |\n");
2283 }
2284
2285 #[test]
2286 fn strict_unescapes_inline_underscores_legacy_keeps_them() {
2287 let mut doc = DoclingDocument::new("t");
2288 doc.add_heading(1, "a\\_b");
2289 doc.add_paragraph("x\\_y");
2290 doc.push(Node::ListItem {
2291 ordered: false,
2292 number: 1,
2293 first_in_list: true,
2294 text: "i\\_j".into(),
2295 level: 0,
2296 marker: None,
2297 location: None,
2298 dclx: None,
2299 href: None,
2300 layer: None,
2301 });
2302 assert_eq!(doc.export_to_markdown(), "# a\\_b\n\nx\\_y\n\n- i\\_j\n");
2304 assert_eq!(doc.export_to_markdown_with(true), "# a_b\n\nx_y\n\n- i_j\n");
2306 }
2307
2308 fn assert_stream_matches(
2311 doc: &DoclingDocument,
2312 strict: bool,
2313 images: ImageMode,
2314 splits: &[usize],
2315 ) {
2316 let (want, want_artifacts) = to_markdown_images(doc, strict, images, "artifacts");
2317 let mut streamer =
2318 MarkdownStreamer::with_artifacts(strict, images, doc.compact_tables, "artifacts")
2319 .with_page_break_placeholder(doc.page_break_placeholder.clone());
2320 let mut got = String::new();
2321 let mut got_artifacts = Vec::new();
2322 let mut start = 0;
2323 for &end in splits {
2324 let links = if start == 0 {
2327 doc.links.as_slice()
2328 } else {
2329 &[]
2330 };
2331 got.push_str(&streamer.push(&doc.nodes[start..end], links));
2332 got_artifacts.extend(streamer.take_artifacts());
2335 start = end;
2336 }
2337 got.push_str(&streamer.push(
2338 &doc.nodes[start..],
2339 if start == 0 {
2340 doc.links.as_slice()
2341 } else {
2342 &[]
2343 },
2344 ));
2345 got_artifacts.extend(streamer.take_artifacts());
2346 got.push_str(&streamer.finish());
2347 assert_eq!(
2348 got, want,
2349 "streamed output diverged (splits={splits:?}, strict={strict})"
2350 );
2351 assert_eq!(
2352 got_artifacts, want_artifacts,
2353 "streamed artifacts diverged (splits={splits:?}, strict={strict})"
2354 );
2355 }
2356
2357 #[test]
2358 fn streaming_is_byte_identical_to_buffered() {
2359 let mut doc = DoclingDocument::new("d");
2360 doc.add_heading(1, "Title");
2361 doc.add_paragraph("First paragraph.");
2362 doc.push(Node::ListItem {
2363 ordered: false,
2364 number: 1,
2365 first_in_list: true,
2366 text: "a".into(),
2367 level: 0,
2368 marker: None,
2369 location: None,
2370 dclx: None,
2371 href: None,
2372 layer: None,
2373 });
2374 doc.push(Node::ListItem {
2375 ordered: false,
2376 number: 2,
2377 first_in_list: false,
2378 text: "b".into(),
2379 level: 0,
2380 marker: None,
2381 location: None,
2382 dclx: None,
2383 href: None,
2384 layer: None,
2385 });
2386 doc.push(Node::Code {
2387 language: Some("rust".into()),
2388 text: "let x = 1;".into(),
2389 orig: None,
2390 pretty: None,
2391 });
2392 doc.push(Node::Table(Table {
2393 rows: vec![vec!["a".into(), "b".into()], vec!["1".into(), "2".into()]],
2394 location: None,
2395 structure: None,
2396 cell_blocks: None,
2397 cells: None,
2398 caption: None,
2399 caption_parent: Default::default(),
2400 caption_location: None,
2401 }));
2402 doc.push(Node::Picture {
2403 caption: Some("Fig 1".into()),
2404 caption_href: None,
2405 image: Some(PictureImage {
2406 dpi: PictureImage::DEFAULT_DPI,
2407 mimetype: "image/png".into(),
2408 width: 2,
2409 height: 2,
2410 data: b"png-one".to_vec(),
2411 display_size: None,
2412 }),
2413 classification: None,
2414 description: None,
2415 caption_parent: Default::default(),
2416 caption_location: None,
2417 });
2418 doc.add_paragraph("Last paragraph.");
2419 doc.push(Node::Picture {
2422 caption: None,
2423 caption_href: None,
2424 image: Some(PictureImage {
2425 dpi: PictureImage::DEFAULT_DPI,
2426 mimetype: "image/png".into(),
2427 width: 2,
2428 height: 2,
2429 data: b"png-two".to_vec(),
2430 display_size: None,
2431 }),
2432 classification: None,
2433 description: None,
2434 caption_parent: Default::default(),
2435 caption_location: None,
2436 });
2437
2438 for &strict in &[false, true] {
2441 for &images in &[
2442 ImageMode::Placeholder,
2443 ImageMode::Embedded,
2444 ImageMode::Referenced,
2445 ] {
2446 for splits in [&[][..], &[1][..], &[2][..], &[4][..], &[1, 4, 6, 7][..]] {
2447 assert_stream_matches(&doc, strict, images, splits);
2448 }
2449 }
2450 }
2451 }
2452
2453 #[test]
2454 fn streaming_applies_recovered_links_in_strict_mode() {
2455 let mut doc = DoclingDocument::new("d");
2456 doc.add_paragraph("See LinkedIn for details.");
2457 doc.add_paragraph("And GitHub too.");
2458 doc.links = vec![
2459 ("LinkedIn".into(), "https://lnkd/".into()),
2460 ("GitHub".into(), "https://gh/".into()),
2461 ];
2462 assert_stream_matches(&doc, true, ImageMode::Placeholder, &[1]);
2465 }
2466
2467 fn paged_doc() -> DoclingDocument {
2470 let mut doc = DoclingDocument::new("p");
2471 doc.push(Node::PageInfo {
2472 page_no: 1,
2473 width: 100.0,
2474 height: 100.0,
2475 });
2476 doc.add_heading(1, "Title");
2477 doc.add_paragraph("Page one.");
2478 doc.push(Node::PageBreak);
2480 doc.push(Node::PageInfo {
2481 page_no: 2,
2482 width: 100.0,
2483 height: 100.0,
2484 });
2485 doc.push(Node::PageFurniture {
2486 footer: true,
2487 location: [0, 500, 511, 511],
2488 text: "2".into(),
2489 });
2490 doc.push(Node::PageBreak);
2491 doc.push(Node::PageInfo {
2492 page_no: 3,
2493 width: 100.0,
2494 height: 100.0,
2495 });
2496 doc.add_paragraph("Page three.");
2497 doc.push(Node::PageBreak);
2499 doc
2500 }
2501
2502 #[test]
2503 fn page_break_placeholder_lands_between_pages_only() {
2504 let mut doc = paged_doc();
2505 assert_eq!(
2507 doc.export_to_markdown(),
2508 "# Title\n\nPage one.\n\nPage three.\n"
2509 );
2510 doc.page_break_placeholder = Some("<!-- page break -->".into());
2511 assert_eq!(
2515 doc.export_to_markdown(),
2516 "# Title\n\nPage one.\n\n<!-- page break -->\n\nPage three.\n"
2517 );
2518 doc.page_break_placeholder = Some(String::new());
2521 assert_eq!(
2522 doc.export_to_markdown(),
2523 "# Title\n\nPage one.\n\n\n\nPage three.\n"
2524 );
2525 }
2526
2527 #[test]
2528 fn page_break_placeholder_never_leads_a_single_page() {
2529 let mut doc = DoclingDocument::new("one");
2530 doc.page_break_placeholder = Some("---".into());
2531 doc.push(Node::PageBreak);
2532 doc.push(Node::PageInfo {
2533 page_no: 1,
2534 width: 10.0,
2535 height: 10.0,
2536 });
2537 doc.add_paragraph("Only page.");
2538 assert_eq!(doc.export_to_markdown(), "Only page.\n");
2539 doc.push(Node::PageBreak);
2541 doc.push(Node::PageBreak);
2542 doc.add_paragraph("Next.");
2543 assert_eq!(doc.export_to_markdown(), "Only page.\n\n---\n\nNext.\n");
2544 }
2545
2546 #[test]
2547 fn page_break_placeholder_streams_byte_identical() {
2548 let mut doc = paged_doc();
2549 doc.page_break_placeholder = Some("<!-- page break -->".into());
2550 for splits in [
2554 &[3usize][..],
2555 &[3, 6],
2556 &[3, 6, 8],
2557 &[1, 2, 3, 4, 5, 6, 7, 8, 9],
2558 &[8],
2559 ] {
2560 assert_stream_matches(&doc, false, ImageMode::Placeholder, splits);
2561 assert_stream_matches(&doc, true, ImageMode::Placeholder, splits);
2562 }
2563 }
2564
2565 #[test]
2566 fn strict_tightens_punctuation_spacing_legacy_keeps_it() {
2567 let mut doc = DoclingDocument::new("t");
2568 doc.add_paragraph("see [ 37 , 36 ] and ( x ) .");
2569 assert_eq!(doc.export_to_markdown(), "see [ 37 , 36 ] and ( x ) .\n");
2571 assert_eq!(doc.export_to_markdown_with(true), "see [37, 36] and (x).\n");
2573 }
2574
2575 fn options_doc() -> DoclingDocument {
2580 use crate::document::ContentLayer;
2581 let mut doc = DoclingDocument::new("opts");
2582 doc.push(Node::PageFurniture {
2583 footer: false,
2584 location: [0, 0, 0, 0],
2585 text: "FAX COVER \\_ R&D".into(),
2586 });
2587 doc.add_heading(1, "R&D \\_ report");
2588 doc.add_paragraph("Body a\\_b <tag>");
2589 doc.push(Node::Picture {
2590 caption: Some("Figure 1".into()),
2591 caption_href: None,
2592 image: None,
2593 classification: None,
2594 description: None,
2595 caption_parent: Default::default(),
2596 caption_location: None,
2597 });
2598 doc.push(Node::PictureChildren(vec![
2599 Node::Paragraph {
2600 text: "Field: Name".into(),
2601 },
2602 Node::Paragraph {
2603 text: "Field: Date".into(),
2604 },
2605 ]));
2606 doc.push(Node::CommentSection {
2607 name: "comment-1".into(),
2608 text: "[author: A]: note".into(),
2609 refs_note_text: false,
2610 grouped: true,
2611 });
2612 doc.push(Node::Group {
2613 label: "section".into(),
2614 name: Some("sheet: Hidden".into()),
2615 layer: Some(ContentLayer::Invisible),
2616 children: vec![Node::Paragraph {
2617 text: "hidden cell".into(),
2618 }],
2619 });
2620 doc.push(Node::Furniture {
2621 layer: ContentLayer::Furniture,
2622 inner: Box::new(Node::Paragraph {
2623 text: "Page 1 of 2".into(),
2624 }),
2625 });
2626 doc.push(Node::FurnitureText {
2627 label: "page_footer".into(),
2628 text: "footer line".into(),
2629 });
2630 doc
2631 }
2632
2633 #[test]
2637 fn default_options_are_the_default_export() {
2638 let doc = options_doc();
2639 let plain = doc.export_to_markdown();
2640 assert_eq!(
2641 plain,
2642 "# R&D \\_ report\n\nBody a\\_b <tag>\n\nFigure 1\n\n<!-- image -->\n"
2643 );
2644 let (with_default, artifacts) =
2645 doc.export_to_markdown_with_options(&MarkdownExportOptions::default());
2646 assert_eq!(with_default, plain);
2647 assert!(artifacts.is_empty());
2648 }
2649
2650 #[test]
2651 fn layers_render_furniture_notes_and_hidden_sheets() {
2652 use crate::document::ContentLayer;
2653 let doc = options_doc();
2654 let md = |layers: ContentLayers| {
2655 doc.export_to_markdown_with_options(&MarkdownExportOptions {
2656 layers,
2657 ..MarkdownExportOptions::default()
2658 })
2659 .0
2660 };
2661 assert_eq!(
2662 md(ContentLayers::BODY.with(ContentLayer::Furniture)),
2663 "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",
2664 "a page header, a furniture-wrapped paragraph and a furniture text item render as paragraphs"
2665 );
2666 assert_eq!(
2667 md(ContentLayers::BODY.with(ContentLayer::Notes)),
2668 "# R&D \\_ report\n\nBody a\\_b <tag>\n\nFigure 1\n\n<!-- image -->\n\n[author: A]: note\n"
2669 );
2670 assert_eq!(
2671 md(ContentLayers::BODY.with(ContentLayer::Invisible)),
2672 "# R&D \\_ report\n\nBody a\\_b <tag>\n\nFigure 1\n\n<!-- image -->\n\nhidden cell\n",
2673 "a hidden sheet's items sit on the sheet's layer"
2674 );
2675 assert_eq!(
2676 md(ContentLayers::NONE.with(ContentLayer::Furniture)),
2677 "FAX COVER \\_ R&D\n\nPage 1 of 2\n\nfooter line\n",
2678 "without the body layer only the furniture prints"
2679 );
2680 assert_eq!(md(ContentLayers::NONE), "");
2681 assert_eq!(
2682 md(ContentLayers::ALL),
2683 "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"
2684 );
2685 }
2686
2687 #[test]
2690 fn layers_apply_to_list_items() {
2691 use crate::document::ContentLayer;
2692 let mut doc = DoclingDocument::new("lists");
2693 doc.push(Node::ListItem {
2694 ordered: false,
2695 number: 1,
2696 first_in_list: true,
2697 text: "nav link".into(),
2698 level: 0,
2699 marker: None,
2700 location: None,
2701 dclx: None,
2702 href: None,
2703 layer: Some(ContentLayer::Furniture),
2704 });
2705 doc.add_paragraph("body");
2706 assert_eq!(doc.export_to_markdown(), "body\n");
2707 let (md, _) = doc.export_to_markdown_with_options(&MarkdownExportOptions {
2708 layers: ContentLayers::BODY.with(ContentLayer::Furniture),
2709 ..MarkdownExportOptions::default()
2710 });
2711 assert_eq!(md, "- nav link\n\nbody\n");
2712 }
2713
2714 #[test]
2715 fn traverse_pictures_prints_the_nested_text_after_the_picture() {
2716 let doc = options_doc();
2717 let (md, _) = doc.export_to_markdown_with_options(&MarkdownExportOptions {
2718 traverse_pictures: true,
2719 ..MarkdownExportOptions::default()
2720 });
2721 assert_eq!(
2722 md,
2723 "# R&D \\_ report\n\nBody a\\_b <tag>\n\nFigure 1\n\n<!-- image -->\n\nField: Name\n\nField: Date\n"
2724 );
2725 }
2726
2727 #[test]
2728 fn escaping_can_be_turned_off_per_kind() {
2729 let doc = options_doc();
2730 let (md, _) = doc.export_to_markdown_with_options(&MarkdownExportOptions {
2731 escape_html: false,
2732 ..MarkdownExportOptions::default()
2733 });
2734 assert_eq!(
2735 md,
2736 "# R&D \\_ report\n\nBody a\\_b <tag>\n\nFigure 1\n\n<!-- image -->\n"
2737 );
2738 let (md, _) = doc.export_to_markdown_with_options(&MarkdownExportOptions {
2739 escape_underscores: false,
2740 ..MarkdownExportOptions::default()
2741 });
2742 assert_eq!(
2743 md,
2744 "# R&D _ report\n\nBody a_b <tag>\n\nFigure 1\n\n<!-- image -->\n"
2745 );
2746 let mut doc = DoclingDocument::new("code");
2749 doc.push(Node::Code {
2750 language: None,
2751 text: "a & b \\_ c".into(),
2752 orig: None,
2753 pretty: None,
2754 });
2755 let (md, _) = doc.export_to_markdown_with_options(&MarkdownExportOptions {
2756 escape_html: false,
2757 escape_underscores: false,
2758 ..MarkdownExportOptions::default()
2759 });
2760 assert_eq!(md, "```\na & b \\_ c\n```\n");
2761 }
2762
2763 #[test]
2767 fn fences_and_spans_out_length_inner_backticks() {
2768 let code = |text: &str| Node::Code {
2769 language: Some("md".into()),
2770 text: text.into(),
2771 orig: None,
2772 pretty: None,
2773 };
2774 let fence_of = |text: &str| {
2775 let mut doc = DoclingDocument::new("d");
2776 doc.push(code(text));
2777 (doc.export_to_markdown(), doc.export_to_markdown_with(true))
2778 };
2779 assert_eq!(fence_of("x").0, "```\nx\n```\n");
2780 assert_eq!(fence_of("a ` b").0, "```\na ` b\n```\n");
2781 assert_eq!(fence_of("a ``` b").0, "````\na ``` b\n````\n");
2782 assert_eq!(
2783 fence_of("a ``` b ```` c"),
2784 (
2785 "`````\na ``` b ```` c\n`````\n".to_string(),
2786 "`````md\na ``` b ```` c\n`````\n".to_string()
2787 )
2788 );
2789 assert_eq!(code_span("x"), "`x`");
2790 assert_eq!(code_span("a`b"), "``a`b``");
2791 assert_eq!(code_span("a ``` b"), "````a ``` b````");
2792 assert_eq!(code_span("`x"), "`` `x ``");
2793 assert_eq!(code_span("x`"), "`` x` ``");
2794 for (span, text) in [
2795 ("`x`", "x"),
2796 ("``a`b``", "a`b"),
2797 ("`` `x ``", "`x"),
2798 ("````a ``` b````", "a ``` b"),
2799 ] {
2800 assert_eq!(
2801 plain_inline(&format!("see {span} here")),
2802 format!("see {text} here")
2803 );
2804 }
2805 let mut doc = DoclingDocument::new("d");
2808 doc.push(code("a ``` b"));
2809 assert_eq!(doc.export_to_text(), "a ``` b");
2810 let mut doc = DoclingDocument::new("d");
2812 doc.add_paragraph("Before.");
2813 doc.push(code("a ``` b ```` c"));
2814 doc.add_paragraph("After.");
2815 assert_stream_matches(&doc, false, ImageMode::Placeholder, &[1, 2]);
2816 assert_stream_matches(&doc, true, ImageMode::Placeholder, &[1, 2]);
2817 }
2818
2819 #[test]
2824 fn mermaid_code_item_replaces_its_picture_placeholder() {
2825 let picture = || Node::Picture {
2826 caption: None,
2827 caption_href: None,
2828 image: None,
2829 classification: None,
2830 description: None,
2831 caption_parent: Default::default(),
2832 caption_location: None,
2833 };
2834 let mut doc = DoclingDocument::new("d");
2835 doc.add_paragraph("Before.");
2836 doc.push(picture());
2837 doc.push(Node::Code {
2838 language: Some("mermaid".into()),
2839 text: "flowchart TD\n A --> B".into(),
2840 orig: None,
2841 pretty: None,
2842 });
2843 doc.add_paragraph("After.");
2844 doc.push(picture());
2845 let md = doc.export_to_markdown();
2846 assert_eq!(
2847 md,
2848 "Before.\n\n```mermaid\nflowchart TD\n A --> B\n```\n\nAfter.\n\n<!-- image -->\n"
2849 );
2850 assert_eq!(doc.export_to_markdown_with(true), md);
2851 for splits in [&[1][..], &[2], &[1, 2], &[2, 3], &[1, 2, 3, 4]] {
2852 assert_stream_matches(&doc, false, ImageMode::Placeholder, splits);
2853 assert_stream_matches(&doc, true, ImageMode::Placeholder, splits);
2854 }
2855 let mut doc = DoclingDocument::new("d");
2857 doc.add_paragraph("Only.");
2858 doc.push(picture());
2859 assert_eq!(doc.export_to_markdown(), "Only.\n\n<!-- image -->\n");
2860 assert_stream_matches(&doc, false, ImageMode::Placeholder, &[1]);
2861 assert_stream_matches(&doc, false, ImageMode::Placeholder, &[2]);
2862 }
2863
2864 #[test]
2865 fn image_placeholder_is_configurable() {
2866 let doc = options_doc();
2867 let (md, _) = doc.export_to_markdown_with_options(&MarkdownExportOptions {
2868 image_placeholder: "[figure]".into(),
2869 ..MarkdownExportOptions::default()
2870 });
2871 assert!(md.contains("Figure 1\n\n[figure]\n"), "{md}");
2872 assert!(!md.contains("<!-- image -->"));
2873 }
2874
2875 #[test]
2881 fn empty_image_placeholder_prints_nothing() {
2882 fn picture(caption: Option<&str>) -> Node {
2883 Node::Picture {
2884 caption: caption.map(Into::into),
2885 caption_href: None,
2886 image: None,
2887 classification: None,
2888 description: None,
2889 caption_parent: Default::default(),
2890 caption_location: None,
2891 }
2892 }
2893 fn page(no: usize) -> Node {
2894 Node::PageInfo {
2895 page_no: no,
2896 width: 100.0,
2897 height: 100.0,
2898 }
2899 }
2900 fn para(text: &str) -> Node {
2901 Node::Paragraph { text: text.into() }
2902 }
2903 let options = MarkdownExportOptions {
2904 image_placeholder: String::new(),
2905 ..MarkdownExportOptions::default()
2906 };
2907 let cases: Vec<(Vec<Node>, Option<&str>, &str)> = vec![
2909 (
2910 vec![page(1), para("a"), picture(None), para("b")],
2911 None,
2912 "a\n\nb\n",
2913 ),
2914 (
2915 vec![page(1), para("a"), picture(Some("Figure 1")), para("b")],
2916 None,
2917 "a\n\nFigure 1\n\nb\n",
2918 ),
2919 (
2920 vec![
2921 page(1),
2922 para("a"),
2923 page(2),
2924 picture(None),
2925 page(3),
2926 para("b"),
2927 ],
2928 Some("<!-- pb -->"),
2929 "a\n\n<!-- pb -->\n\n<!-- pb -->\n\nb\n",
2930 ),
2931 (
2932 vec![
2933 page(1),
2934 para("a"),
2935 page(2),
2936 picture(None),
2937 page(3),
2938 para("b"),
2939 ],
2940 Some(""),
2941 "a\n\n\n\n\n\nb\n",
2942 ),
2943 (
2944 vec![page(1), picture(None), page(2), para("b")],
2945 Some("<!-- pb -->"),
2946 "<!-- pb -->\n\nb\n",
2947 ),
2948 (
2949 vec![page(1), picture(None), page(2), para("b")],
2950 Some(""),
2951 "\n\nb\n",
2952 ),
2953 (
2954 vec![page(1), para("a"), page(2), picture(None)],
2955 Some("<!-- pb -->"),
2956 "a\n\n<!-- pb -->\n",
2957 ),
2958 ];
2959 for (nodes, placeholder, expected) in cases {
2960 let mut doc = DoclingDocument::new("t");
2961 doc.page_break_placeholder = placeholder.map(Into::into);
2962 for node in nodes {
2963 doc.push(node);
2964 }
2965 let (md, _) = doc.export_to_markdown_with_options(&options);
2966 assert_eq!(md, expected, "page break {placeholder:?}");
2967 for size in 1..=doc.nodes.len() {
2970 let mut streamer = MarkdownStreamer::new(false, ImageMode::Placeholder, false)
2971 .with_export_options(&options)
2972 .with_page_break_placeholder(placeholder.map(Into::into));
2973 let mut out = String::new();
2974 for chunk in doc.nodes.chunks(size) {
2975 out.push_str(&streamer.push(chunk, &[]));
2976 }
2977 out.push_str(&streamer.finish());
2978 assert_eq!(
2979 out, expected,
2980 "chunks of {size}, page break {placeholder:?}"
2981 );
2982 }
2983 }
2984 let mut doc = DoclingDocument::new("t");
2986 doc.push(para("a"));
2987 doc.push(picture(None));
2988 doc.push(para("b"));
2989 assert_eq!(doc.export_to_markdown(), "a\n\n<!-- image -->\n\nb\n");
2990 }
2991
2992 #[test]
2995 fn streamer_matches_buffered_export_with_options() {
2996 let doc = options_doc();
2997 let options = MarkdownExportOptions {
2998 layers: ContentLayers::ALL,
2999 traverse_pictures: true,
3000 escape_html: false,
3001 escape_underscores: false,
3002 image_placeholder: "(img)".into(),
3003 ..MarkdownExportOptions::default()
3004 };
3005 let (buffered, _) = doc.export_to_markdown_with_options(&options);
3006 let mut streamer = MarkdownStreamer::new(false, ImageMode::Placeholder, false)
3007 .with_export_options(&options);
3008 let mut out = String::new();
3009 for chunk in doc.nodes.chunks(2) {
3010 out.push_str(&streamer.push(chunk, &[]));
3011 }
3012 out.push_str(&streamer.finish());
3013 assert_eq!(out, buffered);
3014 assert!(
3015 buffered.contains("(img)") && buffered.contains("R&D _ report"),
3016 "{buffered}"
3017 );
3018 }
3019
3020 #[test]
3023 fn plain_text_drops_the_markdown_decoration() {
3024 let item = |text: &str, ordered: bool, number: u64, level: u8| Node::ListItem {
3025 ordered,
3026 number,
3027 first_in_list: number == 1 && level == 0,
3028 text: text.into(),
3029 level,
3030 marker: None,
3031 location: None,
3032 dclx: None,
3033 href: None,
3034 layer: None,
3035 };
3036 let mut doc = DoclingDocument::new("plain");
3037 doc.add_heading(1, "Title with **bold**");
3038 doc.add_heading(2, "Section");
3039 doc.add_paragraph("Some **bold**, *italic*, ~~gone~~ and `code` with a [link](https://x.org).\nNext line.");
3040 doc.push(item("*one*", false, 1, 0));
3041 doc.push(item("[two](https://y.org)", false, 2, 0));
3042 doc.push(item("nested", true, 1, 1));
3043 doc.push(Node::CheckboxItem {
3044 checked: true,
3045 text: "done".into(),
3046 });
3047 doc.push(Node::Code {
3048 language: Some("rust".into()),
3049 text: "let x = **y**;".into(),
3050 orig: None,
3051 pretty: None,
3052 });
3053 doc.push(Node::Picture {
3054 caption: Some("Figure *1*".into()),
3055 caption_href: None,
3056 image: None,
3057 classification: None,
3058 description: None,
3059 caption_parent: Default::default(),
3060 caption_location: None,
3061 });
3062 doc.push(Node::Caption {
3063 text: "Linked caption".into(),
3064 href: Some("https://z.org".into()),
3065 });
3066 doc.push(Node::Table(Table {
3067 rows: vec![
3068 vec!["**n**".into(), "rich".into()],
3069 vec!["**2**".into(), "a \nb".into()],
3070 ],
3071 ..Default::default()
3072 }));
3073 doc.add_paragraph("R&D \\_ x");
3074 let text = doc.export_to_text();
3075 assert_eq!(
3076 text,
3077 "Title with bold\n\n\
3078 Section\n\n\
3079 Some bold, italic, gone and code with a link.\nNext line.\n\n\
3080 - one\n- two\n 1. nested\n\n\
3081 - [x] done\n\n\
3082 let x = **y**;\n\n\
3083 Figure 1\n\n\
3084 Linked caption\n\n\
3085 | n | rich |\n\
3086 |-----|--------|\n\
3087 | 2 | a b |\n\n\
3088 R&D _ x"
3089 );
3090
3091 let options = MarkdownExportOptions::plain_text();
3092 let (buffered, _) = doc.export_to_markdown_with_options(&options);
3093 assert_eq!(buffered, format!("{text}\n"));
3094 let mut streamer = MarkdownStreamer::new(false, ImageMode::Placeholder, false)
3095 .with_export_options(&options);
3096 let mut out = String::new();
3097 for chunk in doc.nodes.chunks(3) {
3098 out.push_str(&streamer.push(chunk, &[]));
3099 }
3100 out.push_str(&streamer.finish());
3101 assert_eq!(out, buffered);
3102 }
3103
3104 #[test]
3106 fn plain_inline_keeps_literal_markers() {
3107 for (input, want) in [
3108 ("**bold** and *it* and ***both***", "bold and it and both"),
3109 ("**[label](https://a.b/c_(d))** end", "label end"),
3110 (
3111 "grant *USE and *OBJMGT authority",
3112 "grant *USE and *OBJMGT authority",
3113 ),
3114 ("2 * 3 * 4", "2 * 3 * 4"),
3115 ("a ** b", "a ** b"),
3116 ("the `quoted’ and `x` term", "the `quoted’ and x term"),
3117 ("see [12] and [docs](u)", "see [12] and docs"),
3118 ("formula $[a](b)$ stays", "formula $[a](b)$ stays"),
3119 ("price $5 and **bold**", "price $5 and bold"),
3120 ("[outer [inner] text](u)", "outer [inner] text"),
3121 ("unclosed **bold", "unclosed **bold"),
3122 ] {
3123 assert_eq!(plain_inline(input), want, "{input}");
3124 }
3125 }
3126}