1use crate::document::{DoclingDocument, Node, Table};
4
5const MISSING_KEY_VALUE_ITEM: &str = "<!-- missing-key-value-item -->";
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
12pub enum ImageMode {
13 #[default]
15 Placeholder,
16 Embedded,
18 Referenced,
21}
22
23struct Ctx {
25 strict: bool,
26 compact_tables: bool,
28 images: ImageMode,
29 artifacts_dir: String,
30 artifacts: Vec<(String, Vec<u8>)>,
32 pic_index: usize,
33 in_table_cell: bool,
37 page_break: Option<String>,
41 pending_page_break: bool,
47 emitted_any: bool,
50}
51
52pub fn to_markdown(doc: &DoclingDocument, strict: bool) -> String {
58 to_markdown_images(doc, strict, ImageMode::Placeholder, "artifacts").0
59}
60
61pub fn to_markdown_images(
65 doc: &DoclingDocument,
66 strict: bool,
67 images: ImageMode,
68 artifacts_dir: &str,
69) -> (String, Vec<(String, Vec<u8>)>) {
70 let mut ctx = Ctx {
71 strict,
72 compact_tables: doc.compact_tables,
73 images,
74 artifacts_dir: artifacts_dir.to_string(),
75 artifacts: Vec::new(),
76 pic_index: 0,
77 in_table_cell: false,
78 page_break: doc.page_break_placeholder.clone(),
79 pending_page_break: false,
80 emitted_any: false,
81 };
82 let mut blocks: Vec<String> = Vec::new();
83 render(&doc.nodes, &mut blocks, &mut ctx);
84 let mut body = blocks.join("\n\n");
85 if strict && !doc.links.is_empty() {
89 body = apply_links(&body, &doc.links);
90 }
91 let md = if body.is_empty() {
92 String::new()
93 } else {
94 format!("{body}\n")
95 };
96 (md, ctx.artifacts)
97}
98
99pub fn to_markdown_table_cell(doc: &DoclingDocument, strict: bool) -> String {
108 let mut ctx = Ctx {
109 strict,
110 compact_tables: doc.compact_tables,
111 images: ImageMode::Placeholder,
112 artifacts_dir: String::new(),
113 artifacts: Vec::new(),
114 pic_index: 0,
115 in_table_cell: true,
116 page_break: None,
120 pending_page_break: false,
121 emitted_any: false,
122 };
123 let mut blocks: Vec<String> = Vec::new();
124 render(&doc.nodes, &mut blocks, &mut ctx);
125 blocks.join("\n\n")
126}
127
128fn apply_links(body: &str, links: &[(String, String)]) -> String {
136 let mut out = body.to_string();
137 let mut cursor = 0usize;
138 for (anchor, href) in links {
139 let anchor = anchor
140 .replace('&', "&")
141 .replace('<', "<")
142 .replace('>', ">");
143 if anchor.is_empty() {
144 continue;
145 }
146 if let Some(rel) = out[cursor..].find(&anchor) {
147 let at = cursor + rel;
148 let replacement = format!("[{anchor}]({href})");
150 out.replace_range(at..at + anchor.len(), &replacement);
151 cursor = at + replacement.len();
152 }
153 }
154 out
155}
156
157fn apply_links_chunk(chunk: &str, queue: &mut Vec<(String, String)>) -> String {
168 let mut out = chunk.to_string();
169 let mut cursor = 0usize;
170 let mut carried: Vec<(String, String)> = Vec::new();
171 for (anchor_raw, href) in std::mem::take(queue) {
172 let anchor = anchor_raw
173 .replace('&', "&")
174 .replace('<', "<")
175 .replace('>', ">");
176 if anchor.is_empty() {
177 continue;
178 }
179 if let Some(rel) = out[cursor..].find(&anchor) {
180 let at = cursor + rel;
181 let replacement = format!("[{anchor}]({href})");
182 out.replace_range(at..at + anchor.len(), &replacement);
183 cursor = at + replacement.len();
184 } else {
185 carried.push((anchor_raw, href));
187 }
188 }
189 *queue = carried;
190 out
191}
192
193pub struct MarkdownStreamer {
211 strict: bool,
212 images: ImageMode,
213 compact_tables: bool,
214 emitted_any: bool,
217 links: Vec<(String, String)>,
219 artifacts_dir: String,
223 artifacts: Vec<(String, Vec<u8>)>,
224 pic_index: usize,
225 page_break: Option<String>,
229 pending_page_break: bool,
230}
231
232impl MarkdownStreamer {
233 pub fn new(strict: bool, images: ImageMode, compact_tables: bool) -> Self {
236 debug_assert!(
237 images != ImageMode::Referenced,
238 "referenced image mode needs an artifacts dir; use with_artifacts"
239 );
240 Self::with_artifacts(strict, images, compact_tables, "artifacts")
241 }
242
243 pub fn with_artifacts(
250 strict: bool,
251 images: ImageMode,
252 compact_tables: bool,
253 artifacts_dir: &str,
254 ) -> Self {
255 Self {
256 strict,
257 images,
258 compact_tables,
259 emitted_any: false,
260 links: Vec::new(),
261 artifacts_dir: artifacts_dir.to_string(),
262 artifacts: Vec::new(),
263 pic_index: 0,
264 page_break: None,
265 pending_page_break: false,
266 }
267 }
268
269 pub fn with_page_break_placeholder(mut self, placeholder: Option<String>) -> Self {
274 self.page_break = placeholder;
275 self
276 }
277
278 pub fn take_artifacts(&mut self) -> Vec<(String, Vec<u8>)> {
283 std::mem::take(&mut self.artifacts)
284 }
285
286 pub fn push(&mut self, nodes: &[Node], links: &[(String, String)]) -> String {
291 self.links.extend(links.iter().cloned());
292 let mut ctx = Ctx {
293 strict: self.strict,
294 compact_tables: self.compact_tables,
295 images: self.images,
296 artifacts_dir: std::mem::take(&mut self.artifacts_dir),
297 artifacts: std::mem::take(&mut self.artifacts),
298 pic_index: self.pic_index,
299 in_table_cell: false,
300 page_break: std::mem::take(&mut self.page_break),
301 pending_page_break: self.pending_page_break,
302 emitted_any: self.emitted_any,
303 };
304 let mut blocks: Vec<String> = Vec::new();
305 render(nodes, &mut blocks, &mut ctx);
306 self.artifacts_dir = std::mem::take(&mut ctx.artifacts_dir);
307 self.artifacts = std::mem::take(&mut ctx.artifacts);
308 self.pic_index = ctx.pic_index;
309 self.page_break = std::mem::take(&mut ctx.page_break);
310 self.pending_page_break = ctx.pending_page_break;
311 if blocks.is_empty() {
312 return String::new();
313 }
314 let mut body = blocks.join("\n\n");
315 if self.strict && !self.links.is_empty() {
316 body = apply_links_chunk(&body, &mut self.links);
317 }
318 let chunk = if self.emitted_any {
319 format!("\n\n{body}")
320 } else {
321 body
322 };
323 self.emitted_any = true;
324 chunk
325 }
326
327 pub fn finish(self) -> String {
330 if self.emitted_any {
331 "\n".to_string()
332 } else {
333 String::new()
334 }
335 }
336}
337
338fn strict_text(text: &str, strict: bool) -> String {
346 if !strict {
347 return text.to_string();
348 }
349 text.replace("\\_", "_")
350 .replace(" ,", ",")
351 .replace(" .", ".")
352 .replace(" ;", ";")
353 .replace(" )", ")")
354 .replace("( ", "(")
355 .replace(" ]", "]")
356 .replace("[ ", "[")
357}
358
359fn md_line_breaks(text: &str) -> String {
365 if !text.contains('\n') {
366 return text.to_string();
367 }
368 text.split("\n\n")
369 .map(|para| para.replace('\n', " \n"))
370 .collect::<Vec<_>>()
371 .join("\n\n")
372}
373
374pub(crate) fn strip_hard_breaks(text: &str) -> String {
379 if text.contains(" \n") {
380 text.replace(" \n", "\n")
381 } else {
382 text.to_string()
383 }
384}
385
386fn heading_line_breaks(text: &str) -> String {
390 text.replace('\n', " ")
391}
392
393fn render(nodes: &[Node], blocks: &mut Vec<String>, ctx: &mut Ctx) {
394 let mut i = 0;
395 while i < nodes.len() {
396 let before = blocks.len();
397 match &nodes[i] {
398 Node::PageBreak | Node::PageInfo { .. } => {
408 if ctx.page_break.is_some() && ctx.emitted_any {
409 ctx.pending_page_break = true;
410 }
411 i += 1;
412 }
413 Node::ListItem { .. } => {
414 let start = i;
415 i += 1;
416 loop {
417 match nodes.get(i) {
418 Some(Node::ListItem { .. }) => i += 1,
419 Some(Node::Paragraph { text })
423 if text.is_empty()
424 && matches!(nodes.get(i + 1), Some(Node::ListItem { .. })) =>
425 {
426 i += 1
427 }
428 _ => break,
429 }
430 }
431 render_list_run(&nodes[start..i], blocks, ctx.strict);
432 }
433 other => {
434 render_one(other, blocks, ctx);
435 i += 1;
436 }
437 }
438 if blocks.len() > before {
439 if ctx.pending_page_break {
440 if let Some(placeholder) = &ctx.page_break {
445 blocks.insert(before, placeholder.clone());
446 }
447 ctx.pending_page_break = false;
448 }
449 ctx.emitted_any = true;
450 }
451 }
452}
453
454fn render_list_run(items: &[Node], blocks: &mut Vec<String>, strict: bool) {
460 let mut lines: Vec<String> = Vec::new();
461 let mut any_top = false;
464
465 for item in items {
466 let Node::ListItem {
467 ordered,
468 number,
469 first_in_list,
470 text,
471 level,
472 marker: orig_marker,
473 location: _,
474 dclx: _,
475 href: _,
476 layer,
477 } = item
478 else {
479 continue;
480 };
481 if layer.is_some() {
484 continue;
485 }
486 let level = *level as usize;
487
488 if level == 0 {
497 if any_top && *first_in_list {
498 lines.push(String::new());
499 }
500 any_top = true;
501 }
502
503 let indent = " ".repeat(level);
504 let verbatim = orig_marker.as_deref().filter(|m| {
509 m.strip_suffix('.')
510 .is_some_and(|d| !d.is_empty() && d.chars().all(char::is_numeric))
511 });
512 let marker = match verbatim {
513 Some(m) if *ordered => m.to_string(),
514 _ if *ordered => format!("{number}."),
515 _ => "-".to_string(),
516 };
517 lines.push(format!("{indent}{marker} {}", list_item_text(text, strict)));
518 }
519
520 if !lines.is_empty() {
523 blocks.push(lines.join("\n"));
524 }
525}
526
527fn list_item_text(text: &str, strict: bool) -> String {
535 let escaped = strict_text(text, strict);
536 if let Some((own, tail)) = escaped.split_once('\n') {
537 if is_folded_child_tail(tail) {
538 return format!("{}\n{tail}", md_line_breaks(own));
539 }
540 }
541 md_line_breaks(&escaped)
542}
543
544fn is_folded_child_tail(tail: &str) -> bool {
551 const MARKER: &str = "<!-- image -->";
552 const FENCE: &str = "```";
553 let mut lines = tail.split('\n').peekable();
554 let mut any = false;
555 while let Some(line) = lines.next() {
556 let line = line.trim_start();
557 if line == MARKER {
558 any = true;
559 } else if line == FENCE {
560 loop {
562 match lines.next() {
563 Some(l) if l.trim_start() == FENCE => break,
564 Some(_) => {}
565 None => return false,
566 }
567 }
568 any = true;
569 } else if lines.next().map(str::trim_start) == Some(MARKER) {
570 any = true; } else {
572 return false;
573 }
574 }
575 any
576}
577
578fn render_one(node: &Node, blocks: &mut Vec<String>, ctx: &mut Ctx) {
579 match node {
580 Node::Heading { level, text } => {
581 let text = heading_line_breaks(&strict_text(text, ctx.strict));
582 if ctx.in_table_cell {
583 blocks.push(text);
585 } else {
586 let hashes = "#".repeat((*level).clamp(1, 6) as usize);
587 blocks.push(format!("{hashes} {text}"));
588 }
589 }
590 Node::Paragraph { text } if text.is_empty() => {}
593 Node::Paragraph { text } => blocks.push(md_line_breaks(&strict_text(text, ctx.strict))),
594 Node::Caption { text, .. } if text.is_empty() => {}
597 Node::Caption { text, href } => {
598 let body = md_line_breaks(&strict_text(text, ctx.strict));
599 blocks.push(match href {
600 Some(url) => format!("[{body}]({url})"),
601 None => body,
602 });
603 }
604 Node::CheckboxItem { checked, text } => {
605 let mark = if *checked { "- [x] " } else { "- [ ] " };
606 blocks.push(md_line_breaks(&strict_text(
607 &format!("{mark}{text}"),
608 ctx.strict,
609 )));
610 }
611 Node::Code {
612 language,
613 text,
614 pretty,
615 ..
616 } => {
617 let lang = match language {
619 Some(l) if ctx.strict => l.as_str(),
620 _ => "",
621 };
622 let body = match pretty {
625 Some(p) if ctx.strict => p.as_str(),
626 _ => text.as_str(),
627 };
628 blocks.push(format!("```{lang}\n{body}\n```"));
629 }
630 Node::Formula { latex, .. } => blocks.push(format!("$${latex}$$")),
633 Node::Table(table) => {
634 if let Some(cap) = &table.caption {
637 if !cap.is_empty() {
638 blocks.push(md_line_breaks(&strict_text(cap, ctx.strict)));
639 }
640 }
641 let rendered = render_table(table, ctx.compact_tables);
642 if !rendered.is_empty() {
643 blocks.push(rendered);
644 }
645 }
646 Node::Picture { caption, image, .. } => {
648 if let Some(cap) = caption {
649 if !cap.is_empty() {
650 blocks.push(md_line_breaks(cap));
651 }
652 }
653 blocks.push(picture_marker(image.as_ref(), ctx));
654 }
655 Node::Chart {
659 kind,
660 table,
661 caption,
662 ..
663 } => {
664 if let Some(cap) = caption {
665 if !cap.is_empty() {
666 blocks.push(md_line_breaks(cap));
667 }
668 }
669 blocks.push(picture_marker(None, ctx));
670 blocks.push(humanize_label(kind));
671 let rendered = render_table(table, false);
672 if !rendered.is_empty() {
673 blocks.push(rendered);
674 }
675 }
676 Node::DoclangOnly(_) => {}
678 Node::Group { layer: Some(_), .. } => {}
681 Node::Group { children, .. } => render(children, blocks, ctx),
682 Node::FieldRegion { items } => {
683 for item in items {
688 for part in [&item.marker, &item.key, &item.value].into_iter().flatten() {
689 blocks.push(md_line_breaks(&strict_text(part, ctx.strict)));
690 }
691 }
692 }
693 Node::KeyValueGraph { .. } => blocks.push(MISSING_KEY_VALUE_ITEM.to_string()),
696 Node::InlineGroup { md_text, .. } => {
699 blocks.push(md_line_breaks(&strict_text(md_text, ctx.strict)))
700 }
701 Node::TextDump(text) => {
703 if !text.is_empty() {
704 blocks.push(text.clone());
705 }
706 }
707 Node::Furniture { .. } => {}
710 Node::PageFurniture { .. } | Node::FurnitureText { .. } => {}
711 Node::PictureChildren(_) => {}
714 Node::CommentSection { .. } => {}
717 Node::Commented { inner, .. } => render_one(inner, blocks, ctx),
718 Node::Located { inner, .. } | Node::Prov { inner, .. } => render_one(inner, blocks, ctx),
720 Node::PageBreak => {}
722 Node::PageInfo { .. } => {}
724 Node::ListItem { .. } => render_list_run(std::slice::from_ref(node), blocks, ctx.strict),
729 }
730}
731
732fn humanize_label(label: &str) -> String {
737 let text = label.replace('_', " ");
738 let mut chars = text.chars();
739 match chars.next() {
740 Some(f) => f.to_uppercase().collect::<String>() + chars.as_str(),
741 None => text,
742 }
743}
744
745fn picture_marker(image: Option<&crate::PictureImage>, ctx: &mut Ctx) -> String {
746 match (ctx.images, image) {
747 (ImageMode::Embedded, Some(img)) => {
749 format!("", crate::pixel_digest::docling_data_uri(img).1)
750 }
751 (ImageMode::Referenced, Some(img)) => {
752 let path = format!(
753 "{}/image_{:06}.{}",
754 ctx.artifacts_dir,
755 ctx.pic_index,
756 ext_for(&img.mimetype)
757 );
758 ctx.pic_index += 1;
759 ctx.artifacts.push((path.clone(), img.data.clone()));
760 format!("", escape_uri_path(&path))
761 }
762 _ => "<!-- image -->".to_string(),
764 }
765}
766
767pub(crate) fn escape_uri_path(value: &str) -> String {
780 const KEEP: &str = "/%:@+,;=~$!&'*";
781 let s = value.replace('\\', "/");
782 if let Some(rest) = s.strip_prefix("//") {
783 let rest = rest.trim_start_matches('/');
785 let (host, tail) = rest.split_once('/').unwrap_or((rest, ""));
786 return format!("file://{host}{}", percent_quote(&format!("/{tail}"), KEEP));
787 }
788 let bytes = s.as_bytes();
789 if bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'/' {
790 return format!("file:///{}", percent_quote(&s, KEEP));
792 }
793 if let Some((scheme, rest)) = s.split_once(':') {
797 let valid_scheme = scheme.len() > 1
798 && scheme.as_bytes()[0].is_ascii_alphabetic()
799 && scheme
800 .bytes()
801 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'-' | b'.'));
802 if valid_scheme {
803 let (authority, rest) = match rest.strip_prefix("//") {
804 Some(r) => {
805 let end = r.find(['/', '?', '#']).unwrap_or(r.len());
806 (Some(&r[..end]), &r[end..])
807 }
808 None => (None, rest),
809 };
810 let (before_frag, fragment) = rest.split_once('#').unwrap_or((rest, ""));
811 let (path, query) = before_frag.split_once('?').unwrap_or((before_frag, ""));
812 let mut out = format!("{scheme}:");
813 if let Some(a) = authority {
814 out.push_str("//");
815 out.push_str(a);
816 }
817 out.push_str(&percent_quote(path, KEEP));
818 if !query.is_empty() {
819 out.push('?');
820 out.push_str(&percent_quote(query, KEEP));
821 }
822 if !fragment.is_empty() {
823 out.push('#');
824 out.push_str(&percent_quote(fragment, KEEP));
825 }
826 return out;
827 }
828 }
829 percent_quote(&s, KEEP)
831}
832
833fn percent_quote(s: &str, safe: &str) -> String {
836 let mut out = String::with_capacity(s.len());
837 for &b in s.as_bytes() {
838 let keep = b.is_ascii_alphanumeric()
839 || matches!(b, b'_' | b'.' | b'-' | b'~')
840 || (b.is_ascii() && safe.contains(b as char));
841 if keep {
842 out.push(b as char);
843 } else {
844 out.push_str(&format!("%{b:02X}"));
845 }
846 }
847 out
848}
849
850pub(crate) fn ext_for(mimetype: &str) -> &str {
851 match mimetype {
852 "image/jpeg" => "jpg",
853 "image/gif" => "gif",
854 "image/webp" => "webp",
855 "image/bmp" => "bmp",
856 "image/tiff" => "tif",
857 _ => "png",
858 }
859}
860
861fn is_number_cell(t: &str) -> bool {
880 t.parse::<f64>().is_ok() || is_thousands_number(t)
881}
882
883fn is_thousands_number(t: &str) -> bool {
889 let b = t.as_bytes();
890 let mut i = 0;
891 let start = i;
892 if i < b.len() && (b[i] == b'+' || b[i] == b'-') {
893 i += 1;
894 }
895 let d0 = i;
897 while i < b.len() && b[i].is_ascii_digit() && i - d0 < 3 {
898 i += 1;
899 }
900 let has_int = i > d0;
901 if has_int {
902 while i + 3 < b.len() + 1
904 && b.get(i) == Some(&b',')
905 && b.get(i + 1).is_some_and(u8::is_ascii_digit)
906 && b.get(i + 2).is_some_and(u8::is_ascii_digit)
907 && b.get(i + 3).is_some_and(u8::is_ascii_digit)
908 {
909 i += 4;
910 }
911 } else {
912 i = start;
914 }
915 if i < b.len() && b[i] == b'.' {
917 i += 1;
918 let f0 = i;
919 while i < b.len() && b[i].is_ascii_digit() {
920 i += 1;
921 }
922 if !has_int && i == f0 {
923 return false; }
925 } else if !has_int {
926 return false; }
928 i == b.len()
929}
930
931fn flatten_header_rows(header_rows: &[Vec<String>], num_cols: usize) -> Vec<String> {
940 (0..num_cols)
941 .map(|c| {
942 let mut parts: Vec<&str> = Vec::new();
943 for row in header_rows {
944 let text = row.get(c).map(String::as_str).unwrap_or("");
945 if !text.is_empty() && parts.last() != Some(&text) {
946 parts.push(text);
947 }
948 }
949 parts.join(" - ")
950 })
951 .collect()
952}
953
954pub(crate) fn render_table(table: &Table, compact: bool) -> String {
955 if table.rows.is_empty() {
956 return String::new();
957 }
958 let num_cols = table.rows.iter().map(Vec::len).max().unwrap_or(0);
959 if num_cols == 0 {
960 return String::new();
961 }
962
963 let num_headers = table.header_row_count().min(table.rows.len());
968 let escaped = |r: usize| -> Vec<String> {
969 (0..num_cols)
970 .map(|c| escape_cell(table.rows[r].get(c).map(String::as_str).unwrap_or("")))
971 .collect()
972 };
973 let header_rows: Vec<Vec<String>> = (0..num_headers).map(escaped).collect();
974 let header = flatten_header_rows(&header_rows, num_cols);
975 let body: Vec<Vec<String>> = (num_headers..table.rows.len())
976 .map(|r| {
977 escaped(r)
978 .into_iter()
979 .map(|c| c.trim().to_string())
980 .collect()
981 })
982 .collect();
983
984 if compact {
985 let render_row = |row: &[String]| -> String { format!("| {} |", row.join(" | ")) };
987 let mut lines = Vec::with_capacity(body.len() + 2);
988 lines.push(render_row(&header));
989 let sep: Vec<&str> = (0..num_cols).map(|_| "-").collect();
990 lines.push(format!("| {} |", sep.join(" | ")));
991 for row in &body {
992 lines.push(render_row(row));
993 }
994 return lines.join("\n");
995 }
996
997 let dw = |s: &str| s.chars().count();
999
1000 let right: Vec<bool> = (0..num_cols)
1005 .map(|c| {
1006 let mut any = false;
1007 for row in &body {
1008 let t = row[c].trim();
1009 if t.is_empty() {
1010 continue;
1011 }
1012 if !is_number_cell(t) {
1013 return false;
1014 }
1015 any = true;
1016 }
1017 any
1018 })
1019 .collect();
1020
1021 let width: Vec<usize> = (0..num_cols)
1023 .map(|c| {
1024 let mut w = dw(&header[c]) + 2;
1025 for row in &body {
1026 w = w.max(dw(&row[c]));
1027 }
1028 w
1029 })
1030 .collect();
1031
1032 let fmt_cell = |s: &str, c: usize| -> String {
1033 let pad = " ".repeat(width[c].saturating_sub(dw(s)));
1034 let body = if right[c] {
1035 format!("{pad}{s}")
1036 } else {
1037 format!("{s}{pad}")
1038 };
1039 format!(" {body} ")
1040 };
1041 let render_row = |row: &[String]| -> String {
1042 let cells: Vec<String> = (0..num_cols).map(|c| fmt_cell(&row[c], c)).collect();
1043 format!("|{}|", cells.join("|"))
1044 };
1045
1046 let mut lines = Vec::with_capacity(body.len() + 2);
1047 lines.push(render_row(&header));
1048 let sep: Vec<String> = (0..num_cols).map(|c| "-".repeat(width[c] + 2)).collect();
1049 lines.push(format!("|{}|", sep.join("|")));
1050 for row in &body {
1051 lines.push(render_row(row));
1052 }
1053 lines.join("\n")
1054}
1055
1056fn escape_cell(s: &str) -> String {
1059 s.replace('\n', " ").replace('|', "|")
1060}
1061
1062#[cfg(test)]
1063mod tests {
1064 use super::*;
1065 use crate::{PictureImage, TableCell, TableStructure};
1066
1067 #[test]
1073 fn list_boundaries_come_from_the_backend_not_the_numbering() {
1074 let item = |ordered: bool, number: u64, first_in_list: bool, text: &str| Node::ListItem {
1075 ordered,
1076 number,
1077 first_in_list,
1078 text: text.into(),
1079 level: 0,
1080 marker: None,
1081 location: None,
1082 dclx: None,
1083 href: None,
1084 layer: None,
1085 };
1086 let md = |items: Vec<Node>| {
1087 let mut doc = DoclingDocument::new("t");
1088 for n in items {
1089 doc.push(n);
1090 }
1091 doc.export_to_markdown()
1092 };
1093 assert_eq!(
1095 md(vec![
1096 item(true, 1, true, "one"),
1097 item(true, 5, false, "five")
1098 ]),
1099 "1. one\n5. five\n"
1100 );
1101 assert_eq!(
1103 md(vec![
1104 item(false, 0, true, "bullet"),
1105 item(true, 1, false, "one"),
1106 item(false, 0, false, "bullet two"),
1107 ]),
1108 "- bullet\n1. one\n- bullet two\n"
1109 );
1110 assert_eq!(
1112 md(vec![
1113 item(true, 1, true, "a"),
1114 item(true, 2, false, "b"),
1115 item(true, 3, true, "new list, continuing count"),
1116 ]),
1117 "1. a\n2. b\n\n3. new list, continuing count\n"
1118 );
1119 }
1120
1121 #[test]
1122 fn renders_headings_paragraphs_and_lists() {
1123 let mut doc = DoclingDocument::new("demo");
1124 doc.add_heading(1, "Title");
1125 doc.add_paragraph("Hello world.");
1126 doc.push(Node::ListItem {
1127 ordered: false,
1128 number: 1,
1129 first_in_list: true,
1130 text: "first".into(),
1131 level: 0,
1132 marker: None,
1133 location: None,
1134 dclx: None,
1135 href: None,
1136 layer: None,
1137 });
1138 doc.push(Node::ListItem {
1139 ordered: false,
1140 number: 2,
1141 first_in_list: false,
1142 text: "second".into(),
1143 level: 0,
1144 marker: None,
1145 location: None,
1146 dclx: None,
1147 href: None,
1148 layer: None,
1149 });
1150 let md = doc.export_to_markdown();
1151 assert_eq!(md, "# Title\n\nHello world.\n\n- first\n- second\n");
1152 }
1153
1154 #[test]
1158 fn single_newlines_become_gfm_hard_line_breaks() {
1159 let mut doc = DoclingDocument::new("t");
1160 doc.push(Node::Heading {
1161 level: 1,
1162 text: "Hello\nWorld".into(),
1163 });
1164 doc.push(Node::Paragraph {
1165 text: "line one\nline two\n\npara two".into(),
1166 });
1167 doc.push(Node::ListItem {
1168 ordered: false,
1169 number: 1,
1170 first_in_list: true,
1171 text: "item\ncontinued".into(),
1172 level: 0,
1173 marker: None,
1174 location: None,
1175 dclx: None,
1176 href: None,
1177 layer: None,
1178 });
1179 doc.push(Node::TextDump("A1 B1 \n\n\nC1".into()));
1180 assert_eq!(
1181 doc.export_to_markdown(),
1182 "# Hello World\n\nline one \nline two\n\npara two\n\n- item \ncontinued\n\nA1 B1 \n\n\nC1\n"
1183 );
1184 }
1185
1186 #[test]
1189 fn table_cell_mode_and_field_regions() {
1190 let mut doc = DoclingDocument::new("t");
1191 doc.push(Node::Heading {
1192 level: 2,
1193 text: "A text".into(),
1194 });
1195 doc.push(Node::Paragraph {
1196 text: "body".into(),
1197 });
1198 assert_eq!(to_markdown_table_cell(&doc, false), "A text\n\nbody");
1199 assert_eq!(doc.export_to_markdown(), "## A text\n\nbody\n");
1200
1201 let mut doc = DoclingDocument::new("f");
1202 doc.push(Node::FieldRegion {
1203 items: vec![crate::FieldItem {
1204 marker: None,
1205 key: Some("Name:".into()),
1206 value: Some("John Doe".into()),
1207 value_kind: None,
1208 }],
1209 });
1210 assert_eq!(doc.export_to_markdown(), "Name:\n\nJohn Doe\n");
1211 }
1212
1213 #[test]
1214 fn strict_renders_recovered_links_legacy_does_not() {
1215 let mut doc = DoclingDocument::new("cv");
1216 doc.add_paragraph("Find me on LinkedIn or GitHub.");
1217 doc.links = vec![
1218 ("LinkedIn".into(), "https://www.linkedin.com/in/x/".into()),
1219 ("GitHub".into(), "https://github.com/x/".into()),
1220 ];
1221 assert_eq!(doc.export_to_markdown(), "Find me on LinkedIn or GitHub.\n");
1223 assert_eq!(
1225 doc.export_to_markdown_with(true),
1226 "Find me on [LinkedIn](https://www.linkedin.com/in/x/) or [GitHub](https://github.com/x/).\n"
1227 );
1228 }
1229
1230 #[test]
1231 fn strict_links_match_escaped_anchor_and_consume_in_order() {
1232 let mut doc = DoclingDocument::new("d");
1233 doc.add_paragraph("AI & ML here, and issues here, then issues there.");
1237 doc.links = vec![
1238 ("AI & ML".into(), "https://a/".into()),
1239 ("issues".into(), "https://first/".into()),
1240 ("issues".into(), "https://second/".into()),
1241 ];
1242 assert_eq!(
1243 doc.export_to_markdown_with(true),
1244 "[AI & ML](https://a/) here, and [issues](https://first/) here, then [issues](https://second/) there.\n"
1245 );
1246 }
1247
1248 #[test]
1252 fn referenced_image_destinations_are_escaped() {
1253 let cases = [
1254 (
1255 "doc_artifacts/image_000001_ab12.png",
1256 "doc_artifacts/image_000001_ab12.png",
1257 ),
1258 (
1259 "My Report_artifacts/img.png",
1260 "My%20Report_artifacts/img.png",
1261 ),
1262 ("artifacts/img (1).png", "artifacts/img%20%281%29.png"),
1263 ("100%_scale/a#b?c.png", "100%_scale/a%23b%3Fc.png"),
1264 ("/home/a b/img.png", "/home/a%20b/img.png"),
1265 (
1266 "My Report_artifacts\\img.png",
1267 "My%20Report_artifacts/img.png",
1268 ),
1269 (
1270 "C:/Users/me/My Docs/img.png",
1271 "file:///C:/Users/me/My%20Docs/img.png",
1272 ),
1273 ("C:\\Users\\me\\img.png", "file:///C:/Users/me/img.png"),
1274 (
1275 "//server/share/My Docs/img.png",
1276 "file://server/share/My%20Docs/img.png",
1277 ),
1278 ("\\\\server\\share\\img.png", "file://server/share/img.png"),
1279 ("file:///home/a b/img.png", "file:///home/a%20b/img.png"),
1280 (
1281 "s3://bucket/My Report_artifacts/img.png",
1282 "s3://bucket/My%20Report_artifacts/img.png",
1283 ),
1284 (
1285 "https://example.com:8080/a b.png?w=1&h=2#frag",
1286 "https://example.com:8080/a%20b.png?w=1&h=2#frag",
1287 ),
1288 (
1289 "https://example.com/img (1).png",
1290 "https://example.com/img%20%281%29.png",
1291 ),
1292 ("caf\u{e9}/im\u{e4}ge.png", "caf%C3%A9/im%C3%A4ge.png"),
1293 ];
1294 for (input, expected) in cases {
1295 assert_eq!(escape_uri_path(input), expected, "input {input:?}");
1296 assert_eq!(
1297 escape_uri_path(expected),
1298 expected,
1299 "idempotent {expected:?}"
1300 );
1301 }
1302 let mut doc = DoclingDocument::new("t");
1304 doc.push(Node::Picture {
1305 caption: None,
1306 caption_href: None,
1307 image: Some(PictureImage {
1308 dpi: PictureImage::DEFAULT_DPI,
1309 mimetype: "image/png".into(),
1310 width: 1,
1311 height: 1,
1312 data: b"x".to_vec(),
1313 }),
1314 classification: None,
1315 caption_parent: Default::default(),
1316 });
1317 let (md, files) = doc
1318 .export_to_markdown_with_images(ImageMode::Referenced, "My Report (final)_artifacts");
1319 assert!(
1320 md.contains(""),
1321 "got:\n{md}"
1322 );
1323 assert_eq!(files[0].0, "My Report (final)_artifacts/image_000000.png");
1325 }
1326
1327 #[test]
1331 fn folded_list_item_pictures_keep_plain_newlines() {
1332 assert_eq!(
1333 list_item_text("Step\n<!-- image -->", false),
1334 "Step\n<!-- image -->"
1335 );
1336 assert_eq!(
1337 list_item_text("Step\nAlt text\n<!-- image -->\n<!-- image -->", false),
1338 "Step\nAlt text\n<!-- image -->\n<!-- image -->"
1339 );
1340 assert_eq!(
1341 list_item_text("line one\nline two", false),
1342 "line one \nline two"
1343 );
1344 }
1345
1346 #[test]
1349 fn stacked_header_rows_flatten_into_one() {
1350 let mut t = Table {
1351 rows: vec![
1352 vec!["".into(), "% of Total".into(), "% of Total".into()],
1353 vec!["class".into(), "Train".into(), "Test".into()],
1354 vec!["Caption".into(), "2.04".into(), "1.77".into()],
1355 ],
1356 ..Default::default()
1357 };
1358 t.structure = Some(TableStructure {
1359 header_row: vec![true, true, false],
1360 col_continuation: vec![
1361 vec![false, false, true],
1362 vec![false, false, false],
1363 vec![false, false, false],
1364 ],
1365 ..Default::default()
1366 });
1367 assert_eq!(t.header_row_count(), 2);
1368 assert_eq!(
1369 render_table(&t, true),
1370 "| class | % of Total - Train | % of Total - Test |\n| - | - | - |\n| Caption | 2.04 | 1.77 |"
1371 );
1372 assert_eq!(
1374 render_table(&t, false),
1375 "| class | % of Total - Train | % of Total - Test |\n\
1376 |---------|----------------------|---------------------|\n\
1377 | Caption | 2.04 | 1.77 |"
1378 );
1379 }
1380
1381 #[test]
1384 fn vertically_spanning_header_does_not_extend_the_block() {
1385 let mut t = Table {
1386 rows: vec![
1387 vec!["Name".into(), "Value".into()],
1388 vec!["Name".into(), "1".into()],
1389 vec!["x".into(), "2".into()],
1390 ],
1391 ..Default::default()
1392 };
1393 t.structure = Some(TableStructure {
1394 col_header: vec![vec![true, true], vec![true, false], vec![false, false]],
1395 row_continuation: vec![vec![false, false], vec![true, false], vec![false, false]],
1396 ..Default::default()
1397 });
1398 assert_eq!(t.header_row_count(), 1);
1399 assert_eq!(
1400 render_table(&t, true),
1401 "| Name | Value |\n| - | - |\n| Name | 1 |\n| x | 2 |"
1402 );
1403 }
1404
1405 #[test]
1408 fn header_flags_not_on_row_zero_keep_all_rows_in_the_body() {
1409 let mut t = Table {
1410 rows: vec![
1411 vec!["1".into(), "2".into()],
1412 vec!["a".into(), "b".into()],
1413 vec!["333".into(), "4".into()],
1414 ],
1415 ..Default::default()
1416 };
1417 t.structure = Some(TableStructure {
1418 header_row: vec![false, true, false],
1419 ..Default::default()
1420 });
1421 assert_eq!(t.header_row_count(), 0);
1422 assert_eq!(
1423 render_table(&t, false),
1424 "| | |\n|-----|----|\n| 1 | 2 |\n| a | b |\n| 333 | 4 |"
1425 );
1426 }
1427
1428 #[test]
1433 fn pivot_row_headers_do_not_extend_the_header() {
1434 let mut t = Table {
1435 rows: vec![
1436 vec!["Year".into(), "Month".into()],
1437 vec!["2025".into(), "January".into()],
1438 vec!["2025".into(), "February".into()],
1439 ],
1440 ..Default::default()
1441 };
1442 t.structure = Some(TableStructure {
1443 col_header: vec![vec![true, true], vec![false, false], vec![false, false]],
1444 row_header: vec![vec![false, false], vec![true, false], vec![true, false]],
1445 row_continuation: vec![vec![false, false], vec![false, false], vec![true, false]],
1446 ..Default::default()
1447 });
1448 assert_eq!(t.header_row_count(), 1);
1449 assert_eq!(
1450 render_table(&t, true),
1451 "| Year | Month |\n| - | - |\n| 2025 | January |\n| 2025 | February |"
1452 );
1453 }
1454
1455 #[test]
1458 fn unflagged_cells_keep_row_zero_as_header() {
1459 let mut t = Table {
1460 rows: vec![vec!["h".into()], vec!["d".into()]],
1461 ..Default::default()
1462 };
1463 t.cells = Some(
1464 [(0usize, "h"), (1, "d")]
1465 .into_iter()
1466 .map(|(r, text)| TableCell {
1467 text: text.into(),
1468 bbox: None,
1469 start_row: r,
1470 start_col: 0,
1471 row_span: 1,
1472 col_span: 1,
1473 column_header: false,
1474 row_header: false,
1475 row_section: false,
1476 })
1477 .collect(),
1478 );
1479 assert_eq!(t.header_row_count(), 1);
1480 assert_eq!(render_table(&t, true), "| h |\n| - |\n| d |");
1481 }
1482
1483 #[test]
1484 fn renders_compact_table() {
1485 let mut doc = DoclingDocument::new("t");
1486 doc.compact_tables = true;
1489 doc.push(Node::Table(Table {
1490 rows: vec![vec!["a".into(), "b".into()], vec!["1".into(), "2".into()]],
1491 location: None,
1492 structure: None,
1493 cell_blocks: None,
1494 cells: None,
1495 caption: None,
1496 caption_parent: Default::default(),
1497 }));
1498 let md = doc.export_to_markdown();
1499 assert_eq!(md, "| a | b |\n| - | - |\n| 1 | 2 |\n");
1500 }
1501
1502 #[test]
1503 fn renders_padded_github_table_by_default() {
1504 let mut doc = DoclingDocument::new("t");
1505 doc.push(Node::Table(Table {
1506 rows: vec![vec!["a".into(), "b".into()], vec!["1".into(), "2".into()]],
1507 location: None,
1508 structure: None,
1509 cell_blocks: None,
1510 cells: None,
1511 caption: None,
1512 caption_parent: Default::default(),
1513 }));
1514 let md = doc.export_to_markdown();
1515 assert_eq!(md, "| a | b |\n|-----|-----|\n| 1 | 2 |\n");
1517 }
1518
1519 #[test]
1520 fn strict_unescapes_inline_underscores_legacy_keeps_them() {
1521 let mut doc = DoclingDocument::new("t");
1522 doc.add_heading(1, "a\\_b");
1523 doc.add_paragraph("x\\_y");
1524 doc.push(Node::ListItem {
1525 ordered: false,
1526 number: 1,
1527 first_in_list: true,
1528 text: "i\\_j".into(),
1529 level: 0,
1530 marker: None,
1531 location: None,
1532 dclx: None,
1533 href: None,
1534 layer: None,
1535 });
1536 assert_eq!(doc.export_to_markdown(), "# a\\_b\n\nx\\_y\n\n- i\\_j\n");
1538 assert_eq!(doc.export_to_markdown_with(true), "# a_b\n\nx_y\n\n- i_j\n");
1540 }
1541
1542 fn assert_stream_matches(
1545 doc: &DoclingDocument,
1546 strict: bool,
1547 images: ImageMode,
1548 splits: &[usize],
1549 ) {
1550 let (want, want_artifacts) = to_markdown_images(doc, strict, images, "artifacts");
1551 let mut streamer =
1552 MarkdownStreamer::with_artifacts(strict, images, doc.compact_tables, "artifacts")
1553 .with_page_break_placeholder(doc.page_break_placeholder.clone());
1554 let mut got = String::new();
1555 let mut got_artifacts = Vec::new();
1556 let mut start = 0;
1557 for &end in splits {
1558 let links = if start == 0 {
1561 doc.links.as_slice()
1562 } else {
1563 &[]
1564 };
1565 got.push_str(&streamer.push(&doc.nodes[start..end], links));
1566 got_artifacts.extend(streamer.take_artifacts());
1569 start = end;
1570 }
1571 got.push_str(&streamer.push(
1572 &doc.nodes[start..],
1573 if start == 0 {
1574 doc.links.as_slice()
1575 } else {
1576 &[]
1577 },
1578 ));
1579 got_artifacts.extend(streamer.take_artifacts());
1580 got.push_str(&streamer.finish());
1581 assert_eq!(
1582 got, want,
1583 "streamed output diverged (splits={splits:?}, strict={strict})"
1584 );
1585 assert_eq!(
1586 got_artifacts, want_artifacts,
1587 "streamed artifacts diverged (splits={splits:?}, strict={strict})"
1588 );
1589 }
1590
1591 #[test]
1592 fn streaming_is_byte_identical_to_buffered() {
1593 let mut doc = DoclingDocument::new("d");
1594 doc.add_heading(1, "Title");
1595 doc.add_paragraph("First paragraph.");
1596 doc.push(Node::ListItem {
1597 ordered: false,
1598 number: 1,
1599 first_in_list: true,
1600 text: "a".into(),
1601 level: 0,
1602 marker: None,
1603 location: None,
1604 dclx: None,
1605 href: None,
1606 layer: None,
1607 });
1608 doc.push(Node::ListItem {
1609 ordered: false,
1610 number: 2,
1611 first_in_list: false,
1612 text: "b".into(),
1613 level: 0,
1614 marker: None,
1615 location: None,
1616 dclx: None,
1617 href: None,
1618 layer: None,
1619 });
1620 doc.push(Node::Code {
1621 language: Some("rust".into()),
1622 text: "let x = 1;".into(),
1623 orig: None,
1624 pretty: None,
1625 });
1626 doc.push(Node::Table(Table {
1627 rows: vec![vec!["a".into(), "b".into()], vec!["1".into(), "2".into()]],
1628 location: None,
1629 structure: None,
1630 cell_blocks: None,
1631 cells: None,
1632 caption: None,
1633 caption_parent: Default::default(),
1634 }));
1635 doc.push(Node::Picture {
1636 caption: Some("Fig 1".into()),
1637 caption_href: None,
1638 image: Some(PictureImage {
1639 dpi: PictureImage::DEFAULT_DPI,
1640 mimetype: "image/png".into(),
1641 width: 2,
1642 height: 2,
1643 data: b"png-one".to_vec(),
1644 }),
1645 classification: None,
1646 caption_parent: Default::default(),
1647 });
1648 doc.add_paragraph("Last paragraph.");
1649 doc.push(Node::Picture {
1652 caption: None,
1653 caption_href: None,
1654 image: Some(PictureImage {
1655 dpi: PictureImage::DEFAULT_DPI,
1656 mimetype: "image/png".into(),
1657 width: 2,
1658 height: 2,
1659 data: b"png-two".to_vec(),
1660 }),
1661 classification: None,
1662 caption_parent: Default::default(),
1663 });
1664
1665 for &strict in &[false, true] {
1668 for &images in &[
1669 ImageMode::Placeholder,
1670 ImageMode::Embedded,
1671 ImageMode::Referenced,
1672 ] {
1673 for splits in [&[][..], &[1][..], &[2][..], &[4][..], &[1, 4, 6, 7][..]] {
1674 assert_stream_matches(&doc, strict, images, splits);
1675 }
1676 }
1677 }
1678 }
1679
1680 #[test]
1681 fn streaming_applies_recovered_links_in_strict_mode() {
1682 let mut doc = DoclingDocument::new("d");
1683 doc.add_paragraph("See LinkedIn for details.");
1684 doc.add_paragraph("And GitHub too.");
1685 doc.links = vec![
1686 ("LinkedIn".into(), "https://lnkd/".into()),
1687 ("GitHub".into(), "https://gh/".into()),
1688 ];
1689 assert_stream_matches(&doc, true, ImageMode::Placeholder, &[1]);
1692 }
1693
1694 fn paged_doc() -> DoclingDocument {
1697 let mut doc = DoclingDocument::new("p");
1698 doc.push(Node::PageInfo {
1699 page_no: 1,
1700 width: 100.0,
1701 height: 100.0,
1702 });
1703 doc.add_heading(1, "Title");
1704 doc.add_paragraph("Page one.");
1705 doc.push(Node::PageBreak);
1707 doc.push(Node::PageInfo {
1708 page_no: 2,
1709 width: 100.0,
1710 height: 100.0,
1711 });
1712 doc.push(Node::PageFurniture {
1713 footer: true,
1714 location: [0, 500, 511, 511],
1715 text: "2".into(),
1716 });
1717 doc.push(Node::PageBreak);
1718 doc.push(Node::PageInfo {
1719 page_no: 3,
1720 width: 100.0,
1721 height: 100.0,
1722 });
1723 doc.add_paragraph("Page three.");
1724 doc.push(Node::PageBreak);
1726 doc
1727 }
1728
1729 #[test]
1730 fn page_break_placeholder_lands_between_pages_only() {
1731 let mut doc = paged_doc();
1732 assert_eq!(
1734 doc.export_to_markdown(),
1735 "# Title\n\nPage one.\n\nPage three.\n"
1736 );
1737 doc.page_break_placeholder = Some("<!-- page break -->".into());
1738 assert_eq!(
1742 doc.export_to_markdown(),
1743 "# Title\n\nPage one.\n\n<!-- page break -->\n\nPage three.\n"
1744 );
1745 doc.page_break_placeholder = Some(String::new());
1748 assert_eq!(
1749 doc.export_to_markdown(),
1750 "# Title\n\nPage one.\n\n\n\nPage three.\n"
1751 );
1752 }
1753
1754 #[test]
1755 fn page_break_placeholder_never_leads_a_single_page() {
1756 let mut doc = DoclingDocument::new("one");
1757 doc.page_break_placeholder = Some("---".into());
1758 doc.push(Node::PageBreak);
1759 doc.push(Node::PageInfo {
1760 page_no: 1,
1761 width: 10.0,
1762 height: 10.0,
1763 });
1764 doc.add_paragraph("Only page.");
1765 assert_eq!(doc.export_to_markdown(), "Only page.\n");
1766 doc.push(Node::PageBreak);
1768 doc.push(Node::PageBreak);
1769 doc.add_paragraph("Next.");
1770 assert_eq!(doc.export_to_markdown(), "Only page.\n\n---\n\nNext.\n");
1771 }
1772
1773 #[test]
1774 fn page_break_placeholder_streams_byte_identical() {
1775 let mut doc = paged_doc();
1776 doc.page_break_placeholder = Some("<!-- page break -->".into());
1777 for splits in [
1781 &[3usize][..],
1782 &[3, 6],
1783 &[3, 6, 8],
1784 &[1, 2, 3, 4, 5, 6, 7, 8, 9],
1785 &[8],
1786 ] {
1787 assert_stream_matches(&doc, false, ImageMode::Placeholder, splits);
1788 assert_stream_matches(&doc, true, ImageMode::Placeholder, splits);
1789 }
1790 }
1791
1792 #[test]
1793 fn strict_tightens_punctuation_spacing_legacy_keeps_it() {
1794 let mut doc = DoclingDocument::new("t");
1795 doc.add_paragraph("see [ 37 , 36 ] and ( x ) .");
1796 assert_eq!(doc.export_to_markdown(), "see [ 37 , 36 ] and ( x ) .\n");
1798 assert_eq!(doc.export_to_markdown_with(true), "see [37, 36] and (x).\n");
1800 }
1801}