1use crate::document::{ContentLayer, FieldItem, InlineRun, Node, Script, Table};
19use std::borrow::Cow;
20
21const INDENT: &str = " ";
22
23struct Out {
28 lines: Vec<(i32, String, bool)>,
29 pic_index: usize,
32 assets: Option<Vec<(String, Vec<u8>)>>,
35}
36
37impl Out {
38 fn push(&mut self, depth: i32, s: impl Into<String>) {
39 self.lines.push((depth, s.into(), true));
40 }
41
42 fn push_glue(&mut self, s: impl Into<String>) {
44 self.lines.push((0, s.into(), false));
45 }
46
47 fn finish(self) -> String {
48 let mut s = String::new();
49 for (d, line, nl) in self.lines {
50 for _ in 0..d {
56 s.push_str(INDENT);
57 }
58 s.push_str(&line);
59 if nl {
60 s.push('\n');
61 }
62 }
63 if s.ends_with('\n') {
67 s.pop();
68 }
69 s
70 }
71}
72
73fn sanitize_xml_illegal(text: &str) -> std::borrow::Cow<'_, str> {
81 let illegal = |c: char| matches!(c, '\u{00}'..='\u{08}' | '\u{0B}' | '\u{0C}' | '\u{0E}'..='\u{1F}' | '\u{FFFE}' | '\u{FFFF}');
82 if !text.contains(illegal) {
83 return std::borrow::Cow::Borrowed(text);
84 }
85 let mut out = String::with_capacity(text.len() + 8);
86 for c in text.chars() {
87 if illegal(c) {
88 out.push_str(&format!("[U+{:04X}]", c as u32));
89 } else {
90 out.push(c);
91 }
92 }
93 std::borrow::Cow::Owned(out)
94}
95
96fn unescape_stored(text: &str) -> Cow<'_, str> {
98 if !text.contains('&') && !text.contains('\\') {
99 return Cow::Borrowed(text);
100 }
101 Cow::Owned(
102 text.replace("<", "<")
103 .replace(">", ">")
104 .replace("&", "&")
105 .replace("\\_", "_"),
106 )
107}
108
109fn escape_text(text: &str) -> String {
118 let raw = unescape_stored(text);
119 let text = sanitize_xml_illegal(raw.as_ref());
120 let text = text.as_ref();
121 let needs_cdata = text.contains(['"', '\'', '&', '<', '>']);
122 let needs_content = text != text.trim() || text.contains('\n');
123 let mut t = if needs_cdata {
124 format!("<![CDATA[{}]]>", text.replace("]]>", "]]]]><![CDATA[>"))
125 } else {
126 text.to_string()
127 };
128 if needs_content {
129 t = format!("<content>{t}</content>");
130 }
131 t
132}
133
134enum Run {
136 Plain(String),
137 Bold(String),
138 Italic(String),
139 BoldItalic(String),
140 Code(String),
141 Link {
145 anchor: String,
146 uri: String,
147 },
148}
149
150fn inline_runs(text: &str) -> Vec<Run> {
153 let mut runs = Vec::new();
154 let mut plain = String::new();
155 let chars: Vec<char> = text.chars().collect();
156 let n = chars.len();
157 let mut i = 0;
158 let find = |open: usize, pat: &[char]| -> Option<usize> {
162 chars
163 .get(open..)?
164 .windows(pat.len())
165 .position(|w| w == pat)
166 .map(|p| open + p)
167 };
168 let starts = |at: usize, pat: &[char]| chars[at..].starts_with(pat);
169 while i < n {
170 let take = |runs: &mut Vec<Run>, plain: &mut String, r: Run| {
171 if !plain.is_empty() {
172 runs.push(Run::Plain(std::mem::take(plain)));
173 }
174 runs.push(r);
175 };
176 if starts(i, &['*', '*', '*']) {
177 if let Some(end) = find(i + 3, &['*', '*', '*']) {
178 let inner: String = chars[i + 3..end].iter().collect();
179 take(&mut runs, &mut plain, Run::BoldItalic(inner));
180 i = end + 3;
181 continue;
182 }
183 }
184 if starts(i, &['*', '*']) {
185 if let Some(end) = find(i + 2, &['*', '*']) {
186 let inner: String = chars[i + 2..end].iter().collect();
187 take(&mut runs, &mut plain, Run::Bold(inner));
188 i = end + 2;
189 continue;
190 }
191 }
192 if chars[i] == '*' && !starts(i, &['*', '*']) {
193 if let Some(end) = find(i + 1, &['*']) {
194 let inner: String = chars[i + 1..end].iter().collect();
195 if !inner.is_empty() {
196 take(&mut runs, &mut plain, Run::Italic(inner));
197 i = end + 1;
198 continue;
199 }
200 }
201 }
202 if chars[i] == '`' {
203 if let Some(end) = find(i + 1, &['`']) {
204 let inner: String = chars[i + 1..end].iter().collect();
205 take(&mut runs, &mut plain, Run::Code(inner));
206 i = end + 1;
207 continue;
208 }
209 }
210 if chars[i] == '[' {
211 if let Some(close) = find(i + 1, &[']', '(']) {
212 if let Some(endp) = link_dest_end(&chars, close + 2) {
213 let anchor: String = chars[i + 1..close].iter().collect();
214 let uri: String = chars[close + 2..endp].iter().collect();
215 take(&mut runs, &mut plain, Run::Link { anchor, uri });
216 i = endp + 1;
217 continue;
218 }
219 }
220 }
221 plain.push(chars[i]);
222 i += 1;
223 }
224 if !plain.is_empty() {
225 runs.push(Run::Plain(plain));
226 }
227 runs
228}
229
230pub fn inline_runs_from_markdown(text: &str) -> Vec<InlineRun> {
237 let mut out = Vec::new();
238 parse_md_runs(
239 &text.chars().collect::<Vec<_>>(),
240 InlineRun::default(),
241 &mut out,
242 );
243 out
244}
245
246fn flush_md_plain(buf: &mut String, style: &InlineRun, out: &mut Vec<InlineRun>) {
250 let text = std::mem::take(buf);
251 let text = text.trim();
252 if !text.is_empty() {
253 out.push(InlineRun {
254 text: text.to_string(),
255 ..style.clone()
256 });
257 }
258}
259
260fn parse_md_runs(chars: &[char], style: InlineRun, out: &mut Vec<InlineRun>) {
263 let n = chars.len();
264 let mut i = 0;
265 let mut plain = String::new();
266 let find = |open: usize, pat: &[char]| -> Option<usize> {
269 chars
270 .get(open..)?
271 .windows(pat.len())
272 .position(|w| w == pat)
273 .map(|p| open + p)
274 };
275 let starts = |at: usize, pat: &[char]| chars[at..].starts_with(pat);
276 let sub = |a: usize, b: usize| -> Vec<char> { chars[a..b].to_vec() };
277 while i < n {
278 if starts(i, &['*', '*', '*']) {
280 if let Some(end) = find(i + 3, &['*', '*', '*']) {
281 flush_md_plain(&mut plain, &style, out);
282 parse_md_runs(
283 &sub(i + 3, end),
284 InlineRun {
285 bold: true,
286 italic: true,
287 ..style.clone()
288 },
289 out,
290 );
291 i = end + 3;
292 continue;
293 }
294 }
295 if starts(i, &['*', '*']) {
296 if let Some(end) = find(i + 2, &['*', '*']) {
297 flush_md_plain(&mut plain, &style, out);
298 parse_md_runs(
299 &sub(i + 2, end),
300 InlineRun {
301 bold: true,
302 ..style.clone()
303 },
304 out,
305 );
306 i = end + 2;
307 continue;
308 }
309 }
310 if chars[i] == '*' {
311 if let Some(end) = find(i + 1, &['*']) {
312 if end > i + 1 {
313 flush_md_plain(&mut plain, &style, out);
314 parse_md_runs(
315 &sub(i + 1, end),
316 InlineRun {
317 italic: true,
318 ..style.clone()
319 },
320 out,
321 );
322 i = end + 1;
323 continue;
324 }
325 }
326 }
327 if starts(i, &['~', '~']) {
328 if let Some(end) = find(i + 2, &['~', '~']) {
329 flush_md_plain(&mut plain, &style, out);
330 parse_md_runs(
331 &sub(i + 2, end),
332 InlineRun {
333 strike: true,
334 ..style.clone()
335 },
336 out,
337 );
338 i = end + 2;
339 continue;
340 }
341 }
342 if chars[i] == '`' {
343 if let Some(end) = find(i + 1, &['`']) {
344 flush_md_plain(&mut plain, &style, out);
345 let inner: String = sub(i + 1, end).iter().collect();
346 let inner = inner.trim();
347 if !inner.is_empty() {
348 out.push(InlineRun {
349 text: inner.to_string(),
350 code: true,
351 ..style.clone()
352 });
353 }
354 i = end + 1;
355 continue;
356 }
357 }
358 if chars[i] == '[' {
359 if let Some(close) = find(i + 1, &[']', '(']) {
360 if let Some(endp) = link_dest_end(chars, close + 2) {
361 flush_md_plain(&mut plain, &style, out);
362 parse_md_runs(&sub(i + 1, close), style.clone(), out);
364 i = endp + 1;
365 continue;
366 }
367 }
368 }
369 plain.push(chars[i]);
370 i += 1;
371 }
372 flush_md_plain(&mut plain, &style, out);
373}
374
375pub(crate) fn link_dest_end(chars: &[char], start: usize) -> Option<usize> {
381 let mut depth = 0usize;
382 for (offset, c) in chars.get(start..)?.iter().enumerate() {
383 match c {
384 '(' => depth += 1,
385 ')' if depth == 0 => return Some(start + offset),
386 ')' => depth -= 1,
387 _ => {}
388 }
389 }
390 None
391}
392
393fn attr_escape(v: &str) -> String {
395 v.replace('&', "&").replace('"', """)
396}
397
398fn emit_text_element(
405 out: &mut Out,
406 depth: i32,
407 tag_open: &str,
408 tag: &str,
409 text: &str,
410 location: Option<&[u16; 4]>,
411) {
412 if let Some(loc) = location {
415 out.push(depth, format!("<{tag_open}>"));
416 push_location(out, depth + 1, loc);
417 if !text.is_empty() {
418 emit_runs(out, depth + 1, inline_runs(text));
419 }
420 out.push(depth, format!("</{tag}>"));
421 return;
422 }
423 if text.is_empty() {
426 out.push(depth, format!("<{tag_open}></{tag}>"));
427 return;
428 }
429 let runs = inline_runs(text);
430 let only_plain = runs.len() == 1 && matches!(runs[0], Run::Plain(_));
431 if runs.len() == 1 {
434 if let Run::Link { anchor, uri } = &runs[0] {
435 out.push(depth, format!("<{tag_open}>"));
436 out.push(depth + 1, format!("<href uri=\"{}\"/>", attr_escape(uri)));
437 if !anchor.trim().is_empty() {
438 emit_runs(out, depth + 1, inline_runs(anchor));
439 }
440 out.push(depth, format!("</{tag}>"));
441 return;
442 }
443 }
444 if only_plain {
445 let mut body = escape_text(text);
446 if is_rtl(text) {
448 body = format!("<rtl>{body}</rtl>");
449 }
450 if body.starts_with("<content>") || body.starts_with("<rtl>") {
454 out.push(depth, format!("<{tag_open}>"));
455 out.push(depth + 1, body);
456 out.push(depth, format!("</{tag}>"));
457 } else {
458 out.push(depth, format!("<{tag_open}>{body}</{tag}>"));
459 }
460 return;
461 }
462 out.push(depth, format!("<{tag_open}>"));
463 emit_runs(out, depth + 1, runs);
464 out.push(depth, format!("</{tag}>"));
465}
466
467fn emit_runs(out: &mut Out, depth: i32, runs: Vec<Run>) {
468 for run in runs {
469 match run {
470 Run::Plain(t) => {
471 let t = t.trim_matches('\n');
472 if !t.is_empty() {
473 emit_text_node(out, depth, t);
474 }
475 }
476 Run::Bold(t) => out.push(depth, format!("<bold>{}</bold>", escape_text(&t))),
477 Run::Italic(t) => out.push(depth, format!("<italic>{}</italic>", escape_text(&t))),
478 Run::BoldItalic(t) => {
479 out.push(depth, "<italic>".to_string());
480 out.push(depth + 1, format!("<bold>{}</bold>", escape_text(&t)));
481 out.push(depth, "</italic>".to_string());
482 }
483 Run::Code(t) => out.push(depth, format!("<code>{}</code>", escape_text(&t))),
484 Run::Link { anchor, .. } => {
485 if !anchor.is_empty() {
487 emit_text_node(out, depth, &anchor);
488 }
489 }
490 }
491 }
492}
493
494fn emit_text_node(out: &mut Out, depth: i32, text: &str) {
498 let e = escape_text(text);
499 if e.starts_with("<![CDATA[") {
500 out.push_glue(e);
501 } else if is_rtl(text) {
502 out.push(depth, format!("<rtl>{e}</rtl>"));
506 } else {
507 out.push(depth, e);
508 }
509}
510
511fn is_rtl(text: &str) -> bool {
521 let mut chars = text.chars();
522 let Some(first) = chars.next() else {
523 return false;
524 };
525 if is_strong_rtl(first) {
526 return true;
527 }
528 let total = 1 + chars.count();
529 let rtl = text.chars().filter(|c| is_strong_rtl(*c)).count();
530 rtl * 2 > total
531}
532
533fn is_strong_rtl(c: char) -> bool {
535 let c = c as u32;
536 if matches!(c, 0x0600..=0x0605 | 0x0660..=0x0669 | 0x066B..=0x066C | 0x06DD | 0x06F0..=0x06F9) {
539 return false;
540 }
541 matches!(c,
542 0x0590..=0x05FF | 0x0600..=0x07BF | 0x07C0..=0x085F | 0x0860..=0x08FF | 0x200F | 0xFB1D..=0xFB4F | 0xFB50..=0xFDFF | 0xFE70..=0xFEFF | 0x10800..=0x10FFF | 0x1E800..=0x1EC6F | 0x1EE00..=0x1EEFF )
554}
555
556fn code_lang_label(lang: &str) -> Option<&'static str> {
561 let lang = crate::json::code_language(Some(lang));
565 Some(match lang {
566 "Bash" => "Shell",
568 "FORTRAN" => "Fortran",
569 "Latex" => "TeX",
570 "Lisp" => "Common Lisp",
571 "Matlab" | "Octave" => "MATLAB",
572 "ObjectiveC" => "Objective-C",
573 "SML" => "Standard ML",
574 "VisualBasic" => "Visual Basic .NET",
575 "DocLang" => "XML",
576 "bc" | "dc" | "Tikz" => "other",
578 "Ada" | "Awk" | "C" | "C#" | "C++" | "CMake" | "COBOL" | "CSS" | "Ceylon" | "Clojure"
580 | "Crystal" | "Cuda" | "Cython" | "D" | "Dart" | "Dockerfile" | "Elixir" | "Erlang"
581 | "Forth" | "Go" | "HTML" | "Haskell" | "Haxe" | "Java" | "JavaScript" | "JSON"
582 | "Julia" | "Kotlin" | "Lua" | "MoonScript" | "Nim" | "OCaml" | "PHP" | "Pascal"
583 | "Perl" | "Prolog" | "Python" | "Racket" | "Ruby" | "Rust" | "SQL" | "Scala"
584 | "Scheme" | "Swift" | "TypeScript" | "XML" | "YAML" => {
585 return Some(IDENTITY_LABELS[IDENTITY_LABELS.iter().position(|&x| x == lang).unwrap()])
586 }
587 _ => return None, })
589}
590
591static IDENTITY_LABELS: &[&str] = &[
594 "Ada",
595 "Awk",
596 "C",
597 "C#",
598 "C++",
599 "CMake",
600 "COBOL",
601 "CSS",
602 "Ceylon",
603 "Clojure",
604 "Crystal",
605 "Cuda",
606 "Cython",
607 "D",
608 "Dart",
609 "Dockerfile",
610 "Elixir",
611 "Erlang",
612 "Forth",
613 "Go",
614 "HTML",
615 "Haskell",
616 "Haxe",
617 "Java",
618 "JavaScript",
619 "JSON",
620 "Julia",
621 "Kotlin",
622 "Lua",
623 "MoonScript",
624 "Nim",
625 "OCaml",
626 "PHP",
627 "Pascal",
628 "Perl",
629 "Prolog",
630 "Python",
631 "Racket",
632 "Ruby",
633 "Rust",
634 "SQL",
635 "Scala",
636 "Scheme",
637 "Swift",
638 "TypeScript",
639 "XML",
640 "YAML",
641];
642
643fn emit_code(
648 out: &mut Out,
649 depth: i32,
650 language: Option<&str>,
651 text: &str,
652 location: Option<&[u16; 4]>,
653) {
654 let label = language.and_then(code_lang_label);
655 let escaped = escape_text(text);
656 let is_content_element = escaped.starts_with("<content>");
657 if let Some(loc) = location {
660 out.push(depth, "<code>".to_string());
661 push_location(out, depth + 1, loc);
662 if let Some(l) = label {
663 out.push(depth + 1, format!("<label value=\"{}\"/>", attr_escape(l)));
664 }
665 if is_content_element {
666 out.push(depth + 1, escaped);
667 } else {
668 out.push_glue(escaped);
669 }
670 out.push(depth, "</code>".to_string());
671 return;
672 }
673 match (label, is_content_element) {
674 (None, false) => out.push(depth, format!("<code>{escaped}</code>")),
675 (None, true) => {
676 out.push(depth, "<code>".to_string());
677 out.push(depth + 1, escaped);
678 out.push(depth, "</code>".to_string());
679 }
680 (Some(l), false) => {
681 out.push(depth, "<code>".to_string());
682 out.push(depth + 1, format!("<label value=\"{}\"/>", attr_escape(l)));
683 out.push_glue(escaped);
685 out.push(depth, "</code>".to_string());
686 }
687 (Some(l), true) => {
688 out.push(depth, "<code>".to_string());
689 out.push(depth + 1, format!("<label value=\"{}\"/>", attr_escape(l)));
690 out.push(depth + 1, escaped);
691 out.push(depth, "</code>".to_string());
692 }
693 }
694}
695
696fn push_location(out: &mut Out, depth: i32, loc: &[u16; 4]) {
699 for v in loc {
700 out.push(depth, format!("<location value=\"{v}\"/>"));
701 }
702}
703
704fn emit_table(out: &mut Out, depth: i32, table: &Table) {
705 out.push(depth, "<table>".to_string());
706 if let Some(cap) = &table.caption {
707 out.push(depth + 1, format!("<caption>{cap}</caption>"));
710 }
711 emit_table_rows(out, depth, table);
712 out.push(depth, "</table>".to_string());
713}
714
715fn emit_chart(
719 out: &mut Out,
720 depth: i32,
721 kind: &str,
722 table: &Table,
723 caption: Option<&str>,
724 location: Option<&[u16; 4]>,
725) {
726 out.pic_index += 1;
727 out.push(depth, "<picture class=\"chart\">".to_string());
728 out.push(
729 depth + 1,
730 format!("<label value=\"{}\"/>", attr_escape(kind)),
731 );
732 if let Some(loc) = location {
733 push_location(out, depth + 1, loc);
734 }
735 if let Some(cap) = caption {
736 out.push(
737 depth + 1,
738 format!("<caption>{}</caption>", escape_text(cap)),
739 );
740 }
741 out.push(depth + 1, "<tabular>".to_string());
742 emit_table_rows(out, depth + 1, table);
743 out.push(depth + 1, "</tabular>".to_string());
744 out.push(depth, "</picture>".to_string());
745}
746
747fn emit_table_rows(out: &mut Out, depth: i32, table: &Table) {
750 if let Some(loc) = &table.location {
753 push_location(out, depth + 1, loc);
754 }
755 for (ri, row) in table.rows.iter().enumerate() {
756 for (ci, cell) in row.iter().enumerate() {
757 let cont = |grid: &Vec<Vec<bool>>| {
761 grid.get(ri)
762 .and_then(|r| r.get(ci))
763 .copied()
764 .unwrap_or(false)
765 };
766 let is_lcel = table
767 .structure
768 .as_ref()
769 .map(|s| cont(&s.col_continuation))
770 .unwrap_or(false);
771 let is_ucel = table
772 .structure
773 .as_ref()
774 .map(|s| cont(&s.row_continuation))
775 .unwrap_or(false);
776 let is_header = match &table.structure {
777 Some(s) if !s.col_header.is_empty() => s
778 .col_header
779 .get(ri)
780 .and_then(|r| r.get(ci))
781 .copied()
782 .unwrap_or(false),
783 Some(s) => s.header_row.get(ri).copied().unwrap_or(false),
784 None => ri == 0,
785 };
786 let is_row_header = table
787 .structure
788 .as_ref()
789 .map(|s| {
790 s.row_header
791 .get(ri)
792 .and_then(|r| r.get(ci))
793 .copied()
794 .unwrap_or(false)
795 })
796 .unwrap_or(false);
797 let tok = if is_lcel && is_ucel {
798 "<xcel/>"
800 } else if is_lcel {
801 "<lcel/>"
802 } else if is_ucel {
803 "<ucel/>"
804 } else if cell.trim().is_empty() {
805 "<ecel/>"
806 } else if is_header {
807 "<ched/>"
808 } else if is_row_header {
809 "<rhed/>"
810 } else {
811 "<fcel/>"
812 };
813 out.push(depth + 1, tok.to_string());
814 if !is_lcel && !is_ucel {
815 let blocks = table
819 .cell_blocks
820 .as_ref()
821 .and_then(|b| b.get(ri))
822 .and_then(|r| r.get(ci))
823 .filter(|b| !b.is_empty());
824 if let Some(blocks) = blocks {
825 let mut bi = 0;
826 emit_nodes(out, depth + 1, blocks, &mut bi, 0);
827 } else if !cell.trim().is_empty() {
828 emit_cell_text(out, depth + 1, cell);
829 }
830 }
831 }
832 out.push(depth + 1, "<nl/>".to_string());
833 }
834}
835
836fn emit_cell_text(out: &mut Out, depth: i32, text: &str) {
838 let runs = inline_runs(text.trim());
839 emit_runs(out, depth, runs);
840}
841
842pub fn export_to_doclang(nodes: &[Node]) -> String {
844 render(nodes, false).0
845}
846
847pub fn export_to_doclang_with_assets(nodes: &[Node]) -> (String, Vec<(String, Vec<u8>)>) {
852 render(nodes, true)
853}
854
855fn render(nodes: &[Node], collect_assets: bool) -> (String, Vec<(String, Vec<u8>)>) {
856 let mut out = Out {
857 lines: Vec::new(),
858 pic_index: 0,
859 assets: collect_assets.then(Vec::new),
860 };
861 out.push(0, "<doclang version=\"0.7\">".to_string());
862 let mut i = 0usize;
863 emit_nodes(&mut out, 1, nodes, &mut i, 0);
864 out.push(0, "</doclang>".to_string());
865 let assets = out.assets.take().unwrap_or_default();
866 (out.finish(), assets)
867}
868
869fn emit_nodes(out: &mut Out, depth: i32, nodes: &[Node], i: &mut usize, level: u8) {
872 while *i < nodes.len() {
873 match &nodes[*i] {
874 Node::Heading { level, text } => {
875 let open = if *level <= 1 {
876 "heading".to_string()
877 } else {
878 format!("heading level=\"{}\"", (*level).min(6))
881 };
882 emit_text_element(out, depth, &open, "heading", text, None);
883 *i += 1;
884 }
885 Node::Paragraph { text } => {
886 if let Some(latex) = text
890 .strip_prefix("$$")
891 .and_then(|t| t.strip_suffix("$$"))
892 .filter(|t| !t.is_empty())
893 {
894 out.push(depth, format!("<formula>{}</formula>", escape_text(latex)));
895 } else {
896 emit_text_element(out, depth, "text", "text", text, None);
897 }
898 *i += 1;
899 }
900 Node::Caption { text, href } => {
901 emit_caption(out, depth, text, href.as_deref());
902 *i += 1;
903 }
904 Node::LabeledText { .. } => {
905 let para = nodes[*i].labeled_as_paragraph();
906 emit_nodes(
907 out,
908 depth,
909 std::slice::from_ref(para.as_ref()),
910 &mut 0,
911 level,
912 );
913 *i += 1;
914 }
915 Node::CheckboxItem { checked, text } => {
916 let class = if *checked { "selected" } else { "unselected" };
919 out.push(depth, "<text>".to_string());
920 out.push(depth + 1, format!("<checkbox class=\"{class}\"/>"));
921 if !text.is_empty() {
922 out.push(depth + 1, escape_text(text));
923 }
924 out.push(depth, "</text>".to_string());
925 *i += 1;
926 }
927 Node::Code { language, text, .. } => {
928 emit_code(out, depth, language.as_deref(), text, None);
929 *i += 1;
930 }
931 Node::Formula {
934 latex, location, ..
935 } => {
936 if let Some(loc) = location {
937 out.push(depth, "<formula>".to_string());
938 push_location(out, depth + 1, loc);
939 if !latex.is_empty() {
940 out.push(depth + 1, escape_text(latex));
941 }
942 out.push(depth, "</formula>".to_string());
943 } else {
944 out.push(depth, format!("<formula>{}</formula>", escape_text(latex)));
945 }
946 *i += 1;
947 }
948 Node::PageFurniture {
949 footer,
950 location,
951 text,
952 } => {
953 let tag = if *footer {
954 "page_footer"
955 } else {
956 "page_header"
957 };
958 out.push(depth, format!("<{tag}>"));
959 out.push(depth + 1, "<layer value=\"furniture\"/>".to_string());
960 push_location(out, depth + 1, location);
961 if !text.is_empty() {
962 out.push(depth + 1, escape_text(text));
963 }
964 out.push(depth, format!("</{tag}>"));
965 *i += 1;
966 }
967 Node::FurnitureText { label, text } => {
968 out.push(depth, format!("<{label}>"));
969 out.push(depth + 1, "<layer value=\"furniture\"/>".to_string());
970 if !text.is_empty() {
971 out.push(depth + 1, escape_text(text));
972 }
973 out.push(depth, format!("</{label}>"));
974 *i += 1;
975 }
976 Node::Table(t) => {
977 emit_table(out, depth, t);
978 *i += 1;
979 }
980 Node::Picture {
983 caption,
984 caption_href,
985 image,
986 ..
987 } => {
988 emit_picture(
989 out,
990 depth,
991 caption.as_deref(),
992 caption_href.as_deref(),
993 image.as_ref(),
994 None,
995 );
996 *i += 1;
997 }
998 Node::Chart {
999 kind,
1000 table,
1001 caption,
1002 location,
1003 } => {
1004 emit_chart(
1005 out,
1006 depth,
1007 kind,
1008 table,
1009 caption.as_deref(),
1010 location.as_ref(),
1011 );
1012 *i += 1;
1013 }
1014 Node::DoclangOnly(inner) => {
1015 let mut j = 0;
1016 emit_nodes(out, depth, std::slice::from_ref(inner), &mut j, level);
1017 *i += 1;
1018 }
1019 Node::ListItem { level: l, .. } => {
1020 if *l < level {
1021 return; }
1023 emit_list(out, depth, nodes, i, *l);
1024 }
1025 Node::Group {
1029 children, layer, ..
1030 } => {
1031 match layer {
1032 Some(l) => {
1033 for child in children {
1034 emit_furniture(out, depth, *l, child);
1035 }
1036 }
1037 None => {
1038 let mut j = 0usize;
1039 emit_nodes(out, depth, children, &mut j, 0);
1040 }
1041 }
1042 *i += 1;
1043 }
1044 Node::FieldRegion { items } => {
1045 emit_field_region(out, depth, items);
1046 *i += 1;
1047 }
1048 Node::KeyValueGraph { .. } => *i += 1,
1050 Node::InlineGroup {
1051 unwrapped, runs, ..
1052 } => {
1053 emit_inline_group(out, depth, *unwrapped, runs);
1054 *i += 1;
1055 }
1056 Node::Furniture { layer, inner } => {
1057 emit_furniture(out, depth, *layer, inner);
1058 *i += 1;
1059 }
1060 Node::CommentSection { text, .. } => {
1063 emit_furniture(
1064 out,
1065 depth,
1066 ContentLayer::Notes,
1067 &Node::Paragraph { text: text.clone() },
1068 );
1069 *i += 1;
1070 }
1071 Node::Commented { inner, .. } => {
1074 emit_nodes(out, depth, std::slice::from_ref(inner), &mut 0, level);
1075 *i += 1;
1076 }
1077 Node::Located { location, inner } => {
1078 emit_located(out, depth, location, inner);
1079 *i += 1;
1080 }
1081 Node::Prov { inner, .. } => {
1084 emit_nodes(out, depth, std::slice::from_ref(inner), &mut 0, level);
1085 *i += 1;
1086 }
1087 Node::PageBreak => {
1088 out.push(depth, "<page_break/>".to_string());
1089 *i += 1;
1090 }
1091 Node::PageInfo { .. } => {
1094 *i += 1;
1095 }
1096 Node::PictureChildren(_) => {
1100 *i += 1;
1101 }
1102 Node::TextDump(text) => {
1103 emit_text_dump(out, depth, text);
1104 *i += 1;
1105 }
1106 }
1107 }
1108}
1109
1110enum DumpNode {
1113 Text(String),
1114 Cdata(String),
1115 Elem(String),
1116}
1117
1118fn emit_text_dump(out: &mut Out, depth: i32, text: &str) {
1130 let records = dump_records(text);
1131 if records.is_empty() {
1132 out.push(depth, "<text></text>".to_string());
1133 return;
1134 }
1135 let mut nodes: Vec<DumpNode> = Vec::new();
1139 let mut buf = String::new();
1140 for (r, (line, italic)) in records.iter().enumerate() {
1141 if r > 0 {
1142 buf.push('\n'); }
1144 let raw = unescape_stored(line);
1145 let s = raw.as_ref();
1146 let is_cdata = s.contains(['"', '\'', '&', '<', '>']);
1147 if *italic || is_cdata {
1148 if !buf.is_empty() {
1149 nodes.push(DumpNode::Text(std::mem::take(&mut buf)));
1150 }
1151 let inner = if is_cdata {
1152 format!("<![CDATA[{s}]]>")
1153 } else {
1154 s.to_string()
1155 };
1156 if *italic {
1157 nodes.push(DumpNode::Elem(format!("<italic>{inner}</italic>")));
1158 } else {
1159 nodes.push(DumpNode::Cdata(inner));
1160 }
1161 } else {
1162 buf.push_str(s);
1163 }
1164 }
1165 if !buf.is_empty() {
1166 nodes.push(DumpNode::Text(buf));
1167 }
1168
1169 if let [DumpNode::Text(d)] = nodes.as_slice() {
1171 out.push(depth, format!("<text>{d}\n</text>"));
1172 return;
1173 }
1174
1175 let ind_child = INDENT.repeat((depth + 1).max(0) as usize);
1178 let ind_self = INDENT.repeat(depth.max(0) as usize);
1179 let mut raw = String::new();
1180 for node in &nodes {
1181 match node {
1182 DumpNode::Text(d) => {
1183 raw.push_str(&ind_child);
1184 raw.push_str(d);
1185 raw.push('\n');
1186 }
1187 DumpNode::Cdata(b) => raw.push_str(b),
1188 DumpNode::Elem(b) => {
1189 raw.push_str(&ind_child);
1190 raw.push_str(b);
1191 raw.push('\n');
1192 }
1193 }
1194 }
1195 let full = format!("{ind_self}<text>\n{raw}{ind_self}</text>");
1196 for line in full.split('\n') {
1197 if !line.trim().is_empty() {
1198 out.push(0, line.to_string());
1199 }
1200 }
1201}
1202
1203fn dump_records(text: &str) -> Vec<(String, bool)> {
1208 let chars: Vec<char> = text.chars().collect();
1209 let n = chars.len();
1210
1211 struct Delim {
1212 pos: usize,
1213 length: usize,
1214 rem: usize,
1215 can_open: bool,
1216 can_close: bool,
1217 }
1218 let is_ws = |c: Option<char>| c.is_none_or(|c| c.is_whitespace());
1219 let is_punct =
1220 |c: Option<char>| c.is_some_and(|c| "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~".contains(c));
1221
1222 let mut delims: Vec<Delim> = Vec::new();
1224 let mut i = 0;
1225 while i < n {
1226 if chars[i] == '*' {
1227 let mut j = i;
1228 while j < n && chars[j] == '*' {
1229 j += 1;
1230 }
1231 let prev = (i > 0).then(|| chars[i - 1]);
1232 let next = (j < n).then(|| chars[j]);
1233 let left = !is_ws(next) && (!is_punct(next) || is_ws(prev) || is_punct(prev));
1234 let right = !is_ws(prev) && (!is_punct(prev) || is_ws(next) || is_punct(next));
1235 delims.push(Delim {
1236 pos: i,
1237 length: j - i,
1238 rem: j - i,
1239 can_open: left,
1240 can_close: right,
1241 });
1242 i = j;
1243 } else {
1244 i += 1;
1245 }
1246 }
1247
1248 let mut emph = vec![false; n];
1251 let mut consumed = vec![false; n];
1252 let mut ci = 0;
1253 while ci < delims.len() {
1254 if !(delims[ci].can_close && delims[ci].rem > 0) {
1255 ci += 1;
1256 continue;
1257 }
1258 let mut found: Option<usize> = None;
1259 let mut oi = ci as i64 - 1;
1260 while oi >= 0 {
1261 let o = &delims[oi as usize];
1262 let c = &delims[ci];
1263 if o.can_open && o.rem > 0 {
1264 let odd = (o.can_close || c.can_open)
1267 && (o.length + c.length) % 3 == 0
1268 && !(o.length % 3 == 0 && c.length % 3 == 0);
1269 if !odd {
1270 found = Some(oi as usize);
1271 break;
1272 }
1273 }
1274 oi -= 1;
1275 }
1276 let Some(fi) = found else {
1277 ci += 1;
1278 continue;
1279 };
1280 let use_ = if delims[fi].rem >= 2 && delims[ci].rem >= 2 {
1281 2
1282 } else {
1283 1
1284 };
1285 let oend = delims[fi].pos + delims[fi].rem;
1286 for c in consumed.iter_mut().take(oend).skip(oend - use_) {
1287 *c = true;
1288 }
1289 let cstart = delims[ci].pos + (delims[ci].length - delims[ci].rem);
1290 for c in consumed.iter_mut().take(cstart + use_).skip(cstart) {
1291 *c = true;
1292 }
1293 for e in emph.iter_mut().take(cstart).skip(oend) {
1294 *e = true;
1295 }
1296 delims[fi].rem -= use_;
1297 delims[ci].rem -= use_;
1298 delims.drain((fi + 1)..ci);
1299 ci = if delims[fi].rem == 0 { fi + 1 } else { fi };
1300 }
1301
1302 let mut records: Vec<(String, bool)> = Vec::new();
1304 let mut line = String::new();
1305 let mut line_italic = false;
1306 let push_line = |line: &mut String, italic: &mut bool, out: &mut Vec<(String, bool)>| {
1307 let text = std::mem::take(line);
1308 let ital = std::mem::replace(italic, false);
1309 let trimmed = text.trim();
1310 if trimmed.is_empty() {
1311 return;
1312 }
1313 let norm = if trimmed.len() >= 3 && trimmed.chars().all(|c| c == '_') {
1315 "_".repeat(10)
1316 } else {
1317 text
1318 };
1319 out.push((norm, ital));
1320 };
1321 for k in 0..n {
1322 if consumed[k] {
1323 continue;
1324 }
1325 if chars[k] == '\n' {
1326 push_line(&mut line, &mut line_italic, &mut records);
1327 } else {
1328 line.push(chars[k]);
1329 if emph[k] {
1330 line_italic = true;
1331 }
1332 }
1333 }
1334 push_line(&mut line, &mut line_italic, &mut records);
1335 records
1336}
1337
1338fn emit_inline_group(out: &mut Out, depth: i32, unwrapped: bool, runs: &[InlineRun]) {
1352 let has_styled = runs.iter().any(|r| !r.is_plain());
1353
1354 if unwrapped {
1355 for run in runs {
1356 if run.is_plain() {
1357 out.push(0, escape_text(&run.text));
1358 } else if run.formula {
1359 out.push(
1360 depth,
1361 format!("<formula>{}</formula>", escape_text(&run.text)),
1362 );
1363 } else {
1364 emit_styled(out, depth, &style_tags(run), &escape_text(&run.text));
1365 }
1366 }
1367 return;
1368 }
1369
1370 if !has_styled {
1373 let joined = runs
1374 .iter()
1375 .map(|r| escape_text(&r.text))
1376 .collect::<Vec<_>>()
1377 .join("\n");
1378 out.push(depth, format!("<text>{joined}\n</text>"));
1379 return;
1380 }
1381
1382 out.push(depth, "<text>".to_string());
1383 emit_inline_runs_body(out, depth + 1, runs);
1384 out.push(depth, "</text>".to_string());
1385}
1386
1387fn emit_inline_runs_body(out: &mut Out, depth: i32, runs: &[InlineRun]) {
1393 for (i, run) in runs.iter().enumerate() {
1394 if run.is_plain() {
1395 let e = escape_text(&run.text);
1396 let d = if e.starts_with("<content>") || i == 0 {
1397 depth
1398 } else {
1399 0
1400 };
1401 if e.starts_with("<![CDATA[") && i + 1 == runs.len() && d == 0 {
1402 out.push_glue(e);
1407 out.push(depth, "");
1408 } else {
1409 out.push(d, e);
1410 }
1411 } else if run.formula {
1412 out.push(
1413 depth,
1414 format!("<formula>{}</formula>", escape_text(&run.text)),
1415 );
1416 } else {
1417 emit_styled(out, depth, &style_tags(run), &escape_text(&run.text));
1418 }
1419 }
1420}
1421
1422fn style_tags(run: &InlineRun) -> Vec<&'static str> {
1426 let mut tags = Vec::new();
1427 match run.script {
1428 Script::Sub => tags.push("subscript"),
1429 Script::Super => tags.push("superscript"),
1430 Script::Baseline => {}
1431 }
1432 if run.strike {
1433 tags.push("strikethrough");
1434 }
1435 if run.underline {
1436 tags.push("underline");
1437 }
1438 if run.italic {
1439 tags.push("italic");
1440 }
1441 if run.bold {
1442 tags.push("bold");
1443 }
1444 if run.code {
1445 tags.push("code");
1446 }
1447 tags
1448}
1449
1450fn emit_styled(out: &mut Out, depth: i32, tags: &[&str], inner: &str) {
1455 match tags {
1456 [] => emit_text_node(out, depth, inner),
1457 [tag] => out.push(depth, format!("<{tag}>{inner}</{tag}>")),
1458 [tag, rest @ ..] => {
1459 out.push(depth, format!("<{tag}>"));
1460 emit_styled(out, depth + 1, rest, inner);
1461 out.push(depth, format!("</{tag}>"));
1462 }
1463 }
1464}
1465
1466fn lone_link(text: &str) -> Option<(String, String)> {
1470 let mut runs = inline_runs(text);
1471 if runs.len() != 1 {
1472 return None;
1473 }
1474 match runs.pop() {
1475 Some(Run::Link { anchor, uri }) => Some((anchor, uri)),
1476 _ => None,
1477 }
1478}
1479
1480fn emit_furniture(out: &mut Out, depth: i32, layer: ContentLayer, inner: &Node) {
1485 let token = format!("<layer value=\"{}\"/>", layer.value());
1486 match inner {
1487 Node::Heading { level, text } => {
1488 let open = if *level <= 1 {
1489 "heading".to_string()
1490 } else {
1491 format!("heading level=\"{}\"", (*level).min(6))
1494 };
1495 out.push(depth, format!("<{open}>"));
1496 out.push(depth + 1, token);
1497 out.push(depth + 1, escape_text(text));
1498 out.push(depth, "</heading>".to_string());
1499 }
1500 Node::Paragraph { text } => {
1501 out.push(depth, "<text>".to_string());
1502 match lone_link(text) {
1508 Some((anchor, uri)) => {
1509 out.push(depth + 1, format!("<href uri=\"{}\"/>", attr_escape(&uri)));
1510 out.push(depth + 1, token);
1511 if !anchor.trim().is_empty() {
1512 emit_runs(out, depth + 1, inline_runs(&anchor));
1513 }
1514 }
1515 None => {
1516 out.push(depth + 1, token);
1517 out.push(depth + 1, escape_text(text));
1518 }
1519 }
1520 out.push(depth, "</text>".to_string());
1521 }
1522 Node::CheckboxItem { checked, text } => {
1525 let class = if *checked { "selected" } else { "unselected" };
1526 out.push(depth, "<text>".to_string());
1527 out.push(depth + 1, token);
1528 out.push(depth + 1, format!("<checkbox class=\"{class}\"/>"));
1529 if !text.is_empty() {
1530 out.push(depth + 1, escape_text(text));
1531 }
1532 out.push(depth, "</text>".to_string());
1533 }
1534 Node::Located { location, inner } => {
1537 if let Node::Paragraph { text } = &**inner {
1538 out.push(depth, "<text>".to_string());
1539 out.push(depth + 1, token);
1540 push_location(out, depth + 1, location);
1541 out.push(depth + 1, escape_text(text));
1542 out.push(depth, "</text>".to_string());
1543 } else {
1544 let mut i = 0usize;
1545 emit_nodes(out, depth, std::slice::from_ref(inner.as_ref()), &mut i, 0);
1546 }
1547 }
1548 Node::InlineGroup { runs, .. } => {
1552 out.push(depth, "<text>".to_string());
1553 for run in runs {
1554 out.push(depth + 1, token.clone());
1555 if run.is_plain() {
1556 out.push(depth + 1, escape_text(&run.text));
1557 } else if run.formula {
1558 out.push(
1559 depth + 1,
1560 format!("<formula>{}</formula>", escape_text(&run.text)),
1561 );
1562 } else {
1563 emit_styled(out, depth + 1, &style_tags(run), &escape_text(&run.text));
1564 }
1565 }
1566 out.push(depth, "</text>".to_string());
1567 }
1568 Node::Picture {
1574 caption,
1575 caption_href,
1576 image,
1577 ..
1578 } => {
1579 let caption = caption.as_deref().filter(|c| !c.trim().is_empty());
1580 out.push(depth, "<picture>".to_string());
1581 out.push(depth + 1, token.clone());
1582 if let Some(img) = image {
1583 let (_, uri) = crate::pixel_digest::docling_data_uri(img);
1586 out.push(depth + 1, format!("<src uri=\"{uri}\"/>"));
1587 }
1588 if let Some(c) = caption {
1589 out.push(depth + 1, "<caption>".to_string());
1590 let link = caption_href
1594 .clone()
1595 .map(|uri| (c.to_string(), uri))
1596 .or_else(|| lone_link(c));
1597 match link {
1598 Some((anchor, uri)) => {
1599 out.push(depth + 2, format!("<href uri=\"{}\"/>", attr_escape(&uri)));
1600 out.push(depth + 2, token);
1601 out.push(depth + 2, escape_text(&anchor));
1602 }
1603 None => {
1604 out.push(depth + 2, token);
1605 out.push(depth + 2, escape_text(c));
1606 }
1607 }
1608 out.push(depth + 1, "</caption>".to_string());
1609 }
1610 out.push(depth, "</picture>".to_string());
1611 }
1612 Node::Table(table) => {
1615 out.push(depth, "<table>".to_string());
1616 out.push(depth + 1, token);
1617 emit_table_rows(out, depth, table);
1618 out.push(depth, "</table>".to_string());
1619 }
1620 other => {
1621 let mut i = 0usize;
1622 emit_nodes(out, depth, std::slice::from_ref(other), &mut i, 0);
1623 }
1624 }
1625}
1626
1627fn emit_picture(
1630 out: &mut Out,
1631 depth: i32,
1632 caption: Option<&str>,
1633 caption_href: Option<&str>,
1634 image: Option<&crate::document::PictureImage>,
1635 location: Option<&[u16; 4]>,
1636) {
1637 let caption = caption.filter(|c| !c.trim().is_empty());
1638 let idx = out.pic_index;
1647 out.pic_index += 1;
1648 let src = image.map(|img| {
1649 let path = format!(
1650 "assets/image_{idx:06}_{}.png",
1651 crate::pixel_digest::image_digest(&img.data)
1652 );
1653 if let Some(assets) = out.assets.as_mut() {
1654 let bytes =
1655 crate::pixel_digest::asset_png(&img.data).unwrap_or_else(|| img.data.clone());
1656 assets.push((path.clone(), bytes));
1657 }
1658 path
1659 });
1660 if location.is_none() && caption.is_none() && src.is_none() {
1661 out.push(depth, "<picture></picture>".to_string());
1662 return;
1663 }
1664 out.push(depth, "<picture>".to_string());
1665 if let Some(loc) = location {
1666 push_location(out, depth + 1, loc);
1667 }
1668 if let Some(s) = src {
1669 out.push(depth + 1, format!("<src uri=\"{}\"/>", attr_escape(&s)));
1670 }
1671 if let Some(c) = caption {
1672 emit_caption(out, depth + 1, c, caption_href);
1673 }
1674 out.push(depth, "</picture>".to_string());
1675}
1676
1677fn emit_caption(out: &mut Out, depth: i32, text: &str, href: Option<&str>) {
1682 if let Some(uri) = href {
1683 out.push(depth, "<caption>".to_string());
1684 out.push(depth + 1, format!("<href uri=\"{}\"/>", attr_escape(uri)));
1685 out.push(depth + 1, escape_text(text));
1686 out.push(depth, "</caption>".to_string());
1687 return;
1688 }
1689 if let Some(Run::Link { anchor, uri }) = inline_runs(text).into_iter().next() {
1690 if inline_runs(text).len() == 1 {
1691 out.push(depth, "<caption>".to_string());
1692 out.push(depth + 1, format!("<href uri=\"{}\"/>", attr_escape(&uri)));
1693 out.push(depth + 1, escape_text(&anchor));
1694 out.push(depth, "</caption>".to_string());
1695 return;
1696 }
1697 }
1698 out.push(depth, format!("<caption>{}</caption>", escape_text(text)));
1699}
1700
1701fn strip_lone_link(text: &str) -> Cow<'_, str> {
1705 if let Some(rest) = text.strip_prefix('[') {
1706 if let Some(close) = rest.find("](") {
1707 if rest.ends_with(')') {
1708 let anchor = &rest[..close];
1709 let uri = &rest[close + 2..rest.len() - 1];
1710 if !anchor.contains(['[', ']']) && !uri.contains(['(', ')']) {
1711 return Cow::Owned(anchor.to_string());
1712 }
1713 }
1714 }
1715 }
1716 Cow::Borrowed(text)
1717}
1718
1719fn emit_located(out: &mut Out, depth: i32, location: &[u16; 4], inner: &Node) {
1722 match inner {
1723 Node::Heading { level, text } => {
1724 let open = if *level <= 1 {
1725 "heading".to_string()
1726 } else {
1727 format!("heading level=\"{}\"", (*level).min(6))
1730 };
1731 emit_text_element(out, depth, &open, "heading", text, Some(location));
1732 }
1733 Node::Paragraph { text } => {
1734 emit_text_element(out, depth, "text", "text", text, Some(location));
1735 }
1736 Node::LabeledText { .. } => {
1737 emit_located(out, depth, location, &inner.labeled_as_paragraph())
1738 }
1739 Node::Picture {
1740 caption,
1741 caption_href,
1742 image,
1743 ..
1744 } => {
1745 emit_picture(
1746 out,
1747 depth,
1748 caption.as_deref(),
1749 caption_href.as_deref(),
1750 image.as_ref(),
1751 Some(location),
1752 );
1753 }
1754 Node::Table(t) => {
1755 let mut t = t.clone();
1757 t.location = Some(*location);
1758 emit_table(out, depth, &t);
1759 }
1760 Node::Code { language, text, .. } => {
1761 emit_code(out, depth, language.as_deref(), text, Some(location));
1762 }
1763 other => {
1767 let mut i = 0usize;
1768 emit_nodes(out, depth, std::slice::from_ref(other), &mut i, 0);
1769 }
1770 }
1771}
1772
1773fn emit_list(out: &mut Out, depth: i32, nodes: &[Node], i: &mut usize, level: u8) {
1774 let ordered = match &nodes[*i] {
1777 Node::ListItem { ordered, dclx, .. } => dclx.as_ref().map_or(*ordered, |d| d.ordered),
1778 _ => false,
1779 };
1780 let open = if ordered {
1781 "<list class=\"ordered\">"
1782 } else {
1783 "<list>"
1784 };
1785 out.push(depth, open.to_string());
1786 let start = *i;
1787 while *i < nodes.len() {
1788 match &nodes[*i] {
1789 Node::ListItem {
1790 level: l,
1791 text,
1792 marker,
1793 ordered: o,
1794 number,
1795 first_in_list,
1796 location,
1797 dclx,
1798 href,
1799 layer,
1800 } if *l == level => {
1801 let eff_marker = dclx.as_ref().map_or(marker.as_ref(), |d| d.marker.as_ref());
1804 if *i != start && *first_in_list {
1809 break;
1810 }
1811 let has_nested = {
1817 let mut found = false;
1818 let mut j = *i + 1;
1819 while let Some(Node::ListItem {
1820 level: nl,
1821 first_in_list: nf,
1822 ..
1823 }) = nodes.get(j)
1824 {
1825 if *nl > level {
1826 found = true;
1827 break;
1828 }
1829 if *nl < level {
1830 break;
1831 }
1832 if *nf {
1835 break;
1836 }
1837 j += 1;
1838 }
1839 found
1840 };
1841 match eff_marker {
1844 Some(m) => {
1845 out.push(depth + 1, "<ldiv>".to_string());
1846 out.push(depth + 2, format!("<marker>{}</marker>", escape_text(m)));
1847 out.push(depth + 1, "</ldiv>".to_string());
1848 }
1849 None => out.push(depth + 1, "<ldiv/>".to_string()),
1850 }
1851 if let Some(loc) = location {
1855 push_location(out, depth + 1, loc);
1856 }
1857 match dclx {
1858 Some(d) if !d.runs.is_empty() => {
1863 if has_nested {
1864 out.push(depth + 1, "<text>".to_string());
1865 emit_inline_runs_body(out, depth + 2, &d.runs);
1866 out.push(depth + 1, "</text>".to_string());
1867 } else {
1868 emit_inline_runs_body(out, depth + 1, &d.runs);
1869 }
1870 }
1871 Some(d) => emit_list_item_content(out, depth + 1, &d.text, has_nested),
1874 None => {
1875 let stripped = strip_lone_link(text);
1880 let eff_href = href
1881 .as_deref()
1882 .filter(|_| matches!(stripped, Cow::Owned(_)));
1883 if eff_href.is_some() || layer.is_some() {
1884 let content: &str = if eff_href.is_some() {
1885 stripped.as_ref()
1886 } else {
1887 text.as_str()
1888 };
1889 emit_list_item_with_head(
1890 out,
1891 depth + 1,
1892 content,
1893 has_nested,
1894 eff_href,
1895 *layer,
1896 );
1897 } else {
1898 emit_list_item_content(out, depth + 1, text, has_nested);
1899 }
1900 }
1901 }
1902 *i += 1;
1903 }
1904 Node::ListItem { level: l, .. } if *l > level => {
1905 emit_list(out, depth + 1, nodes, i, *l);
1906 }
1907 Node::Paragraph { text }
1913 if text.is_empty()
1914 && matches!(
1915 nodes.get(*i + 1),
1916 Some(Node::ListItem { level: nl, first_in_list: nf, .. })
1917 if *nl > level || (*nl == level && !*nf)
1918 ) =>
1919 {
1920 *i += 1;
1921 }
1922 _ => break,
1923 }
1924 }
1925 out.push(depth, "</list>".to_string());
1926}
1927
1928fn emit_list_item_with_head(
1939 out: &mut Out,
1940 depth: i32,
1941 text: &str,
1942 has_nested: bool,
1943 href: Option<&str>,
1944 layer: Option<ContentLayer>,
1945) {
1946 let head = |out: &mut Out, d: i32| {
1947 if let Some(uri) = href {
1948 out.push(d, format!("<href uri=\"{}\"/>", attr_escape(uri)));
1949 }
1950 if let Some(l) = layer {
1951 out.push(d, format!("<layer value=\"{}\"/>", l.value()));
1952 }
1953 };
1954 if has_nested {
1955 out.push(depth, "<text>".to_string());
1956 head(out, depth + 1);
1957 emit_runs(out, depth + 1, inline_runs(text));
1958 out.push(depth, "</text>".to_string());
1959 } else {
1960 head(out, depth);
1961 emit_runs(out, depth, inline_runs(text));
1962 }
1963}
1964
1965fn emit_list_item_content(out: &mut Out, depth: i32, text: &str, has_nested: bool) {
1966 let runs = inline_runs_from_markdown(text);
1972 let single_plain = runs.len() <= 1 && runs.first().is_none_or(|r| r.is_plain());
1973 if single_plain {
1974 if has_nested {
1975 emit_text_element(out, depth, "text", "text", text, None);
1976 } else if !text.trim().is_empty() {
1977 emit_text_node(out, depth, text);
1981 }
1982 } else if has_nested {
1983 emit_inline_group(out, depth, false, &runs);
1984 } else {
1985 emit_inline_runs_body(out, depth, &runs);
1988 }
1989}
1990
1991fn emit_field_region(out: &mut Out, depth: i32, items: &[FieldItem]) {
1992 out.push(depth, "<field_region>".to_string());
1993 for item in items {
1994 out.push(depth + 1, "<field_item>".to_string());
1995 if let Some(m) = item.marker.as_ref().filter(|s| !s.is_empty()) {
1996 out.push(depth + 2, format!("<marker>{}</marker>", escape_text(m)));
1997 }
1998 if let Some(k) = item.key.as_ref().filter(|s| !s.is_empty()) {
1999 out.push(depth + 2, format!("<key>{}</key>", escape_text(k)));
2000 }
2001 if let Some(v) = item.value.as_ref().filter(|s| !s.is_empty()) {
2002 out.push(depth + 2, format!("<value>{}</value>", escape_text(v)));
2003 }
2004 out.push(depth + 1, "</field_item>".to_string());
2005 }
2006 out.push(depth, "</field_region>".to_string());
2007}
2008
2009#[cfg(test)]
2010mod tests {
2011 use super::*;
2012
2013 #[test]
2017 fn xml_illegal_characters_become_visible_markers() {
2018 let doclang = export_to_doclang(&[Node::Paragraph {
2019 text: "Before break\u{0B}After\u{FFFF} tab\tok".into(),
2020 }]);
2021 assert!(
2022 doclang.contains("Before break[U+000B]After[U+FFFF] tab\tok"),
2023 "got:\n{doclang}"
2024 );
2025 }
2026
2027 #[test]
2031 fn cdata_closing_delimiter_splits_sections() {
2032 let doclang = export_to_doclang(&[Node::Paragraph {
2033 text: "a]]>b & c".into(),
2034 }]);
2035 assert!(
2036 doclang.contains("<![CDATA[a]]]]><![CDATA[>b & c]]>"),
2037 "got:\n{doclang}"
2038 );
2039 }
2040
2041 #[test]
2044 fn deep_heading_levels_clamp_to_six() {
2045 let doclang = export_to_doclang(&[
2046 Node::Heading {
2047 level: 6,
2048 text: "Deep".into(),
2049 },
2050 Node::Heading {
2051 level: 42,
2052 text: "Deeper".into(),
2053 },
2054 ]);
2055 assert!(doclang.contains("<heading level=\"6\">Deep</heading>"));
2056 assert!(
2057 doclang.contains("<heading level=\"6\">Deeper</heading>"),
2058 "got:\n{doclang}"
2059 );
2060 }
2061
2062 #[test]
2063 fn located_heading_emits_location_tokens_in_block_form() {
2064 let doclang = export_to_doclang(&[Node::Located {
2065 location: [44, 170, 340, 386],
2066 inner: Box::new(Node::Heading {
2067 level: 1,
2068 text: "X-Library".into(),
2069 }),
2070 }]);
2071 assert!(
2072 doclang.contains(
2073 "<heading>\n <location value=\"44\"/>\n <location value=\"170\"/>\n \
2074 <location value=\"340\"/>\n <location value=\"386\"/>\n X-Library\n </heading>"
2075 ),
2076 "got:\n{doclang}"
2077 );
2078 }
2079
2080 fn code(language: Option<&str>, text: &str) -> String {
2081 export_to_doclang(&[Node::Code {
2082 language: language.map(String::from),
2083 text: text.into(),
2084 orig: None,
2085 pretty: None,
2086 }])
2087 }
2088
2089 #[test]
2090 fn code_with_language_emits_linguist_label_block_form() {
2091 assert_eq!(
2094 code(Some("python"), "print(\"Hello world!\")"),
2095 "<doclang version=\"0.7\">\n <code>\n <label value=\"Python\"/>\n\
2096 <![CDATA[print(\"Hello world!\")]]> </code>\n</doclang>"
2097 );
2098 assert!(code(Some("bash"), "ls -la").contains("<label value=\"Shell\"/>"));
2100 }
2101
2102 fn plain(text: &str) -> InlineRun {
2103 InlineRun {
2104 text: text.into(),
2105 ..Default::default()
2106 }
2107 }
2108 fn bold(text: &str) -> InlineRun {
2109 InlineRun {
2110 text: text.into(),
2111 bold: true,
2112 ..Default::default()
2113 }
2114 }
2115 fn ig(unwrapped: bool, runs: Vec<InlineRun>) -> String {
2116 let body = export_to_doclang(&[Node::InlineGroup {
2117 unwrapped,
2118 runs,
2119 md_text: String::new(),
2120 }]);
2121 body.trim_start_matches("<doclang version=\"0.7\">\n")
2123 .trim_end_matches("\n</doclang>")
2124 .to_string()
2125 }
2126
2127 #[test]
2128 fn inline_group_matches_reference_layout() {
2129 assert_eq!(
2131 ig(
2132 false,
2133 vec![plain("This is a"), bold("bold"), plain("example")]
2134 ),
2135 " <text>\n This is a\n <bold>bold</bold>\nexample\n </text>"
2136 );
2137 assert_eq!(
2139 ig(
2140 true,
2141 vec![
2142 plain("aa"),
2143 bold("bb"),
2144 plain("cc"),
2145 bold("dd"),
2146 plain("ee")
2147 ]
2148 ),
2149 "aa\n <bold>bb</bold>\ncc\n <bold>dd</bold>\nee"
2150 );
2151 assert_eq!(
2153 ig(false, vec![plain("aa"), plain("bb")]),
2154 " <text>aa\nbb\n</text>"
2155 );
2156 assert_eq!(ig(false, vec![plain("aa")]), " <text>aa\n</text>");
2157 assert_eq!(
2159 ig(false, vec![bold("bb")]),
2160 " <text>\n <bold>bb</bold>\n </text>"
2161 );
2162 }
2163
2164 #[test]
2165 fn nested_styles_wrap_outermost_last_applied() {
2166 let bi = InlineRun {
2167 text: "bi".into(),
2168 bold: true,
2169 italic: true,
2170 ..Default::default()
2171 };
2172 assert_eq!(
2174 ig(true, vec![bi]),
2175 " <italic>\n <bold>bi</bold>\n </italic>"
2176 );
2177 let sub = InlineRun {
2178 text: "2".into(),
2179 script: Script::Sub,
2180 ..Default::default()
2181 };
2182 assert_eq!(ig(true, vec![sub]), " <subscript>2</subscript>");
2183 }
2184
2185 #[test]
2186 fn furniture_heading_gets_layer_head() {
2187 let out = export_to_doclang(&[Node::Furniture {
2188 layer: ContentLayer::Furniture,
2189 inner: Box::new(Node::Heading {
2190 level: 1,
2191 text: "Anchor Links Test".into(),
2192 }),
2193 }]);
2194 assert_eq!(
2195 out,
2196 "<doclang version=\"0.7\">\n <heading>\n <layer value=\"furniture\"/>\n Anchor Links Test\n </heading>\n</doclang>"
2197 );
2198 }
2199
2200 #[test]
2201 fn furniture_checkbox_keeps_its_layer() {
2202 let out = export_to_doclang(&[Node::Furniture {
2204 layer: ContentLayer::Furniture,
2205 inner: Box::new(Node::CheckboxItem {
2206 checked: false,
2207 text: "Main menu".into(),
2208 }),
2209 }]);
2210 assert_eq!(
2211 out,
2212 "<doclang version=\"0.7\">\n <text>\n <layer value=\"furniture\"/>\n <checkbox class=\"unselected\"/>\n Main menu\n </text>\n</doclang>"
2213 );
2214 }
2215
2216 #[test]
2217 fn furniture_text_that_is_one_link_gets_an_href_head() {
2218 let out = export_to_doclang(&[Node::Furniture {
2221 layer: ContentLayer::Furniture,
2222 inner: Box::new(Node::Paragraph {
2223 text: "[Jump to content](#bodyContent)".into(),
2224 }),
2225 }]);
2226 assert_eq!(
2227 out,
2228 "<doclang version=\"0.7\">\n <text>\n <href uri=\"#bodyContent\"/>\n <layer value=\"furniture\"/>\n Jump to content\n </text>\n</doclang>"
2229 );
2230 let mixed = export_to_doclang(&[Node::Furniture {
2232 layer: ContentLayer::Furniture,
2233 inner: Box::new(Node::Paragraph {
2234 text: "see [here](#x) now".into(),
2235 }),
2236 }]);
2237 assert!(
2238 mixed.contains("<layer value=\"furniture\"/>\n see [here](#x) now"),
2239 "{mixed}"
2240 );
2241 }
2242
2243 #[test]
2244 fn link_destinations_balance_their_parentheses() {
2245 let out = export_to_doclang(&[Node::Paragraph {
2249 text: "[Brezhoneg](https://br.wikipedia.org/wiki/Houad_(evn))".into(),
2250 }]);
2251 assert_eq!(
2252 out,
2253 "<doclang version=\"0.7\">\n <text>\n <href uri=\"https://br.wikipedia.org/wiki/Houad_(evn)\"/>\n Brezhoneg\n </text>\n</doclang>"
2254 );
2255 }
2256
2257 #[test]
2258 fn right_to_left_text_is_wrapped_in_rtl() {
2259 let out = export_to_doclang(&[
2262 Node::Paragraph {
2263 text: "العربية".into(),
2264 },
2265 Node::Paragraph {
2266 text: "Aragonés".into(),
2267 },
2268 ]);
2269 assert_eq!(
2270 out,
2271 "<doclang version=\"0.7\">\n <text>\n <rtl>العربية</rtl>\n </text>\n <text>Aragonés</text>\n</doclang>"
2272 );
2273 assert!(is_rtl("עברית"));
2276 assert!(!is_rtl("Hebrew: עב"));
2277 assert!(!is_rtl(""));
2278 assert!(!is_rtl("١٢٣"));
2280 }
2281
2282 #[test]
2283 fn text_dump_reproduces_minidom_per_line_layout() {
2284 let text = "PATN\nWKU 1\nPAL K. \"Determination\"\nfollow-up\n*Note A\n_______________\nNote B*\nEND";
2289 let out = export_to_doclang(&[Node::TextDump(text.into())]);
2290 let expected = "<doclang version=\"0.7\">\n \
2291 <text>\n \
2292 PATN\nWKU 1\n\
2293 <![CDATA[PAL K. \"Determination\"]]> \n\
2294 follow-up\n \
2295 <italic>Note A</italic>\n \
2296 <italic>__________</italic>\n \
2297 <italic>Note B</italic>\n\
2298 END\n \
2299 </text>\n</doclang>";
2300 assert_eq!(out, expected, "got:\n{out}");
2301 }
2302
2303 #[test]
2304 fn code_without_language_stays_inline_and_unlabeled() {
2305 assert_eq!(
2306 code(None, "print(\"Hi!\")"),
2307 "<doclang version=\"0.7\">\n <code><![CDATA[print(\"Hi!\")]]></code>\n</doclang>"
2308 );
2309 assert!(!code(Some("brainfuck"), "+++.").contains("<label"));
2311 }
2312}