1use crate::document::{DoclingDocument, Node, Table};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
7pub enum ImageMode {
8 #[default]
10 Placeholder,
11 Embedded,
13 Referenced,
16}
17
18struct Ctx {
20 strict: bool,
21 compact_tables: bool,
23 images: ImageMode,
24 artifacts_dir: String,
25 artifacts: Vec<(String, Vec<u8>)>,
27 pic_index: usize,
28 in_table_cell: bool,
32}
33
34pub fn to_markdown(doc: &DoclingDocument, strict: bool) -> String {
40 to_markdown_images(doc, strict, ImageMode::Placeholder, "artifacts").0
41}
42
43pub fn to_markdown_images(
47 doc: &DoclingDocument,
48 strict: bool,
49 images: ImageMode,
50 artifacts_dir: &str,
51) -> (String, Vec<(String, Vec<u8>)>) {
52 let mut ctx = Ctx {
53 strict,
54 compact_tables: doc.compact_tables,
55 images,
56 artifacts_dir: artifacts_dir.to_string(),
57 artifacts: Vec::new(),
58 pic_index: 0,
59 in_table_cell: false,
60 };
61 let mut blocks: Vec<String> = Vec::new();
62 render(&doc.nodes, &mut blocks, &mut ctx);
63 let mut body = blocks.join("\n\n");
64 if strict && !doc.links.is_empty() {
68 body = apply_links(&body, &doc.links);
69 }
70 let md = if body.is_empty() {
71 String::new()
72 } else {
73 format!("{body}\n")
74 };
75 (md, ctx.artifacts)
76}
77
78pub fn to_markdown_table_cell(doc: &DoclingDocument, strict: bool) -> String {
87 let mut ctx = Ctx {
88 strict,
89 compact_tables: doc.compact_tables,
90 images: ImageMode::Placeholder,
91 artifacts_dir: String::new(),
92 artifacts: Vec::new(),
93 pic_index: 0,
94 in_table_cell: true,
95 };
96 let mut blocks: Vec<String> = Vec::new();
97 render(&doc.nodes, &mut blocks, &mut ctx);
98 blocks.join("\n\n")
99}
100
101fn apply_links(body: &str, links: &[(String, String)]) -> String {
109 let mut out = body.to_string();
110 let mut cursor = 0usize;
111 for (anchor, href) in links {
112 let anchor = anchor
113 .replace('&', "&")
114 .replace('<', "<")
115 .replace('>', ">");
116 if anchor.is_empty() {
117 continue;
118 }
119 if let Some(rel) = out[cursor..].find(&anchor) {
120 let at = cursor + rel;
121 let replacement = format!("[{anchor}]({href})");
123 out.replace_range(at..at + anchor.len(), &replacement);
124 cursor = at + replacement.len();
125 }
126 }
127 out
128}
129
130fn apply_links_chunk(chunk: &str, queue: &mut Vec<(String, String)>) -> String {
141 let mut out = chunk.to_string();
142 let mut cursor = 0usize;
143 let mut carried: Vec<(String, String)> = Vec::new();
144 for (anchor_raw, href) in std::mem::take(queue) {
145 let anchor = anchor_raw
146 .replace('&', "&")
147 .replace('<', "<")
148 .replace('>', ">");
149 if anchor.is_empty() {
150 continue;
151 }
152 if let Some(rel) = out[cursor..].find(&anchor) {
153 let at = cursor + rel;
154 let replacement = format!("[{anchor}]({href})");
155 out.replace_range(at..at + anchor.len(), &replacement);
156 cursor = at + replacement.len();
157 } else {
158 carried.push((anchor_raw, href));
160 }
161 }
162 *queue = carried;
163 out
164}
165
166pub struct MarkdownStreamer {
184 strict: bool,
185 images: ImageMode,
186 compact_tables: bool,
187 emitted_any: bool,
190 links: Vec<(String, String)>,
192 artifacts_dir: String,
196 artifacts: Vec<(String, Vec<u8>)>,
197 pic_index: usize,
198}
199
200impl MarkdownStreamer {
201 pub fn new(strict: bool, images: ImageMode, compact_tables: bool) -> Self {
204 debug_assert!(
205 images != ImageMode::Referenced,
206 "referenced image mode needs an artifacts dir; use with_artifacts"
207 );
208 Self::with_artifacts(strict, images, compact_tables, "artifacts")
209 }
210
211 pub fn with_artifacts(
218 strict: bool,
219 images: ImageMode,
220 compact_tables: bool,
221 artifacts_dir: &str,
222 ) -> Self {
223 Self {
224 strict,
225 images,
226 compact_tables,
227 emitted_any: false,
228 links: Vec::new(),
229 artifacts_dir: artifacts_dir.to_string(),
230 artifacts: Vec::new(),
231 pic_index: 0,
232 }
233 }
234
235 pub fn take_artifacts(&mut self) -> Vec<(String, Vec<u8>)> {
240 std::mem::take(&mut self.artifacts)
241 }
242
243 pub fn push(&mut self, nodes: &[Node], links: &[(String, String)]) -> String {
248 self.links.extend(links.iter().cloned());
249 let mut ctx = Ctx {
250 strict: self.strict,
251 compact_tables: self.compact_tables,
252 images: self.images,
253 artifacts_dir: std::mem::take(&mut self.artifacts_dir),
254 artifacts: std::mem::take(&mut self.artifacts),
255 pic_index: self.pic_index,
256 in_table_cell: false,
257 };
258 let mut blocks: Vec<String> = Vec::new();
259 render(nodes, &mut blocks, &mut ctx);
260 self.artifacts_dir = std::mem::take(&mut ctx.artifacts_dir);
261 self.artifacts = std::mem::take(&mut ctx.artifacts);
262 self.pic_index = ctx.pic_index;
263 if blocks.is_empty() {
264 return String::new();
265 }
266 let mut body = blocks.join("\n\n");
267 if self.strict && !self.links.is_empty() {
268 body = apply_links_chunk(&body, &mut self.links);
269 }
270 let chunk = if self.emitted_any {
271 format!("\n\n{body}")
272 } else {
273 body
274 };
275 self.emitted_any = true;
276 chunk
277 }
278
279 pub fn finish(self) -> String {
282 if self.emitted_any {
283 "\n".to_string()
284 } else {
285 String::new()
286 }
287 }
288}
289
290fn strict_text(text: &str, strict: bool) -> String {
298 if !strict {
299 return text.to_string();
300 }
301 text.replace("\\_", "_")
302 .replace(" ,", ",")
303 .replace(" .", ".")
304 .replace(" ;", ";")
305 .replace(" )", ")")
306 .replace("( ", "(")
307 .replace(" ]", "]")
308 .replace("[ ", "[")
309}
310
311fn md_line_breaks(text: &str) -> String {
317 if !text.contains('\n') {
318 return text.to_string();
319 }
320 text.split("\n\n")
321 .map(|para| para.replace('\n', " \n"))
322 .collect::<Vec<_>>()
323 .join("\n\n")
324}
325
326pub(crate) fn strip_hard_breaks(text: &str) -> String {
331 if text.contains(" \n") {
332 text.replace(" \n", "\n")
333 } else {
334 text.to_string()
335 }
336}
337
338fn heading_line_breaks(text: &str) -> String {
342 text.replace('\n', " ")
343}
344
345fn render(nodes: &[Node], blocks: &mut Vec<String>, ctx: &mut Ctx) {
346 let mut i = 0;
347 while i < nodes.len() {
348 match &nodes[i] {
349 Node::ListItem { .. } => {
350 let start = i;
351 i += 1;
352 loop {
353 match nodes.get(i) {
354 Some(Node::ListItem { .. }) => i += 1,
355 Some(Node::Paragraph { text })
359 if text.is_empty()
360 && matches!(nodes.get(i + 1), Some(Node::ListItem { .. })) =>
361 {
362 i += 1
363 }
364 _ => break,
365 }
366 }
367 render_list_run(&nodes[start..i], blocks, ctx.strict);
368 }
369 other => {
370 render_one(other, blocks, ctx);
371 i += 1;
372 }
373 }
374 }
375}
376
377fn render_list_run(items: &[Node], blocks: &mut Vec<String>, strict: bool) {
383 let mut lines: Vec<String> = Vec::new();
384 let mut prev: Vec<Option<(bool, u64)>> = Vec::new();
387 let mut prev_projected = false;
396 let mut nested_since_top = false;
405
406 for item in items {
407 let Node::ListItem {
408 ordered,
409 number,
410 first_in_list,
411 text,
412 level,
413 marker: _,
414 location: _,
415 dclx,
416 href: _,
417 layer,
418 } = item
419 else {
420 continue;
421 };
422 if layer.is_some() {
425 continue;
426 }
427 let level = *level as usize;
428
429 prev.truncate(level + 1);
431 while prev.len() <= level {
432 prev.push(None);
433 }
434
435 let eff_ordered = dclx.as_ref().map_or(*ordered, |d| d.ordered);
441 if level == 0 {
442 if let Some((prev_ordered, prev_number)) = prev[level] {
443 let same_word_list = prev_projected && eff_ordered;
448 let new_list = *first_in_list
449 || (!same_word_list
450 && (prev_ordered != *ordered
451 || (*ordered && !nested_since_top && *number != prev_number + 1)));
452 if new_list {
453 lines.push(String::new());
454 }
455 }
456 prev_projected = eff_ordered && !*ordered;
457 nested_since_top = false;
458 } else {
459 nested_since_top = true;
460 }
461
462 let indent = " ".repeat(level);
463 let marker = if *ordered {
464 format!("{number}.")
465 } else {
466 "-".to_string()
467 };
468 lines.push(format!("{indent}{marker} {}", list_item_text(text, strict)));
469 prev[level] = Some((*ordered, *number));
470 }
471
472 if !lines.is_empty() {
475 blocks.push(lines.join("\n"));
476 }
477}
478
479fn list_item_text(text: &str, strict: bool) -> String {
487 let escaped = strict_text(text, strict);
488 if let Some((own, tail)) = escaped.split_once('\n') {
489 if is_folded_child_tail(tail) {
490 return format!("{}\n{tail}", md_line_breaks(own));
491 }
492 }
493 md_line_breaks(&escaped)
494}
495
496fn is_folded_child_tail(tail: &str) -> bool {
503 const MARKER: &str = "<!-- image -->";
504 const FENCE: &str = "```";
505 let mut lines = tail.split('\n').peekable();
506 let mut any = false;
507 while let Some(line) = lines.next() {
508 let line = line.trim_start();
509 if line == MARKER {
510 any = true;
511 } else if line == FENCE {
512 loop {
514 match lines.next() {
515 Some(l) if l.trim_start() == FENCE => break,
516 Some(_) => {}
517 None => return false,
518 }
519 }
520 any = true;
521 } else if lines.next().map(str::trim_start) == Some(MARKER) {
522 any = true; } else {
524 return false;
525 }
526 }
527 any
528}
529
530fn render_one(node: &Node, blocks: &mut Vec<String>, ctx: &mut Ctx) {
531 match node {
532 Node::Heading { level, text } => {
533 let text = heading_line_breaks(&strict_text(text, ctx.strict));
534 if ctx.in_table_cell {
535 blocks.push(text);
537 } else {
538 let hashes = "#".repeat((*level).clamp(1, 6) as usize);
539 blocks.push(format!("{hashes} {text}"));
540 }
541 }
542 Node::Paragraph { text } if text.is_empty() => {}
545 Node::Paragraph { text } => blocks.push(md_line_breaks(&strict_text(text, ctx.strict))),
546 Node::Caption { text, .. } if text.is_empty() => {}
549 Node::Caption { text, href } => {
550 let body = md_line_breaks(&strict_text(text, ctx.strict));
551 blocks.push(match href {
552 Some(url) => format!("[{body}]({url})"),
553 None => body,
554 });
555 }
556 Node::CheckboxItem { checked, text } => {
557 let mark = if *checked { "- [x] " } else { "- [ ] " };
558 blocks.push(md_line_breaks(&strict_text(
559 &format!("{mark}{text}"),
560 ctx.strict,
561 )));
562 }
563 Node::Code {
564 language,
565 text,
566 pretty,
567 ..
568 } => {
569 let lang = match language {
571 Some(l) if ctx.strict => l.as_str(),
572 _ => "",
573 };
574 let body = match pretty {
577 Some(p) if ctx.strict => p.as_str(),
578 _ => text.as_str(),
579 };
580 blocks.push(format!("```{lang}\n{body}\n```"));
581 }
582 Node::Formula { latex, .. } => blocks.push(format!("$${latex}$$")),
585 Node::Table(table) => {
586 if let Some(cap) = &table.caption {
589 if !cap.is_empty() {
590 blocks.push(md_line_breaks(&strict_text(cap, ctx.strict)));
591 }
592 }
593 let rendered = render_table(table, ctx.compact_tables);
594 if !rendered.is_empty() {
595 blocks.push(rendered);
596 }
597 }
598 Node::Picture { caption, image, .. } => {
600 if let Some(cap) = caption {
601 if !cap.is_empty() {
602 blocks.push(md_line_breaks(cap));
603 }
604 }
605 blocks.push(picture_marker(image.as_ref(), ctx));
606 }
607 Node::Chart {
611 kind,
612 table,
613 caption,
614 ..
615 } => {
616 if let Some(cap) = caption {
617 if !cap.is_empty() {
618 blocks.push(md_line_breaks(cap));
619 }
620 }
621 blocks.push(picture_marker(None, ctx));
622 blocks.push(humanize_label(kind));
623 let rendered = render_table(table, false);
624 if !rendered.is_empty() {
625 blocks.push(rendered);
626 }
627 }
628 Node::DoclangOnly(_) => {}
630 Node::Group { layer: Some(_), .. } => {}
633 Node::Group { children, .. } => render(children, blocks, ctx),
634 Node::FieldRegion { items } => {
635 for item in items {
640 for part in [&item.marker, &item.key, &item.value].into_iter().flatten() {
641 blocks.push(md_line_breaks(&strict_text(part, ctx.strict)));
642 }
643 }
644 }
645 Node::InlineGroup { md_text, .. } => {
648 blocks.push(md_line_breaks(&strict_text(md_text, ctx.strict)))
649 }
650 Node::TextDump(text) => {
652 if !text.is_empty() {
653 blocks.push(text.clone());
654 }
655 }
656 Node::Furniture { .. } => {}
659 Node::PageFurniture { .. } => {}
660 Node::CommentSection { .. } => {}
663 Node::Commented { inner, .. } => render_one(inner, blocks, ctx),
664 Node::Located { inner, .. } => render_one(inner, blocks, ctx),
666 Node::PageBreak => {}
668 Node::PageInfo { .. } => {}
670 Node::ListItem { .. } => render_list_run(std::slice::from_ref(node), blocks, ctx.strict),
675 }
676}
677
678fn humanize_label(label: &str) -> String {
683 let text = label.replace('_', " ");
684 let mut chars = text.chars();
685 match chars.next() {
686 Some(f) => f.to_uppercase().collect::<String>() + chars.as_str(),
687 None => text,
688 }
689}
690
691fn picture_marker(image: Option<&crate::PictureImage>, ctx: &mut Ctx) -> String {
692 match (ctx.images, image) {
693 (ImageMode::Embedded, Some(img)) => format!("", img.data_uri()),
694 (ImageMode::Referenced, Some(img)) => {
695 let path = format!(
696 "{}/image_{:06}.{}",
697 ctx.artifacts_dir,
698 ctx.pic_index,
699 ext_for(&img.mimetype)
700 );
701 ctx.pic_index += 1;
702 ctx.artifacts.push((path.clone(), img.data.clone()));
703 format!("", escape_uri_path(&path))
704 }
705 _ => "<!-- image -->".to_string(),
707 }
708}
709
710pub(crate) fn escape_uri_path(value: &str) -> String {
723 const KEEP: &str = "/%:@+,;=~$!&'*";
724 let s = value.replace('\\', "/");
725 if let Some(rest) = s.strip_prefix("//") {
726 let rest = rest.trim_start_matches('/');
728 let (host, tail) = rest.split_once('/').unwrap_or((rest, ""));
729 return format!("file://{host}{}", percent_quote(&format!("/{tail}"), KEEP));
730 }
731 let bytes = s.as_bytes();
732 if bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'/' {
733 return format!("file:///{}", percent_quote(&s, KEEP));
735 }
736 if let Some((scheme, rest)) = s.split_once(':') {
740 let valid_scheme = scheme.len() > 1
741 && scheme.as_bytes()[0].is_ascii_alphabetic()
742 && scheme
743 .bytes()
744 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'-' | b'.'));
745 if valid_scheme {
746 let (authority, rest) = match rest.strip_prefix("//") {
747 Some(r) => {
748 let end = r.find(['/', '?', '#']).unwrap_or(r.len());
749 (Some(&r[..end]), &r[end..])
750 }
751 None => (None, rest),
752 };
753 let (before_frag, fragment) = rest.split_once('#').unwrap_or((rest, ""));
754 let (path, query) = before_frag.split_once('?').unwrap_or((before_frag, ""));
755 let mut out = format!("{scheme}:");
756 if let Some(a) = authority {
757 out.push_str("//");
758 out.push_str(a);
759 }
760 out.push_str(&percent_quote(path, KEEP));
761 if !query.is_empty() {
762 out.push('?');
763 out.push_str(&percent_quote(query, KEEP));
764 }
765 if !fragment.is_empty() {
766 out.push('#');
767 out.push_str(&percent_quote(fragment, KEEP));
768 }
769 return out;
770 }
771 }
772 percent_quote(&s, KEEP)
774}
775
776fn percent_quote(s: &str, safe: &str) -> String {
779 let mut out = String::with_capacity(s.len());
780 for &b in s.as_bytes() {
781 let keep = b.is_ascii_alphanumeric()
782 || matches!(b, b'_' | b'.' | b'-' | b'~')
783 || (b.is_ascii() && safe.contains(b as char));
784 if keep {
785 out.push(b as char);
786 } else {
787 out.push_str(&format!("%{b:02X}"));
788 }
789 }
790 out
791}
792
793fn ext_for(mimetype: &str) -> &str {
794 match mimetype {
795 "image/jpeg" => "jpg",
796 "image/gif" => "gif",
797 "image/webp" => "webp",
798 "image/bmp" => "bmp",
799 "image/tiff" => "tif",
800 _ => "png",
801 }
802}
803
804fn is_number_cell(t: &str) -> bool {
823 t.parse::<f64>().is_ok() || is_thousands_number(t)
824}
825
826fn is_thousands_number(t: &str) -> bool {
832 let b = t.as_bytes();
833 let mut i = 0;
834 let start = i;
835 if i < b.len() && (b[i] == b'+' || b[i] == b'-') {
836 i += 1;
837 }
838 let d0 = i;
840 while i < b.len() && b[i].is_ascii_digit() && i - d0 < 3 {
841 i += 1;
842 }
843 let has_int = i > d0;
844 if has_int {
845 while i + 3 < b.len() + 1
847 && b.get(i) == Some(&b',')
848 && b.get(i + 1).is_some_and(u8::is_ascii_digit)
849 && b.get(i + 2).is_some_and(u8::is_ascii_digit)
850 && b.get(i + 3).is_some_and(u8::is_ascii_digit)
851 {
852 i += 4;
853 }
854 } else {
855 i = start;
857 }
858 if i < b.len() && b[i] == b'.' {
860 i += 1;
861 let f0 = i;
862 while i < b.len() && b[i].is_ascii_digit() {
863 i += 1;
864 }
865 if !has_int && i == f0 {
866 return false; }
868 } else if !has_int {
869 return false; }
871 i == b.len()
872}
873
874fn flatten_header_rows(header_rows: &[Vec<String>], num_cols: usize) -> Vec<String> {
883 (0..num_cols)
884 .map(|c| {
885 let mut parts: Vec<&str> = Vec::new();
886 for row in header_rows {
887 let text = row.get(c).map(String::as_str).unwrap_or("");
888 if !text.is_empty() && parts.last() != Some(&text) {
889 parts.push(text);
890 }
891 }
892 parts.join(" - ")
893 })
894 .collect()
895}
896
897pub(crate) fn render_table(table: &Table, compact: bool) -> String {
898 if table.rows.is_empty() {
899 return String::new();
900 }
901 let num_cols = table.rows.iter().map(Vec::len).max().unwrap_or(0);
902 if num_cols == 0 {
903 return String::new();
904 }
905
906 let num_headers = table.header_row_count().min(table.rows.len());
911 let escaped = |r: usize| -> Vec<String> {
912 (0..num_cols)
913 .map(|c| escape_cell(table.rows[r].get(c).map(String::as_str).unwrap_or("")))
914 .collect()
915 };
916 let header_rows: Vec<Vec<String>> = (0..num_headers).map(escaped).collect();
917 let header = flatten_header_rows(&header_rows, num_cols);
918 let body: Vec<Vec<String>> = (num_headers..table.rows.len())
919 .map(|r| {
920 escaped(r)
921 .into_iter()
922 .map(|c| c.trim().to_string())
923 .collect()
924 })
925 .collect();
926
927 if compact {
928 let render_row = |row: &[String]| -> String { format!("| {} |", row.join(" | ")) };
930 let mut lines = Vec::with_capacity(body.len() + 2);
931 lines.push(render_row(&header));
932 let sep: Vec<&str> = (0..num_cols).map(|_| "-").collect();
933 lines.push(format!("| {} |", sep.join(" | ")));
934 for row in &body {
935 lines.push(render_row(row));
936 }
937 return lines.join("\n");
938 }
939
940 let dw = |s: &str| s.chars().count();
942
943 let right: Vec<bool> = (0..num_cols)
948 .map(|c| {
949 let mut any = false;
950 for row in &body {
951 let t = row[c].trim();
952 if t.is_empty() {
953 continue;
954 }
955 if !is_number_cell(t) {
956 return false;
957 }
958 any = true;
959 }
960 any
961 })
962 .collect();
963
964 let width: Vec<usize> = (0..num_cols)
966 .map(|c| {
967 let mut w = dw(&header[c]) + 2;
968 for row in &body {
969 w = w.max(dw(&row[c]));
970 }
971 w
972 })
973 .collect();
974
975 let fmt_cell = |s: &str, c: usize| -> String {
976 let pad = " ".repeat(width[c].saturating_sub(dw(s)));
977 let body = if right[c] {
978 format!("{pad}{s}")
979 } else {
980 format!("{s}{pad}")
981 };
982 format!(" {body} ")
983 };
984 let render_row = |row: &[String]| -> String {
985 let cells: Vec<String> = (0..num_cols).map(|c| fmt_cell(&row[c], c)).collect();
986 format!("|{}|", cells.join("|"))
987 };
988
989 let mut lines = Vec::with_capacity(body.len() + 2);
990 lines.push(render_row(&header));
991 let sep: Vec<String> = (0..num_cols).map(|c| "-".repeat(width[c] + 2)).collect();
992 lines.push(format!("|{}|", sep.join("|")));
993 for row in &body {
994 lines.push(render_row(row));
995 }
996 lines.join("\n")
997}
998
999fn escape_cell(s: &str) -> String {
1002 s.replace('\n', " ").replace('|', "|")
1003}
1004
1005#[cfg(test)]
1006mod tests {
1007 use super::*;
1008 use crate::{PictureImage, TableCell, TableStructure};
1009
1010 #[test]
1011 fn renders_headings_paragraphs_and_lists() {
1012 let mut doc = DoclingDocument::new("demo");
1013 doc.add_heading(1, "Title");
1014 doc.add_paragraph("Hello world.");
1015 doc.push(Node::ListItem {
1016 ordered: false,
1017 number: 1,
1018 first_in_list: true,
1019 text: "first".into(),
1020 level: 0,
1021 marker: None,
1022 location: None,
1023 dclx: None,
1024 href: None,
1025 layer: None,
1026 });
1027 doc.push(Node::ListItem {
1028 ordered: false,
1029 number: 2,
1030 first_in_list: false,
1031 text: "second".into(),
1032 level: 0,
1033 marker: None,
1034 location: None,
1035 dclx: None,
1036 href: None,
1037 layer: None,
1038 });
1039 let md = doc.export_to_markdown();
1040 assert_eq!(md, "# Title\n\nHello world.\n\n- first\n- second\n");
1041 }
1042
1043 #[test]
1047 fn single_newlines_become_gfm_hard_line_breaks() {
1048 let mut doc = DoclingDocument::new("t");
1049 doc.push(Node::Heading {
1050 level: 1,
1051 text: "Hello\nWorld".into(),
1052 });
1053 doc.push(Node::Paragraph {
1054 text: "line one\nline two\n\npara two".into(),
1055 });
1056 doc.push(Node::ListItem {
1057 ordered: false,
1058 number: 1,
1059 first_in_list: true,
1060 text: "item\ncontinued".into(),
1061 level: 0,
1062 marker: None,
1063 location: None,
1064 dclx: None,
1065 href: None,
1066 layer: None,
1067 });
1068 doc.push(Node::TextDump("A1 B1 \n\n\nC1".into()));
1069 assert_eq!(
1070 doc.export_to_markdown(),
1071 "# Hello World\n\nline one \nline two\n\npara two\n\n- item \ncontinued\n\nA1 B1 \n\n\nC1\n"
1072 );
1073 }
1074
1075 #[test]
1078 fn table_cell_mode_and_field_regions() {
1079 let mut doc = DoclingDocument::new("t");
1080 doc.push(Node::Heading {
1081 level: 2,
1082 text: "A text".into(),
1083 });
1084 doc.push(Node::Paragraph {
1085 text: "body".into(),
1086 });
1087 assert_eq!(to_markdown_table_cell(&doc, false), "A text\n\nbody");
1088 assert_eq!(doc.export_to_markdown(), "## A text\n\nbody\n");
1089
1090 let mut doc = DoclingDocument::new("f");
1091 doc.push(Node::FieldRegion {
1092 items: vec![crate::FieldItem {
1093 marker: None,
1094 key: Some("Name:".into()),
1095 value: Some("John Doe".into()),
1096 }],
1097 });
1098 assert_eq!(doc.export_to_markdown(), "Name:\n\nJohn Doe\n");
1099 }
1100
1101 #[test]
1102 fn strict_renders_recovered_links_legacy_does_not() {
1103 let mut doc = DoclingDocument::new("cv");
1104 doc.add_paragraph("Find me on LinkedIn or GitHub.");
1105 doc.links = vec![
1106 ("LinkedIn".into(), "https://www.linkedin.com/in/x/".into()),
1107 ("GitHub".into(), "https://github.com/x/".into()),
1108 ];
1109 assert_eq!(doc.export_to_markdown(), "Find me on LinkedIn or GitHub.\n");
1111 assert_eq!(
1113 doc.export_to_markdown_with(true),
1114 "Find me on [LinkedIn](https://www.linkedin.com/in/x/) or [GitHub](https://github.com/x/).\n"
1115 );
1116 }
1117
1118 #[test]
1119 fn strict_links_match_escaped_anchor_and_consume_in_order() {
1120 let mut doc = DoclingDocument::new("d");
1121 doc.add_paragraph("AI & ML here, and issues here, then issues there.");
1125 doc.links = vec![
1126 ("AI & ML".into(), "https://a/".into()),
1127 ("issues".into(), "https://first/".into()),
1128 ("issues".into(), "https://second/".into()),
1129 ];
1130 assert_eq!(
1131 doc.export_to_markdown_with(true),
1132 "[AI & ML](https://a/) here, and [issues](https://first/) here, then [issues](https://second/) there.\n"
1133 );
1134 }
1135
1136 #[test]
1140 fn referenced_image_destinations_are_escaped() {
1141 let cases = [
1142 (
1143 "doc_artifacts/image_000001_ab12.png",
1144 "doc_artifacts/image_000001_ab12.png",
1145 ),
1146 (
1147 "My Report_artifacts/img.png",
1148 "My%20Report_artifacts/img.png",
1149 ),
1150 ("artifacts/img (1).png", "artifacts/img%20%281%29.png"),
1151 ("100%_scale/a#b?c.png", "100%_scale/a%23b%3Fc.png"),
1152 ("/home/a b/img.png", "/home/a%20b/img.png"),
1153 (
1154 "My Report_artifacts\\img.png",
1155 "My%20Report_artifacts/img.png",
1156 ),
1157 (
1158 "C:/Users/me/My Docs/img.png",
1159 "file:///C:/Users/me/My%20Docs/img.png",
1160 ),
1161 ("C:\\Users\\me\\img.png", "file:///C:/Users/me/img.png"),
1162 (
1163 "//server/share/My Docs/img.png",
1164 "file://server/share/My%20Docs/img.png",
1165 ),
1166 ("\\\\server\\share\\img.png", "file://server/share/img.png"),
1167 ("file:///home/a b/img.png", "file:///home/a%20b/img.png"),
1168 (
1169 "s3://bucket/My Report_artifacts/img.png",
1170 "s3://bucket/My%20Report_artifacts/img.png",
1171 ),
1172 (
1173 "https://example.com:8080/a b.png?w=1&h=2#frag",
1174 "https://example.com:8080/a%20b.png?w=1&h=2#frag",
1175 ),
1176 (
1177 "https://example.com/img (1).png",
1178 "https://example.com/img%20%281%29.png",
1179 ),
1180 ("caf\u{e9}/im\u{e4}ge.png", "caf%C3%A9/im%C3%A4ge.png"),
1181 ];
1182 for (input, expected) in cases {
1183 assert_eq!(escape_uri_path(input), expected, "input {input:?}");
1184 assert_eq!(
1185 escape_uri_path(expected),
1186 expected,
1187 "idempotent {expected:?}"
1188 );
1189 }
1190 let mut doc = DoclingDocument::new("t");
1192 doc.push(Node::Picture {
1193 caption: None,
1194 caption_href: None,
1195 image: Some(PictureImage {
1196 mimetype: "image/png".into(),
1197 width: 1,
1198 height: 1,
1199 data: b"x".to_vec(),
1200 }),
1201 classification: None,
1202 });
1203 let (md, files) = doc
1204 .export_to_markdown_with_images(ImageMode::Referenced, "My Report (final)_artifacts");
1205 assert!(
1206 md.contains(""),
1207 "got:\n{md}"
1208 );
1209 assert_eq!(files[0].0, "My Report (final)_artifacts/image_000000.png");
1211 }
1212
1213 #[test]
1217 fn folded_list_item_pictures_keep_plain_newlines() {
1218 assert_eq!(
1219 list_item_text("Step\n<!-- image -->", false),
1220 "Step\n<!-- image -->"
1221 );
1222 assert_eq!(
1223 list_item_text("Step\nAlt text\n<!-- image -->\n<!-- image -->", false),
1224 "Step\nAlt text\n<!-- image -->\n<!-- image -->"
1225 );
1226 assert_eq!(
1227 list_item_text("line one\nline two", false),
1228 "line one \nline two"
1229 );
1230 }
1231
1232 #[test]
1235 fn stacked_header_rows_flatten_into_one() {
1236 let mut t = Table {
1237 rows: vec![
1238 vec!["".into(), "% of Total".into(), "% of Total".into()],
1239 vec!["class".into(), "Train".into(), "Test".into()],
1240 vec!["Caption".into(), "2.04".into(), "1.77".into()],
1241 ],
1242 ..Default::default()
1243 };
1244 t.structure = Some(TableStructure {
1245 header_row: vec![true, true, false],
1246 col_continuation: vec![
1247 vec![false, false, true],
1248 vec![false, false, false],
1249 vec![false, false, false],
1250 ],
1251 ..Default::default()
1252 });
1253 assert_eq!(t.header_row_count(), 2);
1254 assert_eq!(
1255 render_table(&t, true),
1256 "| class | % of Total - Train | % of Total - Test |\n| - | - | - |\n| Caption | 2.04 | 1.77 |"
1257 );
1258 assert_eq!(
1260 render_table(&t, false),
1261 "| class | % of Total - Train | % of Total - Test |\n\
1262 |---------|----------------------|---------------------|\n\
1263 | Caption | 2.04 | 1.77 |"
1264 );
1265 }
1266
1267 #[test]
1270 fn vertically_spanning_header_does_not_extend_the_block() {
1271 let mut t = Table {
1272 rows: vec![
1273 vec!["Name".into(), "Value".into()],
1274 vec!["Name".into(), "1".into()],
1275 vec!["x".into(), "2".into()],
1276 ],
1277 ..Default::default()
1278 };
1279 t.structure = Some(TableStructure {
1280 col_header: vec![vec![true, true], vec![true, false], vec![false, false]],
1281 row_continuation: vec![vec![false, false], vec![true, false], vec![false, false]],
1282 ..Default::default()
1283 });
1284 assert_eq!(t.header_row_count(), 1);
1285 assert_eq!(
1286 render_table(&t, true),
1287 "| Name | Value |\n| - | - |\n| Name | 1 |\n| x | 2 |"
1288 );
1289 }
1290
1291 #[test]
1294 fn header_flags_not_on_row_zero_keep_all_rows_in_the_body() {
1295 let mut t = Table {
1296 rows: vec![
1297 vec!["1".into(), "2".into()],
1298 vec!["a".into(), "b".into()],
1299 vec!["333".into(), "4".into()],
1300 ],
1301 ..Default::default()
1302 };
1303 t.structure = Some(TableStructure {
1304 header_row: vec![false, true, false],
1305 ..Default::default()
1306 });
1307 assert_eq!(t.header_row_count(), 0);
1308 assert_eq!(
1309 render_table(&t, false),
1310 "| | |\n|-----|----|\n| 1 | 2 |\n| a | b |\n| 333 | 4 |"
1311 );
1312 }
1313
1314 #[test]
1319 fn pivot_row_headers_do_not_extend_the_header() {
1320 let mut t = Table {
1321 rows: vec![
1322 vec!["Year".into(), "Month".into()],
1323 vec!["2025".into(), "January".into()],
1324 vec!["2025".into(), "February".into()],
1325 ],
1326 ..Default::default()
1327 };
1328 t.structure = Some(TableStructure {
1329 col_header: vec![vec![true, true], vec![false, false], vec![false, false]],
1330 row_header: vec![vec![false, false], vec![true, false], vec![true, false]],
1331 row_continuation: vec![vec![false, false], vec![false, false], vec![true, false]],
1332 ..Default::default()
1333 });
1334 assert_eq!(t.header_row_count(), 1);
1335 assert_eq!(
1336 render_table(&t, true),
1337 "| Year | Month |\n| - | - |\n| 2025 | January |\n| 2025 | February |"
1338 );
1339 }
1340
1341 #[test]
1344 fn unflagged_cells_keep_row_zero_as_header() {
1345 let mut t = Table {
1346 rows: vec![vec!["h".into()], vec!["d".into()]],
1347 ..Default::default()
1348 };
1349 t.cells = Some(
1350 [(0usize, "h"), (1, "d")]
1351 .into_iter()
1352 .map(|(r, text)| TableCell {
1353 text: text.into(),
1354 bbox: None,
1355 start_row: r,
1356 start_col: 0,
1357 row_span: 1,
1358 col_span: 1,
1359 column_header: false,
1360 row_header: false,
1361 row_section: false,
1362 })
1363 .collect(),
1364 );
1365 assert_eq!(t.header_row_count(), 1);
1366 assert_eq!(render_table(&t, true), "| h |\n| - |\n| d |");
1367 }
1368
1369 #[test]
1370 fn renders_compact_table() {
1371 let mut doc = DoclingDocument::new("t");
1372 doc.compact_tables = true;
1375 doc.push(Node::Table(Table {
1376 rows: vec![vec!["a".into(), "b".into()], vec!["1".into(), "2".into()]],
1377 location: None,
1378 structure: None,
1379 cell_blocks: None,
1380 cells: None,
1381 caption: None,
1382 }));
1383 let md = doc.export_to_markdown();
1384 assert_eq!(md, "| a | b |\n| - | - |\n| 1 | 2 |\n");
1385 }
1386
1387 #[test]
1388 fn renders_padded_github_table_by_default() {
1389 let mut doc = DoclingDocument::new("t");
1390 doc.push(Node::Table(Table {
1391 rows: vec![vec!["a".into(), "b".into()], vec!["1".into(), "2".into()]],
1392 location: None,
1393 structure: None,
1394 cell_blocks: None,
1395 cells: None,
1396 caption: None,
1397 }));
1398 let md = doc.export_to_markdown();
1399 assert_eq!(md, "| a | b |\n|-----|-----|\n| 1 | 2 |\n");
1401 }
1402
1403 #[test]
1404 fn strict_unescapes_inline_underscores_legacy_keeps_them() {
1405 let mut doc = DoclingDocument::new("t");
1406 doc.add_heading(1, "a\\_b");
1407 doc.add_paragraph("x\\_y");
1408 doc.push(Node::ListItem {
1409 ordered: false,
1410 number: 1,
1411 first_in_list: true,
1412 text: "i\\_j".into(),
1413 level: 0,
1414 marker: None,
1415 location: None,
1416 dclx: None,
1417 href: None,
1418 layer: None,
1419 });
1420 assert_eq!(doc.export_to_markdown(), "# a\\_b\n\nx\\_y\n\n- i\\_j\n");
1422 assert_eq!(doc.export_to_markdown_with(true), "# a_b\n\nx_y\n\n- i_j\n");
1424 }
1425
1426 fn assert_stream_matches(
1429 doc: &DoclingDocument,
1430 strict: bool,
1431 images: ImageMode,
1432 splits: &[usize],
1433 ) {
1434 let (want, want_artifacts) = to_markdown_images(doc, strict, images, "artifacts");
1435 let mut streamer =
1436 MarkdownStreamer::with_artifacts(strict, images, doc.compact_tables, "artifacts");
1437 let mut got = String::new();
1438 let mut got_artifacts = Vec::new();
1439 let mut start = 0;
1440 for &end in splits {
1441 let links = if start == 0 {
1444 doc.links.as_slice()
1445 } else {
1446 &[]
1447 };
1448 got.push_str(&streamer.push(&doc.nodes[start..end], links));
1449 got_artifacts.extend(streamer.take_artifacts());
1452 start = end;
1453 }
1454 got.push_str(&streamer.push(
1455 &doc.nodes[start..],
1456 if start == 0 {
1457 doc.links.as_slice()
1458 } else {
1459 &[]
1460 },
1461 ));
1462 got_artifacts.extend(streamer.take_artifacts());
1463 got.push_str(&streamer.finish());
1464 assert_eq!(
1465 got, want,
1466 "streamed output diverged (splits={splits:?}, strict={strict})"
1467 );
1468 assert_eq!(
1469 got_artifacts, want_artifacts,
1470 "streamed artifacts diverged (splits={splits:?}, strict={strict})"
1471 );
1472 }
1473
1474 #[test]
1475 fn streaming_is_byte_identical_to_buffered() {
1476 let mut doc = DoclingDocument::new("d");
1477 doc.add_heading(1, "Title");
1478 doc.add_paragraph("First paragraph.");
1479 doc.push(Node::ListItem {
1480 ordered: false,
1481 number: 1,
1482 first_in_list: true,
1483 text: "a".into(),
1484 level: 0,
1485 marker: None,
1486 location: None,
1487 dclx: None,
1488 href: None,
1489 layer: None,
1490 });
1491 doc.push(Node::ListItem {
1492 ordered: false,
1493 number: 2,
1494 first_in_list: false,
1495 text: "b".into(),
1496 level: 0,
1497 marker: None,
1498 location: None,
1499 dclx: None,
1500 href: None,
1501 layer: None,
1502 });
1503 doc.push(Node::Code {
1504 language: Some("rust".into()),
1505 text: "let x = 1;".into(),
1506 orig: None,
1507 pretty: None,
1508 });
1509 doc.push(Node::Table(Table {
1510 rows: vec![vec!["a".into(), "b".into()], vec!["1".into(), "2".into()]],
1511 location: None,
1512 structure: None,
1513 cell_blocks: None,
1514 cells: None,
1515 caption: None,
1516 }));
1517 doc.push(Node::Picture {
1518 caption: Some("Fig 1".into()),
1519 caption_href: None,
1520 image: Some(PictureImage {
1521 mimetype: "image/png".into(),
1522 width: 2,
1523 height: 2,
1524 data: b"png-one".to_vec(),
1525 }),
1526 classification: None,
1527 });
1528 doc.add_paragraph("Last paragraph.");
1529 doc.push(Node::Picture {
1532 caption: None,
1533 caption_href: None,
1534 image: Some(PictureImage {
1535 mimetype: "image/png".into(),
1536 width: 2,
1537 height: 2,
1538 data: b"png-two".to_vec(),
1539 }),
1540 classification: None,
1541 });
1542
1543 for &strict in &[false, true] {
1546 for &images in &[
1547 ImageMode::Placeholder,
1548 ImageMode::Embedded,
1549 ImageMode::Referenced,
1550 ] {
1551 for splits in [&[][..], &[1][..], &[2][..], &[4][..], &[1, 4, 6, 7][..]] {
1552 assert_stream_matches(&doc, strict, images, splits);
1553 }
1554 }
1555 }
1556 }
1557
1558 #[test]
1559 fn streaming_applies_recovered_links_in_strict_mode() {
1560 let mut doc = DoclingDocument::new("d");
1561 doc.add_paragraph("See LinkedIn for details.");
1562 doc.add_paragraph("And GitHub too.");
1563 doc.links = vec![
1564 ("LinkedIn".into(), "https://lnkd/".into()),
1565 ("GitHub".into(), "https://gh/".into()),
1566 ];
1567 assert_stream_matches(&doc, true, ImageMode::Placeholder, &[1]);
1570 }
1571
1572 #[test]
1573 fn strict_tightens_punctuation_spacing_legacy_keeps_it() {
1574 let mut doc = DoclingDocument::new("t");
1575 doc.add_paragraph("see [ 37 , 36 ] and ( x ) .");
1576 assert_eq!(doc.export_to_markdown(), "see [ 37 , 36 ] and ( x ) .\n");
1578 assert_eq!(doc.export_to_markdown_with(true), "see [37, 36] and (x).\n");
1580 }
1581}