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 }],
1186 });
1187 assert_eq!(doc.export_to_markdown(), "Name:\n\nJohn Doe\n");
1188 }
1189
1190 #[test]
1191 fn strict_renders_recovered_links_legacy_does_not() {
1192 let mut doc = DoclingDocument::new("cv");
1193 doc.add_paragraph("Find me on LinkedIn or GitHub.");
1194 doc.links = vec![
1195 ("LinkedIn".into(), "https://www.linkedin.com/in/x/".into()),
1196 ("GitHub".into(), "https://github.com/x/".into()),
1197 ];
1198 assert_eq!(doc.export_to_markdown(), "Find me on LinkedIn or GitHub.\n");
1200 assert_eq!(
1202 doc.export_to_markdown_with(true),
1203 "Find me on [LinkedIn](https://www.linkedin.com/in/x/) or [GitHub](https://github.com/x/).\n"
1204 );
1205 }
1206
1207 #[test]
1208 fn strict_links_match_escaped_anchor_and_consume_in_order() {
1209 let mut doc = DoclingDocument::new("d");
1210 doc.add_paragraph("AI & ML here, and issues here, then issues there.");
1214 doc.links = vec![
1215 ("AI & ML".into(), "https://a/".into()),
1216 ("issues".into(), "https://first/".into()),
1217 ("issues".into(), "https://second/".into()),
1218 ];
1219 assert_eq!(
1220 doc.export_to_markdown_with(true),
1221 "[AI & ML](https://a/) here, and [issues](https://first/) here, then [issues](https://second/) there.\n"
1222 );
1223 }
1224
1225 #[test]
1229 fn referenced_image_destinations_are_escaped() {
1230 let cases = [
1231 (
1232 "doc_artifacts/image_000001_ab12.png",
1233 "doc_artifacts/image_000001_ab12.png",
1234 ),
1235 (
1236 "My Report_artifacts/img.png",
1237 "My%20Report_artifacts/img.png",
1238 ),
1239 ("artifacts/img (1).png", "artifacts/img%20%281%29.png"),
1240 ("100%_scale/a#b?c.png", "100%_scale/a%23b%3Fc.png"),
1241 ("/home/a b/img.png", "/home/a%20b/img.png"),
1242 (
1243 "My Report_artifacts\\img.png",
1244 "My%20Report_artifacts/img.png",
1245 ),
1246 (
1247 "C:/Users/me/My Docs/img.png",
1248 "file:///C:/Users/me/My%20Docs/img.png",
1249 ),
1250 ("C:\\Users\\me\\img.png", "file:///C:/Users/me/img.png"),
1251 (
1252 "//server/share/My Docs/img.png",
1253 "file://server/share/My%20Docs/img.png",
1254 ),
1255 ("\\\\server\\share\\img.png", "file://server/share/img.png"),
1256 ("file:///home/a b/img.png", "file:///home/a%20b/img.png"),
1257 (
1258 "s3://bucket/My Report_artifacts/img.png",
1259 "s3://bucket/My%20Report_artifacts/img.png",
1260 ),
1261 (
1262 "https://example.com:8080/a b.png?w=1&h=2#frag",
1263 "https://example.com:8080/a%20b.png?w=1&h=2#frag",
1264 ),
1265 (
1266 "https://example.com/img (1).png",
1267 "https://example.com/img%20%281%29.png",
1268 ),
1269 ("caf\u{e9}/im\u{e4}ge.png", "caf%C3%A9/im%C3%A4ge.png"),
1270 ];
1271 for (input, expected) in cases {
1272 assert_eq!(escape_uri_path(input), expected, "input {input:?}");
1273 assert_eq!(
1274 escape_uri_path(expected),
1275 expected,
1276 "idempotent {expected:?}"
1277 );
1278 }
1279 let mut doc = DoclingDocument::new("t");
1281 doc.push(Node::Picture {
1282 caption: None,
1283 caption_href: None,
1284 image: Some(PictureImage {
1285 mimetype: "image/png".into(),
1286 width: 1,
1287 height: 1,
1288 data: b"x".to_vec(),
1289 }),
1290 classification: None,
1291 caption_parent: Default::default(),
1292 });
1293 let (md, files) = doc
1294 .export_to_markdown_with_images(ImageMode::Referenced, "My Report (final)_artifacts");
1295 assert!(
1296 md.contains(""),
1297 "got:\n{md}"
1298 );
1299 assert_eq!(files[0].0, "My Report (final)_artifacts/image_000000.png");
1301 }
1302
1303 #[test]
1307 fn folded_list_item_pictures_keep_plain_newlines() {
1308 assert_eq!(
1309 list_item_text("Step\n<!-- image -->", false),
1310 "Step\n<!-- image -->"
1311 );
1312 assert_eq!(
1313 list_item_text("Step\nAlt text\n<!-- image -->\n<!-- image -->", false),
1314 "Step\nAlt text\n<!-- image -->\n<!-- image -->"
1315 );
1316 assert_eq!(
1317 list_item_text("line one\nline two", false),
1318 "line one \nline two"
1319 );
1320 }
1321
1322 #[test]
1325 fn stacked_header_rows_flatten_into_one() {
1326 let mut t = Table {
1327 rows: vec![
1328 vec!["".into(), "% of Total".into(), "% of Total".into()],
1329 vec!["class".into(), "Train".into(), "Test".into()],
1330 vec!["Caption".into(), "2.04".into(), "1.77".into()],
1331 ],
1332 ..Default::default()
1333 };
1334 t.structure = Some(TableStructure {
1335 header_row: vec![true, true, false],
1336 col_continuation: vec![
1337 vec![false, false, true],
1338 vec![false, false, false],
1339 vec![false, false, false],
1340 ],
1341 ..Default::default()
1342 });
1343 assert_eq!(t.header_row_count(), 2);
1344 assert_eq!(
1345 render_table(&t, true),
1346 "| class | % of Total - Train | % of Total - Test |\n| - | - | - |\n| Caption | 2.04 | 1.77 |"
1347 );
1348 assert_eq!(
1350 render_table(&t, false),
1351 "| class | % of Total - Train | % of Total - Test |\n\
1352 |---------|----------------------|---------------------|\n\
1353 | Caption | 2.04 | 1.77 |"
1354 );
1355 }
1356
1357 #[test]
1360 fn vertically_spanning_header_does_not_extend_the_block() {
1361 let mut t = Table {
1362 rows: vec![
1363 vec!["Name".into(), "Value".into()],
1364 vec!["Name".into(), "1".into()],
1365 vec!["x".into(), "2".into()],
1366 ],
1367 ..Default::default()
1368 };
1369 t.structure = Some(TableStructure {
1370 col_header: vec![vec![true, true], vec![true, false], vec![false, false]],
1371 row_continuation: vec![vec![false, false], vec![true, false], vec![false, false]],
1372 ..Default::default()
1373 });
1374 assert_eq!(t.header_row_count(), 1);
1375 assert_eq!(
1376 render_table(&t, true),
1377 "| Name | Value |\n| - | - |\n| Name | 1 |\n| x | 2 |"
1378 );
1379 }
1380
1381 #[test]
1384 fn header_flags_not_on_row_zero_keep_all_rows_in_the_body() {
1385 let mut t = Table {
1386 rows: vec![
1387 vec!["1".into(), "2".into()],
1388 vec!["a".into(), "b".into()],
1389 vec!["333".into(), "4".into()],
1390 ],
1391 ..Default::default()
1392 };
1393 t.structure = Some(TableStructure {
1394 header_row: vec![false, true, false],
1395 ..Default::default()
1396 });
1397 assert_eq!(t.header_row_count(), 0);
1398 assert_eq!(
1399 render_table(&t, false),
1400 "| | |\n|-----|----|\n| 1 | 2 |\n| a | b |\n| 333 | 4 |"
1401 );
1402 }
1403
1404 #[test]
1409 fn pivot_row_headers_do_not_extend_the_header() {
1410 let mut t = Table {
1411 rows: vec![
1412 vec!["Year".into(), "Month".into()],
1413 vec!["2025".into(), "January".into()],
1414 vec!["2025".into(), "February".into()],
1415 ],
1416 ..Default::default()
1417 };
1418 t.structure = Some(TableStructure {
1419 col_header: vec![vec![true, true], vec![false, false], vec![false, false]],
1420 row_header: vec![vec![false, false], vec![true, false], vec![true, false]],
1421 row_continuation: vec![vec![false, false], vec![false, false], vec![true, false]],
1422 ..Default::default()
1423 });
1424 assert_eq!(t.header_row_count(), 1);
1425 assert_eq!(
1426 render_table(&t, true),
1427 "| Year | Month |\n| - | - |\n| 2025 | January |\n| 2025 | February |"
1428 );
1429 }
1430
1431 #[test]
1434 fn unflagged_cells_keep_row_zero_as_header() {
1435 let mut t = Table {
1436 rows: vec![vec!["h".into()], vec!["d".into()]],
1437 ..Default::default()
1438 };
1439 t.cells = Some(
1440 [(0usize, "h"), (1, "d")]
1441 .into_iter()
1442 .map(|(r, text)| TableCell {
1443 text: text.into(),
1444 bbox: None,
1445 start_row: r,
1446 start_col: 0,
1447 row_span: 1,
1448 col_span: 1,
1449 column_header: false,
1450 row_header: false,
1451 row_section: false,
1452 })
1453 .collect(),
1454 );
1455 assert_eq!(t.header_row_count(), 1);
1456 assert_eq!(render_table(&t, true), "| h |\n| - |\n| d |");
1457 }
1458
1459 #[test]
1460 fn renders_compact_table() {
1461 let mut doc = DoclingDocument::new("t");
1462 doc.compact_tables = true;
1465 doc.push(Node::Table(Table {
1466 rows: vec![vec!["a".into(), "b".into()], vec!["1".into(), "2".into()]],
1467 location: None,
1468 structure: None,
1469 cell_blocks: None,
1470 cells: None,
1471 caption: None,
1472 caption_parent: Default::default(),
1473 }));
1474 let md = doc.export_to_markdown();
1475 assert_eq!(md, "| a | b |\n| - | - |\n| 1 | 2 |\n");
1476 }
1477
1478 #[test]
1479 fn renders_padded_github_table_by_default() {
1480 let mut doc = DoclingDocument::new("t");
1481 doc.push(Node::Table(Table {
1482 rows: vec![vec!["a".into(), "b".into()], vec!["1".into(), "2".into()]],
1483 location: None,
1484 structure: None,
1485 cell_blocks: None,
1486 cells: None,
1487 caption: None,
1488 caption_parent: Default::default(),
1489 }));
1490 let md = doc.export_to_markdown();
1491 assert_eq!(md, "| a | b |\n|-----|-----|\n| 1 | 2 |\n");
1493 }
1494
1495 #[test]
1496 fn strict_unescapes_inline_underscores_legacy_keeps_them() {
1497 let mut doc = DoclingDocument::new("t");
1498 doc.add_heading(1, "a\\_b");
1499 doc.add_paragraph("x\\_y");
1500 doc.push(Node::ListItem {
1501 ordered: false,
1502 number: 1,
1503 first_in_list: true,
1504 text: "i\\_j".into(),
1505 level: 0,
1506 marker: None,
1507 location: None,
1508 dclx: None,
1509 href: None,
1510 layer: None,
1511 });
1512 assert_eq!(doc.export_to_markdown(), "# a\\_b\n\nx\\_y\n\n- i\\_j\n");
1514 assert_eq!(doc.export_to_markdown_with(true), "# a_b\n\nx_y\n\n- i_j\n");
1516 }
1517
1518 fn assert_stream_matches(
1521 doc: &DoclingDocument,
1522 strict: bool,
1523 images: ImageMode,
1524 splits: &[usize],
1525 ) {
1526 let (want, want_artifacts) = to_markdown_images(doc, strict, images, "artifacts");
1527 let mut streamer =
1528 MarkdownStreamer::with_artifacts(strict, images, doc.compact_tables, "artifacts")
1529 .with_page_break_placeholder(doc.page_break_placeholder.clone());
1530 let mut got = String::new();
1531 let mut got_artifacts = Vec::new();
1532 let mut start = 0;
1533 for &end in splits {
1534 let links = if start == 0 {
1537 doc.links.as_slice()
1538 } else {
1539 &[]
1540 };
1541 got.push_str(&streamer.push(&doc.nodes[start..end], links));
1542 got_artifacts.extend(streamer.take_artifacts());
1545 start = end;
1546 }
1547 got.push_str(&streamer.push(
1548 &doc.nodes[start..],
1549 if start == 0 {
1550 doc.links.as_slice()
1551 } else {
1552 &[]
1553 },
1554 ));
1555 got_artifacts.extend(streamer.take_artifacts());
1556 got.push_str(&streamer.finish());
1557 assert_eq!(
1558 got, want,
1559 "streamed output diverged (splits={splits:?}, strict={strict})"
1560 );
1561 assert_eq!(
1562 got_artifacts, want_artifacts,
1563 "streamed artifacts diverged (splits={splits:?}, strict={strict})"
1564 );
1565 }
1566
1567 #[test]
1568 fn streaming_is_byte_identical_to_buffered() {
1569 let mut doc = DoclingDocument::new("d");
1570 doc.add_heading(1, "Title");
1571 doc.add_paragraph("First paragraph.");
1572 doc.push(Node::ListItem {
1573 ordered: false,
1574 number: 1,
1575 first_in_list: true,
1576 text: "a".into(),
1577 level: 0,
1578 marker: None,
1579 location: None,
1580 dclx: None,
1581 href: None,
1582 layer: None,
1583 });
1584 doc.push(Node::ListItem {
1585 ordered: false,
1586 number: 2,
1587 first_in_list: false,
1588 text: "b".into(),
1589 level: 0,
1590 marker: None,
1591 location: None,
1592 dclx: None,
1593 href: None,
1594 layer: None,
1595 });
1596 doc.push(Node::Code {
1597 language: Some("rust".into()),
1598 text: "let x = 1;".into(),
1599 orig: None,
1600 pretty: None,
1601 });
1602 doc.push(Node::Table(Table {
1603 rows: vec![vec!["a".into(), "b".into()], vec!["1".into(), "2".into()]],
1604 location: None,
1605 structure: None,
1606 cell_blocks: None,
1607 cells: None,
1608 caption: None,
1609 caption_parent: Default::default(),
1610 }));
1611 doc.push(Node::Picture {
1612 caption: Some("Fig 1".into()),
1613 caption_href: None,
1614 image: Some(PictureImage {
1615 mimetype: "image/png".into(),
1616 width: 2,
1617 height: 2,
1618 data: b"png-one".to_vec(),
1619 }),
1620 classification: None,
1621 caption_parent: Default::default(),
1622 });
1623 doc.add_paragraph("Last paragraph.");
1624 doc.push(Node::Picture {
1627 caption: None,
1628 caption_href: None,
1629 image: Some(PictureImage {
1630 mimetype: "image/png".into(),
1631 width: 2,
1632 height: 2,
1633 data: b"png-two".to_vec(),
1634 }),
1635 classification: None,
1636 caption_parent: Default::default(),
1637 });
1638
1639 for &strict in &[false, true] {
1642 for &images in &[
1643 ImageMode::Placeholder,
1644 ImageMode::Embedded,
1645 ImageMode::Referenced,
1646 ] {
1647 for splits in [&[][..], &[1][..], &[2][..], &[4][..], &[1, 4, 6, 7][..]] {
1648 assert_stream_matches(&doc, strict, images, splits);
1649 }
1650 }
1651 }
1652 }
1653
1654 #[test]
1655 fn streaming_applies_recovered_links_in_strict_mode() {
1656 let mut doc = DoclingDocument::new("d");
1657 doc.add_paragraph("See LinkedIn for details.");
1658 doc.add_paragraph("And GitHub too.");
1659 doc.links = vec![
1660 ("LinkedIn".into(), "https://lnkd/".into()),
1661 ("GitHub".into(), "https://gh/".into()),
1662 ];
1663 assert_stream_matches(&doc, true, ImageMode::Placeholder, &[1]);
1666 }
1667
1668 fn paged_doc() -> DoclingDocument {
1671 let mut doc = DoclingDocument::new("p");
1672 doc.push(Node::PageInfo {
1673 page_no: 1,
1674 width: 100.0,
1675 height: 100.0,
1676 });
1677 doc.add_heading(1, "Title");
1678 doc.add_paragraph("Page one.");
1679 doc.push(Node::PageBreak);
1681 doc.push(Node::PageInfo {
1682 page_no: 2,
1683 width: 100.0,
1684 height: 100.0,
1685 });
1686 doc.push(Node::PageFurniture {
1687 footer: true,
1688 location: [0, 500, 511, 511],
1689 text: "2".into(),
1690 });
1691 doc.push(Node::PageBreak);
1692 doc.push(Node::PageInfo {
1693 page_no: 3,
1694 width: 100.0,
1695 height: 100.0,
1696 });
1697 doc.add_paragraph("Page three.");
1698 doc.push(Node::PageBreak);
1700 doc
1701 }
1702
1703 #[test]
1704 fn page_break_placeholder_lands_between_pages_only() {
1705 let mut doc = paged_doc();
1706 assert_eq!(
1708 doc.export_to_markdown(),
1709 "# Title\n\nPage one.\n\nPage three.\n"
1710 );
1711 doc.page_break_placeholder = Some("<!-- page break -->".into());
1712 assert_eq!(
1716 doc.export_to_markdown(),
1717 "# Title\n\nPage one.\n\n<!-- page break -->\n\nPage three.\n"
1718 );
1719 doc.page_break_placeholder = Some(String::new());
1722 assert_eq!(
1723 doc.export_to_markdown(),
1724 "# Title\n\nPage one.\n\n\n\nPage three.\n"
1725 );
1726 }
1727
1728 #[test]
1729 fn page_break_placeholder_never_leads_a_single_page() {
1730 let mut doc = DoclingDocument::new("one");
1731 doc.page_break_placeholder = Some("---".into());
1732 doc.push(Node::PageBreak);
1733 doc.push(Node::PageInfo {
1734 page_no: 1,
1735 width: 10.0,
1736 height: 10.0,
1737 });
1738 doc.add_paragraph("Only page.");
1739 assert_eq!(doc.export_to_markdown(), "Only page.\n");
1740 doc.push(Node::PageBreak);
1742 doc.push(Node::PageBreak);
1743 doc.add_paragraph("Next.");
1744 assert_eq!(doc.export_to_markdown(), "Only page.\n\n---\n\nNext.\n");
1745 }
1746
1747 #[test]
1748 fn page_break_placeholder_streams_byte_identical() {
1749 let mut doc = paged_doc();
1750 doc.page_break_placeholder = Some("<!-- page break -->".into());
1751 for splits in [
1755 &[3usize][..],
1756 &[3, 6],
1757 &[3, 6, 8],
1758 &[1, 2, 3, 4, 5, 6, 7, 8, 9],
1759 &[8],
1760 ] {
1761 assert_stream_matches(&doc, false, ImageMode::Placeholder, splits);
1762 assert_stream_matches(&doc, true, ImageMode::Placeholder, splits);
1763 }
1764 }
1765
1766 #[test]
1767 fn strict_tightens_punctuation_spacing_legacy_keeps_it() {
1768 let mut doc = DoclingDocument::new("t");
1769 doc.add_paragraph("see [ 37 , 36 ] and ( x ) .");
1770 assert_eq!(doc.export_to_markdown(), "see [ 37 , 36 ] and ( x ) .\n");
1772 assert_eq!(doc.export_to_markdown_with(true), "see [37, 36] and (x).\n");
1774 }
1775}