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 compact_tables: bool,
30 images: ImageMode,
31 artifacts_dir: String,
32 artifacts: Vec<(String, Vec<u8>)>,
34 pic_index: usize,
35 in_table_cell: bool,
39 page_break: Option<String>,
43 pending_page_break: bool,
49 emitted_any: bool,
52 layers: ContentLayers,
54 current_layer: Option<ContentLayer>,
59 traverse_pictures: bool,
61 escape_html: bool,
64 escape_underscores: bool,
66 image_placeholder: String,
68 plain: bool,
70}
71
72impl Ctx {
73 fn new(doc: &DoclingDocument, strict: bool, options: &MarkdownExportOptions) -> Self {
76 Ctx {
77 strict,
78 compact_tables: doc.compact_tables,
79 images: options.image_mode,
80 artifacts_dir: options.artifacts_dir.clone(),
81 artifacts: Vec::new(),
82 pic_index: 0,
83 in_table_cell: false,
84 page_break: doc.page_break_placeholder.clone(),
85 pending_page_break: false,
86 emitted_any: false,
87 layers: options.layers,
88 current_layer: None,
89 traverse_pictures: options.traverse_pictures,
90 escape_html: options.escape_html,
91 escape_underscores: options.escape_underscores,
92 image_placeholder: options.image_placeholder.clone(),
93 plain: options.plain_text,
94 }
95 }
96
97 fn layer_visible(&self) -> bool {
99 self.layers.contains(self.current_layer)
100 }
101
102 fn unescape(&self, text: &str) -> String {
109 let mut out = std::borrow::Cow::Borrowed(text);
110 if !self.escape_html
111 && (out.contains("&") || out.contains("<") || out.contains(">"))
112 {
113 out = std::borrow::Cow::Owned(
114 out.replace("<", "<")
115 .replace(">", ">")
116 .replace("&", "&"),
117 );
118 }
119 if !self.escape_underscores && out.contains("\\_") {
120 out = std::borrow::Cow::Owned(out.replace("\\_", "_"));
121 }
122 out.into_owned()
123 }
124
125 fn text(&self, text: &str) -> String {
128 let text = strict_text(text, self.strict);
129 if self.plain {
130 self.unescape(&plain_inline(&self.drop_image_markers(&text)))
131 } else {
132 self.unescape(&text)
133 }
134 }
135
136 fn caption(&self, text: &str) -> String {
140 if self.plain {
141 self.unescape(&plain_inline(text))
142 } else {
143 self.unescape(text)
144 }
145 }
146
147 fn breaks(&self, text: &str) -> String {
150 if self.plain {
151 text.to_string()
152 } else {
153 md_line_breaks(text)
154 }
155 }
156
157 fn plain_table(&self, table: &Table) -> Table {
164 let mut plain = table.clone();
165 for cell in plain.rows.iter_mut().flatten() {
166 if cell.contains(['*', '~', '`', '[', '\n', '<']) {
167 let text = self.drop_image_markers(&strip_hard_breaks(cell));
168 *cell = plain_inline(&text);
169 }
170 }
171 plain
172 }
173
174 fn drop_image_markers(&self, text: &str) -> String {
181 const MARKER: &str = "<!-- image -->";
182 if !text.contains(MARKER) && !text.contains("```") {
183 return text.to_string();
184 }
185 text.split("\n\n")
189 .filter_map(|block| {
190 let lines: Vec<String> = block
191 .split('\n')
192 .filter_map(|line| {
193 if line.trim() == MARKER || line.trim() == "```" {
196 return None;
197 }
198 Some(line.replace(MARKER, &self.image_placeholder))
199 })
200 .collect();
201 (!lines.is_empty()).then(|| lines.join("\n"))
202 })
203 .collect::<Vec<_>>()
204 .join("\n\n")
205 }
206}
207
208pub fn to_markdown(doc: &DoclingDocument, strict: bool) -> String {
214 to_markdown_images(doc, strict, ImageMode::Placeholder, "artifacts").0
215}
216
217pub fn to_markdown_images(
221 doc: &DoclingDocument,
222 strict: bool,
223 images: ImageMode,
224 artifacts_dir: &str,
225) -> (String, Vec<(String, Vec<u8>)>) {
226 to_markdown_with_options(
227 doc,
228 strict,
229 &MarkdownExportOptions {
230 image_mode: images,
231 artifacts_dir: artifacts_dir.to_string(),
232 ..MarkdownExportOptions::default()
233 },
234 )
235}
236
237pub fn to_markdown_with_options(
241 doc: &DoclingDocument,
242 strict: bool,
243 options: &MarkdownExportOptions,
244) -> (String, Vec<(String, Vec<u8>)>) {
245 let mut ctx = Ctx::new(doc, strict, options);
246 let mut blocks: Vec<String> = Vec::new();
247 render(&doc.nodes, &mut blocks, &mut ctx);
248 let mut body = blocks.join("\n\n");
249 if strict && !options.plain_text && !doc.links.is_empty() {
253 body = apply_links(&body, &doc.links);
254 }
255 let md = if body.is_empty() {
256 String::new()
257 } else {
258 format!("{body}\n")
259 };
260 (md, ctx.artifacts)
261}
262
263pub fn to_markdown_table_cell(doc: &DoclingDocument, strict: bool) -> String {
272 let mut ctx = Ctx::new(doc, strict, &MarkdownExportOptions::default());
273 ctx.in_table_cell = true;
274 ctx.artifacts_dir = String::new();
275 ctx.page_break = None;
279 let mut blocks: Vec<String> = Vec::new();
280 render(&doc.nodes, &mut blocks, &mut ctx);
281 blocks.join("\n\n")
282}
283
284fn apply_links(body: &str, links: &[(String, String)]) -> String {
292 let mut out = body.to_string();
293 let mut cursor = 0usize;
294 for (anchor, href) in links {
295 let anchor = anchor
296 .replace('&', "&")
297 .replace('<', "<")
298 .replace('>', ">");
299 if anchor.is_empty() {
300 continue;
301 }
302 if let Some(rel) = out[cursor..].find(&anchor) {
303 let at = cursor + rel;
304 let replacement = format!("[{anchor}]({href})");
306 out.replace_range(at..at + anchor.len(), &replacement);
307 cursor = at + replacement.len();
308 }
309 }
310 out
311}
312
313fn apply_links_chunk(chunk: &str, queue: &mut Vec<(String, String)>) -> String {
324 let mut out = chunk.to_string();
325 let mut cursor = 0usize;
326 let mut carried: Vec<(String, String)> = Vec::new();
327 for (anchor_raw, href) in std::mem::take(queue) {
328 let anchor = anchor_raw
329 .replace('&', "&")
330 .replace('<', "<")
331 .replace('>', ">");
332 if anchor.is_empty() {
333 continue;
334 }
335 if let Some(rel) = out[cursor..].find(&anchor) {
336 let at = cursor + rel;
337 let replacement = format!("[{anchor}]({href})");
338 out.replace_range(at..at + anchor.len(), &replacement);
339 cursor = at + replacement.len();
340 } else {
341 carried.push((anchor_raw, href));
343 }
344 }
345 *queue = carried;
346 out
347}
348
349pub struct MarkdownStreamer {
367 strict: bool,
368 images: ImageMode,
369 compact_tables: bool,
370 emitted_any: bool,
373 links: Vec<(String, String)>,
375 artifacts_dir: String,
379 artifacts: Vec<(String, Vec<u8>)>,
380 pic_index: usize,
381 page_break: Option<String>,
385 pending_page_break: bool,
386 items_emitted: bool,
392 layers: ContentLayers,
395 traverse_pictures: bool,
396 escape_html: bool,
397 escape_underscores: bool,
398 image_placeholder: String,
399 plain: bool,
400}
401
402impl MarkdownStreamer {
403 pub fn new(strict: bool, images: ImageMode, compact_tables: bool) -> Self {
406 debug_assert!(
407 images != ImageMode::Referenced,
408 "referenced image mode needs an artifacts dir; use with_artifacts"
409 );
410 Self::with_artifacts(strict, images, compact_tables, "artifacts")
411 }
412
413 pub fn with_artifacts(
420 strict: bool,
421 images: ImageMode,
422 compact_tables: bool,
423 artifacts_dir: &str,
424 ) -> Self {
425 Self {
426 strict,
427 images,
428 compact_tables,
429 emitted_any: false,
430 links: Vec::new(),
431 artifacts_dir: artifacts_dir.to_string(),
432 artifacts: Vec::new(),
433 pic_index: 0,
434 page_break: None,
435 pending_page_break: false,
436 items_emitted: false,
437 layers: ContentLayers::BODY,
438 traverse_pictures: false,
439 escape_html: true,
440 escape_underscores: true,
441 image_placeholder: "<!-- image -->".to_string(),
442 plain: false,
443 }
444 }
445
446 pub fn with_export_options(mut self, options: &MarkdownExportOptions) -> Self {
453 self.images = options.image_mode;
454 self.artifacts_dir = options.artifacts_dir.clone();
455 self.layers = options.layers;
456 self.traverse_pictures = options.traverse_pictures;
457 self.escape_html = options.escape_html;
458 self.escape_underscores = options.escape_underscores;
459 self.image_placeholder = options.image_placeholder.clone();
460 self.plain = options.plain_text;
461 self
462 }
463
464 pub fn with_page_break_placeholder(mut self, placeholder: Option<String>) -> Self {
469 self.page_break = placeholder;
470 self
471 }
472
473 pub fn take_artifacts(&mut self) -> Vec<(String, Vec<u8>)> {
478 std::mem::take(&mut self.artifacts)
479 }
480
481 pub fn push(&mut self, nodes: &[Node], links: &[(String, String)]) -> String {
486 self.links.extend(links.iter().cloned());
487 let mut ctx = Ctx {
488 strict: self.strict,
489 compact_tables: self.compact_tables,
490 images: self.images,
491 artifacts_dir: std::mem::take(&mut self.artifacts_dir),
492 artifacts: std::mem::take(&mut self.artifacts),
493 pic_index: self.pic_index,
494 in_table_cell: false,
495 page_break: std::mem::take(&mut self.page_break),
496 pending_page_break: self.pending_page_break,
497 emitted_any: self.items_emitted,
498 layers: self.layers,
499 current_layer: None,
500 traverse_pictures: self.traverse_pictures,
501 escape_html: self.escape_html,
502 escape_underscores: self.escape_underscores,
503 image_placeholder: std::mem::take(&mut self.image_placeholder),
504 plain: self.plain,
505 };
506 let mut blocks: Vec<String> = Vec::new();
507 render(nodes, &mut blocks, &mut ctx);
508 self.image_placeholder = std::mem::take(&mut ctx.image_placeholder);
509 self.artifacts_dir = std::mem::take(&mut ctx.artifacts_dir);
510 self.artifacts = std::mem::take(&mut ctx.artifacts);
511 self.pic_index = ctx.pic_index;
512 self.page_break = std::mem::take(&mut ctx.page_break);
513 self.pending_page_break = ctx.pending_page_break;
514 self.items_emitted = ctx.emitted_any;
515 if blocks.is_empty() {
516 return String::new();
517 }
518 let mut body = blocks.join("\n\n");
519 if self.strict && !self.plain && !self.links.is_empty() {
520 body = apply_links_chunk(&body, &mut self.links);
521 }
522 let chunk = if self.emitted_any {
523 format!("\n\n{body}")
524 } else {
525 body
526 };
527 self.emitted_any = true;
528 chunk
529 }
530
531 pub fn finish(self) -> String {
534 if self.emitted_any {
535 "\n".to_string()
536 } else {
537 String::new()
538 }
539 }
540}
541
542fn strict_text(text: &str, strict: bool) -> String {
550 if !strict {
551 return text.to_string();
552 }
553 text.replace("\\_", "_")
554 .replace(" ,", ",")
555 .replace(" .", ".")
556 .replace(" ;", ";")
557 .replace(" )", ")")
558 .replace("( ", "(")
559 .replace(" ]", "]")
560 .replace("[ ", "[")
561}
562
563pub(crate) fn plain_inline(text: &str) -> String {
576 if !text.contains(['*', '~', '`', '[']) {
577 return text.to_string();
578 }
579 let chars: Vec<char> = text.chars().collect();
580 let mut out = String::with_capacity(text.len());
581 plain_inline_into(&chars, &mut out);
582 out
583}
584
585fn plain_inline_into(chars: &[char], out: &mut String) {
586 let find = |open: usize, pat: &[char]| -> Option<usize> {
587 chars
588 .get(open..)?
589 .windows(pat.len())
590 .position(|w| w == pat)
591 .map(|p| open + p)
592 };
593 let span = |i: usize, pat: &[char]| -> Option<usize> {
596 if !chars[i..].starts_with(pat) {
597 return None;
598 }
599 let open = i + pat.len();
600 let end = find(open, pat)?;
601 (end > open && !chars[open].is_whitespace() && !chars[end - 1].is_whitespace())
602 .then_some(end)
603 };
604 let mut i = 0;
605 while i < chars.len() {
606 let mut matched = false;
607 for pat in [&['*', '*', '*'][..], &['*', '*'], &['~', '~'], &['*']] {
608 if let Some(end) = span(i, pat) {
609 plain_inline_into(&chars[i + pat.len()..end], out);
610 i = end + pat.len();
611 matched = true;
612 break;
613 }
614 }
615 if matched {
616 continue;
617 }
618 if chars[i] == '`' {
622 if let Some(end) = find(i + 1, &['`']).filter(|&e| {
623 e > i + 1 && !chars[i + 1].is_whitespace() && !chars[e - 1].is_whitespace()
624 }) {
625 out.extend(&chars[i + 1..end]);
626 i = end + 1;
627 continue;
628 }
629 }
630 if chars[i] == '$' {
633 if let Some(end) = find(i + 1, &['$']) {
634 out.extend(&chars[i..=end]);
635 i = end + 1;
636 continue;
637 }
638 }
639 if chars[i] == '[' {
642 if let Some(close) = closing_bracket(chars, i) {
643 if chars.get(close + 1) == Some(&'(') {
644 if let Some(endp) = crate::doclang::link_dest_end(chars, close + 2) {
645 plain_inline_into(&chars[i + 1..close], out);
646 i = endp + 1;
647 continue;
648 }
649 }
650 }
651 }
652 let c = chars[i];
655 out.push(c);
656 i += 1;
657 if matches!(c, '*' | '~') {
658 while i < chars.len() && chars[i] == c {
659 out.push(c);
660 i += 1;
661 }
662 }
663 }
664}
665
666fn closing_bracket(chars: &[char], open: usize) -> Option<usize> {
669 let mut depth = 0usize;
670 for (k, c) in chars.iter().enumerate().skip(open + 1) {
671 match c {
672 '[' => depth += 1,
673 ']' if depth == 0 => return Some(k),
674 ']' => depth -= 1,
675 _ => {}
676 }
677 }
678 None
679}
680
681fn md_line_breaks(text: &str) -> String {
687 if !text.contains('\n') {
688 return text.to_string();
689 }
690 text.split("\n\n")
691 .map(|para| para.replace('\n', " \n"))
692 .collect::<Vec<_>>()
693 .join("\n\n")
694}
695
696pub(crate) fn strip_hard_breaks(text: &str) -> String {
701 if text.contains(" \n") {
702 text.replace(" \n", "\n")
703 } else {
704 text.to_string()
705 }
706}
707
708fn heading_line_breaks(text: &str) -> String {
712 text.replace('\n', " ")
713}
714
715fn render(nodes: &[Node], blocks: &mut Vec<String>, ctx: &mut Ctx) {
716 let mut i = 0;
717 while i < nodes.len() {
718 let before = blocks.len();
719 match &nodes[i] {
720 Node::PageBreak | Node::PageInfo { .. } => {
730 if ctx.page_break.is_some() && ctx.emitted_any {
731 ctx.pending_page_break = true;
732 }
733 i += 1;
734 }
735 Node::ListItem { .. } => {
736 let start = i;
737 i += 1;
738 loop {
739 match nodes.get(i) {
740 Some(Node::ListItem { .. }) => i += 1,
741 Some(Node::Paragraph { text })
745 if text.is_empty()
746 && matches!(nodes.get(i + 1), Some(Node::ListItem { .. })) =>
747 {
748 i += 1
749 }
750 _ => break,
751 }
752 }
753 render_list_run(&nodes[start..i], blocks, ctx);
754 }
755 other => {
756 render_one(other, blocks, ctx);
757 i += 1;
758 }
759 }
760 if blocks.len() > before {
761 let kept: Vec<String> = blocks.drain(before..).filter(|b| !b.is_empty()).collect();
771 blocks.extend(kept);
772 if ctx.pending_page_break {
773 if let Some(placeholder) = &ctx.page_break {
778 blocks.insert(before, placeholder.clone());
779 }
780 ctx.pending_page_break = false;
781 }
782 ctx.emitted_any = true;
783 }
784 }
785}
786
787fn render_list_run(items: &[Node], blocks: &mut Vec<String>, ctx: &Ctx) {
793 let mut lines: Vec<String> = Vec::new();
794 let mut any_top = false;
797
798 for item in items {
799 let Node::ListItem {
800 ordered,
801 number,
802 first_in_list,
803 text,
804 level,
805 marker: orig_marker,
806 location: _,
807 dclx: _,
808 href: _,
809 layer,
810 } = item
811 else {
812 continue;
813 };
814 if !ctx.layers.contains(layer.or(ctx.current_layer)) {
818 continue;
819 }
820 let level = *level as usize;
821
822 if level == 0 {
831 if any_top && *first_in_list {
832 lines.push(String::new());
833 }
834 any_top = true;
835 }
836
837 let indent = " ".repeat(level);
838 let verbatim = orig_marker.as_deref().filter(|m| {
843 m.strip_suffix('.')
844 .is_some_and(|d| !d.is_empty() && d.chars().all(char::is_numeric))
845 });
846 let marker = match verbatim {
847 Some(m) if *ordered => m.to_string(),
848 _ if *ordered => format!("{number}."),
849 _ => "-".to_string(),
850 };
851 lines.push(format!("{indent}{marker} {}", list_item_text(text, ctx)));
852 }
853
854 if !lines.is_empty() {
857 blocks.push(lines.join("\n"));
858 }
859}
860
861fn list_item_text(text: &str, ctx: &Ctx) -> String {
869 let escaped = ctx.text(text);
870 if ctx.plain {
871 return escaped;
872 }
873 if let Some((own, tail)) = escaped.split_once('\n') {
874 if is_folded_child_tail(tail) {
875 return format!("{}\n{tail}", md_line_breaks(own));
876 }
877 }
878 md_line_breaks(&escaped)
879}
880
881fn is_folded_child_tail(tail: &str) -> bool {
888 const MARKER: &str = "<!-- image -->";
889 const FENCE: &str = "```";
890 let mut lines = tail.split('\n').peekable();
891 let mut any = false;
892 while let Some(line) = lines.next() {
893 let line = line.trim_start();
894 if line == MARKER {
895 any = true;
896 } else if line == FENCE {
897 loop {
899 match lines.next() {
900 Some(l) if l.trim_start() == FENCE => break,
901 Some(_) => {}
902 None => return false,
903 }
904 }
905 any = true;
906 } else if lines.next().map(str::trim_start) == Some(MARKER) {
907 any = true; } else {
909 return false;
910 }
911 }
912 any
913}
914
915fn render_one(node: &Node, blocks: &mut Vec<String>, ctx: &mut Ctx) {
920 match node {
921 Node::Group { .. }
923 | Node::Furniture { .. }
924 | Node::PageFurniture { .. }
925 | Node::FurnitureText { .. }
926 | Node::CommentSection { .. }
927 | Node::PictureChildren(_)
928 | Node::Commented { .. }
929 | Node::Located { .. }
930 | Node::Prov { .. }
931 | Node::Track { .. }
932 | Node::PageBreak
933 | Node::PageInfo { .. }
934 | Node::DoclangOnly(_) => {}
935 _ if !ctx.layer_visible() => return,
936 _ => {}
937 }
938 render_item(node, blocks, ctx)
939}
940
941fn render_item(node: &Node, blocks: &mut Vec<String>, ctx: &mut Ctx) {
942 match node {
943 Node::Heading { level, text } => {
944 if ctx.plain {
945 blocks.push(ctx.text(text));
948 return;
949 }
950 let text = heading_line_breaks(&ctx.text(text));
951 if ctx.in_table_cell {
952 blocks.push(text);
954 } else {
955 let hashes = "#".repeat((*level).clamp(1, 6) as usize);
956 blocks.push(format!("{hashes} {text}"));
957 }
958 }
959 Node::Paragraph { text } if text.is_empty() => {}
962 Node::Paragraph { text } => blocks.push(ctx.breaks(&ctx.text(text))),
963 Node::LabeledText { .. } => render_item(&node.labeled_as_paragraph(), blocks, ctx),
964 Node::Caption { text, .. } if text.is_empty() => {}
967 Node::Caption { text, href } => {
968 let body = ctx.breaks(&ctx.text(text));
969 blocks.push(match href {
970 Some(url) if !ctx.plain => format!("[{body}]({url})"),
971 _ => body,
972 });
973 }
974 Node::CheckboxItem { checked, text } => {
975 let mark = if *checked { "- [x] " } else { "- [ ] " };
976 blocks.push(ctx.breaks(&ctx.text(&format!("{mark}{text}"))));
977 }
978 Node::Code {
979 language,
980 text,
981 pretty,
982 ..
983 } => {
984 let lang = match language {
986 Some(l) if ctx.strict => l.as_str(),
987 _ => "",
988 };
989 let body = match pretty {
992 Some(p) if ctx.strict => p.as_str(),
993 _ => text.as_str(),
994 };
995 blocks.push(if ctx.plain {
998 body.to_string()
999 } else {
1000 format!("```{lang}\n{body}\n```")
1001 });
1002 }
1003 Node::Formula { latex, .. } => blocks.push(format!("$${latex}$$")),
1006 Node::Table(table) => {
1007 if let Some(cap) = &table.caption {
1010 if !cap.is_empty() {
1011 blocks.push(ctx.breaks(&ctx.text(cap)));
1012 }
1013 }
1014 let rendered = if ctx.plain {
1015 render_table(&ctx.plain_table(table), ctx.compact_tables)
1016 } else {
1017 render_table(table, ctx.compact_tables)
1018 };
1019 if !rendered.is_empty() {
1020 blocks.push(rendered);
1021 }
1022 }
1023 Node::Picture {
1028 caption,
1029 image,
1030 description,
1031 ..
1032 } => {
1033 if let Some(cap) = caption {
1034 if !cap.is_empty() {
1035 blocks.push(ctx.breaks(&ctx.caption(cap)));
1036 }
1037 }
1038 if let Some(desc) = description {
1039 if !desc.text.is_empty() {
1040 blocks.push(ctx.breaks(&ctx.text(&desc.text)));
1041 }
1042 }
1043 blocks.push(picture_marker(image.as_ref(), ctx));
1044 }
1045 Node::Chart {
1049 kind,
1050 table,
1051 caption,
1052 ..
1053 } => {
1054 if let Some(cap) = caption {
1055 if !cap.is_empty() {
1056 blocks.push(ctx.breaks(&ctx.caption(cap)));
1057 }
1058 }
1059 blocks.push(picture_marker(None, ctx));
1060 blocks.push(humanize_label(kind));
1061 let rendered = if ctx.plain {
1062 render_table(&ctx.plain_table(table), false)
1063 } else {
1064 render_table(table, false)
1065 };
1066 if !rendered.is_empty() {
1067 blocks.push(rendered);
1068 }
1069 }
1070 Node::DoclangOnly(_) => {}
1072 Node::Group {
1077 layer: Some(layer),
1078 children,
1079 ..
1080 } => {
1081 let outer = ctx.current_layer.replace(*layer);
1082 render(children, blocks, ctx);
1083 ctx.current_layer = outer;
1084 }
1085 Node::Group { children, .. } => render(children, blocks, ctx),
1086 Node::FieldRegion { items } => {
1087 for item in items {
1092 for part in [&item.marker, &item.key, &item.value].into_iter().flatten() {
1093 blocks.push(ctx.breaks(&ctx.text(part)));
1094 }
1095 }
1096 }
1097 Node::KeyValueGraph { .. } => blocks.push(MISSING_KEY_VALUE_ITEM.to_string()),
1100 Node::InlineGroup { md_text, .. } => blocks.push(ctx.breaks(&ctx.text(md_text))),
1103 Node::TextDump(text) => {
1105 if !text.is_empty() {
1106 blocks.push(text.clone());
1107 }
1108 }
1109 Node::Furniture { layer, inner } => {
1113 let outer = ctx.current_layer.replace(*layer);
1114 render_one(inner, blocks, ctx);
1115 ctx.current_layer = outer;
1116 }
1117 Node::PageFurniture { text, .. } | Node::FurnitureText { text, .. } => {
1121 if ctx.layers.furniture && !text.is_empty() {
1122 blocks.push(ctx.breaks(&ctx.text(text)));
1123 }
1124 }
1125 Node::PictureChildren(children) => {
1130 if ctx.traverse_pictures {
1131 render(children, blocks, ctx);
1132 }
1133 }
1134 Node::CommentSection { text, .. } => {
1139 if ctx.layers.notes && !text.is_empty() {
1140 blocks.push(ctx.breaks(&ctx.text(text)));
1141 }
1142 }
1143 Node::Commented { inner, .. } => render_one(inner, blocks, ctx),
1144 Node::Located { inner, .. } | Node::Prov { inner, .. } | Node::Track { inner, .. } => {
1146 render_one(inner, blocks, ctx)
1147 }
1148 Node::PageBreak => {}
1150 Node::PageInfo { .. } => {}
1152 Node::ListItem { .. } => render_list_run(std::slice::from_ref(node), blocks, ctx),
1157 }
1158}
1159
1160fn humanize_label(label: &str) -> String {
1165 let text = label.replace('_', " ");
1166 let mut chars = text.chars();
1167 match chars.next() {
1168 Some(f) => f.to_uppercase().collect::<String>() + chars.as_str(),
1169 None => text,
1170 }
1171}
1172
1173fn picture_marker(image: Option<&crate::PictureImage>, ctx: &mut Ctx) -> String {
1174 match (ctx.images, image) {
1175 (ImageMode::Embedded, Some(img)) => {
1177 format!("", crate::pixel_digest::docling_data_uri(img).1)
1178 }
1179 (ImageMode::Referenced, Some(img)) => {
1180 let path = format!(
1181 "{}/image_{:06}.{}",
1182 ctx.artifacts_dir,
1183 ctx.pic_index,
1184 ext_for(&img.mimetype)
1185 );
1186 ctx.pic_index += 1;
1187 ctx.artifacts.push((path.clone(), img.data.clone()));
1188 format!("", escape_uri_path(&path))
1189 }
1190 _ => ctx.image_placeholder.clone(),
1192 }
1193}
1194
1195pub(crate) fn escape_uri_path(value: &str) -> String {
1208 const KEEP: &str = "/%:@+,;=~$!&'*";
1209 let s = value.replace('\\', "/");
1210 if let Some(rest) = s.strip_prefix("//") {
1211 let rest = rest.trim_start_matches('/');
1213 let (host, tail) = rest.split_once('/').unwrap_or((rest, ""));
1214 return format!("file://{host}{}", percent_quote(&format!("/{tail}"), KEEP));
1215 }
1216 let bytes = s.as_bytes();
1217 if bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'/' {
1218 return format!("file:///{}", percent_quote(&s, KEEP));
1220 }
1221 if let Some((scheme, rest)) = s.split_once(':') {
1225 let valid_scheme = scheme.len() > 1
1226 && scheme.as_bytes()[0].is_ascii_alphabetic()
1227 && scheme
1228 .bytes()
1229 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'-' | b'.'));
1230 if valid_scheme {
1231 let (authority, rest) = match rest.strip_prefix("//") {
1232 Some(r) => {
1233 let end = r.find(['/', '?', '#']).unwrap_or(r.len());
1234 (Some(&r[..end]), &r[end..])
1235 }
1236 None => (None, rest),
1237 };
1238 let (before_frag, fragment) = rest.split_once('#').unwrap_or((rest, ""));
1239 let (path, query) = before_frag.split_once('?').unwrap_or((before_frag, ""));
1240 let mut out = format!("{scheme}:");
1241 if let Some(a) = authority {
1242 out.push_str("//");
1243 out.push_str(a);
1244 }
1245 out.push_str(&percent_quote(path, KEEP));
1246 if !query.is_empty() {
1247 out.push('?');
1248 out.push_str(&percent_quote(query, KEEP));
1249 }
1250 if !fragment.is_empty() {
1251 out.push('#');
1252 out.push_str(&percent_quote(fragment, KEEP));
1253 }
1254 return out;
1255 }
1256 }
1257 percent_quote(&s, KEEP)
1259}
1260
1261fn percent_quote(s: &str, safe: &str) -> String {
1264 let mut out = String::with_capacity(s.len());
1265 for &b in s.as_bytes() {
1266 let keep = b.is_ascii_alphanumeric()
1267 || matches!(b, b'_' | b'.' | b'-' | b'~')
1268 || (b.is_ascii() && safe.contains(b as char));
1269 if keep {
1270 out.push(b as char);
1271 } else {
1272 out.push_str(&format!("%{b:02X}"));
1273 }
1274 }
1275 out
1276}
1277
1278pub(crate) fn ext_for(mimetype: &str) -> &str {
1279 match mimetype {
1280 "image/jpeg" => "jpg",
1281 "image/gif" => "gif",
1282 "image/webp" => "webp",
1283 "image/bmp" => "bmp",
1284 "image/tiff" => "tif",
1285 _ => "png",
1286 }
1287}
1288
1289fn is_number_cell(t: &str) -> bool {
1308 t.parse::<f64>().is_ok() || is_thousands_number(t)
1309}
1310
1311fn is_thousands_number(t: &str) -> bool {
1317 let b = t.as_bytes();
1318 let mut i = 0;
1319 let start = i;
1320 if i < b.len() && (b[i] == b'+' || b[i] == b'-') {
1321 i += 1;
1322 }
1323 let d0 = i;
1325 while i < b.len() && b[i].is_ascii_digit() && i - d0 < 3 {
1326 i += 1;
1327 }
1328 let has_int = i > d0;
1329 if has_int {
1330 while i + 3 < b.len() + 1
1332 && b.get(i) == Some(&b',')
1333 && b.get(i + 1).is_some_and(u8::is_ascii_digit)
1334 && b.get(i + 2).is_some_and(u8::is_ascii_digit)
1335 && b.get(i + 3).is_some_and(u8::is_ascii_digit)
1336 {
1337 i += 4;
1338 }
1339 } else {
1340 i = start;
1342 }
1343 if i < b.len() && b[i] == b'.' {
1345 i += 1;
1346 let f0 = i;
1347 while i < b.len() && b[i].is_ascii_digit() {
1348 i += 1;
1349 }
1350 if !has_int && i == f0 {
1351 return false; }
1353 } else if !has_int {
1354 return false; }
1356 i == b.len()
1357}
1358
1359fn flatten_header_rows(header_rows: &[Vec<String>], num_cols: usize) -> Vec<String> {
1368 (0..num_cols)
1369 .map(|c| {
1370 let mut parts: Vec<&str> = Vec::new();
1371 for row in header_rows {
1372 let text = row.get(c).map(String::as_str).unwrap_or("");
1373 if !text.is_empty() && parts.last() != Some(&text) {
1374 parts.push(text);
1375 }
1376 }
1377 parts.join(" - ")
1378 })
1379 .collect()
1380}
1381
1382pub(crate) fn render_table(table: &Table, compact: bool) -> String {
1383 if table.rows.is_empty() {
1384 return String::new();
1385 }
1386 let num_cols = table.rows.iter().map(Vec::len).max().unwrap_or(0);
1387 if num_cols == 0 {
1388 return String::new();
1389 }
1390
1391 let num_headers = table.header_row_count().min(table.rows.len());
1396 let escaped = |r: usize| -> Vec<String> {
1397 (0..num_cols)
1398 .map(|c| escape_cell(table.rows[r].get(c).map(String::as_str).unwrap_or("")))
1399 .collect()
1400 };
1401 let header_rows: Vec<Vec<String>> = (0..num_headers).map(escaped).collect();
1402 let header = flatten_header_rows(&header_rows, num_cols);
1403 let body: Vec<Vec<String>> = (num_headers..table.rows.len())
1404 .map(|r| {
1405 escaped(r)
1406 .into_iter()
1407 .map(|c| c.trim().to_string())
1408 .collect()
1409 })
1410 .collect();
1411
1412 if compact {
1413 let render_row = |row: &[String]| -> String { format!("| {} |", row.join(" | ")) };
1415 let mut lines = Vec::with_capacity(body.len() + 2);
1416 lines.push(render_row(&header));
1417 let sep: Vec<&str> = (0..num_cols).map(|_| "-").collect();
1418 lines.push(format!("| {} |", sep.join(" | ")));
1419 for row in &body {
1420 lines.push(render_row(row));
1421 }
1422 return lines.join("\n");
1423 }
1424
1425 let dw = |s: &str| s.chars().count();
1427
1428 let right: Vec<bool> = (0..num_cols)
1433 .map(|c| {
1434 let mut any = false;
1435 for row in &body {
1436 let t = row[c].trim();
1437 if t.is_empty() {
1438 continue;
1439 }
1440 if !is_number_cell(t) {
1441 return false;
1442 }
1443 any = true;
1444 }
1445 any
1446 })
1447 .collect();
1448
1449 let width: Vec<usize> = (0..num_cols)
1451 .map(|c| {
1452 let mut w = dw(&header[c]) + 2;
1453 for row in &body {
1454 w = w.max(dw(&row[c]));
1455 }
1456 w
1457 })
1458 .collect();
1459
1460 let fmt_cell = |s: &str, c: usize| -> String {
1461 let pad = " ".repeat(width[c].saturating_sub(dw(s)));
1462 let body = if right[c] {
1463 format!("{pad}{s}")
1464 } else {
1465 format!("{s}{pad}")
1466 };
1467 format!(" {body} ")
1468 };
1469 let render_row = |row: &[String]| -> String {
1470 let cells: Vec<String> = (0..num_cols).map(|c| fmt_cell(&row[c], c)).collect();
1471 format!("|{}|", cells.join("|"))
1472 };
1473
1474 let mut lines = Vec::with_capacity(body.len() + 2);
1475 lines.push(render_row(&header));
1476 let sep: Vec<String> = (0..num_cols).map(|c| "-".repeat(width[c] + 2)).collect();
1477 lines.push(format!("|{}|", sep.join("|")));
1478 for row in &body {
1479 lines.push(render_row(row));
1480 }
1481 lines.join("\n")
1482}
1483
1484fn escape_cell(s: &str) -> String {
1487 s.replace('\n', " ").replace('|', "|")
1488}
1489
1490#[cfg(test)]
1491mod tests {
1492 use super::*;
1493 use crate::{PictureImage, TableCell, TableStructure};
1494
1495 #[test]
1501 fn list_boundaries_come_from_the_backend_not_the_numbering() {
1502 let item = |ordered: bool, number: u64, first_in_list: bool, text: &str| Node::ListItem {
1503 ordered,
1504 number,
1505 first_in_list,
1506 text: text.into(),
1507 level: 0,
1508 marker: None,
1509 location: None,
1510 dclx: None,
1511 href: None,
1512 layer: None,
1513 };
1514 let md = |items: Vec<Node>| {
1515 let mut doc = DoclingDocument::new("t");
1516 for n in items {
1517 doc.push(n);
1518 }
1519 doc.export_to_markdown()
1520 };
1521 assert_eq!(
1523 md(vec![
1524 item(true, 1, true, "one"),
1525 item(true, 5, false, "five")
1526 ]),
1527 "1. one\n5. five\n"
1528 );
1529 assert_eq!(
1531 md(vec![
1532 item(false, 0, true, "bullet"),
1533 item(true, 1, false, "one"),
1534 item(false, 0, false, "bullet two"),
1535 ]),
1536 "- bullet\n1. one\n- bullet two\n"
1537 );
1538 assert_eq!(
1540 md(vec![
1541 item(true, 1, true, "a"),
1542 item(true, 2, false, "b"),
1543 item(true, 3, true, "new list, continuing count"),
1544 ]),
1545 "1. a\n2. b\n\n3. new list, continuing count\n"
1546 );
1547 }
1548
1549 #[test]
1550 fn renders_headings_paragraphs_and_lists() {
1551 let mut doc = DoclingDocument::new("demo");
1552 doc.add_heading(1, "Title");
1553 doc.add_paragraph("Hello world.");
1554 doc.push(Node::ListItem {
1555 ordered: false,
1556 number: 1,
1557 first_in_list: true,
1558 text: "first".into(),
1559 level: 0,
1560 marker: None,
1561 location: None,
1562 dclx: None,
1563 href: None,
1564 layer: None,
1565 });
1566 doc.push(Node::ListItem {
1567 ordered: false,
1568 number: 2,
1569 first_in_list: false,
1570 text: "second".into(),
1571 level: 0,
1572 marker: None,
1573 location: None,
1574 dclx: None,
1575 href: None,
1576 layer: None,
1577 });
1578 let md = doc.export_to_markdown();
1579 assert_eq!(md, "# Title\n\nHello world.\n\n- first\n- second\n");
1580 }
1581
1582 #[test]
1586 fn single_newlines_become_gfm_hard_line_breaks() {
1587 let mut doc = DoclingDocument::new("t");
1588 doc.push(Node::Heading {
1589 level: 1,
1590 text: "Hello\nWorld".into(),
1591 });
1592 doc.push(Node::Paragraph {
1593 text: "line one\nline two\n\npara two".into(),
1594 });
1595 doc.push(Node::ListItem {
1596 ordered: false,
1597 number: 1,
1598 first_in_list: true,
1599 text: "item\ncontinued".into(),
1600 level: 0,
1601 marker: None,
1602 location: None,
1603 dclx: None,
1604 href: None,
1605 layer: None,
1606 });
1607 doc.push(Node::TextDump("A1 B1 \n\n\nC1".into()));
1608 assert_eq!(
1609 doc.export_to_markdown(),
1610 "# Hello World\n\nline one \nline two\n\npara two\n\n- item \ncontinued\n\nA1 B1 \n\n\nC1\n"
1611 );
1612 }
1613
1614 #[test]
1617 fn table_cell_mode_and_field_regions() {
1618 let mut doc = DoclingDocument::new("t");
1619 doc.push(Node::Heading {
1620 level: 2,
1621 text: "A text".into(),
1622 });
1623 doc.push(Node::Paragraph {
1624 text: "body".into(),
1625 });
1626 assert_eq!(to_markdown_table_cell(&doc, false), "A text\n\nbody");
1627 assert_eq!(doc.export_to_markdown(), "## A text\n\nbody\n");
1628
1629 let mut doc = DoclingDocument::new("f");
1630 doc.push(Node::FieldRegion {
1631 items: vec![crate::FieldItem {
1632 marker: None,
1633 key: Some("Name:".into()),
1634 value: Some("John Doe".into()),
1635 value_kind: None,
1636 }],
1637 });
1638 assert_eq!(doc.export_to_markdown(), "Name:\n\nJohn Doe\n");
1639 }
1640
1641 #[test]
1642 fn strict_renders_recovered_links_legacy_does_not() {
1643 let mut doc = DoclingDocument::new("cv");
1644 doc.add_paragraph("Find me on LinkedIn or GitHub.");
1645 doc.links = vec![
1646 ("LinkedIn".into(), "https://www.linkedin.com/in/x/".into()),
1647 ("GitHub".into(), "https://github.com/x/".into()),
1648 ];
1649 assert_eq!(doc.export_to_markdown(), "Find me on LinkedIn or GitHub.\n");
1651 assert_eq!(
1653 doc.export_to_markdown_with(true),
1654 "Find me on [LinkedIn](https://www.linkedin.com/in/x/) or [GitHub](https://github.com/x/).\n"
1655 );
1656 }
1657
1658 #[test]
1659 fn strict_links_match_escaped_anchor_and_consume_in_order() {
1660 let mut doc = DoclingDocument::new("d");
1661 doc.add_paragraph("AI & ML here, and issues here, then issues there.");
1665 doc.links = vec![
1666 ("AI & ML".into(), "https://a/".into()),
1667 ("issues".into(), "https://first/".into()),
1668 ("issues".into(), "https://second/".into()),
1669 ];
1670 assert_eq!(
1671 doc.export_to_markdown_with(true),
1672 "[AI & ML](https://a/) here, and [issues](https://first/) here, then [issues](https://second/) there.\n"
1673 );
1674 }
1675
1676 #[test]
1680 fn referenced_image_destinations_are_escaped() {
1681 let cases = [
1682 (
1683 "doc_artifacts/image_000001_ab12.png",
1684 "doc_artifacts/image_000001_ab12.png",
1685 ),
1686 (
1687 "My Report_artifacts/img.png",
1688 "My%20Report_artifacts/img.png",
1689 ),
1690 ("artifacts/img (1).png", "artifacts/img%20%281%29.png"),
1691 ("100%_scale/a#b?c.png", "100%_scale/a%23b%3Fc.png"),
1692 ("/home/a b/img.png", "/home/a%20b/img.png"),
1693 (
1694 "My Report_artifacts\\img.png",
1695 "My%20Report_artifacts/img.png",
1696 ),
1697 (
1698 "C:/Users/me/My Docs/img.png",
1699 "file:///C:/Users/me/My%20Docs/img.png",
1700 ),
1701 ("C:\\Users\\me\\img.png", "file:///C:/Users/me/img.png"),
1702 (
1703 "//server/share/My Docs/img.png",
1704 "file://server/share/My%20Docs/img.png",
1705 ),
1706 ("\\\\server\\share\\img.png", "file://server/share/img.png"),
1707 ("file:///home/a b/img.png", "file:///home/a%20b/img.png"),
1708 (
1709 "s3://bucket/My Report_artifacts/img.png",
1710 "s3://bucket/My%20Report_artifacts/img.png",
1711 ),
1712 (
1713 "https://example.com:8080/a b.png?w=1&h=2#frag",
1714 "https://example.com:8080/a%20b.png?w=1&h=2#frag",
1715 ),
1716 (
1717 "https://example.com/img (1).png",
1718 "https://example.com/img%20%281%29.png",
1719 ),
1720 ("caf\u{e9}/im\u{e4}ge.png", "caf%C3%A9/im%C3%A4ge.png"),
1721 ];
1722 for (input, expected) in cases {
1723 assert_eq!(escape_uri_path(input), expected, "input {input:?}");
1724 assert_eq!(
1725 escape_uri_path(expected),
1726 expected,
1727 "idempotent {expected:?}"
1728 );
1729 }
1730 let mut doc = DoclingDocument::new("t");
1732 doc.push(Node::Picture {
1733 caption: None,
1734 caption_href: None,
1735 image: Some(PictureImage {
1736 dpi: PictureImage::DEFAULT_DPI,
1737 mimetype: "image/png".into(),
1738 width: 1,
1739 height: 1,
1740 data: b"x".to_vec(),
1741 }),
1742 classification: None,
1743 description: None,
1744 caption_parent: Default::default(),
1745 caption_location: None,
1746 });
1747 let (md, files) = doc
1748 .export_to_markdown_with_images(ImageMode::Referenced, "My Report (final)_artifacts");
1749 assert!(
1750 md.contains(""),
1751 "got:\n{md}"
1752 );
1753 assert_eq!(files[0].0, "My Report (final)_artifacts/image_000000.png");
1755 }
1756
1757 #[test]
1761 fn folded_list_item_pictures_keep_plain_newlines() {
1762 let doc = DoclingDocument::new("t");
1763 let ctx = Ctx::new(&doc, false, &MarkdownExportOptions::default());
1764 assert_eq!(
1765 list_item_text("Step\n<!-- image -->", &ctx),
1766 "Step\n<!-- image -->"
1767 );
1768 assert_eq!(
1769 list_item_text("Step\nAlt text\n<!-- image -->\n<!-- image -->", &ctx),
1770 "Step\nAlt text\n<!-- image -->\n<!-- image -->"
1771 );
1772 assert_eq!(
1773 list_item_text("line one\nline two", &ctx),
1774 "line one \nline two"
1775 );
1776 }
1777
1778 #[test]
1781 fn stacked_header_rows_flatten_into_one() {
1782 let mut t = Table {
1783 rows: vec![
1784 vec!["".into(), "% of Total".into(), "% of Total".into()],
1785 vec!["class".into(), "Train".into(), "Test".into()],
1786 vec!["Caption".into(), "2.04".into(), "1.77".into()],
1787 ],
1788 ..Default::default()
1789 };
1790 t.structure = Some(TableStructure {
1791 header_row: vec![true, true, false],
1792 col_continuation: vec![
1793 vec![false, false, true],
1794 vec![false, false, false],
1795 vec![false, false, false],
1796 ],
1797 ..Default::default()
1798 });
1799 assert_eq!(t.header_row_count(), 2);
1800 assert_eq!(
1801 render_table(&t, true),
1802 "| class | % of Total - Train | % of Total - Test |\n| - | - | - |\n| Caption | 2.04 | 1.77 |"
1803 );
1804 assert_eq!(
1806 render_table(&t, false),
1807 "| class | % of Total - Train | % of Total - Test |\n\
1808 |---------|----------------------|---------------------|\n\
1809 | Caption | 2.04 | 1.77 |"
1810 );
1811 }
1812
1813 #[test]
1816 fn vertically_spanning_header_does_not_extend_the_block() {
1817 let mut t = Table {
1818 rows: vec![
1819 vec!["Name".into(), "Value".into()],
1820 vec!["Name".into(), "1".into()],
1821 vec!["x".into(), "2".into()],
1822 ],
1823 ..Default::default()
1824 };
1825 t.structure = Some(TableStructure {
1826 col_header: vec![vec![true, true], vec![true, false], vec![false, false]],
1827 row_continuation: vec![vec![false, false], vec![true, false], vec![false, false]],
1828 ..Default::default()
1829 });
1830 assert_eq!(t.header_row_count(), 1);
1831 assert_eq!(
1832 render_table(&t, true),
1833 "| Name | Value |\n| - | - |\n| Name | 1 |\n| x | 2 |"
1834 );
1835 }
1836
1837 #[test]
1840 fn header_flags_not_on_row_zero_keep_all_rows_in_the_body() {
1841 let mut t = Table {
1842 rows: vec![
1843 vec!["1".into(), "2".into()],
1844 vec!["a".into(), "b".into()],
1845 vec!["333".into(), "4".into()],
1846 ],
1847 ..Default::default()
1848 };
1849 t.structure = Some(TableStructure {
1850 header_row: vec![false, true, false],
1851 ..Default::default()
1852 });
1853 assert_eq!(t.header_row_count(), 0);
1854 assert_eq!(
1855 render_table(&t, false),
1856 "| | |\n|-----|----|\n| 1 | 2 |\n| a | b |\n| 333 | 4 |"
1857 );
1858 }
1859
1860 #[test]
1865 fn pivot_row_headers_do_not_extend_the_header() {
1866 let mut t = Table {
1867 rows: vec![
1868 vec!["Year".into(), "Month".into()],
1869 vec!["2025".into(), "January".into()],
1870 vec!["2025".into(), "February".into()],
1871 ],
1872 ..Default::default()
1873 };
1874 t.structure = Some(TableStructure {
1875 col_header: vec![vec![true, true], vec![false, false], vec![false, false]],
1876 row_header: vec![vec![false, false], vec![true, false], vec![true, false]],
1877 row_continuation: vec![vec![false, false], vec![false, false], vec![true, false]],
1878 ..Default::default()
1879 });
1880 assert_eq!(t.header_row_count(), 1);
1881 assert_eq!(
1882 render_table(&t, true),
1883 "| Year | Month |\n| - | - |\n| 2025 | January |\n| 2025 | February |"
1884 );
1885 }
1886
1887 #[test]
1890 fn unflagged_cells_keep_row_zero_as_header() {
1891 let mut t = Table {
1892 rows: vec![vec!["h".into()], vec!["d".into()]],
1893 ..Default::default()
1894 };
1895 t.cells = Some(
1896 [(0usize, "h"), (1, "d")]
1897 .into_iter()
1898 .map(|(r, text)| TableCell {
1899 text: text.into(),
1900 bbox: None,
1901 start_row: r,
1902 start_col: 0,
1903 row_span: 1,
1904 col_span: 1,
1905 column_header: false,
1906 row_header: false,
1907 row_section: false,
1908 })
1909 .collect(),
1910 );
1911 assert_eq!(t.header_row_count(), 1);
1912 assert_eq!(render_table(&t, true), "| h |\n| - |\n| d |");
1913 }
1914
1915 fn flagged_table(rows: &[&[&str]], ched: impl Fn(usize, usize) -> bool) -> Table {
1917 let mut cells = Vec::new();
1918 for (r, row) in rows.iter().enumerate() {
1919 for (c, text) in row.iter().enumerate() {
1920 cells.push(TableCell {
1921 text: (*text).into(),
1922 bbox: None,
1923 start_row: r,
1924 start_col: c,
1925 row_span: 1,
1926 col_span: 1,
1927 column_header: ched(r, c),
1928 row_header: false,
1929 row_section: false,
1930 });
1931 }
1932 }
1933 Table {
1934 rows: rows
1935 .iter()
1936 .map(|row| row.iter().map(|t| (*t).to_string()).collect())
1937 .collect(),
1938 cells: Some(cells),
1939 ..Default::default()
1940 }
1941 }
1942
1943 #[test]
1947 fn rows_with_body_text_stop_the_header_block() {
1948 let t = flagged_table(
1950 &[
1951 &["Item", "Q1", "Q2"],
1952 &["Sales", "10", "20"],
1953 &["Costs", "5", "7"],
1954 ],
1955 |r, c| r == 0 || c == 0,
1956 );
1957 assert_eq!(t.header_row_count(), 1);
1958 assert_eq!(
1959 render_table(&t, false),
1960 "| Item | Q1 | Q2 |\n\
1961 |--------|------|------|\n\
1962 | Sales | 10 | 20 |\n\
1963 | Costs | 5 | 7 |"
1964 );
1965 let t = flagged_table(&[&["Sales", "10", "20"], &["Costs", "5", "7"]], |_, c| {
1968 c == 0
1969 });
1970 assert_eq!(t.header_row_count(), 0);
1971 assert_eq!(
1972 render_table(&t, false),
1973 "| | | |\n\
1974 |-------|----|----|\n\
1975 | Sales | 10 | 20 |\n\
1976 | Costs | 5 | 7 |"
1977 );
1978 let t = flagged_table(
1979 &[&["a", "1", "2"], &["b", "3", "4"], &["c", "5", "6"]],
1980 |r, c| c == 0 && r < 2,
1981 );
1982 assert_eq!(t.header_row_count(), 0);
1983 let t = flagged_table(&[&["a", "1", "2"], &["b", "3", "4"]], |r, c| {
1986 r == 0 && c == 0
1987 });
1988 assert_eq!(t.header_row_count(), 1);
1989 assert_eq!(
1990 render_table(&t, false),
1991 "| a | 1 | 2 |\n\
1992 |-----|-----|-----|\n\
1993 | b | 3 | 4 |"
1994 );
1995 let t = flagged_table(&[&["Item", "", ""], &["x", "1", "2"]], |_, c| c == 0);
1997 assert_eq!(t.header_row_count(), 1);
1998 assert_eq!(
1999 render_table(&t, false),
2000 "| Item | | |\n\
2001 |--------|----|----|\n\
2002 | x | 1 | 2 |"
2003 );
2004 }
2005
2006 #[test]
2007 fn renders_compact_table() {
2008 let mut doc = DoclingDocument::new("t");
2009 doc.compact_tables = true;
2012 doc.push(Node::Table(Table {
2013 rows: vec![vec!["a".into(), "b".into()], vec!["1".into(), "2".into()]],
2014 location: None,
2015 structure: None,
2016 cell_blocks: None,
2017 cells: None,
2018 caption: None,
2019 caption_parent: Default::default(),
2020 caption_location: None,
2021 }));
2022 let md = doc.export_to_markdown();
2023 assert_eq!(md, "| a | b |\n| - | - |\n| 1 | 2 |\n");
2024 }
2025
2026 #[test]
2027 fn renders_padded_github_table_by_default() {
2028 let mut doc = DoclingDocument::new("t");
2029 doc.push(Node::Table(Table {
2030 rows: vec![vec!["a".into(), "b".into()], vec!["1".into(), "2".into()]],
2031 location: None,
2032 structure: None,
2033 cell_blocks: None,
2034 cells: None,
2035 caption: None,
2036 caption_parent: Default::default(),
2037 caption_location: None,
2038 }));
2039 let md = doc.export_to_markdown();
2040 assert_eq!(md, "| a | b |\n|-----|-----|\n| 1 | 2 |\n");
2042 }
2043
2044 #[test]
2045 fn strict_unescapes_inline_underscores_legacy_keeps_them() {
2046 let mut doc = DoclingDocument::new("t");
2047 doc.add_heading(1, "a\\_b");
2048 doc.add_paragraph("x\\_y");
2049 doc.push(Node::ListItem {
2050 ordered: false,
2051 number: 1,
2052 first_in_list: true,
2053 text: "i\\_j".into(),
2054 level: 0,
2055 marker: None,
2056 location: None,
2057 dclx: None,
2058 href: None,
2059 layer: None,
2060 });
2061 assert_eq!(doc.export_to_markdown(), "# a\\_b\n\nx\\_y\n\n- i\\_j\n");
2063 assert_eq!(doc.export_to_markdown_with(true), "# a_b\n\nx_y\n\n- i_j\n");
2065 }
2066
2067 fn assert_stream_matches(
2070 doc: &DoclingDocument,
2071 strict: bool,
2072 images: ImageMode,
2073 splits: &[usize],
2074 ) {
2075 let (want, want_artifacts) = to_markdown_images(doc, strict, images, "artifacts");
2076 let mut streamer =
2077 MarkdownStreamer::with_artifacts(strict, images, doc.compact_tables, "artifacts")
2078 .with_page_break_placeholder(doc.page_break_placeholder.clone());
2079 let mut got = String::new();
2080 let mut got_artifacts = Vec::new();
2081 let mut start = 0;
2082 for &end in splits {
2083 let links = if start == 0 {
2086 doc.links.as_slice()
2087 } else {
2088 &[]
2089 };
2090 got.push_str(&streamer.push(&doc.nodes[start..end], links));
2091 got_artifacts.extend(streamer.take_artifacts());
2094 start = end;
2095 }
2096 got.push_str(&streamer.push(
2097 &doc.nodes[start..],
2098 if start == 0 {
2099 doc.links.as_slice()
2100 } else {
2101 &[]
2102 },
2103 ));
2104 got_artifacts.extend(streamer.take_artifacts());
2105 got.push_str(&streamer.finish());
2106 assert_eq!(
2107 got, want,
2108 "streamed output diverged (splits={splits:?}, strict={strict})"
2109 );
2110 assert_eq!(
2111 got_artifacts, want_artifacts,
2112 "streamed artifacts diverged (splits={splits:?}, strict={strict})"
2113 );
2114 }
2115
2116 #[test]
2117 fn streaming_is_byte_identical_to_buffered() {
2118 let mut doc = DoclingDocument::new("d");
2119 doc.add_heading(1, "Title");
2120 doc.add_paragraph("First paragraph.");
2121 doc.push(Node::ListItem {
2122 ordered: false,
2123 number: 1,
2124 first_in_list: true,
2125 text: "a".into(),
2126 level: 0,
2127 marker: None,
2128 location: None,
2129 dclx: None,
2130 href: None,
2131 layer: None,
2132 });
2133 doc.push(Node::ListItem {
2134 ordered: false,
2135 number: 2,
2136 first_in_list: false,
2137 text: "b".into(),
2138 level: 0,
2139 marker: None,
2140 location: None,
2141 dclx: None,
2142 href: None,
2143 layer: None,
2144 });
2145 doc.push(Node::Code {
2146 language: Some("rust".into()),
2147 text: "let x = 1;".into(),
2148 orig: None,
2149 pretty: None,
2150 });
2151 doc.push(Node::Table(Table {
2152 rows: vec![vec!["a".into(), "b".into()], vec!["1".into(), "2".into()]],
2153 location: None,
2154 structure: None,
2155 cell_blocks: None,
2156 cells: None,
2157 caption: None,
2158 caption_parent: Default::default(),
2159 caption_location: None,
2160 }));
2161 doc.push(Node::Picture {
2162 caption: Some("Fig 1".into()),
2163 caption_href: None,
2164 image: Some(PictureImage {
2165 dpi: PictureImage::DEFAULT_DPI,
2166 mimetype: "image/png".into(),
2167 width: 2,
2168 height: 2,
2169 data: b"png-one".to_vec(),
2170 }),
2171 classification: None,
2172 description: None,
2173 caption_parent: Default::default(),
2174 caption_location: None,
2175 });
2176 doc.add_paragraph("Last paragraph.");
2177 doc.push(Node::Picture {
2180 caption: None,
2181 caption_href: None,
2182 image: Some(PictureImage {
2183 dpi: PictureImage::DEFAULT_DPI,
2184 mimetype: "image/png".into(),
2185 width: 2,
2186 height: 2,
2187 data: b"png-two".to_vec(),
2188 }),
2189 classification: None,
2190 description: None,
2191 caption_parent: Default::default(),
2192 caption_location: None,
2193 });
2194
2195 for &strict in &[false, true] {
2198 for &images in &[
2199 ImageMode::Placeholder,
2200 ImageMode::Embedded,
2201 ImageMode::Referenced,
2202 ] {
2203 for splits in [&[][..], &[1][..], &[2][..], &[4][..], &[1, 4, 6, 7][..]] {
2204 assert_stream_matches(&doc, strict, images, splits);
2205 }
2206 }
2207 }
2208 }
2209
2210 #[test]
2211 fn streaming_applies_recovered_links_in_strict_mode() {
2212 let mut doc = DoclingDocument::new("d");
2213 doc.add_paragraph("See LinkedIn for details.");
2214 doc.add_paragraph("And GitHub too.");
2215 doc.links = vec![
2216 ("LinkedIn".into(), "https://lnkd/".into()),
2217 ("GitHub".into(), "https://gh/".into()),
2218 ];
2219 assert_stream_matches(&doc, true, ImageMode::Placeholder, &[1]);
2222 }
2223
2224 fn paged_doc() -> DoclingDocument {
2227 let mut doc = DoclingDocument::new("p");
2228 doc.push(Node::PageInfo {
2229 page_no: 1,
2230 width: 100.0,
2231 height: 100.0,
2232 });
2233 doc.add_heading(1, "Title");
2234 doc.add_paragraph("Page one.");
2235 doc.push(Node::PageBreak);
2237 doc.push(Node::PageInfo {
2238 page_no: 2,
2239 width: 100.0,
2240 height: 100.0,
2241 });
2242 doc.push(Node::PageFurniture {
2243 footer: true,
2244 location: [0, 500, 511, 511],
2245 text: "2".into(),
2246 });
2247 doc.push(Node::PageBreak);
2248 doc.push(Node::PageInfo {
2249 page_no: 3,
2250 width: 100.0,
2251 height: 100.0,
2252 });
2253 doc.add_paragraph("Page three.");
2254 doc.push(Node::PageBreak);
2256 doc
2257 }
2258
2259 #[test]
2260 fn page_break_placeholder_lands_between_pages_only() {
2261 let mut doc = paged_doc();
2262 assert_eq!(
2264 doc.export_to_markdown(),
2265 "# Title\n\nPage one.\n\nPage three.\n"
2266 );
2267 doc.page_break_placeholder = Some("<!-- page break -->".into());
2268 assert_eq!(
2272 doc.export_to_markdown(),
2273 "# Title\n\nPage one.\n\n<!-- page break -->\n\nPage three.\n"
2274 );
2275 doc.page_break_placeholder = Some(String::new());
2278 assert_eq!(
2279 doc.export_to_markdown(),
2280 "# Title\n\nPage one.\n\n\n\nPage three.\n"
2281 );
2282 }
2283
2284 #[test]
2285 fn page_break_placeholder_never_leads_a_single_page() {
2286 let mut doc = DoclingDocument::new("one");
2287 doc.page_break_placeholder = Some("---".into());
2288 doc.push(Node::PageBreak);
2289 doc.push(Node::PageInfo {
2290 page_no: 1,
2291 width: 10.0,
2292 height: 10.0,
2293 });
2294 doc.add_paragraph("Only page.");
2295 assert_eq!(doc.export_to_markdown(), "Only page.\n");
2296 doc.push(Node::PageBreak);
2298 doc.push(Node::PageBreak);
2299 doc.add_paragraph("Next.");
2300 assert_eq!(doc.export_to_markdown(), "Only page.\n\n---\n\nNext.\n");
2301 }
2302
2303 #[test]
2304 fn page_break_placeholder_streams_byte_identical() {
2305 let mut doc = paged_doc();
2306 doc.page_break_placeholder = Some("<!-- page break -->".into());
2307 for splits in [
2311 &[3usize][..],
2312 &[3, 6],
2313 &[3, 6, 8],
2314 &[1, 2, 3, 4, 5, 6, 7, 8, 9],
2315 &[8],
2316 ] {
2317 assert_stream_matches(&doc, false, ImageMode::Placeholder, splits);
2318 assert_stream_matches(&doc, true, ImageMode::Placeholder, splits);
2319 }
2320 }
2321
2322 #[test]
2323 fn strict_tightens_punctuation_spacing_legacy_keeps_it() {
2324 let mut doc = DoclingDocument::new("t");
2325 doc.add_paragraph("see [ 37 , 36 ] and ( x ) .");
2326 assert_eq!(doc.export_to_markdown(), "see [ 37 , 36 ] and ( x ) .\n");
2328 assert_eq!(doc.export_to_markdown_with(true), "see [37, 36] and (x).\n");
2330 }
2331
2332 fn options_doc() -> DoclingDocument {
2337 use crate::document::ContentLayer;
2338 let mut doc = DoclingDocument::new("opts");
2339 doc.push(Node::PageFurniture {
2340 footer: false,
2341 location: [0, 0, 0, 0],
2342 text: "FAX COVER \\_ R&D".into(),
2343 });
2344 doc.add_heading(1, "R&D \\_ report");
2345 doc.add_paragraph("Body a\\_b <tag>");
2346 doc.push(Node::Picture {
2347 caption: Some("Figure 1".into()),
2348 caption_href: None,
2349 image: None,
2350 classification: None,
2351 description: None,
2352 caption_parent: Default::default(),
2353 caption_location: None,
2354 });
2355 doc.push(Node::PictureChildren(vec![
2356 Node::Paragraph {
2357 text: "Field: Name".into(),
2358 },
2359 Node::Paragraph {
2360 text: "Field: Date".into(),
2361 },
2362 ]));
2363 doc.push(Node::CommentSection {
2364 name: "comment-1".into(),
2365 text: "[author: A]: note".into(),
2366 refs_note_text: false,
2367 grouped: true,
2368 });
2369 doc.push(Node::Group {
2370 label: "section".into(),
2371 name: Some("sheet: Hidden".into()),
2372 layer: Some(ContentLayer::Invisible),
2373 children: vec![Node::Paragraph {
2374 text: "hidden cell".into(),
2375 }],
2376 });
2377 doc.push(Node::Furniture {
2378 layer: ContentLayer::Furniture,
2379 inner: Box::new(Node::Paragraph {
2380 text: "Page 1 of 2".into(),
2381 }),
2382 });
2383 doc.push(Node::FurnitureText {
2384 label: "page_footer".into(),
2385 text: "footer line".into(),
2386 });
2387 doc
2388 }
2389
2390 #[test]
2394 fn default_options_are_the_default_export() {
2395 let doc = options_doc();
2396 let plain = doc.export_to_markdown();
2397 assert_eq!(
2398 plain,
2399 "# R&D \\_ report\n\nBody a\\_b <tag>\n\nFigure 1\n\n<!-- image -->\n"
2400 );
2401 let (with_default, artifacts) =
2402 doc.export_to_markdown_with_options(&MarkdownExportOptions::default());
2403 assert_eq!(with_default, plain);
2404 assert!(artifacts.is_empty());
2405 }
2406
2407 #[test]
2408 fn layers_render_furniture_notes_and_hidden_sheets() {
2409 use crate::document::ContentLayer;
2410 let doc = options_doc();
2411 let md = |layers: ContentLayers| {
2412 doc.export_to_markdown_with_options(&MarkdownExportOptions {
2413 layers,
2414 ..MarkdownExportOptions::default()
2415 })
2416 .0
2417 };
2418 assert_eq!(
2419 md(ContentLayers::BODY.with(ContentLayer::Furniture)),
2420 "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",
2421 "a page header, a furniture-wrapped paragraph and a furniture text item render as paragraphs"
2422 );
2423 assert_eq!(
2424 md(ContentLayers::BODY.with(ContentLayer::Notes)),
2425 "# R&D \\_ report\n\nBody a\\_b <tag>\n\nFigure 1\n\n<!-- image -->\n\n[author: A]: note\n"
2426 );
2427 assert_eq!(
2428 md(ContentLayers::BODY.with(ContentLayer::Invisible)),
2429 "# R&D \\_ report\n\nBody a\\_b <tag>\n\nFigure 1\n\n<!-- image -->\n\nhidden cell\n",
2430 "a hidden sheet's items sit on the sheet's layer"
2431 );
2432 assert_eq!(
2433 md(ContentLayers::NONE.with(ContentLayer::Furniture)),
2434 "FAX COVER \\_ R&D\n\nPage 1 of 2\n\nfooter line\n",
2435 "without the body layer only the furniture prints"
2436 );
2437 assert_eq!(md(ContentLayers::NONE), "");
2438 assert_eq!(
2439 md(ContentLayers::ALL),
2440 "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"
2441 );
2442 }
2443
2444 #[test]
2447 fn layers_apply_to_list_items() {
2448 use crate::document::ContentLayer;
2449 let mut doc = DoclingDocument::new("lists");
2450 doc.push(Node::ListItem {
2451 ordered: false,
2452 number: 1,
2453 first_in_list: true,
2454 text: "nav link".into(),
2455 level: 0,
2456 marker: None,
2457 location: None,
2458 dclx: None,
2459 href: None,
2460 layer: Some(ContentLayer::Furniture),
2461 });
2462 doc.add_paragraph("body");
2463 assert_eq!(doc.export_to_markdown(), "body\n");
2464 let (md, _) = doc.export_to_markdown_with_options(&MarkdownExportOptions {
2465 layers: ContentLayers::BODY.with(ContentLayer::Furniture),
2466 ..MarkdownExportOptions::default()
2467 });
2468 assert_eq!(md, "- nav link\n\nbody\n");
2469 }
2470
2471 #[test]
2472 fn traverse_pictures_prints_the_nested_text_after_the_picture() {
2473 let doc = options_doc();
2474 let (md, _) = doc.export_to_markdown_with_options(&MarkdownExportOptions {
2475 traverse_pictures: true,
2476 ..MarkdownExportOptions::default()
2477 });
2478 assert_eq!(
2479 md,
2480 "# R&D \\_ report\n\nBody a\\_b <tag>\n\nFigure 1\n\n<!-- image -->\n\nField: Name\n\nField: Date\n"
2481 );
2482 }
2483
2484 #[test]
2485 fn escaping_can_be_turned_off_per_kind() {
2486 let doc = options_doc();
2487 let (md, _) = doc.export_to_markdown_with_options(&MarkdownExportOptions {
2488 escape_html: false,
2489 ..MarkdownExportOptions::default()
2490 });
2491 assert_eq!(
2492 md,
2493 "# R&D \\_ report\n\nBody a\\_b <tag>\n\nFigure 1\n\n<!-- image -->\n"
2494 );
2495 let (md, _) = doc.export_to_markdown_with_options(&MarkdownExportOptions {
2496 escape_underscores: false,
2497 ..MarkdownExportOptions::default()
2498 });
2499 assert_eq!(
2500 md,
2501 "# R&D _ report\n\nBody a_b <tag>\n\nFigure 1\n\n<!-- image -->\n"
2502 );
2503 let mut doc = DoclingDocument::new("code");
2506 doc.push(Node::Code {
2507 language: None,
2508 text: "a & b \\_ c".into(),
2509 orig: None,
2510 pretty: None,
2511 });
2512 let (md, _) = doc.export_to_markdown_with_options(&MarkdownExportOptions {
2513 escape_html: false,
2514 escape_underscores: false,
2515 ..MarkdownExportOptions::default()
2516 });
2517 assert_eq!(md, "```\na & b \\_ c\n```\n");
2518 }
2519
2520 #[test]
2521 fn image_placeholder_is_configurable() {
2522 let doc = options_doc();
2523 let (md, _) = doc.export_to_markdown_with_options(&MarkdownExportOptions {
2524 image_placeholder: "[figure]".into(),
2525 ..MarkdownExportOptions::default()
2526 });
2527 assert!(md.contains("Figure 1\n\n[figure]\n"), "{md}");
2528 assert!(!md.contains("<!-- image -->"));
2529 }
2530
2531 #[test]
2537 fn empty_image_placeholder_prints_nothing() {
2538 fn picture(caption: Option<&str>) -> Node {
2539 Node::Picture {
2540 caption: caption.map(Into::into),
2541 caption_href: None,
2542 image: None,
2543 classification: None,
2544 description: None,
2545 caption_parent: Default::default(),
2546 caption_location: None,
2547 }
2548 }
2549 fn page(no: usize) -> Node {
2550 Node::PageInfo {
2551 page_no: no,
2552 width: 100.0,
2553 height: 100.0,
2554 }
2555 }
2556 fn para(text: &str) -> Node {
2557 Node::Paragraph { text: text.into() }
2558 }
2559 let options = MarkdownExportOptions {
2560 image_placeholder: String::new(),
2561 ..MarkdownExportOptions::default()
2562 };
2563 let cases: Vec<(Vec<Node>, Option<&str>, &str)> = vec![
2565 (
2566 vec![page(1), para("a"), picture(None), para("b")],
2567 None,
2568 "a\n\nb\n",
2569 ),
2570 (
2571 vec![page(1), para("a"), picture(Some("Figure 1")), para("b")],
2572 None,
2573 "a\n\nFigure 1\n\nb\n",
2574 ),
2575 (
2576 vec![
2577 page(1),
2578 para("a"),
2579 page(2),
2580 picture(None),
2581 page(3),
2582 para("b"),
2583 ],
2584 Some("<!-- pb -->"),
2585 "a\n\n<!-- pb -->\n\n<!-- pb -->\n\nb\n",
2586 ),
2587 (
2588 vec![
2589 page(1),
2590 para("a"),
2591 page(2),
2592 picture(None),
2593 page(3),
2594 para("b"),
2595 ],
2596 Some(""),
2597 "a\n\n\n\n\n\nb\n",
2598 ),
2599 (
2600 vec![page(1), picture(None), page(2), para("b")],
2601 Some("<!-- pb -->"),
2602 "<!-- pb -->\n\nb\n",
2603 ),
2604 (
2605 vec![page(1), picture(None), page(2), para("b")],
2606 Some(""),
2607 "\n\nb\n",
2608 ),
2609 (
2610 vec![page(1), para("a"), page(2), picture(None)],
2611 Some("<!-- pb -->"),
2612 "a\n\n<!-- pb -->\n",
2613 ),
2614 ];
2615 for (nodes, placeholder, expected) in cases {
2616 let mut doc = DoclingDocument::new("t");
2617 doc.page_break_placeholder = placeholder.map(Into::into);
2618 for node in nodes {
2619 doc.push(node);
2620 }
2621 let (md, _) = doc.export_to_markdown_with_options(&options);
2622 assert_eq!(md, expected, "page break {placeholder:?}");
2623 for size in 1..=doc.nodes.len() {
2626 let mut streamer = MarkdownStreamer::new(false, ImageMode::Placeholder, false)
2627 .with_export_options(&options)
2628 .with_page_break_placeholder(placeholder.map(Into::into));
2629 let mut out = String::new();
2630 for chunk in doc.nodes.chunks(size) {
2631 out.push_str(&streamer.push(chunk, &[]));
2632 }
2633 out.push_str(&streamer.finish());
2634 assert_eq!(
2635 out, expected,
2636 "chunks of {size}, page break {placeholder:?}"
2637 );
2638 }
2639 }
2640 let mut doc = DoclingDocument::new("t");
2642 doc.push(para("a"));
2643 doc.push(picture(None));
2644 doc.push(para("b"));
2645 assert_eq!(doc.export_to_markdown(), "a\n\n<!-- image -->\n\nb\n");
2646 }
2647
2648 #[test]
2651 fn streamer_matches_buffered_export_with_options() {
2652 let doc = options_doc();
2653 let options = MarkdownExportOptions {
2654 layers: ContentLayers::ALL,
2655 traverse_pictures: true,
2656 escape_html: false,
2657 escape_underscores: false,
2658 image_placeholder: "(img)".into(),
2659 ..MarkdownExportOptions::default()
2660 };
2661 let (buffered, _) = doc.export_to_markdown_with_options(&options);
2662 let mut streamer = MarkdownStreamer::new(false, ImageMode::Placeholder, false)
2663 .with_export_options(&options);
2664 let mut out = String::new();
2665 for chunk in doc.nodes.chunks(2) {
2666 out.push_str(&streamer.push(chunk, &[]));
2667 }
2668 out.push_str(&streamer.finish());
2669 assert_eq!(out, buffered);
2670 assert!(
2671 buffered.contains("(img)") && buffered.contains("R&D _ report"),
2672 "{buffered}"
2673 );
2674 }
2675
2676 #[test]
2679 fn plain_text_drops_the_markdown_decoration() {
2680 let item = |text: &str, ordered: bool, number: u64, level: u8| Node::ListItem {
2681 ordered,
2682 number,
2683 first_in_list: number == 1 && level == 0,
2684 text: text.into(),
2685 level,
2686 marker: None,
2687 location: None,
2688 dclx: None,
2689 href: None,
2690 layer: None,
2691 };
2692 let mut doc = DoclingDocument::new("plain");
2693 doc.add_heading(1, "Title with **bold**");
2694 doc.add_heading(2, "Section");
2695 doc.add_paragraph("Some **bold**, *italic*, ~~gone~~ and `code` with a [link](https://x.org).\nNext line.");
2696 doc.push(item("*one*", false, 1, 0));
2697 doc.push(item("[two](https://y.org)", false, 2, 0));
2698 doc.push(item("nested", true, 1, 1));
2699 doc.push(Node::CheckboxItem {
2700 checked: true,
2701 text: "done".into(),
2702 });
2703 doc.push(Node::Code {
2704 language: Some("rust".into()),
2705 text: "let x = **y**;".into(),
2706 orig: None,
2707 pretty: None,
2708 });
2709 doc.push(Node::Picture {
2710 caption: Some("Figure *1*".into()),
2711 caption_href: None,
2712 image: None,
2713 classification: None,
2714 description: None,
2715 caption_parent: Default::default(),
2716 caption_location: None,
2717 });
2718 doc.push(Node::Caption {
2719 text: "Linked caption".into(),
2720 href: Some("https://z.org".into()),
2721 });
2722 doc.push(Node::Table(Table {
2723 rows: vec![
2724 vec!["**n**".into(), "rich".into()],
2725 vec!["**2**".into(), "a \nb".into()],
2726 ],
2727 ..Default::default()
2728 }));
2729 doc.add_paragraph("R&D \\_ x");
2730 let text = doc.export_to_text();
2731 assert_eq!(
2732 text,
2733 "Title with bold\n\n\
2734 Section\n\n\
2735 Some bold, italic, gone and code with a link.\nNext line.\n\n\
2736 - one\n- two\n 1. nested\n\n\
2737 - [x] done\n\n\
2738 let x = **y**;\n\n\
2739 Figure 1\n\n\
2740 Linked caption\n\n\
2741 | n | rich |\n\
2742 |-----|--------|\n\
2743 | 2 | a b |\n\n\
2744 R&D _ x"
2745 );
2746
2747 let options = MarkdownExportOptions::plain_text();
2748 let (buffered, _) = doc.export_to_markdown_with_options(&options);
2749 assert_eq!(buffered, format!("{text}\n"));
2750 let mut streamer = MarkdownStreamer::new(false, ImageMode::Placeholder, false)
2751 .with_export_options(&options);
2752 let mut out = String::new();
2753 for chunk in doc.nodes.chunks(3) {
2754 out.push_str(&streamer.push(chunk, &[]));
2755 }
2756 out.push_str(&streamer.finish());
2757 assert_eq!(out, buffered);
2758 }
2759
2760 #[test]
2762 fn plain_inline_keeps_literal_markers() {
2763 for (input, want) in [
2764 ("**bold** and *it* and ***both***", "bold and it and both"),
2765 ("**[label](https://a.b/c_(d))** end", "label end"),
2766 (
2767 "grant *USE and *OBJMGT authority",
2768 "grant *USE and *OBJMGT authority",
2769 ),
2770 ("2 * 3 * 4", "2 * 3 * 4"),
2771 ("a ** b", "a ** b"),
2772 ("the `quoted’ and `x` term", "the `quoted’ and x term"),
2773 ("see [12] and [docs](u)", "see [12] and docs"),
2774 ("formula $[a](b)$ stays", "formula $[a](b)$ stays"),
2775 ("price $5 and **bold**", "price $5 and bold"),
2776 ("[outer [inner] text](u)", "outer [inner] text"),
2777 ("unclosed **bold", "unclosed **bold"),
2778 ] {
2779 assert_eq!(plain_inline(input), want, "{input}");
2780 }
2781 }
2782}