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}
69
70impl Ctx {
71 fn new(doc: &DoclingDocument, strict: bool, options: &MarkdownExportOptions) -> Self {
74 Ctx {
75 strict,
76 compact_tables: doc.compact_tables,
77 images: options.image_mode,
78 artifacts_dir: options.artifacts_dir.clone(),
79 artifacts: Vec::new(),
80 pic_index: 0,
81 in_table_cell: false,
82 page_break: doc.page_break_placeholder.clone(),
83 pending_page_break: false,
84 emitted_any: false,
85 layers: options.layers,
86 current_layer: None,
87 traverse_pictures: options.traverse_pictures,
88 escape_html: options.escape_html,
89 escape_underscores: options.escape_underscores,
90 image_placeholder: options.image_placeholder.clone(),
91 }
92 }
93
94 fn layer_visible(&self) -> bool {
96 self.layers.contains(self.current_layer)
97 }
98
99 fn unescape(&self, text: &str) -> String {
106 let mut out = std::borrow::Cow::Borrowed(text);
107 if !self.escape_html
108 && (out.contains("&") || out.contains("<") || out.contains(">"))
109 {
110 out = std::borrow::Cow::Owned(
111 out.replace("<", "<")
112 .replace(">", ">")
113 .replace("&", "&"),
114 );
115 }
116 if !self.escape_underscores && out.contains("\\_") {
117 out = std::borrow::Cow::Owned(out.replace("\\_", "_"));
118 }
119 out.into_owned()
120 }
121
122 fn text(&self, text: &str) -> String {
125 self.unescape(&strict_text(text, self.strict))
126 }
127}
128
129pub fn to_markdown(doc: &DoclingDocument, strict: bool) -> String {
135 to_markdown_images(doc, strict, ImageMode::Placeholder, "artifacts").0
136}
137
138pub fn to_markdown_images(
142 doc: &DoclingDocument,
143 strict: bool,
144 images: ImageMode,
145 artifacts_dir: &str,
146) -> (String, Vec<(String, Vec<u8>)>) {
147 to_markdown_with_options(
148 doc,
149 strict,
150 &MarkdownExportOptions {
151 image_mode: images,
152 artifacts_dir: artifacts_dir.to_string(),
153 ..MarkdownExportOptions::default()
154 },
155 )
156}
157
158pub fn to_markdown_with_options(
162 doc: &DoclingDocument,
163 strict: bool,
164 options: &MarkdownExportOptions,
165) -> (String, Vec<(String, Vec<u8>)>) {
166 let mut ctx = Ctx::new(doc, strict, options);
167 let mut blocks: Vec<String> = Vec::new();
168 render(&doc.nodes, &mut blocks, &mut ctx);
169 let mut body = blocks.join("\n\n");
170 if strict && !doc.links.is_empty() {
174 body = apply_links(&body, &doc.links);
175 }
176 let md = if body.is_empty() {
177 String::new()
178 } else {
179 format!("{body}\n")
180 };
181 (md, ctx.artifacts)
182}
183
184pub fn to_markdown_table_cell(doc: &DoclingDocument, strict: bool) -> String {
193 let mut ctx = Ctx::new(doc, strict, &MarkdownExportOptions::default());
194 ctx.in_table_cell = true;
195 ctx.artifacts_dir = String::new();
196 ctx.page_break = None;
200 let mut blocks: Vec<String> = Vec::new();
201 render(&doc.nodes, &mut blocks, &mut ctx);
202 blocks.join("\n\n")
203}
204
205fn apply_links(body: &str, links: &[(String, String)]) -> String {
213 let mut out = body.to_string();
214 let mut cursor = 0usize;
215 for (anchor, href) in links {
216 let anchor = anchor
217 .replace('&', "&")
218 .replace('<', "<")
219 .replace('>', ">");
220 if anchor.is_empty() {
221 continue;
222 }
223 if let Some(rel) = out[cursor..].find(&anchor) {
224 let at = cursor + rel;
225 let replacement = format!("[{anchor}]({href})");
227 out.replace_range(at..at + anchor.len(), &replacement);
228 cursor = at + replacement.len();
229 }
230 }
231 out
232}
233
234fn apply_links_chunk(chunk: &str, queue: &mut Vec<(String, String)>) -> String {
245 let mut out = chunk.to_string();
246 let mut cursor = 0usize;
247 let mut carried: Vec<(String, String)> = Vec::new();
248 for (anchor_raw, href) in std::mem::take(queue) {
249 let anchor = anchor_raw
250 .replace('&', "&")
251 .replace('<', "<")
252 .replace('>', ">");
253 if anchor.is_empty() {
254 continue;
255 }
256 if let Some(rel) = out[cursor..].find(&anchor) {
257 let at = cursor + rel;
258 let replacement = format!("[{anchor}]({href})");
259 out.replace_range(at..at + anchor.len(), &replacement);
260 cursor = at + replacement.len();
261 } else {
262 carried.push((anchor_raw, href));
264 }
265 }
266 *queue = carried;
267 out
268}
269
270pub struct MarkdownStreamer {
288 strict: bool,
289 images: ImageMode,
290 compact_tables: bool,
291 emitted_any: bool,
294 links: Vec<(String, String)>,
296 artifacts_dir: String,
300 artifacts: Vec<(String, Vec<u8>)>,
301 pic_index: usize,
302 page_break: Option<String>,
306 pending_page_break: bool,
307 items_emitted: bool,
313 layers: ContentLayers,
316 traverse_pictures: bool,
317 escape_html: bool,
318 escape_underscores: bool,
319 image_placeholder: String,
320}
321
322impl MarkdownStreamer {
323 pub fn new(strict: bool, images: ImageMode, compact_tables: bool) -> Self {
326 debug_assert!(
327 images != ImageMode::Referenced,
328 "referenced image mode needs an artifacts dir; use with_artifacts"
329 );
330 Self::with_artifacts(strict, images, compact_tables, "artifacts")
331 }
332
333 pub fn with_artifacts(
340 strict: bool,
341 images: ImageMode,
342 compact_tables: bool,
343 artifacts_dir: &str,
344 ) -> Self {
345 Self {
346 strict,
347 images,
348 compact_tables,
349 emitted_any: false,
350 links: Vec::new(),
351 artifacts_dir: artifacts_dir.to_string(),
352 artifacts: Vec::new(),
353 pic_index: 0,
354 page_break: None,
355 pending_page_break: false,
356 items_emitted: false,
357 layers: ContentLayers::BODY,
358 traverse_pictures: false,
359 escape_html: true,
360 escape_underscores: true,
361 image_placeholder: "<!-- image -->".to_string(),
362 }
363 }
364
365 pub fn with_export_options(mut self, options: &MarkdownExportOptions) -> Self {
372 self.images = options.image_mode;
373 self.artifacts_dir = options.artifacts_dir.clone();
374 self.layers = options.layers;
375 self.traverse_pictures = options.traverse_pictures;
376 self.escape_html = options.escape_html;
377 self.escape_underscores = options.escape_underscores;
378 self.image_placeholder = options.image_placeholder.clone();
379 self
380 }
381
382 pub fn with_page_break_placeholder(mut self, placeholder: Option<String>) -> Self {
387 self.page_break = placeholder;
388 self
389 }
390
391 pub fn take_artifacts(&mut self) -> Vec<(String, Vec<u8>)> {
396 std::mem::take(&mut self.artifacts)
397 }
398
399 pub fn push(&mut self, nodes: &[Node], links: &[(String, String)]) -> String {
404 self.links.extend(links.iter().cloned());
405 let mut ctx = Ctx {
406 strict: self.strict,
407 compact_tables: self.compact_tables,
408 images: self.images,
409 artifacts_dir: std::mem::take(&mut self.artifacts_dir),
410 artifacts: std::mem::take(&mut self.artifacts),
411 pic_index: self.pic_index,
412 in_table_cell: false,
413 page_break: std::mem::take(&mut self.page_break),
414 pending_page_break: self.pending_page_break,
415 emitted_any: self.items_emitted,
416 layers: self.layers,
417 current_layer: None,
418 traverse_pictures: self.traverse_pictures,
419 escape_html: self.escape_html,
420 escape_underscores: self.escape_underscores,
421 image_placeholder: std::mem::take(&mut self.image_placeholder),
422 };
423 let mut blocks: Vec<String> = Vec::new();
424 render(nodes, &mut blocks, &mut ctx);
425 self.image_placeholder = std::mem::take(&mut ctx.image_placeholder);
426 self.artifacts_dir = std::mem::take(&mut ctx.artifacts_dir);
427 self.artifacts = std::mem::take(&mut ctx.artifacts);
428 self.pic_index = ctx.pic_index;
429 self.page_break = std::mem::take(&mut ctx.page_break);
430 self.pending_page_break = ctx.pending_page_break;
431 self.items_emitted = ctx.emitted_any;
432 if blocks.is_empty() {
433 return String::new();
434 }
435 let mut body = blocks.join("\n\n");
436 if self.strict && !self.links.is_empty() {
437 body = apply_links_chunk(&body, &mut self.links);
438 }
439 let chunk = if self.emitted_any {
440 format!("\n\n{body}")
441 } else {
442 body
443 };
444 self.emitted_any = true;
445 chunk
446 }
447
448 pub fn finish(self) -> String {
451 if self.emitted_any {
452 "\n".to_string()
453 } else {
454 String::new()
455 }
456 }
457}
458
459fn strict_text(text: &str, strict: bool) -> String {
467 if !strict {
468 return text.to_string();
469 }
470 text.replace("\\_", "_")
471 .replace(" ,", ",")
472 .replace(" .", ".")
473 .replace(" ;", ";")
474 .replace(" )", ")")
475 .replace("( ", "(")
476 .replace(" ]", "]")
477 .replace("[ ", "[")
478}
479
480fn md_line_breaks(text: &str) -> String {
486 if !text.contains('\n') {
487 return text.to_string();
488 }
489 text.split("\n\n")
490 .map(|para| para.replace('\n', " \n"))
491 .collect::<Vec<_>>()
492 .join("\n\n")
493}
494
495pub(crate) fn strip_hard_breaks(text: &str) -> String {
500 if text.contains(" \n") {
501 text.replace(" \n", "\n")
502 } else {
503 text.to_string()
504 }
505}
506
507fn heading_line_breaks(text: &str) -> String {
511 text.replace('\n', " ")
512}
513
514fn render(nodes: &[Node], blocks: &mut Vec<String>, ctx: &mut Ctx) {
515 let mut i = 0;
516 while i < nodes.len() {
517 let before = blocks.len();
518 match &nodes[i] {
519 Node::PageBreak | Node::PageInfo { .. } => {
529 if ctx.page_break.is_some() && ctx.emitted_any {
530 ctx.pending_page_break = true;
531 }
532 i += 1;
533 }
534 Node::ListItem { .. } => {
535 let start = i;
536 i += 1;
537 loop {
538 match nodes.get(i) {
539 Some(Node::ListItem { .. }) => i += 1,
540 Some(Node::Paragraph { text })
544 if text.is_empty()
545 && matches!(nodes.get(i + 1), Some(Node::ListItem { .. })) =>
546 {
547 i += 1
548 }
549 _ => break,
550 }
551 }
552 render_list_run(&nodes[start..i], blocks, ctx);
553 }
554 other => {
555 render_one(other, blocks, ctx);
556 i += 1;
557 }
558 }
559 if blocks.len() > before {
560 let kept: Vec<String> = blocks.drain(before..).filter(|b| !b.is_empty()).collect();
570 blocks.extend(kept);
571 if ctx.pending_page_break {
572 if let Some(placeholder) = &ctx.page_break {
577 blocks.insert(before, placeholder.clone());
578 }
579 ctx.pending_page_break = false;
580 }
581 ctx.emitted_any = true;
582 }
583 }
584}
585
586fn render_list_run(items: &[Node], blocks: &mut Vec<String>, ctx: &Ctx) {
592 let mut lines: Vec<String> = Vec::new();
593 let mut any_top = false;
596
597 for item in items {
598 let Node::ListItem {
599 ordered,
600 number,
601 first_in_list,
602 text,
603 level,
604 marker: orig_marker,
605 location: _,
606 dclx: _,
607 href: _,
608 layer,
609 } = item
610 else {
611 continue;
612 };
613 if !ctx.layers.contains(layer.or(ctx.current_layer)) {
617 continue;
618 }
619 let level = *level as usize;
620
621 if level == 0 {
630 if any_top && *first_in_list {
631 lines.push(String::new());
632 }
633 any_top = true;
634 }
635
636 let indent = " ".repeat(level);
637 let verbatim = orig_marker.as_deref().filter(|m| {
642 m.strip_suffix('.')
643 .is_some_and(|d| !d.is_empty() && d.chars().all(char::is_numeric))
644 });
645 let marker = match verbatim {
646 Some(m) if *ordered => m.to_string(),
647 _ if *ordered => format!("{number}."),
648 _ => "-".to_string(),
649 };
650 lines.push(format!("{indent}{marker} {}", list_item_text(text, ctx)));
651 }
652
653 if !lines.is_empty() {
656 blocks.push(lines.join("\n"));
657 }
658}
659
660fn list_item_text(text: &str, ctx: &Ctx) -> String {
668 let escaped = ctx.text(text);
669 if let Some((own, tail)) = escaped.split_once('\n') {
670 if is_folded_child_tail(tail) {
671 return format!("{}\n{tail}", md_line_breaks(own));
672 }
673 }
674 md_line_breaks(&escaped)
675}
676
677fn is_folded_child_tail(tail: &str) -> bool {
684 const MARKER: &str = "<!-- image -->";
685 const FENCE: &str = "```";
686 let mut lines = tail.split('\n').peekable();
687 let mut any = false;
688 while let Some(line) = lines.next() {
689 let line = line.trim_start();
690 if line == MARKER {
691 any = true;
692 } else if line == FENCE {
693 loop {
695 match lines.next() {
696 Some(l) if l.trim_start() == FENCE => break,
697 Some(_) => {}
698 None => return false,
699 }
700 }
701 any = true;
702 } else if lines.next().map(str::trim_start) == Some(MARKER) {
703 any = true; } else {
705 return false;
706 }
707 }
708 any
709}
710
711fn render_one(node: &Node, blocks: &mut Vec<String>, ctx: &mut Ctx) {
716 match node {
717 Node::Group { .. }
719 | Node::Furniture { .. }
720 | Node::PageFurniture { .. }
721 | Node::FurnitureText { .. }
722 | Node::CommentSection { .. }
723 | Node::PictureChildren(_)
724 | Node::Commented { .. }
725 | Node::Located { .. }
726 | Node::Prov { .. }
727 | Node::PageBreak
728 | Node::PageInfo { .. }
729 | Node::DoclangOnly(_) => {}
730 _ if !ctx.layer_visible() => return,
731 _ => {}
732 }
733 render_item(node, blocks, ctx)
734}
735
736fn render_item(node: &Node, blocks: &mut Vec<String>, ctx: &mut Ctx) {
737 match node {
738 Node::Heading { level, text } => {
739 let text = heading_line_breaks(&ctx.text(text));
740 if ctx.in_table_cell {
741 blocks.push(text);
743 } else {
744 let hashes = "#".repeat((*level).clamp(1, 6) as usize);
745 blocks.push(format!("{hashes} {text}"));
746 }
747 }
748 Node::Paragraph { text } if text.is_empty() => {}
751 Node::Paragraph { text } => blocks.push(md_line_breaks(&ctx.text(text))),
752 Node::Caption { text, .. } if text.is_empty() => {}
755 Node::Caption { text, href } => {
756 let body = md_line_breaks(&ctx.text(text));
757 blocks.push(match href {
758 Some(url) => format!("[{body}]({url})"),
759 None => body,
760 });
761 }
762 Node::CheckboxItem { checked, text } => {
763 let mark = if *checked { "- [x] " } else { "- [ ] " };
764 blocks.push(md_line_breaks(&ctx.text(&format!("{mark}{text}"))));
765 }
766 Node::Code {
767 language,
768 text,
769 pretty,
770 ..
771 } => {
772 let lang = match language {
774 Some(l) if ctx.strict => l.as_str(),
775 _ => "",
776 };
777 let body = match pretty {
780 Some(p) if ctx.strict => p.as_str(),
781 _ => text.as_str(),
782 };
783 blocks.push(format!("```{lang}\n{body}\n```"));
784 }
785 Node::Formula { latex, .. } => blocks.push(format!("$${latex}$$")),
788 Node::Table(table) => {
789 if let Some(cap) = &table.caption {
792 if !cap.is_empty() {
793 blocks.push(md_line_breaks(&ctx.text(cap)));
794 }
795 }
796 let rendered = render_table(table, ctx.compact_tables);
797 if !rendered.is_empty() {
798 blocks.push(rendered);
799 }
800 }
801 Node::Picture { caption, image, .. } => {
803 if let Some(cap) = caption {
804 if !cap.is_empty() {
805 blocks.push(md_line_breaks(&ctx.unescape(cap)));
806 }
807 }
808 blocks.push(picture_marker(image.as_ref(), ctx));
809 }
810 Node::Chart {
814 kind,
815 table,
816 caption,
817 ..
818 } => {
819 if let Some(cap) = caption {
820 if !cap.is_empty() {
821 blocks.push(md_line_breaks(&ctx.unescape(cap)));
822 }
823 }
824 blocks.push(picture_marker(None, ctx));
825 blocks.push(humanize_label(kind));
826 let rendered = render_table(table, false);
827 if !rendered.is_empty() {
828 blocks.push(rendered);
829 }
830 }
831 Node::DoclangOnly(_) => {}
833 Node::Group {
838 layer: Some(layer),
839 children,
840 ..
841 } => {
842 let outer = ctx.current_layer.replace(*layer);
843 render(children, blocks, ctx);
844 ctx.current_layer = outer;
845 }
846 Node::Group { children, .. } => render(children, blocks, ctx),
847 Node::FieldRegion { items } => {
848 for item in items {
853 for part in [&item.marker, &item.key, &item.value].into_iter().flatten() {
854 blocks.push(md_line_breaks(&ctx.text(part)));
855 }
856 }
857 }
858 Node::KeyValueGraph { .. } => blocks.push(MISSING_KEY_VALUE_ITEM.to_string()),
861 Node::InlineGroup { md_text, .. } => blocks.push(md_line_breaks(&ctx.text(md_text))),
864 Node::TextDump(text) => {
866 if !text.is_empty() {
867 blocks.push(text.clone());
868 }
869 }
870 Node::Furniture { layer, inner } => {
874 let outer = ctx.current_layer.replace(*layer);
875 render_one(inner, blocks, ctx);
876 ctx.current_layer = outer;
877 }
878 Node::PageFurniture { text, .. } | Node::FurnitureText { text, .. } => {
882 if ctx.layers.furniture && !text.is_empty() {
883 blocks.push(md_line_breaks(&ctx.text(text)));
884 }
885 }
886 Node::PictureChildren(children) => {
891 if ctx.traverse_pictures {
892 render(children, blocks, ctx);
893 }
894 }
895 Node::CommentSection { text, .. } => {
900 if ctx.layers.notes && !text.is_empty() {
901 blocks.push(md_line_breaks(&ctx.text(text)));
902 }
903 }
904 Node::Commented { inner, .. } => render_one(inner, blocks, ctx),
905 Node::Located { inner, .. } | Node::Prov { inner, .. } => render_one(inner, blocks, ctx),
907 Node::PageBreak => {}
909 Node::PageInfo { .. } => {}
911 Node::ListItem { .. } => render_list_run(std::slice::from_ref(node), blocks, ctx),
916 }
917}
918
919fn humanize_label(label: &str) -> String {
924 let text = label.replace('_', " ");
925 let mut chars = text.chars();
926 match chars.next() {
927 Some(f) => f.to_uppercase().collect::<String>() + chars.as_str(),
928 None => text,
929 }
930}
931
932fn picture_marker(image: Option<&crate::PictureImage>, ctx: &mut Ctx) -> String {
933 match (ctx.images, image) {
934 (ImageMode::Embedded, Some(img)) => {
936 format!("", crate::pixel_digest::docling_data_uri(img).1)
937 }
938 (ImageMode::Referenced, Some(img)) => {
939 let path = format!(
940 "{}/image_{:06}.{}",
941 ctx.artifacts_dir,
942 ctx.pic_index,
943 ext_for(&img.mimetype)
944 );
945 ctx.pic_index += 1;
946 ctx.artifacts.push((path.clone(), img.data.clone()));
947 format!("", escape_uri_path(&path))
948 }
949 _ => ctx.image_placeholder.clone(),
951 }
952}
953
954pub(crate) fn escape_uri_path(value: &str) -> String {
967 const KEEP: &str = "/%:@+,;=~$!&'*";
968 let s = value.replace('\\', "/");
969 if let Some(rest) = s.strip_prefix("//") {
970 let rest = rest.trim_start_matches('/');
972 let (host, tail) = rest.split_once('/').unwrap_or((rest, ""));
973 return format!("file://{host}{}", percent_quote(&format!("/{tail}"), KEEP));
974 }
975 let bytes = s.as_bytes();
976 if bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'/' {
977 return format!("file:///{}", percent_quote(&s, KEEP));
979 }
980 if let Some((scheme, rest)) = s.split_once(':') {
984 let valid_scheme = scheme.len() > 1
985 && scheme.as_bytes()[0].is_ascii_alphabetic()
986 && scheme
987 .bytes()
988 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'-' | b'.'));
989 if valid_scheme {
990 let (authority, rest) = match rest.strip_prefix("//") {
991 Some(r) => {
992 let end = r.find(['/', '?', '#']).unwrap_or(r.len());
993 (Some(&r[..end]), &r[end..])
994 }
995 None => (None, rest),
996 };
997 let (before_frag, fragment) = rest.split_once('#').unwrap_or((rest, ""));
998 let (path, query) = before_frag.split_once('?').unwrap_or((before_frag, ""));
999 let mut out = format!("{scheme}:");
1000 if let Some(a) = authority {
1001 out.push_str("//");
1002 out.push_str(a);
1003 }
1004 out.push_str(&percent_quote(path, KEEP));
1005 if !query.is_empty() {
1006 out.push('?');
1007 out.push_str(&percent_quote(query, KEEP));
1008 }
1009 if !fragment.is_empty() {
1010 out.push('#');
1011 out.push_str(&percent_quote(fragment, KEEP));
1012 }
1013 return out;
1014 }
1015 }
1016 percent_quote(&s, KEEP)
1018}
1019
1020fn percent_quote(s: &str, safe: &str) -> String {
1023 let mut out = String::with_capacity(s.len());
1024 for &b in s.as_bytes() {
1025 let keep = b.is_ascii_alphanumeric()
1026 || matches!(b, b'_' | b'.' | b'-' | b'~')
1027 || (b.is_ascii() && safe.contains(b as char));
1028 if keep {
1029 out.push(b as char);
1030 } else {
1031 out.push_str(&format!("%{b:02X}"));
1032 }
1033 }
1034 out
1035}
1036
1037pub(crate) fn ext_for(mimetype: &str) -> &str {
1038 match mimetype {
1039 "image/jpeg" => "jpg",
1040 "image/gif" => "gif",
1041 "image/webp" => "webp",
1042 "image/bmp" => "bmp",
1043 "image/tiff" => "tif",
1044 _ => "png",
1045 }
1046}
1047
1048fn is_number_cell(t: &str) -> bool {
1067 t.parse::<f64>().is_ok() || is_thousands_number(t)
1068}
1069
1070fn is_thousands_number(t: &str) -> bool {
1076 let b = t.as_bytes();
1077 let mut i = 0;
1078 let start = i;
1079 if i < b.len() && (b[i] == b'+' || b[i] == b'-') {
1080 i += 1;
1081 }
1082 let d0 = i;
1084 while i < b.len() && b[i].is_ascii_digit() && i - d0 < 3 {
1085 i += 1;
1086 }
1087 let has_int = i > d0;
1088 if has_int {
1089 while i + 3 < b.len() + 1
1091 && b.get(i) == Some(&b',')
1092 && b.get(i + 1).is_some_and(u8::is_ascii_digit)
1093 && b.get(i + 2).is_some_and(u8::is_ascii_digit)
1094 && b.get(i + 3).is_some_and(u8::is_ascii_digit)
1095 {
1096 i += 4;
1097 }
1098 } else {
1099 i = start;
1101 }
1102 if i < b.len() && b[i] == b'.' {
1104 i += 1;
1105 let f0 = i;
1106 while i < b.len() && b[i].is_ascii_digit() {
1107 i += 1;
1108 }
1109 if !has_int && i == f0 {
1110 return false; }
1112 } else if !has_int {
1113 return false; }
1115 i == b.len()
1116}
1117
1118fn flatten_header_rows(header_rows: &[Vec<String>], num_cols: usize) -> Vec<String> {
1127 (0..num_cols)
1128 .map(|c| {
1129 let mut parts: Vec<&str> = Vec::new();
1130 for row in header_rows {
1131 let text = row.get(c).map(String::as_str).unwrap_or("");
1132 if !text.is_empty() && parts.last() != Some(&text) {
1133 parts.push(text);
1134 }
1135 }
1136 parts.join(" - ")
1137 })
1138 .collect()
1139}
1140
1141pub(crate) fn render_table(table: &Table, compact: bool) -> String {
1142 if table.rows.is_empty() {
1143 return String::new();
1144 }
1145 let num_cols = table.rows.iter().map(Vec::len).max().unwrap_or(0);
1146 if num_cols == 0 {
1147 return String::new();
1148 }
1149
1150 let num_headers = table.header_row_count().min(table.rows.len());
1155 let escaped = |r: usize| -> Vec<String> {
1156 (0..num_cols)
1157 .map(|c| escape_cell(table.rows[r].get(c).map(String::as_str).unwrap_or("")))
1158 .collect()
1159 };
1160 let header_rows: Vec<Vec<String>> = (0..num_headers).map(escaped).collect();
1161 let header = flatten_header_rows(&header_rows, num_cols);
1162 let body: Vec<Vec<String>> = (num_headers..table.rows.len())
1163 .map(|r| {
1164 escaped(r)
1165 .into_iter()
1166 .map(|c| c.trim().to_string())
1167 .collect()
1168 })
1169 .collect();
1170
1171 if compact {
1172 let render_row = |row: &[String]| -> String { format!("| {} |", row.join(" | ")) };
1174 let mut lines = Vec::with_capacity(body.len() + 2);
1175 lines.push(render_row(&header));
1176 let sep: Vec<&str> = (0..num_cols).map(|_| "-").collect();
1177 lines.push(format!("| {} |", sep.join(" | ")));
1178 for row in &body {
1179 lines.push(render_row(row));
1180 }
1181 return lines.join("\n");
1182 }
1183
1184 let dw = |s: &str| s.chars().count();
1186
1187 let right: Vec<bool> = (0..num_cols)
1192 .map(|c| {
1193 let mut any = false;
1194 for row in &body {
1195 let t = row[c].trim();
1196 if t.is_empty() {
1197 continue;
1198 }
1199 if !is_number_cell(t) {
1200 return false;
1201 }
1202 any = true;
1203 }
1204 any
1205 })
1206 .collect();
1207
1208 let width: Vec<usize> = (0..num_cols)
1210 .map(|c| {
1211 let mut w = dw(&header[c]) + 2;
1212 for row in &body {
1213 w = w.max(dw(&row[c]));
1214 }
1215 w
1216 })
1217 .collect();
1218
1219 let fmt_cell = |s: &str, c: usize| -> String {
1220 let pad = " ".repeat(width[c].saturating_sub(dw(s)));
1221 let body = if right[c] {
1222 format!("{pad}{s}")
1223 } else {
1224 format!("{s}{pad}")
1225 };
1226 format!(" {body} ")
1227 };
1228 let render_row = |row: &[String]| -> String {
1229 let cells: Vec<String> = (0..num_cols).map(|c| fmt_cell(&row[c], c)).collect();
1230 format!("|{}|", cells.join("|"))
1231 };
1232
1233 let mut lines = Vec::with_capacity(body.len() + 2);
1234 lines.push(render_row(&header));
1235 let sep: Vec<String> = (0..num_cols).map(|c| "-".repeat(width[c] + 2)).collect();
1236 lines.push(format!("|{}|", sep.join("|")));
1237 for row in &body {
1238 lines.push(render_row(row));
1239 }
1240 lines.join("\n")
1241}
1242
1243fn escape_cell(s: &str) -> String {
1246 s.replace('\n', " ").replace('|', "|")
1247}
1248
1249#[cfg(test)]
1250mod tests {
1251 use super::*;
1252 use crate::{PictureImage, TableCell, TableStructure};
1253
1254 #[test]
1260 fn list_boundaries_come_from_the_backend_not_the_numbering() {
1261 let item = |ordered: bool, number: u64, first_in_list: bool, text: &str| Node::ListItem {
1262 ordered,
1263 number,
1264 first_in_list,
1265 text: text.into(),
1266 level: 0,
1267 marker: None,
1268 location: None,
1269 dclx: None,
1270 href: None,
1271 layer: None,
1272 };
1273 let md = |items: Vec<Node>| {
1274 let mut doc = DoclingDocument::new("t");
1275 for n in items {
1276 doc.push(n);
1277 }
1278 doc.export_to_markdown()
1279 };
1280 assert_eq!(
1282 md(vec![
1283 item(true, 1, true, "one"),
1284 item(true, 5, false, "five")
1285 ]),
1286 "1. one\n5. five\n"
1287 );
1288 assert_eq!(
1290 md(vec![
1291 item(false, 0, true, "bullet"),
1292 item(true, 1, false, "one"),
1293 item(false, 0, false, "bullet two"),
1294 ]),
1295 "- bullet\n1. one\n- bullet two\n"
1296 );
1297 assert_eq!(
1299 md(vec![
1300 item(true, 1, true, "a"),
1301 item(true, 2, false, "b"),
1302 item(true, 3, true, "new list, continuing count"),
1303 ]),
1304 "1. a\n2. b\n\n3. new list, continuing count\n"
1305 );
1306 }
1307
1308 #[test]
1309 fn renders_headings_paragraphs_and_lists() {
1310 let mut doc = DoclingDocument::new("demo");
1311 doc.add_heading(1, "Title");
1312 doc.add_paragraph("Hello world.");
1313 doc.push(Node::ListItem {
1314 ordered: false,
1315 number: 1,
1316 first_in_list: true,
1317 text: "first".into(),
1318 level: 0,
1319 marker: None,
1320 location: None,
1321 dclx: None,
1322 href: None,
1323 layer: None,
1324 });
1325 doc.push(Node::ListItem {
1326 ordered: false,
1327 number: 2,
1328 first_in_list: false,
1329 text: "second".into(),
1330 level: 0,
1331 marker: None,
1332 location: None,
1333 dclx: None,
1334 href: None,
1335 layer: None,
1336 });
1337 let md = doc.export_to_markdown();
1338 assert_eq!(md, "# Title\n\nHello world.\n\n- first\n- second\n");
1339 }
1340
1341 #[test]
1345 fn single_newlines_become_gfm_hard_line_breaks() {
1346 let mut doc = DoclingDocument::new("t");
1347 doc.push(Node::Heading {
1348 level: 1,
1349 text: "Hello\nWorld".into(),
1350 });
1351 doc.push(Node::Paragraph {
1352 text: "line one\nline two\n\npara two".into(),
1353 });
1354 doc.push(Node::ListItem {
1355 ordered: false,
1356 number: 1,
1357 first_in_list: true,
1358 text: "item\ncontinued".into(),
1359 level: 0,
1360 marker: None,
1361 location: None,
1362 dclx: None,
1363 href: None,
1364 layer: None,
1365 });
1366 doc.push(Node::TextDump("A1 B1 \n\n\nC1".into()));
1367 assert_eq!(
1368 doc.export_to_markdown(),
1369 "# Hello World\n\nline one \nline two\n\npara two\n\n- item \ncontinued\n\nA1 B1 \n\n\nC1\n"
1370 );
1371 }
1372
1373 #[test]
1376 fn table_cell_mode_and_field_regions() {
1377 let mut doc = DoclingDocument::new("t");
1378 doc.push(Node::Heading {
1379 level: 2,
1380 text: "A text".into(),
1381 });
1382 doc.push(Node::Paragraph {
1383 text: "body".into(),
1384 });
1385 assert_eq!(to_markdown_table_cell(&doc, false), "A text\n\nbody");
1386 assert_eq!(doc.export_to_markdown(), "## A text\n\nbody\n");
1387
1388 let mut doc = DoclingDocument::new("f");
1389 doc.push(Node::FieldRegion {
1390 items: vec![crate::FieldItem {
1391 marker: None,
1392 key: Some("Name:".into()),
1393 value: Some("John Doe".into()),
1394 value_kind: None,
1395 }],
1396 });
1397 assert_eq!(doc.export_to_markdown(), "Name:\n\nJohn Doe\n");
1398 }
1399
1400 #[test]
1401 fn strict_renders_recovered_links_legacy_does_not() {
1402 let mut doc = DoclingDocument::new("cv");
1403 doc.add_paragraph("Find me on LinkedIn or GitHub.");
1404 doc.links = vec![
1405 ("LinkedIn".into(), "https://www.linkedin.com/in/x/".into()),
1406 ("GitHub".into(), "https://github.com/x/".into()),
1407 ];
1408 assert_eq!(doc.export_to_markdown(), "Find me on LinkedIn or GitHub.\n");
1410 assert_eq!(
1412 doc.export_to_markdown_with(true),
1413 "Find me on [LinkedIn](https://www.linkedin.com/in/x/) or [GitHub](https://github.com/x/).\n"
1414 );
1415 }
1416
1417 #[test]
1418 fn strict_links_match_escaped_anchor_and_consume_in_order() {
1419 let mut doc = DoclingDocument::new("d");
1420 doc.add_paragraph("AI & ML here, and issues here, then issues there.");
1424 doc.links = vec![
1425 ("AI & ML".into(), "https://a/".into()),
1426 ("issues".into(), "https://first/".into()),
1427 ("issues".into(), "https://second/".into()),
1428 ];
1429 assert_eq!(
1430 doc.export_to_markdown_with(true),
1431 "[AI & ML](https://a/) here, and [issues](https://first/) here, then [issues](https://second/) there.\n"
1432 );
1433 }
1434
1435 #[test]
1439 fn referenced_image_destinations_are_escaped() {
1440 let cases = [
1441 (
1442 "doc_artifacts/image_000001_ab12.png",
1443 "doc_artifacts/image_000001_ab12.png",
1444 ),
1445 (
1446 "My Report_artifacts/img.png",
1447 "My%20Report_artifacts/img.png",
1448 ),
1449 ("artifacts/img (1).png", "artifacts/img%20%281%29.png"),
1450 ("100%_scale/a#b?c.png", "100%_scale/a%23b%3Fc.png"),
1451 ("/home/a b/img.png", "/home/a%20b/img.png"),
1452 (
1453 "My Report_artifacts\\img.png",
1454 "My%20Report_artifacts/img.png",
1455 ),
1456 (
1457 "C:/Users/me/My Docs/img.png",
1458 "file:///C:/Users/me/My%20Docs/img.png",
1459 ),
1460 ("C:\\Users\\me\\img.png", "file:///C:/Users/me/img.png"),
1461 (
1462 "//server/share/My Docs/img.png",
1463 "file://server/share/My%20Docs/img.png",
1464 ),
1465 ("\\\\server\\share\\img.png", "file://server/share/img.png"),
1466 ("file:///home/a b/img.png", "file:///home/a%20b/img.png"),
1467 (
1468 "s3://bucket/My Report_artifacts/img.png",
1469 "s3://bucket/My%20Report_artifacts/img.png",
1470 ),
1471 (
1472 "https://example.com:8080/a b.png?w=1&h=2#frag",
1473 "https://example.com:8080/a%20b.png?w=1&h=2#frag",
1474 ),
1475 (
1476 "https://example.com/img (1).png",
1477 "https://example.com/img%20%281%29.png",
1478 ),
1479 ("caf\u{e9}/im\u{e4}ge.png", "caf%C3%A9/im%C3%A4ge.png"),
1480 ];
1481 for (input, expected) in cases {
1482 assert_eq!(escape_uri_path(input), expected, "input {input:?}");
1483 assert_eq!(
1484 escape_uri_path(expected),
1485 expected,
1486 "idempotent {expected:?}"
1487 );
1488 }
1489 let mut doc = DoclingDocument::new("t");
1491 doc.push(Node::Picture {
1492 caption: None,
1493 caption_href: None,
1494 image: Some(PictureImage {
1495 dpi: PictureImage::DEFAULT_DPI,
1496 mimetype: "image/png".into(),
1497 width: 1,
1498 height: 1,
1499 data: b"x".to_vec(),
1500 }),
1501 classification: None,
1502 caption_parent: Default::default(),
1503 caption_location: None,
1504 });
1505 let (md, files) = doc
1506 .export_to_markdown_with_images(ImageMode::Referenced, "My Report (final)_artifacts");
1507 assert!(
1508 md.contains(""),
1509 "got:\n{md}"
1510 );
1511 assert_eq!(files[0].0, "My Report (final)_artifacts/image_000000.png");
1513 }
1514
1515 #[test]
1519 fn folded_list_item_pictures_keep_plain_newlines() {
1520 let doc = DoclingDocument::new("t");
1521 let ctx = Ctx::new(&doc, false, &MarkdownExportOptions::default());
1522 assert_eq!(
1523 list_item_text("Step\n<!-- image -->", &ctx),
1524 "Step\n<!-- image -->"
1525 );
1526 assert_eq!(
1527 list_item_text("Step\nAlt text\n<!-- image -->\n<!-- image -->", &ctx),
1528 "Step\nAlt text\n<!-- image -->\n<!-- image -->"
1529 );
1530 assert_eq!(
1531 list_item_text("line one\nline two", &ctx),
1532 "line one \nline two"
1533 );
1534 }
1535
1536 #[test]
1539 fn stacked_header_rows_flatten_into_one() {
1540 let mut t = Table {
1541 rows: vec![
1542 vec!["".into(), "% of Total".into(), "% of Total".into()],
1543 vec!["class".into(), "Train".into(), "Test".into()],
1544 vec!["Caption".into(), "2.04".into(), "1.77".into()],
1545 ],
1546 ..Default::default()
1547 };
1548 t.structure = Some(TableStructure {
1549 header_row: vec![true, true, false],
1550 col_continuation: vec![
1551 vec![false, false, true],
1552 vec![false, false, false],
1553 vec![false, false, false],
1554 ],
1555 ..Default::default()
1556 });
1557 assert_eq!(t.header_row_count(), 2);
1558 assert_eq!(
1559 render_table(&t, true),
1560 "| class | % of Total - Train | % of Total - Test |\n| - | - | - |\n| Caption | 2.04 | 1.77 |"
1561 );
1562 assert_eq!(
1564 render_table(&t, false),
1565 "| class | % of Total - Train | % of Total - Test |\n\
1566 |---------|----------------------|---------------------|\n\
1567 | Caption | 2.04 | 1.77 |"
1568 );
1569 }
1570
1571 #[test]
1574 fn vertically_spanning_header_does_not_extend_the_block() {
1575 let mut t = Table {
1576 rows: vec![
1577 vec!["Name".into(), "Value".into()],
1578 vec!["Name".into(), "1".into()],
1579 vec!["x".into(), "2".into()],
1580 ],
1581 ..Default::default()
1582 };
1583 t.structure = Some(TableStructure {
1584 col_header: vec![vec![true, true], vec![true, false], vec![false, false]],
1585 row_continuation: vec![vec![false, false], vec![true, false], vec![false, false]],
1586 ..Default::default()
1587 });
1588 assert_eq!(t.header_row_count(), 1);
1589 assert_eq!(
1590 render_table(&t, true),
1591 "| Name | Value |\n| - | - |\n| Name | 1 |\n| x | 2 |"
1592 );
1593 }
1594
1595 #[test]
1598 fn header_flags_not_on_row_zero_keep_all_rows_in_the_body() {
1599 let mut t = Table {
1600 rows: vec![
1601 vec!["1".into(), "2".into()],
1602 vec!["a".into(), "b".into()],
1603 vec!["333".into(), "4".into()],
1604 ],
1605 ..Default::default()
1606 };
1607 t.structure = Some(TableStructure {
1608 header_row: vec![false, true, false],
1609 ..Default::default()
1610 });
1611 assert_eq!(t.header_row_count(), 0);
1612 assert_eq!(
1613 render_table(&t, false),
1614 "| | |\n|-----|----|\n| 1 | 2 |\n| a | b |\n| 333 | 4 |"
1615 );
1616 }
1617
1618 #[test]
1623 fn pivot_row_headers_do_not_extend_the_header() {
1624 let mut t = Table {
1625 rows: vec![
1626 vec!["Year".into(), "Month".into()],
1627 vec!["2025".into(), "January".into()],
1628 vec!["2025".into(), "February".into()],
1629 ],
1630 ..Default::default()
1631 };
1632 t.structure = Some(TableStructure {
1633 col_header: vec![vec![true, true], vec![false, false], vec![false, false]],
1634 row_header: vec![vec![false, false], vec![true, false], vec![true, false]],
1635 row_continuation: vec![vec![false, false], vec![false, false], vec![true, false]],
1636 ..Default::default()
1637 });
1638 assert_eq!(t.header_row_count(), 1);
1639 assert_eq!(
1640 render_table(&t, true),
1641 "| Year | Month |\n| - | - |\n| 2025 | January |\n| 2025 | February |"
1642 );
1643 }
1644
1645 #[test]
1648 fn unflagged_cells_keep_row_zero_as_header() {
1649 let mut t = Table {
1650 rows: vec![vec!["h".into()], vec!["d".into()]],
1651 ..Default::default()
1652 };
1653 t.cells = Some(
1654 [(0usize, "h"), (1, "d")]
1655 .into_iter()
1656 .map(|(r, text)| TableCell {
1657 text: text.into(),
1658 bbox: None,
1659 start_row: r,
1660 start_col: 0,
1661 row_span: 1,
1662 col_span: 1,
1663 column_header: false,
1664 row_header: false,
1665 row_section: false,
1666 })
1667 .collect(),
1668 );
1669 assert_eq!(t.header_row_count(), 1);
1670 assert_eq!(render_table(&t, true), "| h |\n| - |\n| d |");
1671 }
1672
1673 fn flagged_table(rows: &[&[&str]], ched: impl Fn(usize, usize) -> bool) -> Table {
1675 let mut cells = Vec::new();
1676 for (r, row) in rows.iter().enumerate() {
1677 for (c, text) in row.iter().enumerate() {
1678 cells.push(TableCell {
1679 text: (*text).into(),
1680 bbox: None,
1681 start_row: r,
1682 start_col: c,
1683 row_span: 1,
1684 col_span: 1,
1685 column_header: ched(r, c),
1686 row_header: false,
1687 row_section: false,
1688 });
1689 }
1690 }
1691 Table {
1692 rows: rows
1693 .iter()
1694 .map(|row| row.iter().map(|t| (*t).to_string()).collect())
1695 .collect(),
1696 cells: Some(cells),
1697 ..Default::default()
1698 }
1699 }
1700
1701 #[test]
1705 fn rows_with_body_text_stop_the_header_block() {
1706 let t = flagged_table(
1708 &[
1709 &["Item", "Q1", "Q2"],
1710 &["Sales", "10", "20"],
1711 &["Costs", "5", "7"],
1712 ],
1713 |r, c| r == 0 || c == 0,
1714 );
1715 assert_eq!(t.header_row_count(), 1);
1716 assert_eq!(
1717 render_table(&t, false),
1718 "| Item | Q1 | Q2 |\n\
1719 |--------|------|------|\n\
1720 | Sales | 10 | 20 |\n\
1721 | Costs | 5 | 7 |"
1722 );
1723 let t = flagged_table(&[&["Sales", "10", "20"], &["Costs", "5", "7"]], |_, c| {
1726 c == 0
1727 });
1728 assert_eq!(t.header_row_count(), 0);
1729 assert_eq!(
1730 render_table(&t, false),
1731 "| | | |\n\
1732 |-------|----|----|\n\
1733 | Sales | 10 | 20 |\n\
1734 | Costs | 5 | 7 |"
1735 );
1736 let t = flagged_table(
1737 &[&["a", "1", "2"], &["b", "3", "4"], &["c", "5", "6"]],
1738 |r, c| c == 0 && r < 2,
1739 );
1740 assert_eq!(t.header_row_count(), 0);
1741 let t = flagged_table(&[&["a", "1", "2"], &["b", "3", "4"]], |r, c| {
1744 r == 0 && c == 0
1745 });
1746 assert_eq!(t.header_row_count(), 1);
1747 assert_eq!(
1748 render_table(&t, false),
1749 "| a | 1 | 2 |\n\
1750 |-----|-----|-----|\n\
1751 | b | 3 | 4 |"
1752 );
1753 let t = flagged_table(&[&["Item", "", ""], &["x", "1", "2"]], |_, c| c == 0);
1755 assert_eq!(t.header_row_count(), 1);
1756 assert_eq!(
1757 render_table(&t, false),
1758 "| Item | | |\n\
1759 |--------|----|----|\n\
1760 | x | 1 | 2 |"
1761 );
1762 }
1763
1764 #[test]
1765 fn renders_compact_table() {
1766 let mut doc = DoclingDocument::new("t");
1767 doc.compact_tables = true;
1770 doc.push(Node::Table(Table {
1771 rows: vec![vec!["a".into(), "b".into()], vec!["1".into(), "2".into()]],
1772 location: None,
1773 structure: None,
1774 cell_blocks: None,
1775 cells: None,
1776 caption: None,
1777 caption_parent: Default::default(),
1778 caption_location: None,
1779 }));
1780 let md = doc.export_to_markdown();
1781 assert_eq!(md, "| a | b |\n| - | - |\n| 1 | 2 |\n");
1782 }
1783
1784 #[test]
1785 fn renders_padded_github_table_by_default() {
1786 let mut doc = DoclingDocument::new("t");
1787 doc.push(Node::Table(Table {
1788 rows: vec![vec!["a".into(), "b".into()], vec!["1".into(), "2".into()]],
1789 location: None,
1790 structure: None,
1791 cell_blocks: None,
1792 cells: None,
1793 caption: None,
1794 caption_parent: Default::default(),
1795 caption_location: None,
1796 }));
1797 let md = doc.export_to_markdown();
1798 assert_eq!(md, "| a | b |\n|-----|-----|\n| 1 | 2 |\n");
1800 }
1801
1802 #[test]
1803 fn strict_unescapes_inline_underscores_legacy_keeps_them() {
1804 let mut doc = DoclingDocument::new("t");
1805 doc.add_heading(1, "a\\_b");
1806 doc.add_paragraph("x\\_y");
1807 doc.push(Node::ListItem {
1808 ordered: false,
1809 number: 1,
1810 first_in_list: true,
1811 text: "i\\_j".into(),
1812 level: 0,
1813 marker: None,
1814 location: None,
1815 dclx: None,
1816 href: None,
1817 layer: None,
1818 });
1819 assert_eq!(doc.export_to_markdown(), "# a\\_b\n\nx\\_y\n\n- i\\_j\n");
1821 assert_eq!(doc.export_to_markdown_with(true), "# a_b\n\nx_y\n\n- i_j\n");
1823 }
1824
1825 fn assert_stream_matches(
1828 doc: &DoclingDocument,
1829 strict: bool,
1830 images: ImageMode,
1831 splits: &[usize],
1832 ) {
1833 let (want, want_artifacts) = to_markdown_images(doc, strict, images, "artifacts");
1834 let mut streamer =
1835 MarkdownStreamer::with_artifacts(strict, images, doc.compact_tables, "artifacts")
1836 .with_page_break_placeholder(doc.page_break_placeholder.clone());
1837 let mut got = String::new();
1838 let mut got_artifacts = Vec::new();
1839 let mut start = 0;
1840 for &end in splits {
1841 let links = if start == 0 {
1844 doc.links.as_slice()
1845 } else {
1846 &[]
1847 };
1848 got.push_str(&streamer.push(&doc.nodes[start..end], links));
1849 got_artifacts.extend(streamer.take_artifacts());
1852 start = end;
1853 }
1854 got.push_str(&streamer.push(
1855 &doc.nodes[start..],
1856 if start == 0 {
1857 doc.links.as_slice()
1858 } else {
1859 &[]
1860 },
1861 ));
1862 got_artifacts.extend(streamer.take_artifacts());
1863 got.push_str(&streamer.finish());
1864 assert_eq!(
1865 got, want,
1866 "streamed output diverged (splits={splits:?}, strict={strict})"
1867 );
1868 assert_eq!(
1869 got_artifacts, want_artifacts,
1870 "streamed artifacts diverged (splits={splits:?}, strict={strict})"
1871 );
1872 }
1873
1874 #[test]
1875 fn streaming_is_byte_identical_to_buffered() {
1876 let mut doc = DoclingDocument::new("d");
1877 doc.add_heading(1, "Title");
1878 doc.add_paragraph("First paragraph.");
1879 doc.push(Node::ListItem {
1880 ordered: false,
1881 number: 1,
1882 first_in_list: true,
1883 text: "a".into(),
1884 level: 0,
1885 marker: None,
1886 location: None,
1887 dclx: None,
1888 href: None,
1889 layer: None,
1890 });
1891 doc.push(Node::ListItem {
1892 ordered: false,
1893 number: 2,
1894 first_in_list: false,
1895 text: "b".into(),
1896 level: 0,
1897 marker: None,
1898 location: None,
1899 dclx: None,
1900 href: None,
1901 layer: None,
1902 });
1903 doc.push(Node::Code {
1904 language: Some("rust".into()),
1905 text: "let x = 1;".into(),
1906 orig: None,
1907 pretty: None,
1908 });
1909 doc.push(Node::Table(Table {
1910 rows: vec![vec!["a".into(), "b".into()], vec!["1".into(), "2".into()]],
1911 location: None,
1912 structure: None,
1913 cell_blocks: None,
1914 cells: None,
1915 caption: None,
1916 caption_parent: Default::default(),
1917 caption_location: None,
1918 }));
1919 doc.push(Node::Picture {
1920 caption: Some("Fig 1".into()),
1921 caption_href: None,
1922 image: Some(PictureImage {
1923 dpi: PictureImage::DEFAULT_DPI,
1924 mimetype: "image/png".into(),
1925 width: 2,
1926 height: 2,
1927 data: b"png-one".to_vec(),
1928 }),
1929 classification: None,
1930 caption_parent: Default::default(),
1931 caption_location: None,
1932 });
1933 doc.add_paragraph("Last paragraph.");
1934 doc.push(Node::Picture {
1937 caption: None,
1938 caption_href: None,
1939 image: Some(PictureImage {
1940 dpi: PictureImage::DEFAULT_DPI,
1941 mimetype: "image/png".into(),
1942 width: 2,
1943 height: 2,
1944 data: b"png-two".to_vec(),
1945 }),
1946 classification: None,
1947 caption_parent: Default::default(),
1948 caption_location: None,
1949 });
1950
1951 for &strict in &[false, true] {
1954 for &images in &[
1955 ImageMode::Placeholder,
1956 ImageMode::Embedded,
1957 ImageMode::Referenced,
1958 ] {
1959 for splits in [&[][..], &[1][..], &[2][..], &[4][..], &[1, 4, 6, 7][..]] {
1960 assert_stream_matches(&doc, strict, images, splits);
1961 }
1962 }
1963 }
1964 }
1965
1966 #[test]
1967 fn streaming_applies_recovered_links_in_strict_mode() {
1968 let mut doc = DoclingDocument::new("d");
1969 doc.add_paragraph("See LinkedIn for details.");
1970 doc.add_paragraph("And GitHub too.");
1971 doc.links = vec![
1972 ("LinkedIn".into(), "https://lnkd/".into()),
1973 ("GitHub".into(), "https://gh/".into()),
1974 ];
1975 assert_stream_matches(&doc, true, ImageMode::Placeholder, &[1]);
1978 }
1979
1980 fn paged_doc() -> DoclingDocument {
1983 let mut doc = DoclingDocument::new("p");
1984 doc.push(Node::PageInfo {
1985 page_no: 1,
1986 width: 100.0,
1987 height: 100.0,
1988 });
1989 doc.add_heading(1, "Title");
1990 doc.add_paragraph("Page one.");
1991 doc.push(Node::PageBreak);
1993 doc.push(Node::PageInfo {
1994 page_no: 2,
1995 width: 100.0,
1996 height: 100.0,
1997 });
1998 doc.push(Node::PageFurniture {
1999 footer: true,
2000 location: [0, 500, 511, 511],
2001 text: "2".into(),
2002 });
2003 doc.push(Node::PageBreak);
2004 doc.push(Node::PageInfo {
2005 page_no: 3,
2006 width: 100.0,
2007 height: 100.0,
2008 });
2009 doc.add_paragraph("Page three.");
2010 doc.push(Node::PageBreak);
2012 doc
2013 }
2014
2015 #[test]
2016 fn page_break_placeholder_lands_between_pages_only() {
2017 let mut doc = paged_doc();
2018 assert_eq!(
2020 doc.export_to_markdown(),
2021 "# Title\n\nPage one.\n\nPage three.\n"
2022 );
2023 doc.page_break_placeholder = Some("<!-- page break -->".into());
2024 assert_eq!(
2028 doc.export_to_markdown(),
2029 "# Title\n\nPage one.\n\n<!-- page break -->\n\nPage three.\n"
2030 );
2031 doc.page_break_placeholder = Some(String::new());
2034 assert_eq!(
2035 doc.export_to_markdown(),
2036 "# Title\n\nPage one.\n\n\n\nPage three.\n"
2037 );
2038 }
2039
2040 #[test]
2041 fn page_break_placeholder_never_leads_a_single_page() {
2042 let mut doc = DoclingDocument::new("one");
2043 doc.page_break_placeholder = Some("---".into());
2044 doc.push(Node::PageBreak);
2045 doc.push(Node::PageInfo {
2046 page_no: 1,
2047 width: 10.0,
2048 height: 10.0,
2049 });
2050 doc.add_paragraph("Only page.");
2051 assert_eq!(doc.export_to_markdown(), "Only page.\n");
2052 doc.push(Node::PageBreak);
2054 doc.push(Node::PageBreak);
2055 doc.add_paragraph("Next.");
2056 assert_eq!(doc.export_to_markdown(), "Only page.\n\n---\n\nNext.\n");
2057 }
2058
2059 #[test]
2060 fn page_break_placeholder_streams_byte_identical() {
2061 let mut doc = paged_doc();
2062 doc.page_break_placeholder = Some("<!-- page break -->".into());
2063 for splits in [
2067 &[3usize][..],
2068 &[3, 6],
2069 &[3, 6, 8],
2070 &[1, 2, 3, 4, 5, 6, 7, 8, 9],
2071 &[8],
2072 ] {
2073 assert_stream_matches(&doc, false, ImageMode::Placeholder, splits);
2074 assert_stream_matches(&doc, true, ImageMode::Placeholder, splits);
2075 }
2076 }
2077
2078 #[test]
2079 fn strict_tightens_punctuation_spacing_legacy_keeps_it() {
2080 let mut doc = DoclingDocument::new("t");
2081 doc.add_paragraph("see [ 37 , 36 ] and ( x ) .");
2082 assert_eq!(doc.export_to_markdown(), "see [ 37 , 36 ] and ( x ) .\n");
2084 assert_eq!(doc.export_to_markdown_with(true), "see [37, 36] and (x).\n");
2086 }
2087
2088 fn options_doc() -> DoclingDocument {
2093 use crate::document::ContentLayer;
2094 let mut doc = DoclingDocument::new("opts");
2095 doc.push(Node::PageFurniture {
2096 footer: false,
2097 location: [0, 0, 0, 0],
2098 text: "FAX COVER \\_ R&D".into(),
2099 });
2100 doc.add_heading(1, "R&D \\_ report");
2101 doc.add_paragraph("Body a\\_b <tag>");
2102 doc.push(Node::Picture {
2103 caption: Some("Figure 1".into()),
2104 caption_href: None,
2105 image: None,
2106 classification: None,
2107 caption_parent: Default::default(),
2108 caption_location: None,
2109 });
2110 doc.push(Node::PictureChildren(vec![
2111 Node::Paragraph {
2112 text: "Field: Name".into(),
2113 },
2114 Node::Paragraph {
2115 text: "Field: Date".into(),
2116 },
2117 ]));
2118 doc.push(Node::CommentSection {
2119 name: "comment-1".into(),
2120 text: "[author: A]: note".into(),
2121 refs_note_text: false,
2122 grouped: true,
2123 });
2124 doc.push(Node::Group {
2125 label: "section".into(),
2126 name: Some("sheet: Hidden".into()),
2127 layer: Some(ContentLayer::Invisible),
2128 children: vec![Node::Paragraph {
2129 text: "hidden cell".into(),
2130 }],
2131 });
2132 doc.push(Node::Furniture {
2133 layer: ContentLayer::Furniture,
2134 inner: Box::new(Node::Paragraph {
2135 text: "Page 1 of 2".into(),
2136 }),
2137 });
2138 doc.push(Node::FurnitureText {
2139 label: "page_footer".into(),
2140 text: "footer line".into(),
2141 });
2142 doc
2143 }
2144
2145 #[test]
2149 fn default_options_are_the_default_export() {
2150 let doc = options_doc();
2151 let plain = doc.export_to_markdown();
2152 assert_eq!(
2153 plain,
2154 "# R&D \\_ report\n\nBody a\\_b <tag>\n\nFigure 1\n\n<!-- image -->\n"
2155 );
2156 let (with_default, artifacts) =
2157 doc.export_to_markdown_with_options(&MarkdownExportOptions::default());
2158 assert_eq!(with_default, plain);
2159 assert!(artifacts.is_empty());
2160 }
2161
2162 #[test]
2163 fn layers_render_furniture_notes_and_hidden_sheets() {
2164 use crate::document::ContentLayer;
2165 let doc = options_doc();
2166 let md = |layers: ContentLayers| {
2167 doc.export_to_markdown_with_options(&MarkdownExportOptions {
2168 layers,
2169 ..MarkdownExportOptions::default()
2170 })
2171 .0
2172 };
2173 assert_eq!(
2174 md(ContentLayers::BODY.with(ContentLayer::Furniture)),
2175 "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",
2176 "a page header, a furniture-wrapped paragraph and a furniture text item render as paragraphs"
2177 );
2178 assert_eq!(
2179 md(ContentLayers::BODY.with(ContentLayer::Notes)),
2180 "# R&D \\_ report\n\nBody a\\_b <tag>\n\nFigure 1\n\n<!-- image -->\n\n[author: A]: note\n"
2181 );
2182 assert_eq!(
2183 md(ContentLayers::BODY.with(ContentLayer::Invisible)),
2184 "# R&D \\_ report\n\nBody a\\_b <tag>\n\nFigure 1\n\n<!-- image -->\n\nhidden cell\n",
2185 "a hidden sheet's items sit on the sheet's layer"
2186 );
2187 assert_eq!(
2188 md(ContentLayers::NONE.with(ContentLayer::Furniture)),
2189 "FAX COVER \\_ R&D\n\nPage 1 of 2\n\nfooter line\n",
2190 "without the body layer only the furniture prints"
2191 );
2192 assert_eq!(md(ContentLayers::NONE), "");
2193 assert_eq!(
2194 md(ContentLayers::ALL),
2195 "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"
2196 );
2197 }
2198
2199 #[test]
2202 fn layers_apply_to_list_items() {
2203 use crate::document::ContentLayer;
2204 let mut doc = DoclingDocument::new("lists");
2205 doc.push(Node::ListItem {
2206 ordered: false,
2207 number: 1,
2208 first_in_list: true,
2209 text: "nav link".into(),
2210 level: 0,
2211 marker: None,
2212 location: None,
2213 dclx: None,
2214 href: None,
2215 layer: Some(ContentLayer::Furniture),
2216 });
2217 doc.add_paragraph("body");
2218 assert_eq!(doc.export_to_markdown(), "body\n");
2219 let (md, _) = doc.export_to_markdown_with_options(&MarkdownExportOptions {
2220 layers: ContentLayers::BODY.with(ContentLayer::Furniture),
2221 ..MarkdownExportOptions::default()
2222 });
2223 assert_eq!(md, "- nav link\n\nbody\n");
2224 }
2225
2226 #[test]
2227 fn traverse_pictures_prints_the_nested_text_after_the_picture() {
2228 let doc = options_doc();
2229 let (md, _) = doc.export_to_markdown_with_options(&MarkdownExportOptions {
2230 traverse_pictures: true,
2231 ..MarkdownExportOptions::default()
2232 });
2233 assert_eq!(
2234 md,
2235 "# R&D \\_ report\n\nBody a\\_b <tag>\n\nFigure 1\n\n<!-- image -->\n\nField: Name\n\nField: Date\n"
2236 );
2237 }
2238
2239 #[test]
2240 fn escaping_can_be_turned_off_per_kind() {
2241 let doc = options_doc();
2242 let (md, _) = doc.export_to_markdown_with_options(&MarkdownExportOptions {
2243 escape_html: false,
2244 ..MarkdownExportOptions::default()
2245 });
2246 assert_eq!(
2247 md,
2248 "# R&D \\_ report\n\nBody a\\_b <tag>\n\nFigure 1\n\n<!-- image -->\n"
2249 );
2250 let (md, _) = doc.export_to_markdown_with_options(&MarkdownExportOptions {
2251 escape_underscores: false,
2252 ..MarkdownExportOptions::default()
2253 });
2254 assert_eq!(
2255 md,
2256 "# R&D _ report\n\nBody a_b <tag>\n\nFigure 1\n\n<!-- image -->\n"
2257 );
2258 let mut doc = DoclingDocument::new("code");
2261 doc.push(Node::Code {
2262 language: None,
2263 text: "a & b \\_ c".into(),
2264 orig: None,
2265 pretty: None,
2266 });
2267 let (md, _) = doc.export_to_markdown_with_options(&MarkdownExportOptions {
2268 escape_html: false,
2269 escape_underscores: false,
2270 ..MarkdownExportOptions::default()
2271 });
2272 assert_eq!(md, "```\na & b \\_ c\n```\n");
2273 }
2274
2275 #[test]
2276 fn image_placeholder_is_configurable() {
2277 let doc = options_doc();
2278 let (md, _) = doc.export_to_markdown_with_options(&MarkdownExportOptions {
2279 image_placeholder: "[figure]".into(),
2280 ..MarkdownExportOptions::default()
2281 });
2282 assert!(md.contains("Figure 1\n\n[figure]\n"), "{md}");
2283 assert!(!md.contains("<!-- image -->"));
2284 }
2285
2286 #[test]
2292 fn empty_image_placeholder_prints_nothing() {
2293 fn picture(caption: Option<&str>) -> Node {
2294 Node::Picture {
2295 caption: caption.map(Into::into),
2296 caption_href: None,
2297 image: None,
2298 classification: None,
2299 caption_parent: Default::default(),
2300 caption_location: None,
2301 }
2302 }
2303 fn page(no: usize) -> Node {
2304 Node::PageInfo {
2305 page_no: no,
2306 width: 100.0,
2307 height: 100.0,
2308 }
2309 }
2310 fn para(text: &str) -> Node {
2311 Node::Paragraph { text: text.into() }
2312 }
2313 let options = MarkdownExportOptions {
2314 image_placeholder: String::new(),
2315 ..MarkdownExportOptions::default()
2316 };
2317 let cases: Vec<(Vec<Node>, Option<&str>, &str)> = vec![
2319 (
2320 vec![page(1), para("a"), picture(None), para("b")],
2321 None,
2322 "a\n\nb\n",
2323 ),
2324 (
2325 vec![page(1), para("a"), picture(Some("Figure 1")), para("b")],
2326 None,
2327 "a\n\nFigure 1\n\nb\n",
2328 ),
2329 (
2330 vec![
2331 page(1),
2332 para("a"),
2333 page(2),
2334 picture(None),
2335 page(3),
2336 para("b"),
2337 ],
2338 Some("<!-- pb -->"),
2339 "a\n\n<!-- pb -->\n\n<!-- pb -->\n\nb\n",
2340 ),
2341 (
2342 vec![
2343 page(1),
2344 para("a"),
2345 page(2),
2346 picture(None),
2347 page(3),
2348 para("b"),
2349 ],
2350 Some(""),
2351 "a\n\n\n\n\n\nb\n",
2352 ),
2353 (
2354 vec![page(1), picture(None), page(2), para("b")],
2355 Some("<!-- pb -->"),
2356 "<!-- pb -->\n\nb\n",
2357 ),
2358 (
2359 vec![page(1), picture(None), page(2), para("b")],
2360 Some(""),
2361 "\n\nb\n",
2362 ),
2363 (
2364 vec![page(1), para("a"), page(2), picture(None)],
2365 Some("<!-- pb -->"),
2366 "a\n\n<!-- pb -->\n",
2367 ),
2368 ];
2369 for (nodes, placeholder, expected) in cases {
2370 let mut doc = DoclingDocument::new("t");
2371 doc.page_break_placeholder = placeholder.map(Into::into);
2372 for node in nodes {
2373 doc.push(node);
2374 }
2375 let (md, _) = doc.export_to_markdown_with_options(&options);
2376 assert_eq!(md, expected, "page break {placeholder:?}");
2377 for size in 1..=doc.nodes.len() {
2380 let mut streamer = MarkdownStreamer::new(false, ImageMode::Placeholder, false)
2381 .with_export_options(&options)
2382 .with_page_break_placeholder(placeholder.map(Into::into));
2383 let mut out = String::new();
2384 for chunk in doc.nodes.chunks(size) {
2385 out.push_str(&streamer.push(chunk, &[]));
2386 }
2387 out.push_str(&streamer.finish());
2388 assert_eq!(
2389 out, expected,
2390 "chunks of {size}, page break {placeholder:?}"
2391 );
2392 }
2393 }
2394 let mut doc = DoclingDocument::new("t");
2396 doc.push(para("a"));
2397 doc.push(picture(None));
2398 doc.push(para("b"));
2399 assert_eq!(doc.export_to_markdown(), "a\n\n<!-- image -->\n\nb\n");
2400 }
2401
2402 #[test]
2405 fn streamer_matches_buffered_export_with_options() {
2406 use crate::document::ContentLayer;
2407 let doc = options_doc();
2408 let options = MarkdownExportOptions {
2409 layers: ContentLayers::ALL,
2410 traverse_pictures: true,
2411 escape_html: false,
2412 escape_underscores: false,
2413 image_placeholder: "(img)".into(),
2414 ..MarkdownExportOptions::default()
2415 };
2416 let (buffered, _) = doc.export_to_markdown_with_options(&options);
2417 let mut streamer = MarkdownStreamer::new(false, ImageMode::Placeholder, false)
2418 .with_export_options(&options);
2419 let mut out = String::new();
2420 for chunk in doc.nodes.chunks(2) {
2421 out.push_str(&streamer.push(chunk, &[]));
2422 }
2423 out.push_str(&streamer.finish());
2424 assert_eq!(out, buffered);
2425 assert!(
2426 buffered.contains("(img)") && buffered.contains("R&D _ report"),
2427 "{buffered}"
2428 );
2429 let _ = ContentLayer::Notes;
2430 }
2431}