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 page_break: Option<String>,
36 pending_page_break: bool,
42 emitted_any: bool,
45}
46
47pub fn to_markdown(doc: &DoclingDocument, strict: bool) -> String {
53 to_markdown_images(doc, strict, ImageMode::Placeholder, "artifacts").0
54}
55
56pub fn to_markdown_images(
60 doc: &DoclingDocument,
61 strict: bool,
62 images: ImageMode,
63 artifacts_dir: &str,
64) -> (String, Vec<(String, Vec<u8>)>) {
65 let mut ctx = Ctx {
66 strict,
67 compact_tables: doc.compact_tables,
68 images,
69 artifacts_dir: artifacts_dir.to_string(),
70 artifacts: Vec::new(),
71 pic_index: 0,
72 in_table_cell: false,
73 page_break: doc.page_break_placeholder.clone(),
74 pending_page_break: false,
75 emitted_any: false,
76 };
77 let mut blocks: Vec<String> = Vec::new();
78 render(&doc.nodes, &mut blocks, &mut ctx);
79 let mut body = blocks.join("\n\n");
80 if strict && !doc.links.is_empty() {
84 body = apply_links(&body, &doc.links);
85 }
86 let md = if body.is_empty() {
87 String::new()
88 } else {
89 format!("{body}\n")
90 };
91 (md, ctx.artifacts)
92}
93
94pub fn to_markdown_table_cell(doc: &DoclingDocument, strict: bool) -> String {
103 let mut ctx = Ctx {
104 strict,
105 compact_tables: doc.compact_tables,
106 images: ImageMode::Placeholder,
107 artifacts_dir: String::new(),
108 artifacts: Vec::new(),
109 pic_index: 0,
110 in_table_cell: true,
111 page_break: None,
115 pending_page_break: false,
116 emitted_any: false,
117 };
118 let mut blocks: Vec<String> = Vec::new();
119 render(&doc.nodes, &mut blocks, &mut ctx);
120 blocks.join("\n\n")
121}
122
123fn apply_links(body: &str, links: &[(String, String)]) -> String {
131 let mut out = body.to_string();
132 let mut cursor = 0usize;
133 for (anchor, href) in links {
134 let anchor = anchor
135 .replace('&', "&")
136 .replace('<', "<")
137 .replace('>', ">");
138 if anchor.is_empty() {
139 continue;
140 }
141 if let Some(rel) = out[cursor..].find(&anchor) {
142 let at = cursor + rel;
143 let replacement = format!("[{anchor}]({href})");
145 out.replace_range(at..at + anchor.len(), &replacement);
146 cursor = at + replacement.len();
147 }
148 }
149 out
150}
151
152fn apply_links_chunk(chunk: &str, queue: &mut Vec<(String, String)>) -> String {
163 let mut out = chunk.to_string();
164 let mut cursor = 0usize;
165 let mut carried: Vec<(String, String)> = Vec::new();
166 for (anchor_raw, href) in std::mem::take(queue) {
167 let anchor = anchor_raw
168 .replace('&', "&")
169 .replace('<', "<")
170 .replace('>', ">");
171 if anchor.is_empty() {
172 continue;
173 }
174 if let Some(rel) = out[cursor..].find(&anchor) {
175 let at = cursor + rel;
176 let replacement = format!("[{anchor}]({href})");
177 out.replace_range(at..at + anchor.len(), &replacement);
178 cursor = at + replacement.len();
179 } else {
180 carried.push((anchor_raw, href));
182 }
183 }
184 *queue = carried;
185 out
186}
187
188pub struct MarkdownStreamer {
206 strict: bool,
207 images: ImageMode,
208 compact_tables: bool,
209 emitted_any: bool,
212 links: Vec<(String, String)>,
214 artifacts_dir: String,
218 artifacts: Vec<(String, Vec<u8>)>,
219 pic_index: usize,
220 page_break: Option<String>,
224 pending_page_break: bool,
225}
226
227impl MarkdownStreamer {
228 pub fn new(strict: bool, images: ImageMode, compact_tables: bool) -> Self {
231 debug_assert!(
232 images != ImageMode::Referenced,
233 "referenced image mode needs an artifacts dir; use with_artifacts"
234 );
235 Self::with_artifacts(strict, images, compact_tables, "artifacts")
236 }
237
238 pub fn with_artifacts(
245 strict: bool,
246 images: ImageMode,
247 compact_tables: bool,
248 artifacts_dir: &str,
249 ) -> Self {
250 Self {
251 strict,
252 images,
253 compact_tables,
254 emitted_any: false,
255 links: Vec::new(),
256 artifacts_dir: artifacts_dir.to_string(),
257 artifacts: Vec::new(),
258 pic_index: 0,
259 page_break: None,
260 pending_page_break: false,
261 }
262 }
263
264 pub fn with_page_break_placeholder(mut self, placeholder: Option<String>) -> Self {
269 self.page_break = placeholder;
270 self
271 }
272
273 pub fn take_artifacts(&mut self) -> Vec<(String, Vec<u8>)> {
278 std::mem::take(&mut self.artifacts)
279 }
280
281 pub fn push(&mut self, nodes: &[Node], links: &[(String, String)]) -> String {
286 self.links.extend(links.iter().cloned());
287 let mut ctx = Ctx {
288 strict: self.strict,
289 compact_tables: self.compact_tables,
290 images: self.images,
291 artifacts_dir: std::mem::take(&mut self.artifacts_dir),
292 artifacts: std::mem::take(&mut self.artifacts),
293 pic_index: self.pic_index,
294 in_table_cell: false,
295 page_break: std::mem::take(&mut self.page_break),
296 pending_page_break: self.pending_page_break,
297 emitted_any: self.emitted_any,
298 };
299 let mut blocks: Vec<String> = Vec::new();
300 render(nodes, &mut blocks, &mut ctx);
301 self.artifacts_dir = std::mem::take(&mut ctx.artifacts_dir);
302 self.artifacts = std::mem::take(&mut ctx.artifacts);
303 self.pic_index = ctx.pic_index;
304 self.page_break = std::mem::take(&mut ctx.page_break);
305 self.pending_page_break = ctx.pending_page_break;
306 if blocks.is_empty() {
307 return String::new();
308 }
309 let mut body = blocks.join("\n\n");
310 if self.strict && !self.links.is_empty() {
311 body = apply_links_chunk(&body, &mut self.links);
312 }
313 let chunk = if self.emitted_any {
314 format!("\n\n{body}")
315 } else {
316 body
317 };
318 self.emitted_any = true;
319 chunk
320 }
321
322 pub fn finish(self) -> String {
325 if self.emitted_any {
326 "\n".to_string()
327 } else {
328 String::new()
329 }
330 }
331}
332
333fn strict_text(text: &str, strict: bool) -> String {
341 if !strict {
342 return text.to_string();
343 }
344 text.replace("\\_", "_")
345 .replace(" ,", ",")
346 .replace(" .", ".")
347 .replace(" ;", ";")
348 .replace(" )", ")")
349 .replace("( ", "(")
350 .replace(" ]", "]")
351 .replace("[ ", "[")
352}
353
354fn md_line_breaks(text: &str) -> String {
360 if !text.contains('\n') {
361 return text.to_string();
362 }
363 text.split("\n\n")
364 .map(|para| para.replace('\n', " \n"))
365 .collect::<Vec<_>>()
366 .join("\n\n")
367}
368
369pub(crate) fn strip_hard_breaks(text: &str) -> String {
374 if text.contains(" \n") {
375 text.replace(" \n", "\n")
376 } else {
377 text.to_string()
378 }
379}
380
381fn heading_line_breaks(text: &str) -> String {
385 text.replace('\n', " ")
386}
387
388fn render(nodes: &[Node], blocks: &mut Vec<String>, ctx: &mut Ctx) {
389 let mut i = 0;
390 while i < nodes.len() {
391 let before = blocks.len();
392 match &nodes[i] {
393 Node::PageBreak | Node::PageInfo { .. } => {
403 if ctx.page_break.is_some() && ctx.emitted_any {
404 ctx.pending_page_break = true;
405 }
406 i += 1;
407 }
408 Node::ListItem { .. } => {
409 let start = i;
410 i += 1;
411 loop {
412 match nodes.get(i) {
413 Some(Node::ListItem { .. }) => i += 1,
414 Some(Node::Paragraph { text })
418 if text.is_empty()
419 && matches!(nodes.get(i + 1), Some(Node::ListItem { .. })) =>
420 {
421 i += 1
422 }
423 _ => break,
424 }
425 }
426 render_list_run(&nodes[start..i], blocks, ctx.strict);
427 }
428 other => {
429 render_one(other, blocks, ctx);
430 i += 1;
431 }
432 }
433 if blocks.len() > before {
434 if ctx.pending_page_break {
435 if let Some(placeholder) = &ctx.page_break {
440 blocks.insert(before, placeholder.clone());
441 }
442 ctx.pending_page_break = false;
443 }
444 ctx.emitted_any = true;
445 }
446 }
447}
448
449fn render_list_run(items: &[Node], blocks: &mut Vec<String>, strict: bool) {
455 let mut lines: Vec<String> = Vec::new();
456 let mut any_top = false;
459
460 for item in items {
461 let Node::ListItem {
462 ordered,
463 number,
464 first_in_list,
465 text,
466 level,
467 marker: _,
468 location: _,
469 dclx: _,
470 href: _,
471 layer,
472 } = item
473 else {
474 continue;
475 };
476 if layer.is_some() {
479 continue;
480 }
481 let level = *level as usize;
482
483 if level == 0 {
492 if any_top && *first_in_list {
493 lines.push(String::new());
494 }
495 any_top = true;
496 }
497
498 let indent = " ".repeat(level);
499 let marker = if *ordered {
500 format!("{number}.")
501 } else {
502 "-".to_string()
503 };
504 lines.push(format!("{indent}{marker} {}", list_item_text(text, strict)));
505 }
506
507 if !lines.is_empty() {
510 blocks.push(lines.join("\n"));
511 }
512}
513
514fn list_item_text(text: &str, strict: bool) -> String {
522 let escaped = strict_text(text, strict);
523 if let Some((own, tail)) = escaped.split_once('\n') {
524 if is_folded_child_tail(tail) {
525 return format!("{}\n{tail}", md_line_breaks(own));
526 }
527 }
528 md_line_breaks(&escaped)
529}
530
531fn is_folded_child_tail(tail: &str) -> bool {
538 const MARKER: &str = "<!-- image -->";
539 const FENCE: &str = "```";
540 let mut lines = tail.split('\n').peekable();
541 let mut any = false;
542 while let Some(line) = lines.next() {
543 let line = line.trim_start();
544 if line == MARKER {
545 any = true;
546 } else if line == FENCE {
547 loop {
549 match lines.next() {
550 Some(l) if l.trim_start() == FENCE => break,
551 Some(_) => {}
552 None => return false,
553 }
554 }
555 any = true;
556 } else if lines.next().map(str::trim_start) == Some(MARKER) {
557 any = true; } else {
559 return false;
560 }
561 }
562 any
563}
564
565fn render_one(node: &Node, blocks: &mut Vec<String>, ctx: &mut Ctx) {
566 match node {
567 Node::Heading { level, text } => {
568 let text = heading_line_breaks(&strict_text(text, ctx.strict));
569 if ctx.in_table_cell {
570 blocks.push(text);
572 } else {
573 let hashes = "#".repeat((*level).clamp(1, 6) as usize);
574 blocks.push(format!("{hashes} {text}"));
575 }
576 }
577 Node::Paragraph { text } if text.is_empty() => {}
580 Node::Paragraph { text } => blocks.push(md_line_breaks(&strict_text(text, ctx.strict))),
581 Node::Caption { text, .. } if text.is_empty() => {}
584 Node::Caption { text, href } => {
585 let body = md_line_breaks(&strict_text(text, ctx.strict));
586 blocks.push(match href {
587 Some(url) => format!("[{body}]({url})"),
588 None => body,
589 });
590 }
591 Node::CheckboxItem { checked, text } => {
592 let mark = if *checked { "- [x] " } else { "- [ ] " };
593 blocks.push(md_line_breaks(&strict_text(
594 &format!("{mark}{text}"),
595 ctx.strict,
596 )));
597 }
598 Node::Code {
599 language,
600 text,
601 pretty,
602 ..
603 } => {
604 let lang = match language {
606 Some(l) if ctx.strict => l.as_str(),
607 _ => "",
608 };
609 let body = match pretty {
612 Some(p) if ctx.strict => p.as_str(),
613 _ => text.as_str(),
614 };
615 blocks.push(format!("```{lang}\n{body}\n```"));
616 }
617 Node::Formula { latex, .. } => blocks.push(format!("$${latex}$$")),
620 Node::Table(table) => {
621 if let Some(cap) = &table.caption {
624 if !cap.is_empty() {
625 blocks.push(md_line_breaks(&strict_text(cap, ctx.strict)));
626 }
627 }
628 let rendered = render_table(table, ctx.compact_tables);
629 if !rendered.is_empty() {
630 blocks.push(rendered);
631 }
632 }
633 Node::Picture { caption, image, .. } => {
635 if let Some(cap) = caption {
636 if !cap.is_empty() {
637 blocks.push(md_line_breaks(cap));
638 }
639 }
640 blocks.push(picture_marker(image.as_ref(), ctx));
641 }
642 Node::Chart {
646 kind,
647 table,
648 caption,
649 ..
650 } => {
651 if let Some(cap) = caption {
652 if !cap.is_empty() {
653 blocks.push(md_line_breaks(cap));
654 }
655 }
656 blocks.push(picture_marker(None, ctx));
657 blocks.push(humanize_label(kind));
658 let rendered = render_table(table, false);
659 if !rendered.is_empty() {
660 blocks.push(rendered);
661 }
662 }
663 Node::DoclangOnly(_) => {}
665 Node::Group { layer: Some(_), .. } => {}
668 Node::Group { children, .. } => render(children, blocks, ctx),
669 Node::FieldRegion { items } => {
670 for item in items {
675 for part in [&item.marker, &item.key, &item.value].into_iter().flatten() {
676 blocks.push(md_line_breaks(&strict_text(part, ctx.strict)));
677 }
678 }
679 }
680 Node::InlineGroup { md_text, .. } => {
683 blocks.push(md_line_breaks(&strict_text(md_text, ctx.strict)))
684 }
685 Node::TextDump(text) => {
687 if !text.is_empty() {
688 blocks.push(text.clone());
689 }
690 }
691 Node::Furniture { .. } => {}
694 Node::PageFurniture { .. } => {}
695 Node::CommentSection { .. } => {}
698 Node::Commented { inner, .. } => render_one(inner, blocks, ctx),
699 Node::Located { inner, .. } | Node::Prov { inner, .. } => render_one(inner, blocks, ctx),
701 Node::PageBreak => {}
703 Node::PageInfo { .. } => {}
705 Node::ListItem { .. } => render_list_run(std::slice::from_ref(node), blocks, ctx.strict),
710 }
711}
712
713fn humanize_label(label: &str) -> String {
718 let text = label.replace('_', " ");
719 let mut chars = text.chars();
720 match chars.next() {
721 Some(f) => f.to_uppercase().collect::<String>() + chars.as_str(),
722 None => text,
723 }
724}
725
726fn picture_marker(image: Option<&crate::PictureImage>, ctx: &mut Ctx) -> String {
727 match (ctx.images, image) {
728 (ImageMode::Embedded, Some(img)) => format!("", img.data_uri()),
729 (ImageMode::Referenced, Some(img)) => {
730 let path = format!(
731 "{}/image_{:06}.{}",
732 ctx.artifacts_dir,
733 ctx.pic_index,
734 ext_for(&img.mimetype)
735 );
736 ctx.pic_index += 1;
737 ctx.artifacts.push((path.clone(), img.data.clone()));
738 format!("", escape_uri_path(&path))
739 }
740 _ => "<!-- image -->".to_string(),
742 }
743}
744
745pub(crate) fn escape_uri_path(value: &str) -> String {
758 const KEEP: &str = "/%:@+,;=~$!&'*";
759 let s = value.replace('\\', "/");
760 if let Some(rest) = s.strip_prefix("//") {
761 let rest = rest.trim_start_matches('/');
763 let (host, tail) = rest.split_once('/').unwrap_or((rest, ""));
764 return format!("file://{host}{}", percent_quote(&format!("/{tail}"), KEEP));
765 }
766 let bytes = s.as_bytes();
767 if bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'/' {
768 return format!("file:///{}", percent_quote(&s, KEEP));
770 }
771 if let Some((scheme, rest)) = s.split_once(':') {
775 let valid_scheme = scheme.len() > 1
776 && scheme.as_bytes()[0].is_ascii_alphabetic()
777 && scheme
778 .bytes()
779 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'-' | b'.'));
780 if valid_scheme {
781 let (authority, rest) = match rest.strip_prefix("//") {
782 Some(r) => {
783 let end = r.find(['/', '?', '#']).unwrap_or(r.len());
784 (Some(&r[..end]), &r[end..])
785 }
786 None => (None, rest),
787 };
788 let (before_frag, fragment) = rest.split_once('#').unwrap_or((rest, ""));
789 let (path, query) = before_frag.split_once('?').unwrap_or((before_frag, ""));
790 let mut out = format!("{scheme}:");
791 if let Some(a) = authority {
792 out.push_str("//");
793 out.push_str(a);
794 }
795 out.push_str(&percent_quote(path, KEEP));
796 if !query.is_empty() {
797 out.push('?');
798 out.push_str(&percent_quote(query, KEEP));
799 }
800 if !fragment.is_empty() {
801 out.push('#');
802 out.push_str(&percent_quote(fragment, KEEP));
803 }
804 return out;
805 }
806 }
807 percent_quote(&s, KEEP)
809}
810
811fn percent_quote(s: &str, safe: &str) -> String {
814 let mut out = String::with_capacity(s.len());
815 for &b in s.as_bytes() {
816 let keep = b.is_ascii_alphanumeric()
817 || matches!(b, b'_' | b'.' | b'-' | b'~')
818 || (b.is_ascii() && safe.contains(b as char));
819 if keep {
820 out.push(b as char);
821 } else {
822 out.push_str(&format!("%{b:02X}"));
823 }
824 }
825 out
826}
827
828fn ext_for(mimetype: &str) -> &str {
829 match mimetype {
830 "image/jpeg" => "jpg",
831 "image/gif" => "gif",
832 "image/webp" => "webp",
833 "image/bmp" => "bmp",
834 "image/tiff" => "tif",
835 _ => "png",
836 }
837}
838
839fn is_number_cell(t: &str) -> bool {
858 t.parse::<f64>().is_ok() || is_thousands_number(t)
859}
860
861fn is_thousands_number(t: &str) -> bool {
867 let b = t.as_bytes();
868 let mut i = 0;
869 let start = i;
870 if i < b.len() && (b[i] == b'+' || b[i] == b'-') {
871 i += 1;
872 }
873 let d0 = i;
875 while i < b.len() && b[i].is_ascii_digit() && i - d0 < 3 {
876 i += 1;
877 }
878 let has_int = i > d0;
879 if has_int {
880 while i + 3 < b.len() + 1
882 && b.get(i) == Some(&b',')
883 && b.get(i + 1).is_some_and(u8::is_ascii_digit)
884 && b.get(i + 2).is_some_and(u8::is_ascii_digit)
885 && b.get(i + 3).is_some_and(u8::is_ascii_digit)
886 {
887 i += 4;
888 }
889 } else {
890 i = start;
892 }
893 if i < b.len() && b[i] == b'.' {
895 i += 1;
896 let f0 = i;
897 while i < b.len() && b[i].is_ascii_digit() {
898 i += 1;
899 }
900 if !has_int && i == f0 {
901 return false; }
903 } else if !has_int {
904 return false; }
906 i == b.len()
907}
908
909fn flatten_header_rows(header_rows: &[Vec<String>], num_cols: usize) -> Vec<String> {
918 (0..num_cols)
919 .map(|c| {
920 let mut parts: Vec<&str> = Vec::new();
921 for row in header_rows {
922 let text = row.get(c).map(String::as_str).unwrap_or("");
923 if !text.is_empty() && parts.last() != Some(&text) {
924 parts.push(text);
925 }
926 }
927 parts.join(" - ")
928 })
929 .collect()
930}
931
932pub(crate) fn render_table(table: &Table, compact: bool) -> String {
933 if table.rows.is_empty() {
934 return String::new();
935 }
936 let num_cols = table.rows.iter().map(Vec::len).max().unwrap_or(0);
937 if num_cols == 0 {
938 return String::new();
939 }
940
941 let num_headers = table.header_row_count().min(table.rows.len());
946 let escaped = |r: usize| -> Vec<String> {
947 (0..num_cols)
948 .map(|c| escape_cell(table.rows[r].get(c).map(String::as_str).unwrap_or("")))
949 .collect()
950 };
951 let header_rows: Vec<Vec<String>> = (0..num_headers).map(escaped).collect();
952 let header = flatten_header_rows(&header_rows, num_cols);
953 let body: Vec<Vec<String>> = (num_headers..table.rows.len())
954 .map(|r| {
955 escaped(r)
956 .into_iter()
957 .map(|c| c.trim().to_string())
958 .collect()
959 })
960 .collect();
961
962 if compact {
963 let render_row = |row: &[String]| -> String { format!("| {} |", row.join(" | ")) };
965 let mut lines = Vec::with_capacity(body.len() + 2);
966 lines.push(render_row(&header));
967 let sep: Vec<&str> = (0..num_cols).map(|_| "-").collect();
968 lines.push(format!("| {} |", sep.join(" | ")));
969 for row in &body {
970 lines.push(render_row(row));
971 }
972 return lines.join("\n");
973 }
974
975 let dw = |s: &str| s.chars().count();
977
978 let right: Vec<bool> = (0..num_cols)
983 .map(|c| {
984 let mut any = false;
985 for row in &body {
986 let t = row[c].trim();
987 if t.is_empty() {
988 continue;
989 }
990 if !is_number_cell(t) {
991 return false;
992 }
993 any = true;
994 }
995 any
996 })
997 .collect();
998
999 let width: Vec<usize> = (0..num_cols)
1001 .map(|c| {
1002 let mut w = dw(&header[c]) + 2;
1003 for row in &body {
1004 w = w.max(dw(&row[c]));
1005 }
1006 w
1007 })
1008 .collect();
1009
1010 let fmt_cell = |s: &str, c: usize| -> String {
1011 let pad = " ".repeat(width[c].saturating_sub(dw(s)));
1012 let body = if right[c] {
1013 format!("{pad}{s}")
1014 } else {
1015 format!("{s}{pad}")
1016 };
1017 format!(" {body} ")
1018 };
1019 let render_row = |row: &[String]| -> String {
1020 let cells: Vec<String> = (0..num_cols).map(|c| fmt_cell(&row[c], c)).collect();
1021 format!("|{}|", cells.join("|"))
1022 };
1023
1024 let mut lines = Vec::with_capacity(body.len() + 2);
1025 lines.push(render_row(&header));
1026 let sep: Vec<String> = (0..num_cols).map(|c| "-".repeat(width[c] + 2)).collect();
1027 lines.push(format!("|{}|", sep.join("|")));
1028 for row in &body {
1029 lines.push(render_row(row));
1030 }
1031 lines.join("\n")
1032}
1033
1034fn escape_cell(s: &str) -> String {
1037 s.replace('\n', " ").replace('|', "|")
1038}
1039
1040#[cfg(test)]
1041mod tests {
1042 use super::*;
1043 use crate::{PictureImage, TableCell, TableStructure};
1044
1045 #[test]
1051 fn list_boundaries_come_from_the_backend_not_the_numbering() {
1052 let item = |ordered: bool, number: u64, first_in_list: bool, text: &str| Node::ListItem {
1053 ordered,
1054 number,
1055 first_in_list,
1056 text: text.into(),
1057 level: 0,
1058 marker: None,
1059 location: None,
1060 dclx: None,
1061 href: None,
1062 layer: None,
1063 };
1064 let md = |items: Vec<Node>| {
1065 let mut doc = DoclingDocument::new("t");
1066 for n in items {
1067 doc.push(n);
1068 }
1069 doc.export_to_markdown()
1070 };
1071 assert_eq!(
1073 md(vec![
1074 item(true, 1, true, "one"),
1075 item(true, 5, false, "five")
1076 ]),
1077 "1. one\n5. five\n"
1078 );
1079 assert_eq!(
1081 md(vec![
1082 item(false, 0, true, "bullet"),
1083 item(true, 1, false, "one"),
1084 item(false, 0, false, "bullet two"),
1085 ]),
1086 "- bullet\n1. one\n- bullet two\n"
1087 );
1088 assert_eq!(
1090 md(vec![
1091 item(true, 1, true, "a"),
1092 item(true, 2, false, "b"),
1093 item(true, 3, true, "new list, continuing count"),
1094 ]),
1095 "1. a\n2. b\n\n3. new list, continuing count\n"
1096 );
1097 }
1098
1099 #[test]
1100 fn renders_headings_paragraphs_and_lists() {
1101 let mut doc = DoclingDocument::new("demo");
1102 doc.add_heading(1, "Title");
1103 doc.add_paragraph("Hello world.");
1104 doc.push(Node::ListItem {
1105 ordered: false,
1106 number: 1,
1107 first_in_list: true,
1108 text: "first".into(),
1109 level: 0,
1110 marker: None,
1111 location: None,
1112 dclx: None,
1113 href: None,
1114 layer: None,
1115 });
1116 doc.push(Node::ListItem {
1117 ordered: false,
1118 number: 2,
1119 first_in_list: false,
1120 text: "second".into(),
1121 level: 0,
1122 marker: None,
1123 location: None,
1124 dclx: None,
1125 href: None,
1126 layer: None,
1127 });
1128 let md = doc.export_to_markdown();
1129 assert_eq!(md, "# Title\n\nHello world.\n\n- first\n- second\n");
1130 }
1131
1132 #[test]
1136 fn single_newlines_become_gfm_hard_line_breaks() {
1137 let mut doc = DoclingDocument::new("t");
1138 doc.push(Node::Heading {
1139 level: 1,
1140 text: "Hello\nWorld".into(),
1141 });
1142 doc.push(Node::Paragraph {
1143 text: "line one\nline two\n\npara two".into(),
1144 });
1145 doc.push(Node::ListItem {
1146 ordered: false,
1147 number: 1,
1148 first_in_list: true,
1149 text: "item\ncontinued".into(),
1150 level: 0,
1151 marker: None,
1152 location: None,
1153 dclx: None,
1154 href: None,
1155 layer: None,
1156 });
1157 doc.push(Node::TextDump("A1 B1 \n\n\nC1".into()));
1158 assert_eq!(
1159 doc.export_to_markdown(),
1160 "# Hello World\n\nline one \nline two\n\npara two\n\n- item \ncontinued\n\nA1 B1 \n\n\nC1\n"
1161 );
1162 }
1163
1164 #[test]
1167 fn table_cell_mode_and_field_regions() {
1168 let mut doc = DoclingDocument::new("t");
1169 doc.push(Node::Heading {
1170 level: 2,
1171 text: "A text".into(),
1172 });
1173 doc.push(Node::Paragraph {
1174 text: "body".into(),
1175 });
1176 assert_eq!(to_markdown_table_cell(&doc, false), "A text\n\nbody");
1177 assert_eq!(doc.export_to_markdown(), "## A text\n\nbody\n");
1178
1179 let mut doc = DoclingDocument::new("f");
1180 doc.push(Node::FieldRegion {
1181 items: vec![crate::FieldItem {
1182 marker: None,
1183 key: Some("Name:".into()),
1184 value: Some("John Doe".into()),
1185 value_kind: None,
1186 }],
1187 });
1188 assert_eq!(doc.export_to_markdown(), "Name:\n\nJohn Doe\n");
1189 }
1190
1191 #[test]
1192 fn strict_renders_recovered_links_legacy_does_not() {
1193 let mut doc = DoclingDocument::new("cv");
1194 doc.add_paragraph("Find me on LinkedIn or GitHub.");
1195 doc.links = vec![
1196 ("LinkedIn".into(), "https://www.linkedin.com/in/x/".into()),
1197 ("GitHub".into(), "https://github.com/x/".into()),
1198 ];
1199 assert_eq!(doc.export_to_markdown(), "Find me on LinkedIn or GitHub.\n");
1201 assert_eq!(
1203 doc.export_to_markdown_with(true),
1204 "Find me on [LinkedIn](https://www.linkedin.com/in/x/) or [GitHub](https://github.com/x/).\n"
1205 );
1206 }
1207
1208 #[test]
1209 fn strict_links_match_escaped_anchor_and_consume_in_order() {
1210 let mut doc = DoclingDocument::new("d");
1211 doc.add_paragraph("AI & ML here, and issues here, then issues there.");
1215 doc.links = vec![
1216 ("AI & ML".into(), "https://a/".into()),
1217 ("issues".into(), "https://first/".into()),
1218 ("issues".into(), "https://second/".into()),
1219 ];
1220 assert_eq!(
1221 doc.export_to_markdown_with(true),
1222 "[AI & ML](https://a/) here, and [issues](https://first/) here, then [issues](https://second/) there.\n"
1223 );
1224 }
1225
1226 #[test]
1230 fn referenced_image_destinations_are_escaped() {
1231 let cases = [
1232 (
1233 "doc_artifacts/image_000001_ab12.png",
1234 "doc_artifacts/image_000001_ab12.png",
1235 ),
1236 (
1237 "My Report_artifacts/img.png",
1238 "My%20Report_artifacts/img.png",
1239 ),
1240 ("artifacts/img (1).png", "artifacts/img%20%281%29.png"),
1241 ("100%_scale/a#b?c.png", "100%_scale/a%23b%3Fc.png"),
1242 ("/home/a b/img.png", "/home/a%20b/img.png"),
1243 (
1244 "My Report_artifacts\\img.png",
1245 "My%20Report_artifacts/img.png",
1246 ),
1247 (
1248 "C:/Users/me/My Docs/img.png",
1249 "file:///C:/Users/me/My%20Docs/img.png",
1250 ),
1251 ("C:\\Users\\me\\img.png", "file:///C:/Users/me/img.png"),
1252 (
1253 "//server/share/My Docs/img.png",
1254 "file://server/share/My%20Docs/img.png",
1255 ),
1256 ("\\\\server\\share\\img.png", "file://server/share/img.png"),
1257 ("file:///home/a b/img.png", "file:///home/a%20b/img.png"),
1258 (
1259 "s3://bucket/My Report_artifacts/img.png",
1260 "s3://bucket/My%20Report_artifacts/img.png",
1261 ),
1262 (
1263 "https://example.com:8080/a b.png?w=1&h=2#frag",
1264 "https://example.com:8080/a%20b.png?w=1&h=2#frag",
1265 ),
1266 (
1267 "https://example.com/img (1).png",
1268 "https://example.com/img%20%281%29.png",
1269 ),
1270 ("caf\u{e9}/im\u{e4}ge.png", "caf%C3%A9/im%C3%A4ge.png"),
1271 ];
1272 for (input, expected) in cases {
1273 assert_eq!(escape_uri_path(input), expected, "input {input:?}");
1274 assert_eq!(
1275 escape_uri_path(expected),
1276 expected,
1277 "idempotent {expected:?}"
1278 );
1279 }
1280 let mut doc = DoclingDocument::new("t");
1282 doc.push(Node::Picture {
1283 caption: None,
1284 caption_href: None,
1285 image: Some(PictureImage {
1286 mimetype: "image/png".into(),
1287 width: 1,
1288 height: 1,
1289 data: b"x".to_vec(),
1290 }),
1291 classification: None,
1292 caption_parent: Default::default(),
1293 });
1294 let (md, files) = doc
1295 .export_to_markdown_with_images(ImageMode::Referenced, "My Report (final)_artifacts");
1296 assert!(
1297 md.contains(""),
1298 "got:\n{md}"
1299 );
1300 assert_eq!(files[0].0, "My Report (final)_artifacts/image_000000.png");
1302 }
1303
1304 #[test]
1308 fn folded_list_item_pictures_keep_plain_newlines() {
1309 assert_eq!(
1310 list_item_text("Step\n<!-- image -->", false),
1311 "Step\n<!-- image -->"
1312 );
1313 assert_eq!(
1314 list_item_text("Step\nAlt text\n<!-- image -->\n<!-- image -->", false),
1315 "Step\nAlt text\n<!-- image -->\n<!-- image -->"
1316 );
1317 assert_eq!(
1318 list_item_text("line one\nline two", false),
1319 "line one \nline two"
1320 );
1321 }
1322
1323 #[test]
1326 fn stacked_header_rows_flatten_into_one() {
1327 let mut t = Table {
1328 rows: vec![
1329 vec!["".into(), "% of Total".into(), "% of Total".into()],
1330 vec!["class".into(), "Train".into(), "Test".into()],
1331 vec!["Caption".into(), "2.04".into(), "1.77".into()],
1332 ],
1333 ..Default::default()
1334 };
1335 t.structure = Some(TableStructure {
1336 header_row: vec![true, true, false],
1337 col_continuation: vec![
1338 vec![false, false, true],
1339 vec![false, false, false],
1340 vec![false, false, false],
1341 ],
1342 ..Default::default()
1343 });
1344 assert_eq!(t.header_row_count(), 2);
1345 assert_eq!(
1346 render_table(&t, true),
1347 "| class | % of Total - Train | % of Total - Test |\n| - | - | - |\n| Caption | 2.04 | 1.77 |"
1348 );
1349 assert_eq!(
1351 render_table(&t, false),
1352 "| class | % of Total - Train | % of Total - Test |\n\
1353 |---------|----------------------|---------------------|\n\
1354 | Caption | 2.04 | 1.77 |"
1355 );
1356 }
1357
1358 #[test]
1361 fn vertically_spanning_header_does_not_extend_the_block() {
1362 let mut t = Table {
1363 rows: vec![
1364 vec!["Name".into(), "Value".into()],
1365 vec!["Name".into(), "1".into()],
1366 vec!["x".into(), "2".into()],
1367 ],
1368 ..Default::default()
1369 };
1370 t.structure = Some(TableStructure {
1371 col_header: vec![vec![true, true], vec![true, false], vec![false, false]],
1372 row_continuation: vec![vec![false, false], vec![true, false], vec![false, false]],
1373 ..Default::default()
1374 });
1375 assert_eq!(t.header_row_count(), 1);
1376 assert_eq!(
1377 render_table(&t, true),
1378 "| Name | Value |\n| - | - |\n| Name | 1 |\n| x | 2 |"
1379 );
1380 }
1381
1382 #[test]
1385 fn header_flags_not_on_row_zero_keep_all_rows_in_the_body() {
1386 let mut t = Table {
1387 rows: vec![
1388 vec!["1".into(), "2".into()],
1389 vec!["a".into(), "b".into()],
1390 vec!["333".into(), "4".into()],
1391 ],
1392 ..Default::default()
1393 };
1394 t.structure = Some(TableStructure {
1395 header_row: vec![false, true, false],
1396 ..Default::default()
1397 });
1398 assert_eq!(t.header_row_count(), 0);
1399 assert_eq!(
1400 render_table(&t, false),
1401 "| | |\n|-----|----|\n| 1 | 2 |\n| a | b |\n| 333 | 4 |"
1402 );
1403 }
1404
1405 #[test]
1410 fn pivot_row_headers_do_not_extend_the_header() {
1411 let mut t = Table {
1412 rows: vec![
1413 vec!["Year".into(), "Month".into()],
1414 vec!["2025".into(), "January".into()],
1415 vec!["2025".into(), "February".into()],
1416 ],
1417 ..Default::default()
1418 };
1419 t.structure = Some(TableStructure {
1420 col_header: vec![vec![true, true], vec![false, false], vec![false, false]],
1421 row_header: vec![vec![false, false], vec![true, false], vec![true, false]],
1422 row_continuation: vec![vec![false, false], vec![false, false], vec![true, false]],
1423 ..Default::default()
1424 });
1425 assert_eq!(t.header_row_count(), 1);
1426 assert_eq!(
1427 render_table(&t, true),
1428 "| Year | Month |\n| - | - |\n| 2025 | January |\n| 2025 | February |"
1429 );
1430 }
1431
1432 #[test]
1435 fn unflagged_cells_keep_row_zero_as_header() {
1436 let mut t = Table {
1437 rows: vec![vec!["h".into()], vec!["d".into()]],
1438 ..Default::default()
1439 };
1440 t.cells = Some(
1441 [(0usize, "h"), (1, "d")]
1442 .into_iter()
1443 .map(|(r, text)| TableCell {
1444 text: text.into(),
1445 bbox: None,
1446 start_row: r,
1447 start_col: 0,
1448 row_span: 1,
1449 col_span: 1,
1450 column_header: false,
1451 row_header: false,
1452 row_section: false,
1453 })
1454 .collect(),
1455 );
1456 assert_eq!(t.header_row_count(), 1);
1457 assert_eq!(render_table(&t, true), "| h |\n| - |\n| d |");
1458 }
1459
1460 #[test]
1461 fn renders_compact_table() {
1462 let mut doc = DoclingDocument::new("t");
1463 doc.compact_tables = true;
1466 doc.push(Node::Table(Table {
1467 rows: vec![vec!["a".into(), "b".into()], vec!["1".into(), "2".into()]],
1468 location: None,
1469 structure: None,
1470 cell_blocks: None,
1471 cells: None,
1472 caption: None,
1473 caption_parent: Default::default(),
1474 }));
1475 let md = doc.export_to_markdown();
1476 assert_eq!(md, "| a | b |\n| - | - |\n| 1 | 2 |\n");
1477 }
1478
1479 #[test]
1480 fn renders_padded_github_table_by_default() {
1481 let mut doc = DoclingDocument::new("t");
1482 doc.push(Node::Table(Table {
1483 rows: vec![vec!["a".into(), "b".into()], vec!["1".into(), "2".into()]],
1484 location: None,
1485 structure: None,
1486 cell_blocks: None,
1487 cells: None,
1488 caption: None,
1489 caption_parent: Default::default(),
1490 }));
1491 let md = doc.export_to_markdown();
1492 assert_eq!(md, "| a | b |\n|-----|-----|\n| 1 | 2 |\n");
1494 }
1495
1496 #[test]
1497 fn strict_unescapes_inline_underscores_legacy_keeps_them() {
1498 let mut doc = DoclingDocument::new("t");
1499 doc.add_heading(1, "a\\_b");
1500 doc.add_paragraph("x\\_y");
1501 doc.push(Node::ListItem {
1502 ordered: false,
1503 number: 1,
1504 first_in_list: true,
1505 text: "i\\_j".into(),
1506 level: 0,
1507 marker: None,
1508 location: None,
1509 dclx: None,
1510 href: None,
1511 layer: None,
1512 });
1513 assert_eq!(doc.export_to_markdown(), "# a\\_b\n\nx\\_y\n\n- i\\_j\n");
1515 assert_eq!(doc.export_to_markdown_with(true), "# a_b\n\nx_y\n\n- i_j\n");
1517 }
1518
1519 fn assert_stream_matches(
1522 doc: &DoclingDocument,
1523 strict: bool,
1524 images: ImageMode,
1525 splits: &[usize],
1526 ) {
1527 let (want, want_artifacts) = to_markdown_images(doc, strict, images, "artifacts");
1528 let mut streamer =
1529 MarkdownStreamer::with_artifacts(strict, images, doc.compact_tables, "artifacts")
1530 .with_page_break_placeholder(doc.page_break_placeholder.clone());
1531 let mut got = String::new();
1532 let mut got_artifacts = Vec::new();
1533 let mut start = 0;
1534 for &end in splits {
1535 let links = if start == 0 {
1538 doc.links.as_slice()
1539 } else {
1540 &[]
1541 };
1542 got.push_str(&streamer.push(&doc.nodes[start..end], links));
1543 got_artifacts.extend(streamer.take_artifacts());
1546 start = end;
1547 }
1548 got.push_str(&streamer.push(
1549 &doc.nodes[start..],
1550 if start == 0 {
1551 doc.links.as_slice()
1552 } else {
1553 &[]
1554 },
1555 ));
1556 got_artifacts.extend(streamer.take_artifacts());
1557 got.push_str(&streamer.finish());
1558 assert_eq!(
1559 got, want,
1560 "streamed output diverged (splits={splits:?}, strict={strict})"
1561 );
1562 assert_eq!(
1563 got_artifacts, want_artifacts,
1564 "streamed artifacts diverged (splits={splits:?}, strict={strict})"
1565 );
1566 }
1567
1568 #[test]
1569 fn streaming_is_byte_identical_to_buffered() {
1570 let mut doc = DoclingDocument::new("d");
1571 doc.add_heading(1, "Title");
1572 doc.add_paragraph("First paragraph.");
1573 doc.push(Node::ListItem {
1574 ordered: false,
1575 number: 1,
1576 first_in_list: true,
1577 text: "a".into(),
1578 level: 0,
1579 marker: None,
1580 location: None,
1581 dclx: None,
1582 href: None,
1583 layer: None,
1584 });
1585 doc.push(Node::ListItem {
1586 ordered: false,
1587 number: 2,
1588 first_in_list: false,
1589 text: "b".into(),
1590 level: 0,
1591 marker: None,
1592 location: None,
1593 dclx: None,
1594 href: None,
1595 layer: None,
1596 });
1597 doc.push(Node::Code {
1598 language: Some("rust".into()),
1599 text: "let x = 1;".into(),
1600 orig: None,
1601 pretty: None,
1602 });
1603 doc.push(Node::Table(Table {
1604 rows: vec![vec!["a".into(), "b".into()], vec!["1".into(), "2".into()]],
1605 location: None,
1606 structure: None,
1607 cell_blocks: None,
1608 cells: None,
1609 caption: None,
1610 caption_parent: Default::default(),
1611 }));
1612 doc.push(Node::Picture {
1613 caption: Some("Fig 1".into()),
1614 caption_href: None,
1615 image: Some(PictureImage {
1616 mimetype: "image/png".into(),
1617 width: 2,
1618 height: 2,
1619 data: b"png-one".to_vec(),
1620 }),
1621 classification: None,
1622 caption_parent: Default::default(),
1623 });
1624 doc.add_paragraph("Last paragraph.");
1625 doc.push(Node::Picture {
1628 caption: None,
1629 caption_href: None,
1630 image: Some(PictureImage {
1631 mimetype: "image/png".into(),
1632 width: 2,
1633 height: 2,
1634 data: b"png-two".to_vec(),
1635 }),
1636 classification: None,
1637 caption_parent: Default::default(),
1638 });
1639
1640 for &strict in &[false, true] {
1643 for &images in &[
1644 ImageMode::Placeholder,
1645 ImageMode::Embedded,
1646 ImageMode::Referenced,
1647 ] {
1648 for splits in [&[][..], &[1][..], &[2][..], &[4][..], &[1, 4, 6, 7][..]] {
1649 assert_stream_matches(&doc, strict, images, splits);
1650 }
1651 }
1652 }
1653 }
1654
1655 #[test]
1656 fn streaming_applies_recovered_links_in_strict_mode() {
1657 let mut doc = DoclingDocument::new("d");
1658 doc.add_paragraph("See LinkedIn for details.");
1659 doc.add_paragraph("And GitHub too.");
1660 doc.links = vec![
1661 ("LinkedIn".into(), "https://lnkd/".into()),
1662 ("GitHub".into(), "https://gh/".into()),
1663 ];
1664 assert_stream_matches(&doc, true, ImageMode::Placeholder, &[1]);
1667 }
1668
1669 fn paged_doc() -> DoclingDocument {
1672 let mut doc = DoclingDocument::new("p");
1673 doc.push(Node::PageInfo {
1674 page_no: 1,
1675 width: 100.0,
1676 height: 100.0,
1677 });
1678 doc.add_heading(1, "Title");
1679 doc.add_paragraph("Page one.");
1680 doc.push(Node::PageBreak);
1682 doc.push(Node::PageInfo {
1683 page_no: 2,
1684 width: 100.0,
1685 height: 100.0,
1686 });
1687 doc.push(Node::PageFurniture {
1688 footer: true,
1689 location: [0, 500, 511, 511],
1690 text: "2".into(),
1691 });
1692 doc.push(Node::PageBreak);
1693 doc.push(Node::PageInfo {
1694 page_no: 3,
1695 width: 100.0,
1696 height: 100.0,
1697 });
1698 doc.add_paragraph("Page three.");
1699 doc.push(Node::PageBreak);
1701 doc
1702 }
1703
1704 #[test]
1705 fn page_break_placeholder_lands_between_pages_only() {
1706 let mut doc = paged_doc();
1707 assert_eq!(
1709 doc.export_to_markdown(),
1710 "# Title\n\nPage one.\n\nPage three.\n"
1711 );
1712 doc.page_break_placeholder = Some("<!-- page break -->".into());
1713 assert_eq!(
1717 doc.export_to_markdown(),
1718 "# Title\n\nPage one.\n\n<!-- page break -->\n\nPage three.\n"
1719 );
1720 doc.page_break_placeholder = Some(String::new());
1723 assert_eq!(
1724 doc.export_to_markdown(),
1725 "# Title\n\nPage one.\n\n\n\nPage three.\n"
1726 );
1727 }
1728
1729 #[test]
1730 fn page_break_placeholder_never_leads_a_single_page() {
1731 let mut doc = DoclingDocument::new("one");
1732 doc.page_break_placeholder = Some("---".into());
1733 doc.push(Node::PageBreak);
1734 doc.push(Node::PageInfo {
1735 page_no: 1,
1736 width: 10.0,
1737 height: 10.0,
1738 });
1739 doc.add_paragraph("Only page.");
1740 assert_eq!(doc.export_to_markdown(), "Only page.\n");
1741 doc.push(Node::PageBreak);
1743 doc.push(Node::PageBreak);
1744 doc.add_paragraph("Next.");
1745 assert_eq!(doc.export_to_markdown(), "Only page.\n\n---\n\nNext.\n");
1746 }
1747
1748 #[test]
1749 fn page_break_placeholder_streams_byte_identical() {
1750 let mut doc = paged_doc();
1751 doc.page_break_placeholder = Some("<!-- page break -->".into());
1752 for splits in [
1756 &[3usize][..],
1757 &[3, 6],
1758 &[3, 6, 8],
1759 &[1, 2, 3, 4, 5, 6, 7, 8, 9],
1760 &[8],
1761 ] {
1762 assert_stream_matches(&doc, false, ImageMode::Placeholder, splits);
1763 assert_stream_matches(&doc, true, ImageMode::Placeholder, splits);
1764 }
1765 }
1766
1767 #[test]
1768 fn strict_tightens_punctuation_spacing_legacy_keeps_it() {
1769 let mut doc = DoclingDocument::new("t");
1770 doc.add_paragraph("see [ 37 , 36 ] and ( x ) .");
1771 assert_eq!(doc.export_to_markdown(), "see [ 37 , 36 ] and ( x ) .\n");
1773 assert_eq!(doc.export_to_markdown_with(true), "see [37, 36] and (x).\n");
1775 }
1776}