1use std::collections::{BTreeMap, HashMap, HashSet};
62
63use serde_json::Value;
64
65use crate::document::{ContentLayers, DoclingDocument};
66use crate::markdown::ImageMode;
67
68pub const PANDOC_API_VERSION: [u32; 4] = [1, 23, 1, 1];
70
71pub type PandocOutput = (String, Vec<(String, Vec<u8>)>);
74
75#[derive(Debug, Clone)]
77pub struct PandocExportOptions {
78 pub image_mode: ImageMode,
81 pub artifacts_dir: String,
84 pub layers: ContentLayers,
86 pub api_version: Option<String>,
89}
90
91impl Default for PandocExportOptions {
92 fn default() -> Self {
93 Self {
94 image_mode: ImageMode::Embedded,
95 artifacts_dir: "artifacts".to_string(),
96 layers: ContentLayers::BODY,
97 api_version: None,
98 }
99 }
100}
101
102#[derive(Debug, Clone, PartialEq, Eq)]
104pub enum PandocError {
105 UnsupportedApiVersion { requested: String },
107}
108
109impl std::fmt::Display for PandocError {
110 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111 match self {
112 PandocError::UnsupportedApiVersion { requested } => write!(
113 f,
114 "unsupported Pandoc API version '{requested}': only {} (pandoc-types {}, Pandoc 3.x) is supported",
115 version_string(&PANDOC_API_VERSION[..2]),
116 version_string(&PANDOC_API_VERSION),
117 ),
118 }
119 }
120}
121
122impl std::error::Error for PandocError {}
123
124fn version_string(parts: &[u32]) -> String {
125 parts
126 .iter()
127 .map(u32::to_string)
128 .collect::<Vec<_>>()
129 .join(".")
130}
131
132pub fn check_api_version(requested: &str) -> Result<(), PandocError> {
136 let err = || PandocError::UnsupportedApiVersion {
137 requested: requested.to_string(),
138 };
139 let parts: Vec<u32> = requested
140 .trim()
141 .split('.')
142 .map(|p| p.parse::<u32>().map_err(|_| err()))
143 .collect::<Result<_, _>>()?;
144 if parts.len() < 2 || parts[..2] != PANDOC_API_VERSION[..2] {
145 return Err(err());
146 }
147 if parts.len() > PANDOC_API_VERSION.len()
148 || parts
149 .iter()
150 .zip(PANDOC_API_VERSION.iter())
151 .skip(2)
152 .any(|(a, b)| a > b)
153 {
154 return Err(err());
156 }
157 Ok(())
158}
159
160pub fn to_pandoc(
164 doc: &DoclingDocument,
165 options: &PandocExportOptions,
166) -> Result<PandocOutput, PandocError> {
167 from_docling_json(&crate::json::to_json_with_notes(doc), options)
169}
170
171pub fn from_docling_json(
175 json: &Value,
176 options: &PandocExportOptions,
177) -> Result<PandocOutput, PandocError> {
178 if let Some(v) = &options.api_version {
179 check_api_version(v)?;
180 }
181 let render = || {
184 let mut ser = Serializer::new(json, options);
185 let blocks = ser.body();
186 let doc = ast::document(blocks);
187 (
188 serde_json::to_string(&doc).expect("Pandoc JSON is always serializable"),
189 ser.artifacts,
190 )
191 };
192 #[cfg(not(target_arch = "wasm32"))]
193 {
194 Ok(std::thread::scope(|scope| {
195 std::thread::Builder::new()
196 .name("docling-pandoc".into())
197 .stack_size(256 << 20)
198 .spawn_scoped(scope, render)
199 .expect("spawn the pandoc serializer thread")
200 .join()
201 .expect("pandoc serializer thread panicked")
202 }))
203 }
204 #[cfg(target_arch = "wasm32")]
205 {
206 Ok(render())
207 }
208}
209
210pub mod ast {
213 use serde_json::{json, Value};
214
215 use super::PANDOC_API_VERSION;
216
217 pub fn document(blocks: Vec<Value>) -> Value {
218 json!({ "pandoc-api-version": PANDOC_API_VERSION, "meta": {}, "blocks": blocks })
219 }
220
221 pub fn attr(classes: &[&str]) -> Value {
223 json!(["", classes, []])
224 }
225
226 fn node(t: &str, c: Value) -> Value {
227 json!({ "t": t, "c": c })
228 }
229
230 pub fn para(inlines: Vec<Value>) -> Value {
233 node("Para", json!(inlines))
234 }
235 pub fn plain(inlines: Vec<Value>) -> Value {
236 node("Plain", json!(inlines))
237 }
238 pub fn header(level: usize, inlines: Vec<Value>) -> Value {
239 node("Header", json!([level, attr(&[]), inlines]))
240 }
241 pub fn code_block(language: Option<&str>, text: &str) -> Value {
242 let classes: Vec<&str> = language.into_iter().collect();
243 node("CodeBlock", json!([attr(&classes), text]))
244 }
245 pub fn bullet_list(items: Vec<Vec<Value>>) -> Value {
246 node("BulletList", json!(items))
247 }
248 pub fn ordered_list(start: u64, items: Vec<Vec<Value>>) -> Value {
249 node(
250 "OrderedList",
251 json!([[start, { "t": "Decimal" }, { "t": "Period" }], items]),
252 )
253 }
254 pub fn definition_list(entries: Vec<(Vec<Value>, Vec<Vec<Value>>)>) -> Value {
255 node(
256 "DefinitionList",
257 Value::Array(
258 entries
259 .into_iter()
260 .map(|(term, defs)| json!([term, defs]))
261 .collect(),
262 ),
263 )
264 }
265 pub fn raw_block(format: &str, text: &str) -> Value {
268 node("RawBlock", json!([format, text]))
269 }
270 pub fn div(classes: &[&str], blocks: Vec<Value>) -> Value {
271 node("Div", json!([attr(classes), blocks]))
272 }
273 pub fn caption(blocks: Vec<Value>) -> Value {
275 json!([null, blocks])
276 }
277 pub fn figure(classes: &[&str], caption: Value, blocks: Vec<Value>) -> Value {
278 node("Figure", json!([attr(classes), caption, blocks]))
279 }
280 pub fn cell(row_span: usize, col_span: usize, blocks: Vec<Value>) -> Value {
282 json!([attr(&[]), { "t": "AlignDefault" }, row_span, col_span, blocks])
283 }
284 pub fn row(cells: Vec<Value>) -> Value {
285 json!([attr(&[]), cells])
286 }
287 pub fn table(caption: Value, num_cols: usize, head: Vec<Value>, body: Vec<Value>) -> Value {
289 let colspecs: Vec<Value> = (0..num_cols)
290 .map(|_| json!([{ "t": "AlignDefault" }, { "t": "ColWidthDefault" }]))
291 .collect();
292 node(
293 "Table",
294 json!([
295 attr(&[]),
296 caption,
297 colspecs,
298 [attr(&[]), head],
299 [[attr(&[]), 0, [], body]],
300 [attr(&[]), []]
301 ]),
302 )
303 }
304
305 pub fn str_(s: &str) -> Value {
308 node("Str", json!(s))
309 }
310 pub fn space() -> Value {
311 json!({ "t": "Space" })
312 }
313 pub fn line_break() -> Value {
314 json!({ "t": "LineBreak" })
315 }
316 pub fn wrap(t: &str, inlines: Vec<Value>) -> Value {
318 node(t, json!(inlines))
319 }
320 pub fn code(text: &str) -> Value {
321 node("Code", json!([attr(&[]), text]))
322 }
323 pub fn math(display: bool, tex: &str) -> Value {
324 let kind = if display { "DisplayMath" } else { "InlineMath" };
325 node("Math", json!([{ "t": kind }, tex]))
326 }
327 pub fn link(inlines: Vec<Value>, url: &str) -> Value {
328 node("Link", json!([attr(&[]), inlines, [url, ""]]))
329 }
330 pub fn image(classes: &[&str], alt: Vec<Value>, src: &str) -> Value {
331 node("Image", json!([attr(classes), alt, [src, ""]]))
332 }
333 pub fn note(blocks: Vec<Value>) -> Value {
334 node("Note", json!(blocks))
335 }
336}
337
338fn text_inlines(text: &str) -> Vec<Value> {
342 let mut out = Vec::new();
343 for (i, line) in text.trim().split('\n').enumerate() {
344 if i > 0 {
345 out.push(ast::line_break());
346 }
347 let mut first = true;
348 for word in line
349 .split(|c: char| c.is_whitespace() && c != '\u{a0}')
350 .filter(|w| !w.is_empty())
351 {
352 if !first {
353 out.push(ast::space());
354 }
355 out.push(ast::str_(word));
356 first = false;
357 }
358 }
359 out
360}
361
362fn text_with_notes(text: &str, notes: &[Value]) -> Vec<Value> {
367 let mut calls: Vec<(usize, &str)> = notes
368 .iter()
369 .filter_map(|n| Some((n.get(0)?.as_u64()? as usize, n.get(1)?.as_str()?)))
370 .collect();
371 calls.sort_by_key(|&(offset, _)| offset);
372 let chars: Vec<char> = text.chars().collect();
373 let mut out: Vec<Value> = Vec::new();
374 let push_segment = |out: &mut Vec<Value>, seg: &str| {
375 let inlines = text_inlines(seg);
376 if inlines.is_empty() {
377 if !out.is_empty() && seg.chars().any(char::is_whitespace) {
378 out.push(ast::space());
379 }
380 return;
381 }
382 if !out.is_empty() && seg.starts_with(char::is_whitespace) {
383 out.push(ast::space());
384 }
385 out.extend(inlines);
386 if seg.ends_with(char::is_whitespace) {
387 out.push(ast::space());
388 }
389 };
390 let mut prev = 0usize;
391 for (offset, note) in calls {
392 let at = offset.clamp(prev, chars.len());
393 let seg: String = chars[prev..at].iter().collect();
394 push_segment(&mut out, &seg);
395 out.push(ast::note(vec![ast::para(text_inlines(note))]));
396 prev = at;
397 }
398 let rest: String = chars[prev..].iter().collect();
399 push_segment(&mut out, &rest);
400 while out
401 .last()
402 .is_some_and(|v| v.get("t").and_then(Value::as_str) == Some("Space"))
403 {
404 out.pop();
405 }
406 out
407}
408
409fn join_inlines(parts: Vec<Vec<Value>>) -> Vec<Value> {
415 fn edge_char(inlines: &[Value], first: bool) -> Option<char> {
416 let node = if first {
417 inlines.first()
418 } else {
419 inlines.last()
420 }?;
421 match node.get("t").and_then(Value::as_str)? {
422 "Str" => {
423 let s = node.get("c")?.as_str()?;
424 if first {
425 s.chars().next()
426 } else {
427 s.chars().last()
428 }
429 }
430 "Strong" | "Emph" | "Underline" | "Strikeout" | "Subscript" | "Superscript" => {
432 edge_char(node.get("c")?.as_array()?, first)
433 }
434 "Link" => edge_char(node.get("c")?.get(1)?.as_array()?, first),
435 _ => None,
436 }
437 }
438 let mut out: Vec<Value> = Vec::new();
439 for part in parts.into_iter().filter(|p| !p.is_empty()) {
440 if !out.is_empty() {
441 let closes = edge_char(&part, true)
442 .is_some_and(|c| ".,;:!?)]}%\u{bb}\u{201d}\u{2019}".contains(c));
443 let opens = edge_char(&out, false).is_some_and(|c| "([{\u{ab}\u{201c}".contains(c));
444 if !closes && !opens {
445 out.push(ast::space());
446 }
447 }
448 out.extend(part);
449 }
450 out
451}
452
453fn marker_start(marker: &str) -> Option<u64> {
455 marker
456 .trim()
457 .trim_end_matches(['.', ')'])
458 .parse::<u64>()
459 .ok()
460}
461
462struct GridCell<'a> {
463 cell: &'a Value,
464 start_row: usize,
465 start_col: usize,
466 row_span: usize,
467 col_span: usize,
468}
469
470struct Serializer<'a> {
471 json: &'a Value,
472 image_mode: ImageMode,
473 artifacts_dir: String,
474 layers: ContentLayers,
475 artifacts: Vec<(String, Vec<u8>)>,
476 pic_index: usize,
477 visited: HashSet<String>,
478 owned_captions: HashSet<String>,
481 owned_footnotes: HashSet<String>,
482}
483
484impl<'a> Serializer<'a> {
485 fn new(json: &'a Value, options: &PandocExportOptions) -> Self {
486 let mut owned_captions = HashSet::new();
487 let mut owned_footnotes = HashSet::new();
488 for bucket in [
489 "pictures",
490 "tables",
491 "key_value_items",
492 "form_items",
493 "texts",
494 ] {
495 for item in Self::array(json.get(bucket)) {
496 for (key, set) in [
497 ("captions", &mut owned_captions),
498 ("footnotes", &mut owned_footnotes),
499 ] {
500 for r in Self::array(item.get(key)) {
501 if let Some(cref) = r.get("$ref").and_then(Value::as_str) {
502 set.insert(cref.to_string());
503 }
504 }
505 }
506 }
507 }
508 Self {
509 json,
510 image_mode: options.image_mode,
511 artifacts_dir: options.artifacts_dir.clone(),
512 layers: options.layers,
513 artifacts: Vec::new(),
514 pic_index: 0,
515 visited: HashSet::new(),
516 owned_captions,
517 owned_footnotes,
518 }
519 }
520
521 fn array(v: Option<&'a Value>) -> impl Iterator<Item = &'a Value> {
522 v.and_then(Value::as_array).into_iter().flatten()
523 }
524
525 fn resolve(&self, cref: &str) -> Option<&'a Value> {
526 let rest = cref.strip_prefix("#/")?;
527 if rest == "body" {
528 return self.json.get("body");
529 }
530 let (bucket, idx) = rest.split_once('/')?;
531 self.json.get(bucket)?.get(idx.parse::<usize>().ok()?)
532 }
533
534 fn refs(v: Option<&'a Value>) -> Vec<&'a str> {
535 Self::array(v)
536 .filter_map(|r| r.get("$ref").and_then(Value::as_str))
537 .collect()
538 }
539
540 fn children(item: &'a Value) -> Vec<&'a str> {
541 Self::refs(item.get("children"))
542 }
543
544 fn self_ref(item: &Value) -> &str {
545 item.get("self_ref").and_then(Value::as_str).unwrap_or("")
546 }
547
548 fn label(item: &Value) -> &str {
549 item.get("label").and_then(Value::as_str).unwrap_or("")
550 }
551
552 fn text(item: &Value) -> &str {
553 item.get("text").and_then(Value::as_str).unwrap_or("")
554 }
555
556 fn is_group(item: &Value) -> bool {
557 Self::self_ref(item).starts_with("#/groups/")
558 }
559
560 fn excluded(&self, item: &Value) -> bool {
561 let layer = item
562 .get("content_layer")
563 .and_then(Value::as_str)
564 .unwrap_or("body");
565 !self.layers.contains_name(layer)
566 }
567
568 fn descendants(&self, root: &'a Value, out: &mut Vec<&'a Value>) {
573 let picture = Self::self_ref(root).starts_with("#/pictures/");
574 let captions = if picture {
575 Self::refs(root.get("captions"))
576 } else {
577 Vec::new()
578 };
579 for cref in Self::children(root) {
580 if picture && !captions.contains(&cref) {
581 continue;
582 }
583 let Some(child) = self.resolve(cref) else {
584 continue;
585 };
586 if !self.excluded(child) {
587 out.push(child);
588 }
589 self.descendants(child, out);
590 }
591 }
592
593 fn blocks_of(&mut self, item: &'a Value) -> Vec<Value> {
597 let mut nodes = Vec::new();
598 self.descendants(item, &mut nodes);
599 let mut out = Vec::new();
600 for node in nodes {
601 if self.visited.insert(Self::self_ref(node).to_string()) {
602 out.extend(self.serialize(node));
603 }
604 }
605 out
606 }
607
608 fn body(&mut self) -> Vec<Value> {
609 let Some(body) = self.json.get("body") else {
610 return Vec::new();
611 };
612 self.visited.insert("#/body".to_string());
613 let mut blocks = self.blocks_of(body);
614 blocks.extend(self.unplaced_notes());
615 blocks
616 }
617
618 fn unplaced_notes(&self) -> Vec<Value> {
624 Self::array(self.json.get("texts"))
625 .filter(|t| Self::label(t) == "footnote" && !Self::is_note_body(t))
626 .filter(|t| t.get("content_layer").and_then(Value::as_str) == Some("furniture"))
627 .filter(|t| self.excluded(t) && !self.owned_footnotes.contains(Self::self_ref(t)))
628 .filter_map(|t| {
629 let text = text_inlines(Self::text(t));
630 (!text.is_empty()).then(|| ast::para(vec![ast::note(vec![ast::para(text)])]))
631 })
632 .collect()
633 }
634
635 fn serialize(&mut self, item: &'a Value) -> Vec<Value> {
637 let sref = Self::self_ref(item);
638 if Self::is_group(item) {
639 return match Self::label(item) {
640 "list" => self.list_group(item),
641 "inline" => {
642 let (inlines, mut rest) = self.inline_group(item);
643 let mut out = Vec::new();
644 if !inlines.is_empty() {
645 out.push(ast::para(inlines));
646 }
647 out.append(&mut rest);
648 out
649 }
650 _ => self.blocks_of(item),
651 };
652 }
653 if sref.starts_with("#/texts/") {
654 if self.owned_captions.contains(sref) || self.owned_footnotes.contains(sref) {
655 return Vec::new();
656 }
657 if self.excluded(item) || Self::is_note_body(item) {
659 return Vec::new();
660 }
661 return self.text_item(item);
662 }
663 if sref.starts_with("#/tables/") {
664 return self.table(item);
665 }
666 if sref.starts_with("#/pictures/") {
667 return self.picture(item);
668 }
669 if sref.starts_with("#/key_value_items/") {
670 return self.graph_item(item, "key-value-region");
671 }
672 if sref.starts_with("#/form_items/") {
673 return self.graph_item(item, "form-container");
674 }
675 if sref.starts_with("#/field_regions/") {
676 return self.field_region(item);
677 }
678 if sref.starts_with("#/field_items/") {
679 let entries = vec![self.field_item(item)];
680 return Self::field_blocks(entries);
681 }
682 Vec::new()
683 }
684
685 fn field_region(&mut self, region: &'a Value) -> Vec<Value> {
689 let mut entries = Vec::new();
690 for cref in Self::children(region) {
691 let Some(child) = self.resolve(cref) else {
692 continue;
693 };
694 if !self.visited.insert(cref.to_string()) {
695 continue;
696 }
697 if cref.starts_with("#/field_items/") {
698 entries.push(self.field_item(child));
699 } else {
700 let mut blocks = if self.excluded(child) {
701 Vec::new()
702 } else {
703 self.serialize(child)
704 };
705 blocks.extend(self.blocks_of(child));
706 entries.push((Vec::new(), blocks));
707 }
708 }
709 let blocks = Self::field_blocks(entries);
710 if blocks.is_empty() {
711 Vec::new()
712 } else {
713 vec![ast::div(&["field-region"], blocks)]
714 }
715 }
716
717 fn field_item(&mut self, field: &'a Value) -> (Vec<Value>, Vec<Value>) {
720 let mut nodes = Vec::new();
721 self.descendants(field, &mut nodes);
722 let (mut term, mut defs) = (Vec::new(), Vec::new());
723 for node in nodes {
724 if !self.visited.insert(Self::self_ref(node).to_string()) {
725 continue;
726 }
727 match Self::label(node) {
728 "marker" | "field_key" => {
729 let inlines = self.formatted(node);
730 if !inlines.is_empty() {
731 term = join_inlines(vec![term, inlines]);
732 }
733 }
734 "field_value" => {
735 let inlines = self.formatted(node);
736 if !inlines.is_empty() {
737 defs.push(ast::plain(inlines));
738 }
739 }
740 _ => defs.extend(self.serialize(node)),
741 }
742 }
743 (term, defs)
744 }
745
746 fn field_blocks(entries: Vec<(Vec<Value>, Vec<Value>)>) -> Vec<Value> {
749 let mut out = Vec::new();
750 let mut run: Vec<(Vec<Value>, Vec<Vec<Value>>)> = Vec::new();
751 for (term, defs) in entries {
752 if term.is_empty() {
753 if !run.is_empty() {
754 out.push(ast::definition_list(std::mem::take(&mut run)));
755 }
756 out.extend(defs);
757 } else {
758 let defs = defs.into_iter().map(|d| vec![d]).collect();
759 run.push((term, defs));
760 }
761 }
762 if !run.is_empty() {
763 out.push(ast::definition_list(run));
764 }
765 out
766 }
767
768 fn own_inlines(&mut self, item: &'a Value) -> (Vec<Value>, Vec<Value>, bool) {
773 let children = Self::children(item);
774 if Self::text(item).is_empty() && children.len() == 1 {
775 if let Some(group) = self
776 .resolve(children[0])
777 .filter(|c| Self::is_group(c) && Self::label(c) == "inline")
778 {
779 self.visited.insert(Self::self_ref(group).to_string());
780 let (inlines, rest) = self.inline_group(group);
781 return (inlines, rest, true);
782 }
783 }
784 (self.formatted(item), Vec::new(), false)
785 }
786
787 fn formatted(&self, item: &Value) -> Vec<Value> {
790 let base = match Self::label(item) {
791 "code" => vec![ast::code(Self::text(item))],
792 "formula" if !Self::text(item).is_empty() => {
793 vec![ast::math(false, Self::text(item).trim())]
794 }
795 _ => match item.get("_notes").and_then(Value::as_array) {
796 Some(notes) if !notes.is_empty() => text_with_notes(Self::text(item), notes),
797 _ => text_inlines(Self::text(item)),
798 },
799 };
800 Self::decorate(base, item)
801 }
802
803 fn is_note_body(item: &Value) -> bool {
806 item.get("_note_body").and_then(Value::as_bool) == Some(true)
807 }
808
809 fn decorate(mut inlines: Vec<Value>, item: &Value) -> Vec<Value> {
810 if inlines.is_empty() {
811 return inlines;
812 }
813 if let Some(f) = item.get("formatting") {
814 let on = |k: &str| f.get(k).and_then(Value::as_bool).unwrap_or(false);
815 if on("bold") {
816 inlines = vec![ast::wrap("Strong", inlines)];
817 }
818 if on("italic") {
819 inlines = vec![ast::wrap("Emph", inlines)];
820 }
821 if on("underline") {
822 inlines = vec![ast::wrap("Underline", inlines)];
823 }
824 if on("strikethrough") {
825 inlines = vec![ast::wrap("Strikeout", inlines)];
826 }
827 match f.get("script").and_then(Value::as_str) {
828 Some("sub") => inlines = vec![ast::wrap("Subscript", inlines)],
829 Some("super") => inlines = vec![ast::wrap("Superscript", inlines)],
830 _ => {}
831 }
832 }
833 if let Some(url) = item.get("hyperlink").and_then(Value::as_str) {
834 inlines = vec![ast::link(inlines, url)];
835 }
836 inlines
837 }
838
839 fn inline_group(&mut self, group: &'a Value) -> (Vec<Value>, Vec<Value>) {
843 let mut nodes = Vec::new();
844 self.descendants(group, &mut nodes);
845 let mut runs: Vec<Vec<Value>> = Vec::new();
846 let mut rest: Vec<Value> = Vec::new();
847 for node in nodes {
848 let r = Self::self_ref(node).to_string();
849 if self.visited.contains(&r) {
850 continue;
851 }
852 self.visited.insert(r.clone());
853 if r.starts_with("#/texts/") {
854 if self.owned_captions.contains(&r)
855 || self.owned_footnotes.contains(&r)
856 || Self::is_note_body(node)
857 {
858 continue;
859 }
860 runs.push(self.formatted(node));
861 continue;
862 }
863 if Self::is_group(node) && Self::label(node) == "inline" {
864 let (inl, mut more) = self.inline_group(node);
865 runs.push(inl);
866 rest.append(&mut more);
867 continue;
868 }
869 rest.extend(self.serialize(node));
870 }
871 (join_inlines(runs), rest)
872 }
873
874 fn text_item(&mut self, item: &'a Value) -> Vec<Value> {
875 let label = Self::label(item);
876 let mut out = Vec::new();
877 match label {
878 "list_item" => {
879 let blocks = self.list_item_blocks(item);
881 if !blocks.is_empty() {
882 out.push(ast::bullet_list(vec![blocks]));
883 }
884 return out;
885 }
886 "code" => {
887 let lang = item
888 .get("code_language")
889 .and_then(Value::as_str)
890 .filter(|l| !l.is_empty() && *l != "unknown")
891 .map(str::to_ascii_lowercase);
892 out.push(ast::code_block(lang.as_deref(), Self::text(item)));
893 }
894 "formula" => {
895 let tex = Self::text(item).trim();
896 if !tex.is_empty() {
897 out.push(ast::para(vec![ast::math(true, tex)]));
898 }
899 }
900 _ => {
901 let (mut inlines, rest, _) = self.own_inlines(item);
902 match label {
903 "title" | "section_header" => {
904 let level = if label == "title" {
905 1
906 } else {
907 (item.get("level").and_then(Value::as_u64).unwrap_or(1) as usize + 1)
908 .min(6)
909 };
910 if !inlines.is_empty() {
911 out.push(ast::header(level, inlines));
912 }
913 }
914 "text" | "paragraph" => {
915 if !inlines.is_empty() {
916 out.push(ast::para(inlines));
917 }
918 }
919 "checkbox_selected" | "checkbox_unselected" => {
920 let mark = if label == "checkbox_selected" {
921 "☒"
922 } else {
923 "☐"
924 };
925 let mut with_mark = vec![ast::str_(mark)];
926 if !inlines.is_empty() {
927 with_mark.push(ast::space());
928 with_mark.append(&mut inlines);
929 }
930 out.push(ast::para(with_mark));
931 }
932 other => {
933 if !inlines.is_empty() {
934 let class = format!("docling-{other}");
935 out.push(ast::div(&[&class], vec![ast::para(inlines)]));
936 }
937 }
938 }
939 out.extend(rest);
940 }
941 }
942 out.extend(self.blocks_of(item));
944 out
945 }
946
947 fn list_item_blocks(&mut self, item: &'a Value) -> Vec<Value> {
950 let (inlines, rest, _) = self.own_inlines(item);
951 let mut blocks = Vec::new();
952 if !inlines.is_empty() {
953 blocks.push(ast::plain(inlines));
954 }
955 blocks.extend(rest);
956 blocks.extend(self.blocks_of(item));
957 blocks
958 }
959
960 fn list_group(&mut self, group: &'a Value) -> Vec<Value> {
961 let first = Self::children(group)
962 .first()
963 .and_then(|r| self.resolve(r))
964 .filter(|c| Self::label(c) == "list_item");
965 let enumerated = first.is_some_and(|c| {
966 c.get("enumerated")
967 .and_then(Value::as_bool)
968 .unwrap_or(false)
969 });
970 let start = first
971 .and_then(|c| c.get("marker").and_then(Value::as_str))
972 .and_then(marker_start)
973 .unwrap_or(1);
974
975 let mut nodes = Vec::new();
976 self.descendants(group, &mut nodes);
977 let mut items: Vec<Vec<Value>> = Vec::new();
978 for node in nodes {
979 let r = Self::self_ref(node).to_string();
980 if !self.visited.insert(r.clone()) {
981 continue;
982 }
983 if Self::label(node) == "list_item"
984 && r.starts_with("#/texts/")
985 && !self.owned_captions.contains(&r)
986 {
987 let blocks = self.list_item_blocks(node);
988 items.push(blocks);
989 continue;
990 }
991 let blocks = self.serialize(node);
994 if blocks.is_empty() {
995 continue;
996 }
997 match items.last_mut() {
998 Some(last) => last.extend(blocks),
999 None => items.push(blocks),
1000 }
1001 }
1002 if items.is_empty() {
1003 return Vec::new();
1004 }
1005 if enumerated {
1006 vec![ast::ordered_list(start, items)]
1007 } else {
1008 vec![ast::bullet_list(items)]
1009 }
1010 }
1011
1012 fn caption(&self, item: &'a Value) -> Value {
1015 let mut parts: Vec<Vec<Value>> = Vec::new();
1016 for cref in Self::refs(item.get("captions")) {
1017 if let Some(cap) = self.resolve(cref) {
1018 if cref.starts_with("#/texts/") && !self.excluded(cap) {
1019 parts.push(text_inlines(Self::text(cap)));
1020 }
1021 }
1022 }
1023 let mut inlines = join_inlines(parts);
1024 for cref in Self::refs(item.get("footnotes")) {
1025 if let Some(note) = self.resolve(cref) {
1026 if cref.starts_with("#/texts/") && !self.excluded(note) {
1027 let text = text_inlines(Self::text(note));
1028 if !text.is_empty() {
1029 inlines.push(ast::note(vec![ast::para(text)]));
1030 }
1031 }
1032 }
1033 }
1034 if inlines.is_empty() {
1035 ast::caption(Vec::new())
1036 } else {
1037 ast::caption(vec![ast::plain(inlines)])
1038 }
1039 }
1040
1041 fn caption_text(&self, item: &'a Value) -> Vec<Value> {
1044 let parts = Self::refs(item.get("captions"))
1045 .into_iter()
1046 .filter(|cref| cref.starts_with("#/texts/"))
1047 .filter_map(|cref| self.resolve(cref))
1048 .filter(|cap| !self.excluded(cap))
1049 .map(|cap| text_inlines(Self::text(cap)))
1050 .collect();
1051 join_inlines(parts)
1052 }
1053
1054 fn grid(data: &'a Value) -> (usize, Vec<Vec<Option<GridCell<'a>>>>) {
1056 let num_rows = data.get("num_rows").and_then(Value::as_u64).unwrap_or(0) as usize;
1057 let num_cols = data.get("num_cols").and_then(Value::as_u64).unwrap_or(0) as usize;
1058 let mut grid: Vec<Vec<Option<GridCell<'a>>>> = (0..num_rows)
1059 .map(|_| (0..num_cols).map(|_| None).collect())
1060 .collect();
1061 for cell in Self::array(data.get("table_cells")) {
1062 let get = |k: &str| cell.get(k).and_then(Value::as_u64).unwrap_or(0) as usize;
1063 let (r0, c0) = (get("start_row_offset_idx"), get("start_col_offset_idx"));
1064 let (r1, c1) = (get("end_row_offset_idx"), get("end_col_offset_idx"));
1065 if r0 >= num_rows || c0 >= num_cols {
1066 continue;
1067 }
1068 let (r1, c1) = (r1.clamp(r0 + 1, num_rows), c1.clamp(c0 + 1, num_cols));
1069 for row in grid.iter_mut().take(r1).skip(r0) {
1070 for slot in row.iter_mut().take(c1).skip(c0) {
1071 *slot = Some(GridCell {
1072 cell,
1073 start_row: r0,
1074 start_col: c0,
1075 row_span: r1 - r0,
1076 col_span: c1 - c0,
1077 });
1078 }
1079 }
1080 }
1081 (num_cols, grid)
1082 }
1083
1084 fn table_from_data(&mut self, data: &'a Value, caption: Value) -> Option<Value> {
1085 let (num_cols, grid) = Self::grid(data);
1086 if grid.is_empty() || num_cols == 0 {
1087 return None;
1088 }
1089 let flag = |c: &Value, k: &str| c.get(k).and_then(Value::as_bool).unwrap_or(false);
1090 let mut rows: Vec<(bool, Value)> = Vec::new();
1091 for (i, row) in grid.iter().enumerate() {
1092 let mut cells = Vec::new();
1093 let mut all_header = true;
1094 let mut anchored = 0;
1095 for (j, slot) in row.iter().enumerate() {
1096 let Some(g) = slot else {
1097 cells.push(ast::cell(1, 1, Vec::new()));
1100 continue;
1101 };
1102 if g.start_row != i || g.start_col != j {
1103 continue;
1104 }
1105 anchored += 1;
1106 all_header &= flag(g.cell, "column_header");
1107 let blocks = match g
1108 .cell
1109 .get("ref")
1110 .and_then(|r| r.get("$ref"))
1111 .and_then(Value::as_str)
1112 {
1113 Some(cref) => match self.resolve(cref) {
1114 Some(target) => {
1115 self.visited.insert(cref.to_string());
1116 self.serialize(target)
1117 }
1118 None => Vec::new(),
1119 },
1120 None => {
1121 let inl =
1122 text_inlines(g.cell.get("text").and_then(Value::as_str).unwrap_or(""));
1123 if inl.is_empty() {
1124 Vec::new()
1125 } else {
1126 vec![ast::plain(inl)]
1127 }
1128 }
1129 };
1130 cells.push(ast::cell(g.row_span, g.col_span, blocks));
1131 }
1132 rows.push((all_header && anchored > 0, ast::row(cells)));
1133 }
1134 let head_len = rows.iter().take_while(|(h, _)| *h).count();
1136 let head_len = if head_len == rows.len() { 0 } else { head_len };
1137 let mut head = Vec::new();
1138 let mut body = Vec::new();
1139 for (i, (_, row)) in rows.into_iter().enumerate() {
1140 if i < head_len {
1141 head.push(row);
1142 } else {
1143 body.push(row);
1144 }
1145 }
1146 Some(ast::table(caption, num_cols, head, body))
1147 }
1148
1149 fn table(&mut self, item: &'a Value) -> Vec<Value> {
1150 let caption = self.caption(item);
1151 if self.excluded(item) {
1152 return Vec::new();
1153 }
1154 match item.get("data") {
1155 Some(data) => self.table_from_data(data, caption).into_iter().collect(),
1156 None => Vec::new(),
1157 }
1158 }
1159
1160 fn picture(&mut self, item: &'a Value) -> Vec<Value> {
1161 if self.excluded(item) {
1162 return Vec::new();
1163 }
1164 let caption = self.caption(item);
1165 let has_caption = caption
1166 .get(1)
1167 .and_then(Value::as_array)
1168 .is_some_and(|b| !b.is_empty());
1169 let uri = item
1170 .get("image")
1171 .and_then(|i| i.get("uri"))
1172 .and_then(Value::as_str);
1173 let src = match (self.image_mode, uri) {
1174 (ImageMode::Embedded, Some(uri)) if uri.starts_with("data:") => Some(uri.to_string()),
1175 (ImageMode::Referenced, Some(uri)) => self
1176 .reference_image(uri)
1177 .or_else(|| (!uri.starts_with("data:")).then(|| uri.to_string())),
1178 _ => None,
1179 };
1180 let alt = self.caption_text(item);
1186 let image = match &src {
1187 Some(src) => ast::image(&[], alt, src),
1188 None => ast::image(&["docling-placeholder"], alt, ""),
1189 };
1190 let chart = item
1192 .get("meta")
1193 .and_then(|m| m.get("tabular_chart"))
1194 .and_then(|t| t.get("chart_data"))
1195 .and_then(|chart| self.table_from_data(chart, ast::caption(Vec::new())));
1196 if chart.is_none() && !has_caption {
1197 return vec![ast::para(vec![image])];
1199 }
1200 let mut blocks = vec![ast::plain(vec![image])];
1201 blocks.extend(chart);
1202 vec![ast::figure(&[], caption, blocks)]
1203 }
1204
1205 fn reference_image(&mut self, uri: &str) -> Option<String> {
1208 let rest = uri.strip_prefix("data:")?;
1209 let (mime, payload) = rest.split_once(";base64,")?;
1210 let bytes = crate::base64::decode(payload)?;
1211 let path = format!(
1212 "{}/image_{:06}.{}",
1213 self.artifacts_dir,
1214 self.pic_index,
1215 crate::markdown::ext_for(mime)
1216 );
1217 self.pic_index += 1;
1218 self.artifacts.push((path.clone(), bytes));
1219 Some(path)
1220 }
1221
1222 fn graph_item(&mut self, item: &'a Value, class: &str) -> Vec<Value> {
1227 let mut out = Vec::new();
1228 if !self.excluded(item) {
1229 if let Some(graph) = item.get("graph") {
1230 if let Some(block) = Self::graph(graph) {
1231 out.push(ast::div(&[class], vec![block]));
1232 }
1233 }
1234 }
1235 let caption = self.caption(item);
1237 for block in caption
1238 .get(1)
1239 .and_then(Value::as_array)
1240 .into_iter()
1241 .flatten()
1242 {
1243 if let Some(inlines) = block.get("c").and_then(Value::as_array) {
1244 out.push(ast::para(inlines.clone()));
1245 }
1246 }
1247 out
1248 }
1249
1250 fn graph(graph: &'a Value) -> Option<Value> {
1251 let cells: HashMap<u64, &Value> = Self::array(graph.get("cells"))
1252 .filter_map(|c| Some((c.get("cell_id")?.as_u64()?, c)))
1253 .collect();
1254 let order: Vec<u64> = Self::array(graph.get("cells"))
1255 .filter_map(|c| c.get("cell_id")?.as_u64())
1256 .collect();
1257 let mut child_links: HashMap<u64, Vec<u64>> = HashMap::new();
1258 let mut value_links: BTreeMap<u64, Vec<u64>> = BTreeMap::new();
1259 let mut value_order: Vec<u64> = Vec::new();
1260 let mut parents: HashSet<u64> = HashSet::new();
1261 for link in Self::array(graph.get("links")) {
1262 let (Some(s), Some(t)) = (
1263 link.get("source_cell_id").and_then(Value::as_u64),
1264 link.get("target_cell_id").and_then(Value::as_u64),
1265 ) else {
1266 continue;
1267 };
1268 if !cells.contains_key(&s) || !cells.contains_key(&t) {
1269 continue;
1270 }
1271 match link.get("label").and_then(Value::as_str) {
1272 Some("to_child") => {
1273 child_links.entry(s).or_default().push(t);
1274 parents.insert(t);
1275 }
1276 Some("to_value") => {
1277 if !value_links.contains_key(&s) {
1278 value_order.push(s);
1279 }
1280 value_links.entry(s).or_default().push(t);
1281 }
1282 _ => {}
1283 }
1284 }
1285 let cell_text =
1286 |id: u64| text_inlines(cells[&id].get("text").and_then(Value::as_str).unwrap_or(""));
1287 if child_links.is_empty() {
1288 if value_order.is_empty() {
1289 return None;
1290 }
1291 let entries = value_order
1292 .iter()
1293 .map(|&k| {
1294 let defs = value_links[&k]
1295 .iter()
1296 .map(|&v| vec![ast::plain(cell_text(v))])
1297 .collect();
1298 (cell_text(k), defs)
1299 })
1300 .collect();
1301 return Some(ast::definition_list(entries));
1302 }
1303 fn node(
1305 id: u64,
1306 cell_text: &dyn Fn(u64) -> Vec<Value>,
1307 child_links: &HashMap<u64, Vec<u64>>,
1308 value_links: &BTreeMap<u64, Vec<u64>>,
1309 path: &mut HashSet<u64>,
1310 ) -> Option<Vec<Value>> {
1311 if !path.insert(id) {
1312 return None;
1313 }
1314 let mut inlines = cell_text(id);
1315 if let Some(values) = value_links.get(&id) {
1316 let vals = join_inlines(values.iter().map(|&v| cell_text(v)).collect());
1317 inlines = vec![ast::wrap("Strong", inlines)];
1318 inlines.push(ast::str_(":"));
1319 if !vals.is_empty() {
1320 inlines.push(ast::space());
1321 inlines.extend(vals);
1322 }
1323 }
1324 let mut blocks = vec![ast::plain(inlines)];
1325 let kids: Vec<Vec<Value>> = child_links
1326 .get(&id)
1327 .into_iter()
1328 .flatten()
1329 .filter_map(|&c| node(c, cell_text, child_links, value_links, path))
1330 .collect();
1331 if !kids.is_empty() {
1332 blocks.push(ast::bullet_list(kids));
1333 }
1334 path.remove(&id);
1335 Some(blocks)
1336 }
1337 let mut path = HashSet::new();
1338 let items: Vec<Vec<Value>> = order
1339 .iter()
1340 .filter(|id| !parents.contains(id))
1341 .filter_map(|&id| node(id, &cell_text, &child_links, &value_links, &mut path))
1342 .collect();
1343 (!items.is_empty()).then(|| ast::bullet_list(items))
1344 }
1345}
1346
1347#[cfg(test)]
1348mod tests {
1349 use super::*;
1350 use serde_json::json;
1351
1352 fn doc(body: &[&str], texts: Value, groups: Value, tables: Value) -> Value {
1354 json!({
1355 "schema_name": "DoclingDocument",
1356 "name": "t",
1357 "body": {"self_ref": "#/body", "children": body.iter().map(|r| json!({"$ref": r})).collect::<Vec<_>>()},
1358 "texts": texts, "groups": groups, "tables": tables, "pictures": []
1359 })
1360 }
1361
1362 fn text(i: usize, label: &str, text: &str, extra: Value) -> Value {
1363 let mut v = json!({"self_ref": format!("#/texts/{i}"), "children": [], "label": label, "text": text, "content_layer": "body"});
1364 for (k, x) in extra.as_object().unwrap() {
1365 v[k] = x.clone();
1366 }
1367 v
1368 }
1369
1370 fn blocks(json: &Value) -> Vec<Value> {
1371 let (s, _) = from_docling_json(json, &PandocExportOptions::default()).unwrap();
1372 let v: Value = serde_json::from_str(&s).unwrap();
1373 assert_eq!(v["pandoc-api-version"], json!(PANDOC_API_VERSION));
1374 v["blocks"].as_array().unwrap().clone()
1375 }
1376
1377 #[test]
1381 fn note_calls_become_notes_at_their_call_sites() {
1382 let j = doc(
1383 &["#/texts/0", "#/texts/1"],
1384 json!([
1385 text(
1386 0,
1387 "text",
1388 "debut suite end",
1389 json!({"_notes": [[5, "first"], [15, "last"]]})
1390 ),
1391 text(
1392 1,
1393 "footnote",
1394 "first",
1395 json!({"content_layer": "furniture", "_note_body": true})
1396 ),
1397 ]),
1398 json!([]),
1399 json!([]),
1400 );
1401 let note = |t: &str| ast::note(vec![ast::para(vec![ast::str_(t)])]);
1402 assert_eq!(
1403 blocks(&j),
1404 vec![ast::para(vec![
1405 ast::str_("debut"),
1406 note("first"),
1407 ast::space(),
1408 ast::str_("suite"),
1409 ast::space(),
1410 ast::str_("end"),
1411 note("last"),
1412 ])]
1413 );
1414 let opts = PandocExportOptions {
1416 layers: ContentLayers::ALL,
1417 ..Default::default()
1418 };
1419 let (s, _) = from_docling_json(&j, &opts).unwrap();
1420 assert!(!s.contains("docling-footnote"), "{s}");
1421 }
1422
1423 #[test]
1426 fn uncalled_furniture_footnotes_trail_as_notes() {
1427 let j = doc(
1428 &["#/texts/0", "#/texts/1"],
1429 json!([
1430 text(0, "text", "body", json!({})),
1431 text(
1432 1,
1433 "footnote",
1434 "MARKFN",
1435 json!({"content_layer": "furniture"})
1436 ),
1437 ]),
1438 json!([]),
1439 json!([]),
1440 );
1441 assert_eq!(
1442 blocks(&j),
1443 vec![
1444 ast::para(vec![ast::str_("body")]),
1445 ast::para(vec![ast::note(vec![ast::para(vec![ast::str_("MARKFN")])])]),
1446 ]
1447 );
1448 }
1449
1450 #[test]
1455 fn pictures_are_always_images() {
1456 let mut j = doc(
1457 &["#/pictures/0", "#/pictures/1", "#/pictures/2"],
1458 json!([text(0, "caption", "A duck", json!({})),]),
1459 json!([]),
1460 json!([]),
1461 );
1462 j["pictures"] = json!([
1463 {"self_ref": "#/pictures/0", "children": [], "label": "picture", "content_layer": "body",
1464 "image": {"uri": "data:image/png;base64,AAAA"}, "captions": []},
1465 {"self_ref": "#/pictures/1", "children": [], "label": "picture", "content_layer": "body",
1466 "image": {"uri": "data:image/png;base64,BBBB"}, "captions": [{"$ref": "#/texts/0"}]},
1467 {"self_ref": "#/pictures/2", "children": [], "label": "picture", "content_layer": "body",
1468 "captions": []},
1469 ]);
1470 let b = blocks(&j);
1471 assert_eq!(
1472 b[0],
1473 ast::para(vec![ast::image(&[], vec![], "data:image/png;base64,AAAA")])
1474 );
1475 assert_eq!(
1476 b[1],
1477 ast::figure(
1478 &[],
1479 ast::caption(vec![ast::plain(vec![
1480 ast::str_("A"),
1481 ast::space(),
1482 ast::str_("duck")
1483 ])]),
1484 vec![ast::plain(vec![ast::image(
1485 &[],
1486 vec![ast::str_("A"), ast::space(), ast::str_("duck")],
1487 "data:image/png;base64,BBBB"
1488 )])]
1489 )
1490 );
1491 assert_eq!(
1492 b[2],
1493 ast::para(vec![ast::image(&["docling-placeholder"], vec![], "")])
1494 );
1495 assert_eq!(b.len(), 3);
1496 let opts = PandocExportOptions {
1498 image_mode: ImageMode::Placeholder,
1499 ..Default::default()
1500 };
1501 let (s, _) = from_docling_json(&j, &opts).unwrap();
1502 assert!(!s.contains("base64") && !s.contains("RawBlock"), "{s}");
1503 assert_eq!(s.matches("docling-placeholder").count(), 3, "{s}");
1504 }
1505
1506 #[test]
1507 fn text_splits_into_words_spaces_and_line_breaks() {
1508 assert_eq!(
1509 text_inlines(" two words\nnext\u{a0}line "),
1510 vec![
1511 ast::str_("two"),
1512 ast::space(),
1513 ast::str_("words"),
1514 ast::line_break(),
1515 ast::str_("next\u{a0}line"),
1516 ]
1517 );
1518 assert!(text_inlines(" ").is_empty());
1519 }
1520
1521 #[test]
1522 fn headings_paragraphs_and_lists() {
1523 let j = doc(
1524 &["#/texts/0", "#/texts/1", "#/texts/2", "#/groups/0"],
1525 json!([
1526 text(0, "title", "Ducks", json!({})),
1527 text(1, "section_header", "Diet", json!({"level": 1})),
1528 text(
1529 2,
1530 "text",
1531 "Ducks eat plants.",
1532 json!({"formatting": {"bold": true}, "hyperlink": "https://x.org"})
1533 ),
1534 text(
1535 3,
1536 "list_item",
1537 "seeds",
1538 json!({"enumerated": true, "marker": "3."})
1539 ),
1540 text(
1541 4,
1542 "list_item",
1543 "insects",
1544 json!({"enumerated": true, "marker": "4."})
1545 ),
1546 ]),
1547 json!([{"self_ref": "#/groups/0", "children": [{"$ref": "#/texts/3"}, {"$ref": "#/texts/4"}], "label": "list", "name": "list"}]),
1548 json!([]),
1549 );
1550 let b = blocks(&j);
1551 assert_eq!(b[0], ast::header(1, vec![ast::str_("Ducks")]));
1552 assert_eq!(b[1], ast::header(2, vec![ast::str_("Diet")]));
1553 assert_eq!(b[2]["t"], "Para");
1555 assert_eq!(b[2]["c"][0]["t"], "Link");
1556 assert_eq!(b[2]["c"][0]["c"][1][0]["t"], "Strong");
1557 assert_eq!(b[3]["t"], "OrderedList");
1558 assert_eq!(b[3]["c"][0][0], 3, "start from the first marker");
1559 assert_eq!(
1560 b[3]["c"][1],
1561 json!([
1562 [ast::plain(vec![ast::str_("seeds")])],
1563 [ast::plain(vec![ast::str_("insects")])]
1564 ])
1565 );
1566 }
1567
1568 #[test]
1569 fn code_formulas_checkboxes_and_unmapped_labels() {
1570 let j = doc(
1571 &["#/texts/0", "#/texts/1", "#/texts/2", "#/texts/3"],
1572 json!([
1573 text(0, "code", "print(1)", json!({"code_language": "Python"})),
1574 text(1, "formula", "E = mc^2", json!({})),
1575 text(2, "checkbox_selected", "done", json!({})),
1576 text(3, "reference", "[1] Duck, D. (2020).", json!({})),
1577 ]),
1578 json!([]),
1579 json!([]),
1580 );
1581 let b = blocks(&j);
1582 assert_eq!(b[0], ast::code_block(Some("python"), "print(1)"));
1583 assert_eq!(b[1], ast::para(vec![ast::math(true, "E = mc^2")]));
1584 assert_eq!(b[2]["c"][0], ast::str_("☒"));
1585 assert_eq!(b[3]["t"], "Div");
1586 assert_eq!(b[3]["c"][0][1], json!(["docling-reference"]));
1587 }
1588
1589 #[test]
1590 fn tables_keep_spans_header_rows_and_captions() {
1591 let cell = |r0: usize, c0: usize, rs: usize, cs: usize, t: &str, header: bool| {
1592 json!({"text": t, "row_span": rs, "col_span": cs,
1593 "start_row_offset_idx": r0, "end_row_offset_idx": r0 + rs,
1594 "start_col_offset_idx": c0, "end_col_offset_idx": c0 + cs,
1595 "column_header": header})
1596 };
1597 let j = doc(
1598 &["#/tables/0"],
1599 json!([text(0, "caption", "Table 1: Ducks", json!({}))]),
1600 json!([]),
1601 json!([{"self_ref": "#/tables/0", "children": [{"$ref": "#/texts/0"}], "label": "table",
1602 "captions": [{"$ref": "#/texts/0"}],
1603 "data": {"num_rows": 2, "num_cols": 2, "table_cells": [
1604 cell(0, 0, 1, 2, "Species", true),
1605 cell(1, 0, 1, 1, "Mallard", false),
1606 cell(1, 1, 1, 1, "Anas", false)]}}]),
1607 );
1608 let b = blocks(&j);
1609 assert_eq!(b.len(), 1, "the caption is rendered by its table only");
1610 let t = &b[0];
1611 assert_eq!(t["t"], "Table");
1612 assert_eq!(
1613 t["c"][1][1][0],
1614 ast::plain(vec![
1615 ast::str_("Table"),
1616 ast::space(),
1617 ast::str_("1:"),
1618 ast::space(),
1619 ast::str_("Ducks")
1620 ])
1621 );
1622 assert_eq!(t["c"][2].as_array().unwrap().len(), 2);
1623 let head = t["c"][3][1].as_array().unwrap();
1624 assert_eq!(head.len(), 1);
1625 assert_eq!(head[0][1][0][3], 2, "colspan");
1626 assert_eq!(t["c"][4][0][3].as_array().unwrap().len(), 1, "one body row");
1627 }
1628
1629 #[test]
1630 fn api_version_is_checked() {
1631 for ok in ["1.23", "1.23.1", "1.23.1.1"] {
1632 assert!(check_api_version(ok).is_ok(), "{ok}");
1633 }
1634 for bad in ["1.22", "2.0", "1", "1.23.2", "1.23.1.2", "x.y", ""] {
1635 let e = check_api_version(bad).unwrap_err();
1636 assert!(e.to_string().contains("only 1.23"), "{bad}: {e}");
1637 }
1638 let err = to_pandoc(
1639 &DoclingDocument::new("t"),
1640 &PandocExportOptions {
1641 api_version: Some("1.22".into()),
1642 ..Default::default()
1643 },
1644 )
1645 .unwrap_err();
1646 assert_eq!(
1647 err,
1648 PandocError::UnsupportedApiVersion {
1649 requested: "1.22".into()
1650 }
1651 );
1652 }
1653
1654 #[test]
1655 fn empty_document_is_a_valid_pandoc_document() {
1656 let (s, artifacts) =
1657 to_pandoc(&DoclingDocument::new("e"), &PandocExportOptions::default()).unwrap();
1658 assert!(artifacts.is_empty());
1659 assert_eq!(
1660 s,
1661 r#"{"pandoc-api-version":[1,23,1,1],"meta":{},"blocks":[]}"#
1662 );
1663 assert_eq!(s, DoclingDocument::new("e").export_to_pandoc_json());
1664 }
1665}