1use std::collections::{BTreeMap, HashMap, HashSet};
65
66use serde_json::Value;
67
68use crate::document::{ContentLayers, DoclingDocument};
69use crate::markdown::ImageMode;
70
71pub const PANDOC_API_VERSION: [u32; 4] = [1, 23, 1, 1];
73
74pub type PandocOutput = (String, Vec<(String, Vec<u8>)>);
77
78#[derive(Debug, Clone)]
80pub struct PandocExportOptions {
81 pub image_mode: ImageMode,
84 pub artifacts_dir: String,
87 pub layers: ContentLayers,
89 pub api_version: Option<String>,
92}
93
94impl Default for PandocExportOptions {
95 fn default() -> Self {
96 Self {
97 image_mode: ImageMode::Embedded,
98 artifacts_dir: "artifacts".to_string(),
99 layers: ContentLayers::BODY,
100 api_version: None,
101 }
102 }
103}
104
105#[derive(Debug, Clone, PartialEq, Eq)]
107pub enum PandocError {
108 UnsupportedApiVersion { requested: String },
110}
111
112impl std::fmt::Display for PandocError {
113 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114 match self {
115 PandocError::UnsupportedApiVersion { requested } => write!(
116 f,
117 "unsupported Pandoc API version '{requested}': only {} (pandoc-types {}, Pandoc 3.x) is supported",
118 version_string(&PANDOC_API_VERSION[..2]),
119 version_string(&PANDOC_API_VERSION),
120 ),
121 }
122 }
123}
124
125impl std::error::Error for PandocError {}
126
127fn version_string(parts: &[u32]) -> String {
128 parts
129 .iter()
130 .map(u32::to_string)
131 .collect::<Vec<_>>()
132 .join(".")
133}
134
135pub fn check_api_version(requested: &str) -> Result<(), PandocError> {
139 let err = || PandocError::UnsupportedApiVersion {
140 requested: requested.to_string(),
141 };
142 let parts: Vec<u32> = requested
143 .trim()
144 .split('.')
145 .map(|p| p.parse::<u32>().map_err(|_| err()))
146 .collect::<Result<_, _>>()?;
147 if parts.len() < 2 || parts[..2] != PANDOC_API_VERSION[..2] {
148 return Err(err());
149 }
150 if parts.len() > PANDOC_API_VERSION.len()
151 || parts
152 .iter()
153 .zip(PANDOC_API_VERSION.iter())
154 .skip(2)
155 .any(|(a, b)| a > b)
156 {
157 return Err(err());
159 }
160 Ok(())
161}
162
163pub fn to_pandoc(
167 doc: &DoclingDocument,
168 options: &PandocExportOptions,
169) -> Result<PandocOutput, PandocError> {
170 from_docling_json(&crate::json::to_json_with_notes(doc), options)
172}
173
174pub fn from_docling_json(
178 json: &Value,
179 options: &PandocExportOptions,
180) -> Result<PandocOutput, PandocError> {
181 if let Some(v) = &options.api_version {
182 check_api_version(v)?;
183 }
184 let render = || {
187 let mut ser = Serializer::new(json, options);
188 let blocks = ser.body();
189 let doc = ast::document_with_meta(meta_of(json), blocks);
190 (
191 serde_json::to_string(&doc).expect("Pandoc JSON is always serializable"),
192 ser.artifacts,
193 )
194 };
195 #[cfg(not(target_arch = "wasm32"))]
196 {
197 Ok(std::thread::scope(|scope| {
198 std::thread::Builder::new()
199 .name("docling-pandoc".into())
200 .stack_size(256 << 20)
201 .spawn_scoped(scope, render)
202 .expect("spawn the pandoc serializer thread")
203 .join()
204 .expect("pandoc serializer thread panicked")
205 }))
206 }
207 #[cfg(target_arch = "wasm32")]
208 {
209 Ok(render())
210 }
211}
212
213pub mod ast {
216 use serde_json::{json, Value};
217
218 use super::PANDOC_API_VERSION;
219
220 pub fn document(blocks: Vec<Value>) -> Value {
221 document_with_meta(json!({}), blocks)
222 }
223 pub fn document_with_meta(meta: Value, blocks: Vec<Value>) -> Value {
225 json!({ "pandoc-api-version": PANDOC_API_VERSION, "meta": meta, "blocks": blocks })
226 }
227 pub fn meta_inlines(inlines: Vec<Value>) -> Value {
228 node("MetaInlines", json!(inlines))
229 }
230 pub fn meta_string(s: &str) -> Value {
231 node("MetaString", json!(s))
232 }
233 pub fn meta_list(items: Vec<Value>) -> Value {
234 node("MetaList", json!(items))
235 }
236 pub fn span(classes: &[&str], attrs: &[(String, String)], inlines: Vec<Value>) -> Value {
238 let kv: Vec<Value> = attrs.iter().map(|(k, v)| json!([k, v])).collect();
239 node("Span", json!([["", classes, kv], inlines]))
240 }
241
242 pub fn attr(classes: &[&str]) -> Value {
244 json!(["", classes, []])
245 }
246
247 fn node(t: &str, c: Value) -> Value {
248 json!({ "t": t, "c": c })
249 }
250
251 pub fn para(inlines: Vec<Value>) -> Value {
254 node("Para", json!(inlines))
255 }
256 pub fn plain(inlines: Vec<Value>) -> Value {
257 node("Plain", json!(inlines))
258 }
259 pub fn header(level: usize, inlines: Vec<Value>) -> Value {
260 node("Header", json!([level, attr(&[]), inlines]))
261 }
262 pub fn code_block(language: Option<&str>, text: &str) -> Value {
263 let classes: Vec<&str> = language.into_iter().collect();
264 node("CodeBlock", json!([attr(&classes), text]))
265 }
266 pub fn bullet_list(items: Vec<Vec<Value>>) -> Value {
267 node("BulletList", json!(items))
268 }
269 pub fn ordered_list(start: u64, style: &str, delim: &str, items: Vec<Vec<Value>>) -> Value {
273 node(
274 "OrderedList",
275 json!([[start, { "t": style }, { "t": delim }], items]),
276 )
277 }
278 pub fn definition_list(entries: Vec<(Vec<Value>, Vec<Vec<Value>>)>) -> Value {
279 node(
280 "DefinitionList",
281 Value::Array(
282 entries
283 .into_iter()
284 .map(|(term, defs)| json!([term, defs]))
285 .collect(),
286 ),
287 )
288 }
289 pub fn raw_block(format: &str, text: &str) -> Value {
292 node("RawBlock", json!([format, text]))
293 }
294 pub fn div(classes: &[&str], blocks: Vec<Value>) -> Value {
295 node("Div", json!([attr(classes), blocks]))
296 }
297 pub fn caption(blocks: Vec<Value>) -> Value {
299 json!([null, blocks])
300 }
301 pub fn figure(classes: &[&str], caption: Value, blocks: Vec<Value>) -> Value {
302 node("Figure", json!([attr(classes), caption, blocks]))
303 }
304 pub fn cell(row_span: usize, col_span: usize, blocks: Vec<Value>) -> Value {
306 json!([attr(&[]), { "t": "AlignDefault" }, row_span, col_span, blocks])
307 }
308 pub fn row(cells: Vec<Value>) -> Value {
309 json!([attr(&[]), cells])
310 }
311 pub fn table(caption: Value, num_cols: usize, head: Vec<Value>, body: Vec<Value>) -> Value {
313 let colspecs: Vec<Value> = (0..num_cols)
314 .map(|_| json!([{ "t": "AlignDefault" }, { "t": "ColWidthDefault" }]))
315 .collect();
316 node(
317 "Table",
318 json!([
319 attr(&[]),
320 caption,
321 colspecs,
322 [attr(&[]), head],
323 [[attr(&[]), 0, [], body]],
324 [attr(&[]), []]
325 ]),
326 )
327 }
328
329 pub fn str_(s: &str) -> Value {
332 node("Str", json!(s))
333 }
334 pub fn space() -> Value {
335 json!({ "t": "Space" })
336 }
337 pub fn line_break() -> Value {
338 json!({ "t": "LineBreak" })
339 }
340 pub fn wrap(t: &str, inlines: Vec<Value>) -> Value {
342 node(t, json!(inlines))
343 }
344 pub fn code(text: &str) -> Value {
345 node("Code", json!([attr(&[]), text]))
346 }
347 pub fn math(display: bool, tex: &str) -> Value {
348 let kind = if display { "DisplayMath" } else { "InlineMath" };
349 node("Math", json!([{ "t": kind }, tex]))
350 }
351 pub fn link(inlines: Vec<Value>, url: &str) -> Value {
352 node("Link", json!([attr(&[]), inlines, [url, ""]]))
353 }
354 pub fn image(classes: &[&str], alt: Vec<Value>, src: &str) -> Value {
355 image_sized(classes, &[], alt, src)
356 }
357 pub fn image_sized(
359 classes: &[&str],
360 attrs: &[(String, String)],
361 alt: Vec<Value>,
362 src: &str,
363 ) -> Value {
364 let kv: Vec<Value> = attrs.iter().map(|(k, v)| json!([k, v])).collect();
365 node("Image", json!([["", classes, kv], alt, [src, ""]]))
366 }
367 pub fn note(blocks: Vec<Value>) -> Value {
368 node("Note", json!(blocks))
369 }
370}
371
372fn text_inlines(text: &str) -> Vec<Value> {
376 let mut out = Vec::new();
377 for (i, line) in text.trim().split('\n').enumerate() {
378 if i > 0 {
379 out.push(ast::line_break());
380 }
381 let mut first = true;
382 for word in line
383 .split(|c: char| c.is_whitespace() && c != '\u{a0}')
384 .filter(|w| !w.is_empty())
385 {
386 if !first {
387 out.push(ast::space());
388 }
389 out.push(ast::str_(word));
390 first = false;
391 }
392 }
393 out
394}
395
396fn text_with_notes(text: &str, notes: &[Value]) -> Vec<Value> {
401 let mut calls: Vec<(usize, &str)> = notes
402 .iter()
403 .filter_map(|n| Some((n.get(0)?.as_u64()? as usize, n.get(1)?.as_str()?)))
404 .collect();
405 calls.sort_by_key(|&(offset, _)| offset);
406 let chars: Vec<char> = text.chars().collect();
407 let mut out: Vec<Value> = Vec::new();
408 let push_segment = |out: &mut Vec<Value>, seg: &str| {
409 let inlines = text_inlines(seg);
410 if inlines.is_empty() {
411 if !out.is_empty() && seg.chars().any(char::is_whitespace) {
412 out.push(ast::space());
413 }
414 return;
415 }
416 if !out.is_empty() && seg.starts_with(char::is_whitespace) {
417 out.push(ast::space());
418 }
419 out.extend(inlines);
420 if seg.ends_with(char::is_whitespace) {
421 out.push(ast::space());
422 }
423 };
424 let mut prev = 0usize;
425 for (offset, note) in calls {
426 let at = offset.clamp(prev, chars.len());
427 let seg: String = chars[prev..at].iter().collect();
428 push_segment(&mut out, &seg);
429 out.push(ast::note(vec![ast::para(text_inlines(note))]));
430 prev = at;
431 }
432 let rest: String = chars[prev..].iter().collect();
433 push_segment(&mut out, &rest);
434 while out
435 .last()
436 .is_some_and(|v| v.get("t").and_then(Value::as_str) == Some("Space"))
437 {
438 out.pop();
439 }
440 out
441}
442
443fn join_inlines(parts: Vec<Vec<Value>>) -> Vec<Value> {
449 fn edge_char(inlines: &[Value], first: bool) -> Option<char> {
450 let node = if first {
451 inlines.first()
452 } else {
453 inlines.last()
454 }?;
455 match node.get("t").and_then(Value::as_str)? {
456 "Str" => {
457 let s = node.get("c")?.as_str()?;
458 if first {
459 s.chars().next()
460 } else {
461 s.chars().last()
462 }
463 }
464 "Strong" | "Emph" | "Underline" | "Strikeout" | "Subscript" | "Superscript" => {
466 edge_char(node.get("c")?.as_array()?, first)
467 }
468 "Link" => edge_char(node.get("c")?.get(1)?.as_array()?, first),
469 _ => None,
470 }
471 }
472 let mut out: Vec<Value> = Vec::new();
473 for part in parts.into_iter().filter(|p| !p.is_empty()) {
474 if !out.is_empty() {
475 let closes = edge_char(&part, true)
476 .is_some_and(|c| ".,;:!?)]}%\u{bb}\u{201d}\u{2019}".contains(c));
477 let opens = edge_char(&out, false).is_some_and(|c| "([{\u{ab}\u{201c}".contains(c));
478 if !closes && !opens {
479 out.push(ast::space());
480 }
481 }
482 out.extend(part);
483 }
484 out
485}
486
487fn meta_of(json: &Value) -> Value {
496 let mut meta = serde_json::Map::new();
497 let Some(m) = json.get("metadata").and_then(Value::as_object) else {
498 return Value::Object(meta);
499 };
500 let text = |key: &str| {
501 m.get(key)
502 .and_then(Value::as_str)
503 .map(text_inlines)
504 .filter(|t| !t.is_empty())
505 };
506 let list = |key: &str| -> Vec<Value> {
507 m.get(key)
508 .and_then(Value::as_array)
509 .into_iter()
510 .flatten()
511 .filter_map(Value::as_str)
512 .map(text_inlines)
513 .filter(|t| !t.is_empty())
514 .map(ast::meta_inlines)
515 .collect()
516 };
517 for (key, field) in [
518 ("title", "title"),
519 ("subject", "subject"),
520 ("description", "description"),
521 ("publisher", "publisher"),
522 ("rights", "rights"),
523 ("identifier", "identifier"),
524 ] {
525 if let Some(t) = text(field) {
526 meta.insert(key.into(), ast::meta_inlines(t));
527 }
528 }
529 let authors = list("authors");
530 if !authors.is_empty() {
531 meta.insert("author".into(), ast::meta_list(authors));
532 }
533 if let Some(date) = text("date").or_else(|| text("created")) {
534 meta.insert("date".into(), ast::meta_inlines(date));
535 }
536 let keywords = list("keywords");
537 if !keywords.is_empty() {
538 meta.insert("keywords".into(), ast::meta_list(keywords));
539 }
540 if let Some(lang) = m.get("language").and_then(Value::as_str).map(str::trim) {
541 if !lang.is_empty() {
542 meta.insert("lang".into(), ast::meta_string(lang));
543 }
544 }
545 Value::Object(meta)
546}
547
548fn split_comment(note: &str) -> (Option<String>, Option<String>, String) {
552 let Some(rest) = note.strip_prefix('[') else {
553 return (None, None, note.to_string());
554 };
555 let Some((head, body)) = rest.split_once(']') else {
556 return (None, None, note.to_string());
557 };
558 let body = body.strip_prefix(':').unwrap_or(body).trim().to_string();
559 let mut author = None;
560 let mut date = None;
561 for part in head.split(", ") {
562 if let Some(a) = part.strip_prefix("author: ") {
563 author = Some(a.trim().to_string());
564 } else if let Some(t) = part.strip_prefix("time: ") {
565 date = Some(t.trim().to_string());
566 }
567 }
568 (author, date, body)
569}
570
571fn inches(v: f64) -> String {
574 let s = format!("{v:.4}");
575 let s = s.trim_end_matches('0').trim_end_matches('.');
576 format!("{}in", if s.is_empty() { "0" } else { s })
577}
578
579fn list_attributes(markers: &[&str]) -> (u64, &'static str, &'static str) {
590 fn split(marker: &str) -> (&str, &'static str) {
591 let m = marker.trim();
592 if let Some(inner) = m.strip_prefix('(').and_then(|r| r.strip_suffix(')')) {
593 return (inner.trim(), "TwoParens");
594 }
595 if let Some(inner) = m.strip_suffix(')') {
596 return (inner.trim(), "OneParen");
597 }
598 if let Some(inner) = m.strip_suffix('.') {
599 return (inner.trim(), "Period");
600 }
601 (m, "DefaultDelim")
602 }
603 fn roman(token: &str) -> Option<u64> {
604 let value = |c: char| match c.to_ascii_lowercase() {
605 'i' => Some(1),
606 'v' => Some(5),
607 'x' => Some(10),
608 'l' => Some(50),
609 'c' => Some(100),
610 'd' => Some(500),
611 'm' => Some(1000),
612 _ => None,
613 };
614 let values: Vec<i64> = token.chars().map(value).collect::<Option<_>>()?;
615 if values.is_empty() {
616 return None;
617 }
618 let mut total = 0i64;
619 for (i, &v) in values.iter().enumerate() {
620 if values.get(i + 1).is_some_and(|&next| next > v) {
622 total -= v;
623 } else {
624 total += v;
625 }
626 }
627 (total > 0).then_some(total as u64)
628 }
629 fn alpha(token: &str) -> Option<u64> {
630 let first = token.chars().next()?;
631 if !first.is_ascii_alphabetic() || !token.chars().all(|c| c == first) {
632 return None;
633 }
634 let ordinal = (first.to_ascii_lowercase() as u64) - ('a' as u64) + 1;
635 Some((token.chars().count() as u64 - 1) * 26 + ordinal)
636 }
637 const DEFAULT: (u64, &str, &str) = (1, "Decimal", "Period");
638 let Some(first) = markers.first() else {
639 return DEFAULT;
640 };
641 let (token, delim) = split(first);
642 if token.is_empty() {
643 return DEFAULT;
644 }
645 if token.chars().all(|c| c.is_ascii_digit()) {
646 return token
647 .parse()
648 .map(|n| (n, "Decimal", delim))
649 .unwrap_or(DEFAULT);
650 }
651 if !token.chars().all(|c| c.is_ascii_alphabetic()) {
652 return DEFAULT;
653 }
654 let upper = token.chars().next().is_some_and(|c| c.is_ascii_uppercase());
655 let letters = |t: &str| t.chars().all(|c| c.is_ascii_alphabetic());
656 let reads_roman = roman(token).is_some()
657 && (token.len() > 1
658 || token.eq_ignore_ascii_case("i")
659 || markers[1..].iter().any(|m| {
660 let (t, _) = split(m);
661 t.len() > 1 && letters(t) && roman(t).is_some()
662 }));
663 if reads_roman {
664 let style = if upper { "UpperRoman" } else { "LowerRoman" };
665 return (roman(token).unwrap_or(1), style, delim);
666 }
667 match alpha(token) {
668 Some(n) => (n, if upper { "UpperAlpha" } else { "LowerAlpha" }, delim),
669 None => DEFAULT,
670 }
671}
672
673struct GridCell<'a> {
674 cell: &'a Value,
675 start_row: usize,
676 start_col: usize,
677 row_span: usize,
678 col_span: usize,
679}
680
681struct Serializer<'a> {
682 json: &'a Value,
683 image_mode: ImageMode,
684 artifacts_dir: String,
685 layers: ContentLayers,
686 artifacts: Vec<(String, Vec<u8>)>,
687 pic_index: usize,
688 visited: HashSet<String>,
689 owned_captions: HashSet<String>,
692 owned_footnotes: HashSet<String>,
693}
694
695impl<'a> Serializer<'a> {
696 fn new(json: &'a Value, options: &PandocExportOptions) -> Self {
697 let mut owned_captions = HashSet::new();
698 let mut owned_footnotes = HashSet::new();
699 for bucket in [
700 "pictures",
701 "tables",
702 "key_value_items",
703 "form_items",
704 "texts",
705 ] {
706 for item in Self::array(json.get(bucket)) {
707 for (key, set) in [
708 ("captions", &mut owned_captions),
709 ("footnotes", &mut owned_footnotes),
710 ] {
711 for r in Self::array(item.get(key)) {
712 if let Some(cref) = r.get("$ref").and_then(Value::as_str) {
713 set.insert(cref.to_string());
714 }
715 }
716 }
717 }
718 }
719 Self {
720 json,
721 image_mode: options.image_mode,
722 artifacts_dir: options.artifacts_dir.clone(),
723 layers: options.layers,
724 artifacts: Vec::new(),
725 pic_index: 0,
726 visited: HashSet::new(),
727 owned_captions,
728 owned_footnotes,
729 }
730 }
731
732 fn array(v: Option<&'a Value>) -> impl Iterator<Item = &'a Value> {
733 v.and_then(Value::as_array).into_iter().flatten()
734 }
735
736 fn resolve(&self, cref: &str) -> Option<&'a Value> {
737 let rest = cref.strip_prefix("#/")?;
738 if rest == "body" {
739 return self.json.get("body");
740 }
741 let (bucket, idx) = rest.split_once('/')?;
742 self.json.get(bucket)?.get(idx.parse::<usize>().ok()?)
743 }
744
745 fn refs(v: Option<&'a Value>) -> Vec<&'a str> {
746 Self::array(v)
747 .filter_map(|r| r.get("$ref").and_then(Value::as_str))
748 .collect()
749 }
750
751 fn children(item: &'a Value) -> Vec<&'a str> {
752 Self::refs(item.get("children"))
753 }
754
755 fn self_ref(item: &Value) -> &str {
756 item.get("self_ref").and_then(Value::as_str).unwrap_or("")
757 }
758
759 fn label(item: &Value) -> &str {
760 item.get("label").and_then(Value::as_str).unwrap_or("")
761 }
762
763 fn text(item: &Value) -> &str {
764 item.get("text").and_then(Value::as_str).unwrap_or("")
765 }
766
767 fn is_group(item: &Value) -> bool {
768 Self::self_ref(item).starts_with("#/groups/")
769 }
770
771 fn excluded(&self, item: &Value) -> bool {
772 let layer = item
773 .get("content_layer")
774 .and_then(Value::as_str)
775 .unwrap_or("body");
776 !self.layers.contains_name(layer)
777 }
778
779 fn descendants(&self, root: &'a Value, out: &mut Vec<&'a Value>) {
784 let picture = Self::self_ref(root).starts_with("#/pictures/");
785 let captions = if picture {
786 Self::refs(root.get("captions"))
787 } else {
788 Vec::new()
789 };
790 for cref in Self::children(root) {
791 if picture && !captions.contains(&cref) {
792 continue;
793 }
794 if self.owned_captions.contains(cref) || self.owned_footnotes.contains(cref) {
798 continue;
799 }
800 let Some(child) = self.resolve(cref) else {
801 continue;
802 };
803 if !self.excluded(child) {
804 out.push(child);
805 }
806 self.descendants(child, out);
807 }
808 }
809
810 fn blocks_of(&mut self, item: &'a Value) -> Vec<Value> {
814 let mut nodes = Vec::new();
815 self.descendants(item, &mut nodes);
816 let mut out = Vec::new();
817 for node in nodes {
818 if self.visited.insert(Self::self_ref(node).to_string()) {
819 out.extend(self.serialize(node));
820 }
821 }
822 out
823 }
824
825 fn body(&mut self) -> Vec<Value> {
826 let Some(body) = self.json.get("body") else {
827 return Vec::new();
828 };
829 self.visited.insert("#/body".to_string());
830 let mut blocks = self.blocks_of(body);
831 let notes = self.unplaced_notes();
832 blocks.extend(notes);
833 blocks
834 }
835
836 fn unplaced_notes(&mut self) -> Vec<Value> {
842 let notes: Vec<&'a Value> = Self::array(self.json.get("texts"))
843 .filter(|t| Self::label(t) == "footnote" && !Self::is_note_body(t))
844 .filter(|t| t.get("content_layer").and_then(Value::as_str) == Some("furniture"))
845 .filter(|t| self.excluded(t) && !self.owned_footnotes.contains(Self::self_ref(t)))
846 .collect();
847 let mut out = Vec::new();
848 for t in notes {
849 let (text, _, _) = self.own_inlines(t);
851 if !text.is_empty() {
852 out.push(ast::para(vec![ast::note(vec![ast::para(text)])]));
853 }
854 }
855 out
856 }
857
858 fn serialize(&mut self, item: &'a Value) -> Vec<Value> {
860 let sref = Self::self_ref(item);
861 if Self::is_group(item) {
862 return match Self::label(item) {
863 "comment_section" => Vec::new(),
867 "list" => self.list_group(item),
868 "inline" => {
869 let (inlines, mut rest) = self.inline_group(item);
870 let mut out = Vec::new();
871 if !inlines.is_empty() {
872 out.push(ast::para(inlines));
873 }
874 out.append(&mut rest);
875 out
876 }
877 _ => self.blocks_of(item),
878 };
879 }
880 if sref.starts_with("#/texts/") {
881 if self.owned_captions.contains(sref) || self.owned_footnotes.contains(sref) {
882 return Vec::new();
883 }
884 if self.excluded(item) || Self::is_note_body(item) {
886 return Vec::new();
887 }
888 return self.text_item(item);
889 }
890 if sref.starts_with("#/tables/") {
891 return self.table(item);
892 }
893 if sref.starts_with("#/pictures/") {
894 return self.picture(item);
895 }
896 if sref.starts_with("#/key_value_items/") {
897 return self.graph_item(item, "key-value-region");
898 }
899 if sref.starts_with("#/form_items/") {
900 return self.graph_item(item, "form-container");
901 }
902 if sref.starts_with("#/field_regions/") {
903 return self.field_region(item);
904 }
905 if sref.starts_with("#/field_items/") {
906 let entries = vec![self.field_item(item)];
907 return Self::field_blocks(entries);
908 }
909 Vec::new()
910 }
911
912 fn field_region(&mut self, region: &'a Value) -> Vec<Value> {
916 let mut entries = Vec::new();
917 for cref in Self::children(region) {
918 let Some(child) = self.resolve(cref) else {
919 continue;
920 };
921 if !self.visited.insert(cref.to_string()) {
922 continue;
923 }
924 if cref.starts_with("#/field_items/") {
925 entries.push(self.field_item(child));
926 } else {
927 let mut blocks = if self.excluded(child) {
928 Vec::new()
929 } else {
930 self.serialize(child)
931 };
932 blocks.extend(self.blocks_of(child));
933 entries.push((Vec::new(), blocks));
934 }
935 }
936 let blocks = Self::field_blocks(entries);
937 if blocks.is_empty() {
938 Vec::new()
939 } else {
940 vec![ast::div(&["field-region"], blocks)]
941 }
942 }
943
944 fn field_item(&mut self, field: &'a Value) -> (Vec<Value>, Vec<Value>) {
947 let mut nodes = Vec::new();
948 self.descendants(field, &mut nodes);
949 let (mut term, mut defs) = (Vec::new(), Vec::new());
950 for node in nodes {
951 if !self.visited.insert(Self::self_ref(node).to_string()) {
952 continue;
953 }
954 match Self::label(node) {
955 "marker" | "field_key" => {
956 let inlines = self.formatted(node);
957 if !inlines.is_empty() {
958 term = join_inlines(vec![term, inlines]);
959 }
960 }
961 "field_value" => {
962 let inlines = self.formatted(node);
963 if !inlines.is_empty() {
964 defs.push(ast::plain(inlines));
965 }
966 }
967 _ => defs.extend(self.serialize(node)),
968 }
969 }
970 (term, defs)
971 }
972
973 fn field_blocks(entries: Vec<(Vec<Value>, Vec<Value>)>) -> Vec<Value> {
976 let mut out = Vec::new();
977 let mut run: Vec<(Vec<Value>, Vec<Vec<Value>>)> = Vec::new();
978 for (term, defs) in entries {
979 if term.is_empty() {
980 if !run.is_empty() {
981 out.push(ast::definition_list(std::mem::take(&mut run)));
982 }
983 out.extend(defs);
984 } else {
985 let defs = defs.into_iter().map(|d| vec![d]).collect();
986 run.push((term, defs));
987 }
988 }
989 if !run.is_empty() {
990 out.push(ast::definition_list(run));
991 }
992 out
993 }
994
995 fn own_inlines(&mut self, item: &'a Value) -> (Vec<Value>, Vec<Value>, bool) {
1002 let children = Self::children(item);
1003 if Self::text(item).is_empty() {
1004 if let Some(group) = children
1005 .first()
1006 .and_then(|c| self.resolve(c))
1007 .filter(|c| Self::is_group(c) && Self::label(c) == "inline")
1008 {
1009 self.visited.insert(Self::self_ref(group).to_string());
1010 let (inlines, rest) = self.inline_group(group);
1011 return (inlines, rest, true);
1012 }
1013 }
1014 (self.formatted(item), Vec::new(), false)
1015 }
1016
1017 fn with_comments(&mut self, item: &'a Value, inlines: Vec<Value>) -> Vec<Value> {
1026 let refs = Self::refs(item.get("comments"));
1027 if refs.is_empty() {
1028 return inlines;
1029 }
1030 let mut starts = Vec::new();
1031 let mut ends = Vec::new();
1032 for cref in refs {
1033 let Some(group) = self.resolve(cref) else {
1034 continue;
1035 };
1036 if !Self::is_group(group) || Self::label(group) != "comment_section" {
1037 continue;
1038 }
1039 self.visited.insert(cref.to_string());
1040 let id = group
1041 .get("name")
1042 .and_then(Value::as_str)
1043 .and_then(|n| n.strip_prefix("comment-"))
1044 .unwrap_or("0")
1045 .to_string();
1046 let mut k = 0;
1049 for r in Self::children(group) {
1050 self.visited.insert(r.to_string());
1051 let Some(node) = self.resolve(r).filter(|_| r.starts_with("#/texts/")) else {
1052 continue;
1053 };
1054 let (author, date, text) = split_comment(Self::text(node));
1055 let comment_id = if k == 0 {
1056 id.clone()
1057 } else {
1058 format!("{id}-{k}")
1059 };
1060 k += 1;
1061 let mut attrs = vec![("comment-id".to_string(), comment_id.clone())];
1062 if let Some(a) = author {
1063 attrs.push(("author".to_string(), a));
1064 }
1065 if let Some(d) = date {
1066 attrs.push(("date".to_string(), d));
1067 }
1068 starts.push(ast::span(&["comment-start"], &attrs, text_inlines(&text)));
1069 ends.push(ast::span(
1070 &["comment-end"],
1071 &[("comment-id".to_string(), comment_id)],
1072 Vec::new(),
1073 ));
1074 }
1075 }
1076 if starts.is_empty() {
1077 return inlines;
1078 }
1079 let mut out = starts;
1080 out.extend(inlines);
1081 out.extend(ends);
1082 out
1083 }
1084
1085 fn formatted(&self, item: &Value) -> Vec<Value> {
1088 let base = match Self::label(item) {
1089 "code" => vec![ast::code(Self::text(item))],
1090 "formula" if !Self::text(item).is_empty() => {
1091 vec![ast::math(false, Self::text(item).trim())]
1092 }
1093 _ => match item.get("_notes").and_then(Value::as_array) {
1094 Some(notes) if !notes.is_empty() => text_with_notes(Self::text(item), notes),
1095 _ => text_inlines(Self::text(item)),
1096 },
1097 };
1098 Self::decorate(base, item)
1099 }
1100
1101 fn is_note_body(item: &Value) -> bool {
1104 item.get("_note_body").and_then(Value::as_bool) == Some(true)
1105 }
1106
1107 fn decorate(mut inlines: Vec<Value>, item: &Value) -> Vec<Value> {
1108 if inlines.is_empty() {
1109 return inlines;
1110 }
1111 if let Some(f) = item.get("formatting") {
1112 let on = |k: &str| f.get(k).and_then(Value::as_bool).unwrap_or(false);
1113 if on("bold") {
1114 inlines = vec![ast::wrap("Strong", inlines)];
1115 }
1116 if on("italic") {
1117 inlines = vec![ast::wrap("Emph", inlines)];
1118 }
1119 if on("underline") {
1120 inlines = vec![ast::wrap("Underline", inlines)];
1121 }
1122 if on("strikethrough") {
1123 inlines = vec![ast::wrap("Strikeout", inlines)];
1124 }
1125 match f.get("script").and_then(Value::as_str) {
1126 Some("sub") => inlines = vec![ast::wrap("Subscript", inlines)],
1127 Some("super") => inlines = vec![ast::wrap("Superscript", inlines)],
1128 _ => {}
1129 }
1130 }
1131 if let Some(url) = item.get("hyperlink").and_then(Value::as_str) {
1132 inlines = vec![ast::link(inlines, url)];
1133 }
1134 inlines
1135 }
1136
1137 fn inline_group(&mut self, group: &'a Value) -> (Vec<Value>, Vec<Value>) {
1141 let mut nodes = Vec::new();
1142 self.descendants(group, &mut nodes);
1143 let mut runs: Vec<Vec<Value>> = Vec::new();
1144 let mut rest: Vec<Value> = Vec::new();
1145 for node in nodes {
1146 let r = Self::self_ref(node).to_string();
1147 if self.visited.contains(&r) {
1148 continue;
1149 }
1150 self.visited.insert(r.clone());
1151 if r.starts_with("#/texts/") {
1152 if self.owned_captions.contains(&r)
1153 || self.owned_footnotes.contains(&r)
1154 || Self::is_note_body(node)
1155 {
1156 continue;
1157 }
1158 runs.push(self.formatted(node));
1159 continue;
1160 }
1161 if Self::is_group(node) && Self::label(node) == "inline" {
1162 let (inl, mut more) = self.inline_group(node);
1163 runs.push(inl);
1164 rest.append(&mut more);
1165 continue;
1166 }
1167 rest.extend(self.serialize(node));
1168 }
1169 (join_inlines(runs), rest)
1170 }
1171
1172 fn text_item(&mut self, item: &'a Value) -> Vec<Value> {
1173 let label = Self::label(item);
1174 let mut out = Vec::new();
1175 match label {
1176 "list_item" => {
1177 let blocks = self.list_item_blocks(item);
1179 if !blocks.is_empty() {
1180 out.push(ast::bullet_list(vec![blocks]));
1181 }
1182 return out;
1183 }
1184 "code" => {
1185 let lang = item
1186 .get("code_language")
1187 .and_then(Value::as_str)
1188 .filter(|l| !l.is_empty() && *l != "unknown")
1189 .map(str::to_ascii_lowercase);
1190 out.push(ast::code_block(lang.as_deref(), Self::text(item)));
1191 }
1192 "formula" => {
1193 let tex = Self::text(item).trim();
1194 if !tex.is_empty() {
1195 out.push(ast::para(vec![ast::math(true, tex)]));
1196 }
1197 }
1198 _ => {
1199 let (inlines, rest, _) = self.own_inlines(item);
1200 let mut inlines = self.with_comments(item, inlines);
1201 match label {
1202 "title" | "section_header" => {
1203 let level = if label == "title" {
1204 1
1205 } else {
1206 (item.get("level").and_then(Value::as_u64).unwrap_or(1) as usize + 1)
1207 .min(6)
1208 };
1209 if !inlines.is_empty() {
1210 out.push(ast::header(level, inlines));
1211 }
1212 }
1213 "text" | "paragraph" => {
1214 if !inlines.is_empty() {
1215 out.push(ast::para(inlines));
1216 }
1217 }
1218 "checkbox_selected" | "checkbox_unselected" => {
1219 let mark = if label == "checkbox_selected" {
1220 "☒"
1221 } else {
1222 "☐"
1223 };
1224 let mut with_mark = vec![ast::str_(mark)];
1225 if !inlines.is_empty() {
1226 with_mark.push(ast::space());
1227 with_mark.append(&mut inlines);
1228 }
1229 out.push(ast::para(with_mark));
1230 }
1231 other => {
1232 if !inlines.is_empty() {
1233 let class = format!("docling-{other}");
1234 out.push(ast::div(&[&class], vec![ast::para(inlines)]));
1235 }
1236 }
1237 }
1238 out.extend(rest);
1239 }
1240 }
1241 out.extend(self.blocks_of(item));
1243 out
1244 }
1245
1246 fn list_item_blocks(&mut self, item: &'a Value) -> Vec<Value> {
1249 let (inlines, rest, _) = self.own_inlines(item);
1250 let inlines = self.with_comments(item, inlines);
1251 let mut blocks = Vec::new();
1252 if !inlines.is_empty() {
1253 blocks.push(ast::plain(inlines));
1254 }
1255 blocks.extend(rest);
1256 blocks.extend(self.blocks_of(item));
1257 blocks
1258 }
1259
1260 fn list_group(&mut self, group: &'a Value) -> Vec<Value> {
1261 let first = Self::children(group)
1262 .first()
1263 .and_then(|r| self.resolve(r))
1264 .filter(|c| Self::label(c) == "list_item");
1265 let enumerated = first.is_some_and(|c| {
1266 c.get("enumerated")
1267 .and_then(Value::as_bool)
1268 .unwrap_or(false)
1269 });
1270 let markers: Vec<&str> = Self::children(group)
1273 .iter()
1274 .filter_map(|r| self.resolve(r))
1275 .filter(|c| Self::label(c) == "list_item")
1276 .filter_map(|c| c.get("marker").and_then(Value::as_str))
1277 .collect();
1278 let (start, style, delim) = list_attributes(&markers);
1279
1280 let mut nodes = Vec::new();
1281 self.descendants(group, &mut nodes);
1282 let mut items: Vec<Vec<Value>> = Vec::new();
1283 for node in nodes {
1284 let r = Self::self_ref(node).to_string();
1285 if !self.visited.insert(r.clone()) {
1286 continue;
1287 }
1288 if Self::label(node) == "list_item"
1289 && r.starts_with("#/texts/")
1290 && !self.owned_captions.contains(&r)
1291 {
1292 let blocks = self.list_item_blocks(node);
1293 items.push(blocks);
1294 continue;
1295 }
1296 let blocks = self.serialize(node);
1299 if blocks.is_empty() {
1300 continue;
1301 }
1302 match items.last_mut() {
1303 Some(last) => last.extend(blocks),
1304 None => items.push(blocks),
1305 }
1306 }
1307 if items.is_empty() {
1308 return Vec::new();
1309 }
1310 if enumerated {
1311 vec![ast::ordered_list(start, style, delim, items)]
1312 } else {
1313 vec![ast::bullet_list(items)]
1314 }
1315 }
1316
1317 fn caption(&mut self, item: &'a Value) -> Value {
1323 let mut inlines = self.caption_text(item);
1324 for cref in Self::refs(item.get("footnotes")) {
1325 if let Some(note) = self.resolve(cref) {
1326 if cref.starts_with("#/texts/") && !self.excluded(note) {
1327 let (text, _, _) = self.own_inlines(note);
1328 if !text.is_empty() {
1329 inlines.push(ast::note(vec![ast::para(text)]));
1330 }
1331 }
1332 }
1333 }
1334 if inlines.is_empty() {
1335 ast::caption(Vec::new())
1336 } else {
1337 ast::caption(vec![ast::plain(inlines)])
1338 }
1339 }
1340
1341 fn caption_text(&mut self, item: &'a Value) -> Vec<Value> {
1344 let caps: Vec<&'a Value> = Self::refs(item.get("captions"))
1345 .into_iter()
1346 .filter(|cref| cref.starts_with("#/texts/"))
1347 .filter_map(|cref| self.resolve(cref))
1348 .filter(|cap| !self.excluded(cap))
1349 .collect();
1350 let mut parts = Vec::new();
1351 for cap in caps {
1352 let (inlines, _, _) = self.own_inlines(cap);
1353 parts.push(inlines);
1354 }
1355 join_inlines(parts)
1356 }
1357
1358 fn grid(data: &'a Value) -> (usize, Vec<Vec<Option<GridCell<'a>>>>) {
1360 let num_rows = data.get("num_rows").and_then(Value::as_u64).unwrap_or(0) as usize;
1361 let num_cols = data.get("num_cols").and_then(Value::as_u64).unwrap_or(0) as usize;
1362 let mut grid: Vec<Vec<Option<GridCell<'a>>>> = (0..num_rows)
1363 .map(|_| (0..num_cols).map(|_| None).collect())
1364 .collect();
1365 for cell in Self::array(data.get("table_cells")) {
1366 let get = |k: &str| cell.get(k).and_then(Value::as_u64).unwrap_or(0) as usize;
1367 let (r0, c0) = (get("start_row_offset_idx"), get("start_col_offset_idx"));
1368 let (r1, c1) = (get("end_row_offset_idx"), get("end_col_offset_idx"));
1369 if r0 >= num_rows || c0 >= num_cols {
1370 continue;
1371 }
1372 let (r1, c1) = (r1.clamp(r0 + 1, num_rows), c1.clamp(c0 + 1, num_cols));
1373 for row in grid.iter_mut().take(r1).skip(r0) {
1374 for slot in row.iter_mut().take(c1).skip(c0) {
1375 *slot = Some(GridCell {
1376 cell,
1377 start_row: r0,
1378 start_col: c0,
1379 row_span: r1 - r0,
1380 col_span: c1 - c0,
1381 });
1382 }
1383 }
1384 }
1385 (num_cols, grid)
1386 }
1387
1388 fn table_from_data(&mut self, data: &'a Value, caption: Value) -> Option<Value> {
1389 let (num_cols, grid) = Self::grid(data);
1390 if grid.is_empty() || num_cols == 0 {
1391 return None;
1392 }
1393 let flag = |c: &Value, k: &str| c.get(k).and_then(Value::as_bool).unwrap_or(false);
1394 let mut rows: Vec<(bool, Value)> = Vec::new();
1395 for (i, row) in grid.iter().enumerate() {
1396 let mut cells = Vec::new();
1397 let mut all_header = true;
1398 let mut anchored = 0;
1399 for (j, slot) in row.iter().enumerate() {
1400 let Some(g) = slot else {
1401 cells.push(ast::cell(1, 1, Vec::new()));
1404 continue;
1405 };
1406 if g.start_row != i || g.start_col != j {
1407 continue;
1408 }
1409 anchored += 1;
1410 all_header &= flag(g.cell, "column_header");
1411 let blocks = match g
1412 .cell
1413 .get("ref")
1414 .and_then(|r| r.get("$ref"))
1415 .and_then(Value::as_str)
1416 {
1417 Some(cref) => match self.resolve(cref) {
1418 Some(target) => {
1419 self.visited.insert(cref.to_string());
1420 self.serialize(target)
1421 }
1422 None => Vec::new(),
1423 },
1424 None => {
1425 let inl =
1426 text_inlines(g.cell.get("text").and_then(Value::as_str).unwrap_or(""));
1427 if inl.is_empty() {
1428 Vec::new()
1429 } else {
1430 vec![ast::plain(inl)]
1431 }
1432 }
1433 };
1434 cells.push(ast::cell(g.row_span, g.col_span, blocks));
1435 }
1436 rows.push((all_header && anchored > 0, ast::row(cells)));
1437 }
1438 let head_len = rows.iter().take_while(|(h, _)| *h).count();
1440 let head_len = if head_len == rows.len() { 0 } else { head_len };
1441 let mut head = Vec::new();
1442 let mut body = Vec::new();
1443 for (i, (_, row)) in rows.into_iter().enumerate() {
1444 if i < head_len {
1445 head.push(row);
1446 } else {
1447 body.push(row);
1448 }
1449 }
1450 Some(ast::table(caption, num_cols, head, body))
1451 }
1452
1453 fn table(&mut self, item: &'a Value) -> Vec<Value> {
1454 let caption = self.caption(item);
1455 if self.excluded(item) {
1456 return Vec::new();
1457 }
1458 match item.get("data") {
1459 Some(data) => self.table_from_data(data, caption).into_iter().collect(),
1460 None => Vec::new(),
1461 }
1462 }
1463
1464 fn picture(&mut self, item: &'a Value) -> Vec<Value> {
1465 if self.excluded(item) {
1466 return Vec::new();
1467 }
1468 let caption = self.caption(item);
1469 let has_caption = caption
1470 .get(1)
1471 .and_then(Value::as_array)
1472 .is_some_and(|b| !b.is_empty());
1473 let uri = item
1474 .get("image")
1475 .and_then(|i| i.get("uri"))
1476 .and_then(Value::as_str);
1477 let src = match (self.image_mode, uri) {
1478 (ImageMode::Embedded, Some(uri)) if uri.starts_with("data:") => Some(uri.to_string()),
1479 (ImageMode::Referenced, Some(uri)) => self
1480 .reference_image(uri)
1481 .or_else(|| (!uri.starts_with("data:")).then(|| uri.to_string())),
1482 _ => None,
1483 };
1484 let description = item
1493 .get("meta")
1494 .and_then(|m| m.get("description"))
1495 .and_then(|d| d.get("text"))
1496 .and_then(Value::as_str)
1497 .map(text_inlines)
1498 .filter(|t| !t.is_empty());
1499 let alt = match description {
1500 Some(alt) => alt,
1501 None => self.caption_text(item),
1502 };
1503 let attrs: Vec<(String, String)> = item
1506 .get("image")
1507 .and_then(|i| i.get("_display"))
1508 .and_then(Value::as_array)
1509 .and_then(|d| Some((d.first()?.as_f64()?, d.get(1)?.as_f64()?)))
1510 .map(|(w, h)| {
1511 vec![
1512 ("width".to_string(), inches(w)),
1513 ("height".to_string(), inches(h)),
1514 ]
1515 })
1516 .unwrap_or_default();
1517 let image = match &src {
1518 Some(src) => ast::image_sized(&[], &attrs, alt, src),
1519 None => ast::image_sized(&["docling-placeholder"], &attrs, alt, ""),
1520 };
1521 let chart = item
1523 .get("meta")
1524 .and_then(|m| m.get("tabular_chart"))
1525 .and_then(|t| t.get("chart_data"))
1526 .and_then(|chart| self.table_from_data(chart, ast::caption(Vec::new())));
1527 if chart.is_none() && !has_caption {
1528 return vec![ast::para(vec![image])];
1530 }
1531 let mut blocks = vec![ast::plain(vec![image])];
1532 blocks.extend(chart);
1533 vec![ast::figure(&[], caption, blocks)]
1534 }
1535
1536 fn reference_image(&mut self, uri: &str) -> Option<String> {
1539 let rest = uri.strip_prefix("data:")?;
1540 let (mime, payload) = rest.split_once(";base64,")?;
1541 let bytes = crate::base64::decode(payload)?;
1542 let path = format!(
1543 "{}/image_{:06}.{}",
1544 self.artifacts_dir,
1545 self.pic_index,
1546 crate::markdown::ext_for(mime)
1547 );
1548 self.pic_index += 1;
1549 self.artifacts.push((path.clone(), bytes));
1550 Some(path)
1551 }
1552
1553 fn graph_item(&mut self, item: &'a Value, class: &str) -> Vec<Value> {
1558 let mut out = Vec::new();
1559 if !self.excluded(item) {
1560 if let Some(graph) = item.get("graph") {
1561 if let Some(block) = Self::graph(graph) {
1562 out.push(ast::div(&[class], vec![block]));
1563 }
1564 }
1565 }
1566 let caption = self.caption(item);
1568 for block in caption
1569 .get(1)
1570 .and_then(Value::as_array)
1571 .into_iter()
1572 .flatten()
1573 {
1574 if let Some(inlines) = block.get("c").and_then(Value::as_array) {
1575 out.push(ast::para(inlines.clone()));
1576 }
1577 }
1578 out
1579 }
1580
1581 fn graph(graph: &'a Value) -> Option<Value> {
1582 let cells: HashMap<u64, &Value> = Self::array(graph.get("cells"))
1583 .filter_map(|c| Some((c.get("cell_id")?.as_u64()?, c)))
1584 .collect();
1585 let order: Vec<u64> = Self::array(graph.get("cells"))
1586 .filter_map(|c| c.get("cell_id")?.as_u64())
1587 .collect();
1588 let mut child_links: HashMap<u64, Vec<u64>> = HashMap::new();
1589 let mut value_links: BTreeMap<u64, Vec<u64>> = BTreeMap::new();
1590 let mut value_order: Vec<u64> = Vec::new();
1591 let mut parents: HashSet<u64> = HashSet::new();
1592 for link in Self::array(graph.get("links")) {
1593 let (Some(s), Some(t)) = (
1594 link.get("source_cell_id").and_then(Value::as_u64),
1595 link.get("target_cell_id").and_then(Value::as_u64),
1596 ) else {
1597 continue;
1598 };
1599 if !cells.contains_key(&s) || !cells.contains_key(&t) {
1600 continue;
1601 }
1602 match link.get("label").and_then(Value::as_str) {
1603 Some("to_child") => {
1604 child_links.entry(s).or_default().push(t);
1605 parents.insert(t);
1606 }
1607 Some("to_value") => {
1608 if !value_links.contains_key(&s) {
1609 value_order.push(s);
1610 }
1611 value_links.entry(s).or_default().push(t);
1612 }
1613 _ => {}
1614 }
1615 }
1616 let cell_text =
1617 |id: u64| text_inlines(cells[&id].get("text").and_then(Value::as_str).unwrap_or(""));
1618 if child_links.is_empty() {
1619 if value_order.is_empty() {
1620 return None;
1621 }
1622 let entries = value_order
1623 .iter()
1624 .map(|&k| {
1625 let defs = value_links[&k]
1626 .iter()
1627 .map(|&v| vec![ast::plain(cell_text(v))])
1628 .collect();
1629 (cell_text(k), defs)
1630 })
1631 .collect();
1632 return Some(ast::definition_list(entries));
1633 }
1634 fn node(
1636 id: u64,
1637 cell_text: &dyn Fn(u64) -> Vec<Value>,
1638 child_links: &HashMap<u64, Vec<u64>>,
1639 value_links: &BTreeMap<u64, Vec<u64>>,
1640 path: &mut HashSet<u64>,
1641 ) -> Option<Vec<Value>> {
1642 if !path.insert(id) {
1643 return None;
1644 }
1645 let mut inlines = cell_text(id);
1646 if let Some(values) = value_links.get(&id) {
1647 let vals = join_inlines(values.iter().map(|&v| cell_text(v)).collect());
1648 inlines = vec![ast::wrap("Strong", inlines)];
1649 inlines.push(ast::str_(":"));
1650 if !vals.is_empty() {
1651 inlines.push(ast::space());
1652 inlines.extend(vals);
1653 }
1654 }
1655 let mut blocks = vec![ast::plain(inlines)];
1656 let kids: Vec<Vec<Value>> = child_links
1657 .get(&id)
1658 .into_iter()
1659 .flatten()
1660 .filter_map(|&c| node(c, cell_text, child_links, value_links, path))
1661 .collect();
1662 if !kids.is_empty() {
1663 blocks.push(ast::bullet_list(kids));
1664 }
1665 path.remove(&id);
1666 Some(blocks)
1667 }
1668 let mut path = HashSet::new();
1669 let items: Vec<Vec<Value>> = order
1670 .iter()
1671 .filter(|id| !parents.contains(id))
1672 .filter_map(|&id| node(id, &cell_text, &child_links, &value_links, &mut path))
1673 .collect();
1674 (!items.is_empty()).then(|| ast::bullet_list(items))
1675 }
1676}
1677
1678#[cfg(test)]
1679mod tests {
1680 use super::*;
1681 use serde_json::json;
1682
1683 fn doc(body: &[&str], texts: Value, groups: Value, tables: Value) -> Value {
1685 json!({
1686 "schema_name": "DoclingDocument",
1687 "name": "t",
1688 "body": {"self_ref": "#/body", "children": body.iter().map(|r| json!({"$ref": r})).collect::<Vec<_>>()},
1689 "texts": texts, "groups": groups, "tables": tables, "pictures": []
1690 })
1691 }
1692
1693 fn text(i: usize, label: &str, text: &str, extra: Value) -> Value {
1694 let mut v = json!({"self_ref": format!("#/texts/{i}"), "children": [], "label": label, "text": text, "content_layer": "body"});
1695 for (k, x) in extra.as_object().unwrap() {
1696 v[k] = x.clone();
1697 }
1698 v
1699 }
1700
1701 fn blocks(json: &Value) -> Vec<Value> {
1702 let (s, _) = from_docling_json(json, &PandocExportOptions::default()).unwrap();
1703 let v: Value = serde_json::from_str(&s).unwrap();
1704 assert_eq!(v["pandoc-api-version"], json!(PANDOC_API_VERSION));
1705 v["blocks"].as_array().unwrap().clone()
1706 }
1707
1708 #[test]
1712 fn note_calls_become_notes_at_their_call_sites() {
1713 let j = doc(
1714 &["#/texts/0", "#/texts/1"],
1715 json!([
1716 text(
1717 0,
1718 "text",
1719 "debut suite end",
1720 json!({"_notes": [[5, "first"], [15, "last"]]})
1721 ),
1722 text(
1723 1,
1724 "footnote",
1725 "first",
1726 json!({"content_layer": "furniture", "_note_body": true})
1727 ),
1728 ]),
1729 json!([]),
1730 json!([]),
1731 );
1732 let note = |t: &str| ast::note(vec![ast::para(vec![ast::str_(t)])]);
1733 assert_eq!(
1734 blocks(&j),
1735 vec![ast::para(vec![
1736 ast::str_("debut"),
1737 note("first"),
1738 ast::space(),
1739 ast::str_("suite"),
1740 ast::space(),
1741 ast::str_("end"),
1742 note("last"),
1743 ])]
1744 );
1745 let opts = PandocExportOptions {
1747 layers: ContentLayers::ALL,
1748 ..Default::default()
1749 };
1750 let (s, _) = from_docling_json(&j, &opts).unwrap();
1751 assert!(!s.contains("docling-footnote"), "{s}");
1752 }
1753
1754 #[test]
1757 fn uncalled_furniture_footnotes_trail_as_notes() {
1758 let j = doc(
1759 &["#/texts/0", "#/texts/1"],
1760 json!([
1761 text(0, "text", "body", json!({})),
1762 text(
1763 1,
1764 "footnote",
1765 "MARKFN",
1766 json!({"content_layer": "furniture"})
1767 ),
1768 ]),
1769 json!([]),
1770 json!([]),
1771 );
1772 assert_eq!(
1773 blocks(&j),
1774 vec![
1775 ast::para(vec![ast::str_("body")]),
1776 ast::para(vec![ast::note(vec![ast::para(vec![ast::str_("MARKFN")])])]),
1777 ]
1778 );
1779 }
1780
1781 #[test]
1786 fn pictures_are_always_images() {
1787 let mut j = doc(
1788 &["#/pictures/0", "#/pictures/1", "#/pictures/2"],
1789 json!([text(0, "caption", "A duck", json!({})),]),
1790 json!([]),
1791 json!([]),
1792 );
1793 j["pictures"] = json!([
1794 {"self_ref": "#/pictures/0", "children": [], "label": "picture", "content_layer": "body",
1795 "image": {"uri": "data:image/png;base64,AAAA"}, "captions": []},
1796 {"self_ref": "#/pictures/1", "children": [], "label": "picture", "content_layer": "body",
1797 "image": {"uri": "data:image/png;base64,BBBB"}, "captions": [{"$ref": "#/texts/0"}]},
1798 {"self_ref": "#/pictures/2", "children": [], "label": "picture", "content_layer": "body",
1799 "captions": []},
1800 ]);
1801 let b = blocks(&j);
1802 assert_eq!(
1803 b[0],
1804 ast::para(vec![ast::image(&[], vec![], "data:image/png;base64,AAAA")])
1805 );
1806 assert_eq!(
1807 b[1],
1808 ast::figure(
1809 &[],
1810 ast::caption(vec![ast::plain(vec![
1811 ast::str_("A"),
1812 ast::space(),
1813 ast::str_("duck")
1814 ])]),
1815 vec![ast::plain(vec![ast::image(
1816 &[],
1817 vec![ast::str_("A"), ast::space(), ast::str_("duck")],
1818 "data:image/png;base64,BBBB"
1819 )])]
1820 )
1821 );
1822 assert_eq!(
1823 b[2],
1824 ast::para(vec![ast::image(&["docling-placeholder"], vec![], "")])
1825 );
1826 assert_eq!(b.len(), 3);
1827 let opts = PandocExportOptions {
1829 image_mode: ImageMode::Placeholder,
1830 ..Default::default()
1831 };
1832 let (s, _) = from_docling_json(&j, &opts).unwrap();
1833 assert!(!s.contains("base64") && !s.contains("RawBlock"), "{s}");
1834 assert_eq!(s.matches("docling-placeholder").count(), 3, "{s}");
1835 }
1836
1837 #[test]
1844 fn captions_headings_and_items_keep_their_inline_groups() {
1845 let mut j = doc(
1846 &["#/texts/0", "#/tables/0", "#/texts/3", "#/groups/2"],
1847 json!([
1848 text(
1849 0,
1850 "caption",
1851 "",
1852 json!({"children": [{"$ref": "#/groups/0"}]})
1853 ),
1854 text(1, "text", "Table 1:", json!({"formatting": {"bold": true}})),
1855 text(
1856 2,
1857 "text",
1858 "Multi-run caption",
1859 json!({"hyperlink": "https://x.org/c"})
1860 ),
1861 text(
1862 3,
1863 "section_header",
1864 "",
1865 json!({"level": 1, "children": [{"$ref": "#/groups/1"}, {"$ref": "#/texts/6"}]})
1866 ),
1867 text(4, "text", "1.1", json!({"formatting": {"bold": true}})),
1868 text(5, "text", "Formatted header", json!({})),
1869 text(6, "text", "Body under header.", json!({})),
1870 text(
1871 7,
1872 "list_item",
1873 "",
1874 json!({"children": [{"$ref": "#/groups/3"}, {"$ref": "#/groups/4"}]})
1875 ),
1876 text(8, "text", "Bold run", json!({"formatting": {"bold": true}})),
1877 text(9, "text", "and tail", json!({})),
1878 text(10, "list_item", "Nested child", json!({})),
1879 text(
1880 11,
1881 "caption",
1882 "Linked bold",
1883 json!({"formatting": {"bold": true}, "hyperlink": "https://x.org/spec"})
1884 ),
1885 ]),
1886 json!([
1887 {"self_ref": "#/groups/0", "children": [{"$ref": "#/texts/1"}, {"$ref": "#/texts/2"}], "label": "inline", "name": "group"},
1888 {"self_ref": "#/groups/1", "children": [{"$ref": "#/texts/4"}, {"$ref": "#/texts/5"}], "label": "inline", "name": "group"},
1889 {"self_ref": "#/groups/2", "children": [{"$ref": "#/texts/7"}], "label": "list", "name": "list"},
1890 {"self_ref": "#/groups/3", "children": [{"$ref": "#/texts/8"}, {"$ref": "#/texts/9"}], "label": "inline", "name": "group"},
1891 {"self_ref": "#/groups/4", "children": [{"$ref": "#/texts/10"}], "label": "list", "name": "list"},
1892 ]),
1893 json!([{"self_ref": "#/tables/0", "children": [], "label": "table",
1894 "captions": [{"$ref": "#/texts/0"}, {"$ref": "#/texts/11"}],
1895 "data": {"num_rows": 1, "num_cols": 1, "table_cells": [
1896 {"text": "v", "row_span": 1, "col_span": 1, "start_row_offset_idx": 0, "end_row_offset_idx": 1,
1897 "start_col_offset_idx": 0, "end_col_offset_idx": 1, "column_header": false}]}}]),
1898 );
1899 j["texts"][0]["children"] = json!([{"$ref": "#/groups/0"}]);
1900 let b = blocks(&j);
1901 assert_eq!(b.len(), 4, "{b:?}");
1902 assert_eq!(b[0]["t"], "Table");
1903 let caption = &b[0]["c"][1][1][0];
1904 assert_eq!(caption["t"], "Plain");
1905 assert_eq!(
1906 caption["c"][0],
1907 ast::wrap(
1908 "Strong",
1909 vec![ast::str_("Table"), ast::space(), ast::str_("1:")]
1910 )
1911 );
1912 assert_eq!(caption["c"][2]["t"], "Link");
1913 let linked = caption["c"].as_array().unwrap().last().unwrap();
1914 assert_eq!(linked["t"], "Link");
1915 assert_eq!(linked["c"][1][0]["t"], "Strong");
1916 assert_eq!(b[1]["t"], "Header");
1917 assert_eq!(b[1]["c"][2][0], ast::wrap("Strong", vec![ast::str_("1.1")]));
1918 assert_eq!(
1919 b[2],
1920 ast::para(vec![
1921 ast::str_("Body"),
1922 ast::space(),
1923 ast::str_("under"),
1924 ast::space(),
1925 ast::str_("header.")
1926 ])
1927 );
1928 assert_eq!(b[3]["t"], "BulletList");
1929 let item = b[3]["c"][0].as_array().unwrap();
1930 assert_eq!(item.len(), 2);
1931 assert_eq!(item[0]["t"], "Plain");
1932 assert_eq!(item[0]["c"][0]["t"], "Strong");
1933 assert_eq!(item[1]["t"], "BulletList");
1934 }
1935
1936 #[test]
1940 fn pictures_carry_alt_text_and_display_size() {
1941 let mut j = doc(
1942 &["#/pictures/0", "#/pictures/1"],
1943 json!([text(0, "caption", "A duck", json!({})),]),
1944 json!([]),
1945 json!([]),
1946 );
1947 j["pictures"] = json!([
1948 {"self_ref": "#/pictures/0", "children": [], "label": "picture", "content_layer": "body",
1949 "meta": {"description": {"text": "Architecture diagram", "created_by": "alt_text"}},
1950 "image": {"uri": "data:image/png;base64,AAAA", "_display": [2.5, 1.25]}, "captions": [{"$ref": "#/texts/0"}]},
1951 {"self_ref": "#/pictures/1", "children": [], "label": "picture", "content_layer": "body",
1952 "image": {"uri": "data:image/png;base64,BBBB"}, "captions": [{"$ref": "#/texts/0"}]},
1953 ]);
1954 let b = blocks(&j);
1955 let image = &b[0]["c"][2][0]["c"][0];
1956 assert_eq!(image["t"], "Image");
1957 assert_eq!(
1958 image["c"][0],
1959 json!(["", [], [["width", "2.5in"], ["height", "1.25in"]]])
1960 );
1961 assert_eq!(
1962 image["c"][1],
1963 json!([
1964 ast::str_("Architecture"),
1965 ast::space(),
1966 ast::str_("diagram")
1967 ])
1968 );
1969 let image = &b[1]["c"][2][0]["c"][0];
1970 assert_eq!(image["c"][0], json!(["", [], []]));
1971 assert_eq!(
1972 image["c"][1],
1973 json!([ast::str_("A"), ast::space(), ast::str_("duck")])
1974 );
1975 assert_eq!(inches(2.5), "2.5in");
1976 assert_eq!(inches(1.0), "1in");
1977 assert_eq!(inches(0.33333), "0.3333in");
1978 }
1979
1980 #[test]
1982 fn list_attributes_follow_the_markers() {
1983 assert_eq!(
1984 list_attributes(&["a)", "b)"]),
1985 (1, "LowerAlpha", "OneParen")
1986 );
1987 assert_eq!(
1988 list_attributes(&["iii.", "iv."]),
1989 (3, "LowerRoman", "Period")
1990 );
1991 assert_eq!(
1992 list_attributes(&["(3)", "(4)"]),
1993 (3, "Decimal", "TwoParens")
1994 );
1995 assert_eq!(
1996 list_attributes(&["c)", "d)"]),
1997 (3, "LowerAlpha", "OneParen")
1998 );
1999 assert_eq!(list_attributes(&["i."]), (1, "LowerRoman", "Period"));
2000 assert_eq!(list_attributes(&["v.", "vi."]), (5, "LowerRoman", "Period"));
2001 assert_eq!(list_attributes(&["x.", "y."]), (24, "LowerAlpha", "Period"));
2002 assert_eq!(list_attributes(&["B."]), (2, "UpperAlpha", "Period"));
2003 assert_eq!(list_attributes(&["IV)"]), (4, "UpperRoman", "OneParen"));
2004 assert_eq!(list_attributes(&["aa)"]), (27, "LowerAlpha", "OneParen"));
2005 assert_eq!(list_attributes(&["12"]), (12, "Decimal", "DefaultDelim"));
2006 assert_eq!(list_attributes(&["3."]), (3, "Decimal", "Period"));
2007 assert_eq!(list_attributes(&["1.2."]), (1, "Decimal", "Period"));
2008 assert_eq!(list_attributes(&[]), (1, "Decimal", "Period"));
2009 let j = doc(
2010 &["#/groups/0"],
2011 json!([
2012 text(
2013 0,
2014 "list_item",
2015 "third",
2016 json!({"enumerated": true, "marker": "(3)"})
2017 ),
2018 text(
2019 1,
2020 "list_item",
2021 "fourth",
2022 json!({"enumerated": true, "marker": "(4)"})
2023 ),
2024 ]),
2025 json!([{"self_ref": "#/groups/0", "children": [{"$ref": "#/texts/0"}, {"$ref": "#/texts/1"}], "label": "list", "name": "list"}]),
2026 json!([]),
2027 );
2028 let b = blocks(&j);
2029 assert_eq!(
2030 b[0]["c"][0],
2031 json!([3, {"t": "Decimal"}, {"t": "TwoParens"}])
2032 );
2033 }
2034
2035 #[test]
2040 fn metadata_and_comments_reach_the_ast() {
2041 let mut j = doc(
2042 &["#/texts/0", "#/groups/0"],
2043 json!([
2044 text(
2045 0,
2046 "text",
2047 "The timeout is 30 s.",
2048 json!({"comments": [{"$ref": "#/groups/0"}]})
2049 ),
2050 text(
2051 1,
2052 "text",
2053 "[author: Reviewer (SR), time: 2026-01-15T12:00:00Z]: Verify it.",
2054 json!({"content_layer": "notes"})
2055 ),
2056 text(2, "text", "Agreed", json!({"content_layer": "notes"})),
2057 ]),
2058 json!([{"self_ref": "#/groups/0", "children": [{"$ref": "#/texts/1"}, {"$ref": "#/texts/2"}], "label": "comment_section", "name": "comment-3", "content_layer": "notes"}]),
2059 json!([]),
2060 );
2061 j["metadata"] = json!({
2062 "title": "Synthetic Spec", "authors": ["A. Author", "B. Author"], "keywords": ["architecture", "protocol"],
2063 "language": "en-US", "created": "2026-01-15T10:00:00Z", "subject": "Gateways"
2064 });
2065 let (s, _) = from_docling_json(&j, &PandocExportOptions::default()).unwrap();
2066 let v: Value = serde_json::from_str(&s).unwrap();
2067 assert_eq!(
2068 v["meta"]["title"],
2069 ast::meta_inlines(vec![
2070 ast::str_("Synthetic"),
2071 ast::space(),
2072 ast::str_("Spec")
2073 ])
2074 );
2075 assert_eq!(v["meta"]["author"]["t"], "MetaList");
2076 assert_eq!(v["meta"]["author"]["c"].as_array().unwrap().len(), 2);
2077 assert_eq!(
2078 v["meta"]["keywords"]["c"][1],
2079 ast::meta_inlines(vec![ast::str_("protocol")])
2080 );
2081 assert_eq!(v["meta"]["lang"], ast::meta_string("en-US"));
2082 assert_eq!(
2083 v["meta"]["date"],
2084 ast::meta_inlines(vec![ast::str_("2026-01-15T10:00:00Z")])
2085 );
2086 assert_eq!(v["meta"]["subject"]["t"], "MetaInlines");
2087 let b = v["blocks"].as_array().unwrap();
2088 assert_eq!(b.len(), 1, "{b:?}");
2089 let inl = b[0]["c"].as_array().unwrap();
2090 assert_eq!(inl[0]["t"], "Span");
2091 assert_eq!(
2092 inl[0]["c"][0],
2093 json!([
2094 "",
2095 ["comment-start"],
2096 [
2097 ["comment-id", "3"],
2098 ["author", "Reviewer (SR)"],
2099 ["date", "2026-01-15T12:00:00Z"]
2100 ]
2101 ])
2102 );
2103 assert_eq!(
2104 inl[0]["c"][1],
2105 json!([ast::str_("Verify"), ast::space(), ast::str_("it.")])
2106 );
2107 assert_eq!(
2108 inl[1]["c"][0],
2109 json!(["", ["comment-start"], [["comment-id", "3-1"]]])
2110 );
2111 assert_eq!(inl[2], ast::str_("The"));
2112 let last = inl.last().unwrap();
2113 assert_eq!(
2114 last["c"][0],
2115 json!(["", ["comment-end"], [["comment-id", "3-1"]]])
2116 );
2117 assert_eq!(
2118 inl[inl.len() - 2]["c"][0],
2119 json!(["", ["comment-end"], [["comment-id", "3"]]])
2120 );
2121 let opts = PandocExportOptions {
2123 layers: ContentLayers::ALL,
2124 ..Default::default()
2125 };
2126 let (s, _) = from_docling_json(&j, &opts).unwrap();
2127 assert_eq!(s.matches("Verify").count(), 1, "{s}");
2128 assert_eq!(
2129 split_comment("[time: t]: x"),
2130 (None, Some("t".into()), "x".into())
2131 );
2132 assert_eq!(split_comment("bare"), (None, None, "bare".into()));
2133 assert_eq!(meta_of(&json!({})), json!({}));
2134 }
2135
2136 #[test]
2137 fn text_splits_into_words_spaces_and_line_breaks() {
2138 assert_eq!(
2139 text_inlines(" two words\nnext\u{a0}line "),
2140 vec![
2141 ast::str_("two"),
2142 ast::space(),
2143 ast::str_("words"),
2144 ast::line_break(),
2145 ast::str_("next\u{a0}line"),
2146 ]
2147 );
2148 assert!(text_inlines(" ").is_empty());
2149 }
2150
2151 #[test]
2152 fn headings_paragraphs_and_lists() {
2153 let j = doc(
2154 &["#/texts/0", "#/texts/1", "#/texts/2", "#/groups/0"],
2155 json!([
2156 text(0, "title", "Ducks", json!({})),
2157 text(1, "section_header", "Diet", json!({"level": 1})),
2158 text(
2159 2,
2160 "text",
2161 "Ducks eat plants.",
2162 json!({"formatting": {"bold": true}, "hyperlink": "https://x.org"})
2163 ),
2164 text(
2165 3,
2166 "list_item",
2167 "seeds",
2168 json!({"enumerated": true, "marker": "3."})
2169 ),
2170 text(
2171 4,
2172 "list_item",
2173 "insects",
2174 json!({"enumerated": true, "marker": "4."})
2175 ),
2176 ]),
2177 json!([{"self_ref": "#/groups/0", "children": [{"$ref": "#/texts/3"}, {"$ref": "#/texts/4"}], "label": "list", "name": "list"}]),
2178 json!([]),
2179 );
2180 let b = blocks(&j);
2181 assert_eq!(b[0], ast::header(1, vec![ast::str_("Ducks")]));
2182 assert_eq!(b[1], ast::header(2, vec![ast::str_("Diet")]));
2183 assert_eq!(b[2]["t"], "Para");
2185 assert_eq!(b[2]["c"][0]["t"], "Link");
2186 assert_eq!(b[2]["c"][0]["c"][1][0]["t"], "Strong");
2187 assert_eq!(b[3]["t"], "OrderedList");
2188 assert_eq!(b[3]["c"][0][0], 3, "start from the first marker");
2189 assert_eq!(
2190 b[3]["c"][1],
2191 json!([
2192 [ast::plain(vec![ast::str_("seeds")])],
2193 [ast::plain(vec![ast::str_("insects")])]
2194 ])
2195 );
2196 }
2197
2198 #[test]
2199 fn code_formulas_checkboxes_and_unmapped_labels() {
2200 let j = doc(
2201 &["#/texts/0", "#/texts/1", "#/texts/2", "#/texts/3"],
2202 json!([
2203 text(0, "code", "print(1)", json!({"code_language": "Python"})),
2204 text(1, "formula", "E = mc^2", json!({})),
2205 text(2, "checkbox_selected", "done", json!({})),
2206 text(3, "reference", "[1] Duck, D. (2020).", json!({})),
2207 ]),
2208 json!([]),
2209 json!([]),
2210 );
2211 let b = blocks(&j);
2212 assert_eq!(b[0], ast::code_block(Some("python"), "print(1)"));
2213 assert_eq!(b[1], ast::para(vec![ast::math(true, "E = mc^2")]));
2214 assert_eq!(b[2]["c"][0], ast::str_("☒"));
2215 assert_eq!(b[3]["t"], "Div");
2216 assert_eq!(b[3]["c"][0][1], json!(["docling-reference"]));
2217 }
2218
2219 #[test]
2220 fn tables_keep_spans_header_rows_and_captions() {
2221 let cell = |r0: usize, c0: usize, rs: usize, cs: usize, t: &str, header: bool| {
2222 json!({"text": t, "row_span": rs, "col_span": cs,
2223 "start_row_offset_idx": r0, "end_row_offset_idx": r0 + rs,
2224 "start_col_offset_idx": c0, "end_col_offset_idx": c0 + cs,
2225 "column_header": header})
2226 };
2227 let j = doc(
2228 &["#/tables/0"],
2229 json!([text(0, "caption", "Table 1: Ducks", json!({}))]),
2230 json!([]),
2231 json!([{"self_ref": "#/tables/0", "children": [{"$ref": "#/texts/0"}], "label": "table",
2232 "captions": [{"$ref": "#/texts/0"}],
2233 "data": {"num_rows": 2, "num_cols": 2, "table_cells": [
2234 cell(0, 0, 1, 2, "Species", true),
2235 cell(1, 0, 1, 1, "Mallard", false),
2236 cell(1, 1, 1, 1, "Anas", false)]}}]),
2237 );
2238 let b = blocks(&j);
2239 assert_eq!(b.len(), 1, "the caption is rendered by its table only");
2240 let t = &b[0];
2241 assert_eq!(t["t"], "Table");
2242 assert_eq!(
2243 t["c"][1][1][0],
2244 ast::plain(vec![
2245 ast::str_("Table"),
2246 ast::space(),
2247 ast::str_("1:"),
2248 ast::space(),
2249 ast::str_("Ducks")
2250 ])
2251 );
2252 assert_eq!(t["c"][2].as_array().unwrap().len(), 2);
2253 let head = t["c"][3][1].as_array().unwrap();
2254 assert_eq!(head.len(), 1);
2255 assert_eq!(head[0][1][0][3], 2, "colspan");
2256 assert_eq!(t["c"][4][0][3].as_array().unwrap().len(), 1, "one body row");
2257 }
2258
2259 #[test]
2260 fn api_version_is_checked() {
2261 for ok in ["1.23", "1.23.1", "1.23.1.1"] {
2262 assert!(check_api_version(ok).is_ok(), "{ok}");
2263 }
2264 for bad in ["1.22", "2.0", "1", "1.23.2", "1.23.1.2", "x.y", ""] {
2265 let e = check_api_version(bad).unwrap_err();
2266 assert!(e.to_string().contains("only 1.23"), "{bad}: {e}");
2267 }
2268 let err = to_pandoc(
2269 &DoclingDocument::new("t"),
2270 &PandocExportOptions {
2271 api_version: Some("1.22".into()),
2272 ..Default::default()
2273 },
2274 )
2275 .unwrap_err();
2276 assert_eq!(
2277 err,
2278 PandocError::UnsupportedApiVersion {
2279 requested: "1.22".into()
2280 }
2281 );
2282 }
2283
2284 #[test]
2285 fn empty_document_is_a_valid_pandoc_document() {
2286 let (s, artifacts) =
2287 to_pandoc(&DoclingDocument::new("e"), &PandocExportOptions::default()).unwrap();
2288 assert!(artifacts.is_empty());
2289 assert_eq!(
2290 s,
2291 r#"{"pandoc-api-version":[1,23,1,1],"meta":{},"blocks":[]}"#
2292 );
2293 assert_eq!(s, DoclingDocument::new("e").export_to_pandoc_json());
2294 }
2295}