1use std::collections::{BTreeMap, HashMap, HashSet};
68
69use serde_json::Value;
70
71use crate::document::{ContentLayers, DoclingDocument};
72use crate::markdown::ImageMode;
73use crate::TrackChanges;
74
75pub const PANDOC_API_VERSION: [u32; 4] = [1, 23, 1, 1];
77
78pub type PandocOutput = (String, Vec<(String, Vec<u8>)>);
81
82#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
84pub enum HeadingDepth {
85 #[default]
93 Docling,
94 Source,
103}
104
105impl HeadingDepth {
106 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#[derive(Debug, Clone)]
133pub struct PandocExportOptions {
134 pub image_mode: ImageMode,
137 pub artifacts_dir: String,
140 pub layers: ContentLayers,
142 pub api_version: Option<String>,
145 pub heading_depth: HeadingDepth,
148 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#[derive(Debug, Clone, PartialEq, Eq)]
171pub enum PandocError {
172 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
199pub 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 return Err(err());
223 }
224 Ok(())
225}
226
227pub fn to_pandoc(
231 doc: &DoclingDocument,
232 options: &PandocExportOptions,
233) -> Result<PandocOutput, PandocError> {
234 from_docling_json(&crate::json::to_json_with_notes(doc), options)
236}
237
238pub 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 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
277pub 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
298pub 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 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 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 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 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 pub fn header_with_id(level: usize, id: &str, inlines: Vec<Value>) -> Value {
349 node("Header", json!([level, [id, [], []], inlines]))
350 }
351 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 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 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 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 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 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 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 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 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
466fn 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 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 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
531fn 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
562fn 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
609fn 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 "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
653fn 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
714fn 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
737fn 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
745fn 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 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 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 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 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 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 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 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 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 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 "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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 HeadingDepth::Docling => (n + 1).min(6),
1473 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 out.extend(self.blocks_of(item));
1516 out
1517 }
1518
1519 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 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 #[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 #[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 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 #[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 #[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 #[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 #[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 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 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 #[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 #[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  here"),
2550 vec"),
2554 ast::space(),
2555 ast::str_("here"),
2556 ]
2557 );
2558 }
2559
2560 #[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 #[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 assert_eq!(
2655 b[2],
2656 ast::header(2, vec![ast::str_("3"), ast::space(), ast::str_("Odd")])
2657 );
2658 }
2659
2660 #[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}