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 any_top = false;
387
388 for item in items {
389 let Node::ListItem {
390 ordered,
391 number,
392 first_in_list,
393 text,
394 level,
395 marker: _,
396 location: _,
397 dclx: _,
398 href: _,
399 layer,
400 } = item
401 else {
402 continue;
403 };
404 if layer.is_some() {
407 continue;
408 }
409 let level = *level as usize;
410
411 if level == 0 {
420 if any_top && *first_in_list {
421 lines.push(String::new());
422 }
423 any_top = true;
424 }
425
426 let indent = " ".repeat(level);
427 let marker = if *ordered {
428 format!("{number}.")
429 } else {
430 "-".to_string()
431 };
432 lines.push(format!("{indent}{marker} {}", list_item_text(text, strict)));
433 }
434
435 if !lines.is_empty() {
438 blocks.push(lines.join("\n"));
439 }
440}
441
442fn list_item_text(text: &str, strict: bool) -> String {
450 let escaped = strict_text(text, strict);
451 if let Some((own, tail)) = escaped.split_once('\n') {
452 if is_folded_child_tail(tail) {
453 return format!("{}\n{tail}", md_line_breaks(own));
454 }
455 }
456 md_line_breaks(&escaped)
457}
458
459fn is_folded_child_tail(tail: &str) -> bool {
466 const MARKER: &str = "<!-- image -->";
467 const FENCE: &str = "```";
468 let mut lines = tail.split('\n').peekable();
469 let mut any = false;
470 while let Some(line) = lines.next() {
471 let line = line.trim_start();
472 if line == MARKER {
473 any = true;
474 } else if line == FENCE {
475 loop {
477 match lines.next() {
478 Some(l) if l.trim_start() == FENCE => break,
479 Some(_) => {}
480 None => return false,
481 }
482 }
483 any = true;
484 } else if lines.next().map(str::trim_start) == Some(MARKER) {
485 any = true; } else {
487 return false;
488 }
489 }
490 any
491}
492
493fn render_one(node: &Node, blocks: &mut Vec<String>, ctx: &mut Ctx) {
494 match node {
495 Node::Heading { level, text } => {
496 let text = heading_line_breaks(&strict_text(text, ctx.strict));
497 if ctx.in_table_cell {
498 blocks.push(text);
500 } else {
501 let hashes = "#".repeat((*level).clamp(1, 6) as usize);
502 blocks.push(format!("{hashes} {text}"));
503 }
504 }
505 Node::Paragraph { text } if text.is_empty() => {}
508 Node::Paragraph { text } => blocks.push(md_line_breaks(&strict_text(text, ctx.strict))),
509 Node::Caption { text, .. } if text.is_empty() => {}
512 Node::Caption { text, href } => {
513 let body = md_line_breaks(&strict_text(text, ctx.strict));
514 blocks.push(match href {
515 Some(url) => format!("[{body}]({url})"),
516 None => body,
517 });
518 }
519 Node::CheckboxItem { checked, text } => {
520 let mark = if *checked { "- [x] " } else { "- [ ] " };
521 blocks.push(md_line_breaks(&strict_text(
522 &format!("{mark}{text}"),
523 ctx.strict,
524 )));
525 }
526 Node::Code {
527 language,
528 text,
529 pretty,
530 ..
531 } => {
532 let lang = match language {
534 Some(l) if ctx.strict => l.as_str(),
535 _ => "",
536 };
537 let body = match pretty {
540 Some(p) if ctx.strict => p.as_str(),
541 _ => text.as_str(),
542 };
543 blocks.push(format!("```{lang}\n{body}\n```"));
544 }
545 Node::Formula { latex, .. } => blocks.push(format!("$${latex}$$")),
548 Node::Table(table) => {
549 if let Some(cap) = &table.caption {
552 if !cap.is_empty() {
553 blocks.push(md_line_breaks(&strict_text(cap, ctx.strict)));
554 }
555 }
556 let rendered = render_table(table, ctx.compact_tables);
557 if !rendered.is_empty() {
558 blocks.push(rendered);
559 }
560 }
561 Node::Picture { caption, image, .. } => {
563 if let Some(cap) = caption {
564 if !cap.is_empty() {
565 blocks.push(md_line_breaks(cap));
566 }
567 }
568 blocks.push(picture_marker(image.as_ref(), ctx));
569 }
570 Node::Chart {
574 kind,
575 table,
576 caption,
577 ..
578 } => {
579 if let Some(cap) = caption {
580 if !cap.is_empty() {
581 blocks.push(md_line_breaks(cap));
582 }
583 }
584 blocks.push(picture_marker(None, ctx));
585 blocks.push(humanize_label(kind));
586 let rendered = render_table(table, false);
587 if !rendered.is_empty() {
588 blocks.push(rendered);
589 }
590 }
591 Node::DoclangOnly(_) => {}
593 Node::Group { layer: Some(_), .. } => {}
596 Node::Group { children, .. } => render(children, blocks, ctx),
597 Node::FieldRegion { items } => {
598 for item in items {
603 for part in [&item.marker, &item.key, &item.value].into_iter().flatten() {
604 blocks.push(md_line_breaks(&strict_text(part, ctx.strict)));
605 }
606 }
607 }
608 Node::InlineGroup { md_text, .. } => {
611 blocks.push(md_line_breaks(&strict_text(md_text, ctx.strict)))
612 }
613 Node::TextDump(text) => {
615 if !text.is_empty() {
616 blocks.push(text.clone());
617 }
618 }
619 Node::Furniture { .. } => {}
622 Node::PageFurniture { .. } => {}
623 Node::CommentSection { .. } => {}
626 Node::Commented { inner, .. } => render_one(inner, blocks, ctx),
627 Node::Located { inner, .. } | Node::Prov { inner, .. } => render_one(inner, blocks, ctx),
629 Node::PageBreak => {}
631 Node::PageInfo { .. } => {}
633 Node::ListItem { .. } => render_list_run(std::slice::from_ref(node), blocks, ctx.strict),
638 }
639}
640
641fn humanize_label(label: &str) -> String {
646 let text = label.replace('_', " ");
647 let mut chars = text.chars();
648 match chars.next() {
649 Some(f) => f.to_uppercase().collect::<String>() + chars.as_str(),
650 None => text,
651 }
652}
653
654fn picture_marker(image: Option<&crate::PictureImage>, ctx: &mut Ctx) -> String {
655 match (ctx.images, image) {
656 (ImageMode::Embedded, Some(img)) => format!("", img.data_uri()),
657 (ImageMode::Referenced, Some(img)) => {
658 let path = format!(
659 "{}/image_{:06}.{}",
660 ctx.artifacts_dir,
661 ctx.pic_index,
662 ext_for(&img.mimetype)
663 );
664 ctx.pic_index += 1;
665 ctx.artifacts.push((path.clone(), img.data.clone()));
666 format!("", escape_uri_path(&path))
667 }
668 _ => "<!-- image -->".to_string(),
670 }
671}
672
673pub(crate) fn escape_uri_path(value: &str) -> String {
686 const KEEP: &str = "/%:@+,;=~$!&'*";
687 let s = value.replace('\\', "/");
688 if let Some(rest) = s.strip_prefix("//") {
689 let rest = rest.trim_start_matches('/');
691 let (host, tail) = rest.split_once('/').unwrap_or((rest, ""));
692 return format!("file://{host}{}", percent_quote(&format!("/{tail}"), KEEP));
693 }
694 let bytes = s.as_bytes();
695 if bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'/' {
696 return format!("file:///{}", percent_quote(&s, KEEP));
698 }
699 if let Some((scheme, rest)) = s.split_once(':') {
703 let valid_scheme = scheme.len() > 1
704 && scheme.as_bytes()[0].is_ascii_alphabetic()
705 && scheme
706 .bytes()
707 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'-' | b'.'));
708 if valid_scheme {
709 let (authority, rest) = match rest.strip_prefix("//") {
710 Some(r) => {
711 let end = r.find(['/', '?', '#']).unwrap_or(r.len());
712 (Some(&r[..end]), &r[end..])
713 }
714 None => (None, rest),
715 };
716 let (before_frag, fragment) = rest.split_once('#').unwrap_or((rest, ""));
717 let (path, query) = before_frag.split_once('?').unwrap_or((before_frag, ""));
718 let mut out = format!("{scheme}:");
719 if let Some(a) = authority {
720 out.push_str("//");
721 out.push_str(a);
722 }
723 out.push_str(&percent_quote(path, KEEP));
724 if !query.is_empty() {
725 out.push('?');
726 out.push_str(&percent_quote(query, KEEP));
727 }
728 if !fragment.is_empty() {
729 out.push('#');
730 out.push_str(&percent_quote(fragment, KEEP));
731 }
732 return out;
733 }
734 }
735 percent_quote(&s, KEEP)
737}
738
739fn percent_quote(s: &str, safe: &str) -> String {
742 let mut out = String::with_capacity(s.len());
743 for &b in s.as_bytes() {
744 let keep = b.is_ascii_alphanumeric()
745 || matches!(b, b'_' | b'.' | b'-' | b'~')
746 || (b.is_ascii() && safe.contains(b as char));
747 if keep {
748 out.push(b as char);
749 } else {
750 out.push_str(&format!("%{b:02X}"));
751 }
752 }
753 out
754}
755
756fn ext_for(mimetype: &str) -> &str {
757 match mimetype {
758 "image/jpeg" => "jpg",
759 "image/gif" => "gif",
760 "image/webp" => "webp",
761 "image/bmp" => "bmp",
762 "image/tiff" => "tif",
763 _ => "png",
764 }
765}
766
767fn is_number_cell(t: &str) -> bool {
786 t.parse::<f64>().is_ok() || is_thousands_number(t)
787}
788
789fn is_thousands_number(t: &str) -> bool {
795 let b = t.as_bytes();
796 let mut i = 0;
797 let start = i;
798 if i < b.len() && (b[i] == b'+' || b[i] == b'-') {
799 i += 1;
800 }
801 let d0 = i;
803 while i < b.len() && b[i].is_ascii_digit() && i - d0 < 3 {
804 i += 1;
805 }
806 let has_int = i > d0;
807 if has_int {
808 while i + 3 < b.len() + 1
810 && b.get(i) == Some(&b',')
811 && b.get(i + 1).is_some_and(u8::is_ascii_digit)
812 && b.get(i + 2).is_some_and(u8::is_ascii_digit)
813 && b.get(i + 3).is_some_and(u8::is_ascii_digit)
814 {
815 i += 4;
816 }
817 } else {
818 i = start;
820 }
821 if i < b.len() && b[i] == b'.' {
823 i += 1;
824 let f0 = i;
825 while i < b.len() && b[i].is_ascii_digit() {
826 i += 1;
827 }
828 if !has_int && i == f0 {
829 return false; }
831 } else if !has_int {
832 return false; }
834 i == b.len()
835}
836
837fn flatten_header_rows(header_rows: &[Vec<String>], num_cols: usize) -> Vec<String> {
846 (0..num_cols)
847 .map(|c| {
848 let mut parts: Vec<&str> = Vec::new();
849 for row in header_rows {
850 let text = row.get(c).map(String::as_str).unwrap_or("");
851 if !text.is_empty() && parts.last() != Some(&text) {
852 parts.push(text);
853 }
854 }
855 parts.join(" - ")
856 })
857 .collect()
858}
859
860pub(crate) fn render_table(table: &Table, compact: bool) -> String {
861 if table.rows.is_empty() {
862 return String::new();
863 }
864 let num_cols = table.rows.iter().map(Vec::len).max().unwrap_or(0);
865 if num_cols == 0 {
866 return String::new();
867 }
868
869 let num_headers = table.header_row_count().min(table.rows.len());
874 let escaped = |r: usize| -> Vec<String> {
875 (0..num_cols)
876 .map(|c| escape_cell(table.rows[r].get(c).map(String::as_str).unwrap_or("")))
877 .collect()
878 };
879 let header_rows: Vec<Vec<String>> = (0..num_headers).map(escaped).collect();
880 let header = flatten_header_rows(&header_rows, num_cols);
881 let body: Vec<Vec<String>> = (num_headers..table.rows.len())
882 .map(|r| {
883 escaped(r)
884 .into_iter()
885 .map(|c| c.trim().to_string())
886 .collect()
887 })
888 .collect();
889
890 if compact {
891 let render_row = |row: &[String]| -> String { format!("| {} |", row.join(" | ")) };
893 let mut lines = Vec::with_capacity(body.len() + 2);
894 lines.push(render_row(&header));
895 let sep: Vec<&str> = (0..num_cols).map(|_| "-").collect();
896 lines.push(format!("| {} |", sep.join(" | ")));
897 for row in &body {
898 lines.push(render_row(row));
899 }
900 return lines.join("\n");
901 }
902
903 let dw = |s: &str| s.chars().count();
905
906 let right: Vec<bool> = (0..num_cols)
911 .map(|c| {
912 let mut any = false;
913 for row in &body {
914 let t = row[c].trim();
915 if t.is_empty() {
916 continue;
917 }
918 if !is_number_cell(t) {
919 return false;
920 }
921 any = true;
922 }
923 any
924 })
925 .collect();
926
927 let width: Vec<usize> = (0..num_cols)
929 .map(|c| {
930 let mut w = dw(&header[c]) + 2;
931 for row in &body {
932 w = w.max(dw(&row[c]));
933 }
934 w
935 })
936 .collect();
937
938 let fmt_cell = |s: &str, c: usize| -> String {
939 let pad = " ".repeat(width[c].saturating_sub(dw(s)));
940 let body = if right[c] {
941 format!("{pad}{s}")
942 } else {
943 format!("{s}{pad}")
944 };
945 format!(" {body} ")
946 };
947 let render_row = |row: &[String]| -> String {
948 let cells: Vec<String> = (0..num_cols).map(|c| fmt_cell(&row[c], c)).collect();
949 format!("|{}|", cells.join("|"))
950 };
951
952 let mut lines = Vec::with_capacity(body.len() + 2);
953 lines.push(render_row(&header));
954 let sep: Vec<String> = (0..num_cols).map(|c| "-".repeat(width[c] + 2)).collect();
955 lines.push(format!("|{}|", sep.join("|")));
956 for row in &body {
957 lines.push(render_row(row));
958 }
959 lines.join("\n")
960}
961
962fn escape_cell(s: &str) -> String {
965 s.replace('\n', " ").replace('|', "|")
966}
967
968#[cfg(test)]
969mod tests {
970 use super::*;
971 use crate::{PictureImage, TableCell, TableStructure};
972
973 #[test]
979 fn list_boundaries_come_from_the_backend_not_the_numbering() {
980 let item = |ordered: bool, number: u64, first_in_list: bool, text: &str| Node::ListItem {
981 ordered,
982 number,
983 first_in_list,
984 text: text.into(),
985 level: 0,
986 marker: None,
987 location: None,
988 dclx: None,
989 href: None,
990 layer: None,
991 };
992 let md = |items: Vec<Node>| {
993 let mut doc = DoclingDocument::new("t");
994 for n in items {
995 doc.push(n);
996 }
997 doc.export_to_markdown()
998 };
999 assert_eq!(
1001 md(vec![
1002 item(true, 1, true, "one"),
1003 item(true, 5, false, "five")
1004 ]),
1005 "1. one\n5. five\n"
1006 );
1007 assert_eq!(
1009 md(vec![
1010 item(false, 0, true, "bullet"),
1011 item(true, 1, false, "one"),
1012 item(false, 0, false, "bullet two"),
1013 ]),
1014 "- bullet\n1. one\n- bullet two\n"
1015 );
1016 assert_eq!(
1018 md(vec![
1019 item(true, 1, true, "a"),
1020 item(true, 2, false, "b"),
1021 item(true, 3, true, "new list, continuing count"),
1022 ]),
1023 "1. a\n2. b\n\n3. new list, continuing count\n"
1024 );
1025 }
1026
1027 #[test]
1028 fn renders_headings_paragraphs_and_lists() {
1029 let mut doc = DoclingDocument::new("demo");
1030 doc.add_heading(1, "Title");
1031 doc.add_paragraph("Hello world.");
1032 doc.push(Node::ListItem {
1033 ordered: false,
1034 number: 1,
1035 first_in_list: true,
1036 text: "first".into(),
1037 level: 0,
1038 marker: None,
1039 location: None,
1040 dclx: None,
1041 href: None,
1042 layer: None,
1043 });
1044 doc.push(Node::ListItem {
1045 ordered: false,
1046 number: 2,
1047 first_in_list: false,
1048 text: "second".into(),
1049 level: 0,
1050 marker: None,
1051 location: None,
1052 dclx: None,
1053 href: None,
1054 layer: None,
1055 });
1056 let md = doc.export_to_markdown();
1057 assert_eq!(md, "# Title\n\nHello world.\n\n- first\n- second\n");
1058 }
1059
1060 #[test]
1064 fn single_newlines_become_gfm_hard_line_breaks() {
1065 let mut doc = DoclingDocument::new("t");
1066 doc.push(Node::Heading {
1067 level: 1,
1068 text: "Hello\nWorld".into(),
1069 });
1070 doc.push(Node::Paragraph {
1071 text: "line one\nline two\n\npara two".into(),
1072 });
1073 doc.push(Node::ListItem {
1074 ordered: false,
1075 number: 1,
1076 first_in_list: true,
1077 text: "item\ncontinued".into(),
1078 level: 0,
1079 marker: None,
1080 location: None,
1081 dclx: None,
1082 href: None,
1083 layer: None,
1084 });
1085 doc.push(Node::TextDump("A1 B1 \n\n\nC1".into()));
1086 assert_eq!(
1087 doc.export_to_markdown(),
1088 "# Hello World\n\nline one \nline two\n\npara two\n\n- item \ncontinued\n\nA1 B1 \n\n\nC1\n"
1089 );
1090 }
1091
1092 #[test]
1095 fn table_cell_mode_and_field_regions() {
1096 let mut doc = DoclingDocument::new("t");
1097 doc.push(Node::Heading {
1098 level: 2,
1099 text: "A text".into(),
1100 });
1101 doc.push(Node::Paragraph {
1102 text: "body".into(),
1103 });
1104 assert_eq!(to_markdown_table_cell(&doc, false), "A text\n\nbody");
1105 assert_eq!(doc.export_to_markdown(), "## A text\n\nbody\n");
1106
1107 let mut doc = DoclingDocument::new("f");
1108 doc.push(Node::FieldRegion {
1109 items: vec![crate::FieldItem {
1110 marker: None,
1111 key: Some("Name:".into()),
1112 value: Some("John Doe".into()),
1113 }],
1114 });
1115 assert_eq!(doc.export_to_markdown(), "Name:\n\nJohn Doe\n");
1116 }
1117
1118 #[test]
1119 fn strict_renders_recovered_links_legacy_does_not() {
1120 let mut doc = DoclingDocument::new("cv");
1121 doc.add_paragraph("Find me on LinkedIn or GitHub.");
1122 doc.links = vec![
1123 ("LinkedIn".into(), "https://www.linkedin.com/in/x/".into()),
1124 ("GitHub".into(), "https://github.com/x/".into()),
1125 ];
1126 assert_eq!(doc.export_to_markdown(), "Find me on LinkedIn or GitHub.\n");
1128 assert_eq!(
1130 doc.export_to_markdown_with(true),
1131 "Find me on [LinkedIn](https://www.linkedin.com/in/x/) or [GitHub](https://github.com/x/).\n"
1132 );
1133 }
1134
1135 #[test]
1136 fn strict_links_match_escaped_anchor_and_consume_in_order() {
1137 let mut doc = DoclingDocument::new("d");
1138 doc.add_paragraph("AI & ML here, and issues here, then issues there.");
1142 doc.links = vec![
1143 ("AI & ML".into(), "https://a/".into()),
1144 ("issues".into(), "https://first/".into()),
1145 ("issues".into(), "https://second/".into()),
1146 ];
1147 assert_eq!(
1148 doc.export_to_markdown_with(true),
1149 "[AI & ML](https://a/) here, and [issues](https://first/) here, then [issues](https://second/) there.\n"
1150 );
1151 }
1152
1153 #[test]
1157 fn referenced_image_destinations_are_escaped() {
1158 let cases = [
1159 (
1160 "doc_artifacts/image_000001_ab12.png",
1161 "doc_artifacts/image_000001_ab12.png",
1162 ),
1163 (
1164 "My Report_artifacts/img.png",
1165 "My%20Report_artifacts/img.png",
1166 ),
1167 ("artifacts/img (1).png", "artifacts/img%20%281%29.png"),
1168 ("100%_scale/a#b?c.png", "100%_scale/a%23b%3Fc.png"),
1169 ("/home/a b/img.png", "/home/a%20b/img.png"),
1170 (
1171 "My Report_artifacts\\img.png",
1172 "My%20Report_artifacts/img.png",
1173 ),
1174 (
1175 "C:/Users/me/My Docs/img.png",
1176 "file:///C:/Users/me/My%20Docs/img.png",
1177 ),
1178 ("C:\\Users\\me\\img.png", "file:///C:/Users/me/img.png"),
1179 (
1180 "//server/share/My Docs/img.png",
1181 "file://server/share/My%20Docs/img.png",
1182 ),
1183 ("\\\\server\\share\\img.png", "file://server/share/img.png"),
1184 ("file:///home/a b/img.png", "file:///home/a%20b/img.png"),
1185 (
1186 "s3://bucket/My Report_artifacts/img.png",
1187 "s3://bucket/My%20Report_artifacts/img.png",
1188 ),
1189 (
1190 "https://example.com:8080/a b.png?w=1&h=2#frag",
1191 "https://example.com:8080/a%20b.png?w=1&h=2#frag",
1192 ),
1193 (
1194 "https://example.com/img (1).png",
1195 "https://example.com/img%20%281%29.png",
1196 ),
1197 ("caf\u{e9}/im\u{e4}ge.png", "caf%C3%A9/im%C3%A4ge.png"),
1198 ];
1199 for (input, expected) in cases {
1200 assert_eq!(escape_uri_path(input), expected, "input {input:?}");
1201 assert_eq!(
1202 escape_uri_path(expected),
1203 expected,
1204 "idempotent {expected:?}"
1205 );
1206 }
1207 let mut doc = DoclingDocument::new("t");
1209 doc.push(Node::Picture {
1210 caption: None,
1211 caption_href: None,
1212 image: Some(PictureImage {
1213 mimetype: "image/png".into(),
1214 width: 1,
1215 height: 1,
1216 data: b"x".to_vec(),
1217 }),
1218 classification: None,
1219 caption_parent: Default::default(),
1220 });
1221 let (md, files) = doc
1222 .export_to_markdown_with_images(ImageMode::Referenced, "My Report (final)_artifacts");
1223 assert!(
1224 md.contains(""),
1225 "got:\n{md}"
1226 );
1227 assert_eq!(files[0].0, "My Report (final)_artifacts/image_000000.png");
1229 }
1230
1231 #[test]
1235 fn folded_list_item_pictures_keep_plain_newlines() {
1236 assert_eq!(
1237 list_item_text("Step\n<!-- image -->", false),
1238 "Step\n<!-- image -->"
1239 );
1240 assert_eq!(
1241 list_item_text("Step\nAlt text\n<!-- image -->\n<!-- image -->", false),
1242 "Step\nAlt text\n<!-- image -->\n<!-- image -->"
1243 );
1244 assert_eq!(
1245 list_item_text("line one\nline two", false),
1246 "line one \nline two"
1247 );
1248 }
1249
1250 #[test]
1253 fn stacked_header_rows_flatten_into_one() {
1254 let mut t = Table {
1255 rows: vec![
1256 vec!["".into(), "% of Total".into(), "% of Total".into()],
1257 vec!["class".into(), "Train".into(), "Test".into()],
1258 vec!["Caption".into(), "2.04".into(), "1.77".into()],
1259 ],
1260 ..Default::default()
1261 };
1262 t.structure = Some(TableStructure {
1263 header_row: vec![true, true, false],
1264 col_continuation: vec![
1265 vec![false, false, true],
1266 vec![false, false, false],
1267 vec![false, false, false],
1268 ],
1269 ..Default::default()
1270 });
1271 assert_eq!(t.header_row_count(), 2);
1272 assert_eq!(
1273 render_table(&t, true),
1274 "| class | % of Total - Train | % of Total - Test |\n| - | - | - |\n| Caption | 2.04 | 1.77 |"
1275 );
1276 assert_eq!(
1278 render_table(&t, false),
1279 "| class | % of Total - Train | % of Total - Test |\n\
1280 |---------|----------------------|---------------------|\n\
1281 | Caption | 2.04 | 1.77 |"
1282 );
1283 }
1284
1285 #[test]
1288 fn vertically_spanning_header_does_not_extend_the_block() {
1289 let mut t = Table {
1290 rows: vec![
1291 vec!["Name".into(), "Value".into()],
1292 vec!["Name".into(), "1".into()],
1293 vec!["x".into(), "2".into()],
1294 ],
1295 ..Default::default()
1296 };
1297 t.structure = Some(TableStructure {
1298 col_header: vec![vec![true, true], vec![true, false], vec![false, false]],
1299 row_continuation: vec![vec![false, false], vec![true, false], vec![false, false]],
1300 ..Default::default()
1301 });
1302 assert_eq!(t.header_row_count(), 1);
1303 assert_eq!(
1304 render_table(&t, true),
1305 "| Name | Value |\n| - | - |\n| Name | 1 |\n| x | 2 |"
1306 );
1307 }
1308
1309 #[test]
1312 fn header_flags_not_on_row_zero_keep_all_rows_in_the_body() {
1313 let mut t = Table {
1314 rows: vec![
1315 vec!["1".into(), "2".into()],
1316 vec!["a".into(), "b".into()],
1317 vec!["333".into(), "4".into()],
1318 ],
1319 ..Default::default()
1320 };
1321 t.structure = Some(TableStructure {
1322 header_row: vec![false, true, false],
1323 ..Default::default()
1324 });
1325 assert_eq!(t.header_row_count(), 0);
1326 assert_eq!(
1327 render_table(&t, false),
1328 "| | |\n|-----|----|\n| 1 | 2 |\n| a | b |\n| 333 | 4 |"
1329 );
1330 }
1331
1332 #[test]
1337 fn pivot_row_headers_do_not_extend_the_header() {
1338 let mut t = Table {
1339 rows: vec![
1340 vec!["Year".into(), "Month".into()],
1341 vec!["2025".into(), "January".into()],
1342 vec!["2025".into(), "February".into()],
1343 ],
1344 ..Default::default()
1345 };
1346 t.structure = Some(TableStructure {
1347 col_header: vec![vec![true, true], vec![false, false], vec![false, false]],
1348 row_header: vec![vec![false, false], vec![true, false], vec![true, false]],
1349 row_continuation: vec![vec![false, false], vec![false, false], vec![true, false]],
1350 ..Default::default()
1351 });
1352 assert_eq!(t.header_row_count(), 1);
1353 assert_eq!(
1354 render_table(&t, true),
1355 "| Year | Month |\n| - | - |\n| 2025 | January |\n| 2025 | February |"
1356 );
1357 }
1358
1359 #[test]
1362 fn unflagged_cells_keep_row_zero_as_header() {
1363 let mut t = Table {
1364 rows: vec![vec!["h".into()], vec!["d".into()]],
1365 ..Default::default()
1366 };
1367 t.cells = Some(
1368 [(0usize, "h"), (1, "d")]
1369 .into_iter()
1370 .map(|(r, text)| TableCell {
1371 text: text.into(),
1372 bbox: None,
1373 start_row: r,
1374 start_col: 0,
1375 row_span: 1,
1376 col_span: 1,
1377 column_header: false,
1378 row_header: false,
1379 row_section: false,
1380 })
1381 .collect(),
1382 );
1383 assert_eq!(t.header_row_count(), 1);
1384 assert_eq!(render_table(&t, true), "| h |\n| - |\n| d |");
1385 }
1386
1387 #[test]
1388 fn renders_compact_table() {
1389 let mut doc = DoclingDocument::new("t");
1390 doc.compact_tables = true;
1393 doc.push(Node::Table(Table {
1394 rows: vec![vec!["a".into(), "b".into()], vec!["1".into(), "2".into()]],
1395 location: None,
1396 structure: None,
1397 cell_blocks: None,
1398 cells: None,
1399 caption: None,
1400 caption_parent: Default::default(),
1401 }));
1402 let md = doc.export_to_markdown();
1403 assert_eq!(md, "| a | b |\n| - | - |\n| 1 | 2 |\n");
1404 }
1405
1406 #[test]
1407 fn renders_padded_github_table_by_default() {
1408 let mut doc = DoclingDocument::new("t");
1409 doc.push(Node::Table(Table {
1410 rows: vec![vec!["a".into(), "b".into()], vec!["1".into(), "2".into()]],
1411 location: None,
1412 structure: None,
1413 cell_blocks: None,
1414 cells: None,
1415 caption: None,
1416 caption_parent: Default::default(),
1417 }));
1418 let md = doc.export_to_markdown();
1419 assert_eq!(md, "| a | b |\n|-----|-----|\n| 1 | 2 |\n");
1421 }
1422
1423 #[test]
1424 fn strict_unescapes_inline_underscores_legacy_keeps_them() {
1425 let mut doc = DoclingDocument::new("t");
1426 doc.add_heading(1, "a\\_b");
1427 doc.add_paragraph("x\\_y");
1428 doc.push(Node::ListItem {
1429 ordered: false,
1430 number: 1,
1431 first_in_list: true,
1432 text: "i\\_j".into(),
1433 level: 0,
1434 marker: None,
1435 location: None,
1436 dclx: None,
1437 href: None,
1438 layer: None,
1439 });
1440 assert_eq!(doc.export_to_markdown(), "# a\\_b\n\nx\\_y\n\n- i\\_j\n");
1442 assert_eq!(doc.export_to_markdown_with(true), "# a_b\n\nx_y\n\n- i_j\n");
1444 }
1445
1446 fn assert_stream_matches(
1449 doc: &DoclingDocument,
1450 strict: bool,
1451 images: ImageMode,
1452 splits: &[usize],
1453 ) {
1454 let (want, want_artifacts) = to_markdown_images(doc, strict, images, "artifacts");
1455 let mut streamer =
1456 MarkdownStreamer::with_artifacts(strict, images, doc.compact_tables, "artifacts");
1457 let mut got = String::new();
1458 let mut got_artifacts = Vec::new();
1459 let mut start = 0;
1460 for &end in splits {
1461 let links = if start == 0 {
1464 doc.links.as_slice()
1465 } else {
1466 &[]
1467 };
1468 got.push_str(&streamer.push(&doc.nodes[start..end], links));
1469 got_artifacts.extend(streamer.take_artifacts());
1472 start = end;
1473 }
1474 got.push_str(&streamer.push(
1475 &doc.nodes[start..],
1476 if start == 0 {
1477 doc.links.as_slice()
1478 } else {
1479 &[]
1480 },
1481 ));
1482 got_artifacts.extend(streamer.take_artifacts());
1483 got.push_str(&streamer.finish());
1484 assert_eq!(
1485 got, want,
1486 "streamed output diverged (splits={splits:?}, strict={strict})"
1487 );
1488 assert_eq!(
1489 got_artifacts, want_artifacts,
1490 "streamed artifacts diverged (splits={splits:?}, strict={strict})"
1491 );
1492 }
1493
1494 #[test]
1495 fn streaming_is_byte_identical_to_buffered() {
1496 let mut doc = DoclingDocument::new("d");
1497 doc.add_heading(1, "Title");
1498 doc.add_paragraph("First paragraph.");
1499 doc.push(Node::ListItem {
1500 ordered: false,
1501 number: 1,
1502 first_in_list: true,
1503 text: "a".into(),
1504 level: 0,
1505 marker: None,
1506 location: None,
1507 dclx: None,
1508 href: None,
1509 layer: None,
1510 });
1511 doc.push(Node::ListItem {
1512 ordered: false,
1513 number: 2,
1514 first_in_list: false,
1515 text: "b".into(),
1516 level: 0,
1517 marker: None,
1518 location: None,
1519 dclx: None,
1520 href: None,
1521 layer: None,
1522 });
1523 doc.push(Node::Code {
1524 language: Some("rust".into()),
1525 text: "let x = 1;".into(),
1526 orig: None,
1527 pretty: None,
1528 });
1529 doc.push(Node::Table(Table {
1530 rows: vec![vec!["a".into(), "b".into()], vec!["1".into(), "2".into()]],
1531 location: None,
1532 structure: None,
1533 cell_blocks: None,
1534 cells: None,
1535 caption: None,
1536 caption_parent: Default::default(),
1537 }));
1538 doc.push(Node::Picture {
1539 caption: Some("Fig 1".into()),
1540 caption_href: None,
1541 image: Some(PictureImage {
1542 mimetype: "image/png".into(),
1543 width: 2,
1544 height: 2,
1545 data: b"png-one".to_vec(),
1546 }),
1547 classification: None,
1548 caption_parent: Default::default(),
1549 });
1550 doc.add_paragraph("Last paragraph.");
1551 doc.push(Node::Picture {
1554 caption: None,
1555 caption_href: None,
1556 image: Some(PictureImage {
1557 mimetype: "image/png".into(),
1558 width: 2,
1559 height: 2,
1560 data: b"png-two".to_vec(),
1561 }),
1562 classification: None,
1563 caption_parent: Default::default(),
1564 });
1565
1566 for &strict in &[false, true] {
1569 for &images in &[
1570 ImageMode::Placeholder,
1571 ImageMode::Embedded,
1572 ImageMode::Referenced,
1573 ] {
1574 for splits in [&[][..], &[1][..], &[2][..], &[4][..], &[1, 4, 6, 7][..]] {
1575 assert_stream_matches(&doc, strict, images, splits);
1576 }
1577 }
1578 }
1579 }
1580
1581 #[test]
1582 fn streaming_applies_recovered_links_in_strict_mode() {
1583 let mut doc = DoclingDocument::new("d");
1584 doc.add_paragraph("See LinkedIn for details.");
1585 doc.add_paragraph("And GitHub too.");
1586 doc.links = vec![
1587 ("LinkedIn".into(), "https://lnkd/".into()),
1588 ("GitHub".into(), "https://gh/".into()),
1589 ];
1590 assert_stream_matches(&doc, true, ImageMode::Placeholder, &[1]);
1593 }
1594
1595 #[test]
1596 fn strict_tightens_punctuation_spacing_legacy_keeps_it() {
1597 let mut doc = DoclingDocument::new("t");
1598 doc.add_paragraph("see [ 37 , 36 ] and ( x ) .");
1599 assert_eq!(doc.export_to_markdown(), "see [ 37 , 36 ] and ( x ) .\n");
1601 assert_eq!(doc.export_to_markdown_with(true), "see [37, 36] and (x).\n");
1603 }
1604}