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 layers: ContentLayers,
310 traverse_pictures: bool,
311 escape_html: bool,
312 escape_underscores: bool,
313 image_placeholder: String,
314}
315
316impl MarkdownStreamer {
317 pub fn new(strict: bool, images: ImageMode, compact_tables: bool) -> Self {
320 debug_assert!(
321 images != ImageMode::Referenced,
322 "referenced image mode needs an artifacts dir; use with_artifacts"
323 );
324 Self::with_artifacts(strict, images, compact_tables, "artifacts")
325 }
326
327 pub fn with_artifacts(
334 strict: bool,
335 images: ImageMode,
336 compact_tables: bool,
337 artifacts_dir: &str,
338 ) -> Self {
339 Self {
340 strict,
341 images,
342 compact_tables,
343 emitted_any: false,
344 links: Vec::new(),
345 artifacts_dir: artifacts_dir.to_string(),
346 artifacts: Vec::new(),
347 pic_index: 0,
348 page_break: None,
349 pending_page_break: false,
350 layers: ContentLayers::BODY,
351 traverse_pictures: false,
352 escape_html: true,
353 escape_underscores: true,
354 image_placeholder: "<!-- image -->".to_string(),
355 }
356 }
357
358 pub fn with_export_options(mut self, options: &MarkdownExportOptions) -> Self {
365 self.images = options.image_mode;
366 self.artifacts_dir = options.artifacts_dir.clone();
367 self.layers = options.layers;
368 self.traverse_pictures = options.traverse_pictures;
369 self.escape_html = options.escape_html;
370 self.escape_underscores = options.escape_underscores;
371 self.image_placeholder = options.image_placeholder.clone();
372 self
373 }
374
375 pub fn with_page_break_placeholder(mut self, placeholder: Option<String>) -> Self {
380 self.page_break = placeholder;
381 self
382 }
383
384 pub fn take_artifacts(&mut self) -> Vec<(String, Vec<u8>)> {
389 std::mem::take(&mut self.artifacts)
390 }
391
392 pub fn push(&mut self, nodes: &[Node], links: &[(String, String)]) -> String {
397 self.links.extend(links.iter().cloned());
398 let mut ctx = Ctx {
399 strict: self.strict,
400 compact_tables: self.compact_tables,
401 images: self.images,
402 artifacts_dir: std::mem::take(&mut self.artifacts_dir),
403 artifacts: std::mem::take(&mut self.artifacts),
404 pic_index: self.pic_index,
405 in_table_cell: false,
406 page_break: std::mem::take(&mut self.page_break),
407 pending_page_break: self.pending_page_break,
408 emitted_any: self.emitted_any,
409 layers: self.layers,
410 current_layer: None,
411 traverse_pictures: self.traverse_pictures,
412 escape_html: self.escape_html,
413 escape_underscores: self.escape_underscores,
414 image_placeholder: std::mem::take(&mut self.image_placeholder),
415 };
416 let mut blocks: Vec<String> = Vec::new();
417 render(nodes, &mut blocks, &mut ctx);
418 self.image_placeholder = std::mem::take(&mut ctx.image_placeholder);
419 self.artifacts_dir = std::mem::take(&mut ctx.artifacts_dir);
420 self.artifacts = std::mem::take(&mut ctx.artifacts);
421 self.pic_index = ctx.pic_index;
422 self.page_break = std::mem::take(&mut ctx.page_break);
423 self.pending_page_break = ctx.pending_page_break;
424 if blocks.is_empty() {
425 return String::new();
426 }
427 let mut body = blocks.join("\n\n");
428 if self.strict && !self.links.is_empty() {
429 body = apply_links_chunk(&body, &mut self.links);
430 }
431 let chunk = if self.emitted_any {
432 format!("\n\n{body}")
433 } else {
434 body
435 };
436 self.emitted_any = true;
437 chunk
438 }
439
440 pub fn finish(self) -> String {
443 if self.emitted_any {
444 "\n".to_string()
445 } else {
446 String::new()
447 }
448 }
449}
450
451fn strict_text(text: &str, strict: bool) -> String {
459 if !strict {
460 return text.to_string();
461 }
462 text.replace("\\_", "_")
463 .replace(" ,", ",")
464 .replace(" .", ".")
465 .replace(" ;", ";")
466 .replace(" )", ")")
467 .replace("( ", "(")
468 .replace(" ]", "]")
469 .replace("[ ", "[")
470}
471
472fn md_line_breaks(text: &str) -> String {
478 if !text.contains('\n') {
479 return text.to_string();
480 }
481 text.split("\n\n")
482 .map(|para| para.replace('\n', " \n"))
483 .collect::<Vec<_>>()
484 .join("\n\n")
485}
486
487pub(crate) fn strip_hard_breaks(text: &str) -> String {
492 if text.contains(" \n") {
493 text.replace(" \n", "\n")
494 } else {
495 text.to_string()
496 }
497}
498
499fn heading_line_breaks(text: &str) -> String {
503 text.replace('\n', " ")
504}
505
506fn render(nodes: &[Node], blocks: &mut Vec<String>, ctx: &mut Ctx) {
507 let mut i = 0;
508 while i < nodes.len() {
509 let before = blocks.len();
510 match &nodes[i] {
511 Node::PageBreak | Node::PageInfo { .. } => {
521 if ctx.page_break.is_some() && ctx.emitted_any {
522 ctx.pending_page_break = true;
523 }
524 i += 1;
525 }
526 Node::ListItem { .. } => {
527 let start = i;
528 i += 1;
529 loop {
530 match nodes.get(i) {
531 Some(Node::ListItem { .. }) => i += 1,
532 Some(Node::Paragraph { text })
536 if text.is_empty()
537 && matches!(nodes.get(i + 1), Some(Node::ListItem { .. })) =>
538 {
539 i += 1
540 }
541 _ => break,
542 }
543 }
544 render_list_run(&nodes[start..i], blocks, ctx);
545 }
546 other => {
547 render_one(other, blocks, ctx);
548 i += 1;
549 }
550 }
551 if blocks.len() > before {
552 if ctx.pending_page_break {
553 if let Some(placeholder) = &ctx.page_break {
558 blocks.insert(before, placeholder.clone());
559 }
560 ctx.pending_page_break = false;
561 }
562 ctx.emitted_any = true;
563 }
564 }
565}
566
567fn render_list_run(items: &[Node], blocks: &mut Vec<String>, ctx: &Ctx) {
573 let mut lines: Vec<String> = Vec::new();
574 let mut any_top = false;
577
578 for item in items {
579 let Node::ListItem {
580 ordered,
581 number,
582 first_in_list,
583 text,
584 level,
585 marker: orig_marker,
586 location: _,
587 dclx: _,
588 href: _,
589 layer,
590 } = item
591 else {
592 continue;
593 };
594 if !ctx.layers.contains(layer.or(ctx.current_layer)) {
598 continue;
599 }
600 let level = *level as usize;
601
602 if level == 0 {
611 if any_top && *first_in_list {
612 lines.push(String::new());
613 }
614 any_top = true;
615 }
616
617 let indent = " ".repeat(level);
618 let verbatim = orig_marker.as_deref().filter(|m| {
623 m.strip_suffix('.')
624 .is_some_and(|d| !d.is_empty() && d.chars().all(char::is_numeric))
625 });
626 let marker = match verbatim {
627 Some(m) if *ordered => m.to_string(),
628 _ if *ordered => format!("{number}."),
629 _ => "-".to_string(),
630 };
631 lines.push(format!("{indent}{marker} {}", list_item_text(text, ctx)));
632 }
633
634 if !lines.is_empty() {
637 blocks.push(lines.join("\n"));
638 }
639}
640
641fn list_item_text(text: &str, ctx: &Ctx) -> String {
649 let escaped = ctx.text(text);
650 if let Some((own, tail)) = escaped.split_once('\n') {
651 if is_folded_child_tail(tail) {
652 return format!("{}\n{tail}", md_line_breaks(own));
653 }
654 }
655 md_line_breaks(&escaped)
656}
657
658fn is_folded_child_tail(tail: &str) -> bool {
665 const MARKER: &str = "<!-- image -->";
666 const FENCE: &str = "```";
667 let mut lines = tail.split('\n').peekable();
668 let mut any = false;
669 while let Some(line) = lines.next() {
670 let line = line.trim_start();
671 if line == MARKER {
672 any = true;
673 } else if line == FENCE {
674 loop {
676 match lines.next() {
677 Some(l) if l.trim_start() == FENCE => break,
678 Some(_) => {}
679 None => return false,
680 }
681 }
682 any = true;
683 } else if lines.next().map(str::trim_start) == Some(MARKER) {
684 any = true; } else {
686 return false;
687 }
688 }
689 any
690}
691
692fn render_one(node: &Node, blocks: &mut Vec<String>, ctx: &mut Ctx) {
697 match node {
698 Node::Group { .. }
700 | Node::Furniture { .. }
701 | Node::PageFurniture { .. }
702 | Node::FurnitureText { .. }
703 | Node::CommentSection { .. }
704 | Node::PictureChildren(_)
705 | Node::Commented { .. }
706 | Node::Located { .. }
707 | Node::Prov { .. }
708 | Node::PageBreak
709 | Node::PageInfo { .. }
710 | Node::DoclangOnly(_) => {}
711 _ if !ctx.layer_visible() => return,
712 _ => {}
713 }
714 render_item(node, blocks, ctx)
715}
716
717fn render_item(node: &Node, blocks: &mut Vec<String>, ctx: &mut Ctx) {
718 match node {
719 Node::Heading { level, text } => {
720 let text = heading_line_breaks(&ctx.text(text));
721 if ctx.in_table_cell {
722 blocks.push(text);
724 } else {
725 let hashes = "#".repeat((*level).clamp(1, 6) as usize);
726 blocks.push(format!("{hashes} {text}"));
727 }
728 }
729 Node::Paragraph { text } if text.is_empty() => {}
732 Node::Paragraph { text } => blocks.push(md_line_breaks(&ctx.text(text))),
733 Node::Caption { text, .. } if text.is_empty() => {}
736 Node::Caption { text, href } => {
737 let body = md_line_breaks(&ctx.text(text));
738 blocks.push(match href {
739 Some(url) => format!("[{body}]({url})"),
740 None => body,
741 });
742 }
743 Node::CheckboxItem { checked, text } => {
744 let mark = if *checked { "- [x] " } else { "- [ ] " };
745 blocks.push(md_line_breaks(&ctx.text(&format!("{mark}{text}"))));
746 }
747 Node::Code {
748 language,
749 text,
750 pretty,
751 ..
752 } => {
753 let lang = match language {
755 Some(l) if ctx.strict => l.as_str(),
756 _ => "",
757 };
758 let body = match pretty {
761 Some(p) if ctx.strict => p.as_str(),
762 _ => text.as_str(),
763 };
764 blocks.push(format!("```{lang}\n{body}\n```"));
765 }
766 Node::Formula { latex, .. } => blocks.push(format!("$${latex}$$")),
769 Node::Table(table) => {
770 if let Some(cap) = &table.caption {
773 if !cap.is_empty() {
774 blocks.push(md_line_breaks(&ctx.text(cap)));
775 }
776 }
777 let rendered = render_table(table, ctx.compact_tables);
778 if !rendered.is_empty() {
779 blocks.push(rendered);
780 }
781 }
782 Node::Picture { caption, image, .. } => {
784 if let Some(cap) = caption {
785 if !cap.is_empty() {
786 blocks.push(md_line_breaks(&ctx.unescape(cap)));
787 }
788 }
789 blocks.push(picture_marker(image.as_ref(), ctx));
790 }
791 Node::Chart {
795 kind,
796 table,
797 caption,
798 ..
799 } => {
800 if let Some(cap) = caption {
801 if !cap.is_empty() {
802 blocks.push(md_line_breaks(&ctx.unescape(cap)));
803 }
804 }
805 blocks.push(picture_marker(None, ctx));
806 blocks.push(humanize_label(kind));
807 let rendered = render_table(table, false);
808 if !rendered.is_empty() {
809 blocks.push(rendered);
810 }
811 }
812 Node::DoclangOnly(_) => {}
814 Node::Group {
819 layer: Some(layer),
820 children,
821 ..
822 } => {
823 let outer = ctx.current_layer.replace(*layer);
824 render(children, blocks, ctx);
825 ctx.current_layer = outer;
826 }
827 Node::Group { children, .. } => render(children, blocks, ctx),
828 Node::FieldRegion { items } => {
829 for item in items {
834 for part in [&item.marker, &item.key, &item.value].into_iter().flatten() {
835 blocks.push(md_line_breaks(&ctx.text(part)));
836 }
837 }
838 }
839 Node::KeyValueGraph { .. } => blocks.push(MISSING_KEY_VALUE_ITEM.to_string()),
842 Node::InlineGroup { md_text, .. } => blocks.push(md_line_breaks(&ctx.text(md_text))),
845 Node::TextDump(text) => {
847 if !text.is_empty() {
848 blocks.push(text.clone());
849 }
850 }
851 Node::Furniture { layer, inner } => {
855 let outer = ctx.current_layer.replace(*layer);
856 render_one(inner, blocks, ctx);
857 ctx.current_layer = outer;
858 }
859 Node::PageFurniture { text, .. } | Node::FurnitureText { text, .. } => {
863 if ctx.layers.furniture && !text.is_empty() {
864 blocks.push(md_line_breaks(&ctx.text(text)));
865 }
866 }
867 Node::PictureChildren(children) => {
872 if ctx.traverse_pictures {
873 render(children, blocks, ctx);
874 }
875 }
876 Node::CommentSection { text, .. } => {
881 if ctx.layers.notes && !text.is_empty() {
882 blocks.push(md_line_breaks(&ctx.text(text)));
883 }
884 }
885 Node::Commented { inner, .. } => render_one(inner, blocks, ctx),
886 Node::Located { inner, .. } | Node::Prov { inner, .. } => render_one(inner, blocks, ctx),
888 Node::PageBreak => {}
890 Node::PageInfo { .. } => {}
892 Node::ListItem { .. } => render_list_run(std::slice::from_ref(node), blocks, ctx),
897 }
898}
899
900fn humanize_label(label: &str) -> String {
905 let text = label.replace('_', " ");
906 let mut chars = text.chars();
907 match chars.next() {
908 Some(f) => f.to_uppercase().collect::<String>() + chars.as_str(),
909 None => text,
910 }
911}
912
913fn picture_marker(image: Option<&crate::PictureImage>, ctx: &mut Ctx) -> String {
914 match (ctx.images, image) {
915 (ImageMode::Embedded, Some(img)) => {
917 format!("", crate::pixel_digest::docling_data_uri(img).1)
918 }
919 (ImageMode::Referenced, Some(img)) => {
920 let path = format!(
921 "{}/image_{:06}.{}",
922 ctx.artifacts_dir,
923 ctx.pic_index,
924 ext_for(&img.mimetype)
925 );
926 ctx.pic_index += 1;
927 ctx.artifacts.push((path.clone(), img.data.clone()));
928 format!("", escape_uri_path(&path))
929 }
930 _ => ctx.image_placeholder.clone(),
932 }
933}
934
935pub(crate) fn escape_uri_path(value: &str) -> String {
948 const KEEP: &str = "/%:@+,;=~$!&'*";
949 let s = value.replace('\\', "/");
950 if let Some(rest) = s.strip_prefix("//") {
951 let rest = rest.trim_start_matches('/');
953 let (host, tail) = rest.split_once('/').unwrap_or((rest, ""));
954 return format!("file://{host}{}", percent_quote(&format!("/{tail}"), KEEP));
955 }
956 let bytes = s.as_bytes();
957 if bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'/' {
958 return format!("file:///{}", percent_quote(&s, KEEP));
960 }
961 if let Some((scheme, rest)) = s.split_once(':') {
965 let valid_scheme = scheme.len() > 1
966 && scheme.as_bytes()[0].is_ascii_alphabetic()
967 && scheme
968 .bytes()
969 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'-' | b'.'));
970 if valid_scheme {
971 let (authority, rest) = match rest.strip_prefix("//") {
972 Some(r) => {
973 let end = r.find(['/', '?', '#']).unwrap_or(r.len());
974 (Some(&r[..end]), &r[end..])
975 }
976 None => (None, rest),
977 };
978 let (before_frag, fragment) = rest.split_once('#').unwrap_or((rest, ""));
979 let (path, query) = before_frag.split_once('?').unwrap_or((before_frag, ""));
980 let mut out = format!("{scheme}:");
981 if let Some(a) = authority {
982 out.push_str("//");
983 out.push_str(a);
984 }
985 out.push_str(&percent_quote(path, KEEP));
986 if !query.is_empty() {
987 out.push('?');
988 out.push_str(&percent_quote(query, KEEP));
989 }
990 if !fragment.is_empty() {
991 out.push('#');
992 out.push_str(&percent_quote(fragment, KEEP));
993 }
994 return out;
995 }
996 }
997 percent_quote(&s, KEEP)
999}
1000
1001fn percent_quote(s: &str, safe: &str) -> String {
1004 let mut out = String::with_capacity(s.len());
1005 for &b in s.as_bytes() {
1006 let keep = b.is_ascii_alphanumeric()
1007 || matches!(b, b'_' | b'.' | b'-' | b'~')
1008 || (b.is_ascii() && safe.contains(b as char));
1009 if keep {
1010 out.push(b as char);
1011 } else {
1012 out.push_str(&format!("%{b:02X}"));
1013 }
1014 }
1015 out
1016}
1017
1018pub(crate) fn ext_for(mimetype: &str) -> &str {
1019 match mimetype {
1020 "image/jpeg" => "jpg",
1021 "image/gif" => "gif",
1022 "image/webp" => "webp",
1023 "image/bmp" => "bmp",
1024 "image/tiff" => "tif",
1025 _ => "png",
1026 }
1027}
1028
1029fn is_number_cell(t: &str) -> bool {
1048 t.parse::<f64>().is_ok() || is_thousands_number(t)
1049}
1050
1051fn is_thousands_number(t: &str) -> bool {
1057 let b = t.as_bytes();
1058 let mut i = 0;
1059 let start = i;
1060 if i < b.len() && (b[i] == b'+' || b[i] == b'-') {
1061 i += 1;
1062 }
1063 let d0 = i;
1065 while i < b.len() && b[i].is_ascii_digit() && i - d0 < 3 {
1066 i += 1;
1067 }
1068 let has_int = i > d0;
1069 if has_int {
1070 while i + 3 < b.len() + 1
1072 && b.get(i) == Some(&b',')
1073 && b.get(i + 1).is_some_and(u8::is_ascii_digit)
1074 && b.get(i + 2).is_some_and(u8::is_ascii_digit)
1075 && b.get(i + 3).is_some_and(u8::is_ascii_digit)
1076 {
1077 i += 4;
1078 }
1079 } else {
1080 i = start;
1082 }
1083 if i < b.len() && b[i] == b'.' {
1085 i += 1;
1086 let f0 = i;
1087 while i < b.len() && b[i].is_ascii_digit() {
1088 i += 1;
1089 }
1090 if !has_int && i == f0 {
1091 return false; }
1093 } else if !has_int {
1094 return false; }
1096 i == b.len()
1097}
1098
1099fn flatten_header_rows(header_rows: &[Vec<String>], num_cols: usize) -> Vec<String> {
1108 (0..num_cols)
1109 .map(|c| {
1110 let mut parts: Vec<&str> = Vec::new();
1111 for row in header_rows {
1112 let text = row.get(c).map(String::as_str).unwrap_or("");
1113 if !text.is_empty() && parts.last() != Some(&text) {
1114 parts.push(text);
1115 }
1116 }
1117 parts.join(" - ")
1118 })
1119 .collect()
1120}
1121
1122pub(crate) fn render_table(table: &Table, compact: bool) -> String {
1123 if table.rows.is_empty() {
1124 return String::new();
1125 }
1126 let num_cols = table.rows.iter().map(Vec::len).max().unwrap_or(0);
1127 if num_cols == 0 {
1128 return String::new();
1129 }
1130
1131 let num_headers = table.header_row_count().min(table.rows.len());
1136 let escaped = |r: usize| -> Vec<String> {
1137 (0..num_cols)
1138 .map(|c| escape_cell(table.rows[r].get(c).map(String::as_str).unwrap_or("")))
1139 .collect()
1140 };
1141 let header_rows: Vec<Vec<String>> = (0..num_headers).map(escaped).collect();
1142 let header = flatten_header_rows(&header_rows, num_cols);
1143 let body: Vec<Vec<String>> = (num_headers..table.rows.len())
1144 .map(|r| {
1145 escaped(r)
1146 .into_iter()
1147 .map(|c| c.trim().to_string())
1148 .collect()
1149 })
1150 .collect();
1151
1152 if compact {
1153 let render_row = |row: &[String]| -> String { format!("| {} |", row.join(" | ")) };
1155 let mut lines = Vec::with_capacity(body.len() + 2);
1156 lines.push(render_row(&header));
1157 let sep: Vec<&str> = (0..num_cols).map(|_| "-").collect();
1158 lines.push(format!("| {} |", sep.join(" | ")));
1159 for row in &body {
1160 lines.push(render_row(row));
1161 }
1162 return lines.join("\n");
1163 }
1164
1165 let dw = |s: &str| s.chars().count();
1167
1168 let right: Vec<bool> = (0..num_cols)
1173 .map(|c| {
1174 let mut any = false;
1175 for row in &body {
1176 let t = row[c].trim();
1177 if t.is_empty() {
1178 continue;
1179 }
1180 if !is_number_cell(t) {
1181 return false;
1182 }
1183 any = true;
1184 }
1185 any
1186 })
1187 .collect();
1188
1189 let width: Vec<usize> = (0..num_cols)
1191 .map(|c| {
1192 let mut w = dw(&header[c]) + 2;
1193 for row in &body {
1194 w = w.max(dw(&row[c]));
1195 }
1196 w
1197 })
1198 .collect();
1199
1200 let fmt_cell = |s: &str, c: usize| -> String {
1201 let pad = " ".repeat(width[c].saturating_sub(dw(s)));
1202 let body = if right[c] {
1203 format!("{pad}{s}")
1204 } else {
1205 format!("{s}{pad}")
1206 };
1207 format!(" {body} ")
1208 };
1209 let render_row = |row: &[String]| -> String {
1210 let cells: Vec<String> = (0..num_cols).map(|c| fmt_cell(&row[c], c)).collect();
1211 format!("|{}|", cells.join("|"))
1212 };
1213
1214 let mut lines = Vec::with_capacity(body.len() + 2);
1215 lines.push(render_row(&header));
1216 let sep: Vec<String> = (0..num_cols).map(|c| "-".repeat(width[c] + 2)).collect();
1217 lines.push(format!("|{}|", sep.join("|")));
1218 for row in &body {
1219 lines.push(render_row(row));
1220 }
1221 lines.join("\n")
1222}
1223
1224fn escape_cell(s: &str) -> String {
1227 s.replace('\n', " ").replace('|', "|")
1228}
1229
1230#[cfg(test)]
1231mod tests {
1232 use super::*;
1233 use crate::{PictureImage, TableCell, TableStructure};
1234
1235 #[test]
1241 fn list_boundaries_come_from_the_backend_not_the_numbering() {
1242 let item = |ordered: bool, number: u64, first_in_list: bool, text: &str| Node::ListItem {
1243 ordered,
1244 number,
1245 first_in_list,
1246 text: text.into(),
1247 level: 0,
1248 marker: None,
1249 location: None,
1250 dclx: None,
1251 href: None,
1252 layer: None,
1253 };
1254 let md = |items: Vec<Node>| {
1255 let mut doc = DoclingDocument::new("t");
1256 for n in items {
1257 doc.push(n);
1258 }
1259 doc.export_to_markdown()
1260 };
1261 assert_eq!(
1263 md(vec![
1264 item(true, 1, true, "one"),
1265 item(true, 5, false, "five")
1266 ]),
1267 "1. one\n5. five\n"
1268 );
1269 assert_eq!(
1271 md(vec![
1272 item(false, 0, true, "bullet"),
1273 item(true, 1, false, "one"),
1274 item(false, 0, false, "bullet two"),
1275 ]),
1276 "- bullet\n1. one\n- bullet two\n"
1277 );
1278 assert_eq!(
1280 md(vec![
1281 item(true, 1, true, "a"),
1282 item(true, 2, false, "b"),
1283 item(true, 3, true, "new list, continuing count"),
1284 ]),
1285 "1. a\n2. b\n\n3. new list, continuing count\n"
1286 );
1287 }
1288
1289 #[test]
1290 fn renders_headings_paragraphs_and_lists() {
1291 let mut doc = DoclingDocument::new("demo");
1292 doc.add_heading(1, "Title");
1293 doc.add_paragraph("Hello world.");
1294 doc.push(Node::ListItem {
1295 ordered: false,
1296 number: 1,
1297 first_in_list: true,
1298 text: "first".into(),
1299 level: 0,
1300 marker: None,
1301 location: None,
1302 dclx: None,
1303 href: None,
1304 layer: None,
1305 });
1306 doc.push(Node::ListItem {
1307 ordered: false,
1308 number: 2,
1309 first_in_list: false,
1310 text: "second".into(),
1311 level: 0,
1312 marker: None,
1313 location: None,
1314 dclx: None,
1315 href: None,
1316 layer: None,
1317 });
1318 let md = doc.export_to_markdown();
1319 assert_eq!(md, "# Title\n\nHello world.\n\n- first\n- second\n");
1320 }
1321
1322 #[test]
1326 fn single_newlines_become_gfm_hard_line_breaks() {
1327 let mut doc = DoclingDocument::new("t");
1328 doc.push(Node::Heading {
1329 level: 1,
1330 text: "Hello\nWorld".into(),
1331 });
1332 doc.push(Node::Paragraph {
1333 text: "line one\nline two\n\npara two".into(),
1334 });
1335 doc.push(Node::ListItem {
1336 ordered: false,
1337 number: 1,
1338 first_in_list: true,
1339 text: "item\ncontinued".into(),
1340 level: 0,
1341 marker: None,
1342 location: None,
1343 dclx: None,
1344 href: None,
1345 layer: None,
1346 });
1347 doc.push(Node::TextDump("A1 B1 \n\n\nC1".into()));
1348 assert_eq!(
1349 doc.export_to_markdown(),
1350 "# Hello World\n\nline one \nline two\n\npara two\n\n- item \ncontinued\n\nA1 B1 \n\n\nC1\n"
1351 );
1352 }
1353
1354 #[test]
1357 fn table_cell_mode_and_field_regions() {
1358 let mut doc = DoclingDocument::new("t");
1359 doc.push(Node::Heading {
1360 level: 2,
1361 text: "A text".into(),
1362 });
1363 doc.push(Node::Paragraph {
1364 text: "body".into(),
1365 });
1366 assert_eq!(to_markdown_table_cell(&doc, false), "A text\n\nbody");
1367 assert_eq!(doc.export_to_markdown(), "## A text\n\nbody\n");
1368
1369 let mut doc = DoclingDocument::new("f");
1370 doc.push(Node::FieldRegion {
1371 items: vec![crate::FieldItem {
1372 marker: None,
1373 key: Some("Name:".into()),
1374 value: Some("John Doe".into()),
1375 value_kind: None,
1376 }],
1377 });
1378 assert_eq!(doc.export_to_markdown(), "Name:\n\nJohn Doe\n");
1379 }
1380
1381 #[test]
1382 fn strict_renders_recovered_links_legacy_does_not() {
1383 let mut doc = DoclingDocument::new("cv");
1384 doc.add_paragraph("Find me on LinkedIn or GitHub.");
1385 doc.links = vec![
1386 ("LinkedIn".into(), "https://www.linkedin.com/in/x/".into()),
1387 ("GitHub".into(), "https://github.com/x/".into()),
1388 ];
1389 assert_eq!(doc.export_to_markdown(), "Find me on LinkedIn or GitHub.\n");
1391 assert_eq!(
1393 doc.export_to_markdown_with(true),
1394 "Find me on [LinkedIn](https://www.linkedin.com/in/x/) or [GitHub](https://github.com/x/).\n"
1395 );
1396 }
1397
1398 #[test]
1399 fn strict_links_match_escaped_anchor_and_consume_in_order() {
1400 let mut doc = DoclingDocument::new("d");
1401 doc.add_paragraph("AI & ML here, and issues here, then issues there.");
1405 doc.links = vec![
1406 ("AI & ML".into(), "https://a/".into()),
1407 ("issues".into(), "https://first/".into()),
1408 ("issues".into(), "https://second/".into()),
1409 ];
1410 assert_eq!(
1411 doc.export_to_markdown_with(true),
1412 "[AI & ML](https://a/) here, and [issues](https://first/) here, then [issues](https://second/) there.\n"
1413 );
1414 }
1415
1416 #[test]
1420 fn referenced_image_destinations_are_escaped() {
1421 let cases = [
1422 (
1423 "doc_artifacts/image_000001_ab12.png",
1424 "doc_artifacts/image_000001_ab12.png",
1425 ),
1426 (
1427 "My Report_artifacts/img.png",
1428 "My%20Report_artifacts/img.png",
1429 ),
1430 ("artifacts/img (1).png", "artifacts/img%20%281%29.png"),
1431 ("100%_scale/a#b?c.png", "100%_scale/a%23b%3Fc.png"),
1432 ("/home/a b/img.png", "/home/a%20b/img.png"),
1433 (
1434 "My Report_artifacts\\img.png",
1435 "My%20Report_artifacts/img.png",
1436 ),
1437 (
1438 "C:/Users/me/My Docs/img.png",
1439 "file:///C:/Users/me/My%20Docs/img.png",
1440 ),
1441 ("C:\\Users\\me\\img.png", "file:///C:/Users/me/img.png"),
1442 (
1443 "//server/share/My Docs/img.png",
1444 "file://server/share/My%20Docs/img.png",
1445 ),
1446 ("\\\\server\\share\\img.png", "file://server/share/img.png"),
1447 ("file:///home/a b/img.png", "file:///home/a%20b/img.png"),
1448 (
1449 "s3://bucket/My Report_artifacts/img.png",
1450 "s3://bucket/My%20Report_artifacts/img.png",
1451 ),
1452 (
1453 "https://example.com:8080/a b.png?w=1&h=2#frag",
1454 "https://example.com:8080/a%20b.png?w=1&h=2#frag",
1455 ),
1456 (
1457 "https://example.com/img (1).png",
1458 "https://example.com/img%20%281%29.png",
1459 ),
1460 ("caf\u{e9}/im\u{e4}ge.png", "caf%C3%A9/im%C3%A4ge.png"),
1461 ];
1462 for (input, expected) in cases {
1463 assert_eq!(escape_uri_path(input), expected, "input {input:?}");
1464 assert_eq!(
1465 escape_uri_path(expected),
1466 expected,
1467 "idempotent {expected:?}"
1468 );
1469 }
1470 let mut doc = DoclingDocument::new("t");
1472 doc.push(Node::Picture {
1473 caption: None,
1474 caption_href: None,
1475 image: Some(PictureImage {
1476 dpi: PictureImage::DEFAULT_DPI,
1477 mimetype: "image/png".into(),
1478 width: 1,
1479 height: 1,
1480 data: b"x".to_vec(),
1481 }),
1482 classification: None,
1483 caption_parent: Default::default(),
1484 });
1485 let (md, files) = doc
1486 .export_to_markdown_with_images(ImageMode::Referenced, "My Report (final)_artifacts");
1487 assert!(
1488 md.contains(""),
1489 "got:\n{md}"
1490 );
1491 assert_eq!(files[0].0, "My Report (final)_artifacts/image_000000.png");
1493 }
1494
1495 #[test]
1499 fn folded_list_item_pictures_keep_plain_newlines() {
1500 let doc = DoclingDocument::new("t");
1501 let ctx = Ctx::new(&doc, false, &MarkdownExportOptions::default());
1502 assert_eq!(
1503 list_item_text("Step\n<!-- image -->", &ctx),
1504 "Step\n<!-- image -->"
1505 );
1506 assert_eq!(
1507 list_item_text("Step\nAlt text\n<!-- image -->\n<!-- image -->", &ctx),
1508 "Step\nAlt text\n<!-- image -->\n<!-- image -->"
1509 );
1510 assert_eq!(
1511 list_item_text("line one\nline two", &ctx),
1512 "line one \nline two"
1513 );
1514 }
1515
1516 #[test]
1519 fn stacked_header_rows_flatten_into_one() {
1520 let mut t = Table {
1521 rows: vec![
1522 vec!["".into(), "% of Total".into(), "% of Total".into()],
1523 vec!["class".into(), "Train".into(), "Test".into()],
1524 vec!["Caption".into(), "2.04".into(), "1.77".into()],
1525 ],
1526 ..Default::default()
1527 };
1528 t.structure = Some(TableStructure {
1529 header_row: vec![true, true, false],
1530 col_continuation: vec![
1531 vec![false, false, true],
1532 vec![false, false, false],
1533 vec![false, false, false],
1534 ],
1535 ..Default::default()
1536 });
1537 assert_eq!(t.header_row_count(), 2);
1538 assert_eq!(
1539 render_table(&t, true),
1540 "| class | % of Total - Train | % of Total - Test |\n| - | - | - |\n| Caption | 2.04 | 1.77 |"
1541 );
1542 assert_eq!(
1544 render_table(&t, false),
1545 "| class | % of Total - Train | % of Total - Test |\n\
1546 |---------|----------------------|---------------------|\n\
1547 | Caption | 2.04 | 1.77 |"
1548 );
1549 }
1550
1551 #[test]
1554 fn vertically_spanning_header_does_not_extend_the_block() {
1555 let mut t = Table {
1556 rows: vec![
1557 vec!["Name".into(), "Value".into()],
1558 vec!["Name".into(), "1".into()],
1559 vec!["x".into(), "2".into()],
1560 ],
1561 ..Default::default()
1562 };
1563 t.structure = Some(TableStructure {
1564 col_header: vec![vec![true, true], vec![true, false], vec![false, false]],
1565 row_continuation: vec![vec![false, false], vec![true, false], vec![false, false]],
1566 ..Default::default()
1567 });
1568 assert_eq!(t.header_row_count(), 1);
1569 assert_eq!(
1570 render_table(&t, true),
1571 "| Name | Value |\n| - | - |\n| Name | 1 |\n| x | 2 |"
1572 );
1573 }
1574
1575 #[test]
1578 fn header_flags_not_on_row_zero_keep_all_rows_in_the_body() {
1579 let mut t = Table {
1580 rows: vec![
1581 vec!["1".into(), "2".into()],
1582 vec!["a".into(), "b".into()],
1583 vec!["333".into(), "4".into()],
1584 ],
1585 ..Default::default()
1586 };
1587 t.structure = Some(TableStructure {
1588 header_row: vec![false, true, false],
1589 ..Default::default()
1590 });
1591 assert_eq!(t.header_row_count(), 0);
1592 assert_eq!(
1593 render_table(&t, false),
1594 "| | |\n|-----|----|\n| 1 | 2 |\n| a | b |\n| 333 | 4 |"
1595 );
1596 }
1597
1598 #[test]
1603 fn pivot_row_headers_do_not_extend_the_header() {
1604 let mut t = Table {
1605 rows: vec![
1606 vec!["Year".into(), "Month".into()],
1607 vec!["2025".into(), "January".into()],
1608 vec!["2025".into(), "February".into()],
1609 ],
1610 ..Default::default()
1611 };
1612 t.structure = Some(TableStructure {
1613 col_header: vec![vec![true, true], vec![false, false], vec![false, false]],
1614 row_header: vec![vec![false, false], vec![true, false], vec![true, false]],
1615 row_continuation: vec![vec![false, false], vec![false, false], vec![true, false]],
1616 ..Default::default()
1617 });
1618 assert_eq!(t.header_row_count(), 1);
1619 assert_eq!(
1620 render_table(&t, true),
1621 "| Year | Month |\n| - | - |\n| 2025 | January |\n| 2025 | February |"
1622 );
1623 }
1624
1625 #[test]
1628 fn unflagged_cells_keep_row_zero_as_header() {
1629 let mut t = Table {
1630 rows: vec![vec!["h".into()], vec!["d".into()]],
1631 ..Default::default()
1632 };
1633 t.cells = Some(
1634 [(0usize, "h"), (1, "d")]
1635 .into_iter()
1636 .map(|(r, text)| TableCell {
1637 text: text.into(),
1638 bbox: None,
1639 start_row: r,
1640 start_col: 0,
1641 row_span: 1,
1642 col_span: 1,
1643 column_header: false,
1644 row_header: false,
1645 row_section: false,
1646 })
1647 .collect(),
1648 );
1649 assert_eq!(t.header_row_count(), 1);
1650 assert_eq!(render_table(&t, true), "| h |\n| - |\n| d |");
1651 }
1652
1653 #[test]
1654 fn renders_compact_table() {
1655 let mut doc = DoclingDocument::new("t");
1656 doc.compact_tables = true;
1659 doc.push(Node::Table(Table {
1660 rows: vec![vec!["a".into(), "b".into()], vec!["1".into(), "2".into()]],
1661 location: None,
1662 structure: None,
1663 cell_blocks: None,
1664 cells: None,
1665 caption: None,
1666 caption_parent: Default::default(),
1667 }));
1668 let md = doc.export_to_markdown();
1669 assert_eq!(md, "| a | b |\n| - | - |\n| 1 | 2 |\n");
1670 }
1671
1672 #[test]
1673 fn renders_padded_github_table_by_default() {
1674 let mut doc = DoclingDocument::new("t");
1675 doc.push(Node::Table(Table {
1676 rows: vec![vec!["a".into(), "b".into()], vec!["1".into(), "2".into()]],
1677 location: None,
1678 structure: None,
1679 cell_blocks: None,
1680 cells: None,
1681 caption: None,
1682 caption_parent: Default::default(),
1683 }));
1684 let md = doc.export_to_markdown();
1685 assert_eq!(md, "| a | b |\n|-----|-----|\n| 1 | 2 |\n");
1687 }
1688
1689 #[test]
1690 fn strict_unescapes_inline_underscores_legacy_keeps_them() {
1691 let mut doc = DoclingDocument::new("t");
1692 doc.add_heading(1, "a\\_b");
1693 doc.add_paragraph("x\\_y");
1694 doc.push(Node::ListItem {
1695 ordered: false,
1696 number: 1,
1697 first_in_list: true,
1698 text: "i\\_j".into(),
1699 level: 0,
1700 marker: None,
1701 location: None,
1702 dclx: None,
1703 href: None,
1704 layer: None,
1705 });
1706 assert_eq!(doc.export_to_markdown(), "# a\\_b\n\nx\\_y\n\n- i\\_j\n");
1708 assert_eq!(doc.export_to_markdown_with(true), "# a_b\n\nx_y\n\n- i_j\n");
1710 }
1711
1712 fn assert_stream_matches(
1715 doc: &DoclingDocument,
1716 strict: bool,
1717 images: ImageMode,
1718 splits: &[usize],
1719 ) {
1720 let (want, want_artifacts) = to_markdown_images(doc, strict, images, "artifacts");
1721 let mut streamer =
1722 MarkdownStreamer::with_artifacts(strict, images, doc.compact_tables, "artifacts")
1723 .with_page_break_placeholder(doc.page_break_placeholder.clone());
1724 let mut got = String::new();
1725 let mut got_artifacts = Vec::new();
1726 let mut start = 0;
1727 for &end in splits {
1728 let links = if start == 0 {
1731 doc.links.as_slice()
1732 } else {
1733 &[]
1734 };
1735 got.push_str(&streamer.push(&doc.nodes[start..end], links));
1736 got_artifacts.extend(streamer.take_artifacts());
1739 start = end;
1740 }
1741 got.push_str(&streamer.push(
1742 &doc.nodes[start..],
1743 if start == 0 {
1744 doc.links.as_slice()
1745 } else {
1746 &[]
1747 },
1748 ));
1749 got_artifacts.extend(streamer.take_artifacts());
1750 got.push_str(&streamer.finish());
1751 assert_eq!(
1752 got, want,
1753 "streamed output diverged (splits={splits:?}, strict={strict})"
1754 );
1755 assert_eq!(
1756 got_artifacts, want_artifacts,
1757 "streamed artifacts diverged (splits={splits:?}, strict={strict})"
1758 );
1759 }
1760
1761 #[test]
1762 fn streaming_is_byte_identical_to_buffered() {
1763 let mut doc = DoclingDocument::new("d");
1764 doc.add_heading(1, "Title");
1765 doc.add_paragraph("First paragraph.");
1766 doc.push(Node::ListItem {
1767 ordered: false,
1768 number: 1,
1769 first_in_list: true,
1770 text: "a".into(),
1771 level: 0,
1772 marker: None,
1773 location: None,
1774 dclx: None,
1775 href: None,
1776 layer: None,
1777 });
1778 doc.push(Node::ListItem {
1779 ordered: false,
1780 number: 2,
1781 first_in_list: false,
1782 text: "b".into(),
1783 level: 0,
1784 marker: None,
1785 location: None,
1786 dclx: None,
1787 href: None,
1788 layer: None,
1789 });
1790 doc.push(Node::Code {
1791 language: Some("rust".into()),
1792 text: "let x = 1;".into(),
1793 orig: None,
1794 pretty: None,
1795 });
1796 doc.push(Node::Table(Table {
1797 rows: vec![vec!["a".into(), "b".into()], vec!["1".into(), "2".into()]],
1798 location: None,
1799 structure: None,
1800 cell_blocks: None,
1801 cells: None,
1802 caption: None,
1803 caption_parent: Default::default(),
1804 }));
1805 doc.push(Node::Picture {
1806 caption: Some("Fig 1".into()),
1807 caption_href: None,
1808 image: Some(PictureImage {
1809 dpi: PictureImage::DEFAULT_DPI,
1810 mimetype: "image/png".into(),
1811 width: 2,
1812 height: 2,
1813 data: b"png-one".to_vec(),
1814 }),
1815 classification: None,
1816 caption_parent: Default::default(),
1817 });
1818 doc.add_paragraph("Last paragraph.");
1819 doc.push(Node::Picture {
1822 caption: None,
1823 caption_href: None,
1824 image: Some(PictureImage {
1825 dpi: PictureImage::DEFAULT_DPI,
1826 mimetype: "image/png".into(),
1827 width: 2,
1828 height: 2,
1829 data: b"png-two".to_vec(),
1830 }),
1831 classification: None,
1832 caption_parent: Default::default(),
1833 });
1834
1835 for &strict in &[false, true] {
1838 for &images in &[
1839 ImageMode::Placeholder,
1840 ImageMode::Embedded,
1841 ImageMode::Referenced,
1842 ] {
1843 for splits in [&[][..], &[1][..], &[2][..], &[4][..], &[1, 4, 6, 7][..]] {
1844 assert_stream_matches(&doc, strict, images, splits);
1845 }
1846 }
1847 }
1848 }
1849
1850 #[test]
1851 fn streaming_applies_recovered_links_in_strict_mode() {
1852 let mut doc = DoclingDocument::new("d");
1853 doc.add_paragraph("See LinkedIn for details.");
1854 doc.add_paragraph("And GitHub too.");
1855 doc.links = vec![
1856 ("LinkedIn".into(), "https://lnkd/".into()),
1857 ("GitHub".into(), "https://gh/".into()),
1858 ];
1859 assert_stream_matches(&doc, true, ImageMode::Placeholder, &[1]);
1862 }
1863
1864 fn paged_doc() -> DoclingDocument {
1867 let mut doc = DoclingDocument::new("p");
1868 doc.push(Node::PageInfo {
1869 page_no: 1,
1870 width: 100.0,
1871 height: 100.0,
1872 });
1873 doc.add_heading(1, "Title");
1874 doc.add_paragraph("Page one.");
1875 doc.push(Node::PageBreak);
1877 doc.push(Node::PageInfo {
1878 page_no: 2,
1879 width: 100.0,
1880 height: 100.0,
1881 });
1882 doc.push(Node::PageFurniture {
1883 footer: true,
1884 location: [0, 500, 511, 511],
1885 text: "2".into(),
1886 });
1887 doc.push(Node::PageBreak);
1888 doc.push(Node::PageInfo {
1889 page_no: 3,
1890 width: 100.0,
1891 height: 100.0,
1892 });
1893 doc.add_paragraph("Page three.");
1894 doc.push(Node::PageBreak);
1896 doc
1897 }
1898
1899 #[test]
1900 fn page_break_placeholder_lands_between_pages_only() {
1901 let mut doc = paged_doc();
1902 assert_eq!(
1904 doc.export_to_markdown(),
1905 "# Title\n\nPage one.\n\nPage three.\n"
1906 );
1907 doc.page_break_placeholder = Some("<!-- page break -->".into());
1908 assert_eq!(
1912 doc.export_to_markdown(),
1913 "# Title\n\nPage one.\n\n<!-- page break -->\n\nPage three.\n"
1914 );
1915 doc.page_break_placeholder = Some(String::new());
1918 assert_eq!(
1919 doc.export_to_markdown(),
1920 "# Title\n\nPage one.\n\n\n\nPage three.\n"
1921 );
1922 }
1923
1924 #[test]
1925 fn page_break_placeholder_never_leads_a_single_page() {
1926 let mut doc = DoclingDocument::new("one");
1927 doc.page_break_placeholder = Some("---".into());
1928 doc.push(Node::PageBreak);
1929 doc.push(Node::PageInfo {
1930 page_no: 1,
1931 width: 10.0,
1932 height: 10.0,
1933 });
1934 doc.add_paragraph("Only page.");
1935 assert_eq!(doc.export_to_markdown(), "Only page.\n");
1936 doc.push(Node::PageBreak);
1938 doc.push(Node::PageBreak);
1939 doc.add_paragraph("Next.");
1940 assert_eq!(doc.export_to_markdown(), "Only page.\n\n---\n\nNext.\n");
1941 }
1942
1943 #[test]
1944 fn page_break_placeholder_streams_byte_identical() {
1945 let mut doc = paged_doc();
1946 doc.page_break_placeholder = Some("<!-- page break -->".into());
1947 for splits in [
1951 &[3usize][..],
1952 &[3, 6],
1953 &[3, 6, 8],
1954 &[1, 2, 3, 4, 5, 6, 7, 8, 9],
1955 &[8],
1956 ] {
1957 assert_stream_matches(&doc, false, ImageMode::Placeholder, splits);
1958 assert_stream_matches(&doc, true, ImageMode::Placeholder, splits);
1959 }
1960 }
1961
1962 #[test]
1963 fn strict_tightens_punctuation_spacing_legacy_keeps_it() {
1964 let mut doc = DoclingDocument::new("t");
1965 doc.add_paragraph("see [ 37 , 36 ] and ( x ) .");
1966 assert_eq!(doc.export_to_markdown(), "see [ 37 , 36 ] and ( x ) .\n");
1968 assert_eq!(doc.export_to_markdown_with(true), "see [37, 36] and (x).\n");
1970 }
1971
1972 fn options_doc() -> DoclingDocument {
1977 use crate::document::ContentLayer;
1978 let mut doc = DoclingDocument::new("opts");
1979 doc.push(Node::PageFurniture {
1980 footer: false,
1981 location: [0, 0, 0, 0],
1982 text: "FAX COVER \\_ R&D".into(),
1983 });
1984 doc.add_heading(1, "R&D \\_ report");
1985 doc.add_paragraph("Body a\\_b <tag>");
1986 doc.push(Node::Picture {
1987 caption: Some("Figure 1".into()),
1988 caption_href: None,
1989 image: None,
1990 classification: None,
1991 caption_parent: Default::default(),
1992 });
1993 doc.push(Node::PictureChildren(vec![
1994 Node::Paragraph {
1995 text: "Field: Name".into(),
1996 },
1997 Node::Paragraph {
1998 text: "Field: Date".into(),
1999 },
2000 ]));
2001 doc.push(Node::CommentSection {
2002 name: "comment-1".into(),
2003 text: "[author: A]: note".into(),
2004 refs_note_text: false,
2005 grouped: true,
2006 });
2007 doc.push(Node::Group {
2008 label: "section".into(),
2009 name: Some("sheet: Hidden".into()),
2010 layer: Some(ContentLayer::Invisible),
2011 children: vec![Node::Paragraph {
2012 text: "hidden cell".into(),
2013 }],
2014 });
2015 doc.push(Node::Furniture {
2016 layer: ContentLayer::Furniture,
2017 inner: Box::new(Node::Paragraph {
2018 text: "Page 1 of 2".into(),
2019 }),
2020 });
2021 doc.push(Node::FurnitureText {
2022 label: "page_footer".into(),
2023 text: "footer line".into(),
2024 });
2025 doc
2026 }
2027
2028 #[test]
2032 fn default_options_are_the_default_export() {
2033 let doc = options_doc();
2034 let plain = doc.export_to_markdown();
2035 assert_eq!(
2036 plain,
2037 "# R&D \\_ report\n\nBody a\\_b <tag>\n\nFigure 1\n\n<!-- image -->\n"
2038 );
2039 let (with_default, artifacts) =
2040 doc.export_to_markdown_with_options(&MarkdownExportOptions::default());
2041 assert_eq!(with_default, plain);
2042 assert!(artifacts.is_empty());
2043 }
2044
2045 #[test]
2046 fn layers_render_furniture_notes_and_hidden_sheets() {
2047 use crate::document::ContentLayer;
2048 let doc = options_doc();
2049 let md = |layers: ContentLayers| {
2050 doc.export_to_markdown_with_options(&MarkdownExportOptions {
2051 layers,
2052 ..MarkdownExportOptions::default()
2053 })
2054 .0
2055 };
2056 assert_eq!(
2057 md(ContentLayers::BODY.with(ContentLayer::Furniture)),
2058 "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",
2059 "a page header, a furniture-wrapped paragraph and a furniture text item render as paragraphs"
2060 );
2061 assert_eq!(
2062 md(ContentLayers::BODY.with(ContentLayer::Notes)),
2063 "# R&D \\_ report\n\nBody a\\_b <tag>\n\nFigure 1\n\n<!-- image -->\n\n[author: A]: note\n"
2064 );
2065 assert_eq!(
2066 md(ContentLayers::BODY.with(ContentLayer::Invisible)),
2067 "# R&D \\_ report\n\nBody a\\_b <tag>\n\nFigure 1\n\n<!-- image -->\n\nhidden cell\n",
2068 "a hidden sheet's items sit on the sheet's layer"
2069 );
2070 assert_eq!(
2071 md(ContentLayers::NONE.with(ContentLayer::Furniture)),
2072 "FAX COVER \\_ R&D\n\nPage 1 of 2\n\nfooter line\n",
2073 "without the body layer only the furniture prints"
2074 );
2075 assert_eq!(md(ContentLayers::NONE), "");
2076 assert_eq!(
2077 md(ContentLayers::ALL),
2078 "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"
2079 );
2080 }
2081
2082 #[test]
2085 fn layers_apply_to_list_items() {
2086 use crate::document::ContentLayer;
2087 let mut doc = DoclingDocument::new("lists");
2088 doc.push(Node::ListItem {
2089 ordered: false,
2090 number: 1,
2091 first_in_list: true,
2092 text: "nav link".into(),
2093 level: 0,
2094 marker: None,
2095 location: None,
2096 dclx: None,
2097 href: None,
2098 layer: Some(ContentLayer::Furniture),
2099 });
2100 doc.add_paragraph("body");
2101 assert_eq!(doc.export_to_markdown(), "body\n");
2102 let (md, _) = doc.export_to_markdown_with_options(&MarkdownExportOptions {
2103 layers: ContentLayers::BODY.with(ContentLayer::Furniture),
2104 ..MarkdownExportOptions::default()
2105 });
2106 assert_eq!(md, "- nav link\n\nbody\n");
2107 }
2108
2109 #[test]
2110 fn traverse_pictures_prints_the_nested_text_after_the_picture() {
2111 let doc = options_doc();
2112 let (md, _) = doc.export_to_markdown_with_options(&MarkdownExportOptions {
2113 traverse_pictures: true,
2114 ..MarkdownExportOptions::default()
2115 });
2116 assert_eq!(
2117 md,
2118 "# R&D \\_ report\n\nBody a\\_b <tag>\n\nFigure 1\n\n<!-- image -->\n\nField: Name\n\nField: Date\n"
2119 );
2120 }
2121
2122 #[test]
2123 fn escaping_can_be_turned_off_per_kind() {
2124 let doc = options_doc();
2125 let (md, _) = doc.export_to_markdown_with_options(&MarkdownExportOptions {
2126 escape_html: false,
2127 ..MarkdownExportOptions::default()
2128 });
2129 assert_eq!(
2130 md,
2131 "# R&D \\_ report\n\nBody a\\_b <tag>\n\nFigure 1\n\n<!-- image -->\n"
2132 );
2133 let (md, _) = doc.export_to_markdown_with_options(&MarkdownExportOptions {
2134 escape_underscores: false,
2135 ..MarkdownExportOptions::default()
2136 });
2137 assert_eq!(
2138 md,
2139 "# R&D _ report\n\nBody a_b <tag>\n\nFigure 1\n\n<!-- image -->\n"
2140 );
2141 let mut doc = DoclingDocument::new("code");
2144 doc.push(Node::Code {
2145 language: None,
2146 text: "a & b \\_ c".into(),
2147 orig: None,
2148 pretty: None,
2149 });
2150 let (md, _) = doc.export_to_markdown_with_options(&MarkdownExportOptions {
2151 escape_html: false,
2152 escape_underscores: false,
2153 ..MarkdownExportOptions::default()
2154 });
2155 assert_eq!(md, "```\na & b \\_ c\n```\n");
2156 }
2157
2158 #[test]
2159 fn image_placeholder_is_configurable() {
2160 let doc = options_doc();
2161 let (md, _) = doc.export_to_markdown_with_options(&MarkdownExportOptions {
2162 image_placeholder: "[figure]".into(),
2163 ..MarkdownExportOptions::default()
2164 });
2165 assert!(md.contains("Figure 1\n\n[figure]\n"), "{md}");
2166 assert!(!md.contains("<!-- image -->"));
2167 }
2168
2169 #[test]
2172 fn streamer_matches_buffered_export_with_options() {
2173 use crate::document::ContentLayer;
2174 let doc = options_doc();
2175 let options = MarkdownExportOptions {
2176 layers: ContentLayers::ALL,
2177 traverse_pictures: true,
2178 escape_html: false,
2179 escape_underscores: false,
2180 image_placeholder: "(img)".into(),
2181 ..MarkdownExportOptions::default()
2182 };
2183 let (buffered, _) = doc.export_to_markdown_with_options(&options);
2184 let mut streamer = MarkdownStreamer::new(false, ImageMode::Placeholder, false)
2185 .with_export_options(&options);
2186 let mut out = String::new();
2187 for chunk in doc.nodes.chunks(2) {
2188 out.push_str(&streamer.push(chunk, &[]));
2189 }
2190 out.push_str(&streamer.finish());
2191 assert_eq!(out, buffered);
2192 assert!(
2193 buffered.contains("(img)") && buffered.contains("R&D _ report"),
2194 "{buffered}"
2195 );
2196 let _ = ContentLayer::Notes;
2197 }
2198}