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 — docling's title-reserving depth; under [`HeadingDepth::Source`] (#848) `Header n`, the depth the DOCX / ODT author wrote |
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 — an internal link's target is `#id` (#849); a Markdown `[label](target)` inside an item's text, the flat node chain's hyperlink (`.doc`, AbiWord, …), is a `Link` too (#852) |
28//! | a numbered heading (#850: a DOCX heading numbered through `w:numPr`, docling's `1.2 Title` text with `_number`) | the `Header` holds the authored text alone, `Title` — Pandoc numbers sections itself (`--number-sections`), and its docx reader writes no number, so a numbered AST would show `1.2 1.2 Title`; the Markdown and JSON keep docling's numbered text |
29//! | a link destination (#849: a DOCX bookmark, `_anchors`) | on a heading, its `Header` identifier (Pandoc's own auto-identifier of the heading text, `target-section`, so `#target-section` resolves as it does from `pandoc -f docx`); anywhere else an empty `Span` with the identifier and the class `anchor`, Pandoc's docx reader's shape, at the start of the item's inlines |
30//! | `list` group | `OrderedList` (first item enumerated; start, number style and delimiter from the items' markers — `a)` LowerAlpha OneParen, `iii.` LowerRoman from 3, `(3)` Decimal TwoParens, #835) / `BulletList`, nested lists inside their item |
31//! | `code` | `CodeBlock` with the language as its class (`Code` inline) |
32//! | `formula` | `Para [Math DisplayMath]` (`Math InlineMath` inline) |
33//! | `checkbox_selected` / `_unselected` | the text after `☒` / `☐` — Pandoc's own task-list convention |
34//! | `table` | `Table`: leading all-header rows as `TableHead`, `rowspan` / `colspan`, rich cells as their blocks, captions as the caption |
35//! | `picture` | `Para [Image]` (Pandoc's readers' shape), or a `Figure` holding it when there is a caption or a tabular chart's data `Table`; the `Image`'s alt text is the picture's description — the alt text a DOCX / PPTX / XLSX / ODT author typed (#834) — else its caption; its `width` / `height` attributes the size the document displays it at; the target per [`ImageMode`] |
36//! | footnotes of a table / picture | `Note` at the end of its caption (the float is the call site) |
37//! | key-value / form graph | `Div .key-value-region` / `.form-container` holding a `DefinitionList` (or the nested `BulletList` of a hierarchical graph) |
38//! | form `field_region` | `Div .field-region` holding a `DefinitionList`: `marker` + `field_key` as the term, each `field_value` a definition |
39//! | any other text label (`caption` without an owner, `footnote` without one, `reference`, `handwritten_text`, `page_header`, …) | `Div .docling-<label> [Para]` |
40//! | the document's metadata (#836) | `meta`: `title`, `subject`, `description`, `date` (the document's own, else its creation date), `publisher`, `rights`, `identifier` as `MetaInlines`, `author` and `keywords` as `MetaList`s of `MetaInlines`, `lang` as `MetaString` |
41//! | a tracked change kept under `track_changes=all` (#851: a DOCX `w:ins` / `w:del` / `w:moveTo` / `w:moveFrom` run, `_revision`) | `Span .insertion` / `Span .deletion` with `author` and `date` around the run's inlines — Pandoc's docx reader's `--track-changes=all` shape; under an explicit `accept` / `reject` the document holds that view's text alone and the comments below are left out, as Pandoc leaves them |
42//! | a reviewer comment on an item (#836) | Pandoc's docx reader's `--track-changes=all` shape: a `Span .comment-start` with `comment-id`, `author` and `date` holding the comment's text before the item's inlines, and an empty `Span .comment-end` with the `comment-id` after them; a thread's replies follow as further pairs |
43//!
44//! No Pandoc equivalent, so not written: provenance (pages, bounding boxes),
45//! confidence and classification meta, form field geometry; furniture
46//! (headers/footers) is off unless its [`ContentLayers`] are asked for,
47//! exactly as in the HTML export, and the notes layer's comment sections
48//! are written as the spans above, never as blocks. Images
49//! are targets, not bytes, in Pandoc: the default [`ImageMode::Embedded`]
50//! writes `data:` URIs, so the AST is self-contained and `pandoc -f json -t
51//! docx` embeds every picture (#537); [`ImageMode::Referenced`] links files
52//! under the artifacts directory (relative paths, resolved from where
53//! `pandoc` runs). A picture without a target — [`ImageMode::Placeholder`],
54//! or one whose payload docling cannot decode (EMF/WMF) — is still an
55//! `Image`, classed `docling-placeholder` with an empty target: it survives
56//! into every writer (which reports the missing resource and prints the alt
57//! text) instead of vanishing as raw HTML.
58//!
59//! Version: the output is stamped [`PANDOC_API_VERSION`] — the latest
60//! `pandoc-types` (Pandoc 3.x). Pandoc accepts a document whose
61//! `pandoc-api-version` agrees on the first two components; a caller asking
62//! for another API ([`PandocExportOptions::api_version`]) gets
63//! [`PandocError::UnsupportedApiVersion`] rather than a document Pandoc would
64//! reject. Every node is built through the [`ast`] constructors, the one
65//! place a future API change touches.
66
67use std::collections::{BTreeMap, HashMap, HashSet};
68
69use serde_json::Value;
70
71use crate::document::{ContentLayers, DoclingDocument};
72use crate::markdown::ImageMode;
73use crate::TrackChanges;
74
75/// The `pandoc-api-version` written — `pandoc-types` 1.23.1.1 (Pandoc 3.x).
76pub const PANDOC_API_VERSION: [u32; 4] = [1, 23, 1, 1];
77
78/// A Pandoc export: the JSON AST, and — for [`ImageMode::Referenced`] — the
79/// image files it links to, as `(path under artifacts_dir, bytes)`.
80pub type PandocOutput = (String, Vec<(String, Vec<u8>)>);
81
82/// How a `section_header`'s `level` becomes a Pandoc `Header` depth (#848).
83#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
84pub enum HeadingDepth {
85    /// docling's convention, the default: `Header 1` is the document title
86    /// and a `section_header` of level *n* is `Header (n+1)` (capped at 6),
87    /// the depth docling's Markdown and HTML serializers give it (`##` for
88    /// level 1). The flat `Node::Heading` round trip relies on it — a
89    /// level-2 heading is stored as a level-1 `section_header` and comes
90    /// back as `Header 2` — as do consumers of docling's JSON, so it is not
91    /// changed under them.
92    #[default]
93    Docling,
94    /// The source's own depth: a `section_header` of level *n* is `Header n`
95    /// (clamped to 1–6; a missing or zero level is 1) and the `title` stays
96    /// `Header 1` — what Pandoc's own docx / odt readers give (a DOCX
97    /// `Heading 1` is `Header 1`), so `--number-sections`, TOC depths and
98    /// `--shift-heading-level-by` behave as on `pandoc -f docx`. A document
99    /// whose backend reserves level 1 for the title (HTML, Markdown: an
100    /// `<h2>` is a level-1 `section_header`) comes out one depth shallower
101    /// under it.
102    Source,
103}
104
105impl HeadingDepth {
106    /// The spellings every surface accepts (`docling` = the default).
107    pub const NAMES: [&'static str; 2] = ["docling", "source"];
108
109    pub fn as_str(self) -> &'static str {
110        match self {
111            HeadingDepth::Docling => "docling",
112            HeadingDepth::Source => "source",
113        }
114    }
115}
116
117impl std::str::FromStr for HeadingDepth {
118    type Err = String;
119
120    fn from_str(s: &str) -> Result<Self, String> {
121        match s.trim().to_ascii_lowercase().as_str() {
122            "docling" | "" => Ok(HeadingDepth::Docling),
123            "source" => Ok(HeadingDepth::Source),
124            other => Err(format!(
125                "unknown heading depth {other:?} (expected docling or source)"
126            )),
127        }
128    }
129}
130
131/// Options for [`to_pandoc`].
132#[derive(Debug, Clone)]
133pub struct PandocExportOptions {
134    /// How pictures carry their image (see the module docs); `Embedded` by
135    /// default, so the AST alone rebuilds a document with its pictures.
136    pub image_mode: ImageMode,
137    /// The directory referenced images are written under
138    /// (`<artifacts_dir>/image_NNNNNN.<ext>`, the Markdown export's names).
139    pub artifacts_dir: String,
140    /// The content layers written; body only by default.
141    pub layers: ContentLayers,
142    /// The Pandoc API version the caller needs (`"1.23"`, `"1.23.1.1"`, …);
143    /// `None` = [`PANDOC_API_VERSION`]. Only that API is supported.
144    pub api_version: Option<String>,
145    /// The `Header` depth of a `section_header` (#848): docling's
146    /// title-reserving `n + 1` by default, or the source's own `n`.
147    pub heading_depth: HeadingDepth,
148    /// The tracked-changes policy the document was read under (#851).
149    /// `None` (no policy asked for) and `Some(All)` write the reviewer
150    /// comments as `Span .comment-start` / `.comment-end` pairs; an
151    /// explicit `Accept` / `Reject` leaves them out, as Pandoc's
152    /// `--track-changes=accept|reject` do.
153    pub track_changes: Option<TrackChanges>,
154}
155
156impl Default for PandocExportOptions {
157    fn default() -> Self {
158        Self {
159            image_mode: ImageMode::Embedded,
160            artifacts_dir: "artifacts".to_string(),
161            layers: ContentLayers::BODY,
162            api_version: None,
163            heading_depth: HeadingDepth::Docling,
164            track_changes: None,
165        }
166    }
167}
168
169/// Why a Pandoc export was refused.
170#[derive(Debug, Clone, PartialEq, Eq)]
171pub enum PandocError {
172    /// The requested `pandoc-api-version` is not the one supported.
173    UnsupportedApiVersion { requested: String },
174}
175
176impl std::fmt::Display for PandocError {
177    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
178        match self {
179            PandocError::UnsupportedApiVersion { requested } => write!(
180                f,
181                "unsupported Pandoc API version '{requested}': only {} (pandoc-types {}, Pandoc 3.x) is supported",
182                version_string(&PANDOC_API_VERSION[..2]),
183                version_string(&PANDOC_API_VERSION),
184            ),
185        }
186    }
187}
188
189impl std::error::Error for PandocError {}
190
191fn version_string(parts: &[u32]) -> String {
192    parts
193        .iter()
194        .map(u32::to_string)
195        .collect::<Vec<_>>()
196        .join(".")
197}
198
199/// Check a requested API version: Pandoc reads a document whose version
200/// shares its first two components (major API, minor API), so `1.23`,
201/// `1.23.1` and `1.23.1.1` are all this export's; anything else is refused.
202pub fn check_api_version(requested: &str) -> Result<(), PandocError> {
203    let err = || PandocError::UnsupportedApiVersion {
204        requested: requested.to_string(),
205    };
206    let parts: Vec<u32> = requested
207        .trim()
208        .split('.')
209        .map(|p| p.parse::<u32>().map_err(|_| err()))
210        .collect::<Result<_, _>>()?;
211    if parts.len() < 2 || parts[..2] != PANDOC_API_VERSION[..2] {
212        return Err(err());
213    }
214    if parts.len() > PANDOC_API_VERSION.len()
215        || parts
216            .iter()
217            .zip(PANDOC_API_VERSION.iter())
218            .skip(2)
219            .any(|(a, b)| a > b)
220    {
221        // A newer patch than this export knows.
222        return Err(err());
223    }
224    Ok(())
225}
226
227/// The document as Pandoc JSON (compact, one line, like `pandoc -t json`),
228/// and for [`ImageMode::Referenced`] the `(path, bytes)` image files to
229/// write.
230pub fn to_pandoc(
231    doc: &DoclingDocument,
232    options: &PandocExportOptions,
233) -> Result<PandocOutput, PandocError> {
234    // The JSON with the note calls docling's model has no field for (#538).
235    from_docling_json(&crate::json::to_json_with_notes(doc), options)
236}
237
238/// [`to_pandoc`] for a document already in docling's JSON wire format —
239/// e.g. one produced by Python docling, or `DoclingDocument.export_to_dict()`
240/// in the Python bindings.
241pub fn from_docling_json(
242    json: &Value,
243    options: &PandocExportOptions,
244) -> Result<PandocOutput, PandocError> {
245    if let Some(v) = &options.api_version {
246        check_api_version(v)?;
247    }
248    // The walk recurses per nesting level; an XBRL instance nests thousands
249    // deep, so it runs on a big-stack thread like the HTML serializer.
250    let render = || {
251        let mut ser = Serializer::new(json, options);
252        let blocks = ser.body();
253        let doc = ast::document_with_meta(meta_of(json), blocks);
254        (
255            serde_json::to_string(&doc).expect("Pandoc JSON is always serializable"),
256            ser.artifacts,
257        )
258    };
259    #[cfg(not(target_arch = "wasm32"))]
260    {
261        Ok(std::thread::scope(|scope| {
262            std::thread::Builder::new()
263                .name("docling-pandoc".into())
264                .stack_size(256 << 20)
265                .spawn_scoped(scope, render)
266                .expect("spawn the pandoc serializer thread")
267                .join()
268                .expect("pandoc serializer thread panicked")
269        }))
270    }
271    #[cfg(target_arch = "wasm32")]
272    {
273        Ok(render())
274    }
275}
276
277/// Pandoc's auto-identifier of a heading (`Text.Pandoc.Shared.
278/// stringToIdentifier`, the default `auto_identifiers` rule, #849): the
279/// text lower-cased, everything but letters, digits, `_`, `-`, `.` and
280/// spaces removed, the words joined by `-`, anything up to the first letter
281/// dropped; `section` when nothing is left. `Target section` →
282/// `target-section`, `2026 Results` → `results`.
283pub fn auto_identifier(text: &str) -> String {
284    let kept: String = text
285        .chars()
286        .filter(|c| c.is_alphanumeric() || c.is_whitespace() || matches!(c, '_' | '-' | '.'))
287        .flat_map(char::to_lowercase)
288        .collect();
289    let joined = kept.split_whitespace().collect::<Vec<_>>().join("-");
290    let id = joined.trim_start_matches(|c: char| !c.is_alphabetic());
291    if id.is_empty() {
292        "section".to_string()
293    } else {
294        id.to_string()
295    }
296}
297
298/// The Pandoc AST constructors (`Text.Pandoc.Definition`, JSON per
299/// `Text.Pandoc.JSON`): every node this module writes is built here.
300pub mod ast {
301    use serde_json::{json, Value};
302
303    use super::PANDOC_API_VERSION;
304
305    pub fn document(blocks: Vec<Value>) -> Value {
306        document_with_meta(json!({}), blocks)
307    }
308    /// A document with its `Meta` (#836).
309    pub fn document_with_meta(meta: Value, blocks: Vec<Value>) -> Value {
310        json!({ "pandoc-api-version": PANDOC_API_VERSION, "meta": meta, "blocks": blocks })
311    }
312    pub fn meta_inlines(inlines: Vec<Value>) -> Value {
313        node("MetaInlines", json!(inlines))
314    }
315    pub fn meta_string(s: &str) -> Value {
316        node("MetaString", json!(s))
317    }
318    pub fn meta_list(items: Vec<Value>) -> Value {
319        node("MetaList", json!(items))
320    }
321    /// `Span Attr [Inline]` with key-value attributes.
322    pub fn span(classes: &[&str], attrs: &[(String, String)], inlines: Vec<Value>) -> Value {
323        let kv: Vec<Value> = attrs.iter().map(|(k, v)| json!([k, v])).collect();
324        node("Span", json!([["", classes, kv], inlines]))
325    }
326
327    /// `Attr`: identifier, classes, key-value pairs.
328    pub fn attr(classes: &[&str]) -> Value {
329        json!(["", classes, []])
330    }
331
332    fn node(t: &str, c: Value) -> Value {
333        json!({ "t": t, "c": c })
334    }
335
336    // --- blocks -------------------------------------------------------------
337
338    pub fn para(inlines: Vec<Value>) -> Value {
339        node("Para", json!(inlines))
340    }
341    pub fn plain(inlines: Vec<Value>) -> Value {
342        node("Plain", json!(inlines))
343    }
344    pub fn header(level: usize, inlines: Vec<Value>) -> Value {
345        node("Header", json!([level, attr(&[]), inlines]))
346    }
347    /// A `Header` with an identifier (#849: a bookmarked heading).
348    pub fn header_with_id(level: usize, id: &str, inlines: Vec<Value>) -> Value {
349        node("Header", json!([level, [id, [], []], inlines]))
350    }
351    /// An empty `Span` carrying an identifier and the class `anchor` — the
352    /// link destination Pandoc's docx reader writes for a bookmark (#849).
353    pub fn anchor(id: &str) -> Value {
354        node("Span", json!([[id, ["anchor"], []], []]))
355    }
356    pub fn code_block(language: Option<&str>, text: &str) -> Value {
357        let classes: Vec<&str> = language.into_iter().collect();
358        node("CodeBlock", json!([attr(&classes), text]))
359    }
360    pub fn bullet_list(items: Vec<Vec<Value>>) -> Value {
361        node("BulletList", json!(items))
362    }
363    /// `OrderedList ListAttributes [[Block]]`: `style` a `ListNumberStyle`
364    /// (`Decimal`, `LowerAlpha`, `UpperRoman`, …), `delim` a
365    /// `ListNumberDelim` (`Period`, `OneParen`, `TwoParens`, `DefaultDelim`).
366    pub fn ordered_list(start: u64, style: &str, delim: &str, items: Vec<Vec<Value>>) -> Value {
367        node(
368            "OrderedList",
369            json!([[start, { "t": style }, { "t": delim }], items]),
370        )
371    }
372    pub fn definition_list(entries: Vec<(Vec<Value>, Vec<Vec<Value>>)>) -> Value {
373        node(
374            "DefinitionList",
375            Value::Array(
376                entries
377                    .into_iter()
378                    .map(|(term, defs)| json!([term, defs]))
379                    .collect(),
380            ),
381        )
382    }
383    /// `RawBlock Format Text` — kept by writers of that format, dropped by
384    /// the others.
385    pub fn raw_block(format: &str, text: &str) -> Value {
386        node("RawBlock", json!([format, text]))
387    }
388    pub fn div(classes: &[&str], blocks: Vec<Value>) -> Value {
389        node("Div", json!([attr(classes), blocks]))
390    }
391    /// `Caption`: no short caption, the long one as blocks.
392    pub fn caption(blocks: Vec<Value>) -> Value {
393        json!([null, blocks])
394    }
395    pub fn figure(classes: &[&str], caption: Value, blocks: Vec<Value>) -> Value {
396        node("Figure", json!([attr(classes), caption, blocks]))
397    }
398    /// A table cell: `Cell Attr Alignment RowSpan ColSpan [Block]`.
399    pub fn cell(row_span: usize, col_span: usize, blocks: Vec<Value>) -> Value {
400        json!([attr(&[]), { "t": "AlignDefault" }, row_span, col_span, blocks])
401    }
402    pub fn row(cells: Vec<Value>) -> Value {
403        json!([attr(&[]), cells])
404    }
405    /// `Table Attr Caption [ColSpec] TableHead [TableBody] TableFoot`.
406    pub fn table(caption: Value, num_cols: usize, head: Vec<Value>, body: Vec<Value>) -> Value {
407        let colspecs: Vec<Value> = (0..num_cols)
408            .map(|_| json!([{ "t": "AlignDefault" }, { "t": "ColWidthDefault" }]))
409            .collect();
410        node(
411            "Table",
412            json!([
413                attr(&[]),
414                caption,
415                colspecs,
416                [attr(&[]), head],
417                [[attr(&[]), 0, [], body]],
418                [attr(&[]), []]
419            ]),
420        )
421    }
422
423    // --- inlines ------------------------------------------------------------
424
425    pub fn str_(s: &str) -> Value {
426        node("Str", json!(s))
427    }
428    pub fn space() -> Value {
429        json!({ "t": "Space" })
430    }
431    pub fn line_break() -> Value {
432        json!({ "t": "LineBreak" })
433    }
434    /// `Strong`, `Emph`, `Underline`, `Strikeout`, `Subscript`, `Superscript`.
435    pub fn wrap(t: &str, inlines: Vec<Value>) -> Value {
436        node(t, json!(inlines))
437    }
438    pub fn code(text: &str) -> Value {
439        node("Code", json!([attr(&[]), text]))
440    }
441    pub fn math(display: bool, tex: &str) -> Value {
442        let kind = if display { "DisplayMath" } else { "InlineMath" };
443        node("Math", json!([{ "t": kind }, tex]))
444    }
445    pub fn link(inlines: Vec<Value>, url: &str) -> Value {
446        node("Link", json!([attr(&[]), inlines, [url, ""]]))
447    }
448    pub fn image(classes: &[&str], alt: Vec<Value>, src: &str) -> Value {
449        image_sized(classes, &[], alt, src)
450    }
451    /// An `Image` with key-value attributes (`width` / `height`, #834).
452    pub fn image_sized(
453        classes: &[&str],
454        attrs: &[(String, String)],
455        alt: Vec<Value>,
456        src: &str,
457    ) -> Value {
458        let kv: Vec<Value> = attrs.iter().map(|(k, v)| json!([k, v])).collect();
459        node("Image", json!([["", classes, kv], alt, [src, ""]]))
460    }
461    pub fn note(blocks: Vec<Value>) -> Value {
462        node("Note", json!(blocks))
463    }
464}
465
466/// Text as `Str` words and `Space`s, `\n` as `LineBreak`; surrounding
467/// whitespace trimmed (a no-break space stays inside its word, as Pandoc's
468/// readers keep it). A Markdown link `[label](target)` in the text — how
469/// the flat node chain (the `.doc`, AbiWord, … readers) carries a
470/// hyperlink, #852 — is a `Link`.
471fn text_inlines(text: &str) -> Vec<Value> {
472    let mut out = Vec::new();
473    for (i, line) in text.trim().split('\n').enumerate() {
474        if i > 0 {
475            out.push(ast::line_break());
476        }
477        let is_ws = |c: char| c.is_whitespace() && c != '\u{a0}';
478        let line_start = out.len();
479        // Whitespace seen since the last inline of this line: one `Space`
480        // before the next.
481        let mut pending_space = false;
482        let mut rest = line;
483        while !rest.is_empty() {
484            let (plain, link) = match markdown_link(rest) {
485                Some((before, label, target, after)) => {
486                    rest = after;
487                    (before, Some((label, target)))
488                }
489                None => (std::mem::take(&mut rest), None),
490            };
491            let words: Vec<&str> = plain.split(is_ws).filter(|w| !w.is_empty()).collect();
492            if words.is_empty() {
493                pending_space |= !plain.is_empty();
494            } else {
495                if out.len() > line_start && (pending_space || plain.starts_with(is_ws)) {
496                    out.push(ast::space());
497                }
498                for (k, word) in words.iter().enumerate() {
499                    if k > 0 {
500                        out.push(ast::space());
501                    }
502                    out.push(ast::str_(word));
503                }
504                pending_space = plain.ends_with(is_ws);
505            }
506            if let Some((label, target)) = link {
507                if out.len() > line_start && pending_space {
508                    out.push(ast::space());
509                }
510                let mut label_inlines = Vec::new();
511                for (k, word) in label.split_whitespace().enumerate() {
512                    if k > 0 {
513                        label_inlines.push(ast::space());
514                    }
515                    label_inlines.push(ast::str_(word));
516                }
517                // An unsafe destination (`javascript:`, #860) keeps the
518                // label's words alone.
519                if crate::hyperlink::is_safe_hyperlink(target) {
520                    out.push(ast::link(label_inlines, target));
521                } else {
522                    out.extend(label_inlines);
523                }
524                pending_space = false;
525            }
526        }
527    }
528    out
529}
530
531/// The first Markdown link of `text` as `(before, label, target, after)`:
532/// `[label](target)` with a label free of brackets and newlines and a
533/// target free of whitespace and parentheses — the shape the flat readers
534/// write; an image's `![alt](src)` is not one.
535fn markdown_link(text: &str) -> Option<(&str, &str, &str, &str)> {
536    let mut from = 0;
537    while let Some(open) = text[from..].find('[') {
538        let open = from + open;
539        from = open + 1;
540        if text[..open].ends_with('!') {
541            continue;
542        }
543        let close = text[open + 1..].find(']')?;
544        let close = open + 1 + close;
545        let label = &text[open + 1..close];
546        if label.is_empty() || label.contains(['[', '\n']) || !text[close + 1..].starts_with('(') {
547            continue;
548        }
549        let Some(end) = text[close + 2..].find(')') else {
550            continue;
551        };
552        let end = close + 2 + end;
553        let target = &text[close + 2..end];
554        if target.is_empty() || target.contains(|c: char| c.is_whitespace() || c == '(') {
555            continue;
556        }
557        return Some((&text[..open], label, target, &text[end + 1..]));
558    }
559    None
560}
561
562/// [`text_inlines`] with a `Note` spliced in at each call: `notes` is
563/// `[[offset, text], …]` (chars into `text`), the JSON's internal `_notes`.
564/// Whitespace around a call becomes a `Space`, as Pandoc's docx reader
565/// writes `word¹ next` → `Str "word", Note, Space, Str "next"`.
566fn text_with_notes(text: &str, notes: &[Value]) -> Vec<Value> {
567    let mut calls: Vec<(usize, &str)> = notes
568        .iter()
569        .filter_map(|n| Some((n.get(0)?.as_u64()? as usize, n.get(1)?.as_str()?)))
570        .collect();
571    calls.sort_by_key(|&(offset, _)| offset);
572    let chars: Vec<char> = text.chars().collect();
573    let mut out: Vec<Value> = Vec::new();
574    let push_segment = |out: &mut Vec<Value>, seg: &str| {
575        let inlines = text_inlines(seg);
576        if inlines.is_empty() {
577            if !out.is_empty() && seg.chars().any(char::is_whitespace) {
578                out.push(ast::space());
579            }
580            return;
581        }
582        if !out.is_empty() && seg.starts_with(char::is_whitespace) {
583            out.push(ast::space());
584        }
585        out.extend(inlines);
586        if seg.ends_with(char::is_whitespace) {
587            out.push(ast::space());
588        }
589    };
590    let mut prev = 0usize;
591    for (offset, note) in calls {
592        let at = offset.clamp(prev, chars.len());
593        let seg: String = chars[prev..at].iter().collect();
594        push_segment(&mut out, &seg);
595        out.push(ast::note(vec![ast::para(text_inlines(note))]));
596        prev = at;
597    }
598    let rest: String = chars[prev..].iter().collect();
599    push_segment(&mut out, &rest);
600    while out
601        .last()
602        .is_some_and(|v| v.get("t").and_then(Value::as_str) == Some("Space"))
603    {
604        out.pop();
605    }
606    out
607}
608
609/// Inline lists joined by single `Space`s (an inline group's runs) — none
610/// before a run that opens with closing punctuation (`.`, `,`, `)`, …) or
611/// after one that ends with opening punctuation: docling keeps each
612/// formatting run as its own item and its serializers join them with a
613/// space, which would put `link .` into every Pandoc output.
614fn join_inlines(parts: Vec<Vec<Value>>) -> Vec<Value> {
615    fn edge_char(inlines: &[Value], first: bool) -> Option<char> {
616        let node = if first {
617            inlines.first()
618        } else {
619            inlines.last()
620        }?;
621        match node.get("t").and_then(Value::as_str)? {
622            "Str" => {
623                let s = node.get("c")?.as_str()?;
624                if first {
625                    s.chars().next()
626                } else {
627                    s.chars().last()
628                }
629            }
630            // Formatting / links: look inside.
631            "Strong" | "Emph" | "Underline" | "Strikeout" | "Subscript" | "Superscript" => {
632                edge_char(node.get("c")?.as_array()?, first)
633            }
634            "Link" => edge_char(node.get("c")?.get(1)?.as_array()?, first),
635            _ => None,
636        }
637    }
638    let mut out: Vec<Value> = Vec::new();
639    for part in parts.into_iter().filter(|p| !p.is_empty()) {
640        if !out.is_empty() {
641            let closes = edge_char(&part, true)
642                .is_some_and(|c| ".,;:!?)]}%\u{bb}\u{201d}\u{2019}".contains(c));
643            let opens = edge_char(&out, false).is_some_and(|c| "([{\u{ab}\u{201c}".contains(c));
644            if !closes && !opens {
645                out.push(ast::space());
646            }
647        }
648        out.extend(part);
649    }
650    out
651}
652
653/// Pandoc's `Meta` from the document's `metadata` (#836, the JSON export's
654/// `--metadata json` object; Python docling's JSON has none): `title`,
655/// `subject`, `description`, `publisher`, `rights`, `identifier` and `date`
656/// — the document's own date, else when it was created — as `MetaInlines`,
657/// `author` and `keywords` as `MetaList`s of `MetaInlines` (Pandoc's own
658/// shape for several authors), `lang` as `MetaString`, so `pandoc -t docx`
659/// sets the title and author and the templates see them. Empty when the
660/// document has none.
661fn meta_of(json: &Value) -> Value {
662    let mut meta = serde_json::Map::new();
663    let Some(m) = json.get("metadata").and_then(Value::as_object) else {
664        return Value::Object(meta);
665    };
666    let text = |key: &str| {
667        m.get(key)
668            .and_then(Value::as_str)
669            .map(text_inlines)
670            .filter(|t| !t.is_empty())
671    };
672    let list = |key: &str| -> Vec<Value> {
673        m.get(key)
674            .and_then(Value::as_array)
675            .into_iter()
676            .flatten()
677            .filter_map(Value::as_str)
678            .map(text_inlines)
679            .filter(|t| !t.is_empty())
680            .map(ast::meta_inlines)
681            .collect()
682    };
683    for (key, field) in [
684        ("title", "title"),
685        ("subject", "subject"),
686        ("description", "description"),
687        ("publisher", "publisher"),
688        ("rights", "rights"),
689        ("identifier", "identifier"),
690    ] {
691        if let Some(t) = text(field) {
692            meta.insert(key.into(), ast::meta_inlines(t));
693        }
694    }
695    let authors = list("authors");
696    if !authors.is_empty() {
697        meta.insert("author".into(), ast::meta_list(authors));
698    }
699    if let Some(date) = text("date").or_else(|| text("created")) {
700        meta.insert("date".into(), ast::meta_inlines(date));
701    }
702    let keywords = list("keywords");
703    if !keywords.is_empty() {
704        meta.insert("keywords".into(), ast::meta_list(keywords));
705    }
706    if let Some(lang) = m.get("language").and_then(Value::as_str).map(str::trim) {
707        if !lang.is_empty() {
708            meta.insert("lang".into(), ast::meta_string(lang));
709        }
710    }
711    Value::Object(meta)
712}
713
714/// A docling comment note — `[author: Name (initials), time: date]: text`,
715/// the DOCX, `.doc`, ODT and XLSX backends' shape — split into its author,
716/// date and text; a note without the head is all text.
717fn split_comment(note: &str) -> (Option<String>, Option<String>, String) {
718    let Some(rest) = note.strip_prefix('[') else {
719        return (None, None, note.to_string());
720    };
721    let Some((head, body)) = rest.split_once(']') else {
722        return (None, None, note.to_string());
723    };
724    let body = body.strip_prefix(':').unwrap_or(body).trim().to_string();
725    let mut author = None;
726    let mut date = None;
727    for part in head.split(", ") {
728        if let Some(a) = part.strip_prefix("author: ") {
729            author = Some(a.trim().to_string());
730        } else if let Some(t) = part.strip_prefix("time: ") {
731            date = Some(t.trim().to_string());
732        }
733    }
734    (author, date, body)
735}
736
737/// A length in inches as Pandoc's readers write it: `2.5in`, `1.25in` —
738/// up to four decimals, trailing zeros dropped.
739fn inches(v: f64) -> String {
740    let s = format!("{v:.4}");
741    let s = s.trim_end_matches('0').trim_end_matches('.');
742    format!("{}in", if s.is_empty() { "0" } else { s })
743}
744
745/// Pandoc's `ListAttributes` — start, number style, delimiter — from a
746/// list's item markers (#835): `a)` → `(1, LowerAlpha, OneParen)`, `iii.` →
747/// `(3, LowerRoman, Period)`, `(3)` → `(3, Decimal, TwoParens)`, a bare
748/// `12` → `DefaultDelim`. Every list used to be `Decimal` / `Period`, and a
749/// non-decimal start fell back to 1. A single letter reads as Roman only
750/// when the list does — `i` / `I` (what Pandoc's Markdown reader assumes),
751/// or another item's marker a multi-letter numeral — else as alpha, so a
752/// list starting at `c)` is the third letter, not 100. Letters repeat past
753/// `z` (`aa` = 27, Pandoc's own extension). A marker nothing parses
754/// (`1.2.`) keeps the old `(1, Decimal, Period)`.
755fn list_attributes(markers: &[&str]) -> (u64, &'static str, &'static str) {
756    fn split(marker: &str) -> (&str, &'static str) {
757        let m = marker.trim();
758        if let Some(inner) = m.strip_prefix('(').and_then(|r| r.strip_suffix(')')) {
759            return (inner.trim(), "TwoParens");
760        }
761        if let Some(inner) = m.strip_suffix(')') {
762            return (inner.trim(), "OneParen");
763        }
764        if let Some(inner) = m.strip_suffix('.') {
765            return (inner.trim(), "Period");
766        }
767        (m, "DefaultDelim")
768    }
769    fn roman(token: &str) -> Option<u64> {
770        let value = |c: char| match c.to_ascii_lowercase() {
771            'i' => Some(1),
772            'v' => Some(5),
773            'x' => Some(10),
774            'l' => Some(50),
775            'c' => Some(100),
776            'd' => Some(500),
777            'm' => Some(1000),
778            _ => None,
779        };
780        let values: Vec<i64> = token.chars().map(value).collect::<Option<_>>()?;
781        if values.is_empty() {
782            return None;
783        }
784        let mut total = 0i64;
785        for (i, &v) in values.iter().enumerate() {
786            // Subtractive notation: `IV` is 5 − 1.
787            if values.get(i + 1).is_some_and(|&next| next > v) {
788                total -= v;
789            } else {
790                total += v;
791            }
792        }
793        (total > 0).then_some(total as u64)
794    }
795    fn alpha(token: &str) -> Option<u64> {
796        let first = token.chars().next()?;
797        if !first.is_ascii_alphabetic() || !token.chars().all(|c| c == first) {
798            return None;
799        }
800        let ordinal = (first.to_ascii_lowercase() as u64) - ('a' as u64) + 1;
801        Some((token.chars().count() as u64 - 1) * 26 + ordinal)
802    }
803    const DEFAULT: (u64, &str, &str) = (1, "Decimal", "Period");
804    let Some(first) = markers.first() else {
805        return DEFAULT;
806    };
807    let (token, delim) = split(first);
808    if token.is_empty() {
809        return DEFAULT;
810    }
811    if token.chars().all(|c| c.is_ascii_digit()) {
812        return token
813            .parse()
814            .map(|n| (n, "Decimal", delim))
815            .unwrap_or(DEFAULT);
816    }
817    if !token.chars().all(|c| c.is_ascii_alphabetic()) {
818        return DEFAULT;
819    }
820    let upper = token.chars().next().is_some_and(|c| c.is_ascii_uppercase());
821    let letters = |t: &str| t.chars().all(|c| c.is_ascii_alphabetic());
822    let reads_roman = roman(token).is_some()
823        && (token.len() > 1
824            || token.eq_ignore_ascii_case("i")
825            || markers[1..].iter().any(|m| {
826                let (t, _) = split(m);
827                t.len() > 1 && letters(t) && roman(t).is_some()
828            }));
829    if reads_roman {
830        let style = if upper { "UpperRoman" } else { "LowerRoman" };
831        return (roman(token).unwrap_or(1), style, delim);
832    }
833    match alpha(token) {
834        Some(n) => (n, if upper { "UpperAlpha" } else { "LowerAlpha" }, delim),
835        None => DEFAULT,
836    }
837}
838
839struct GridCell<'a> {
840    cell: &'a Value,
841    start_row: usize,
842    start_col: usize,
843    row_span: usize,
844    col_span: usize,
845}
846
847struct Serializer<'a> {
848    json: &'a Value,
849    image_mode: ImageMode,
850    artifacts_dir: String,
851    layers: ContentLayers,
852    heading_depth: HeadingDepth,
853    track_changes: Option<TrackChanges>,
854    artifacts: Vec<(String, Vec<u8>)>,
855    pic_index: usize,
856    visited: HashSet<String>,
857    /// Items some table / picture / graph holds as a caption or footnote:
858    /// rendered by their owner, skipped where the walk meets them.
859    owned_captions: HashSet<String>,
860    owned_footnotes: HashSet<String>,
861}
862
863impl<'a> Serializer<'a> {
864    fn new(json: &'a Value, options: &PandocExportOptions) -> Self {
865        let mut owned_captions = HashSet::new();
866        let mut owned_footnotes = HashSet::new();
867        for bucket in [
868            "pictures",
869            "tables",
870            "key_value_items",
871            "form_items",
872            "texts",
873        ] {
874            for item in Self::array(json.get(bucket)) {
875                for (key, set) in [
876                    ("captions", &mut owned_captions),
877                    ("footnotes", &mut owned_footnotes),
878                ] {
879                    for r in Self::array(item.get(key)) {
880                        if let Some(cref) = r.get("$ref").and_then(Value::as_str) {
881                            set.insert(cref.to_string());
882                        }
883                    }
884                }
885            }
886        }
887        Self {
888            json,
889            image_mode: options.image_mode,
890            artifacts_dir: options.artifacts_dir.clone(),
891            layers: options.layers,
892            heading_depth: options.heading_depth,
893            track_changes: options.track_changes,
894            artifacts: Vec::new(),
895            pic_index: 0,
896            visited: HashSet::new(),
897            owned_captions,
898            owned_footnotes,
899        }
900    }
901
902    fn array(v: Option<&'a Value>) -> impl Iterator<Item = &'a Value> {
903        v.and_then(Value::as_array).into_iter().flatten()
904    }
905
906    fn resolve(&self, cref: &str) -> Option<&'a Value> {
907        let rest = cref.strip_prefix("#/")?;
908        if rest == "body" {
909            return self.json.get("body");
910        }
911        let (bucket, idx) = rest.split_once('/')?;
912        self.json.get(bucket)?.get(idx.parse::<usize>().ok()?)
913    }
914
915    fn refs(v: Option<&'a Value>) -> Vec<&'a str> {
916        Self::array(v)
917            .filter_map(|r| r.get("$ref").and_then(Value::as_str))
918            .collect()
919    }
920
921    fn children(item: &'a Value) -> Vec<&'a str> {
922        Self::refs(item.get("children"))
923    }
924
925    fn self_ref(item: &Value) -> &str {
926        item.get("self_ref").and_then(Value::as_str).unwrap_or("")
927    }
928
929    fn label(item: &Value) -> &str {
930        item.get("label").and_then(Value::as_str).unwrap_or("")
931    }
932
933    fn text(item: &Value) -> &str {
934        item.get("text").and_then(Value::as_str).unwrap_or("")
935    }
936
937    /// The item's link destinations (`_anchors`, #849).
938    fn anchors(item: &Value) -> Vec<String> {
939        item.get("_anchors")
940            .and_then(Value::as_array)
941            .map(|a| {
942                a.iter()
943                    .filter_map(Value::as_str)
944                    .map(str::to_string)
945                    .collect()
946            })
947            .unwrap_or_default()
948    }
949
950    fn is_group(item: &Value) -> bool {
951        Self::self_ref(item).starts_with("#/groups/")
952    }
953
954    fn excluded(&self, item: &Value) -> bool {
955        let layer = item
956            .get("content_layer")
957            .and_then(Value::as_str)
958            .unwrap_or("body");
959        !self.layers.contains_name(layer)
960    }
961
962    /// docling's `_iterate_items(with_groups=True, traverse_pictures=False)`
963    /// below `root`, depth-first pre-order; items off the chosen layers are
964    /// not yielded but their subtrees are walked; under a picture only its
965    /// captions are visited.
966    fn descendants(&self, root: &'a Value, out: &mut Vec<&'a Value>) {
967        let picture = Self::self_ref(root).starts_with("#/pictures/");
968        let captions = if picture {
969            Self::refs(root.get("captions"))
970        } else {
971            Vec::new()
972        };
973        for cref in Self::children(root) {
974            if picture && !captions.contains(&cref) {
975                continue;
976            }
977            // A caption or footnote some table / picture / graph owns is
978            // rendered by that owner, inline group and all (#833): neither
979            // it nor what nests under it is a block of the walk.
980            if self.owned_captions.contains(cref) || self.owned_footnotes.contains(cref) {
981                continue;
982            }
983            let Some(child) = self.resolve(cref) else {
984                continue;
985            };
986            if !self.excluded(child) {
987                out.push(child);
988            }
989            self.descendants(child, out);
990        }
991    }
992
993    /// Every not-yet-visited descendant of `item`, serialized in order —
994    /// a container marks what it renders as visited, so the flat walk
995    /// skips it.
996    fn blocks_of(&mut self, item: &'a Value) -> Vec<Value> {
997        let mut nodes = Vec::new();
998        self.descendants(item, &mut nodes);
999        let mut out = Vec::new();
1000        for node in nodes {
1001            if self.visited.insert(Self::self_ref(node).to_string()) {
1002                out.extend(self.serialize(node));
1003            }
1004        }
1005        out
1006    }
1007
1008    fn body(&mut self) -> Vec<Value> {
1009        let Some(body) = self.json.get("body") else {
1010            return Vec::new();
1011        };
1012        self.visited.insert("#/body".to_string());
1013        let mut blocks = self.blocks_of(body);
1014        let notes = self.unplaced_notes();
1015        blocks.extend(notes);
1016        blocks
1017    }
1018
1019    /// Furniture `footnote` items no text item calls — docling's DOCX / ODT
1020    /// note bodies when the JSON carries no call sites (one exported by
1021    /// Python docling), or a call that could not be anchored — written while
1022    /// the furniture layer itself is off: each as a trailing `Note`, so a
1023    /// document rebuilt from the AST still has its notes (#538).
1024    fn unplaced_notes(&mut self) -> Vec<Value> {
1025        let notes: Vec<&'a Value> = Self::array(self.json.get("texts"))
1026            .filter(|t| Self::label(t) == "footnote" && !Self::is_note_body(t))
1027            .filter(|t| t.get("content_layer").and_then(Value::as_str) == Some("furniture"))
1028            .filter(|t| self.excluded(t) && !self.owned_footnotes.contains(Self::self_ref(t)))
1029            .collect();
1030        let mut out = Vec::new();
1031        for t in notes {
1032            // The note's formatting and links too (#833).
1033            let (text, _, _) = self.own_inlines(t);
1034            if !text.is_empty() {
1035                out.push(ast::para(vec![ast::note(vec![ast::para(text)])]));
1036            }
1037        }
1038        out
1039    }
1040
1041    /// One item's blocks.
1042    fn serialize(&mut self, item: &'a Value) -> Vec<Value> {
1043        let sref = Self::self_ref(item);
1044        if Self::is_group(item) {
1045            return match Self::label(item) {
1046                // A comment thread is written as spans on the item it
1047                // annotates (#836), never as blocks; one no item refers to
1048                // (a plain cell's, an orphan's) has no place in the AST.
1049                "comment_section" => Vec::new(),
1050                "list" => self.list_group(item),
1051                "inline" => {
1052                    let (inlines, mut rest) = self.inline_group(item);
1053                    let mut out = Vec::new();
1054                    if !inlines.is_empty() {
1055                        out.push(ast::para(inlines));
1056                    }
1057                    out.append(&mut rest);
1058                    out
1059                }
1060                _ => self.blocks_of(item),
1061            };
1062        }
1063        if sref.starts_with("#/texts/") {
1064            if self.owned_captions.contains(sref) || self.owned_footnotes.contains(sref) {
1065                return Vec::new();
1066            }
1067            // A note body some text item calls is written there, as a `Note`.
1068            if self.excluded(item) || Self::is_note_body(item) {
1069                return Vec::new();
1070            }
1071            return self.text_item(item);
1072        }
1073        if sref.starts_with("#/tables/") {
1074            return self.table(item);
1075        }
1076        if sref.starts_with("#/pictures/") {
1077            return self.picture(item);
1078        }
1079        if sref.starts_with("#/key_value_items/") {
1080            return self.graph_item(item, "key-value-region");
1081        }
1082        if sref.starts_with("#/form_items/") {
1083            return self.graph_item(item, "form-container");
1084        }
1085        if sref.starts_with("#/field_regions/") {
1086            return self.field_region(item);
1087        }
1088        if sref.starts_with("#/field_items/") {
1089            let entries = vec![self.field_item(item)];
1090            return Self::field_blocks(entries);
1091        }
1092        Vec::new()
1093    }
1094
1095    /// A form's field region (#515): its fields as one `DefinitionList` —
1096    /// key → value(s) — in a `Div .field-region`; a field without a key
1097    /// contributes its blocks in place.
1098    fn field_region(&mut self, region: &'a Value) -> Vec<Value> {
1099        let mut entries = Vec::new();
1100        for cref in Self::children(region) {
1101            let Some(child) = self.resolve(cref) else {
1102                continue;
1103            };
1104            if !self.visited.insert(cref.to_string()) {
1105                continue;
1106            }
1107            if cref.starts_with("#/field_items/") {
1108                entries.push(self.field_item(child));
1109            } else {
1110                let mut blocks = if self.excluded(child) {
1111                    Vec::new()
1112                } else {
1113                    self.serialize(child)
1114                };
1115                blocks.extend(self.blocks_of(child));
1116                entries.push((Vec::new(), blocks));
1117            }
1118        }
1119        let blocks = Self::field_blocks(entries);
1120        if blocks.is_empty() {
1121            Vec::new()
1122        } else {
1123            vec![ast::div(&["field-region"], blocks)]
1124        }
1125    }
1126
1127    /// One field: its `marker` and `field_key` texts as the term, each
1128    /// `field_value` (and anything else under it) as a definition.
1129    fn field_item(&mut self, field: &'a Value) -> (Vec<Value>, Vec<Value>) {
1130        let mut nodes = Vec::new();
1131        self.descendants(field, &mut nodes);
1132        let (mut term, mut defs) = (Vec::new(), Vec::new());
1133        for node in nodes {
1134            if !self.visited.insert(Self::self_ref(node).to_string()) {
1135                continue;
1136            }
1137            match Self::label(node) {
1138                "marker" | "field_key" => {
1139                    let inlines = self.formatted(node);
1140                    if !inlines.is_empty() {
1141                        term = join_inlines(vec![term, inlines]);
1142                    }
1143                }
1144                "field_value" => {
1145                    let inlines = self.formatted(node);
1146                    if !inlines.is_empty() {
1147                        defs.push(ast::plain(inlines));
1148                    }
1149                }
1150                _ => defs.extend(self.serialize(node)),
1151            }
1152        }
1153        (term, defs)
1154    }
1155
1156    /// Fields as blocks: runs of keyed fields become `DefinitionList`s, a
1157    /// key-less field's blocks stand on their own.
1158    fn field_blocks(entries: Vec<(Vec<Value>, Vec<Value>)>) -> Vec<Value> {
1159        let mut out = Vec::new();
1160        let mut run: Vec<(Vec<Value>, Vec<Vec<Value>>)> = Vec::new();
1161        for (term, defs) in entries {
1162            if term.is_empty() {
1163                if !run.is_empty() {
1164                    out.push(ast::definition_list(std::mem::take(&mut run)));
1165                }
1166                out.extend(defs);
1167            } else {
1168                let defs = defs.into_iter().map(|d| vec![d]).collect();
1169                run.push((term, defs));
1170            }
1171        }
1172        if !run.is_empty() {
1173            out.push(ast::definition_list(run));
1174        }
1175        out
1176    }
1177
1178    /// A text item's own inlines: its formatted text, or — for an empty
1179    /// item whose first child is an inline group (a list item, heading or
1180    /// caption whose content is a run of formatted spans; whatever else
1181    /// nests under it — a sub-list, a section's paragraphs — follows as
1182    /// blocks of its own, #833) — that group's, with any blocks the group
1183    /// held besides.
1184    fn own_inlines(&mut self, item: &'a Value) -> (Vec<Value>, Vec<Value>, bool) {
1185        let children = Self::children(item);
1186        if Self::text(item).is_empty() {
1187            if let Some(group) = children
1188                .first()
1189                .and_then(|c| self.resolve(c))
1190                .filter(|c| Self::is_group(c) && Self::label(c) == "inline")
1191            {
1192                self.visited.insert(Self::self_ref(group).to_string());
1193                let (inlines, rest) = self.inline_group(group);
1194                return (inlines, rest, true);
1195            }
1196        }
1197        (self.formatted(item), Vec::new(), false)
1198    }
1199
1200    /// `inlines` between the comment spans of the item's reviewer comments
1201    /// (#836): for each `comment_section` group the item's `comments` refer
1202    /// to, one `Span .comment-start` per comment text — `comment-id` (the
1203    /// group's number, `-n` for a thread's replies), `author`, `date` from
1204    /// docling's note head, the comment's text inside — before the inlines
1205    /// and an empty `Span .comment-end` after them, Pandoc's docx reader's
1206    /// `--track-changes=all` shape. The groups are then visited, so a notes
1207    /// layer that is on does not write them again as blocks.
1208    fn with_comments(&mut self, item: &'a Value, inlines: Vec<Value>) -> Vec<Value> {
1209        let refs = Self::refs(item.get("comments"));
1210        if refs.is_empty() {
1211            return inlines;
1212        }
1213        // An explicit accept / reject view carries no comments (#851),
1214        // as Pandoc's `--track-changes=accept|reject` write none.
1215        if matches!(
1216            self.track_changes,
1217            Some(TrackChanges::Accept | TrackChanges::Reject)
1218        ) {
1219            return inlines;
1220        }
1221        let mut starts = Vec::new();
1222        let mut ends = Vec::new();
1223        for cref in refs {
1224            let Some(group) = self.resolve(cref) else {
1225                continue;
1226            };
1227            if !Self::is_group(group) || Self::label(group) != "comment_section" {
1228                continue;
1229            }
1230            self.visited.insert(cref.to_string());
1231            let id = group
1232                .get("name")
1233                .and_then(Value::as_str)
1234                .and_then(|n| n.strip_prefix("comment-"))
1235                .unwrap_or("0")
1236                .to_string();
1237            // The group's texts sit on the notes layer, which the layer
1238            // filter would drop: read the children directly.
1239            let mut k = 0;
1240            for r in Self::children(group) {
1241                self.visited.insert(r.to_string());
1242                let Some(node) = self.resolve(r).filter(|_| r.starts_with("#/texts/")) else {
1243                    continue;
1244                };
1245                let (author, date, text) = split_comment(Self::text(node));
1246                let comment_id = if k == 0 {
1247                    id.clone()
1248                } else {
1249                    format!("{id}-{k}")
1250                };
1251                k += 1;
1252                let mut attrs = vec![("comment-id".to_string(), comment_id.clone())];
1253                if let Some(a) = author {
1254                    attrs.push(("author".to_string(), a));
1255                }
1256                if let Some(d) = date {
1257                    attrs.push(("date".to_string(), d));
1258                }
1259                starts.push(ast::span(&["comment-start"], &attrs, text_inlines(&text)));
1260                ends.push(ast::span(
1261                    &["comment-end"],
1262                    &[("comment-id".to_string(), comment_id)],
1263                    Vec::new(),
1264                ));
1265            }
1266        }
1267        if starts.is_empty() {
1268            return inlines;
1269        }
1270        let mut out = starts;
1271        out.extend(inlines);
1272        out.extend(ends);
1273        out
1274    }
1275
1276    /// The item's text with its formatting and hyperlink applied, and the
1277    /// notes it calls as `Note`s at their call sites (#538).
1278    fn formatted(&self, item: &Value) -> Vec<Value> {
1279        let base = match Self::label(item) {
1280            "code" => vec![ast::code(Self::text(item))],
1281            "formula" if !Self::text(item).is_empty() => {
1282                vec![ast::math(false, Self::text(item).trim())]
1283            }
1284            _ => {
1285                let (text, skipped) = Self::authored_text(item);
1286                match item.get("_notes").and_then(Value::as_array) {
1287                    Some(notes) if !notes.is_empty() => {
1288                        if skipped == 0 {
1289                            text_with_notes(text, notes)
1290                        } else {
1291                            // The note offsets count the numbered text.
1292                            let shifted: Vec<Value> = notes
1293                                .iter()
1294                                .map(|n| {
1295                                    let off = n[0].as_u64().unwrap_or(0).saturating_sub(skipped);
1296                                    serde_json::json!([off, n[1]])
1297                                })
1298                                .collect();
1299                            text_with_notes(text, &shifted)
1300                        }
1301                    }
1302                    _ => text_inlines(text),
1303                }
1304            }
1305        };
1306        Self::decorate(base, item)
1307    }
1308
1309    /// The item's text without the generated numbering it opens with
1310    /// (`_number`, #850), and how many chars were dropped.
1311    fn authored_text(item: &Value) -> (&str, u64) {
1312        let text = Self::text(item);
1313        if let Some(number) = item.get("_number").and_then(Value::as_str) {
1314            if let Some(rest) = text
1315                .strip_prefix(number)
1316                .and_then(|rest| rest.strip_prefix(' '))
1317            {
1318                return (rest, (number.chars().count() + 1) as u64);
1319            }
1320        }
1321        (text, 0)
1322    }
1323
1324    /// A furniture `footnote` item that is the body of a note a text item
1325    /// calls ([`crate::tree::TreeItem::note_body`]).
1326    fn is_note_body(item: &Value) -> bool {
1327        item.get("_note_body").and_then(Value::as_bool) == Some(true)
1328    }
1329
1330    fn decorate(mut inlines: Vec<Value>, item: &Value) -> Vec<Value> {
1331        if inlines.is_empty() {
1332            return inlines;
1333        }
1334        if let Some(f) = item.get("formatting") {
1335            let on = |k: &str| f.get(k).and_then(Value::as_bool).unwrap_or(false);
1336            if on("bold") {
1337                inlines = vec![ast::wrap("Strong", inlines)];
1338            }
1339            if on("italic") {
1340                inlines = vec![ast::wrap("Emph", inlines)];
1341            }
1342            if on("underline") {
1343                inlines = vec![ast::wrap("Underline", inlines)];
1344            }
1345            if on("strikethrough") {
1346                inlines = vec![ast::wrap("Strikeout", inlines)];
1347            }
1348            match f.get("script").and_then(Value::as_str) {
1349                Some("sub") => inlines = vec![ast::wrap("Subscript", inlines)],
1350                Some("super") => inlines = vec![ast::wrap("Superscript", inlines)],
1351                _ => {}
1352            }
1353        }
1354        // An unsafe destination (`javascript:`, #860) keeps the text alone.
1355        if let Some(url) = item
1356            .get("hyperlink")
1357            .and_then(Value::as_str)
1358            .filter(|u| crate::hyperlink::is_safe_hyperlink(u))
1359        {
1360            inlines = vec![ast::link(inlines, url)];
1361        }
1362        // A tracked change kept under `all` (#851): Pandoc's docx reader's
1363        // span, outermost.
1364        if let Some(rev) = item.get("_revision") {
1365            if let Some(kind) = rev.get("kind").and_then(Value::as_str) {
1366                let mut attrs = Vec::new();
1367                for key in ["author", "date"] {
1368                    if let Some(v) = rev.get(key).and_then(Value::as_str) {
1369                        attrs.push((key.to_string(), v.to_string()));
1370                    }
1371                }
1372                inlines = vec![ast::span(&[kind], &attrs, inlines)];
1373            }
1374        }
1375        inlines
1376    }
1377
1378    /// An inline group's runs as inlines joined by `Space`; anything in it
1379    /// that has no inline form (a nested list, a table) comes back as
1380    /// blocks for after the paragraph.
1381    fn inline_group(&mut self, group: &'a Value) -> (Vec<Value>, Vec<Value>) {
1382        let mut nodes = Vec::new();
1383        self.descendants(group, &mut nodes);
1384        let mut runs: Vec<Vec<Value>> = Vec::new();
1385        let mut rest: Vec<Value> = Vec::new();
1386        for node in nodes {
1387            let r = Self::self_ref(node).to_string();
1388            if self.visited.contains(&r) {
1389                continue;
1390            }
1391            self.visited.insert(r.clone());
1392            if r.starts_with("#/texts/") {
1393                if self.owned_captions.contains(&r)
1394                    || self.owned_footnotes.contains(&r)
1395                    || Self::is_note_body(node)
1396                {
1397                    continue;
1398                }
1399                let mut run: Vec<Value> = Self::anchors(node)
1400                    .iter()
1401                    .map(|id| ast::anchor(id))
1402                    .collect();
1403                run.append(&mut self.formatted(node));
1404                runs.push(run);
1405                continue;
1406            }
1407            if Self::is_group(node) && Self::label(node) == "inline" {
1408                let (inl, mut more) = self.inline_group(node);
1409                runs.push(inl);
1410                rest.append(&mut more);
1411                continue;
1412            }
1413            rest.extend(self.serialize(node));
1414        }
1415        (join_inlines(runs), rest)
1416    }
1417
1418    fn text_item(&mut self, item: &'a Value) -> Vec<Value> {
1419        let label = Self::label(item);
1420        let mut out = Vec::new();
1421        match label {
1422            "list_item" => {
1423                // A list item met outside a list group: a one-item list.
1424                let blocks = self.list_item_blocks(item);
1425                if !blocks.is_empty() {
1426                    out.push(ast::bullet_list(vec![blocks]));
1427                }
1428                return out;
1429            }
1430            "code" => {
1431                let lang = item
1432                    .get("code_language")
1433                    .and_then(Value::as_str)
1434                    .filter(|l| !l.is_empty() && *l != "unknown")
1435                    .map(str::to_ascii_lowercase);
1436                out.push(ast::code_block(lang.as_deref(), Self::text(item)));
1437            }
1438            "formula" => {
1439                let tex = Self::text(item).trim();
1440                if !tex.is_empty() {
1441                    out.push(ast::para(vec![ast::math(true, tex)]));
1442                }
1443            }
1444            _ => {
1445                let (inlines, rest, _) = self.own_inlines(item);
1446                let mut inlines = self.with_comments(item, inlines);
1447                // The item's link destinations (#849): a heading takes
1448                // the first as its identifier, the rest open its inlines
1449                // as anchor spans; any other item opens with them all.
1450                let mut anchors = Self::anchors(item);
1451                let header_id =
1452                    if matches!(label, "title" | "section_header") && !anchors.is_empty() {
1453                        Some(anchors.remove(0))
1454                    } else {
1455                        None
1456                    };
1457                if !anchors.is_empty() && (header_id.is_some() || !inlines.is_empty()) {
1458                    let mut with_anchors: Vec<Value> =
1459                        anchors.iter().map(|id| ast::anchor(id)).collect();
1460                    with_anchors.append(&mut inlines);
1461                    inlines = with_anchors;
1462                }
1463                match label {
1464                    "title" | "section_header" => {
1465                        let level = if label == "title" {
1466                            1
1467                        } else {
1468                            let n = item.get("level").and_then(Value::as_u64).unwrap_or(1) as usize;
1469                            match self.heading_depth {
1470                                // docling's depth: level 1 is `##`, under
1471                                // the title's `#`.
1472                                HeadingDepth::Docling => (n + 1).min(6),
1473                                // The source's depth (#848): a DOCX
1474                                // `Heading 1` is `Header 1`, as Pandoc's
1475                                // docx reader writes it.
1476                                HeadingDepth::Source => n.clamp(1, 6),
1477                            }
1478                        };
1479                        if !inlines.is_empty() {
1480                            out.push(match &header_id {
1481                                Some(id) => ast::header_with_id(level, id, inlines),
1482                                None => ast::header(level, inlines),
1483                            });
1484                        }
1485                    }
1486                    "text" | "paragraph" => {
1487                        if !inlines.is_empty() {
1488                            out.push(ast::para(inlines));
1489                        }
1490                    }
1491                    "checkbox_selected" | "checkbox_unselected" => {
1492                        let mark = if label == "checkbox_selected" {
1493                            "☒"
1494                        } else {
1495                            "☐"
1496                        };
1497                        let mut with_mark = vec![ast::str_(mark)];
1498                        if !inlines.is_empty() {
1499                            with_mark.push(ast::space());
1500                            with_mark.append(&mut inlines);
1501                        }
1502                        out.push(ast::para(with_mark));
1503                    }
1504                    other => {
1505                        if !inlines.is_empty() {
1506                            let class = format!("docling-{other}");
1507                            out.push(ast::div(&[&class], vec![ast::para(inlines)]));
1508                        }
1509                    }
1510                }
1511                out.extend(rest);
1512            }
1513        }
1514        // Content nested under the item (an HTML / DOCX heading's section).
1515        out.extend(self.blocks_of(item));
1516        out
1517    }
1518
1519    /// A list item's blocks: its own text as `Plain`, then whatever is
1520    /// nested under it (sub-lists, paragraphs).
1521    fn list_item_blocks(&mut self, item: &'a Value) -> Vec<Value> {
1522        let (inlines, rest, _) = self.own_inlines(item);
1523        let inlines = self.with_comments(item, inlines);
1524        let mut blocks = Vec::new();
1525        if !inlines.is_empty() {
1526            blocks.push(ast::plain(inlines));
1527        }
1528        blocks.extend(rest);
1529        blocks.extend(self.blocks_of(item));
1530        blocks
1531    }
1532
1533    fn list_group(&mut self, group: &'a Value) -> Vec<Value> {
1534        let first = Self::children(group)
1535            .first()
1536            .and_then(|r| self.resolve(r))
1537            .filter(|c| Self::label(c) == "list_item");
1538        let enumerated = first.is_some_and(|c| {
1539            c.get("enumerated")
1540                .and_then(Value::as_bool)
1541                .unwrap_or(false)
1542        });
1543        // The items' markers decide the list's start, number style and
1544        // delimiter (#835).
1545        let markers: Vec<&str> = Self::children(group)
1546            .iter()
1547            .filter_map(|r| self.resolve(r))
1548            .filter(|c| Self::label(c) == "list_item")
1549            .filter_map(|c| c.get("marker").and_then(Value::as_str))
1550            .collect();
1551        let (start, style, delim) = list_attributes(&markers);
1552
1553        let mut nodes = Vec::new();
1554        self.descendants(group, &mut nodes);
1555        let mut items: Vec<Vec<Value>> = Vec::new();
1556        for node in nodes {
1557            let r = Self::self_ref(node).to_string();
1558            if !self.visited.insert(r.clone()) {
1559                continue;
1560            }
1561            if Self::label(node) == "list_item"
1562                && r.starts_with("#/texts/")
1563                && !self.owned_captions.contains(&r)
1564            {
1565                let blocks = self.list_item_blocks(node);
1566                items.push(blocks);
1567                continue;
1568            }
1569            // Something that is not a list item directly in the list (a
1570            // paragraph between items): it belongs to the item before it.
1571            let blocks = self.serialize(node);
1572            if blocks.is_empty() {
1573                continue;
1574            }
1575            match items.last_mut() {
1576                Some(last) => last.extend(blocks),
1577                None => items.push(blocks),
1578            }
1579        }
1580        if items.is_empty() {
1581            return Vec::new();
1582        }
1583        if enumerated {
1584            vec![ast::ordered_list(start, style, delim, items)]
1585        } else {
1586            vec![ast::bullet_list(items)]
1587        }
1588    }
1589
1590    /// The owner's caption texts as one `Caption`, with its footnotes as
1591    /// `Note`s at the end — the float is their call site. Each caption and
1592    /// note keeps its formatting, links and inline group (#833: a bold,
1593    /// linked `<caption>` used to come out as bare words, a multi-run one
1594    /// as an empty caption plus a stray `Para`).
1595    fn caption(&mut self, item: &'a Value) -> Value {
1596        let mut inlines = self.caption_text(item);
1597        for cref in Self::refs(item.get("footnotes")) {
1598            if let Some(note) = self.resolve(cref) {
1599                if cref.starts_with("#/texts/") && !self.excluded(note) {
1600                    let (text, _, _) = self.own_inlines(note);
1601                    if !text.is_empty() {
1602                        inlines.push(ast::note(vec![ast::para(text)]));
1603                    }
1604                }
1605            }
1606        }
1607        if inlines.is_empty() {
1608            ast::caption(Vec::new())
1609        } else {
1610            ast::caption(vec![ast::plain(inlines)])
1611        }
1612    }
1613
1614    /// The owner's caption texts alone (no notes) — a picture's alt text,
1615    /// as Pandoc's readers give a captioned image.
1616    fn caption_text(&mut self, item: &'a Value) -> Vec<Value> {
1617        let caps: Vec<&'a Value> = Self::refs(item.get("captions"))
1618            .into_iter()
1619            .filter(|cref| cref.starts_with("#/texts/"))
1620            .filter_map(|cref| self.resolve(cref))
1621            .filter(|cap| !self.excluded(cap))
1622            .collect();
1623        let mut parts = Vec::new();
1624        for cap in caps {
1625            let (inlines, _, _) = self.own_inlines(cap);
1626            parts.push(inlines);
1627        }
1628        join_inlines(parts)
1629    }
1630
1631    /// docling's `TableData.grid`: each position → the cell covering it.
1632    fn grid(data: &'a Value) -> (usize, Vec<Vec<Option<GridCell<'a>>>>) {
1633        let num_rows = data.get("num_rows").and_then(Value::as_u64).unwrap_or(0) as usize;
1634        let num_cols = data.get("num_cols").and_then(Value::as_u64).unwrap_or(0) as usize;
1635        let mut grid: Vec<Vec<Option<GridCell<'a>>>> = (0..num_rows)
1636            .map(|_| (0..num_cols).map(|_| None).collect())
1637            .collect();
1638        for cell in Self::array(data.get("table_cells")) {
1639            let get = |k: &str| cell.get(k).and_then(Value::as_u64).unwrap_or(0) as usize;
1640            let (r0, c0) = (get("start_row_offset_idx"), get("start_col_offset_idx"));
1641            let (r1, c1) = (get("end_row_offset_idx"), get("end_col_offset_idx"));
1642            if r0 >= num_rows || c0 >= num_cols {
1643                continue;
1644            }
1645            let (r1, c1) = (r1.clamp(r0 + 1, num_rows), c1.clamp(c0 + 1, num_cols));
1646            for row in grid.iter_mut().take(r1).skip(r0) {
1647                for slot in row.iter_mut().take(c1).skip(c0) {
1648                    *slot = Some(GridCell {
1649                        cell,
1650                        start_row: r0,
1651                        start_col: c0,
1652                        row_span: r1 - r0,
1653                        col_span: c1 - c0,
1654                    });
1655                }
1656            }
1657        }
1658        (num_cols, grid)
1659    }
1660
1661    fn table_from_data(&mut self, data: &'a Value, caption: Value) -> Option<Value> {
1662        let (num_cols, grid) = Self::grid(data);
1663        if grid.is_empty() || num_cols == 0 {
1664            return None;
1665        }
1666        let flag = |c: &Value, k: &str| c.get(k).and_then(Value::as_bool).unwrap_or(false);
1667        let mut rows: Vec<(bool, Value)> = Vec::new();
1668        for (i, row) in grid.iter().enumerate() {
1669            let mut cells = Vec::new();
1670            let mut all_header = true;
1671            let mut anchored = 0;
1672            for (j, slot) in row.iter().enumerate() {
1673                let Some(g) = slot else {
1674                    // A position no cell covers (a table's empty corner) is
1675                    // neutral to the header test.
1676                    cells.push(ast::cell(1, 1, Vec::new()));
1677                    continue;
1678                };
1679                if g.start_row != i || g.start_col != j {
1680                    continue;
1681                }
1682                anchored += 1;
1683                all_header &= flag(g.cell, "column_header");
1684                let blocks = match g
1685                    .cell
1686                    .get("ref")
1687                    .and_then(|r| r.get("$ref"))
1688                    .and_then(Value::as_str)
1689                {
1690                    Some(cref) => match self.resolve(cref) {
1691                        Some(target) => {
1692                            self.visited.insert(cref.to_string());
1693                            self.serialize(target)
1694                        }
1695                        None => Vec::new(),
1696                    },
1697                    None => {
1698                        let inl =
1699                            text_inlines(g.cell.get("text").and_then(Value::as_str).unwrap_or(""));
1700                        if inl.is_empty() {
1701                            Vec::new()
1702                        } else {
1703                            vec![ast::plain(inl)]
1704                        }
1705                    }
1706                };
1707                cells.push(ast::cell(g.row_span, g.col_span, blocks));
1708            }
1709            rows.push((all_header && anchored > 0, ast::row(cells)));
1710        }
1711        // Leading all-header rows form the head; the rest is the body.
1712        let head_len = rows.iter().take_while(|(h, _)| *h).count();
1713        let head_len = if head_len == rows.len() { 0 } else { head_len };
1714        let mut head = Vec::new();
1715        let mut body = Vec::new();
1716        for (i, (_, row)) in rows.into_iter().enumerate() {
1717            if i < head_len {
1718                head.push(row);
1719            } else {
1720                body.push(row);
1721            }
1722        }
1723        Some(ast::table(caption, num_cols, head, body))
1724    }
1725
1726    fn table(&mut self, item: &'a Value) -> Vec<Value> {
1727        let caption = self.caption(item);
1728        if self.excluded(item) {
1729            return Vec::new();
1730        }
1731        match item.get("data") {
1732            Some(data) => self.table_from_data(data, caption).into_iter().collect(),
1733            None => Vec::new(),
1734        }
1735    }
1736
1737    fn picture(&mut self, item: &'a Value) -> Vec<Value> {
1738        if self.excluded(item) {
1739            return Vec::new();
1740        }
1741        let caption = self.caption(item);
1742        let has_caption = caption
1743            .get(1)
1744            .and_then(Value::as_array)
1745            .is_some_and(|b| !b.is_empty());
1746        let uri = item
1747            .get("image")
1748            .and_then(|i| i.get("uri"))
1749            .and_then(Value::as_str);
1750        let src = match (self.image_mode, uri) {
1751            (ImageMode::Embedded, Some(uri)) if uri.starts_with("data:") => Some(uri.to_string()),
1752            (ImageMode::Referenced, Some(uri)) => self
1753                .reference_image(uri)
1754                .or_else(|| (!uri.starts_with("data:")).then(|| uri.to_string())),
1755            _ => None,
1756        };
1757        // #537: every picture is an `Image`, so it survives into whatever
1758        // Pandoc writes and can be counted — one without a target
1759        // (placeholder mode, or a payload docling could not decode: EMF/WMF)
1760        // carries the `docling-placeholder` class and an empty target, which
1761        // a writer reports as a missing resource and renders as the alt text.
1762        // The alt text is the author's (`meta.description`, #834) — what
1763        // Pandoc's docx reader takes from `wp:docPr descr` — and only
1764        // without one the caption, as before.
1765        let description = item
1766            .get("meta")
1767            .and_then(|m| m.get("description"))
1768            .and_then(|d| d.get("text"))
1769            .and_then(Value::as_str)
1770            .map(text_inlines)
1771            .filter(|t| !t.is_empty());
1772        let alt = match description {
1773            Some(alt) => alt,
1774            None => self.caption_text(item),
1775        };
1776        // The displayed size (`_display`, inches) as `width` / `height`
1777        // attributes, Pandoc's readers' shape for a sized image (#834).
1778        let attrs: Vec<(String, String)> = item
1779            .get("image")
1780            .and_then(|i| i.get("_display"))
1781            .and_then(Value::as_array)
1782            .and_then(|d| Some((d.first()?.as_f64()?, d.get(1)?.as_f64()?)))
1783            .map(|(w, h)| {
1784                vec![
1785                    ("width".to_string(), inches(w)),
1786                    ("height".to_string(), inches(h)),
1787                ]
1788            })
1789            .unwrap_or_default();
1790        let image = match &src {
1791            Some(src) => ast::image_sized(&[], &attrs, alt, src),
1792            None => ast::image_sized(&["docling-placeholder"], &attrs, alt, ""),
1793        };
1794        // A native chart's data grid (`meta.tabular_chart.chart_data`).
1795        let chart = item
1796            .get("meta")
1797            .and_then(|m| m.get("tabular_chart"))
1798            .and_then(|t| t.get("chart_data"))
1799            .and_then(|chart| self.table_from_data(chart, ast::caption(Vec::new())));
1800        if chart.is_none() && !has_caption {
1801            // Pandoc's own readers put a caption-less image in a paragraph.
1802            return vec![ast::para(vec![image])];
1803        }
1804        let mut blocks = vec![ast::plain(vec![image])];
1805        blocks.extend(chart);
1806        vec![ast::figure(&[], caption, blocks)]
1807    }
1808
1809    /// Referenced mode: a `data:` URI becomes an artifact file named like
1810    /// the Markdown export's; the path is the image target.
1811    fn reference_image(&mut self, uri: &str) -> Option<String> {
1812        let rest = uri.strip_prefix("data:")?;
1813        let (mime, payload) = rest.split_once(";base64,")?;
1814        let bytes = crate::base64::decode(payload)?;
1815        let path = format!(
1816            "{}/image_{:06}.{}",
1817            self.artifacts_dir,
1818            self.pic_index,
1819            crate::markdown::ext_for(mime)
1820        );
1821        self.pic_index += 1;
1822        self.artifacts.push((path.clone(), bytes));
1823        Some(path)
1824    }
1825
1826    /// A key-value / form graph: a `DefinitionList` of keys and their
1827    /// values, or — when `to_child` links make it a hierarchy — the nested
1828    /// `BulletList` of it (the HTML export's two shapes), in a classed `Div`
1829    /// with the item's caption after it.
1830    fn graph_item(&mut self, item: &'a Value, class: &str) -> Vec<Value> {
1831        let mut out = Vec::new();
1832        if !self.excluded(item) {
1833            if let Some(graph) = item.get("graph") {
1834                if let Some(block) = Self::graph(graph) {
1835                    out.push(ast::div(&[class], vec![block]));
1836                }
1837            }
1838        }
1839        // The caption (and footnote notes) as a paragraph after the region.
1840        let caption = self.caption(item);
1841        for block in caption
1842            .get(1)
1843            .and_then(Value::as_array)
1844            .into_iter()
1845            .flatten()
1846        {
1847            if let Some(inlines) = block.get("c").and_then(Value::as_array) {
1848                out.push(ast::para(inlines.clone()));
1849            }
1850        }
1851        out
1852    }
1853
1854    fn graph(graph: &'a Value) -> Option<Value> {
1855        let cells: HashMap<u64, &Value> = Self::array(graph.get("cells"))
1856            .filter_map(|c| Some((c.get("cell_id")?.as_u64()?, c)))
1857            .collect();
1858        let order: Vec<u64> = Self::array(graph.get("cells"))
1859            .filter_map(|c| c.get("cell_id")?.as_u64())
1860            .collect();
1861        let mut child_links: HashMap<u64, Vec<u64>> = HashMap::new();
1862        let mut value_links: BTreeMap<u64, Vec<u64>> = BTreeMap::new();
1863        let mut value_order: Vec<u64> = Vec::new();
1864        let mut parents: HashSet<u64> = HashSet::new();
1865        for link in Self::array(graph.get("links")) {
1866            let (Some(s), Some(t)) = (
1867                link.get("source_cell_id").and_then(Value::as_u64),
1868                link.get("target_cell_id").and_then(Value::as_u64),
1869            ) else {
1870                continue;
1871            };
1872            if !cells.contains_key(&s) || !cells.contains_key(&t) {
1873                continue;
1874            }
1875            match link.get("label").and_then(Value::as_str) {
1876                Some("to_child") => {
1877                    child_links.entry(s).or_default().push(t);
1878                    parents.insert(t);
1879                }
1880                Some("to_value") => {
1881                    if !value_links.contains_key(&s) {
1882                        value_order.push(s);
1883                    }
1884                    value_links.entry(s).or_default().push(t);
1885                }
1886                _ => {}
1887            }
1888        }
1889        let cell_text =
1890            |id: u64| text_inlines(cells[&id].get("text").and_then(Value::as_str).unwrap_or(""));
1891        if child_links.is_empty() {
1892            if value_order.is_empty() {
1893                return None;
1894            }
1895            let entries = value_order
1896                .iter()
1897                .map(|&k| {
1898                    let defs = value_links[&k]
1899                        .iter()
1900                        .map(|&v| vec![ast::plain(cell_text(v))])
1901                        .collect();
1902                    (cell_text(k), defs)
1903                })
1904                .collect();
1905            return Some(ast::definition_list(entries));
1906        }
1907        // Hierarchical: a nested bullet list, cycles cut on the descent path.
1908        fn node(
1909            id: u64,
1910            cell_text: &dyn Fn(u64) -> Vec<Value>,
1911            child_links: &HashMap<u64, Vec<u64>>,
1912            value_links: &BTreeMap<u64, Vec<u64>>,
1913            path: &mut HashSet<u64>,
1914        ) -> Option<Vec<Value>> {
1915            if !path.insert(id) {
1916                return None;
1917            }
1918            let mut inlines = cell_text(id);
1919            if let Some(values) = value_links.get(&id) {
1920                let vals = join_inlines(values.iter().map(|&v| cell_text(v)).collect());
1921                inlines = vec![ast::wrap("Strong", inlines)];
1922                inlines.push(ast::str_(":"));
1923                if !vals.is_empty() {
1924                    inlines.push(ast::space());
1925                    inlines.extend(vals);
1926                }
1927            }
1928            let mut blocks = vec![ast::plain(inlines)];
1929            let kids: Vec<Vec<Value>> = child_links
1930                .get(&id)
1931                .into_iter()
1932                .flatten()
1933                .filter_map(|&c| node(c, cell_text, child_links, value_links, path))
1934                .collect();
1935            if !kids.is_empty() {
1936                blocks.push(ast::bullet_list(kids));
1937            }
1938            path.remove(&id);
1939            Some(blocks)
1940        }
1941        let mut path = HashSet::new();
1942        let items: Vec<Vec<Value>> = order
1943            .iter()
1944            .filter(|id| !parents.contains(id))
1945            .filter_map(|&id| node(id, &cell_text, &child_links, &value_links, &mut path))
1946            .collect();
1947        (!items.is_empty()).then(|| ast::bullet_list(items))
1948    }
1949}
1950
1951#[cfg(test)]
1952mod tests {
1953    use super::*;
1954    use serde_json::json;
1955
1956    /// A docling JSON document from `body` children and the item buckets.
1957    fn doc(body: &[&str], texts: Value, groups: Value, tables: Value) -> Value {
1958        json!({
1959            "schema_name": "DoclingDocument",
1960            "name": "t",
1961            "body": {"self_ref": "#/body", "children": body.iter().map(|r| json!({"$ref": r})).collect::<Vec<_>>()},
1962            "texts": texts, "groups": groups, "tables": tables, "pictures": []
1963        })
1964    }
1965
1966    fn text(i: usize, label: &str, text: &str, extra: Value) -> Value {
1967        let mut v = json!({"self_ref": format!("#/texts/{i}"), "children": [], "label": label, "text": text, "content_layer": "body"});
1968        for (k, x) in extra.as_object().unwrap() {
1969            v[k] = x.clone();
1970        }
1971        v
1972    }
1973
1974    fn blocks(json: &Value) -> Vec<Value> {
1975        let (s, _) = from_docling_json(json, &PandocExportOptions::default()).unwrap();
1976        let v: Value = serde_json::from_str(&s).unwrap();
1977        assert_eq!(v["pandoc-api-version"], json!(PANDOC_API_VERSION));
1978        v["blocks"].as_array().unwrap().clone()
1979    }
1980
1981    /// #538: a text item's note calls become `Note`s at their offsets —
1982    /// glued to the word before, a `Space` where the text had one — and the
1983    /// note bodies they carry are not written again as blocks.
1984    #[test]
1985    fn note_calls_become_notes_at_their_call_sites() {
1986        let j = doc(
1987            &["#/texts/0", "#/texts/1"],
1988            json!([
1989                text(
1990                    0,
1991                    "text",
1992                    "debut suite end",
1993                    json!({"_notes": [[5, "first"], [15, "last"]]})
1994                ),
1995                text(
1996                    1,
1997                    "footnote",
1998                    "first",
1999                    json!({"content_layer": "furniture", "_note_body": true})
2000                ),
2001            ]),
2002            json!([]),
2003            json!([]),
2004        );
2005        let note = |t: &str| ast::note(vec![ast::para(vec![ast::str_(t)])]);
2006        assert_eq!(
2007            blocks(&j),
2008            vec![ast::para(vec![
2009                ast::str_("debut"),
2010                note("first"),
2011                ast::space(),
2012                ast::str_("suite"),
2013                ast::space(),
2014                ast::str_("end"),
2015                note("last"),
2016            ])]
2017        );
2018        // With the furniture layer on, a placed note body is still not a block.
2019        let opts = PandocExportOptions {
2020            layers: ContentLayers::ALL,
2021            ..Default::default()
2022        };
2023        let (s, _) = from_docling_json(&j, &opts).unwrap();
2024        assert!(!s.contains("docling-footnote"), "{s}");
2025    }
2026
2027    /// #538: a furniture footnote nothing calls (Python docling's JSON has no
2028    /// call sites) still reaches the AST, as a trailing `Note`.
2029    #[test]
2030    fn uncalled_furniture_footnotes_trail_as_notes() {
2031        let j = doc(
2032            &["#/texts/0", "#/texts/1"],
2033            json!([
2034                text(0, "text", "body", json!({})),
2035                text(
2036                    1,
2037                    "footnote",
2038                    "MARKFN",
2039                    json!({"content_layer": "furniture"})
2040                ),
2041            ]),
2042            json!([]),
2043            json!([]),
2044        );
2045        assert_eq!(
2046            blocks(&j),
2047            vec![
2048                ast::para(vec![ast::str_("body")]),
2049                ast::para(vec![ast::note(vec![ast::para(vec![ast::str_("MARKFN")])])]),
2050            ]
2051        );
2052    }
2053
2054    /// #537: every picture is an `Image` — embedded by default, in a `Para`
2055    /// without a caption, in a `Figure` (caption = alt text) with one; with
2056    /// no target (placeholder mode, an undecodable payload) it is classed
2057    /// `docling-placeholder` instead of vanishing as raw HTML.
2058    #[test]
2059    fn pictures_are_always_images() {
2060        let mut j = doc(
2061            &["#/pictures/0", "#/pictures/1", "#/pictures/2"],
2062            json!([text(0, "caption", "A duck", json!({})),]),
2063            json!([]),
2064            json!([]),
2065        );
2066        j["pictures"] = json!([
2067            {"self_ref": "#/pictures/0", "children": [], "label": "picture", "content_layer": "body",
2068             "image": {"uri": "data:image/png;base64,AAAA"}, "captions": []},
2069            {"self_ref": "#/pictures/1", "children": [], "label": "picture", "content_layer": "body",
2070             "image": {"uri": "data:image/png;base64,BBBB"}, "captions": [{"$ref": "#/texts/0"}]},
2071            {"self_ref": "#/pictures/2", "children": [], "label": "picture", "content_layer": "body",
2072             "captions": []},
2073        ]);
2074        let b = blocks(&j);
2075        assert_eq!(
2076            b[0],
2077            ast::para(vec![ast::image(&[], vec![], "data:image/png;base64,AAAA")])
2078        );
2079        assert_eq!(
2080            b[1],
2081            ast::figure(
2082                &[],
2083                ast::caption(vec![ast::plain(vec![
2084                    ast::str_("A"),
2085                    ast::space(),
2086                    ast::str_("duck")
2087                ])]),
2088                vec![ast::plain(vec![ast::image(
2089                    &[],
2090                    vec![ast::str_("A"), ast::space(), ast::str_("duck")],
2091                    "data:image/png;base64,BBBB"
2092                )])]
2093            )
2094        );
2095        assert_eq!(
2096            b[2],
2097            ast::para(vec![ast::image(&["docling-placeholder"], vec![], "")])
2098        );
2099        assert_eq!(b.len(), 3);
2100        // Placeholder mode keeps the node, without the pixels.
2101        let opts = PandocExportOptions {
2102            image_mode: ImageMode::Placeholder,
2103            ..Default::default()
2104        };
2105        let (s, _) = from_docling_json(&j, &opts).unwrap();
2106        assert!(!s.contains("base64") && !s.contains("RawBlock"), "{s}");
2107        assert_eq!(s.matches("docling-placeholder").count(), 3, "{s}");
2108    }
2109
2110    /// #833: a caption of formatted runs (an inline group under the caption
2111    /// item) is the table's caption, its runs decorated, and nothing of it
2112    /// strays into the body; a multi-run heading with a section under it
2113    /// is a `Header` followed by the section's blocks; a multi-run list
2114    /// item with a nested list is `[Plain, BulletList]`; a formatted,
2115    /// linked single-run caption keeps `Strong` and `Link`.
2116    #[test]
2117    fn captions_headings_and_items_keep_their_inline_groups() {
2118        let mut j = doc(
2119            &["#/texts/0", "#/tables/0", "#/texts/3", "#/groups/2"],
2120            json!([
2121                text(
2122                    0,
2123                    "caption",
2124                    "",
2125                    json!({"children": [{"$ref": "#/groups/0"}]})
2126                ),
2127                text(1, "text", "Table 1:", json!({"formatting": {"bold": true}})),
2128                text(
2129                    2,
2130                    "text",
2131                    "Multi-run caption",
2132                    json!({"hyperlink": "https://x.org/c"})
2133                ),
2134                text(
2135                    3,
2136                    "section_header",
2137                    "",
2138                    json!({"level": 1, "children": [{"$ref": "#/groups/1"}, {"$ref": "#/texts/6"}]})
2139                ),
2140                text(4, "text", "1.1", json!({"formatting": {"bold": true}})),
2141                text(5, "text", "Formatted header", json!({})),
2142                text(6, "text", "Body under header.", json!({})),
2143                text(
2144                    7,
2145                    "list_item",
2146                    "",
2147                    json!({"children": [{"$ref": "#/groups/3"}, {"$ref": "#/groups/4"}]})
2148                ),
2149                text(8, "text", "Bold run", json!({"formatting": {"bold": true}})),
2150                text(9, "text", "and tail", json!({})),
2151                text(10, "list_item", "Nested child", json!({})),
2152                text(
2153                    11,
2154                    "caption",
2155                    "Linked bold",
2156                    json!({"formatting": {"bold": true}, "hyperlink": "https://x.org/spec"})
2157                ),
2158            ]),
2159            json!([
2160                {"self_ref": "#/groups/0", "children": [{"$ref": "#/texts/1"}, {"$ref": "#/texts/2"}], "label": "inline", "name": "group"},
2161                {"self_ref": "#/groups/1", "children": [{"$ref": "#/texts/4"}, {"$ref": "#/texts/5"}], "label": "inline", "name": "group"},
2162                {"self_ref": "#/groups/2", "children": [{"$ref": "#/texts/7"}], "label": "list", "name": "list"},
2163                {"self_ref": "#/groups/3", "children": [{"$ref": "#/texts/8"}, {"$ref": "#/texts/9"}], "label": "inline", "name": "group"},
2164                {"self_ref": "#/groups/4", "children": [{"$ref": "#/texts/10"}], "label": "list", "name": "list"},
2165            ]),
2166            json!([{"self_ref": "#/tables/0", "children": [], "label": "table",
2167                    "captions": [{"$ref": "#/texts/0"}, {"$ref": "#/texts/11"}],
2168                    "data": {"num_rows": 1, "num_cols": 1, "table_cells": [
2169                        {"text": "v", "row_span": 1, "col_span": 1, "start_row_offset_idx": 0, "end_row_offset_idx": 1,
2170                         "start_col_offset_idx": 0, "end_col_offset_idx": 1, "column_header": false}]}}]),
2171        );
2172        j["texts"][0]["children"] = json!([{"$ref": "#/groups/0"}]);
2173        let b = blocks(&j);
2174        assert_eq!(b.len(), 4, "{b:?}");
2175        assert_eq!(b[0]["t"], "Table");
2176        let caption = &b[0]["c"][1][1][0];
2177        assert_eq!(caption["t"], "Plain");
2178        assert_eq!(
2179            caption["c"][0],
2180            ast::wrap(
2181                "Strong",
2182                vec![ast::str_("Table"), ast::space(), ast::str_("1:")]
2183            )
2184        );
2185        assert_eq!(caption["c"][2]["t"], "Link");
2186        let linked = caption["c"].as_array().unwrap().last().unwrap();
2187        assert_eq!(linked["t"], "Link");
2188        assert_eq!(linked["c"][1][0]["t"], "Strong");
2189        assert_eq!(b[1]["t"], "Header");
2190        assert_eq!(b[1]["c"][2][0], ast::wrap("Strong", vec![ast::str_("1.1")]));
2191        assert_eq!(
2192            b[2],
2193            ast::para(vec![
2194                ast::str_("Body"),
2195                ast::space(),
2196                ast::str_("under"),
2197                ast::space(),
2198                ast::str_("header.")
2199            ])
2200        );
2201        assert_eq!(b[3]["t"], "BulletList");
2202        let item = b[3]["c"][0].as_array().unwrap();
2203        assert_eq!(item.len(), 2);
2204        assert_eq!(item[0]["t"], "Plain");
2205        assert_eq!(item[0]["c"][0]["t"], "Strong");
2206        assert_eq!(item[1]["t"], "BulletList");
2207    }
2208
2209    /// #834: the `Image`'s alt text is the picture's description (the
2210    /// author's alt text) when it has one, the caption only without; the
2211    /// displayed size (`_display`, inches) becomes `width` / `height`.
2212    #[test]
2213    fn pictures_carry_alt_text_and_display_size() {
2214        let mut j = doc(
2215            &["#/pictures/0", "#/pictures/1"],
2216            json!([text(0, "caption", "A duck", json!({})),]),
2217            json!([]),
2218            json!([]),
2219        );
2220        j["pictures"] = json!([
2221            {"self_ref": "#/pictures/0", "children": [], "label": "picture", "content_layer": "body",
2222             "meta": {"description": {"text": "Architecture diagram", "created_by": "alt_text"}},
2223             "image": {"uri": "data:image/png;base64,AAAA", "_display": [2.5, 1.25]}, "captions": [{"$ref": "#/texts/0"}]},
2224            {"self_ref": "#/pictures/1", "children": [], "label": "picture", "content_layer": "body",
2225             "image": {"uri": "data:image/png;base64,BBBB"}, "captions": [{"$ref": "#/texts/0"}]},
2226        ]);
2227        let b = blocks(&j);
2228        let image = &b[0]["c"][2][0]["c"][0];
2229        assert_eq!(image["t"], "Image");
2230        assert_eq!(
2231            image["c"][0],
2232            json!(["", [], [["width", "2.5in"], ["height", "1.25in"]]])
2233        );
2234        assert_eq!(
2235            image["c"][1],
2236            json!([
2237                ast::str_("Architecture"),
2238                ast::space(),
2239                ast::str_("diagram")
2240            ])
2241        );
2242        let image = &b[1]["c"][2][0]["c"][0];
2243        assert_eq!(image["c"][0], json!(["", [], []]));
2244        assert_eq!(
2245            image["c"][1],
2246            json!([ast::str_("A"), ast::space(), ast::str_("duck")])
2247        );
2248        assert_eq!(inches(2.5), "2.5in");
2249        assert_eq!(inches(1.0), "1in");
2250        assert_eq!(inches(0.33333), "0.3333in");
2251    }
2252
2253    /// #835: the markers' number style, delimiter and start.
2254    #[test]
2255    fn list_attributes_follow_the_markers() {
2256        assert_eq!(
2257            list_attributes(&["a)", "b)"]),
2258            (1, "LowerAlpha", "OneParen")
2259        );
2260        assert_eq!(
2261            list_attributes(&["iii.", "iv."]),
2262            (3, "LowerRoman", "Period")
2263        );
2264        assert_eq!(
2265            list_attributes(&["(3)", "(4)"]),
2266            (3, "Decimal", "TwoParens")
2267        );
2268        assert_eq!(
2269            list_attributes(&["c)", "d)"]),
2270            (3, "LowerAlpha", "OneParen")
2271        );
2272        assert_eq!(list_attributes(&["i."]), (1, "LowerRoman", "Period"));
2273        assert_eq!(list_attributes(&["v.", "vi."]), (5, "LowerRoman", "Period"));
2274        assert_eq!(list_attributes(&["x.", "y."]), (24, "LowerAlpha", "Period"));
2275        assert_eq!(list_attributes(&["B."]), (2, "UpperAlpha", "Period"));
2276        assert_eq!(list_attributes(&["IV)"]), (4, "UpperRoman", "OneParen"));
2277        assert_eq!(list_attributes(&["aa)"]), (27, "LowerAlpha", "OneParen"));
2278        assert_eq!(list_attributes(&["12"]), (12, "Decimal", "DefaultDelim"));
2279        assert_eq!(list_attributes(&["3."]), (3, "Decimal", "Period"));
2280        assert_eq!(list_attributes(&["1.2."]), (1, "Decimal", "Period"));
2281        assert_eq!(list_attributes(&[]), (1, "Decimal", "Period"));
2282        let j = doc(
2283            &["#/groups/0"],
2284            json!([
2285                text(
2286                    0,
2287                    "list_item",
2288                    "third",
2289                    json!({"enumerated": true, "marker": "(3)"})
2290                ),
2291                text(
2292                    1,
2293                    "list_item",
2294                    "fourth",
2295                    json!({"enumerated": true, "marker": "(4)"})
2296                ),
2297            ]),
2298            json!([{"self_ref": "#/groups/0", "children": [{"$ref": "#/texts/0"}, {"$ref": "#/texts/1"}], "label": "list", "name": "list"}]),
2299            json!([]),
2300        );
2301        let b = blocks(&j);
2302        assert_eq!(
2303            b[0]["c"][0],
2304            json!([3, {"t": "Decimal"}, {"t": "TwoParens"}])
2305        );
2306    }
2307
2308    /// #836: the document's metadata is Pandoc's `meta`; a reviewer
2309    /// comment is a `comment-start` / `comment-end` span pair around the
2310    /// annotated item's inlines, with its author and date, a reply a
2311    /// further pair; the notes-layer groups never come out as blocks.
2312    #[test]
2313    fn metadata_and_comments_reach_the_ast() {
2314        let mut j = doc(
2315            &["#/texts/0", "#/groups/0"],
2316            json!([
2317                text(
2318                    0,
2319                    "text",
2320                    "The timeout is 30 s.",
2321                    json!({"comments": [{"$ref": "#/groups/0"}]})
2322                ),
2323                text(
2324                    1,
2325                    "text",
2326                    "[author: Reviewer (SR), time: 2026-01-15T12:00:00Z]: Verify it.",
2327                    json!({"content_layer": "notes"})
2328                ),
2329                text(2, "text", "Agreed", json!({"content_layer": "notes"})),
2330            ]),
2331            json!([{"self_ref": "#/groups/0", "children": [{"$ref": "#/texts/1"}, {"$ref": "#/texts/2"}], "label": "comment_section", "name": "comment-3", "content_layer": "notes"}]),
2332            json!([]),
2333        );
2334        j["metadata"] = json!({
2335            "title": "Synthetic Spec", "authors": ["A. Author", "B. Author"], "keywords": ["architecture", "protocol"],
2336            "language": "en-US", "created": "2026-01-15T10:00:00Z", "subject": "Gateways"
2337        });
2338        let (s, _) = from_docling_json(&j, &PandocExportOptions::default()).unwrap();
2339        let v: Value = serde_json::from_str(&s).unwrap();
2340        assert_eq!(
2341            v["meta"]["title"],
2342            ast::meta_inlines(vec![
2343                ast::str_("Synthetic"),
2344                ast::space(),
2345                ast::str_("Spec")
2346            ])
2347        );
2348        assert_eq!(v["meta"]["author"]["t"], "MetaList");
2349        assert_eq!(v["meta"]["author"]["c"].as_array().unwrap().len(), 2);
2350        assert_eq!(
2351            v["meta"]["keywords"]["c"][1],
2352            ast::meta_inlines(vec![ast::str_("protocol")])
2353        );
2354        assert_eq!(v["meta"]["lang"], ast::meta_string("en-US"));
2355        assert_eq!(
2356            v["meta"]["date"],
2357            ast::meta_inlines(vec![ast::str_("2026-01-15T10:00:00Z")])
2358        );
2359        assert_eq!(v["meta"]["subject"]["t"], "MetaInlines");
2360        let b = v["blocks"].as_array().unwrap();
2361        assert_eq!(b.len(), 1, "{b:?}");
2362        let inl = b[0]["c"].as_array().unwrap();
2363        assert_eq!(inl[0]["t"], "Span");
2364        assert_eq!(
2365            inl[0]["c"][0],
2366            json!([
2367                "",
2368                ["comment-start"],
2369                [
2370                    ["comment-id", "3"],
2371                    ["author", "Reviewer (SR)"],
2372                    ["date", "2026-01-15T12:00:00Z"]
2373                ]
2374            ])
2375        );
2376        assert_eq!(
2377            inl[0]["c"][1],
2378            json!([ast::str_("Verify"), ast::space(), ast::str_("it.")])
2379        );
2380        assert_eq!(
2381            inl[1]["c"][0],
2382            json!(["", ["comment-start"], [["comment-id", "3-1"]]])
2383        );
2384        assert_eq!(inl[2], ast::str_("The"));
2385        let last = inl.last().unwrap();
2386        assert_eq!(
2387            last["c"][0],
2388            json!(["", ["comment-end"], [["comment-id", "3-1"]]])
2389        );
2390        assert_eq!(
2391            inl[inl.len() - 2]["c"][0],
2392            json!(["", ["comment-end"], [["comment-id", "3"]]])
2393        );
2394        // With the notes layer on, the comment texts are still not blocks.
2395        let opts = PandocExportOptions {
2396            layers: ContentLayers::ALL,
2397            ..Default::default()
2398        };
2399        let (s, _) = from_docling_json(&j, &opts).unwrap();
2400        assert_eq!(s.matches("Verify").count(), 1, "{s}");
2401        assert_eq!(
2402            split_comment("[time: t]: x"),
2403            (None, Some("t".into()), "x".into())
2404        );
2405        assert_eq!(split_comment("bare"), (None, None, "bare".into()));
2406        assert_eq!(meta_of(&json!({})), json!({}));
2407    }
2408
2409    #[test]
2410    fn text_splits_into_words_spaces_and_line_breaks() {
2411        assert_eq!(
2412            text_inlines("  two  words\nnext\u{a0}line "),
2413            vec![
2414                ast::str_("two"),
2415                ast::space(),
2416                ast::str_("words"),
2417                ast::line_break(),
2418                ast::str_("next\u{a0}line"),
2419            ]
2420        );
2421        assert!(text_inlines("   ").is_empty());
2422    }
2423
2424    #[test]
2425    fn headings_paragraphs_and_lists() {
2426        let j = doc(
2427            &["#/texts/0", "#/texts/1", "#/texts/2", "#/groups/0"],
2428            json!([
2429                text(0, "title", "Ducks", json!({})),
2430                text(1, "section_header", "Diet", json!({"level": 1})),
2431                text(
2432                    2,
2433                    "text",
2434                    "Ducks eat plants.",
2435                    json!({"formatting": {"bold": true}, "hyperlink": "https://x.org"})
2436                ),
2437                text(
2438                    3,
2439                    "list_item",
2440                    "seeds",
2441                    json!({"enumerated": true, "marker": "3."})
2442                ),
2443                text(
2444                    4,
2445                    "list_item",
2446                    "insects",
2447                    json!({"enumerated": true, "marker": "4."})
2448                ),
2449            ]),
2450            json!([{"self_ref": "#/groups/0", "children": [{"$ref": "#/texts/3"}, {"$ref": "#/texts/4"}], "label": "list", "name": "list"}]),
2451            json!([]),
2452        );
2453        let b = blocks(&j);
2454        assert_eq!(b[0], ast::header(1, vec![ast::str_("Ducks")]));
2455        assert_eq!(b[1], ast::header(2, vec![ast::str_("Diet")]));
2456        // formatting inside, the link around it all
2457        assert_eq!(b[2]["t"], "Para");
2458        assert_eq!(b[2]["c"][0]["t"], "Link");
2459        assert_eq!(b[2]["c"][0]["c"][1][0]["t"], "Strong");
2460        assert_eq!(b[3]["t"], "OrderedList");
2461        assert_eq!(b[3]["c"][0][0], 3, "start from the first marker");
2462        assert_eq!(
2463            b[3]["c"][1],
2464            json!([
2465                [ast::plain(vec![ast::str_("seeds")])],
2466                [ast::plain(vec![ast::str_("insects")])]
2467            ])
2468        );
2469    }
2470
2471    /// #848: the two heading projections at their bounds — docling's
2472    /// title-reserving `n + 1` (missing → 2, 0 → 1, capped at 6) and the
2473    /// source's own `n` (missing / 0 → 1, 7 → 6); the title is `Header 1`
2474    /// under both.
2475    #[test]
2476    fn heading_depths_under_both_projections() {
2477        let j = doc(
2478            &[
2479                "#/texts/0",
2480                "#/texts/1",
2481                "#/texts/2",
2482                "#/texts/3",
2483                "#/texts/4",
2484                "#/texts/5",
2485                "#/texts/6",
2486                "#/texts/7",
2487            ],
2488            json!([
2489                text(0, "title", "T", json!({})),
2490                text(1, "section_header", "d1", json!({"level": 1})),
2491                text(2, "section_header", "d2", json!({"level": 2})),
2492                text(3, "section_header", "d5", json!({"level": 5})),
2493                text(4, "section_header", "d6", json!({"level": 6})),
2494                text(5, "section_header", "none", json!({})),
2495                text(6, "section_header", "zero", json!({"level": 0})),
2496                text(7, "section_header", "seven", json!({"level": 7})),
2497            ]),
2498            json!([]),
2499            json!([]),
2500        );
2501        let depths = |heading_depth: HeadingDepth| -> Vec<u64> {
2502            let options = PandocExportOptions {
2503                heading_depth,
2504                ..Default::default()
2505            };
2506            let (s, _) = from_docling_json(&j, &options).unwrap();
2507            let v: Value = serde_json::from_str(&s).unwrap();
2508            v["blocks"]
2509                .as_array()
2510                .unwrap()
2511                .iter()
2512                .map(|b| b["c"][0].as_u64().unwrap())
2513                .collect()
2514        };
2515        assert_eq!(depths(HeadingDepth::Docling), [1, 2, 3, 6, 6, 2, 1, 6]);
2516        assert_eq!(depths(HeadingDepth::Source), [1, 1, 2, 5, 6, 1, 1, 6]);
2517        assert_eq!("Source".parse::<HeadingDepth>(), Ok(HeadingDepth::Source));
2518        assert!("deep".parse::<HeadingDepth>().is_err());
2519    }
2520
2521    /// #852: a Markdown link in an item's text (the flat chain's
2522    /// hyperlink) is a `Link`; brackets that are not one stay text.
2523    #[test]
2524    fn markdown_links_in_text_become_links() {
2525        assert_eq!(
2526            text_inlines("Jump to [target](#target-section) now [x] (y)"),
2527            vec![
2528                ast::str_("Jump"),
2529                ast::space(),
2530                ast::str_("to"),
2531                ast::space(),
2532                ast::link(vec![ast::str_("target")], "#target-section"),
2533                ast::space(),
2534                ast::str_("now"),
2535                ast::space(),
2536                ast::str_("[x]"),
2537                ast::space(),
2538                ast::str_("(y)"),
2539            ]
2540        );
2541        assert_eq!(
2542            text_inlines("[a](https://x.org/a)[b](https://x.org/b)"),
2543            vec![
2544                ast::link(vec![ast::str_("a")], "https://x.org/a"),
2545                ast::link(vec![ast::str_("b")], "https://x.org/b"),
2546            ]
2547        );
2548        assert_eq!(
2549            text_inlines("see ![alt](img.png) here"),
2550            vec![
2551                ast::str_("see"),
2552                ast::space(),
2553                ast::str_("![alt](img.png)"),
2554                ast::space(),
2555                ast::str_("here"),
2556            ]
2557        );
2558    }
2559
2560    /// #851: a run's `_revision` is Pandoc's insertion / deletion span,
2561    /// and an explicit accept / reject view drops the comment spans.
2562    #[test]
2563    fn revisions_become_spans_and_views_drop_comments() {
2564        let j = doc(
2565            &["#/texts/0", "#/texts/1"],
2566            json!([
2567                text(
2568                    0,
2569                    "text",
2570                    "old",
2571                    json!({"_revision": {"kind": "deletion", "author": "Fixture", "date": "2025-01-01T00:00:00Z"}})
2572                ),
2573                text(
2574                    1,
2575                    "text",
2576                    "Commented",
2577                    json!({"comments": [{"$ref": "#/groups/0"}]})
2578                ),
2579                text(
2580                    2,
2581                    "text",
2582                    "[author: Fixture, time: 2025-01-01T00:00:00Z]: Review note",
2583                    json!({"content_layer": "notes"})
2584                ),
2585            ]),
2586            json!([{"self_ref": "#/groups/0", "children": [{"$ref": "#/texts/2"}], "label": "comment_section", "name": "comment-3", "content_layer": "notes"}]),
2587            json!([]),
2588        );
2589        let b = blocks(&j);
2590        assert_eq!(
2591            b[0],
2592            ast::para(vec![ast::span(
2593                &["deletion"],
2594                &[
2595                    ("author".into(), "Fixture".into()),
2596                    ("date".into(), "2025-01-01T00:00:00Z".into())
2597                ],
2598                vec![ast::str_("old")]
2599            )])
2600        );
2601        assert_eq!(
2602            b[1]["c"][0]["t"], "Span",
2603            "comments written without a policy"
2604        );
2605        let accept = PandocExportOptions {
2606            track_changes: Some(TrackChanges::Accept),
2607            ..Default::default()
2608        };
2609        let (s, _) = from_docling_json(&j, &accept).unwrap();
2610        let v: Value = serde_json::from_str(&s).unwrap();
2611        assert_eq!(v["blocks"][1], ast::para(vec![ast::str_("Commented")]));
2612    }
2613
2614    /// #850: a heading numbered by the backend (`_number`) writes its
2615    /// authored text alone; text that merely starts with digits is kept.
2616    #[test]
2617    fn numbered_headings_write_the_authored_text() {
2618        let j = doc(
2619            &["#/texts/0", "#/texts/1", "#/texts/2"],
2620            json!([
2621                text(
2622                    0,
2623                    "section_header",
2624                    "1.1 2026 Results",
2625                    json!({"level": 2, "_number": "1.1"})
2626                ),
2627                text(1, "section_header", "2026 Results", json!({"level": 1})),
2628                text(
2629                    2,
2630                    "section_header",
2631                    "3 Odd",
2632                    json!({"level": 1, "_number": "4"})
2633                ),
2634            ]),
2635            json!([]),
2636            json!([]),
2637        );
2638        let b = blocks(&j);
2639        assert_eq!(
2640            b[0],
2641            ast::header(
2642                3,
2643                vec![ast::str_("2026"), ast::space(), ast::str_("Results")]
2644            )
2645        );
2646        assert_eq!(
2647            b[1],
2648            ast::header(
2649                2,
2650                vec![ast::str_("2026"), ast::space(), ast::str_("Results")]
2651            )
2652        );
2653        // A `_number` the text does not open with is left alone.
2654        assert_eq!(
2655            b[2],
2656            ast::header(2, vec![ast::str_("3"), ast::space(), ast::str_("Odd")])
2657        );
2658    }
2659
2660    /// #849: a bookmarked heading carries its identifier, a bookmark in
2661    /// body text an empty anchor span, and an internal link's `#id` target
2662    /// is written as is.
2663    #[test]
2664    fn anchors_become_header_ids_and_anchor_spans() {
2665        let j = doc(
2666            &["#/texts/0", "#/texts/1", "#/texts/2"],
2667            json!([
2668                text(
2669                    0,
2670                    "section_header",
2671                    "Target section",
2672                    json!({"level": 1, "_anchors": ["target-section", "Alias"]})
2673                ),
2674                text(1, "text", "Jump", json!({"hyperlink": "#target-section"})),
2675                text(2, "text", "Body mark", json!({"_anchors": ["BodyMark"]})),
2676            ]),
2677            json!([]),
2678            json!([]),
2679        );
2680        let b = blocks(&j);
2681        assert_eq!(
2682            b[0],
2683            ast::header_with_id(
2684                2,
2685                "target-section",
2686                vec![
2687                    ast::anchor("Alias"),
2688                    ast::str_("Target"),
2689                    ast::space(),
2690                    ast::str_("section")
2691                ]
2692            )
2693        );
2694        assert_eq!(
2695            b[1],
2696            ast::para(vec![ast::link(vec![ast::str_("Jump")], "#target-section")])
2697        );
2698        assert_eq!(
2699            b[2],
2700            ast::para(vec![
2701                ast::anchor("BodyMark"),
2702                ast::str_("Body"),
2703                ast::space(),
2704                ast::str_("mark")
2705            ])
2706        );
2707        assert_eq!(auto_identifier("Target section"), "target-section");
2708        assert_eq!(auto_identifier("2026 Results"), "results");
2709        assert_eq!(auto_identifier("1.1 Été & hiver!"), "été-hiver");
2710        assert_eq!(auto_identifier("***"), "section");
2711    }
2712
2713    #[test]
2714    fn code_formulas_checkboxes_and_unmapped_labels() {
2715        let j = doc(
2716            &["#/texts/0", "#/texts/1", "#/texts/2", "#/texts/3"],
2717            json!([
2718                text(0, "code", "print(1)", json!({"code_language": "Python"})),
2719                text(1, "formula", "E = mc^2", json!({})),
2720                text(2, "checkbox_selected", "done", json!({})),
2721                text(3, "reference", "[1] Duck, D. (2020).", json!({})),
2722            ]),
2723            json!([]),
2724            json!([]),
2725        );
2726        let b = blocks(&j);
2727        assert_eq!(b[0], ast::code_block(Some("python"), "print(1)"));
2728        assert_eq!(b[1], ast::para(vec![ast::math(true, "E = mc^2")]));
2729        assert_eq!(b[2]["c"][0], ast::str_("☒"));
2730        assert_eq!(b[3]["t"], "Div");
2731        assert_eq!(b[3]["c"][0][1], json!(["docling-reference"]));
2732    }
2733
2734    #[test]
2735    fn tables_keep_spans_header_rows_and_captions() {
2736        let cell = |r0: usize, c0: usize, rs: usize, cs: usize, t: &str, header: bool| {
2737            json!({"text": t, "row_span": rs, "col_span": cs,
2738                   "start_row_offset_idx": r0, "end_row_offset_idx": r0 + rs,
2739                   "start_col_offset_idx": c0, "end_col_offset_idx": c0 + cs,
2740                   "column_header": header})
2741        };
2742        let j = doc(
2743            &["#/tables/0"],
2744            json!([text(0, "caption", "Table 1: Ducks", json!({}))]),
2745            json!([]),
2746            json!([{"self_ref": "#/tables/0", "children": [{"$ref": "#/texts/0"}], "label": "table",
2747                    "captions": [{"$ref": "#/texts/0"}],
2748                    "data": {"num_rows": 2, "num_cols": 2, "table_cells": [
2749                        cell(0, 0, 1, 2, "Species", true),
2750                        cell(1, 0, 1, 1, "Mallard", false),
2751                        cell(1, 1, 1, 1, "Anas", false)]}}]),
2752        );
2753        let b = blocks(&j);
2754        assert_eq!(b.len(), 1, "the caption is rendered by its table only");
2755        let t = &b[0];
2756        assert_eq!(t["t"], "Table");
2757        assert_eq!(
2758            t["c"][1][1][0],
2759            ast::plain(vec![
2760                ast::str_("Table"),
2761                ast::space(),
2762                ast::str_("1:"),
2763                ast::space(),
2764                ast::str_("Ducks")
2765            ])
2766        );
2767        assert_eq!(t["c"][2].as_array().unwrap().len(), 2);
2768        let head = t["c"][3][1].as_array().unwrap();
2769        assert_eq!(head.len(), 1);
2770        assert_eq!(head[0][1][0][3], 2, "colspan");
2771        assert_eq!(t["c"][4][0][3].as_array().unwrap().len(), 1, "one body row");
2772    }
2773
2774    #[test]
2775    fn api_version_is_checked() {
2776        for ok in ["1.23", "1.23.1", "1.23.1.1"] {
2777            assert!(check_api_version(ok).is_ok(), "{ok}");
2778        }
2779        for bad in ["1.22", "2.0", "1", "1.23.2", "1.23.1.2", "x.y", ""] {
2780            let e = check_api_version(bad).unwrap_err();
2781            assert!(e.to_string().contains("only 1.23"), "{bad}: {e}");
2782        }
2783        let err = to_pandoc(
2784            &DoclingDocument::new("t"),
2785            &PandocExportOptions {
2786                api_version: Some("1.22".into()),
2787                ..Default::default()
2788            },
2789        )
2790        .unwrap_err();
2791        assert_eq!(
2792            err,
2793            PandocError::UnsupportedApiVersion {
2794                requested: "1.22".into()
2795            }
2796        );
2797    }
2798
2799    #[test]
2800    fn empty_document_is_a_valid_pandoc_document() {
2801        let (s, artifacts) =
2802            to_pandoc(&DoclingDocument::new("e"), &PandocExportOptions::default()).unwrap();
2803        assert!(artifacts.is_empty());
2804        assert_eq!(
2805            s,
2806            r#"{"pandoc-api-version":[1,23,1,1],"meta":{},"blocks":[]}"#
2807        );
2808        assert_eq!(s, DoclingDocument::new("e").export_to_pandoc_json());
2809    }
2810}