Skip to main content

docling_core/
pandoc.rs

1//! Pandoc AST output (`--to pandoc`, #515): the document as the JSON
2//! serialization of Pandoc's `Pandoc` type (`pandoc -f json`), which hands
3//! docling.rs's parsing to every Pandoc writer — DOCX, ODT, EPUB, RST, Org,
4//! Typst, AsciiDoc, … — through `docling-rs in.pdf --to pandoc | pandoc -f
5//! json -t docx -o out.docx`.
6//!
7//! Like [`crate::html`] and [`crate::latex`] this walks the *JSON* document
8//! model ([`export_to_json_value`](crate::DoclingDocument::export_to_json_value))
9//! rather than the flat [`Node`](crate::Node) stream: the JSON export already
10//! carries docling's item structure for every backend — the backend-built
11//! item tree where one exists (HTML, DOCX: heading nesting, inline groups of
12//! formatted runs, rich table cells), docling's generic grouping rules
13//! otherwise — so the AST inherits those decisions instead of re-deriving
14//! them. The walk is the HTML serializer's (`_iterate_items` with groups,
15//! pictures not traversed, captions rendered by the item that owns them,
16//! content layers filtered with their children still walked); only the
17//! node it emits differs.
18//!
19//! Mapping (Pandoc constructor ← docling item):
20//!
21//! | docling | Pandoc |
22//! |---|---|
23//! | `title` | `Header 1` |
24//! | `section_header` (level *n*) | `Header (n+1)`, capped at 6 |
25//! | `text`, `paragraph` | `Para` (`Plain` inside lists and table cells) |
26//! | `inline` group | the runs' inlines joined by `Space` |
27//! | formatting / hyperlink | `Strong`, `Emph`, `Underline`, `Strikeout`, `Subscript`, `Superscript`, then `Link` around it all |
28//! | `list` group | `OrderedList` (first item enumerated; start from an `N.` marker) / `BulletList`, nested lists inside their item |
29//! | `code` | `CodeBlock` with the language as its class (`Code` inline) |
30//! | `formula` | `Para [Math DisplayMath]` (`Math InlineMath` inline) |
31//! | `checkbox_selected` / `_unselected` | the text after `☒` / `☐` — Pandoc's own task-list convention |
32//! | `table` | `Table`: leading all-header rows as `TableHead`, `rowspan` / `colspan`, rich cells as their blocks, captions as the caption |
33//! | `picture` | `Figure` holding the `Image` per [`ImageMode`], captions as the caption, a tabular chart's data as a `Table` |
34//! | footnotes of a table / picture | `Note` at the end of its caption (the float is the call site) |
35//! | key-value / form graph | `Div .key-value-region` / `.form-container` holding a `DefinitionList` (or the nested `BulletList` of a hierarchical graph) |
36//! | form `field_region` | `Div .field-region` holding a `DefinitionList`: `marker` + `field_key` as the term, each `field_value` a definition |
37//! | any other text label (`caption` without an owner, `footnote` without one, `reference`, `handwritten_text`, `page_header`, …) | `Div .docling-<label> [Para]` |
38//!
39//! No Pandoc equivalent, so not written: provenance (pages, bounding boxes),
40//! confidence and classification meta, comments' authorship, form field
41//! geometry; furniture (headers/footers) and notes are off unless their
42//! [`ContentLayers`] are asked for, exactly as in the HTML export. Images
43//! are targets, not bytes, in Pandoc: [`ImageMode::Embedded`] writes `data:`
44//! URIs (Pandoc's writers accept them), [`ImageMode::Referenced`] files under
45//! the artifacts directory, and the default [`ImageMode::Placeholder`] a
46//! `Figure` carrying only its caption — or, for a caption-less picture,
47//! docling's `<!-- image -->` as a `RawBlock html` (an empty `Figure` would
48//! render as an empty frame; non-HTML writers drop the raw block).
49//!
50//! Version: the output is stamped [`PANDOC_API_VERSION`] — the latest
51//! `pandoc-types` (Pandoc 3.x). Pandoc accepts a document whose
52//! `pandoc-api-version` agrees on the first two components; a caller asking
53//! for another API ([`PandocExportOptions::api_version`]) gets
54//! [`PandocError::UnsupportedApiVersion`] rather than a document Pandoc would
55//! reject. Every node is built through the [`ast`] constructors, the one
56//! place a future API change touches.
57
58use std::collections::{BTreeMap, HashMap, HashSet};
59
60use serde_json::Value;
61
62use crate::document::{ContentLayers, DoclingDocument};
63use crate::markdown::ImageMode;
64
65/// The `pandoc-api-version` written — `pandoc-types` 1.23.1.1 (Pandoc 3.x).
66pub const PANDOC_API_VERSION: [u32; 4] = [1, 23, 1, 1];
67
68/// A Pandoc export: the JSON AST, and — for [`ImageMode::Referenced`] — the
69/// image files it links to, as `(path under artifacts_dir, bytes)`.
70pub type PandocOutput = (String, Vec<(String, Vec<u8>)>);
71
72/// Options for [`to_pandoc`].
73#[derive(Debug, Clone)]
74pub struct PandocExportOptions {
75    /// How pictures carry their image (see the module docs).
76    pub image_mode: ImageMode,
77    /// The directory referenced images are written under
78    /// (`<artifacts_dir>/image_NNNNNN.<ext>`, the Markdown export's names).
79    pub artifacts_dir: String,
80    /// The content layers written; body only by default.
81    pub layers: ContentLayers,
82    /// The Pandoc API version the caller needs (`"1.23"`, `"1.23.1.1"`, …);
83    /// `None` = [`PANDOC_API_VERSION`]. Only that API is supported.
84    pub api_version: Option<String>,
85}
86
87impl Default for PandocExportOptions {
88    fn default() -> Self {
89        Self {
90            image_mode: ImageMode::Placeholder,
91            artifacts_dir: "artifacts".to_string(),
92            layers: ContentLayers::BODY,
93            api_version: None,
94        }
95    }
96}
97
98/// Why a Pandoc export was refused.
99#[derive(Debug, Clone, PartialEq, Eq)]
100pub enum PandocError {
101    /// The requested `pandoc-api-version` is not the one supported.
102    UnsupportedApiVersion { requested: String },
103}
104
105impl std::fmt::Display for PandocError {
106    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
107        match self {
108            PandocError::UnsupportedApiVersion { requested } => write!(
109                f,
110                "unsupported Pandoc API version '{requested}': only {} (pandoc-types {}, Pandoc 3.x) is supported",
111                version_string(&PANDOC_API_VERSION[..2]),
112                version_string(&PANDOC_API_VERSION),
113            ),
114        }
115    }
116}
117
118impl std::error::Error for PandocError {}
119
120fn version_string(parts: &[u32]) -> String {
121    parts
122        .iter()
123        .map(u32::to_string)
124        .collect::<Vec<_>>()
125        .join(".")
126}
127
128/// Check a requested API version: Pandoc reads a document whose version
129/// shares its first two components (major API, minor API), so `1.23`,
130/// `1.23.1` and `1.23.1.1` are all this export's; anything else is refused.
131pub fn check_api_version(requested: &str) -> Result<(), PandocError> {
132    let err = || PandocError::UnsupportedApiVersion {
133        requested: requested.to_string(),
134    };
135    let parts: Vec<u32> = requested
136        .trim()
137        .split('.')
138        .map(|p| p.parse::<u32>().map_err(|_| err()))
139        .collect::<Result<_, _>>()?;
140    if parts.len() < 2 || parts[..2] != PANDOC_API_VERSION[..2] {
141        return Err(err());
142    }
143    if parts.len() > PANDOC_API_VERSION.len()
144        || parts
145            .iter()
146            .zip(PANDOC_API_VERSION.iter())
147            .skip(2)
148            .any(|(a, b)| a > b)
149    {
150        // A newer patch than this export knows.
151        return Err(err());
152    }
153    Ok(())
154}
155
156/// The document as Pandoc JSON (compact, one line, like `pandoc -t json`),
157/// and for [`ImageMode::Referenced`] the `(path, bytes)` image files to
158/// write.
159pub fn to_pandoc(
160    doc: &DoclingDocument,
161    options: &PandocExportOptions,
162) -> Result<PandocOutput, PandocError> {
163    from_docling_json(&doc.export_to_json_value(), options)
164}
165
166/// [`to_pandoc`] for a document already in docling's JSON wire format —
167/// e.g. one produced by Python docling, or `DoclingDocument.export_to_dict()`
168/// in the Python bindings.
169pub fn from_docling_json(
170    json: &Value,
171    options: &PandocExportOptions,
172) -> Result<PandocOutput, PandocError> {
173    if let Some(v) = &options.api_version {
174        check_api_version(v)?;
175    }
176    // The walk recurses per nesting level; an XBRL instance nests thousands
177    // deep, so it runs on a big-stack thread like the HTML serializer.
178    let render = || {
179        let mut ser = Serializer::new(json, options);
180        let blocks = ser.body();
181        let doc = ast::document(blocks);
182        (
183            serde_json::to_string(&doc).expect("Pandoc JSON is always serializable"),
184            ser.artifacts,
185        )
186    };
187    #[cfg(not(target_arch = "wasm32"))]
188    {
189        Ok(std::thread::scope(|scope| {
190            std::thread::Builder::new()
191                .name("docling-pandoc".into())
192                .stack_size(256 << 20)
193                .spawn_scoped(scope, render)
194                .expect("spawn the pandoc serializer thread")
195                .join()
196                .expect("pandoc serializer thread panicked")
197        }))
198    }
199    #[cfg(target_arch = "wasm32")]
200    {
201        Ok(render())
202    }
203}
204
205/// The Pandoc AST constructors (`Text.Pandoc.Definition`, JSON per
206/// `Text.Pandoc.JSON`): every node this module writes is built here.
207pub mod ast {
208    use serde_json::{json, Value};
209
210    use super::PANDOC_API_VERSION;
211
212    pub fn document(blocks: Vec<Value>) -> Value {
213        json!({ "pandoc-api-version": PANDOC_API_VERSION, "meta": {}, "blocks": blocks })
214    }
215
216    /// `Attr`: identifier, classes, key-value pairs.
217    pub fn attr(classes: &[&str]) -> Value {
218        json!(["", classes, []])
219    }
220
221    fn node(t: &str, c: Value) -> Value {
222        json!({ "t": t, "c": c })
223    }
224
225    // --- blocks -------------------------------------------------------------
226
227    pub fn para(inlines: Vec<Value>) -> Value {
228        node("Para", json!(inlines))
229    }
230    pub fn plain(inlines: Vec<Value>) -> Value {
231        node("Plain", json!(inlines))
232    }
233    pub fn header(level: usize, inlines: Vec<Value>) -> Value {
234        node("Header", json!([level, attr(&[]), inlines]))
235    }
236    pub fn code_block(language: Option<&str>, text: &str) -> Value {
237        let classes: Vec<&str> = language.into_iter().collect();
238        node("CodeBlock", json!([attr(&classes), text]))
239    }
240    pub fn bullet_list(items: Vec<Vec<Value>>) -> Value {
241        node("BulletList", json!(items))
242    }
243    pub fn ordered_list(start: u64, items: Vec<Vec<Value>>) -> Value {
244        node(
245            "OrderedList",
246            json!([[start, { "t": "Decimal" }, { "t": "Period" }], items]),
247        )
248    }
249    pub fn definition_list(entries: Vec<(Vec<Value>, Vec<Vec<Value>>)>) -> Value {
250        node(
251            "DefinitionList",
252            Value::Array(
253                entries
254                    .into_iter()
255                    .map(|(term, defs)| json!([term, defs]))
256                    .collect(),
257            ),
258        )
259    }
260    /// `RawBlock Format Text` — kept by writers of that format, dropped by
261    /// the others.
262    pub fn raw_block(format: &str, text: &str) -> Value {
263        node("RawBlock", json!([format, text]))
264    }
265    pub fn div(classes: &[&str], blocks: Vec<Value>) -> Value {
266        node("Div", json!([attr(classes), blocks]))
267    }
268    /// `Caption`: no short caption, the long one as blocks.
269    pub fn caption(blocks: Vec<Value>) -> Value {
270        json!([null, blocks])
271    }
272    pub fn figure(classes: &[&str], caption: Value, blocks: Vec<Value>) -> Value {
273        node("Figure", json!([attr(classes), caption, blocks]))
274    }
275    /// A table cell: `Cell Attr Alignment RowSpan ColSpan [Block]`.
276    pub fn cell(row_span: usize, col_span: usize, blocks: Vec<Value>) -> Value {
277        json!([attr(&[]), { "t": "AlignDefault" }, row_span, col_span, blocks])
278    }
279    pub fn row(cells: Vec<Value>) -> Value {
280        json!([attr(&[]), cells])
281    }
282    /// `Table Attr Caption [ColSpec] TableHead [TableBody] TableFoot`.
283    pub fn table(caption: Value, num_cols: usize, head: Vec<Value>, body: Vec<Value>) -> Value {
284        let colspecs: Vec<Value> = (0..num_cols)
285            .map(|_| json!([{ "t": "AlignDefault" }, { "t": "ColWidthDefault" }]))
286            .collect();
287        node(
288            "Table",
289            json!([
290                attr(&[]),
291                caption,
292                colspecs,
293                [attr(&[]), head],
294                [[attr(&[]), 0, [], body]],
295                [attr(&[]), []]
296            ]),
297        )
298    }
299
300    // --- inlines ------------------------------------------------------------
301
302    pub fn str_(s: &str) -> Value {
303        node("Str", json!(s))
304    }
305    pub fn space() -> Value {
306        json!({ "t": "Space" })
307    }
308    pub fn line_break() -> Value {
309        json!({ "t": "LineBreak" })
310    }
311    /// `Strong`, `Emph`, `Underline`, `Strikeout`, `Subscript`, `Superscript`.
312    pub fn wrap(t: &str, inlines: Vec<Value>) -> Value {
313        node(t, json!(inlines))
314    }
315    pub fn code(text: &str) -> Value {
316        node("Code", json!([attr(&[]), text]))
317    }
318    pub fn math(display: bool, tex: &str) -> Value {
319        let kind = if display { "DisplayMath" } else { "InlineMath" };
320        node("Math", json!([{ "t": kind }, tex]))
321    }
322    pub fn link(inlines: Vec<Value>, url: &str) -> Value {
323        node("Link", json!([attr(&[]), inlines, [url, ""]]))
324    }
325    pub fn image(alt: Vec<Value>, src: &str) -> Value {
326        node("Image", json!([attr(&[]), alt, [src, ""]]))
327    }
328    pub fn note(blocks: Vec<Value>) -> Value {
329        node("Note", json!(blocks))
330    }
331}
332
333/// Text as `Str` words and `Space`s, `\n` as `LineBreak`; surrounding
334/// whitespace trimmed (a no-break space stays inside its word, as Pandoc's
335/// readers keep it).
336fn text_inlines(text: &str) -> Vec<Value> {
337    let mut out = Vec::new();
338    for (i, line) in text.trim().split('\n').enumerate() {
339        if i > 0 {
340            out.push(ast::line_break());
341        }
342        let mut first = true;
343        for word in line
344            .split(|c: char| c.is_whitespace() && c != '\u{a0}')
345            .filter(|w| !w.is_empty())
346        {
347            if !first {
348                out.push(ast::space());
349            }
350            out.push(ast::str_(word));
351            first = false;
352        }
353    }
354    out
355}
356
357/// Inline lists joined by single `Space`s (an inline group's runs) — none
358/// before a run that opens with closing punctuation (`.`, `,`, `)`, …) or
359/// after one that ends with opening punctuation: docling keeps each
360/// formatting run as its own item and its serializers join them with a
361/// space, which would put `link .` into every Pandoc output.
362fn join_inlines(parts: Vec<Vec<Value>>) -> Vec<Value> {
363    fn edge_char(inlines: &[Value], first: bool) -> Option<char> {
364        let node = if first {
365            inlines.first()
366        } else {
367            inlines.last()
368        }?;
369        match node.get("t").and_then(Value::as_str)? {
370            "Str" => {
371                let s = node.get("c")?.as_str()?;
372                if first {
373                    s.chars().next()
374                } else {
375                    s.chars().last()
376                }
377            }
378            // Formatting / links: look inside.
379            "Strong" | "Emph" | "Underline" | "Strikeout" | "Subscript" | "Superscript" => {
380                edge_char(node.get("c")?.as_array()?, first)
381            }
382            "Link" => edge_char(node.get("c")?.get(1)?.as_array()?, first),
383            _ => None,
384        }
385    }
386    let mut out: Vec<Value> = Vec::new();
387    for part in parts.into_iter().filter(|p| !p.is_empty()) {
388        if !out.is_empty() {
389            let closes = edge_char(&part, true)
390                .is_some_and(|c| ".,;:!?)]}%\u{bb}\u{201d}\u{2019}".contains(c));
391            let opens = edge_char(&out, false).is_some_and(|c| "([{\u{ab}\u{201c}".contains(c));
392            if !closes && !opens {
393                out.push(ast::space());
394            }
395        }
396        out.extend(part);
397    }
398    out
399}
400
401/// An `N.` / `N)` list marker's number (an ordered list's `start`).
402fn marker_start(marker: &str) -> Option<u64> {
403    marker
404        .trim()
405        .trim_end_matches(['.', ')'])
406        .parse::<u64>()
407        .ok()
408}
409
410struct GridCell<'a> {
411    cell: &'a Value,
412    start_row: usize,
413    start_col: usize,
414    row_span: usize,
415    col_span: usize,
416}
417
418struct Serializer<'a> {
419    json: &'a Value,
420    image_mode: ImageMode,
421    artifacts_dir: String,
422    layers: ContentLayers,
423    artifacts: Vec<(String, Vec<u8>)>,
424    pic_index: usize,
425    visited: HashSet<String>,
426    /// Items some table / picture / graph holds as a caption or footnote:
427    /// rendered by their owner, skipped where the walk meets them.
428    owned_captions: HashSet<String>,
429    owned_footnotes: HashSet<String>,
430}
431
432impl<'a> Serializer<'a> {
433    fn new(json: &'a Value, options: &PandocExportOptions) -> Self {
434        let mut owned_captions = HashSet::new();
435        let mut owned_footnotes = HashSet::new();
436        for bucket in [
437            "pictures",
438            "tables",
439            "key_value_items",
440            "form_items",
441            "texts",
442        ] {
443            for item in Self::array(json.get(bucket)) {
444                for (key, set) in [
445                    ("captions", &mut owned_captions),
446                    ("footnotes", &mut owned_footnotes),
447                ] {
448                    for r in Self::array(item.get(key)) {
449                        if let Some(cref) = r.get("$ref").and_then(Value::as_str) {
450                            set.insert(cref.to_string());
451                        }
452                    }
453                }
454            }
455        }
456        Self {
457            json,
458            image_mode: options.image_mode,
459            artifacts_dir: options.artifacts_dir.clone(),
460            layers: options.layers,
461            artifacts: Vec::new(),
462            pic_index: 0,
463            visited: HashSet::new(),
464            owned_captions,
465            owned_footnotes,
466        }
467    }
468
469    fn array(v: Option<&'a Value>) -> impl Iterator<Item = &'a Value> {
470        v.and_then(Value::as_array).into_iter().flatten()
471    }
472
473    fn resolve(&self, cref: &str) -> Option<&'a Value> {
474        let rest = cref.strip_prefix("#/")?;
475        if rest == "body" {
476            return self.json.get("body");
477        }
478        let (bucket, idx) = rest.split_once('/')?;
479        self.json.get(bucket)?.get(idx.parse::<usize>().ok()?)
480    }
481
482    fn refs(v: Option<&'a Value>) -> Vec<&'a str> {
483        Self::array(v)
484            .filter_map(|r| r.get("$ref").and_then(Value::as_str))
485            .collect()
486    }
487
488    fn children(item: &'a Value) -> Vec<&'a str> {
489        Self::refs(item.get("children"))
490    }
491
492    fn self_ref(item: &Value) -> &str {
493        item.get("self_ref").and_then(Value::as_str).unwrap_or("")
494    }
495
496    fn label(item: &Value) -> &str {
497        item.get("label").and_then(Value::as_str).unwrap_or("")
498    }
499
500    fn text(item: &Value) -> &str {
501        item.get("text").and_then(Value::as_str).unwrap_or("")
502    }
503
504    fn is_group(item: &Value) -> bool {
505        Self::self_ref(item).starts_with("#/groups/")
506    }
507
508    fn excluded(&self, item: &Value) -> bool {
509        let layer = item
510            .get("content_layer")
511            .and_then(Value::as_str)
512            .unwrap_or("body");
513        !self.layers.contains_name(layer)
514    }
515
516    /// docling's `_iterate_items(with_groups=True, traverse_pictures=False)`
517    /// below `root`, depth-first pre-order; items off the chosen layers are
518    /// not yielded but their subtrees are walked; under a picture only its
519    /// captions are visited.
520    fn descendants(&self, root: &'a Value, out: &mut Vec<&'a Value>) {
521        let picture = Self::self_ref(root).starts_with("#/pictures/");
522        let captions = if picture {
523            Self::refs(root.get("captions"))
524        } else {
525            Vec::new()
526        };
527        for cref in Self::children(root) {
528            if picture && !captions.contains(&cref) {
529                continue;
530            }
531            let Some(child) = self.resolve(cref) else {
532                continue;
533            };
534            if !self.excluded(child) {
535                out.push(child);
536            }
537            self.descendants(child, out);
538        }
539    }
540
541    /// Every not-yet-visited descendant of `item`, serialized in order —
542    /// a container marks what it renders as visited, so the flat walk
543    /// skips it.
544    fn blocks_of(&mut self, item: &'a Value) -> Vec<Value> {
545        let mut nodes = Vec::new();
546        self.descendants(item, &mut nodes);
547        let mut out = Vec::new();
548        for node in nodes {
549            if self.visited.insert(Self::self_ref(node).to_string()) {
550                out.extend(self.serialize(node));
551            }
552        }
553        out
554    }
555
556    fn body(&mut self) -> Vec<Value> {
557        let Some(body) = self.json.get("body") else {
558            return Vec::new();
559        };
560        self.visited.insert("#/body".to_string());
561        self.blocks_of(body)
562    }
563
564    /// One item's blocks.
565    fn serialize(&mut self, item: &'a Value) -> Vec<Value> {
566        let sref = Self::self_ref(item);
567        if Self::is_group(item) {
568            return match Self::label(item) {
569                "list" => self.list_group(item),
570                "inline" => {
571                    let (inlines, mut rest) = self.inline_group(item);
572                    let mut out = Vec::new();
573                    if !inlines.is_empty() {
574                        out.push(ast::para(inlines));
575                    }
576                    out.append(&mut rest);
577                    out
578                }
579                _ => self.blocks_of(item),
580            };
581        }
582        if sref.starts_with("#/texts/") {
583            if self.owned_captions.contains(sref) || self.owned_footnotes.contains(sref) {
584                return Vec::new();
585            }
586            if self.excluded(item) {
587                return Vec::new();
588            }
589            return self.text_item(item);
590        }
591        if sref.starts_with("#/tables/") {
592            return self.table(item);
593        }
594        if sref.starts_with("#/pictures/") {
595            return self.picture(item);
596        }
597        if sref.starts_with("#/key_value_items/") {
598            return self.graph_item(item, "key-value-region");
599        }
600        if sref.starts_with("#/form_items/") {
601            return self.graph_item(item, "form-container");
602        }
603        if sref.starts_with("#/field_regions/") {
604            return self.field_region(item);
605        }
606        if sref.starts_with("#/field_items/") {
607            let entries = vec![self.field_item(item)];
608            return Self::field_blocks(entries);
609        }
610        Vec::new()
611    }
612
613    /// A form's field region (#515): its fields as one `DefinitionList` —
614    /// key → value(s) — in a `Div .field-region`; a field without a key
615    /// contributes its blocks in place.
616    fn field_region(&mut self, region: &'a Value) -> Vec<Value> {
617        let mut entries = Vec::new();
618        for cref in Self::children(region) {
619            let Some(child) = self.resolve(cref) else {
620                continue;
621            };
622            if !self.visited.insert(cref.to_string()) {
623                continue;
624            }
625            if cref.starts_with("#/field_items/") {
626                entries.push(self.field_item(child));
627            } else {
628                let mut blocks = if self.excluded(child) {
629                    Vec::new()
630                } else {
631                    self.serialize(child)
632                };
633                blocks.extend(self.blocks_of(child));
634                entries.push((Vec::new(), blocks));
635            }
636        }
637        let blocks = Self::field_blocks(entries);
638        if blocks.is_empty() {
639            Vec::new()
640        } else {
641            vec![ast::div(&["field-region"], blocks)]
642        }
643    }
644
645    /// One field: its `marker` and `field_key` texts as the term, each
646    /// `field_value` (and anything else under it) as a definition.
647    fn field_item(&mut self, field: &'a Value) -> (Vec<Value>, Vec<Value>) {
648        let mut nodes = Vec::new();
649        self.descendants(field, &mut nodes);
650        let (mut term, mut defs) = (Vec::new(), Vec::new());
651        for node in nodes {
652            if !self.visited.insert(Self::self_ref(node).to_string()) {
653                continue;
654            }
655            match Self::label(node) {
656                "marker" | "field_key" => {
657                    let inlines = self.formatted(node);
658                    if !inlines.is_empty() {
659                        term = join_inlines(vec![term, inlines]);
660                    }
661                }
662                "field_value" => {
663                    let inlines = self.formatted(node);
664                    if !inlines.is_empty() {
665                        defs.push(ast::plain(inlines));
666                    }
667                }
668                _ => defs.extend(self.serialize(node)),
669            }
670        }
671        (term, defs)
672    }
673
674    /// Fields as blocks: runs of keyed fields become `DefinitionList`s, a
675    /// key-less field's blocks stand on their own.
676    fn field_blocks(entries: Vec<(Vec<Value>, Vec<Value>)>) -> Vec<Value> {
677        let mut out = Vec::new();
678        let mut run: Vec<(Vec<Value>, Vec<Vec<Value>>)> = Vec::new();
679        for (term, defs) in entries {
680            if term.is_empty() {
681                if !run.is_empty() {
682                    out.push(ast::definition_list(std::mem::take(&mut run)));
683                }
684                out.extend(defs);
685            } else {
686                let defs = defs.into_iter().map(|d| vec![d]).collect();
687                run.push((term, defs));
688            }
689        }
690        if !run.is_empty() {
691            out.push(ast::definition_list(run));
692        }
693        out
694    }
695
696    /// A text item's own inlines: its formatted text, or — for an empty
697    /// item wrapping one inline group (a list item or heading whose content
698    /// is a run of formatted spans) — that group's, with any blocks the
699    /// group held besides.
700    fn own_inlines(&mut self, item: &'a Value) -> (Vec<Value>, Vec<Value>, bool) {
701        let children = Self::children(item);
702        if Self::text(item).is_empty() && children.len() == 1 {
703            if let Some(group) = self
704                .resolve(children[0])
705                .filter(|c| Self::is_group(c) && Self::label(c) == "inline")
706            {
707                self.visited.insert(Self::self_ref(group).to_string());
708                let (inlines, rest) = self.inline_group(group);
709                return (inlines, rest, true);
710            }
711        }
712        (self.formatted(item), Vec::new(), false)
713    }
714
715    /// The item's text with its formatting and hyperlink applied.
716    fn formatted(&self, item: &Value) -> Vec<Value> {
717        let base = match Self::label(item) {
718            "code" => vec![ast::code(Self::text(item))],
719            "formula" if !Self::text(item).is_empty() => {
720                vec![ast::math(false, Self::text(item).trim())]
721            }
722            _ => text_inlines(Self::text(item)),
723        };
724        Self::decorate(base, item)
725    }
726
727    fn decorate(mut inlines: Vec<Value>, item: &Value) -> Vec<Value> {
728        if inlines.is_empty() {
729            return inlines;
730        }
731        if let Some(f) = item.get("formatting") {
732            let on = |k: &str| f.get(k).and_then(Value::as_bool).unwrap_or(false);
733            if on("bold") {
734                inlines = vec![ast::wrap("Strong", inlines)];
735            }
736            if on("italic") {
737                inlines = vec![ast::wrap("Emph", inlines)];
738            }
739            if on("underline") {
740                inlines = vec![ast::wrap("Underline", inlines)];
741            }
742            if on("strikethrough") {
743                inlines = vec![ast::wrap("Strikeout", inlines)];
744            }
745            match f.get("script").and_then(Value::as_str) {
746                Some("sub") => inlines = vec![ast::wrap("Subscript", inlines)],
747                Some("super") => inlines = vec![ast::wrap("Superscript", inlines)],
748                _ => {}
749            }
750        }
751        if let Some(url) = item.get("hyperlink").and_then(Value::as_str) {
752            inlines = vec![ast::link(inlines, url)];
753        }
754        inlines
755    }
756
757    /// An inline group's runs as inlines joined by `Space`; anything in it
758    /// that has no inline form (a nested list, a table) comes back as
759    /// blocks for after the paragraph.
760    fn inline_group(&mut self, group: &'a Value) -> (Vec<Value>, Vec<Value>) {
761        let mut nodes = Vec::new();
762        self.descendants(group, &mut nodes);
763        let mut runs: Vec<Vec<Value>> = Vec::new();
764        let mut rest: Vec<Value> = Vec::new();
765        for node in nodes {
766            let r = Self::self_ref(node).to_string();
767            if self.visited.contains(&r) {
768                continue;
769            }
770            self.visited.insert(r.clone());
771            if r.starts_with("#/texts/") {
772                if self.owned_captions.contains(&r) || self.owned_footnotes.contains(&r) {
773                    continue;
774                }
775                runs.push(self.formatted(node));
776                continue;
777            }
778            if Self::is_group(node) && Self::label(node) == "inline" {
779                let (inl, mut more) = self.inline_group(node);
780                runs.push(inl);
781                rest.append(&mut more);
782                continue;
783            }
784            rest.extend(self.serialize(node));
785        }
786        (join_inlines(runs), rest)
787    }
788
789    fn text_item(&mut self, item: &'a Value) -> Vec<Value> {
790        let label = Self::label(item);
791        let mut out = Vec::new();
792        match label {
793            "list_item" => {
794                // A list item met outside a list group: a one-item list.
795                let blocks = self.list_item_blocks(item);
796                if !blocks.is_empty() {
797                    out.push(ast::bullet_list(vec![blocks]));
798                }
799                return out;
800            }
801            "code" => {
802                let lang = item
803                    .get("code_language")
804                    .and_then(Value::as_str)
805                    .filter(|l| !l.is_empty() && *l != "unknown")
806                    .map(str::to_ascii_lowercase);
807                out.push(ast::code_block(lang.as_deref(), Self::text(item)));
808            }
809            "formula" => {
810                let tex = Self::text(item).trim();
811                if !tex.is_empty() {
812                    out.push(ast::para(vec![ast::math(true, tex)]));
813                }
814            }
815            _ => {
816                let (mut inlines, rest, _) = self.own_inlines(item);
817                match label {
818                    "title" | "section_header" => {
819                        let level = if label == "title" {
820                            1
821                        } else {
822                            (item.get("level").and_then(Value::as_u64).unwrap_or(1) as usize + 1)
823                                .min(6)
824                        };
825                        if !inlines.is_empty() {
826                            out.push(ast::header(level, inlines));
827                        }
828                    }
829                    "text" | "paragraph" => {
830                        if !inlines.is_empty() {
831                            out.push(ast::para(inlines));
832                        }
833                    }
834                    "checkbox_selected" | "checkbox_unselected" => {
835                        let mark = if label == "checkbox_selected" {
836                            "☒"
837                        } else {
838                            "☐"
839                        };
840                        let mut with_mark = vec![ast::str_(mark)];
841                        if !inlines.is_empty() {
842                            with_mark.push(ast::space());
843                            with_mark.append(&mut inlines);
844                        }
845                        out.push(ast::para(with_mark));
846                    }
847                    other => {
848                        if !inlines.is_empty() {
849                            let class = format!("docling-{other}");
850                            out.push(ast::div(&[&class], vec![ast::para(inlines)]));
851                        }
852                    }
853                }
854                out.extend(rest);
855            }
856        }
857        // Content nested under the item (an HTML / DOCX heading's section).
858        out.extend(self.blocks_of(item));
859        out
860    }
861
862    /// A list item's blocks: its own text as `Plain`, then whatever is
863    /// nested under it (sub-lists, paragraphs).
864    fn list_item_blocks(&mut self, item: &'a Value) -> Vec<Value> {
865        let (inlines, rest, _) = self.own_inlines(item);
866        let mut blocks = Vec::new();
867        if !inlines.is_empty() {
868            blocks.push(ast::plain(inlines));
869        }
870        blocks.extend(rest);
871        blocks.extend(self.blocks_of(item));
872        blocks
873    }
874
875    fn list_group(&mut self, group: &'a Value) -> Vec<Value> {
876        let first = Self::children(group)
877            .first()
878            .and_then(|r| self.resolve(r))
879            .filter(|c| Self::label(c) == "list_item");
880        let enumerated = first.is_some_and(|c| {
881            c.get("enumerated")
882                .and_then(Value::as_bool)
883                .unwrap_or(false)
884        });
885        let start = first
886            .and_then(|c| c.get("marker").and_then(Value::as_str))
887            .and_then(marker_start)
888            .unwrap_or(1);
889
890        let mut nodes = Vec::new();
891        self.descendants(group, &mut nodes);
892        let mut items: Vec<Vec<Value>> = Vec::new();
893        for node in nodes {
894            let r = Self::self_ref(node).to_string();
895            if !self.visited.insert(r.clone()) {
896                continue;
897            }
898            if Self::label(node) == "list_item"
899                && r.starts_with("#/texts/")
900                && !self.owned_captions.contains(&r)
901            {
902                let blocks = self.list_item_blocks(node);
903                items.push(blocks);
904                continue;
905            }
906            // Something that is not a list item directly in the list (a
907            // paragraph between items): it belongs to the item before it.
908            let blocks = self.serialize(node);
909            if blocks.is_empty() {
910                continue;
911            }
912            match items.last_mut() {
913                Some(last) => last.extend(blocks),
914                None => items.push(blocks),
915            }
916        }
917        if items.is_empty() {
918            return Vec::new();
919        }
920        if enumerated {
921            vec![ast::ordered_list(start, items)]
922        } else {
923            vec![ast::bullet_list(items)]
924        }
925    }
926
927    /// The owner's caption texts as one `Caption`, with its footnotes as
928    /// `Note`s at the end — the float is their call site.
929    fn caption(&self, item: &'a Value) -> Value {
930        let mut parts: Vec<Vec<Value>> = Vec::new();
931        for cref in Self::refs(item.get("captions")) {
932            if let Some(cap) = self.resolve(cref) {
933                if cref.starts_with("#/texts/") && !self.excluded(cap) {
934                    parts.push(text_inlines(Self::text(cap)));
935                }
936            }
937        }
938        let mut inlines = join_inlines(parts);
939        for cref in Self::refs(item.get("footnotes")) {
940            if let Some(note) = self.resolve(cref) {
941                if cref.starts_with("#/texts/") && !self.excluded(note) {
942                    let text = text_inlines(Self::text(note));
943                    if !text.is_empty() {
944                        inlines.push(ast::note(vec![ast::para(text)]));
945                    }
946                }
947            }
948        }
949        if inlines.is_empty() {
950            ast::caption(Vec::new())
951        } else {
952            ast::caption(vec![ast::plain(inlines)])
953        }
954    }
955
956    /// docling's `TableData.grid`: each position → the cell covering it.
957    fn grid(data: &'a Value) -> (usize, Vec<Vec<Option<GridCell<'a>>>>) {
958        let num_rows = data.get("num_rows").and_then(Value::as_u64).unwrap_or(0) as usize;
959        let num_cols = data.get("num_cols").and_then(Value::as_u64).unwrap_or(0) as usize;
960        let mut grid: Vec<Vec<Option<GridCell<'a>>>> = (0..num_rows)
961            .map(|_| (0..num_cols).map(|_| None).collect())
962            .collect();
963        for cell in Self::array(data.get("table_cells")) {
964            let get = |k: &str| cell.get(k).and_then(Value::as_u64).unwrap_or(0) as usize;
965            let (r0, c0) = (get("start_row_offset_idx"), get("start_col_offset_idx"));
966            let (r1, c1) = (get("end_row_offset_idx"), get("end_col_offset_idx"));
967            if r0 >= num_rows || c0 >= num_cols {
968                continue;
969            }
970            let (r1, c1) = (r1.clamp(r0 + 1, num_rows), c1.clamp(c0 + 1, num_cols));
971            for row in grid.iter_mut().take(r1).skip(r0) {
972                for slot in row.iter_mut().take(c1).skip(c0) {
973                    *slot = Some(GridCell {
974                        cell,
975                        start_row: r0,
976                        start_col: c0,
977                        row_span: r1 - r0,
978                        col_span: c1 - c0,
979                    });
980                }
981            }
982        }
983        (num_cols, grid)
984    }
985
986    fn table_from_data(&mut self, data: &'a Value, caption: Value) -> Option<Value> {
987        let (num_cols, grid) = Self::grid(data);
988        if grid.is_empty() || num_cols == 0 {
989            return None;
990        }
991        let flag = |c: &Value, k: &str| c.get(k).and_then(Value::as_bool).unwrap_or(false);
992        let mut rows: Vec<(bool, Value)> = Vec::new();
993        for (i, row) in grid.iter().enumerate() {
994            let mut cells = Vec::new();
995            let mut all_header = true;
996            let mut anchored = 0;
997            for (j, slot) in row.iter().enumerate() {
998                let Some(g) = slot else {
999                    // A position no cell covers (a table's empty corner) is
1000                    // neutral to the header test.
1001                    cells.push(ast::cell(1, 1, Vec::new()));
1002                    continue;
1003                };
1004                if g.start_row != i || g.start_col != j {
1005                    continue;
1006                }
1007                anchored += 1;
1008                all_header &= flag(g.cell, "column_header");
1009                let blocks = match g
1010                    .cell
1011                    .get("ref")
1012                    .and_then(|r| r.get("$ref"))
1013                    .and_then(Value::as_str)
1014                {
1015                    Some(cref) => match self.resolve(cref) {
1016                        Some(target) => {
1017                            self.visited.insert(cref.to_string());
1018                            self.serialize(target)
1019                        }
1020                        None => Vec::new(),
1021                    },
1022                    None => {
1023                        let inl =
1024                            text_inlines(g.cell.get("text").and_then(Value::as_str).unwrap_or(""));
1025                        if inl.is_empty() {
1026                            Vec::new()
1027                        } else {
1028                            vec![ast::plain(inl)]
1029                        }
1030                    }
1031                };
1032                cells.push(ast::cell(g.row_span, g.col_span, blocks));
1033            }
1034            rows.push((all_header && anchored > 0, ast::row(cells)));
1035        }
1036        // Leading all-header rows form the head; the rest is the body.
1037        let head_len = rows.iter().take_while(|(h, _)| *h).count();
1038        let head_len = if head_len == rows.len() { 0 } else { head_len };
1039        let mut head = Vec::new();
1040        let mut body = Vec::new();
1041        for (i, (_, row)) in rows.into_iter().enumerate() {
1042            if i < head_len {
1043                head.push(row);
1044            } else {
1045                body.push(row);
1046            }
1047        }
1048        Some(ast::table(caption, num_cols, head, body))
1049    }
1050
1051    fn table(&mut self, item: &'a Value) -> Vec<Value> {
1052        let caption = self.caption(item);
1053        if self.excluded(item) {
1054            return Vec::new();
1055        }
1056        match item.get("data") {
1057            Some(data) => self.table_from_data(data, caption).into_iter().collect(),
1058            None => Vec::new(),
1059        }
1060    }
1061
1062    fn picture(&mut self, item: &'a Value) -> Vec<Value> {
1063        if self.excluded(item) {
1064            return Vec::new();
1065        }
1066        let caption = self.caption(item);
1067        let mut blocks = Vec::new();
1068        let uri = item
1069            .get("image")
1070            .and_then(|i| i.get("uri"))
1071            .and_then(Value::as_str);
1072        let src = match (self.image_mode, uri) {
1073            (ImageMode::Embedded, Some(uri)) if uri.starts_with("data:") => Some(uri.to_string()),
1074            (ImageMode::Referenced, Some(uri)) => self
1075                .reference_image(uri)
1076                .or_else(|| (!uri.starts_with("data:")).then(|| uri.to_string())),
1077            _ => None,
1078        };
1079        if let Some(src) = src {
1080            blocks.push(ast::plain(vec![ast::image(Vec::new(), &src)]));
1081        }
1082        // A native chart's data grid (`meta.tabular_chart.chart_data`).
1083        if let Some(chart) = item
1084            .get("meta")
1085            .and_then(|m| m.get("tabular_chart"))
1086            .and_then(|t| t.get("chart_data"))
1087        {
1088            if let Some(t) = self.table_from_data(chart, ast::caption(Vec::new())) {
1089                blocks.push(t);
1090            }
1091        }
1092        let has_caption = caption
1093            .get(1)
1094            .and_then(Value::as_array)
1095            .is_some_and(|b| !b.is_empty());
1096        if blocks.is_empty() && !has_caption {
1097            // Nothing to show: docling's Markdown placeholder, as raw HTML —
1098            // an invisible comment in HTML / Markdown output, dropped by the
1099            // other writers (an empty `Figure` renders as an empty
1100            // `<figure>` everywhere).
1101            return vec![ast::raw_block("html", "<!-- image -->")];
1102        }
1103        vec![ast::figure(&[], caption, blocks)]
1104    }
1105
1106    /// Referenced mode: a `data:` URI becomes an artifact file named like
1107    /// the Markdown export's; the path is the image target.
1108    fn reference_image(&mut self, uri: &str) -> Option<String> {
1109        let rest = uri.strip_prefix("data:")?;
1110        let (mime, payload) = rest.split_once(";base64,")?;
1111        let bytes = crate::base64::decode(payload)?;
1112        let path = format!(
1113            "{}/image_{:06}.{}",
1114            self.artifacts_dir,
1115            self.pic_index,
1116            crate::markdown::ext_for(mime)
1117        );
1118        self.pic_index += 1;
1119        self.artifacts.push((path.clone(), bytes));
1120        Some(path)
1121    }
1122
1123    /// A key-value / form graph: a `DefinitionList` of keys and their
1124    /// values, or — when `to_child` links make it a hierarchy — the nested
1125    /// `BulletList` of it (the HTML export's two shapes), in a classed `Div`
1126    /// with the item's caption after it.
1127    fn graph_item(&mut self, item: &'a Value, class: &str) -> Vec<Value> {
1128        let mut out = Vec::new();
1129        if !self.excluded(item) {
1130            if let Some(graph) = item.get("graph") {
1131                if let Some(block) = Self::graph(graph) {
1132                    out.push(ast::div(&[class], vec![block]));
1133                }
1134            }
1135        }
1136        // The caption (and footnote notes) as a paragraph after the region.
1137        let caption = self.caption(item);
1138        for block in caption
1139            .get(1)
1140            .and_then(Value::as_array)
1141            .into_iter()
1142            .flatten()
1143        {
1144            if let Some(inlines) = block.get("c").and_then(Value::as_array) {
1145                out.push(ast::para(inlines.clone()));
1146            }
1147        }
1148        out
1149    }
1150
1151    fn graph(graph: &'a Value) -> Option<Value> {
1152        let cells: HashMap<u64, &Value> = Self::array(graph.get("cells"))
1153            .filter_map(|c| Some((c.get("cell_id")?.as_u64()?, c)))
1154            .collect();
1155        let order: Vec<u64> = Self::array(graph.get("cells"))
1156            .filter_map(|c| c.get("cell_id")?.as_u64())
1157            .collect();
1158        let mut child_links: HashMap<u64, Vec<u64>> = HashMap::new();
1159        let mut value_links: BTreeMap<u64, Vec<u64>> = BTreeMap::new();
1160        let mut value_order: Vec<u64> = Vec::new();
1161        let mut parents: HashSet<u64> = HashSet::new();
1162        for link in Self::array(graph.get("links")) {
1163            let (Some(s), Some(t)) = (
1164                link.get("source_cell_id").and_then(Value::as_u64),
1165                link.get("target_cell_id").and_then(Value::as_u64),
1166            ) else {
1167                continue;
1168            };
1169            if !cells.contains_key(&s) || !cells.contains_key(&t) {
1170                continue;
1171            }
1172            match link.get("label").and_then(Value::as_str) {
1173                Some("to_child") => {
1174                    child_links.entry(s).or_default().push(t);
1175                    parents.insert(t);
1176                }
1177                Some("to_value") => {
1178                    if !value_links.contains_key(&s) {
1179                        value_order.push(s);
1180                    }
1181                    value_links.entry(s).or_default().push(t);
1182                }
1183                _ => {}
1184            }
1185        }
1186        let cell_text =
1187            |id: u64| text_inlines(cells[&id].get("text").and_then(Value::as_str).unwrap_or(""));
1188        if child_links.is_empty() {
1189            if value_order.is_empty() {
1190                return None;
1191            }
1192            let entries = value_order
1193                .iter()
1194                .map(|&k| {
1195                    let defs = value_links[&k]
1196                        .iter()
1197                        .map(|&v| vec![ast::plain(cell_text(v))])
1198                        .collect();
1199                    (cell_text(k), defs)
1200                })
1201                .collect();
1202            return Some(ast::definition_list(entries));
1203        }
1204        // Hierarchical: a nested bullet list, cycles cut on the descent path.
1205        fn node(
1206            id: u64,
1207            cell_text: &dyn Fn(u64) -> Vec<Value>,
1208            child_links: &HashMap<u64, Vec<u64>>,
1209            value_links: &BTreeMap<u64, Vec<u64>>,
1210            path: &mut HashSet<u64>,
1211        ) -> Option<Vec<Value>> {
1212            if !path.insert(id) {
1213                return None;
1214            }
1215            let mut inlines = cell_text(id);
1216            if let Some(values) = value_links.get(&id) {
1217                let vals = join_inlines(values.iter().map(|&v| cell_text(v)).collect());
1218                inlines = vec![ast::wrap("Strong", inlines)];
1219                inlines.push(ast::str_(":"));
1220                if !vals.is_empty() {
1221                    inlines.push(ast::space());
1222                    inlines.extend(vals);
1223                }
1224            }
1225            let mut blocks = vec![ast::plain(inlines)];
1226            let kids: Vec<Vec<Value>> = child_links
1227                .get(&id)
1228                .into_iter()
1229                .flatten()
1230                .filter_map(|&c| node(c, cell_text, child_links, value_links, path))
1231                .collect();
1232            if !kids.is_empty() {
1233                blocks.push(ast::bullet_list(kids));
1234            }
1235            path.remove(&id);
1236            Some(blocks)
1237        }
1238        let mut path = HashSet::new();
1239        let items: Vec<Vec<Value>> = order
1240            .iter()
1241            .filter(|id| !parents.contains(id))
1242            .filter_map(|&id| node(id, &cell_text, &child_links, &value_links, &mut path))
1243            .collect();
1244        (!items.is_empty()).then(|| ast::bullet_list(items))
1245    }
1246}
1247
1248#[cfg(test)]
1249mod tests {
1250    use super::*;
1251    use serde_json::json;
1252
1253    /// A docling JSON document from `body` children and the item buckets.
1254    fn doc(body: &[&str], texts: Value, groups: Value, tables: Value) -> Value {
1255        json!({
1256            "schema_name": "DoclingDocument",
1257            "name": "t",
1258            "body": {"self_ref": "#/body", "children": body.iter().map(|r| json!({"$ref": r})).collect::<Vec<_>>()},
1259            "texts": texts, "groups": groups, "tables": tables, "pictures": []
1260        })
1261    }
1262
1263    fn text(i: usize, label: &str, text: &str, extra: Value) -> Value {
1264        let mut v = json!({"self_ref": format!("#/texts/{i}"), "children": [], "label": label, "text": text, "content_layer": "body"});
1265        for (k, x) in extra.as_object().unwrap() {
1266            v[k] = x.clone();
1267        }
1268        v
1269    }
1270
1271    fn blocks(json: &Value) -> Vec<Value> {
1272        let (s, _) = from_docling_json(json, &PandocExportOptions::default()).unwrap();
1273        let v: Value = serde_json::from_str(&s).unwrap();
1274        assert_eq!(v["pandoc-api-version"], json!(PANDOC_API_VERSION));
1275        v["blocks"].as_array().unwrap().clone()
1276    }
1277
1278    #[test]
1279    fn text_splits_into_words_spaces_and_line_breaks() {
1280        assert_eq!(
1281            text_inlines("  two  words\nnext\u{a0}line "),
1282            vec![
1283                ast::str_("two"),
1284                ast::space(),
1285                ast::str_("words"),
1286                ast::line_break(),
1287                ast::str_("next\u{a0}line"),
1288            ]
1289        );
1290        assert!(text_inlines("   ").is_empty());
1291    }
1292
1293    #[test]
1294    fn headings_paragraphs_and_lists() {
1295        let j = doc(
1296            &["#/texts/0", "#/texts/1", "#/texts/2", "#/groups/0"],
1297            json!([
1298                text(0, "title", "Ducks", json!({})),
1299                text(1, "section_header", "Diet", json!({"level": 1})),
1300                text(
1301                    2,
1302                    "text",
1303                    "Ducks eat plants.",
1304                    json!({"formatting": {"bold": true}, "hyperlink": "https://x.org"})
1305                ),
1306                text(
1307                    3,
1308                    "list_item",
1309                    "seeds",
1310                    json!({"enumerated": true, "marker": "3."})
1311                ),
1312                text(
1313                    4,
1314                    "list_item",
1315                    "insects",
1316                    json!({"enumerated": true, "marker": "4."})
1317                ),
1318            ]),
1319            json!([{"self_ref": "#/groups/0", "children": [{"$ref": "#/texts/3"}, {"$ref": "#/texts/4"}], "label": "list", "name": "list"}]),
1320            json!([]),
1321        );
1322        let b = blocks(&j);
1323        assert_eq!(b[0], ast::header(1, vec![ast::str_("Ducks")]));
1324        assert_eq!(b[1], ast::header(2, vec![ast::str_("Diet")]));
1325        // formatting inside, the link around it all
1326        assert_eq!(b[2]["t"], "Para");
1327        assert_eq!(b[2]["c"][0]["t"], "Link");
1328        assert_eq!(b[2]["c"][0]["c"][1][0]["t"], "Strong");
1329        assert_eq!(b[3]["t"], "OrderedList");
1330        assert_eq!(b[3]["c"][0][0], 3, "start from the first marker");
1331        assert_eq!(
1332            b[3]["c"][1],
1333            json!([
1334                [ast::plain(vec![ast::str_("seeds")])],
1335                [ast::plain(vec![ast::str_("insects")])]
1336            ])
1337        );
1338    }
1339
1340    #[test]
1341    fn code_formulas_checkboxes_and_unmapped_labels() {
1342        let j = doc(
1343            &["#/texts/0", "#/texts/1", "#/texts/2", "#/texts/3"],
1344            json!([
1345                text(0, "code", "print(1)", json!({"code_language": "Python"})),
1346                text(1, "formula", "E = mc^2", json!({})),
1347                text(2, "checkbox_selected", "done", json!({})),
1348                text(3, "reference", "[1] Duck, D. (2020).", json!({})),
1349            ]),
1350            json!([]),
1351            json!([]),
1352        );
1353        let b = blocks(&j);
1354        assert_eq!(b[0], ast::code_block(Some("python"), "print(1)"));
1355        assert_eq!(b[1], ast::para(vec![ast::math(true, "E = mc^2")]));
1356        assert_eq!(b[2]["c"][0], ast::str_("☒"));
1357        assert_eq!(b[3]["t"], "Div");
1358        assert_eq!(b[3]["c"][0][1], json!(["docling-reference"]));
1359    }
1360
1361    #[test]
1362    fn tables_keep_spans_header_rows_and_captions() {
1363        let cell = |r0: usize, c0: usize, rs: usize, cs: usize, t: &str, header: bool| {
1364            json!({"text": t, "row_span": rs, "col_span": cs,
1365                   "start_row_offset_idx": r0, "end_row_offset_idx": r0 + rs,
1366                   "start_col_offset_idx": c0, "end_col_offset_idx": c0 + cs,
1367                   "column_header": header})
1368        };
1369        let j = doc(
1370            &["#/tables/0"],
1371            json!([text(0, "caption", "Table 1: Ducks", json!({}))]),
1372            json!([]),
1373            json!([{"self_ref": "#/tables/0", "children": [{"$ref": "#/texts/0"}], "label": "table",
1374                    "captions": [{"$ref": "#/texts/0"}],
1375                    "data": {"num_rows": 2, "num_cols": 2, "table_cells": [
1376                        cell(0, 0, 1, 2, "Species", true),
1377                        cell(1, 0, 1, 1, "Mallard", false),
1378                        cell(1, 1, 1, 1, "Anas", false)]}}]),
1379        );
1380        let b = blocks(&j);
1381        assert_eq!(b.len(), 1, "the caption is rendered by its table only");
1382        let t = &b[0];
1383        assert_eq!(t["t"], "Table");
1384        assert_eq!(
1385            t["c"][1][1][0],
1386            ast::plain(vec![
1387                ast::str_("Table"),
1388                ast::space(),
1389                ast::str_("1:"),
1390                ast::space(),
1391                ast::str_("Ducks")
1392            ])
1393        );
1394        assert_eq!(t["c"][2].as_array().unwrap().len(), 2);
1395        let head = t["c"][3][1].as_array().unwrap();
1396        assert_eq!(head.len(), 1);
1397        assert_eq!(head[0][1][0][3], 2, "colspan");
1398        assert_eq!(t["c"][4][0][3].as_array().unwrap().len(), 1, "one body row");
1399    }
1400
1401    #[test]
1402    fn api_version_is_checked() {
1403        for ok in ["1.23", "1.23.1", "1.23.1.1"] {
1404            assert!(check_api_version(ok).is_ok(), "{ok}");
1405        }
1406        for bad in ["1.22", "2.0", "1", "1.23.2", "1.23.1.2", "x.y", ""] {
1407            let e = check_api_version(bad).unwrap_err();
1408            assert!(e.to_string().contains("only 1.23"), "{bad}: {e}");
1409        }
1410        let err = to_pandoc(
1411            &DoclingDocument::new("t"),
1412            &PandocExportOptions {
1413                api_version: Some("1.22".into()),
1414                ..Default::default()
1415            },
1416        )
1417        .unwrap_err();
1418        assert_eq!(
1419            err,
1420            PandocError::UnsupportedApiVersion {
1421                requested: "1.22".into()
1422            }
1423        );
1424    }
1425
1426    #[test]
1427    fn empty_document_is_a_valid_pandoc_document() {
1428        let (s, artifacts) =
1429            to_pandoc(&DoclingDocument::new("e"), &PandocExportOptions::default()).unwrap();
1430        assert!(artifacts.is_empty());
1431        assert_eq!(
1432            s,
1433            r#"{"pandoc-api-version":[1,23,1,1],"meta":{},"blocks":[]}"#
1434        );
1435        assert_eq!(s, DoclingDocument::new("e").export_to_pandoc_json());
1436    }
1437}