1use std::collections::{BTreeMap, HashMap, HashSet};
68
69use serde_json::Value;
70
71use crate::document::{ContentLayers, DoclingDocument};
72use crate::json::{TableData, TableView};
73use crate::markdown::ImageMode;
74use crate::TrackChanges;
75
76pub const PANDOC_API_VERSION: [u32; 4] = [1, 23, 1, 1];
78
79pub type PandocOutput = (String, Vec<(String, Vec<u8>)>);
82
83#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
85pub enum HeadingDepth {
86 #[default]
94 Docling,
95 Source,
104}
105
106impl HeadingDepth {
107 pub const NAMES: [&'static str; 2] = ["docling", "source"];
109
110 pub fn as_str(self) -> &'static str {
111 match self {
112 HeadingDepth::Docling => "docling",
113 HeadingDepth::Source => "source",
114 }
115 }
116}
117
118impl std::str::FromStr for HeadingDepth {
119 type Err = String;
120
121 fn from_str(s: &str) -> Result<Self, String> {
122 match s.trim().to_ascii_lowercase().as_str() {
123 "docling" | "" => Ok(HeadingDepth::Docling),
124 "source" => Ok(HeadingDepth::Source),
125 other => Err(format!(
126 "unknown heading depth {other:?} (expected docling or source)"
127 )),
128 }
129 }
130}
131
132#[derive(Debug, Clone)]
134pub struct PandocExportOptions {
135 pub image_mode: ImageMode,
138 pub artifacts_dir: String,
141 pub layers: ContentLayers,
143 pub api_version: Option<String>,
146 pub heading_depth: HeadingDepth,
149 pub track_changes: Option<TrackChanges>,
155}
156
157impl Default for PandocExportOptions {
158 fn default() -> Self {
159 Self {
160 image_mode: ImageMode::Embedded,
161 artifacts_dir: "artifacts".to_string(),
162 layers: ContentLayers::BODY,
163 api_version: None,
164 heading_depth: HeadingDepth::Docling,
165 track_changes: None,
166 }
167 }
168}
169
170#[derive(Debug, Clone, PartialEq, Eq)]
172pub enum PandocError {
173 UnsupportedApiVersion { requested: String },
175}
176
177impl std::fmt::Display for PandocError {
178 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
179 match self {
180 PandocError::UnsupportedApiVersion { requested } => write!(
181 f,
182 "unsupported Pandoc API version '{requested}': only {} (pandoc-types {}, Pandoc 3.x) is supported",
183 version_string(&PANDOC_API_VERSION[..2]),
184 version_string(&PANDOC_API_VERSION),
185 ),
186 }
187 }
188}
189
190impl std::error::Error for PandocError {}
191
192fn version_string(parts: &[u32]) -> String {
193 parts
194 .iter()
195 .map(u32::to_string)
196 .collect::<Vec<_>>()
197 .join(".")
198}
199
200pub fn check_api_version(requested: &str) -> Result<(), PandocError> {
204 let err = || PandocError::UnsupportedApiVersion {
205 requested: requested.to_string(),
206 };
207 let parts: Vec<u32> = requested
208 .trim()
209 .split('.')
210 .map(|p| p.parse::<u32>().map_err(|_| err()))
211 .collect::<Result<_, _>>()?;
212 if parts.len() < 2 || parts[..2] != PANDOC_API_VERSION[..2] {
213 return Err(err());
214 }
215 if parts.len() > PANDOC_API_VERSION.len()
216 || parts
217 .iter()
218 .zip(PANDOC_API_VERSION.iter())
219 .skip(2)
220 .any(|(a, b)| a > b)
221 {
222 return Err(err());
224 }
225 Ok(())
226}
227
228pub fn to_pandoc(
232 doc: &DoclingDocument,
233 options: &PandocExportOptions,
234) -> Result<PandocOutput, PandocError> {
235 let (json, tables) = crate::json::to_json_with_notes(doc);
238 render_json(&json, &tables, options)
239}
240
241pub fn from_docling_json(
245 json: &Value,
246 options: &PandocExportOptions,
247) -> Result<PandocOutput, PandocError> {
248 render_json(json, &[], options)
249}
250
251fn render_json(
254 json: &Value,
255 tables: &[TableData],
256 options: &PandocExportOptions,
257) -> Result<PandocOutput, PandocError> {
258 if let Some(v) = &options.api_version {
259 check_api_version(v)?;
260 }
261 let render = || {
264 let mut ser = Serializer::new(json, options);
265 ser.tables = tables;
266 let blocks = ser.body();
267 let doc = ast::document_with_meta(meta_of(json), blocks);
268 (
269 serde_json::to_string(&doc).expect("Pandoc JSON is always serializable"),
270 ser.artifacts,
271 )
272 };
273 #[cfg(not(target_arch = "wasm32"))]
274 {
275 Ok(std::thread::scope(|scope| {
276 std::thread::Builder::new()
277 .name("docling-pandoc".into())
278 .stack_size(256 << 20)
279 .spawn_scoped(scope, render)
280 .expect("spawn the pandoc serializer thread")
281 .join()
282 .expect("pandoc serializer thread panicked")
283 }))
284 }
285 #[cfg(target_arch = "wasm32")]
286 {
287 Ok(render())
288 }
289}
290
291pub fn auto_identifier(text: &str) -> String {
298 let kept: String = text
299 .chars()
300 .filter(|c| c.is_alphanumeric() || c.is_whitespace() || matches!(c, '_' | '-' | '.'))
301 .flat_map(char::to_lowercase)
302 .collect();
303 let joined = kept.split_whitespace().collect::<Vec<_>>().join("-");
304 let id = joined.trim_start_matches(|c: char| !c.is_alphabetic());
305 if id.is_empty() {
306 "section".to_string()
307 } else {
308 id.to_string()
309 }
310}
311
312pub mod ast {
322 use serde_json::{json, Value};
323
324 use super::PANDOC_API_VERSION;
325
326 fn arr<const N: usize>(items: [Value; N]) -> Value {
328 Value::Array(items.into())
329 }
330
331 pub fn document(blocks: Vec<Value>) -> Value {
332 document_with_meta(json!({}), blocks)
333 }
334 pub fn document_with_meta(meta: Value, blocks: Vec<Value>) -> Value {
336 let mut m = serde_json::Map::with_capacity(3);
337 m.insert("pandoc-api-version".into(), json!(PANDOC_API_VERSION));
338 m.insert("meta".into(), meta);
339 m.insert("blocks".into(), Value::Array(blocks));
340 Value::Object(m)
341 }
342 pub fn meta_inlines(inlines: Vec<Value>) -> Value {
343 node("MetaInlines", Value::Array(inlines))
344 }
345 pub fn meta_string(s: &str) -> Value {
346 node("MetaString", json!(s))
347 }
348 pub fn meta_list(items: Vec<Value>) -> Value {
349 node("MetaList", Value::Array(items))
350 }
351 pub fn span(classes: &[&str], attrs: &[(String, String)], inlines: Vec<Value>) -> Value {
353 let kv: Vec<Value> = attrs.iter().map(|(k, v)| json!([k, v])).collect();
354 node(
355 "Span",
356 arr([json!(["", classes, kv]), Value::Array(inlines)]),
357 )
358 }
359
360 pub fn attr(classes: &[&str]) -> Value {
362 json!(["", classes, []])
363 }
364
365 fn node(t: &str, c: Value) -> Value {
366 let mut m = serde_json::Map::with_capacity(2);
367 m.insert("t".into(), Value::String(t.into()));
368 m.insert("c".into(), c);
369 Value::Object(m)
370 }
371
372 pub fn para(inlines: Vec<Value>) -> Value {
375 node("Para", Value::Array(inlines))
376 }
377 pub fn plain(inlines: Vec<Value>) -> Value {
378 node("Plain", Value::Array(inlines))
379 }
380 pub fn header(level: usize, inlines: Vec<Value>) -> Value {
381 node(
382 "Header",
383 arr([level.into(), attr(&[]), Value::Array(inlines)]),
384 )
385 }
386 pub fn header_with_id(level: usize, id: &str, inlines: Vec<Value>) -> Value {
388 node(
389 "Header",
390 arr([level.into(), json!([id, [], []]), Value::Array(inlines)]),
391 )
392 }
393 pub fn anchor(id: &str) -> Value {
396 node("Span", json!([[id, ["anchor"], []], []]))
397 }
398 pub fn code_block(language: Option<&str>, text: &str) -> Value {
399 let classes: Vec<&str> = language.into_iter().collect();
400 node("CodeBlock", arr([attr(&classes), text.into()]))
401 }
402 pub fn bullet_list(items: Vec<Vec<Value>>) -> Value {
403 node(
404 "BulletList",
405 Value::Array(items.into_iter().map(Value::Array).collect()),
406 )
407 }
408 pub fn ordered_list(start: u64, style: &str, delim: &str, items: Vec<Vec<Value>>) -> Value {
412 node(
413 "OrderedList",
414 arr([
415 json!([start, { "t": style }, { "t": delim }]),
416 Value::Array(items.into_iter().map(Value::Array).collect()),
417 ]),
418 )
419 }
420 pub fn definition_list(entries: Vec<(Vec<Value>, Vec<Vec<Value>>)>) -> Value {
421 node(
422 "DefinitionList",
423 Value::Array(
424 entries
425 .into_iter()
426 .map(|(term, defs)| {
427 arr([
428 Value::Array(term),
429 Value::Array(defs.into_iter().map(Value::Array).collect()),
430 ])
431 })
432 .collect(),
433 ),
434 )
435 }
436 pub fn raw_block(format: &str, text: &str) -> Value {
439 node("RawBlock", json!([format, text]))
440 }
441 pub fn div(classes: &[&str], blocks: Vec<Value>) -> Value {
442 node("Div", arr([attr(classes), Value::Array(blocks)]))
443 }
444 pub fn caption(blocks: Vec<Value>) -> Value {
446 arr([Value::Null, Value::Array(blocks)])
447 }
448 pub fn figure(classes: &[&str], caption: Value, blocks: Vec<Value>) -> Value {
449 node(
450 "Figure",
451 arr([attr(classes), caption, Value::Array(blocks)]),
452 )
453 }
454 pub fn cell(row_span: usize, col_span: usize, blocks: Vec<Value>) -> Value {
456 arr([
457 attr(&[]),
458 json!({ "t": "AlignDefault" }),
459 row_span.into(),
460 col_span.into(),
461 Value::Array(blocks),
462 ])
463 }
464 pub fn row(cells: Vec<Value>) -> Value {
465 arr([attr(&[]), Value::Array(cells)])
466 }
467 pub fn table(caption: Value, num_cols: usize, head: Vec<Value>, body: Vec<Value>) -> Value {
469 let colspecs: Vec<Value> = (0..num_cols)
470 .map(|_| json!([{ "t": "AlignDefault" }, { "t": "ColWidthDefault" }]))
471 .collect();
472 node(
473 "Table",
474 arr([
475 attr(&[]),
476 caption,
477 Value::Array(colspecs),
478 arr([attr(&[]), Value::Array(head)]),
479 arr([arr([attr(&[]), 0.into(), json!([]), Value::Array(body)])]),
480 arr([attr(&[]), json!([])]),
481 ]),
482 )
483 }
484
485 pub fn str_(s: &str) -> Value {
488 node("Str", json!(s))
489 }
490 pub fn space() -> Value {
491 json!({ "t": "Space" })
492 }
493 pub fn line_break() -> Value {
494 json!({ "t": "LineBreak" })
495 }
496 pub fn wrap(t: &str, inlines: Vec<Value>) -> Value {
498 node(t, Value::Array(inlines))
499 }
500 pub fn code(text: &str) -> Value {
501 node("Code", arr([attr(&[]), text.into()]))
502 }
503 pub fn math(display: bool, tex: &str) -> Value {
504 let kind = if display { "DisplayMath" } else { "InlineMath" };
505 node("Math", json!([{ "t": kind }, tex]))
506 }
507 pub fn link(inlines: Vec<Value>, url: &str) -> Value {
508 node(
509 "Link",
510 arr([attr(&[]), Value::Array(inlines), json!([url, ""])]),
511 )
512 }
513 pub fn image(classes: &[&str], alt: Vec<Value>, src: &str) -> Value {
514 image_sized(classes, &[], alt, src)
515 }
516 pub fn image_sized(
518 classes: &[&str],
519 attrs: &[(String, String)],
520 alt: Vec<Value>,
521 src: &str,
522 ) -> Value {
523 let kv: Vec<Value> = attrs.iter().map(|(k, v)| json!([k, v])).collect();
524 node(
525 "Image",
526 arr([
527 json!(["", classes, kv]),
528 Value::Array(alt),
529 json!([src, ""]),
530 ]),
531 )
532 }
533 pub fn note(blocks: Vec<Value>) -> Value {
534 node("Note", Value::Array(blocks))
535 }
536}
537
538fn text_inlines(text: &str) -> Vec<Value> {
544 let mut out = Vec::new();
545 for (i, line) in text.trim().split('\n').enumerate() {
546 if i > 0 {
547 out.push(ast::line_break());
548 }
549 let is_ws = |c: char| c.is_whitespace() && c != '\u{a0}';
550 let line_start = out.len();
551 let mut pending_space = false;
554 let mut rest = line;
555 while !rest.is_empty() {
556 let (plain, link) = match markdown_link(rest) {
557 Some((before, label, target, after)) => {
558 rest = after;
559 (before, Some((label, target)))
560 }
561 None => (std::mem::take(&mut rest), None),
562 };
563 let words: Vec<&str> = plain.split(is_ws).filter(|w| !w.is_empty()).collect();
564 if words.is_empty() {
565 pending_space |= !plain.is_empty();
566 } else {
567 if out.len() > line_start && (pending_space || plain.starts_with(is_ws)) {
568 out.push(ast::space());
569 }
570 for (k, word) in words.iter().enumerate() {
571 if k > 0 {
572 out.push(ast::space());
573 }
574 out.push(ast::str_(word));
575 }
576 pending_space = plain.ends_with(is_ws);
577 }
578 if let Some((label, target)) = link {
579 if out.len() > line_start && pending_space {
580 out.push(ast::space());
581 }
582 let mut label_inlines = Vec::new();
583 for (k, word) in label.split_whitespace().enumerate() {
584 if k > 0 {
585 label_inlines.push(ast::space());
586 }
587 label_inlines.push(ast::str_(word));
588 }
589 if crate::hyperlink::is_safe_hyperlink(target) {
592 out.push(ast::link(label_inlines, target));
593 } else {
594 out.extend(label_inlines);
595 }
596 pending_space = false;
597 }
598 }
599 }
600 out
601}
602
603fn markdown_link(text: &str) -> Option<(&str, &str, &str, &str)> {
608 let mut from = 0;
609 while let Some(open) = text[from..].find('[') {
610 let open = from + open;
611 from = open + 1;
612 if text[..open].ends_with('!') {
613 continue;
614 }
615 let close = text[open + 1..].find(']')?;
616 let close = open + 1 + close;
617 let label = &text[open + 1..close];
618 if label.is_empty() || label.contains(['[', '\n']) || !text[close + 1..].starts_with('(') {
619 continue;
620 }
621 let Some(end) = text[close + 2..].find(')') else {
622 continue;
623 };
624 let end = close + 2 + end;
625 let target = &text[close + 2..end];
626 if target.is_empty() || target.contains(|c: char| c.is_whitespace() || c == '(') {
627 continue;
628 }
629 return Some((&text[..open], label, target, &text[end + 1..]));
630 }
631 None
632}
633
634fn text_with_notes(text: &str, notes: &[Value]) -> Vec<Value> {
639 let mut calls: Vec<(usize, &str)> = notes
640 .iter()
641 .filter_map(|n| Some((n.get(0)?.as_u64()? as usize, n.get(1)?.as_str()?)))
642 .collect();
643 calls.sort_by_key(|&(offset, _)| offset);
644 let chars: Vec<char> = text.chars().collect();
645 let mut out: Vec<Value> = Vec::new();
646 let push_segment = |out: &mut Vec<Value>, seg: &str| {
647 let inlines = text_inlines(seg);
648 if inlines.is_empty() {
649 if !out.is_empty() && seg.chars().any(char::is_whitespace) {
650 out.push(ast::space());
651 }
652 return;
653 }
654 if !out.is_empty() && seg.starts_with(char::is_whitespace) {
655 out.push(ast::space());
656 }
657 out.extend(inlines);
658 if seg.ends_with(char::is_whitespace) {
659 out.push(ast::space());
660 }
661 };
662 let mut prev = 0usize;
663 for (offset, note) in calls {
664 let at = offset.clamp(prev, chars.len());
665 let seg: String = chars[prev..at].iter().collect();
666 push_segment(&mut out, &seg);
667 out.push(ast::note(vec![ast::para(text_inlines(note))]));
668 prev = at;
669 }
670 let rest: String = chars[prev..].iter().collect();
671 push_segment(&mut out, &rest);
672 while out
673 .last()
674 .is_some_and(|v| v.get("t").and_then(Value::as_str) == Some("Space"))
675 {
676 out.pop();
677 }
678 out
679}
680
681fn join_inlines(parts: Vec<Vec<Value>>) -> Vec<Value> {
687 fn edge_char(inlines: &[Value], first: bool) -> Option<char> {
688 let node = if first {
689 inlines.first()
690 } else {
691 inlines.last()
692 }?;
693 match node.get("t").and_then(Value::as_str)? {
694 "Str" => {
695 let s = node.get("c")?.as_str()?;
696 if first {
697 s.chars().next()
698 } else {
699 s.chars().last()
700 }
701 }
702 "Strong" | "Emph" | "Underline" | "Strikeout" | "Subscript" | "Superscript" => {
704 edge_char(node.get("c")?.as_array()?, first)
705 }
706 "Link" => edge_char(node.get("c")?.get(1)?.as_array()?, first),
707 _ => None,
708 }
709 }
710 let mut out: Vec<Value> = Vec::new();
711 for part in parts.into_iter().filter(|p| !p.is_empty()) {
712 if !out.is_empty() {
713 let closes = edge_char(&part, true)
714 .is_some_and(|c| ".,;:!?)]}%\u{bb}\u{201d}\u{2019}".contains(c));
715 let opens = edge_char(&out, false).is_some_and(|c| "([{\u{ab}\u{201c}".contains(c));
716 if !closes && !opens {
717 out.push(ast::space());
718 }
719 }
720 out.extend(part);
721 }
722 out
723}
724
725fn meta_of(json: &Value) -> Value {
734 let mut meta = serde_json::Map::new();
735 let Some(m) = json.get("metadata").and_then(Value::as_object) else {
736 return Value::Object(meta);
737 };
738 let text = |key: &str| {
739 m.get(key)
740 .and_then(Value::as_str)
741 .map(text_inlines)
742 .filter(|t| !t.is_empty())
743 };
744 let list = |key: &str| -> Vec<Value> {
745 m.get(key)
746 .and_then(Value::as_array)
747 .into_iter()
748 .flatten()
749 .filter_map(Value::as_str)
750 .map(text_inlines)
751 .filter(|t| !t.is_empty())
752 .map(ast::meta_inlines)
753 .collect()
754 };
755 for (key, field) in [
756 ("title", "title"),
757 ("subject", "subject"),
758 ("description", "description"),
759 ("publisher", "publisher"),
760 ("rights", "rights"),
761 ("identifier", "identifier"),
762 ] {
763 if let Some(t) = text(field) {
764 meta.insert(key.into(), ast::meta_inlines(t));
765 }
766 }
767 let authors = list("authors");
768 if !authors.is_empty() {
769 meta.insert("author".into(), ast::meta_list(authors));
770 }
771 if let Some(date) = text("date").or_else(|| text("created")) {
772 meta.insert("date".into(), ast::meta_inlines(date));
773 }
774 let keywords = list("keywords");
775 if !keywords.is_empty() {
776 meta.insert("keywords".into(), ast::meta_list(keywords));
777 }
778 if let Some(lang) = m.get("language").and_then(Value::as_str).map(str::trim) {
779 if !lang.is_empty() {
780 meta.insert("lang".into(), ast::meta_string(lang));
781 }
782 }
783 Value::Object(meta)
784}
785
786fn split_comment(note: &str) -> (Option<String>, Option<String>, String) {
790 let Some(rest) = note.strip_prefix('[') else {
791 return (None, None, note.to_string());
792 };
793 let Some((head, body)) = rest.split_once(']') else {
794 return (None, None, note.to_string());
795 };
796 let body = body.strip_prefix(':').unwrap_or(body).trim().to_string();
797 let mut author = None;
798 let mut date = None;
799 for part in head.split(", ") {
800 if let Some(a) = part.strip_prefix("author: ") {
801 author = Some(a.trim().to_string());
802 } else if let Some(t) = part.strip_prefix("time: ") {
803 date = Some(t.trim().to_string());
804 }
805 }
806 (author, date, body)
807}
808
809fn inches(v: f64) -> String {
812 let s = format!("{v:.4}");
813 let s = s.trim_end_matches('0').trim_end_matches('.');
814 format!("{}in", if s.is_empty() { "0" } else { s })
815}
816
817fn list_attributes(markers: &[&str]) -> (u64, &'static str, &'static str) {
828 fn split(marker: &str) -> (&str, &'static str) {
829 let m = marker.trim();
830 if let Some(inner) = m.strip_prefix('(').and_then(|r| r.strip_suffix(')')) {
831 return (inner.trim(), "TwoParens");
832 }
833 if let Some(inner) = m.strip_suffix(')') {
834 return (inner.trim(), "OneParen");
835 }
836 if let Some(inner) = m.strip_suffix('.') {
837 return (inner.trim(), "Period");
838 }
839 (m, "DefaultDelim")
840 }
841 fn roman(token: &str) -> Option<u64> {
842 let value = |c: char| match c.to_ascii_lowercase() {
843 'i' => Some(1),
844 'v' => Some(5),
845 'x' => Some(10),
846 'l' => Some(50),
847 'c' => Some(100),
848 'd' => Some(500),
849 'm' => Some(1000),
850 _ => None,
851 };
852 let values: Vec<i64> = token.chars().map(value).collect::<Option<_>>()?;
853 if values.is_empty() {
854 return None;
855 }
856 let mut total = 0i64;
857 for (i, &v) in values.iter().enumerate() {
858 if values.get(i + 1).is_some_and(|&next| next > v) {
860 total -= v;
861 } else {
862 total += v;
863 }
864 }
865 (total > 0).then_some(total as u64)
866 }
867 fn alpha(token: &str) -> Option<u64> {
868 let first = token.chars().next()?;
869 if !first.is_ascii_alphabetic() || !token.chars().all(|c| c == first) {
870 return None;
871 }
872 let ordinal = (first.to_ascii_lowercase() as u64) - ('a' as u64) + 1;
873 Some((token.chars().count() as u64 - 1) * 26 + ordinal)
874 }
875 const DEFAULT: (u64, &str, &str) = (1, "Decimal", "Period");
876 let Some(first) = markers.first() else {
877 return DEFAULT;
878 };
879 let (token, delim) = split(first);
880 if token.is_empty() {
881 return DEFAULT;
882 }
883 if token.chars().all(|c| c.is_ascii_digit()) {
884 return token
885 .parse()
886 .map(|n| (n, "Decimal", delim))
887 .unwrap_or(DEFAULT);
888 }
889 if !token.chars().all(|c| c.is_ascii_alphabetic()) {
890 return DEFAULT;
891 }
892 let upper = token.chars().next().is_some_and(|c| c.is_ascii_uppercase());
893 let letters = |t: &str| t.chars().all(|c| c.is_ascii_alphabetic());
894 let reads_roman = roman(token).is_some()
895 && (token.len() > 1
896 || token.eq_ignore_ascii_case("i")
897 || markers[1..].iter().any(|m| {
898 let (t, _) = split(m);
899 t.len() > 1 && letters(t) && roman(t).is_some()
900 }));
901 if reads_roman {
902 let style = if upper { "UpperRoman" } else { "LowerRoman" };
903 return (roman(token).unwrap_or(1), style, delim);
904 }
905 match alpha(token) {
906 Some(n) => (n, if upper { "UpperAlpha" } else { "LowerAlpha" }, delim),
907 None => DEFAULT,
908 }
909}
910
911struct GridCell {
912 cell: usize,
914 start_row: usize,
915 start_col: usize,
916 row_span: usize,
917 col_span: usize,
918}
919
920struct Serializer<'a> {
921 json: &'a Value,
922 tables: &'a [TableData],
925 image_mode: ImageMode,
926 artifacts_dir: String,
927 layers: ContentLayers,
928 heading_depth: HeadingDepth,
929 track_changes: Option<TrackChanges>,
930 artifacts: Vec<(String, Vec<u8>)>,
931 pic_index: usize,
932 visited: HashSet<String>,
933 owned_captions: HashSet<String>,
936 owned_footnotes: HashSet<String>,
937}
938
939impl<'a> Serializer<'a> {
940 fn new(json: &'a Value, options: &PandocExportOptions) -> Self {
941 let mut owned_captions = HashSet::new();
942 let mut owned_footnotes = HashSet::new();
943 for bucket in [
944 "pictures",
945 "tables",
946 "key_value_items",
947 "form_items",
948 "texts",
949 ] {
950 for item in Self::array(json.get(bucket)) {
951 for (key, set) in [
952 ("captions", &mut owned_captions),
953 ("footnotes", &mut owned_footnotes),
954 ] {
955 for r in Self::array(item.get(key)) {
956 if let Some(cref) = r.get("$ref").and_then(Value::as_str) {
957 set.insert(cref.to_string());
958 }
959 }
960 }
961 }
962 }
963 Self {
964 json,
965 tables: &[],
966 image_mode: options.image_mode,
967 artifacts_dir: options.artifacts_dir.clone(),
968 layers: options.layers,
969 heading_depth: options.heading_depth,
970 track_changes: options.track_changes,
971 artifacts: Vec::new(),
972 pic_index: 0,
973 visited: HashSet::new(),
974 owned_captions,
975 owned_footnotes,
976 }
977 }
978
979 fn array(v: Option<&'a Value>) -> impl Iterator<Item = &'a Value> {
980 v.and_then(Value::as_array).into_iter().flatten()
981 }
982
983 fn resolve(&self, cref: &str) -> Option<&'a Value> {
984 let rest = cref.strip_prefix("#/")?;
985 if rest == "body" {
986 return self.json.get("body");
987 }
988 let (bucket, idx) = rest.split_once('/')?;
989 self.json.get(bucket)?.get(idx.parse::<usize>().ok()?)
990 }
991
992 fn refs(v: Option<&'a Value>) -> Vec<&'a str> {
993 Self::array(v)
994 .filter_map(|r| r.get("$ref").and_then(Value::as_str))
995 .collect()
996 }
997
998 fn children(item: &'a Value) -> Vec<&'a str> {
999 Self::refs(item.get("children"))
1000 }
1001
1002 fn self_ref(item: &Value) -> &str {
1003 item.get("self_ref").and_then(Value::as_str).unwrap_or("")
1004 }
1005
1006 fn label(item: &Value) -> &str {
1007 item.get("label").and_then(Value::as_str).unwrap_or("")
1008 }
1009
1010 fn text(item: &Value) -> &str {
1011 item.get("text").and_then(Value::as_str).unwrap_or("")
1012 }
1013
1014 fn anchors(item: &Value) -> Vec<String> {
1016 item.get("_anchors")
1017 .and_then(Value::as_array)
1018 .map(|a| {
1019 a.iter()
1020 .filter_map(Value::as_str)
1021 .map(str::to_string)
1022 .collect()
1023 })
1024 .unwrap_or_default()
1025 }
1026
1027 fn is_group(item: &Value) -> bool {
1028 Self::self_ref(item).starts_with("#/groups/")
1029 }
1030
1031 fn excluded(&self, item: &Value) -> bool {
1032 let layer = item
1033 .get("content_layer")
1034 .and_then(Value::as_str)
1035 .unwrap_or("body");
1036 !self.layers.contains_name(layer)
1037 }
1038
1039 fn descendants(&self, root: &'a Value, out: &mut Vec<&'a Value>) {
1044 let picture = Self::self_ref(root).starts_with("#/pictures/");
1045 let captions = if picture {
1046 Self::refs(root.get("captions"))
1047 } else {
1048 Vec::new()
1049 };
1050 for cref in Self::children(root) {
1051 if picture && !captions.contains(&cref) {
1052 continue;
1053 }
1054 if self.owned_captions.contains(cref) || self.owned_footnotes.contains(cref) {
1058 continue;
1059 }
1060 let Some(child) = self.resolve(cref) else {
1061 continue;
1062 };
1063 if !self.excluded(child) {
1064 out.push(child);
1065 }
1066 self.descendants(child, out);
1067 }
1068 }
1069
1070 fn blocks_of(&mut self, item: &'a Value) -> Vec<Value> {
1074 let mut nodes = Vec::new();
1075 self.descendants(item, &mut nodes);
1076 let mut out = Vec::new();
1077 for node in nodes {
1078 if self.visited.insert(Self::self_ref(node).to_string()) {
1079 out.extend(self.serialize(node));
1080 }
1081 }
1082 out
1083 }
1084
1085 fn body(&mut self) -> Vec<Value> {
1086 let Some(body) = self.json.get("body") else {
1087 return Vec::new();
1088 };
1089 self.visited.insert("#/body".to_string());
1090 let mut blocks = self.blocks_of(body);
1091 let notes = self.unplaced_notes();
1092 blocks.extend(notes);
1093 blocks
1094 }
1095
1096 fn unplaced_notes(&mut self) -> Vec<Value> {
1102 let notes: Vec<&'a Value> = Self::array(self.json.get("texts"))
1103 .filter(|t| Self::label(t) == "footnote" && !Self::is_note_body(t))
1104 .filter(|t| t.get("content_layer").and_then(Value::as_str) == Some("furniture"))
1105 .filter(|t| self.excluded(t) && !self.owned_footnotes.contains(Self::self_ref(t)))
1106 .collect();
1107 let mut out = Vec::new();
1108 for t in notes {
1109 let (text, _, _) = self.own_inlines(t);
1111 if !text.is_empty() {
1112 out.push(ast::para(vec![ast::note(vec![ast::para(text)])]));
1113 }
1114 }
1115 out
1116 }
1117
1118 fn serialize(&mut self, item: &'a Value) -> Vec<Value> {
1120 let sref = Self::self_ref(item);
1121 if Self::is_group(item) {
1122 return match Self::label(item) {
1123 "comment_section" => Vec::new(),
1127 "list" => self.list_group(item),
1128 "inline" => {
1129 let (inlines, mut rest) = self.inline_group(item);
1130 let mut out = Vec::new();
1131 if !inlines.is_empty() {
1132 out.push(ast::para(inlines));
1133 }
1134 out.append(&mut rest);
1135 out
1136 }
1137 _ => self.blocks_of(item),
1138 };
1139 }
1140 if sref.starts_with("#/texts/") {
1141 if self.owned_captions.contains(sref) || self.owned_footnotes.contains(sref) {
1142 return Vec::new();
1143 }
1144 if self.excluded(item) || Self::is_note_body(item) {
1146 return Vec::new();
1147 }
1148 return self.text_item(item);
1149 }
1150 if sref.starts_with("#/tables/") {
1151 return self.table(item);
1152 }
1153 if sref.starts_with("#/pictures/") {
1154 return self.picture(item);
1155 }
1156 if sref.starts_with("#/key_value_items/") {
1157 return self.graph_item(item, "key-value-region");
1158 }
1159 if sref.starts_with("#/form_items/") {
1160 return self.graph_item(item, "form-container");
1161 }
1162 if sref.starts_with("#/field_regions/") {
1163 return self.field_region(item);
1164 }
1165 if sref.starts_with("#/field_items/") {
1166 let entries = vec![self.field_item(item)];
1167 return Self::field_blocks(entries);
1168 }
1169 Vec::new()
1170 }
1171
1172 fn field_region(&mut self, region: &'a Value) -> Vec<Value> {
1176 let mut entries = Vec::new();
1177 for cref in Self::children(region) {
1178 let Some(child) = self.resolve(cref) else {
1179 continue;
1180 };
1181 if !self.visited.insert(cref.to_string()) {
1182 continue;
1183 }
1184 if cref.starts_with("#/field_items/") {
1185 entries.push(self.field_item(child));
1186 } else {
1187 let mut blocks = if self.excluded(child) {
1188 Vec::new()
1189 } else {
1190 self.serialize(child)
1191 };
1192 blocks.extend(self.blocks_of(child));
1193 entries.push((Vec::new(), blocks));
1194 }
1195 }
1196 let blocks = Self::field_blocks(entries);
1197 if blocks.is_empty() {
1198 Vec::new()
1199 } else {
1200 vec![ast::div(&["field-region"], blocks)]
1201 }
1202 }
1203
1204 fn field_item(&mut self, field: &'a Value) -> (Vec<Value>, Vec<Value>) {
1207 let mut nodes = Vec::new();
1208 self.descendants(field, &mut nodes);
1209 let (mut term, mut defs) = (Vec::new(), Vec::new());
1210 for node in nodes {
1211 if !self.visited.insert(Self::self_ref(node).to_string()) {
1212 continue;
1213 }
1214 match Self::label(node) {
1215 "marker" | "field_key" => {
1216 let inlines = self.formatted(node);
1217 if !inlines.is_empty() {
1218 term = join_inlines(vec![term, inlines]);
1219 }
1220 }
1221 "field_value" => {
1222 let inlines = self.formatted(node);
1223 if !inlines.is_empty() {
1224 defs.push(ast::plain(inlines));
1225 }
1226 }
1227 _ => defs.extend(self.serialize(node)),
1228 }
1229 }
1230 (term, defs)
1231 }
1232
1233 fn field_blocks(entries: Vec<(Vec<Value>, Vec<Value>)>) -> Vec<Value> {
1236 let mut out = Vec::new();
1237 let mut run: Vec<(Vec<Value>, Vec<Vec<Value>>)> = Vec::new();
1238 for (term, defs) in entries {
1239 if term.is_empty() {
1240 if !run.is_empty() {
1241 out.push(ast::definition_list(std::mem::take(&mut run)));
1242 }
1243 out.extend(defs);
1244 } else {
1245 let defs = defs.into_iter().map(|d| vec![d]).collect();
1246 run.push((term, defs));
1247 }
1248 }
1249 if !run.is_empty() {
1250 out.push(ast::definition_list(run));
1251 }
1252 out
1253 }
1254
1255 fn own_inlines(&mut self, item: &'a Value) -> (Vec<Value>, Vec<Value>, bool) {
1262 let children = Self::children(item);
1263 if Self::text(item).is_empty() {
1264 if let Some(group) = children
1265 .first()
1266 .and_then(|c| self.resolve(c))
1267 .filter(|c| Self::is_group(c) && Self::label(c) == "inline")
1268 {
1269 self.visited.insert(Self::self_ref(group).to_string());
1270 let (inlines, rest) = self.inline_group(group);
1271 return (inlines, rest, true);
1272 }
1273 }
1274 (self.formatted(item), Vec::new(), false)
1275 }
1276
1277 fn with_comments(&mut self, item: &'a Value, inlines: Vec<Value>) -> Vec<Value> {
1286 let refs = Self::refs(item.get("comments"));
1287 if refs.is_empty() {
1288 return inlines;
1289 }
1290 if matches!(
1293 self.track_changes,
1294 Some(TrackChanges::Accept | TrackChanges::Reject)
1295 ) {
1296 return inlines;
1297 }
1298 let mut starts = Vec::new();
1299 let mut ends = Vec::new();
1300 for cref in refs {
1301 let Some(group) = self.resolve(cref) else {
1302 continue;
1303 };
1304 if !Self::is_group(group) || Self::label(group) != "comment_section" {
1305 continue;
1306 }
1307 self.visited.insert(cref.to_string());
1308 let id = group
1309 .get("name")
1310 .and_then(Value::as_str)
1311 .and_then(|n| n.strip_prefix("comment-"))
1312 .unwrap_or("0")
1313 .to_string();
1314 let mut k = 0;
1317 for r in Self::children(group) {
1318 self.visited.insert(r.to_string());
1319 let Some(node) = self.resolve(r).filter(|_| r.starts_with("#/texts/")) else {
1320 continue;
1321 };
1322 let (author, date, text) = split_comment(Self::text(node));
1323 let comment_id = if k == 0 {
1324 id.clone()
1325 } else {
1326 format!("{id}-{k}")
1327 };
1328 k += 1;
1329 let mut attrs = vec![("comment-id".to_string(), comment_id.clone())];
1330 if let Some(a) = author {
1331 attrs.push(("author".to_string(), a));
1332 }
1333 if let Some(d) = date {
1334 attrs.push(("date".to_string(), d));
1335 }
1336 starts.push(ast::span(&["comment-start"], &attrs, text_inlines(&text)));
1337 ends.push(ast::span(
1338 &["comment-end"],
1339 &[("comment-id".to_string(), comment_id)],
1340 Vec::new(),
1341 ));
1342 }
1343 }
1344 if starts.is_empty() {
1345 return inlines;
1346 }
1347 let mut out = starts;
1348 out.extend(inlines);
1349 out.extend(ends);
1350 out
1351 }
1352
1353 fn formatted(&self, item: &Value) -> Vec<Value> {
1356 let base = match Self::label(item) {
1357 "code" => vec![ast::code(Self::text(item))],
1358 "formula" if !Self::text(item).is_empty() => {
1359 vec![ast::math(false, Self::text(item).trim())]
1360 }
1361 _ => {
1362 let (text, skipped) = Self::authored_text(item);
1363 match item.get("_notes").and_then(Value::as_array) {
1364 Some(notes) if !notes.is_empty() => {
1365 if skipped == 0 {
1366 text_with_notes(text, notes)
1367 } else {
1368 let shifted: Vec<Value> = notes
1370 .iter()
1371 .map(|n| {
1372 let off = n[0].as_u64().unwrap_or(0).saturating_sub(skipped);
1373 serde_json::json!([off, n[1]])
1374 })
1375 .collect();
1376 text_with_notes(text, &shifted)
1377 }
1378 }
1379 _ => text_inlines(text),
1380 }
1381 }
1382 };
1383 Self::decorate(base, item)
1384 }
1385
1386 fn authored_text(item: &Value) -> (&str, u64) {
1389 let text = Self::text(item);
1390 if let Some(number) = item.get("_number").and_then(Value::as_str) {
1391 if let Some(rest) = text
1392 .strip_prefix(number)
1393 .and_then(|rest| rest.strip_prefix(' '))
1394 {
1395 return (rest, (number.chars().count() + 1) as u64);
1396 }
1397 }
1398 (text, 0)
1399 }
1400
1401 fn is_note_body(item: &Value) -> bool {
1404 item.get("_note_body").and_then(Value::as_bool) == Some(true)
1405 }
1406
1407 fn decorate(mut inlines: Vec<Value>, item: &Value) -> Vec<Value> {
1408 if inlines.is_empty() {
1409 return inlines;
1410 }
1411 if let Some(f) = item.get("formatting") {
1412 let on = |k: &str| f.get(k).and_then(Value::as_bool).unwrap_or(false);
1413 if on("bold") {
1414 inlines = vec![ast::wrap("Strong", inlines)];
1415 }
1416 if on("italic") {
1417 inlines = vec![ast::wrap("Emph", inlines)];
1418 }
1419 if on("underline") {
1420 inlines = vec![ast::wrap("Underline", inlines)];
1421 }
1422 if on("strikethrough") {
1423 inlines = vec![ast::wrap("Strikeout", inlines)];
1424 }
1425 match f.get("script").and_then(Value::as_str) {
1426 Some("sub") => inlines = vec![ast::wrap("Subscript", inlines)],
1427 Some("super") => inlines = vec![ast::wrap("Superscript", inlines)],
1428 _ => {}
1429 }
1430 }
1431 if let Some(url) = item
1433 .get("hyperlink")
1434 .and_then(Value::as_str)
1435 .filter(|u| crate::hyperlink::is_safe_hyperlink(u))
1436 {
1437 inlines = vec![ast::link(inlines, url)];
1438 }
1439 if let Some(rev) = item.get("_revision") {
1442 if let Some(kind) = rev.get("kind").and_then(Value::as_str) {
1443 let mut attrs = Vec::new();
1444 for key in ["author", "date"] {
1445 if let Some(v) = rev.get(key).and_then(Value::as_str) {
1446 attrs.push((key.to_string(), v.to_string()));
1447 }
1448 }
1449 inlines = vec![ast::span(&[kind], &attrs, inlines)];
1450 }
1451 }
1452 inlines
1453 }
1454
1455 fn inline_group(&mut self, group: &'a Value) -> (Vec<Value>, Vec<Value>) {
1459 let mut nodes = Vec::new();
1460 self.descendants(group, &mut nodes);
1461 let mut runs: Vec<Vec<Value>> = Vec::new();
1462 let mut rest: Vec<Value> = Vec::new();
1463 for node in nodes {
1464 let r = Self::self_ref(node).to_string();
1465 if self.visited.contains(&r) {
1466 continue;
1467 }
1468 self.visited.insert(r.clone());
1469 if r.starts_with("#/texts/") {
1470 if self.owned_captions.contains(&r)
1471 || self.owned_footnotes.contains(&r)
1472 || Self::is_note_body(node)
1473 {
1474 continue;
1475 }
1476 let mut run: Vec<Value> = Self::anchors(node)
1477 .iter()
1478 .map(|id| ast::anchor(id))
1479 .collect();
1480 run.append(&mut self.formatted(node));
1481 runs.push(run);
1482 continue;
1483 }
1484 if Self::is_group(node) && Self::label(node) == "inline" {
1485 let (inl, mut more) = self.inline_group(node);
1486 runs.push(inl);
1487 rest.append(&mut more);
1488 continue;
1489 }
1490 rest.extend(self.serialize(node));
1491 }
1492 (join_inlines(runs), rest)
1493 }
1494
1495 fn text_item(&mut self, item: &'a Value) -> Vec<Value> {
1496 let label = Self::label(item);
1497 let mut out = Vec::new();
1498 match label {
1499 "list_item" => {
1500 let blocks = self.list_item_blocks(item);
1502 if !blocks.is_empty() {
1503 out.push(ast::bullet_list(vec![blocks]));
1504 }
1505 return out;
1506 }
1507 "code" => {
1508 let lang = item
1509 .get("code_language")
1510 .and_then(Value::as_str)
1511 .filter(|l| !l.is_empty() && *l != "unknown")
1512 .map(str::to_ascii_lowercase);
1513 out.push(ast::code_block(lang.as_deref(), Self::text(item)));
1514 }
1515 "formula" => {
1516 let tex = Self::text(item).trim();
1517 if !tex.is_empty() {
1518 out.push(ast::para(vec![ast::math(true, tex)]));
1519 }
1520 }
1521 _ => {
1522 let (inlines, rest, _) = self.own_inlines(item);
1523 let mut inlines = self.with_comments(item, inlines);
1524 let mut anchors = Self::anchors(item);
1528 let header_id =
1529 if matches!(label, "title" | "section_header") && !anchors.is_empty() {
1530 Some(anchors.remove(0))
1531 } else {
1532 None
1533 };
1534 if !anchors.is_empty() && (header_id.is_some() || !inlines.is_empty()) {
1535 let mut with_anchors: Vec<Value> =
1536 anchors.iter().map(|id| ast::anchor(id)).collect();
1537 with_anchors.append(&mut inlines);
1538 inlines = with_anchors;
1539 }
1540 match label {
1541 "title" | "section_header" => {
1542 let level = if label == "title" {
1543 1
1544 } else {
1545 let n = item.get("level").and_then(Value::as_u64).unwrap_or(1) as usize;
1546 match self.heading_depth {
1547 HeadingDepth::Docling => (n + 1).min(6),
1550 HeadingDepth::Source => n.clamp(1, 6),
1554 }
1555 };
1556 if !inlines.is_empty() {
1557 out.push(match &header_id {
1558 Some(id) => ast::header_with_id(level, id, inlines),
1559 None => ast::header(level, inlines),
1560 });
1561 }
1562 }
1563 "text" | "paragraph" => {
1564 if !inlines.is_empty() {
1565 out.push(ast::para(inlines));
1566 }
1567 }
1568 "checkbox_selected" | "checkbox_unselected" => {
1569 let mark = if label == "checkbox_selected" {
1570 "☒"
1571 } else {
1572 "☐"
1573 };
1574 let mut with_mark = vec![ast::str_(mark)];
1575 if !inlines.is_empty() {
1576 with_mark.push(ast::space());
1577 with_mark.append(&mut inlines);
1578 }
1579 out.push(ast::para(with_mark));
1580 }
1581 other => {
1582 if !inlines.is_empty() {
1583 let class = format!("docling-{other}");
1584 out.push(ast::div(&[&class], vec![ast::para(inlines)]));
1585 }
1586 }
1587 }
1588 out.extend(rest);
1589 }
1590 }
1591 out.extend(self.blocks_of(item));
1593 out
1594 }
1595
1596 fn list_item_blocks(&mut self, item: &'a Value) -> Vec<Value> {
1599 let (inlines, rest, _) = self.own_inlines(item);
1600 let inlines = self.with_comments(item, inlines);
1601 let mut blocks = Vec::new();
1602 if !inlines.is_empty() {
1603 blocks.push(ast::plain(inlines));
1604 }
1605 blocks.extend(rest);
1606 blocks.extend(self.blocks_of(item));
1607 blocks
1608 }
1609
1610 fn list_group(&mut self, group: &'a Value) -> Vec<Value> {
1611 let first = Self::children(group)
1612 .first()
1613 .and_then(|r| self.resolve(r))
1614 .filter(|c| Self::label(c) == "list_item");
1615 let enumerated = first.is_some_and(|c| {
1616 c.get("enumerated")
1617 .and_then(Value::as_bool)
1618 .unwrap_or(false)
1619 });
1620 let markers: Vec<&str> = Self::children(group)
1623 .iter()
1624 .filter_map(|r| self.resolve(r))
1625 .filter(|c| Self::label(c) == "list_item")
1626 .filter_map(|c| c.get("marker").and_then(Value::as_str))
1627 .collect();
1628 let (start, style, delim) = list_attributes(&markers);
1629
1630 let mut nodes = Vec::new();
1631 self.descendants(group, &mut nodes);
1632 let mut items: Vec<Vec<Value>> = Vec::new();
1633 for node in nodes {
1634 let r = Self::self_ref(node).to_string();
1635 if !self.visited.insert(r.clone()) {
1636 continue;
1637 }
1638 if Self::label(node) == "list_item"
1639 && r.starts_with("#/texts/")
1640 && !self.owned_captions.contains(&r)
1641 {
1642 let blocks = self.list_item_blocks(node);
1643 items.push(blocks);
1644 continue;
1645 }
1646 let blocks = self.serialize(node);
1649 if blocks.is_empty() {
1650 continue;
1651 }
1652 match items.last_mut() {
1653 Some(last) => last.extend(blocks),
1654 None => items.push(blocks),
1655 }
1656 }
1657 if items.is_empty() {
1658 return Vec::new();
1659 }
1660 if enumerated {
1661 vec![ast::ordered_list(start, style, delim, items)]
1662 } else {
1663 vec![ast::bullet_list(items)]
1664 }
1665 }
1666
1667 fn caption(&mut self, item: &'a Value) -> Value {
1673 let mut inlines = self.caption_text(item);
1674 for cref in Self::refs(item.get("footnotes")) {
1675 if let Some(note) = self.resolve(cref) {
1676 if cref.starts_with("#/texts/") && !self.excluded(note) {
1677 let (text, _, _) = self.own_inlines(note);
1678 if !text.is_empty() {
1679 inlines.push(ast::note(vec![ast::para(text)]));
1680 }
1681 }
1682 }
1683 }
1684 if inlines.is_empty() {
1685 ast::caption(Vec::new())
1686 } else {
1687 ast::caption(vec![ast::plain(inlines)])
1688 }
1689 }
1690
1691 fn caption_text(&mut self, item: &'a Value) -> Vec<Value> {
1694 let caps: Vec<&'a Value> = Self::refs(item.get("captions"))
1695 .into_iter()
1696 .filter(|cref| cref.starts_with("#/texts/"))
1697 .filter_map(|cref| self.resolve(cref))
1698 .filter(|cap| !self.excluded(cap))
1699 .collect();
1700 let mut parts = Vec::new();
1701 for cap in caps {
1702 let (inlines, _, _) = self.own_inlines(cap);
1703 parts.push(inlines);
1704 }
1705 join_inlines(parts)
1706 }
1707
1708 fn grid(table: &TableView<'_>) -> (usize, Vec<Vec<Option<GridCell>>>) {
1710 let (num_rows, num_cols) = (table.num_rows, table.num_cols);
1711 let mut grid: Vec<Vec<Option<GridCell>>> = (0..num_rows)
1712 .map(|_| (0..num_cols).map(|_| None).collect())
1713 .collect();
1714 for (i, cell) in table.cells.iter().enumerate() {
1715 let (r0, c0) = (cell.start_row, cell.start_col);
1716 let (r1, c1) = (cell.end_row, cell.end_col);
1717 if r0 >= num_rows || c0 >= num_cols {
1718 continue;
1719 }
1720 let (r1, c1) = (r1.clamp(r0 + 1, num_rows), c1.clamp(c0 + 1, num_cols));
1721 for row in grid.iter_mut().take(r1).skip(r0) {
1722 for slot in row.iter_mut().take(c1).skip(c0) {
1723 *slot = Some(GridCell {
1724 cell: i,
1725 start_row: r0,
1726 start_col: c0,
1727 row_span: r1 - r0,
1728 col_span: c1 - c0,
1729 });
1730 }
1731 }
1732 }
1733 (num_cols, grid)
1734 }
1735
1736 fn table_from_data(&mut self, data: &'a Value, caption: Value) -> Option<Value> {
1737 let table = crate::json::table_view(data, self.tables);
1738 let (num_cols, grid) = Self::grid(&table);
1739 if grid.is_empty() || num_cols == 0 {
1740 return None;
1741 }
1742 let mut rows: Vec<(bool, Value)> = Vec::new();
1743 for (i, row) in grid.iter().enumerate() {
1744 let mut cells = Vec::new();
1745 let mut all_header = true;
1746 let mut anchored = 0;
1747 for (j, slot) in row.iter().enumerate() {
1748 let Some(g) = slot else {
1749 cells.push(ast::cell(1, 1, Vec::new()));
1752 continue;
1753 };
1754 if g.start_row != i || g.start_col != j {
1755 continue;
1756 }
1757 anchored += 1;
1758 let cell = &table.cells[g.cell];
1759 all_header &= cell.column_header;
1760 let blocks = match cell.cell_ref {
1761 Some(cref) => match self.resolve(cref) {
1762 Some(target) => {
1763 self.visited.insert(cref.to_string());
1764 self.serialize(target)
1765 }
1766 None => Vec::new(),
1767 },
1768 None => {
1769 let inl = text_inlines(cell.text);
1770 if inl.is_empty() {
1771 Vec::new()
1772 } else {
1773 vec![ast::plain(inl)]
1774 }
1775 }
1776 };
1777 cells.push(ast::cell(g.row_span, g.col_span, blocks));
1778 }
1779 rows.push((all_header && anchored > 0, ast::row(cells)));
1780 }
1781 let head_len = rows.iter().take_while(|(h, _)| *h).count();
1783 let head_len = if head_len == rows.len() { 0 } else { head_len };
1784 let mut head = Vec::new();
1785 let mut body = Vec::new();
1786 for (i, (_, row)) in rows.into_iter().enumerate() {
1787 if i < head_len {
1788 head.push(row);
1789 } else {
1790 body.push(row);
1791 }
1792 }
1793 Some(ast::table(caption, num_cols, head, body))
1794 }
1795
1796 fn table(&mut self, item: &'a Value) -> Vec<Value> {
1797 let caption = self.caption(item);
1798 if self.excluded(item) {
1799 return Vec::new();
1800 }
1801 match item.get("data") {
1802 Some(data) => self.table_from_data(data, caption).into_iter().collect(),
1803 None => Vec::new(),
1804 }
1805 }
1806
1807 fn picture(&mut self, item: &'a Value) -> Vec<Value> {
1808 if self.excluded(item) {
1809 return Vec::new();
1810 }
1811 let caption = self.caption(item);
1812 let has_caption = caption
1813 .get(1)
1814 .and_then(Value::as_array)
1815 .is_some_and(|b| !b.is_empty());
1816 let uri = item
1817 .get("image")
1818 .and_then(|i| i.get("uri"))
1819 .and_then(Value::as_str);
1820 let src = match (self.image_mode, uri) {
1821 (ImageMode::Embedded, Some(uri)) if uri.starts_with("data:") => Some(uri.to_string()),
1822 (ImageMode::Referenced, Some(uri)) => self
1823 .reference_image(uri)
1824 .or_else(|| (!uri.starts_with("data:")).then(|| uri.to_string())),
1825 _ => None,
1826 };
1827 let description = item
1836 .get("meta")
1837 .and_then(|m| m.get("description"))
1838 .and_then(|d| d.get("text"))
1839 .and_then(Value::as_str)
1840 .map(text_inlines)
1841 .filter(|t| !t.is_empty());
1842 let alt = match description {
1843 Some(alt) => alt,
1844 None => self.caption_text(item),
1845 };
1846 let attrs: Vec<(String, String)> = item
1849 .get("image")
1850 .and_then(|i| i.get("_display"))
1851 .and_then(Value::as_array)
1852 .and_then(|d| Some((d.first()?.as_f64()?, d.get(1)?.as_f64()?)))
1853 .map(|(w, h)| {
1854 vec![
1855 ("width".to_string(), inches(w)),
1856 ("height".to_string(), inches(h)),
1857 ]
1858 })
1859 .unwrap_or_default();
1860 let image = match &src {
1861 Some(src) => ast::image_sized(&[], &attrs, alt, src),
1862 None => ast::image_sized(&["docling-placeholder"], &attrs, alt, ""),
1863 };
1864 let chart = item
1866 .get("meta")
1867 .and_then(|m| m.get("tabular_chart"))
1868 .and_then(|t| t.get("chart_data"))
1869 .and_then(|chart| self.table_from_data(chart, ast::caption(Vec::new())));
1870 if chart.is_none() && !has_caption {
1871 return vec![ast::para(vec![image])];
1873 }
1874 let mut blocks = vec![ast::plain(vec![image])];
1875 blocks.extend(chart);
1876 vec![ast::figure(&[], caption, blocks)]
1877 }
1878
1879 fn reference_image(&mut self, uri: &str) -> Option<String> {
1882 let rest = uri.strip_prefix("data:")?;
1883 let (mime, payload) = rest.split_once(";base64,")?;
1884 let bytes = crate::base64::decode(payload)?;
1885 let path = format!(
1886 "{}/image_{:06}.{}",
1887 self.artifacts_dir,
1888 self.pic_index,
1889 crate::markdown::ext_for(mime)
1890 );
1891 self.pic_index += 1;
1892 self.artifacts.push((path.clone(), bytes));
1893 Some(path)
1894 }
1895
1896 fn graph_item(&mut self, item: &'a Value, class: &str) -> Vec<Value> {
1901 let mut out = Vec::new();
1902 if !self.excluded(item) {
1903 if let Some(graph) = item.get("graph") {
1904 if let Some(block) = Self::graph(graph) {
1905 out.push(ast::div(&[class], vec![block]));
1906 }
1907 }
1908 }
1909 let caption = self.caption(item);
1911 for block in caption
1912 .get(1)
1913 .and_then(Value::as_array)
1914 .into_iter()
1915 .flatten()
1916 {
1917 if let Some(inlines) = block.get("c").and_then(Value::as_array) {
1918 out.push(ast::para(inlines.clone()));
1919 }
1920 }
1921 out
1922 }
1923
1924 fn graph(graph: &'a Value) -> Option<Value> {
1925 let cells: HashMap<u64, &Value> = Self::array(graph.get("cells"))
1926 .filter_map(|c| Some((c.get("cell_id")?.as_u64()?, c)))
1927 .collect();
1928 let order: Vec<u64> = Self::array(graph.get("cells"))
1929 .filter_map(|c| c.get("cell_id")?.as_u64())
1930 .collect();
1931 let mut child_links: HashMap<u64, Vec<u64>> = HashMap::new();
1932 let mut value_links: BTreeMap<u64, Vec<u64>> = BTreeMap::new();
1933 let mut value_order: Vec<u64> = Vec::new();
1934 let mut parents: HashSet<u64> = HashSet::new();
1935 for link in Self::array(graph.get("links")) {
1936 let (Some(s), Some(t)) = (
1937 link.get("source_cell_id").and_then(Value::as_u64),
1938 link.get("target_cell_id").and_then(Value::as_u64),
1939 ) else {
1940 continue;
1941 };
1942 if !cells.contains_key(&s) || !cells.contains_key(&t) {
1943 continue;
1944 }
1945 match link.get("label").and_then(Value::as_str) {
1946 Some("to_child") => {
1947 child_links.entry(s).or_default().push(t);
1948 parents.insert(t);
1949 }
1950 Some("to_value") => {
1951 if !value_links.contains_key(&s) {
1952 value_order.push(s);
1953 }
1954 value_links.entry(s).or_default().push(t);
1955 }
1956 _ => {}
1957 }
1958 }
1959 let cell_text =
1960 |id: u64| text_inlines(cells[&id].get("text").and_then(Value::as_str).unwrap_or(""));
1961 if child_links.is_empty() {
1962 if value_order.is_empty() {
1963 return None;
1964 }
1965 let entries = value_order
1966 .iter()
1967 .map(|&k| {
1968 let defs = value_links[&k]
1969 .iter()
1970 .map(|&v| vec![ast::plain(cell_text(v))])
1971 .collect();
1972 (cell_text(k), defs)
1973 })
1974 .collect();
1975 return Some(ast::definition_list(entries));
1976 }
1977 fn node(
1979 id: u64,
1980 cell_text: &dyn Fn(u64) -> Vec<Value>,
1981 child_links: &HashMap<u64, Vec<u64>>,
1982 value_links: &BTreeMap<u64, Vec<u64>>,
1983 path: &mut HashSet<u64>,
1984 ) -> Option<Vec<Value>> {
1985 if !path.insert(id) {
1986 return None;
1987 }
1988 let mut inlines = cell_text(id);
1989 if let Some(values) = value_links.get(&id) {
1990 let vals = join_inlines(values.iter().map(|&v| cell_text(v)).collect());
1991 inlines = vec![ast::wrap("Strong", inlines)];
1992 inlines.push(ast::str_(":"));
1993 if !vals.is_empty() {
1994 inlines.push(ast::space());
1995 inlines.extend(vals);
1996 }
1997 }
1998 let mut blocks = vec![ast::plain(inlines)];
1999 let kids: Vec<Vec<Value>> = child_links
2000 .get(&id)
2001 .into_iter()
2002 .flatten()
2003 .filter_map(|&c| node(c, cell_text, child_links, value_links, path))
2004 .collect();
2005 if !kids.is_empty() {
2006 blocks.push(ast::bullet_list(kids));
2007 }
2008 path.remove(&id);
2009 Some(blocks)
2010 }
2011 let mut path = HashSet::new();
2012 let items: Vec<Vec<Value>> = order
2013 .iter()
2014 .filter(|id| !parents.contains(id))
2015 .filter_map(|&id| node(id, &cell_text, &child_links, &value_links, &mut path))
2016 .collect();
2017 (!items.is_empty()).then(|| ast::bullet_list(items))
2018 }
2019}
2020
2021#[cfg(test)]
2022mod tests {
2023 use super::*;
2024 use serde_json::json;
2025
2026 fn doc(body: &[&str], texts: Value, groups: Value, tables: Value) -> Value {
2028 json!({
2029 "schema_name": "DoclingDocument",
2030 "name": "t",
2031 "body": {"self_ref": "#/body", "children": body.iter().map(|r| json!({"$ref": r})).collect::<Vec<_>>()},
2032 "texts": texts, "groups": groups, "tables": tables, "pictures": []
2033 })
2034 }
2035
2036 fn text(i: usize, label: &str, text: &str, extra: Value) -> Value {
2037 let mut v = json!({"self_ref": format!("#/texts/{i}"), "children": [], "label": label, "text": text, "content_layer": "body"});
2038 for (k, x) in extra.as_object().unwrap() {
2039 v[k] = x.clone();
2040 }
2041 v
2042 }
2043
2044 fn blocks(json: &Value) -> Vec<Value> {
2045 let (s, _) = from_docling_json(json, &PandocExportOptions::default()).unwrap();
2046 let v: Value = serde_json::from_str(&s).unwrap();
2047 assert_eq!(v["pandoc-api-version"], json!(PANDOC_API_VERSION));
2048 v["blocks"].as_array().unwrap().clone()
2049 }
2050
2051 #[test]
2055 fn note_calls_become_notes_at_their_call_sites() {
2056 let j = doc(
2057 &["#/texts/0", "#/texts/1"],
2058 json!([
2059 text(
2060 0,
2061 "text",
2062 "debut suite end",
2063 json!({"_notes": [[5, "first"], [15, "last"]]})
2064 ),
2065 text(
2066 1,
2067 "footnote",
2068 "first",
2069 json!({"content_layer": "furniture", "_note_body": true})
2070 ),
2071 ]),
2072 json!([]),
2073 json!([]),
2074 );
2075 let note = |t: &str| ast::note(vec![ast::para(vec![ast::str_(t)])]);
2076 assert_eq!(
2077 blocks(&j),
2078 vec![ast::para(vec![
2079 ast::str_("debut"),
2080 note("first"),
2081 ast::space(),
2082 ast::str_("suite"),
2083 ast::space(),
2084 ast::str_("end"),
2085 note("last"),
2086 ])]
2087 );
2088 let opts = PandocExportOptions {
2090 layers: ContentLayers::ALL,
2091 ..Default::default()
2092 };
2093 let (s, _) = from_docling_json(&j, &opts).unwrap();
2094 assert!(!s.contains("docling-footnote"), "{s}");
2095 }
2096
2097 #[test]
2100 fn uncalled_furniture_footnotes_trail_as_notes() {
2101 let j = doc(
2102 &["#/texts/0", "#/texts/1"],
2103 json!([
2104 text(0, "text", "body", json!({})),
2105 text(
2106 1,
2107 "footnote",
2108 "MARKFN",
2109 json!({"content_layer": "furniture"})
2110 ),
2111 ]),
2112 json!([]),
2113 json!([]),
2114 );
2115 assert_eq!(
2116 blocks(&j),
2117 vec![
2118 ast::para(vec![ast::str_("body")]),
2119 ast::para(vec![ast::note(vec![ast::para(vec![ast::str_("MARKFN")])])]),
2120 ]
2121 );
2122 }
2123
2124 #[test]
2129 fn pictures_are_always_images() {
2130 let mut j = doc(
2131 &["#/pictures/0", "#/pictures/1", "#/pictures/2"],
2132 json!([text(0, "caption", "A duck", json!({})),]),
2133 json!([]),
2134 json!([]),
2135 );
2136 j["pictures"] = json!([
2137 {"self_ref": "#/pictures/0", "children": [], "label": "picture", "content_layer": "body",
2138 "image": {"uri": "data:image/png;base64,AAAA"}, "captions": []},
2139 {"self_ref": "#/pictures/1", "children": [], "label": "picture", "content_layer": "body",
2140 "image": {"uri": "data:image/png;base64,BBBB"}, "captions": [{"$ref": "#/texts/0"}]},
2141 {"self_ref": "#/pictures/2", "children": [], "label": "picture", "content_layer": "body",
2142 "captions": []},
2143 ]);
2144 let b = blocks(&j);
2145 assert_eq!(
2146 b[0],
2147 ast::para(vec![ast::image(&[], vec![], "data:image/png;base64,AAAA")])
2148 );
2149 assert_eq!(
2150 b[1],
2151 ast::figure(
2152 &[],
2153 ast::caption(vec![ast::plain(vec![
2154 ast::str_("A"),
2155 ast::space(),
2156 ast::str_("duck")
2157 ])]),
2158 vec![ast::plain(vec![ast::image(
2159 &[],
2160 vec![ast::str_("A"), ast::space(), ast::str_("duck")],
2161 "data:image/png;base64,BBBB"
2162 )])]
2163 )
2164 );
2165 assert_eq!(
2166 b[2],
2167 ast::para(vec![ast::image(&["docling-placeholder"], vec![], "")])
2168 );
2169 assert_eq!(b.len(), 3);
2170 let opts = PandocExportOptions {
2172 image_mode: ImageMode::Placeholder,
2173 ..Default::default()
2174 };
2175 let (s, _) = from_docling_json(&j, &opts).unwrap();
2176 assert!(!s.contains("base64") && !s.contains("RawBlock"), "{s}");
2177 assert_eq!(s.matches("docling-placeholder").count(), 3, "{s}");
2178 }
2179
2180 #[test]
2187 fn captions_headings_and_items_keep_their_inline_groups() {
2188 let mut j = doc(
2189 &["#/texts/0", "#/tables/0", "#/texts/3", "#/groups/2"],
2190 json!([
2191 text(
2192 0,
2193 "caption",
2194 "",
2195 json!({"children": [{"$ref": "#/groups/0"}]})
2196 ),
2197 text(1, "text", "Table 1:", json!({"formatting": {"bold": true}})),
2198 text(
2199 2,
2200 "text",
2201 "Multi-run caption",
2202 json!({"hyperlink": "https://x.org/c"})
2203 ),
2204 text(
2205 3,
2206 "section_header",
2207 "",
2208 json!({"level": 1, "children": [{"$ref": "#/groups/1"}, {"$ref": "#/texts/6"}]})
2209 ),
2210 text(4, "text", "1.1", json!({"formatting": {"bold": true}})),
2211 text(5, "text", "Formatted header", json!({})),
2212 text(6, "text", "Body under header.", json!({})),
2213 text(
2214 7,
2215 "list_item",
2216 "",
2217 json!({"children": [{"$ref": "#/groups/3"}, {"$ref": "#/groups/4"}]})
2218 ),
2219 text(8, "text", "Bold run", json!({"formatting": {"bold": true}})),
2220 text(9, "text", "and tail", json!({})),
2221 text(10, "list_item", "Nested child", json!({})),
2222 text(
2223 11,
2224 "caption",
2225 "Linked bold",
2226 json!({"formatting": {"bold": true}, "hyperlink": "https://x.org/spec"})
2227 ),
2228 ]),
2229 json!([
2230 {"self_ref": "#/groups/0", "children": [{"$ref": "#/texts/1"}, {"$ref": "#/texts/2"}], "label": "inline", "name": "group"},
2231 {"self_ref": "#/groups/1", "children": [{"$ref": "#/texts/4"}, {"$ref": "#/texts/5"}], "label": "inline", "name": "group"},
2232 {"self_ref": "#/groups/2", "children": [{"$ref": "#/texts/7"}], "label": "list", "name": "list"},
2233 {"self_ref": "#/groups/3", "children": [{"$ref": "#/texts/8"}, {"$ref": "#/texts/9"}], "label": "inline", "name": "group"},
2234 {"self_ref": "#/groups/4", "children": [{"$ref": "#/texts/10"}], "label": "list", "name": "list"},
2235 ]),
2236 json!([{"self_ref": "#/tables/0", "children": [], "label": "table",
2237 "captions": [{"$ref": "#/texts/0"}, {"$ref": "#/texts/11"}],
2238 "data": {"num_rows": 1, "num_cols": 1, "table_cells": [
2239 {"text": "v", "row_span": 1, "col_span": 1, "start_row_offset_idx": 0, "end_row_offset_idx": 1,
2240 "start_col_offset_idx": 0, "end_col_offset_idx": 1, "column_header": false}]}}]),
2241 );
2242 j["texts"][0]["children"] = json!([{"$ref": "#/groups/0"}]);
2243 let b = blocks(&j);
2244 assert_eq!(b.len(), 4, "{b:?}");
2245 assert_eq!(b[0]["t"], "Table");
2246 let caption = &b[0]["c"][1][1][0];
2247 assert_eq!(caption["t"], "Plain");
2248 assert_eq!(
2249 caption["c"][0],
2250 ast::wrap(
2251 "Strong",
2252 vec![ast::str_("Table"), ast::space(), ast::str_("1:")]
2253 )
2254 );
2255 assert_eq!(caption["c"][2]["t"], "Link");
2256 let linked = caption["c"].as_array().unwrap().last().unwrap();
2257 assert_eq!(linked["t"], "Link");
2258 assert_eq!(linked["c"][1][0]["t"], "Strong");
2259 assert_eq!(b[1]["t"], "Header");
2260 assert_eq!(b[1]["c"][2][0], ast::wrap("Strong", vec![ast::str_("1.1")]));
2261 assert_eq!(
2262 b[2],
2263 ast::para(vec![
2264 ast::str_("Body"),
2265 ast::space(),
2266 ast::str_("under"),
2267 ast::space(),
2268 ast::str_("header.")
2269 ])
2270 );
2271 assert_eq!(b[3]["t"], "BulletList");
2272 let item = b[3]["c"][0].as_array().unwrap();
2273 assert_eq!(item.len(), 2);
2274 assert_eq!(item[0]["t"], "Plain");
2275 assert_eq!(item[0]["c"][0]["t"], "Strong");
2276 assert_eq!(item[1]["t"], "BulletList");
2277 }
2278
2279 #[test]
2283 fn pictures_carry_alt_text_and_display_size() {
2284 let mut j = doc(
2285 &["#/pictures/0", "#/pictures/1"],
2286 json!([text(0, "caption", "A duck", json!({})),]),
2287 json!([]),
2288 json!([]),
2289 );
2290 j["pictures"] = json!([
2291 {"self_ref": "#/pictures/0", "children": [], "label": "picture", "content_layer": "body",
2292 "meta": {"description": {"text": "Architecture diagram", "created_by": "alt_text"}},
2293 "image": {"uri": "data:image/png;base64,AAAA", "_display": [2.5, 1.25]}, "captions": [{"$ref": "#/texts/0"}]},
2294 {"self_ref": "#/pictures/1", "children": [], "label": "picture", "content_layer": "body",
2295 "image": {"uri": "data:image/png;base64,BBBB"}, "captions": [{"$ref": "#/texts/0"}]},
2296 ]);
2297 let b = blocks(&j);
2298 let image = &b[0]["c"][2][0]["c"][0];
2299 assert_eq!(image["t"], "Image");
2300 assert_eq!(
2301 image["c"][0],
2302 json!(["", [], [["width", "2.5in"], ["height", "1.25in"]]])
2303 );
2304 assert_eq!(
2305 image["c"][1],
2306 json!([
2307 ast::str_("Architecture"),
2308 ast::space(),
2309 ast::str_("diagram")
2310 ])
2311 );
2312 let image = &b[1]["c"][2][0]["c"][0];
2313 assert_eq!(image["c"][0], json!(["", [], []]));
2314 assert_eq!(
2315 image["c"][1],
2316 json!([ast::str_("A"), ast::space(), ast::str_("duck")])
2317 );
2318 assert_eq!(inches(2.5), "2.5in");
2319 assert_eq!(inches(1.0), "1in");
2320 assert_eq!(inches(0.33333), "0.3333in");
2321 }
2322
2323 #[test]
2325 fn list_attributes_follow_the_markers() {
2326 assert_eq!(
2327 list_attributes(&["a)", "b)"]),
2328 (1, "LowerAlpha", "OneParen")
2329 );
2330 assert_eq!(
2331 list_attributes(&["iii.", "iv."]),
2332 (3, "LowerRoman", "Period")
2333 );
2334 assert_eq!(
2335 list_attributes(&["(3)", "(4)"]),
2336 (3, "Decimal", "TwoParens")
2337 );
2338 assert_eq!(
2339 list_attributes(&["c)", "d)"]),
2340 (3, "LowerAlpha", "OneParen")
2341 );
2342 assert_eq!(list_attributes(&["i."]), (1, "LowerRoman", "Period"));
2343 assert_eq!(list_attributes(&["v.", "vi."]), (5, "LowerRoman", "Period"));
2344 assert_eq!(list_attributes(&["x.", "y."]), (24, "LowerAlpha", "Period"));
2345 assert_eq!(list_attributes(&["B."]), (2, "UpperAlpha", "Period"));
2346 assert_eq!(list_attributes(&["IV)"]), (4, "UpperRoman", "OneParen"));
2347 assert_eq!(list_attributes(&["aa)"]), (27, "LowerAlpha", "OneParen"));
2348 assert_eq!(list_attributes(&["12"]), (12, "Decimal", "DefaultDelim"));
2349 assert_eq!(list_attributes(&["3."]), (3, "Decimal", "Period"));
2350 assert_eq!(list_attributes(&["1.2."]), (1, "Decimal", "Period"));
2351 assert_eq!(list_attributes(&[]), (1, "Decimal", "Period"));
2352 let j = doc(
2353 &["#/groups/0"],
2354 json!([
2355 text(
2356 0,
2357 "list_item",
2358 "third",
2359 json!({"enumerated": true, "marker": "(3)"})
2360 ),
2361 text(
2362 1,
2363 "list_item",
2364 "fourth",
2365 json!({"enumerated": true, "marker": "(4)"})
2366 ),
2367 ]),
2368 json!([{"self_ref": "#/groups/0", "children": [{"$ref": "#/texts/0"}, {"$ref": "#/texts/1"}], "label": "list", "name": "list"}]),
2369 json!([]),
2370 );
2371 let b = blocks(&j);
2372 assert_eq!(
2373 b[0]["c"][0],
2374 json!([3, {"t": "Decimal"}, {"t": "TwoParens"}])
2375 );
2376 }
2377
2378 #[test]
2383 fn metadata_and_comments_reach_the_ast() {
2384 let mut j = doc(
2385 &["#/texts/0", "#/groups/0"],
2386 json!([
2387 text(
2388 0,
2389 "text",
2390 "The timeout is 30 s.",
2391 json!({"comments": [{"$ref": "#/groups/0"}]})
2392 ),
2393 text(
2394 1,
2395 "text",
2396 "[author: Reviewer (SR), time: 2026-01-15T12:00:00Z]: Verify it.",
2397 json!({"content_layer": "notes"})
2398 ),
2399 text(2, "text", "Agreed", json!({"content_layer": "notes"})),
2400 ]),
2401 json!([{"self_ref": "#/groups/0", "children": [{"$ref": "#/texts/1"}, {"$ref": "#/texts/2"}], "label": "comment_section", "name": "comment-3", "content_layer": "notes"}]),
2402 json!([]),
2403 );
2404 j["metadata"] = json!({
2405 "title": "Synthetic Spec", "authors": ["A. Author", "B. Author"], "keywords": ["architecture", "protocol"],
2406 "language": "en-US", "created": "2026-01-15T10:00:00Z", "subject": "Gateways"
2407 });
2408 let (s, _) = from_docling_json(&j, &PandocExportOptions::default()).unwrap();
2409 let v: Value = serde_json::from_str(&s).unwrap();
2410 assert_eq!(
2411 v["meta"]["title"],
2412 ast::meta_inlines(vec![
2413 ast::str_("Synthetic"),
2414 ast::space(),
2415 ast::str_("Spec")
2416 ])
2417 );
2418 assert_eq!(v["meta"]["author"]["t"], "MetaList");
2419 assert_eq!(v["meta"]["author"]["c"].as_array().unwrap().len(), 2);
2420 assert_eq!(
2421 v["meta"]["keywords"]["c"][1],
2422 ast::meta_inlines(vec![ast::str_("protocol")])
2423 );
2424 assert_eq!(v["meta"]["lang"], ast::meta_string("en-US"));
2425 assert_eq!(
2426 v["meta"]["date"],
2427 ast::meta_inlines(vec![ast::str_("2026-01-15T10:00:00Z")])
2428 );
2429 assert_eq!(v["meta"]["subject"]["t"], "MetaInlines");
2430 let b = v["blocks"].as_array().unwrap();
2431 assert_eq!(b.len(), 1, "{b:?}");
2432 let inl = b[0]["c"].as_array().unwrap();
2433 assert_eq!(inl[0]["t"], "Span");
2434 assert_eq!(
2435 inl[0]["c"][0],
2436 json!([
2437 "",
2438 ["comment-start"],
2439 [
2440 ["comment-id", "3"],
2441 ["author", "Reviewer (SR)"],
2442 ["date", "2026-01-15T12:00:00Z"]
2443 ]
2444 ])
2445 );
2446 assert_eq!(
2447 inl[0]["c"][1],
2448 json!([ast::str_("Verify"), ast::space(), ast::str_("it.")])
2449 );
2450 assert_eq!(
2451 inl[1]["c"][0],
2452 json!(["", ["comment-start"], [["comment-id", "3-1"]]])
2453 );
2454 assert_eq!(inl[2], ast::str_("The"));
2455 let last = inl.last().unwrap();
2456 assert_eq!(
2457 last["c"][0],
2458 json!(["", ["comment-end"], [["comment-id", "3-1"]]])
2459 );
2460 assert_eq!(
2461 inl[inl.len() - 2]["c"][0],
2462 json!(["", ["comment-end"], [["comment-id", "3"]]])
2463 );
2464 let opts = PandocExportOptions {
2466 layers: ContentLayers::ALL,
2467 ..Default::default()
2468 };
2469 let (s, _) = from_docling_json(&j, &opts).unwrap();
2470 assert_eq!(s.matches("Verify").count(), 1, "{s}");
2471 assert_eq!(
2472 split_comment("[time: t]: x"),
2473 (None, Some("t".into()), "x".into())
2474 );
2475 assert_eq!(split_comment("bare"), (None, None, "bare".into()));
2476 assert_eq!(meta_of(&json!({})), json!({}));
2477 }
2478
2479 #[test]
2480 fn text_splits_into_words_spaces_and_line_breaks() {
2481 assert_eq!(
2482 text_inlines(" two words\nnext\u{a0}line "),
2483 vec![
2484 ast::str_("two"),
2485 ast::space(),
2486 ast::str_("words"),
2487 ast::line_break(),
2488 ast::str_("next\u{a0}line"),
2489 ]
2490 );
2491 assert!(text_inlines(" ").is_empty());
2492 }
2493
2494 #[test]
2495 fn headings_paragraphs_and_lists() {
2496 let j = doc(
2497 &["#/texts/0", "#/texts/1", "#/texts/2", "#/groups/0"],
2498 json!([
2499 text(0, "title", "Ducks", json!({})),
2500 text(1, "section_header", "Diet", json!({"level": 1})),
2501 text(
2502 2,
2503 "text",
2504 "Ducks eat plants.",
2505 json!({"formatting": {"bold": true}, "hyperlink": "https://x.org"})
2506 ),
2507 text(
2508 3,
2509 "list_item",
2510 "seeds",
2511 json!({"enumerated": true, "marker": "3."})
2512 ),
2513 text(
2514 4,
2515 "list_item",
2516 "insects",
2517 json!({"enumerated": true, "marker": "4."})
2518 ),
2519 ]),
2520 json!([{"self_ref": "#/groups/0", "children": [{"$ref": "#/texts/3"}, {"$ref": "#/texts/4"}], "label": "list", "name": "list"}]),
2521 json!([]),
2522 );
2523 let b = blocks(&j);
2524 assert_eq!(b[0], ast::header(1, vec![ast::str_("Ducks")]));
2525 assert_eq!(b[1], ast::header(2, vec![ast::str_("Diet")]));
2526 assert_eq!(b[2]["t"], "Para");
2528 assert_eq!(b[2]["c"][0]["t"], "Link");
2529 assert_eq!(b[2]["c"][0]["c"][1][0]["t"], "Strong");
2530 assert_eq!(b[3]["t"], "OrderedList");
2531 assert_eq!(b[3]["c"][0][0], 3, "start from the first marker");
2532 assert_eq!(
2533 b[3]["c"][1],
2534 json!([
2535 [ast::plain(vec![ast::str_("seeds")])],
2536 [ast::plain(vec![ast::str_("insects")])]
2537 ])
2538 );
2539 }
2540
2541 #[test]
2546 fn heading_depths_under_both_projections() {
2547 let j = doc(
2548 &[
2549 "#/texts/0",
2550 "#/texts/1",
2551 "#/texts/2",
2552 "#/texts/3",
2553 "#/texts/4",
2554 "#/texts/5",
2555 "#/texts/6",
2556 "#/texts/7",
2557 ],
2558 json!([
2559 text(0, "title", "T", json!({})),
2560 text(1, "section_header", "d1", json!({"level": 1})),
2561 text(2, "section_header", "d2", json!({"level": 2})),
2562 text(3, "section_header", "d5", json!({"level": 5})),
2563 text(4, "section_header", "d6", json!({"level": 6})),
2564 text(5, "section_header", "none", json!({})),
2565 text(6, "section_header", "zero", json!({"level": 0})),
2566 text(7, "section_header", "seven", json!({"level": 7})),
2567 ]),
2568 json!([]),
2569 json!([]),
2570 );
2571 let depths = |heading_depth: HeadingDepth| -> Vec<u64> {
2572 let options = PandocExportOptions {
2573 heading_depth,
2574 ..Default::default()
2575 };
2576 let (s, _) = from_docling_json(&j, &options).unwrap();
2577 let v: Value = serde_json::from_str(&s).unwrap();
2578 v["blocks"]
2579 .as_array()
2580 .unwrap()
2581 .iter()
2582 .map(|b| b["c"][0].as_u64().unwrap())
2583 .collect()
2584 };
2585 assert_eq!(depths(HeadingDepth::Docling), [1, 2, 3, 6, 6, 2, 1, 6]);
2586 assert_eq!(depths(HeadingDepth::Source), [1, 1, 2, 5, 6, 1, 1, 6]);
2587 assert_eq!("Source".parse::<HeadingDepth>(), Ok(HeadingDepth::Source));
2588 assert!("deep".parse::<HeadingDepth>().is_err());
2589 }
2590
2591 #[test]
2594 fn markdown_links_in_text_become_links() {
2595 assert_eq!(
2596 text_inlines("Jump to [target](#target-section) now [x] (y)"),
2597 vec![
2598 ast::str_("Jump"),
2599 ast::space(),
2600 ast::str_("to"),
2601 ast::space(),
2602 ast::link(vec![ast::str_("target")], "#target-section"),
2603 ast::space(),
2604 ast::str_("now"),
2605 ast::space(),
2606 ast::str_("[x]"),
2607 ast::space(),
2608 ast::str_("(y)"),
2609 ]
2610 );
2611 assert_eq!(
2612 text_inlines("[a](https://x.org/a)[b](https://x.org/b)"),
2613 vec![
2614 ast::link(vec![ast::str_("a")], "https://x.org/a"),
2615 ast::link(vec![ast::str_("b")], "https://x.org/b"),
2616 ]
2617 );
2618 assert_eq!(
2619 text_inlines("see  here"),
2620 vec"),
2624 ast::space(),
2625 ast::str_("here"),
2626 ]
2627 );
2628 }
2629
2630 #[test]
2633 fn revisions_become_spans_and_views_drop_comments() {
2634 let j = doc(
2635 &["#/texts/0", "#/texts/1"],
2636 json!([
2637 text(
2638 0,
2639 "text",
2640 "old",
2641 json!({"_revision": {"kind": "deletion", "author": "Fixture", "date": "2025-01-01T00:00:00Z"}})
2642 ),
2643 text(
2644 1,
2645 "text",
2646 "Commented",
2647 json!({"comments": [{"$ref": "#/groups/0"}]})
2648 ),
2649 text(
2650 2,
2651 "text",
2652 "[author: Fixture, time: 2025-01-01T00:00:00Z]: Review note",
2653 json!({"content_layer": "notes"})
2654 ),
2655 ]),
2656 json!([{"self_ref": "#/groups/0", "children": [{"$ref": "#/texts/2"}], "label": "comment_section", "name": "comment-3", "content_layer": "notes"}]),
2657 json!([]),
2658 );
2659 let b = blocks(&j);
2660 assert_eq!(
2661 b[0],
2662 ast::para(vec![ast::span(
2663 &["deletion"],
2664 &[
2665 ("author".into(), "Fixture".into()),
2666 ("date".into(), "2025-01-01T00:00:00Z".into())
2667 ],
2668 vec![ast::str_("old")]
2669 )])
2670 );
2671 assert_eq!(
2672 b[1]["c"][0]["t"], "Span",
2673 "comments written without a policy"
2674 );
2675 let accept = PandocExportOptions {
2676 track_changes: Some(TrackChanges::Accept),
2677 ..Default::default()
2678 };
2679 let (s, _) = from_docling_json(&j, &accept).unwrap();
2680 let v: Value = serde_json::from_str(&s).unwrap();
2681 assert_eq!(v["blocks"][1], ast::para(vec![ast::str_("Commented")]));
2682 }
2683
2684 #[test]
2687 fn numbered_headings_write_the_authored_text() {
2688 let j = doc(
2689 &["#/texts/0", "#/texts/1", "#/texts/2"],
2690 json!([
2691 text(
2692 0,
2693 "section_header",
2694 "1.1 2026 Results",
2695 json!({"level": 2, "_number": "1.1"})
2696 ),
2697 text(1, "section_header", "2026 Results", json!({"level": 1})),
2698 text(
2699 2,
2700 "section_header",
2701 "3 Odd",
2702 json!({"level": 1, "_number": "4"})
2703 ),
2704 ]),
2705 json!([]),
2706 json!([]),
2707 );
2708 let b = blocks(&j);
2709 assert_eq!(
2710 b[0],
2711 ast::header(
2712 3,
2713 vec![ast::str_("2026"), ast::space(), ast::str_("Results")]
2714 )
2715 );
2716 assert_eq!(
2717 b[1],
2718 ast::header(
2719 2,
2720 vec![ast::str_("2026"), ast::space(), ast::str_("Results")]
2721 )
2722 );
2723 assert_eq!(
2725 b[2],
2726 ast::header(2, vec![ast::str_("3"), ast::space(), ast::str_("Odd")])
2727 );
2728 }
2729
2730 #[test]
2734 fn anchors_become_header_ids_and_anchor_spans() {
2735 let j = doc(
2736 &["#/texts/0", "#/texts/1", "#/texts/2"],
2737 json!([
2738 text(
2739 0,
2740 "section_header",
2741 "Target section",
2742 json!({"level": 1, "_anchors": ["target-section", "Alias"]})
2743 ),
2744 text(1, "text", "Jump", json!({"hyperlink": "#target-section"})),
2745 text(2, "text", "Body mark", json!({"_anchors": ["BodyMark"]})),
2746 ]),
2747 json!([]),
2748 json!([]),
2749 );
2750 let b = blocks(&j);
2751 assert_eq!(
2752 b[0],
2753 ast::header_with_id(
2754 2,
2755 "target-section",
2756 vec![
2757 ast::anchor("Alias"),
2758 ast::str_("Target"),
2759 ast::space(),
2760 ast::str_("section")
2761 ]
2762 )
2763 );
2764 assert_eq!(
2765 b[1],
2766 ast::para(vec![ast::link(vec![ast::str_("Jump")], "#target-section")])
2767 );
2768 assert_eq!(
2769 b[2],
2770 ast::para(vec![
2771 ast::anchor("BodyMark"),
2772 ast::str_("Body"),
2773 ast::space(),
2774 ast::str_("mark")
2775 ])
2776 );
2777 assert_eq!(auto_identifier("Target section"), "target-section");
2778 assert_eq!(auto_identifier("2026 Results"), "results");
2779 assert_eq!(auto_identifier("1.1 Été & hiver!"), "été-hiver");
2780 assert_eq!(auto_identifier("***"), "section");
2781 }
2782
2783 #[test]
2784 fn code_formulas_checkboxes_and_unmapped_labels() {
2785 let j = doc(
2786 &["#/texts/0", "#/texts/1", "#/texts/2", "#/texts/3"],
2787 json!([
2788 text(0, "code", "print(1)", json!({"code_language": "Python"})),
2789 text(1, "formula", "E = mc^2", json!({})),
2790 text(2, "checkbox_selected", "done", json!({})),
2791 text(3, "reference", "[1] Duck, D. (2020).", json!({})),
2792 ]),
2793 json!([]),
2794 json!([]),
2795 );
2796 let b = blocks(&j);
2797 assert_eq!(b[0], ast::code_block(Some("python"), "print(1)"));
2798 assert_eq!(b[1], ast::para(vec![ast::math(true, "E = mc^2")]));
2799 assert_eq!(b[2]["c"][0], ast::str_("☒"));
2800 assert_eq!(b[3]["t"], "Div");
2801 assert_eq!(b[3]["c"][0][1], json!(["docling-reference"]));
2802 }
2803
2804 #[test]
2805 fn tables_keep_spans_header_rows_and_captions() {
2806 let cell = |r0: usize, c0: usize, rs: usize, cs: usize, t: &str, header: bool| {
2807 json!({"text": t, "row_span": rs, "col_span": cs,
2808 "start_row_offset_idx": r0, "end_row_offset_idx": r0 + rs,
2809 "start_col_offset_idx": c0, "end_col_offset_idx": c0 + cs,
2810 "column_header": header})
2811 };
2812 let j = doc(
2813 &["#/tables/0"],
2814 json!([text(0, "caption", "Table 1: Ducks", json!({}))]),
2815 json!([]),
2816 json!([{"self_ref": "#/tables/0", "children": [{"$ref": "#/texts/0"}], "label": "table",
2817 "captions": [{"$ref": "#/texts/0"}],
2818 "data": {"num_rows": 2, "num_cols": 2, "table_cells": [
2819 cell(0, 0, 1, 2, "Species", true),
2820 cell(1, 0, 1, 1, "Mallard", false),
2821 cell(1, 1, 1, 1, "Anas", false)]}}]),
2822 );
2823 let b = blocks(&j);
2824 assert_eq!(b.len(), 1, "the caption is rendered by its table only");
2825 let t = &b[0];
2826 assert_eq!(t["t"], "Table");
2827 assert_eq!(
2828 t["c"][1][1][0],
2829 ast::plain(vec![
2830 ast::str_("Table"),
2831 ast::space(),
2832 ast::str_("1:"),
2833 ast::space(),
2834 ast::str_("Ducks")
2835 ])
2836 );
2837 assert_eq!(t["c"][2].as_array().unwrap().len(), 2);
2838 let head = t["c"][3][1].as_array().unwrap();
2839 assert_eq!(head.len(), 1);
2840 assert_eq!(head[0][1][0][3], 2, "colspan");
2841 assert_eq!(t["c"][4][0][3].as_array().unwrap().len(), 1, "one body row");
2842 }
2843
2844 #[test]
2845 fn api_version_is_checked() {
2846 for ok in ["1.23", "1.23.1", "1.23.1.1"] {
2847 assert!(check_api_version(ok).is_ok(), "{ok}");
2848 }
2849 for bad in ["1.22", "2.0", "1", "1.23.2", "1.23.1.2", "x.y", ""] {
2850 let e = check_api_version(bad).unwrap_err();
2851 assert!(e.to_string().contains("only 1.23"), "{bad}: {e}");
2852 }
2853 let err = to_pandoc(
2854 &DoclingDocument::new("t"),
2855 &PandocExportOptions {
2856 api_version: Some("1.22".into()),
2857 ..Default::default()
2858 },
2859 )
2860 .unwrap_err();
2861 assert_eq!(
2862 err,
2863 PandocError::UnsupportedApiVersion {
2864 requested: "1.22".into()
2865 }
2866 );
2867 }
2868
2869 #[test]
2870 fn empty_document_is_a_valid_pandoc_document() {
2871 let (s, artifacts) =
2872 to_pandoc(&DoclingDocument::new("e"), &PandocExportOptions::default()).unwrap();
2873 assert!(artifacts.is_empty());
2874 assert_eq!(
2875 s,
2876 r#"{"pandoc-api-version":[1,23,1,1],"meta":{},"blocks":[]}"#
2877 );
2878 assert_eq!(s, DoclingDocument::new("e").export_to_pandoc_json());
2879 }
2880}