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
397 for item in items {
398 let Node::ListItem {
399 ordered,
400 number,
401 first_in_list,
402 text,
403 level,
404 marker: _,
405 location: _,
406 dclx,
407 href: _,
408 layer,
409 } = item
410 else {
411 continue;
412 };
413 if layer.is_some() {
416 continue;
417 }
418 let level = *level as usize;
419
420 prev.truncate(level + 1);
422 while prev.len() <= level {
423 prev.push(None);
424 }
425
426 let eff_ordered = dclx.as_ref().map_or(*ordered, |d| d.ordered);
432 if level == 0 {
433 if let Some((prev_ordered, prev_number)) = prev[level] {
434 let same_word_list = prev_projected && eff_ordered;
439 let new_list = *first_in_list
440 || (!same_word_list
441 && (prev_ordered != *ordered || (*ordered && *number != prev_number + 1)));
442 if new_list {
443 lines.push(String::new());
444 }
445 }
446 prev_projected = eff_ordered && !*ordered;
447 }
448
449 let indent = " ".repeat(level);
450 let marker = if *ordered {
451 format!("{number}.")
452 } else {
453 "-".to_string()
454 };
455 lines.push(format!("{indent}{marker} {}", list_item_text(text, strict)));
456 prev[level] = Some((*ordered, *number));
457 }
458
459 if !lines.is_empty() {
462 blocks.push(lines.join("\n"));
463 }
464}
465
466fn list_item_text(text: &str, strict: bool) -> String {
474 let escaped = strict_text(text, strict);
475 if let Some((own, tail)) = escaped.split_once('\n') {
476 if is_folded_picture_tail(tail) {
477 return format!("{}\n{tail}", md_line_breaks(own));
478 }
479 }
480 md_line_breaks(&escaped)
481}
482
483fn is_folded_picture_tail(tail: &str) -> bool {
484 const MARKER: &str = "<!-- image -->";
485 let mut lines = tail.split('\n').peekable();
486 let mut any = false;
487 while let Some(line) = lines.next() {
488 if line == MARKER {
489 any = true;
490 } else if lines.next() == Some(MARKER) {
491 any = true; } else {
493 return false;
494 }
495 }
496 any
497}
498
499fn render_one(node: &Node, blocks: &mut Vec<String>, ctx: &mut Ctx) {
500 match node {
501 Node::Heading { level, text } => {
502 let text = heading_line_breaks(&strict_text(text, ctx.strict));
503 if ctx.in_table_cell {
504 blocks.push(text);
506 } else {
507 let hashes = "#".repeat((*level).clamp(1, 6) as usize);
508 blocks.push(format!("{hashes} {text}"));
509 }
510 }
511 Node::Paragraph { text } if text.is_empty() => {}
514 Node::Paragraph { text } => blocks.push(md_line_breaks(&strict_text(text, ctx.strict))),
515 Node::Caption { text, .. } if text.is_empty() => {}
518 Node::Caption { text, href } => {
519 let body = md_line_breaks(&strict_text(text, ctx.strict));
520 blocks.push(match href {
521 Some(url) => format!("[{body}]({url})"),
522 None => body,
523 });
524 }
525 Node::CheckboxItem { checked, text } => {
526 let mark = if *checked { "- [x] " } else { "- [ ] " };
527 blocks.push(md_line_breaks(&strict_text(
528 &format!("{mark}{text}"),
529 ctx.strict,
530 )));
531 }
532 Node::Code {
533 language,
534 text,
535 pretty,
536 ..
537 } => {
538 let lang = match language {
540 Some(l) if ctx.strict => l.as_str(),
541 _ => "",
542 };
543 let body = match pretty {
546 Some(p) if ctx.strict => p.as_str(),
547 _ => text.as_str(),
548 };
549 blocks.push(format!("```{lang}\n{body}\n```"));
550 }
551 Node::Formula { latex, .. } => blocks.push(format!("$${latex}$$")),
554 Node::Table(table) => {
555 if let Some(cap) = &table.caption {
558 if !cap.is_empty() {
559 blocks.push(md_line_breaks(&strict_text(cap, ctx.strict)));
560 }
561 }
562 let rendered = render_table(table, ctx.compact_tables);
563 if !rendered.is_empty() {
564 blocks.push(rendered);
565 }
566 }
567 Node::Picture { caption, image, .. } => {
569 if let Some(cap) = caption {
570 if !cap.is_empty() {
571 blocks.push(md_line_breaks(cap));
572 }
573 }
574 blocks.push(picture_marker(image.as_ref(), ctx));
575 }
576 Node::Chart {
580 kind,
581 table,
582 caption,
583 ..
584 } => {
585 if let Some(cap) = caption {
586 if !cap.is_empty() {
587 blocks.push(md_line_breaks(cap));
588 }
589 }
590 blocks.push(picture_marker(None, ctx));
591 blocks.push(humanize_label(kind));
592 let rendered = render_table(table, false);
593 if !rendered.is_empty() {
594 blocks.push(rendered);
595 }
596 }
597 Node::DoclangOnly(_) => {}
599 Node::Group { layer: Some(_), .. } => {}
602 Node::Group { children, .. } => render(children, blocks, ctx),
603 Node::FieldRegion { items } => {
604 for item in items {
609 for part in [&item.marker, &item.key, &item.value].into_iter().flatten() {
610 blocks.push(md_line_breaks(&strict_text(part, ctx.strict)));
611 }
612 }
613 }
614 Node::InlineGroup { md_text, .. } => {
617 blocks.push(md_line_breaks(&strict_text(md_text, ctx.strict)))
618 }
619 Node::TextDump(text) => {
621 if !text.is_empty() {
622 blocks.push(text.clone());
623 }
624 }
625 Node::Furniture { .. } => {}
628 Node::PageFurniture { .. } => {}
629 Node::CommentSection { .. } => {}
632 Node::Commented { inner, .. } => render_one(inner, blocks, ctx),
633 Node::Located { inner, .. } => render_one(inner, blocks, ctx),
635 Node::PageBreak => {}
637 Node::PageInfo { .. } => {}
639 Node::ListItem { .. } => render_list_run(std::slice::from_ref(node), blocks, ctx.strict),
644 }
645}
646
647fn humanize_label(label: &str) -> String {
652 let text = label.replace('_', " ");
653 let mut chars = text.chars();
654 match chars.next() {
655 Some(f) => f.to_uppercase().collect::<String>() + chars.as_str(),
656 None => text,
657 }
658}
659
660fn picture_marker(image: Option<&crate::PictureImage>, ctx: &mut Ctx) -> String {
661 match (ctx.images, image) {
662 (ImageMode::Embedded, Some(img)) => format!("", img.data_uri()),
663 (ImageMode::Referenced, Some(img)) => {
664 let path = format!(
665 "{}/image_{:06}.{}",
666 ctx.artifacts_dir,
667 ctx.pic_index,
668 ext_for(&img.mimetype)
669 );
670 ctx.pic_index += 1;
671 ctx.artifacts.push((path.clone(), img.data.clone()));
672 format!("", escape_uri_path(&path))
673 }
674 _ => "<!-- image -->".to_string(),
676 }
677}
678
679pub(crate) fn escape_uri_path(value: &str) -> String {
692 const KEEP: &str = "/%:@+,;=~$!&'*";
693 let s = value.replace('\\', "/");
694 if let Some(rest) = s.strip_prefix("//") {
695 let rest = rest.trim_start_matches('/');
697 let (host, tail) = rest.split_once('/').unwrap_or((rest, ""));
698 return format!("file://{host}{}", percent_quote(&format!("/{tail}"), KEEP));
699 }
700 let bytes = s.as_bytes();
701 if bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'/' {
702 return format!("file:///{}", percent_quote(&s, KEEP));
704 }
705 if let Some((scheme, rest)) = s.split_once(':') {
709 let valid_scheme = scheme.len() > 1
710 && scheme.as_bytes()[0].is_ascii_alphabetic()
711 && scheme
712 .bytes()
713 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'-' | b'.'));
714 if valid_scheme {
715 let (authority, rest) = match rest.strip_prefix("//") {
716 Some(r) => {
717 let end = r.find(['/', '?', '#']).unwrap_or(r.len());
718 (Some(&r[..end]), &r[end..])
719 }
720 None => (None, rest),
721 };
722 let (before_frag, fragment) = rest.split_once('#').unwrap_or((rest, ""));
723 let (path, query) = before_frag.split_once('?').unwrap_or((before_frag, ""));
724 let mut out = format!("{scheme}:");
725 if let Some(a) = authority {
726 out.push_str("//");
727 out.push_str(a);
728 }
729 out.push_str(&percent_quote(path, KEEP));
730 if !query.is_empty() {
731 out.push('?');
732 out.push_str(&percent_quote(query, KEEP));
733 }
734 if !fragment.is_empty() {
735 out.push('#');
736 out.push_str(&percent_quote(fragment, KEEP));
737 }
738 return out;
739 }
740 }
741 percent_quote(&s, KEEP)
743}
744
745fn percent_quote(s: &str, safe: &str) -> String {
748 let mut out = String::with_capacity(s.len());
749 for &b in s.as_bytes() {
750 let keep = b.is_ascii_alphanumeric()
751 || matches!(b, b'_' | b'.' | b'-' | b'~')
752 || (b.is_ascii() && safe.contains(b as char));
753 if keep {
754 out.push(b as char);
755 } else {
756 out.push_str(&format!("%{b:02X}"));
757 }
758 }
759 out
760}
761
762fn ext_for(mimetype: &str) -> &str {
763 match mimetype {
764 "image/jpeg" => "jpg",
765 "image/gif" => "gif",
766 "image/webp" => "webp",
767 "image/bmp" => "bmp",
768 "image/tiff" => "tif",
769 _ => "png",
770 }
771}
772
773fn is_number_cell(t: &str) -> bool {
792 t.parse::<f64>().is_ok() || is_thousands_number(t)
793}
794
795fn is_thousands_number(t: &str) -> bool {
801 let b = t.as_bytes();
802 let mut i = 0;
803 let start = i;
804 if i < b.len() && (b[i] == b'+' || b[i] == b'-') {
805 i += 1;
806 }
807 let d0 = i;
809 while i < b.len() && b[i].is_ascii_digit() && i - d0 < 3 {
810 i += 1;
811 }
812 let has_int = i > d0;
813 if has_int {
814 while i + 3 < b.len() + 1
816 && b.get(i) == Some(&b',')
817 && b.get(i + 1).is_some_and(u8::is_ascii_digit)
818 && b.get(i + 2).is_some_and(u8::is_ascii_digit)
819 && b.get(i + 3).is_some_and(u8::is_ascii_digit)
820 {
821 i += 4;
822 }
823 } else {
824 i = start;
826 }
827 if i < b.len() && b[i] == b'.' {
829 i += 1;
830 let f0 = i;
831 while i < b.len() && b[i].is_ascii_digit() {
832 i += 1;
833 }
834 if !has_int && i == f0 {
835 return false; }
837 } else if !has_int {
838 return false; }
840 i == b.len()
841}
842
843fn flatten_header_rows(header_rows: &[Vec<String>], num_cols: usize) -> Vec<String> {
852 (0..num_cols)
853 .map(|c| {
854 let mut parts: Vec<&str> = Vec::new();
855 for row in header_rows {
856 let text = row.get(c).map(String::as_str).unwrap_or("");
857 if !text.is_empty() && parts.last() != Some(&text) {
858 parts.push(text);
859 }
860 }
861 parts.join(" - ")
862 })
863 .collect()
864}
865
866pub(crate) fn render_table(table: &Table, compact: bool) -> String {
867 if table.rows.is_empty() {
868 return String::new();
869 }
870 let num_cols = table.rows.iter().map(Vec::len).max().unwrap_or(0);
871 if num_cols == 0 {
872 return String::new();
873 }
874
875 let num_headers = table.header_row_count().min(table.rows.len());
880 let escaped = |r: usize| -> Vec<String> {
881 (0..num_cols)
882 .map(|c| escape_cell(table.rows[r].get(c).map(String::as_str).unwrap_or("")))
883 .collect()
884 };
885 let header_rows: Vec<Vec<String>> = (0..num_headers).map(escaped).collect();
886 let header = flatten_header_rows(&header_rows, num_cols);
887 let body: Vec<Vec<String>> = (num_headers..table.rows.len())
888 .map(|r| {
889 escaped(r)
890 .into_iter()
891 .map(|c| c.trim().to_string())
892 .collect()
893 })
894 .collect();
895
896 if compact {
897 let render_row = |row: &[String]| -> String { format!("| {} |", row.join(" | ")) };
899 let mut lines = Vec::with_capacity(body.len() + 2);
900 lines.push(render_row(&header));
901 let sep: Vec<&str> = (0..num_cols).map(|_| "-").collect();
902 lines.push(format!("| {} |", sep.join(" | ")));
903 for row in &body {
904 lines.push(render_row(row));
905 }
906 return lines.join("\n");
907 }
908
909 let dw = |s: &str| s.chars().count();
911
912 let right: Vec<bool> = (0..num_cols)
917 .map(|c| {
918 let mut any = false;
919 for row in &body {
920 let t = row[c].trim();
921 if t.is_empty() {
922 continue;
923 }
924 if !is_number_cell(t) {
925 return false;
926 }
927 any = true;
928 }
929 any
930 })
931 .collect();
932
933 let width: Vec<usize> = (0..num_cols)
935 .map(|c| {
936 let mut w = dw(&header[c]) + 2;
937 for row in &body {
938 w = w.max(dw(&row[c]));
939 }
940 w
941 })
942 .collect();
943
944 let fmt_cell = |s: &str, c: usize| -> String {
945 let pad = " ".repeat(width[c].saturating_sub(dw(s)));
946 let body = if right[c] {
947 format!("{pad}{s}")
948 } else {
949 format!("{s}{pad}")
950 };
951 format!(" {body} ")
952 };
953 let render_row = |row: &[String]| -> String {
954 let cells: Vec<String> = (0..num_cols).map(|c| fmt_cell(&row[c], c)).collect();
955 format!("|{}|", cells.join("|"))
956 };
957
958 let mut lines = Vec::with_capacity(body.len() + 2);
959 lines.push(render_row(&header));
960 let sep: Vec<String> = (0..num_cols).map(|c| "-".repeat(width[c] + 2)).collect();
961 lines.push(format!("|{}|", sep.join("|")));
962 for row in &body {
963 lines.push(render_row(row));
964 }
965 lines.join("\n")
966}
967
968fn escape_cell(s: &str) -> String {
971 s.replace('\n', " ").replace('|', "|")
972}
973
974#[cfg(test)]
975mod tests {
976 use super::*;
977 use crate::{PictureImage, TableCell, TableStructure};
978
979 #[test]
980 fn renders_headings_paragraphs_and_lists() {
981 let mut doc = DoclingDocument::new("demo");
982 doc.add_heading(1, "Title");
983 doc.add_paragraph("Hello world.");
984 doc.push(Node::ListItem {
985 ordered: false,
986 number: 1,
987 first_in_list: true,
988 text: "first".into(),
989 level: 0,
990 marker: None,
991 location: None,
992 dclx: None,
993 href: None,
994 layer: None,
995 });
996 doc.push(Node::ListItem {
997 ordered: false,
998 number: 2,
999 first_in_list: false,
1000 text: "second".into(),
1001 level: 0,
1002 marker: None,
1003 location: None,
1004 dclx: None,
1005 href: None,
1006 layer: None,
1007 });
1008 let md = doc.export_to_markdown();
1009 assert_eq!(md, "# Title\n\nHello world.\n\n- first\n- second\n");
1010 }
1011
1012 #[test]
1016 fn single_newlines_become_gfm_hard_line_breaks() {
1017 let mut doc = DoclingDocument::new("t");
1018 doc.push(Node::Heading {
1019 level: 1,
1020 text: "Hello\nWorld".into(),
1021 });
1022 doc.push(Node::Paragraph {
1023 text: "line one\nline two\n\npara two".into(),
1024 });
1025 doc.push(Node::ListItem {
1026 ordered: false,
1027 number: 1,
1028 first_in_list: true,
1029 text: "item\ncontinued".into(),
1030 level: 0,
1031 marker: None,
1032 location: None,
1033 dclx: None,
1034 href: None,
1035 layer: None,
1036 });
1037 doc.push(Node::TextDump("A1 B1 \n\n\nC1".into()));
1038 assert_eq!(
1039 doc.export_to_markdown(),
1040 "# Hello World\n\nline one \nline two\n\npara two\n\n- item \ncontinued\n\nA1 B1 \n\n\nC1\n"
1041 );
1042 }
1043
1044 #[test]
1047 fn table_cell_mode_and_field_regions() {
1048 let mut doc = DoclingDocument::new("t");
1049 doc.push(Node::Heading {
1050 level: 2,
1051 text: "A text".into(),
1052 });
1053 doc.push(Node::Paragraph {
1054 text: "body".into(),
1055 });
1056 assert_eq!(to_markdown_table_cell(&doc, false), "A text\n\nbody");
1057 assert_eq!(doc.export_to_markdown(), "## A text\n\nbody\n");
1058
1059 let mut doc = DoclingDocument::new("f");
1060 doc.push(Node::FieldRegion {
1061 items: vec![crate::FieldItem {
1062 marker: None,
1063 key: Some("Name:".into()),
1064 value: Some("John Doe".into()),
1065 }],
1066 });
1067 assert_eq!(doc.export_to_markdown(), "Name:\n\nJohn Doe\n");
1068 }
1069
1070 #[test]
1071 fn strict_renders_recovered_links_legacy_does_not() {
1072 let mut doc = DoclingDocument::new("cv");
1073 doc.add_paragraph("Find me on LinkedIn or GitHub.");
1074 doc.links = vec![
1075 ("LinkedIn".into(), "https://www.linkedin.com/in/x/".into()),
1076 ("GitHub".into(), "https://github.com/x/".into()),
1077 ];
1078 assert_eq!(doc.export_to_markdown(), "Find me on LinkedIn or GitHub.\n");
1080 assert_eq!(
1082 doc.export_to_markdown_with(true),
1083 "Find me on [LinkedIn](https://www.linkedin.com/in/x/) or [GitHub](https://github.com/x/).\n"
1084 );
1085 }
1086
1087 #[test]
1088 fn strict_links_match_escaped_anchor_and_consume_in_order() {
1089 let mut doc = DoclingDocument::new("d");
1090 doc.add_paragraph("AI & ML here, and issues here, then issues there.");
1094 doc.links = vec![
1095 ("AI & ML".into(), "https://a/".into()),
1096 ("issues".into(), "https://first/".into()),
1097 ("issues".into(), "https://second/".into()),
1098 ];
1099 assert_eq!(
1100 doc.export_to_markdown_with(true),
1101 "[AI & ML](https://a/) here, and [issues](https://first/) here, then [issues](https://second/) there.\n"
1102 );
1103 }
1104
1105 #[test]
1109 fn referenced_image_destinations_are_escaped() {
1110 let cases = [
1111 (
1112 "doc_artifacts/image_000001_ab12.png",
1113 "doc_artifacts/image_000001_ab12.png",
1114 ),
1115 (
1116 "My Report_artifacts/img.png",
1117 "My%20Report_artifacts/img.png",
1118 ),
1119 ("artifacts/img (1).png", "artifacts/img%20%281%29.png"),
1120 ("100%_scale/a#b?c.png", "100%_scale/a%23b%3Fc.png"),
1121 ("/home/a b/img.png", "/home/a%20b/img.png"),
1122 (
1123 "My Report_artifacts\\img.png",
1124 "My%20Report_artifacts/img.png",
1125 ),
1126 (
1127 "C:/Users/me/My Docs/img.png",
1128 "file:///C:/Users/me/My%20Docs/img.png",
1129 ),
1130 ("C:\\Users\\me\\img.png", "file:///C:/Users/me/img.png"),
1131 (
1132 "//server/share/My Docs/img.png",
1133 "file://server/share/My%20Docs/img.png",
1134 ),
1135 ("\\\\server\\share\\img.png", "file://server/share/img.png"),
1136 ("file:///home/a b/img.png", "file:///home/a%20b/img.png"),
1137 (
1138 "s3://bucket/My Report_artifacts/img.png",
1139 "s3://bucket/My%20Report_artifacts/img.png",
1140 ),
1141 (
1142 "https://example.com:8080/a b.png?w=1&h=2#frag",
1143 "https://example.com:8080/a%20b.png?w=1&h=2#frag",
1144 ),
1145 (
1146 "https://example.com/img (1).png",
1147 "https://example.com/img%20%281%29.png",
1148 ),
1149 ("caf\u{e9}/im\u{e4}ge.png", "caf%C3%A9/im%C3%A4ge.png"),
1150 ];
1151 for (input, expected) in cases {
1152 assert_eq!(escape_uri_path(input), expected, "input {input:?}");
1153 assert_eq!(
1154 escape_uri_path(expected),
1155 expected,
1156 "idempotent {expected:?}"
1157 );
1158 }
1159 let mut doc = DoclingDocument::new("t");
1161 doc.push(Node::Picture {
1162 caption: None,
1163 caption_href: None,
1164 image: Some(PictureImage {
1165 mimetype: "image/png".into(),
1166 width: 1,
1167 height: 1,
1168 data: b"x".to_vec(),
1169 }),
1170 classification: None,
1171 });
1172 let (md, files) = doc
1173 .export_to_markdown_with_images(ImageMode::Referenced, "My Report (final)_artifacts");
1174 assert!(
1175 md.contains(""),
1176 "got:\n{md}"
1177 );
1178 assert_eq!(files[0].0, "My Report (final)_artifacts/image_000000.png");
1180 }
1181
1182 #[test]
1186 fn folded_list_item_pictures_keep_plain_newlines() {
1187 assert_eq!(
1188 list_item_text("Step\n<!-- image -->", false),
1189 "Step\n<!-- image -->"
1190 );
1191 assert_eq!(
1192 list_item_text("Step\nAlt text\n<!-- image -->\n<!-- image -->", false),
1193 "Step\nAlt text\n<!-- image -->\n<!-- image -->"
1194 );
1195 assert_eq!(
1196 list_item_text("line one\nline two", false),
1197 "line one \nline two"
1198 );
1199 }
1200
1201 #[test]
1204 fn stacked_header_rows_flatten_into_one() {
1205 let mut t = Table {
1206 rows: vec![
1207 vec!["".into(), "% of Total".into(), "% of Total".into()],
1208 vec!["class".into(), "Train".into(), "Test".into()],
1209 vec!["Caption".into(), "2.04".into(), "1.77".into()],
1210 ],
1211 ..Default::default()
1212 };
1213 t.structure = Some(TableStructure {
1214 header_row: vec![true, true, false],
1215 col_continuation: vec![
1216 vec![false, false, true],
1217 vec![false, false, false],
1218 vec![false, false, false],
1219 ],
1220 ..Default::default()
1221 });
1222 assert_eq!(t.header_row_count(), 2);
1223 assert_eq!(
1224 render_table(&t, true),
1225 "| class | % of Total - Train | % of Total - Test |\n| - | - | - |\n| Caption | 2.04 | 1.77 |"
1226 );
1227 assert_eq!(
1229 render_table(&t, false),
1230 "| class | % of Total - Train | % of Total - Test |\n\
1231 |---------|----------------------|---------------------|\n\
1232 | Caption | 2.04 | 1.77 |"
1233 );
1234 }
1235
1236 #[test]
1239 fn vertically_spanning_header_does_not_extend_the_block() {
1240 let mut t = Table {
1241 rows: vec![
1242 vec!["Name".into(), "Value".into()],
1243 vec!["Name".into(), "1".into()],
1244 vec!["x".into(), "2".into()],
1245 ],
1246 ..Default::default()
1247 };
1248 t.structure = Some(TableStructure {
1249 col_header: vec![vec![true, true], vec![true, false], vec![false, false]],
1250 row_continuation: vec![vec![false, false], vec![true, false], vec![false, false]],
1251 ..Default::default()
1252 });
1253 assert_eq!(t.header_row_count(), 1);
1254 assert_eq!(
1255 render_table(&t, true),
1256 "| Name | Value |\n| - | - |\n| Name | 1 |\n| x | 2 |"
1257 );
1258 }
1259
1260 #[test]
1263 fn header_flags_not_on_row_zero_keep_all_rows_in_the_body() {
1264 let mut t = Table {
1265 rows: vec![
1266 vec!["1".into(), "2".into()],
1267 vec!["a".into(), "b".into()],
1268 vec!["333".into(), "4".into()],
1269 ],
1270 ..Default::default()
1271 };
1272 t.structure = Some(TableStructure {
1273 header_row: vec![false, true, false],
1274 ..Default::default()
1275 });
1276 assert_eq!(t.header_row_count(), 0);
1277 assert_eq!(
1278 render_table(&t, false),
1279 "| | |\n|-----|----|\n| 1 | 2 |\n| a | b |\n| 333 | 4 |"
1280 );
1281 }
1282
1283 #[test]
1288 fn row_headers_beside_data_cells_do_not_extend_the_header() {
1289 let mut t = Table {
1290 rows: vec![
1291 vec!["Year".into(), "Month".into()],
1292 vec!["2025".into(), "January".into()],
1293 vec!["2025".into(), "February".into()],
1294 ],
1295 ..Default::default()
1296 };
1297 t.structure = Some(TableStructure {
1298 col_header: vec![vec![true, true], vec![true, false], vec![true, false]],
1299 row_continuation: vec![vec![false, false], vec![false, false], vec![true, false]],
1300 ..Default::default()
1301 });
1302 assert_eq!(t.header_row_count(), 1);
1303 assert_eq!(
1304 render_table(&t, true),
1305 "| Year | Month |\n| - | - |\n| 2025 | January |\n| 2025 | February |"
1306 );
1307 }
1308
1309 #[test]
1312 fn unflagged_cells_keep_row_zero_as_header() {
1313 let mut t = Table {
1314 rows: vec![vec!["h".into()], vec!["d".into()]],
1315 ..Default::default()
1316 };
1317 t.cells = Some(
1318 [(0usize, "h"), (1, "d")]
1319 .into_iter()
1320 .map(|(r, text)| TableCell {
1321 text: text.into(),
1322 bbox: None,
1323 start_row: r,
1324 start_col: 0,
1325 row_span: 1,
1326 col_span: 1,
1327 column_header: false,
1328 row_header: false,
1329 row_section: false,
1330 })
1331 .collect(),
1332 );
1333 assert_eq!(t.header_row_count(), 1);
1334 assert_eq!(render_table(&t, true), "| h |\n| - |\n| d |");
1335 }
1336
1337 #[test]
1338 fn renders_compact_table() {
1339 let mut doc = DoclingDocument::new("t");
1340 doc.compact_tables = true;
1343 doc.push(Node::Table(Table {
1344 rows: vec![vec!["a".into(), "b".into()], vec!["1".into(), "2".into()]],
1345 location: None,
1346 structure: None,
1347 cell_blocks: None,
1348 cells: None,
1349 caption: None,
1350 }));
1351 let md = doc.export_to_markdown();
1352 assert_eq!(md, "| a | b |\n| - | - |\n| 1 | 2 |\n");
1353 }
1354
1355 #[test]
1356 fn renders_padded_github_table_by_default() {
1357 let mut doc = DoclingDocument::new("t");
1358 doc.push(Node::Table(Table {
1359 rows: vec![vec!["a".into(), "b".into()], vec!["1".into(), "2".into()]],
1360 location: None,
1361 structure: None,
1362 cell_blocks: None,
1363 cells: None,
1364 caption: None,
1365 }));
1366 let md = doc.export_to_markdown();
1367 assert_eq!(md, "| a | b |\n|-----|-----|\n| 1 | 2 |\n");
1369 }
1370
1371 #[test]
1372 fn strict_unescapes_inline_underscores_legacy_keeps_them() {
1373 let mut doc = DoclingDocument::new("t");
1374 doc.add_heading(1, "a\\_b");
1375 doc.add_paragraph("x\\_y");
1376 doc.push(Node::ListItem {
1377 ordered: false,
1378 number: 1,
1379 first_in_list: true,
1380 text: "i\\_j".into(),
1381 level: 0,
1382 marker: None,
1383 location: None,
1384 dclx: None,
1385 href: None,
1386 layer: None,
1387 });
1388 assert_eq!(doc.export_to_markdown(), "# a\\_b\n\nx\\_y\n\n- i\\_j\n");
1390 assert_eq!(doc.export_to_markdown_with(true), "# a_b\n\nx_y\n\n- i_j\n");
1392 }
1393
1394 fn assert_stream_matches(
1397 doc: &DoclingDocument,
1398 strict: bool,
1399 images: ImageMode,
1400 splits: &[usize],
1401 ) {
1402 let (want, want_artifacts) = to_markdown_images(doc, strict, images, "artifacts");
1403 let mut streamer =
1404 MarkdownStreamer::with_artifacts(strict, images, doc.compact_tables, "artifacts");
1405 let mut got = String::new();
1406 let mut got_artifacts = Vec::new();
1407 let mut start = 0;
1408 for &end in splits {
1409 let links = if start == 0 {
1412 doc.links.as_slice()
1413 } else {
1414 &[]
1415 };
1416 got.push_str(&streamer.push(&doc.nodes[start..end], links));
1417 got_artifacts.extend(streamer.take_artifacts());
1420 start = end;
1421 }
1422 got.push_str(&streamer.push(
1423 &doc.nodes[start..],
1424 if start == 0 {
1425 doc.links.as_slice()
1426 } else {
1427 &[]
1428 },
1429 ));
1430 got_artifacts.extend(streamer.take_artifacts());
1431 got.push_str(&streamer.finish());
1432 assert_eq!(
1433 got, want,
1434 "streamed output diverged (splits={splits:?}, strict={strict})"
1435 );
1436 assert_eq!(
1437 got_artifacts, want_artifacts,
1438 "streamed artifacts diverged (splits={splits:?}, strict={strict})"
1439 );
1440 }
1441
1442 #[test]
1443 fn streaming_is_byte_identical_to_buffered() {
1444 let mut doc = DoclingDocument::new("d");
1445 doc.add_heading(1, "Title");
1446 doc.add_paragraph("First paragraph.");
1447 doc.push(Node::ListItem {
1448 ordered: false,
1449 number: 1,
1450 first_in_list: true,
1451 text: "a".into(),
1452 level: 0,
1453 marker: None,
1454 location: None,
1455 dclx: None,
1456 href: None,
1457 layer: None,
1458 });
1459 doc.push(Node::ListItem {
1460 ordered: false,
1461 number: 2,
1462 first_in_list: false,
1463 text: "b".into(),
1464 level: 0,
1465 marker: None,
1466 location: None,
1467 dclx: None,
1468 href: None,
1469 layer: None,
1470 });
1471 doc.push(Node::Code {
1472 language: Some("rust".into()),
1473 text: "let x = 1;".into(),
1474 orig: None,
1475 pretty: None,
1476 });
1477 doc.push(Node::Table(Table {
1478 rows: vec![vec!["a".into(), "b".into()], vec!["1".into(), "2".into()]],
1479 location: None,
1480 structure: None,
1481 cell_blocks: None,
1482 cells: None,
1483 caption: None,
1484 }));
1485 doc.push(Node::Picture {
1486 caption: Some("Fig 1".into()),
1487 caption_href: None,
1488 image: Some(PictureImage {
1489 mimetype: "image/png".into(),
1490 width: 2,
1491 height: 2,
1492 data: b"png-one".to_vec(),
1493 }),
1494 classification: None,
1495 });
1496 doc.add_paragraph("Last paragraph.");
1497 doc.push(Node::Picture {
1500 caption: None,
1501 caption_href: None,
1502 image: Some(PictureImage {
1503 mimetype: "image/png".into(),
1504 width: 2,
1505 height: 2,
1506 data: b"png-two".to_vec(),
1507 }),
1508 classification: None,
1509 });
1510
1511 for &strict in &[false, true] {
1514 for &images in &[
1515 ImageMode::Placeholder,
1516 ImageMode::Embedded,
1517 ImageMode::Referenced,
1518 ] {
1519 for splits in [&[][..], &[1][..], &[2][..], &[4][..], &[1, 4, 6, 7][..]] {
1520 assert_stream_matches(&doc, strict, images, splits);
1521 }
1522 }
1523 }
1524 }
1525
1526 #[test]
1527 fn streaming_applies_recovered_links_in_strict_mode() {
1528 let mut doc = DoclingDocument::new("d");
1529 doc.add_paragraph("See LinkedIn for details.");
1530 doc.add_paragraph("And GitHub too.");
1531 doc.links = vec![
1532 ("LinkedIn".into(), "https://lnkd/".into()),
1533 ("GitHub".into(), "https://gh/".into()),
1534 ];
1535 assert_stream_matches(&doc, true, ImageMode::Placeholder, &[1]);
1538 }
1539
1540 #[test]
1541 fn strict_tightens_punctuation_spacing_legacy_keeps_it() {
1542 let mut doc = DoclingDocument::new("t");
1543 doc.add_paragraph("see [ 37 , 36 ] and ( x ) .");
1544 assert_eq!(doc.export_to_markdown(), "see [ 37 , 36 ] and ( x ) .\n");
1546 assert_eq!(doc.export_to_markdown_with(true), "see [37, 36] and (x).\n");
1548 }
1549}