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}
33
34impl Out {
35 fn push(&mut self, depth: i32, s: impl Into<String>) {
36 self.lines.push((depth, s.into(), true));
37 }
38
39 fn push_glue(&mut self, s: impl Into<String>) {
41 self.lines.push((0, s.into(), false));
42 }
43
44 fn finish(self) -> String {
45 let mut s = String::new();
46 for (d, line, nl) in self.lines {
47 for _ in 0..d {
53 s.push_str(INDENT);
54 }
55 s.push_str(&line);
56 if nl {
57 s.push('\n');
58 }
59 }
60 if s.ends_with('\n') {
64 s.pop();
65 }
66 s
67 }
68}
69
70fn unescape_stored(text: &str) -> Cow<'_, str> {
74 if !text.contains('&') && !text.contains('\\') {
75 return Cow::Borrowed(text);
76 }
77 Cow::Owned(
78 text.replace("<", "<")
79 .replace(">", ">")
80 .replace("&", "&")
81 .replace("\\_", "_"),
82 )
83}
84
85fn escape_text(text: &str) -> String {
88 let raw = unescape_stored(text);
89 let text = raw.as_ref();
90 let needs_cdata = text.contains(['"', '\'', '&', '<', '>']);
91 let needs_content = text != text.trim() || text.contains('\n');
92 let mut t = if needs_cdata {
93 format!("<![CDATA[{text}]]>")
94 } else {
95 text.to_string()
96 };
97 if needs_content {
98 t = format!("<content>{t}</content>");
99 }
100 t
101}
102
103enum Run {
105 Plain(String),
106 Bold(String),
107 Italic(String),
108 BoldItalic(String),
109 Code(String),
110 Link {
114 anchor: String,
115 uri: String,
116 },
117}
118
119fn inline_runs(text: &str) -> Vec<Run> {
122 let mut runs = Vec::new();
123 let mut plain = String::new();
124 let chars: Vec<char> = text.chars().collect();
125 let n = chars.len();
126 let mut i = 0;
127 let find = |open: usize, pat: &[char]| -> Option<usize> {
131 chars
132 .get(open..)?
133 .windows(pat.len())
134 .position(|w| w == pat)
135 .map(|p| open + p)
136 };
137 let starts = |at: usize, pat: &[char]| chars[at..].starts_with(pat);
138 while i < n {
139 let take = |runs: &mut Vec<Run>, plain: &mut String, r: Run| {
140 if !plain.is_empty() {
141 runs.push(Run::Plain(std::mem::take(plain)));
142 }
143 runs.push(r);
144 };
145 if starts(i, &['*', '*', '*']) {
146 if let Some(end) = find(i + 3, &['*', '*', '*']) {
147 let inner: String = chars[i + 3..end].iter().collect();
148 take(&mut runs, &mut plain, Run::BoldItalic(inner));
149 i = end + 3;
150 continue;
151 }
152 }
153 if starts(i, &['*', '*']) {
154 if let Some(end) = find(i + 2, &['*', '*']) {
155 let inner: String = chars[i + 2..end].iter().collect();
156 take(&mut runs, &mut plain, Run::Bold(inner));
157 i = end + 2;
158 continue;
159 }
160 }
161 if chars[i] == '*' && !starts(i, &['*', '*']) {
162 if let Some(end) = find(i + 1, &['*']) {
163 let inner: String = chars[i + 1..end].iter().collect();
164 if !inner.is_empty() {
165 take(&mut runs, &mut plain, Run::Italic(inner));
166 i = end + 1;
167 continue;
168 }
169 }
170 }
171 if chars[i] == '`' {
172 if let Some(end) = find(i + 1, &['`']) {
173 let inner: String = chars[i + 1..end].iter().collect();
174 take(&mut runs, &mut plain, Run::Code(inner));
175 i = end + 1;
176 continue;
177 }
178 }
179 if chars[i] == '[' {
180 if let Some(close) = find(i + 1, &[']', '(']) {
181 if let Some(endp) = find(close + 2, &[')']) {
182 let anchor: String = chars[i + 1..close].iter().collect();
183 let uri: String = chars[close + 2..endp].iter().collect();
184 take(&mut runs, &mut plain, Run::Link { anchor, uri });
185 i = endp + 1;
186 continue;
187 }
188 }
189 }
190 plain.push(chars[i]);
191 i += 1;
192 }
193 if !plain.is_empty() {
194 runs.push(Run::Plain(plain));
195 }
196 runs
197}
198
199pub fn inline_runs_from_markdown(text: &str) -> Vec<InlineRun> {
206 let mut out = Vec::new();
207 parse_md_runs(
208 &text.chars().collect::<Vec<_>>(),
209 InlineRun::default(),
210 &mut out,
211 );
212 out
213}
214
215fn flush_md_plain(buf: &mut String, style: &InlineRun, out: &mut Vec<InlineRun>) {
219 let text = std::mem::take(buf);
220 let text = text.trim();
221 if !text.is_empty() {
222 out.push(InlineRun {
223 text: text.to_string(),
224 ..style.clone()
225 });
226 }
227}
228
229fn parse_md_runs(chars: &[char], style: InlineRun, out: &mut Vec<InlineRun>) {
232 let n = chars.len();
233 let mut i = 0;
234 let mut plain = String::new();
235 let find = |open: usize, pat: &[char]| -> Option<usize> {
238 chars
239 .get(open..)?
240 .windows(pat.len())
241 .position(|w| w == pat)
242 .map(|p| open + p)
243 };
244 let starts = |at: usize, pat: &[char]| chars[at..].starts_with(pat);
245 let sub = |a: usize, b: usize| -> Vec<char> { chars[a..b].to_vec() };
246 while i < n {
247 if starts(i, &['*', '*', '*']) {
249 if let Some(end) = find(i + 3, &['*', '*', '*']) {
250 flush_md_plain(&mut plain, &style, out);
251 parse_md_runs(
252 &sub(i + 3, end),
253 InlineRun {
254 bold: true,
255 italic: true,
256 ..style.clone()
257 },
258 out,
259 );
260 i = end + 3;
261 continue;
262 }
263 }
264 if starts(i, &['*', '*']) {
265 if let Some(end) = find(i + 2, &['*', '*']) {
266 flush_md_plain(&mut plain, &style, out);
267 parse_md_runs(
268 &sub(i + 2, end),
269 InlineRun {
270 bold: true,
271 ..style.clone()
272 },
273 out,
274 );
275 i = end + 2;
276 continue;
277 }
278 }
279 if chars[i] == '*' {
280 if let Some(end) = find(i + 1, &['*']) {
281 if end > i + 1 {
282 flush_md_plain(&mut plain, &style, out);
283 parse_md_runs(
284 &sub(i + 1, end),
285 InlineRun {
286 italic: true,
287 ..style.clone()
288 },
289 out,
290 );
291 i = end + 1;
292 continue;
293 }
294 }
295 }
296 if starts(i, &['~', '~']) {
297 if let Some(end) = find(i + 2, &['~', '~']) {
298 flush_md_plain(&mut plain, &style, out);
299 parse_md_runs(
300 &sub(i + 2, end),
301 InlineRun {
302 strike: true,
303 ..style.clone()
304 },
305 out,
306 );
307 i = end + 2;
308 continue;
309 }
310 }
311 if chars[i] == '`' {
312 if let Some(end) = find(i + 1, &['`']) {
313 flush_md_plain(&mut plain, &style, out);
314 let inner: String = sub(i + 1, end).iter().collect();
315 let inner = inner.trim();
316 if !inner.is_empty() {
317 out.push(InlineRun {
318 text: inner.to_string(),
319 code: true,
320 ..style.clone()
321 });
322 }
323 i = end + 1;
324 continue;
325 }
326 }
327 if chars[i] == '[' {
328 if let Some(close) = find(i + 1, &[']', '(']) {
329 if let Some(endp) = find(close + 2, &[')']) {
330 flush_md_plain(&mut plain, &style, out);
331 parse_md_runs(&sub(i + 1, close), style.clone(), out);
333 i = endp + 1;
334 continue;
335 }
336 }
337 }
338 plain.push(chars[i]);
339 i += 1;
340 }
341 flush_md_plain(&mut plain, &style, out);
342}
343
344fn attr_escape(v: &str) -> String {
346 v.replace('&', "&").replace('"', """)
347}
348
349fn emit_text_element(
356 out: &mut Out,
357 depth: i32,
358 tag_open: &str,
359 tag: &str,
360 text: &str,
361 location: Option<&[u16; 4]>,
362) {
363 if let Some(loc) = location {
366 out.push(depth, format!("<{tag_open}>"));
367 push_location(out, depth + 1, loc);
368 if !text.is_empty() {
369 emit_runs(out, depth + 1, inline_runs(text));
370 }
371 out.push(depth, format!("</{tag}>"));
372 return;
373 }
374 if text.is_empty() {
377 out.push(depth, format!("<{tag_open}></{tag}>"));
378 return;
379 }
380 let runs = inline_runs(text);
381 let only_plain = runs.len() == 1 && matches!(runs[0], Run::Plain(_));
382 if runs.len() == 1 {
385 if let Run::Link { anchor, uri } = &runs[0] {
386 out.push(depth, format!("<{tag_open}>"));
387 out.push(depth + 1, format!("<href uri=\"{}\"/>", attr_escape(uri)));
388 if !anchor.trim().is_empty() {
389 emit_runs(out, depth + 1, inline_runs(anchor));
390 }
391 out.push(depth, format!("</{tag}>"));
392 return;
393 }
394 }
395 if only_plain {
396 let body = escape_text(text);
397 if body.starts_with("<content>") {
401 out.push(depth, format!("<{tag_open}>"));
402 out.push(depth + 1, body);
403 out.push(depth, format!("</{tag}>"));
404 } else {
405 out.push(depth, format!("<{tag_open}>{body}</{tag}>"));
406 }
407 return;
408 }
409 out.push(depth, format!("<{tag_open}>"));
410 emit_runs(out, depth + 1, runs);
411 out.push(depth, format!("</{tag}>"));
412}
413
414fn emit_runs(out: &mut Out, depth: i32, runs: Vec<Run>) {
415 for run in runs {
416 match run {
417 Run::Plain(t) => {
418 let t = t.trim_matches('\n');
419 if !t.is_empty() {
420 emit_text_node(out, depth, t);
421 }
422 }
423 Run::Bold(t) => out.push(depth, format!("<bold>{}</bold>", escape_text(&t))),
424 Run::Italic(t) => out.push(depth, format!("<italic>{}</italic>", escape_text(&t))),
425 Run::BoldItalic(t) => {
426 out.push(depth, "<italic>".to_string());
427 out.push(depth + 1, format!("<bold>{}</bold>", escape_text(&t)));
428 out.push(depth, "</italic>".to_string());
429 }
430 Run::Code(t) => out.push(depth, format!("<code>{}</code>", escape_text(&t))),
431 Run::Link { anchor, .. } => {
432 if !anchor.is_empty() {
434 emit_text_node(out, depth, &anchor);
435 }
436 }
437 }
438 }
439}
440
441fn emit_text_node(out: &mut Out, depth: i32, text: &str) {
445 let e = escape_text(text);
446 if e.starts_with("<![CDATA[") {
447 out.push_glue(e);
448 } else {
449 out.push(depth, e);
450 }
451}
452
453fn code_lang_label(lang: &str) -> Option<&'static str> {
458 let lang = crate::json::code_language(Some(lang));
462 Some(match lang {
463 "Bash" => "Shell",
465 "FORTRAN" => "Fortran",
466 "Latex" => "TeX",
467 "Lisp" => "Common Lisp",
468 "Matlab" | "Octave" => "MATLAB",
469 "ObjectiveC" => "Objective-C",
470 "SML" => "Standard ML",
471 "VisualBasic" => "Visual Basic .NET",
472 "DocLang" => "XML",
473 "bc" | "dc" | "Tikz" => "other",
475 "Ada" | "Awk" | "C" | "C#" | "C++" | "CMake" | "COBOL" | "CSS" | "Ceylon" | "Clojure"
477 | "Crystal" | "Cuda" | "Cython" | "D" | "Dart" | "Dockerfile" | "Elixir" | "Erlang"
478 | "Forth" | "Go" | "HTML" | "Haskell" | "Haxe" | "Java" | "JavaScript" | "JSON"
479 | "Julia" | "Kotlin" | "Lua" | "MoonScript" | "Nim" | "OCaml" | "PHP" | "Pascal"
480 | "Perl" | "Prolog" | "Python" | "Racket" | "Ruby" | "Rust" | "SQL" | "Scala"
481 | "Scheme" | "Swift" | "TypeScript" | "XML" | "YAML" => {
482 return Some(IDENTITY_LABELS[IDENTITY_LABELS.iter().position(|&x| x == lang).unwrap()])
483 }
484 _ => return None, })
486}
487
488static IDENTITY_LABELS: &[&str] = &[
491 "Ada",
492 "Awk",
493 "C",
494 "C#",
495 "C++",
496 "CMake",
497 "COBOL",
498 "CSS",
499 "Ceylon",
500 "Clojure",
501 "Crystal",
502 "Cuda",
503 "Cython",
504 "D",
505 "Dart",
506 "Dockerfile",
507 "Elixir",
508 "Erlang",
509 "Forth",
510 "Go",
511 "HTML",
512 "Haskell",
513 "Haxe",
514 "Java",
515 "JavaScript",
516 "JSON",
517 "Julia",
518 "Kotlin",
519 "Lua",
520 "MoonScript",
521 "Nim",
522 "OCaml",
523 "PHP",
524 "Pascal",
525 "Perl",
526 "Prolog",
527 "Python",
528 "Racket",
529 "Ruby",
530 "Rust",
531 "SQL",
532 "Scala",
533 "Scheme",
534 "Swift",
535 "TypeScript",
536 "XML",
537 "YAML",
538];
539
540fn emit_code(
545 out: &mut Out,
546 depth: i32,
547 language: Option<&str>,
548 text: &str,
549 location: Option<&[u16; 4]>,
550) {
551 let label = language.and_then(code_lang_label);
552 let escaped = escape_text(text);
553 let is_content_element = escaped.starts_with("<content>");
554 if let Some(loc) = location {
557 out.push(depth, "<code>".to_string());
558 push_location(out, depth + 1, loc);
559 if let Some(l) = label {
560 out.push(depth + 1, format!("<label value=\"{}\"/>", attr_escape(l)));
561 }
562 if is_content_element {
563 out.push(depth + 1, escaped);
564 } else {
565 out.push_glue(escaped);
566 }
567 out.push(depth, "</code>".to_string());
568 return;
569 }
570 match (label, is_content_element) {
571 (None, false) => out.push(depth, format!("<code>{escaped}</code>")),
572 (None, true) => {
573 out.push(depth, "<code>".to_string());
574 out.push(depth + 1, escaped);
575 out.push(depth, "</code>".to_string());
576 }
577 (Some(l), false) => {
578 out.push(depth, "<code>".to_string());
579 out.push(depth + 1, format!("<label value=\"{}\"/>", attr_escape(l)));
580 out.push_glue(escaped);
582 out.push(depth, "</code>".to_string());
583 }
584 (Some(l), true) => {
585 out.push(depth, "<code>".to_string());
586 out.push(depth + 1, format!("<label value=\"{}\"/>", attr_escape(l)));
587 out.push(depth + 1, escaped);
588 out.push(depth, "</code>".to_string());
589 }
590 }
591}
592
593fn push_location(out: &mut Out, depth: i32, loc: &[u16; 4]) {
596 for v in loc {
597 out.push(depth, format!("<location value=\"{v}\"/>"));
598 }
599}
600
601fn emit_table(out: &mut Out, depth: i32, table: &Table) {
602 out.push(depth, "<table>".to_string());
603 if let Some(cap) = &table.caption {
604 out.push(depth + 1, format!("<caption>{cap}</caption>"));
607 }
608 emit_table_rows(out, depth, table);
609 out.push(depth, "</table>".to_string());
610}
611
612fn emit_chart(
616 out: &mut Out,
617 depth: i32,
618 kind: &str,
619 table: &Table,
620 caption: Option<&str>,
621 location: Option<&[u16; 4]>,
622) {
623 out.pic_index += 1;
624 out.push(depth, "<picture class=\"chart\">".to_string());
625 out.push(
626 depth + 1,
627 format!("<label value=\"{}\"/>", attr_escape(kind)),
628 );
629 if let Some(loc) = location {
630 push_location(out, depth + 1, loc);
631 }
632 if let Some(cap) = caption {
633 out.push(
634 depth + 1,
635 format!("<caption>{}</caption>", escape_text(cap)),
636 );
637 }
638 out.push(depth + 1, "<tabular>".to_string());
639 emit_table_rows(out, depth + 1, table);
640 out.push(depth + 1, "</tabular>".to_string());
641 out.push(depth, "</picture>".to_string());
642}
643
644fn emit_table_rows(out: &mut Out, depth: i32, table: &Table) {
647 if let Some(loc) = &table.location {
650 push_location(out, depth + 1, loc);
651 }
652 for (ri, row) in table.rows.iter().enumerate() {
653 for (ci, cell) in row.iter().enumerate() {
654 let cont = |grid: &Vec<Vec<bool>>| {
658 grid.get(ri)
659 .and_then(|r| r.get(ci))
660 .copied()
661 .unwrap_or(false)
662 };
663 let is_lcel = table
664 .structure
665 .as_ref()
666 .map(|s| cont(&s.col_continuation))
667 .unwrap_or(false);
668 let is_ucel = table
669 .structure
670 .as_ref()
671 .map(|s| cont(&s.row_continuation))
672 .unwrap_or(false);
673 let is_header = match &table.structure {
674 Some(s) if !s.col_header.is_empty() => s
675 .col_header
676 .get(ri)
677 .and_then(|r| r.get(ci))
678 .copied()
679 .unwrap_or(false),
680 Some(s) => s.header_row.get(ri).copied().unwrap_or(false),
681 None => ri == 0,
682 };
683 let is_row_header = table
684 .structure
685 .as_ref()
686 .map(|s| {
687 s.row_header
688 .get(ri)
689 .and_then(|r| r.get(ci))
690 .copied()
691 .unwrap_or(false)
692 })
693 .unwrap_or(false);
694 let tok = if is_lcel && is_ucel {
695 "<xcel/>"
697 } else if is_lcel {
698 "<lcel/>"
699 } else if is_ucel {
700 "<ucel/>"
701 } else if cell.trim().is_empty() {
702 "<ecel/>"
703 } else if is_header {
704 "<ched/>"
705 } else if is_row_header {
706 "<rhed/>"
707 } else {
708 "<fcel/>"
709 };
710 out.push(depth + 1, tok.to_string());
711 if !is_lcel && !is_ucel {
712 let blocks = table
716 .cell_blocks
717 .as_ref()
718 .and_then(|b| b.get(ri))
719 .and_then(|r| r.get(ci))
720 .filter(|b| !b.is_empty());
721 if let Some(blocks) = blocks {
722 let mut bi = 0;
723 emit_nodes(out, depth + 1, blocks, &mut bi, 0);
724 } else if !cell.trim().is_empty() {
725 emit_cell_text(out, depth + 1, cell);
726 }
727 }
728 }
729 out.push(depth + 1, "<nl/>".to_string());
730 }
731}
732
733fn emit_cell_text(out: &mut Out, depth: i32, text: &str) {
735 let runs = inline_runs(text.trim());
736 emit_runs(out, depth, runs);
737}
738
739pub fn export_to_doclang(nodes: &[Node]) -> String {
741 let mut out = Out {
742 lines: Vec::new(),
743 pic_index: 0,
744 };
745 out.push(0, "<doclang version=\"0.7\">".to_string());
746 let mut i = 0usize;
747 emit_nodes(&mut out, 1, nodes, &mut i, 0);
748 out.push(0, "</doclang>".to_string());
749 out.finish()
750}
751
752fn emit_nodes(out: &mut Out, depth: i32, nodes: &[Node], i: &mut usize, level: u8) {
755 while *i < nodes.len() {
756 match &nodes[*i] {
757 Node::Heading { level, text } => {
758 let open = if *level <= 1 {
759 "heading".to_string()
760 } else {
761 format!("heading level=\"{level}\"")
762 };
763 emit_text_element(out, depth, &open, "heading", text, None);
764 *i += 1;
765 }
766 Node::Paragraph { text } => {
767 if let Some(latex) = text
771 .strip_prefix("$$")
772 .and_then(|t| t.strip_suffix("$$"))
773 .filter(|t| !t.is_empty())
774 {
775 out.push(depth, format!("<formula>{}</formula>", escape_text(latex)));
776 } else {
777 emit_text_element(out, depth, "text", "text", text, None);
778 }
779 *i += 1;
780 }
781 Node::CheckboxItem { checked, text } => {
782 let class = if *checked { "selected" } else { "unselected" };
785 out.push(depth, "<text>".to_string());
786 out.push(depth + 1, format!("<checkbox class=\"{class}\"/>"));
787 if !text.is_empty() {
788 out.push(depth + 1, escape_text(text));
789 }
790 out.push(depth, "</text>".to_string());
791 *i += 1;
792 }
793 Node::Code { language, text, .. } => {
794 emit_code(out, depth, language.as_deref(), text, None);
795 *i += 1;
796 }
797 Node::Formula {
800 latex, location, ..
801 } => {
802 if let Some(loc) = location {
803 out.push(depth, "<formula>".to_string());
804 push_location(out, depth + 1, loc);
805 if !latex.is_empty() {
806 out.push(depth + 1, escape_text(latex));
807 }
808 out.push(depth, "</formula>".to_string());
809 } else {
810 out.push(depth, format!("<formula>{}</formula>", escape_text(latex)));
811 }
812 *i += 1;
813 }
814 Node::PageFurniture {
815 footer,
816 location,
817 text,
818 } => {
819 let tag = if *footer {
820 "page_footer"
821 } else {
822 "page_header"
823 };
824 out.push(depth, format!("<{tag}>"));
825 out.push(depth + 1, "<layer value=\"furniture\"/>".to_string());
826 push_location(out, depth + 1, location);
827 if !text.is_empty() {
828 out.push(depth + 1, escape_text(text));
829 }
830 out.push(depth, format!("</{tag}>"));
831 *i += 1;
832 }
833 Node::Table(t) => {
834 emit_table(out, depth, t);
835 *i += 1;
836 }
837 Node::Picture { caption, image, .. } => {
840 emit_picture(out, depth, caption.as_deref(), image.as_ref(), None);
841 *i += 1;
842 }
843 Node::Chart {
844 kind,
845 table,
846 caption,
847 location,
848 } => {
849 emit_chart(
850 out,
851 depth,
852 kind,
853 table,
854 caption.as_deref(),
855 location.as_ref(),
856 );
857 *i += 1;
858 }
859 Node::DoclangOnly(inner) => {
860 let mut j = 0;
861 emit_nodes(out, depth, std::slice::from_ref(inner), &mut j, level);
862 *i += 1;
863 }
864 Node::ListItem { level: l, .. } => {
865 if *l < level {
866 return; }
868 emit_list(out, depth, nodes, i, *l);
869 }
870 Node::Group { children, .. } => {
871 let mut j = 0usize;
872 emit_nodes(out, depth, children, &mut j, 0);
873 *i += 1;
874 }
875 Node::FieldRegion { items } => {
876 emit_field_region(out, depth, items);
877 *i += 1;
878 }
879 Node::InlineGroup {
880 unwrapped, runs, ..
881 } => {
882 emit_inline_group(out, depth, *unwrapped, runs);
883 *i += 1;
884 }
885 Node::Furniture { layer, inner } => {
886 emit_furniture(out, depth, *layer, inner);
887 *i += 1;
888 }
889 Node::Located { location, inner } => {
890 emit_located(out, depth, location, inner);
891 *i += 1;
892 }
893 Node::PageBreak => {
894 out.push(depth, "<page_break/>".to_string());
895 *i += 1;
896 }
897 Node::PageInfo { .. } => {
900 *i += 1;
901 }
902 Node::TextDump(text) => {
903 emit_text_dump(out, depth, text);
904 *i += 1;
905 }
906 }
907 }
908}
909
910enum DumpNode {
913 Text(String),
914 Cdata(String),
915 Elem(String),
916}
917
918fn emit_text_dump(out: &mut Out, depth: i32, text: &str) {
930 let records = dump_records(text);
931 if records.is_empty() {
932 out.push(depth, "<text></text>".to_string());
933 return;
934 }
935 let mut nodes: Vec<DumpNode> = Vec::new();
939 let mut buf = String::new();
940 for (r, (line, italic)) in records.iter().enumerate() {
941 if r > 0 {
942 buf.push('\n'); }
944 let raw = unescape_stored(line);
945 let s = raw.as_ref();
946 let is_cdata = s.contains(['"', '\'', '&', '<', '>']);
947 if *italic || is_cdata {
948 if !buf.is_empty() {
949 nodes.push(DumpNode::Text(std::mem::take(&mut buf)));
950 }
951 let inner = if is_cdata {
952 format!("<![CDATA[{s}]]>")
953 } else {
954 s.to_string()
955 };
956 if *italic {
957 nodes.push(DumpNode::Elem(format!("<italic>{inner}</italic>")));
958 } else {
959 nodes.push(DumpNode::Cdata(inner));
960 }
961 } else {
962 buf.push_str(s);
963 }
964 }
965 if !buf.is_empty() {
966 nodes.push(DumpNode::Text(buf));
967 }
968
969 if let [DumpNode::Text(d)] = nodes.as_slice() {
971 out.push(depth, format!("<text>{d}\n</text>"));
972 return;
973 }
974
975 let ind_child = INDENT.repeat((depth + 1).max(0) as usize);
978 let ind_self = INDENT.repeat(depth.max(0) as usize);
979 let mut raw = String::new();
980 for node in &nodes {
981 match node {
982 DumpNode::Text(d) => {
983 raw.push_str(&ind_child);
984 raw.push_str(d);
985 raw.push('\n');
986 }
987 DumpNode::Cdata(b) => raw.push_str(b),
988 DumpNode::Elem(b) => {
989 raw.push_str(&ind_child);
990 raw.push_str(b);
991 raw.push('\n');
992 }
993 }
994 }
995 let full = format!("{ind_self}<text>\n{raw}{ind_self}</text>");
996 for line in full.split('\n') {
997 if !line.trim().is_empty() {
998 out.push(0, line.to_string());
999 }
1000 }
1001}
1002
1003fn dump_records(text: &str) -> Vec<(String, bool)> {
1008 let chars: Vec<char> = text.chars().collect();
1009 let n = chars.len();
1010
1011 struct Delim {
1012 pos: usize,
1013 length: usize,
1014 rem: usize,
1015 can_open: bool,
1016 can_close: bool,
1017 }
1018 let is_ws = |c: Option<char>| c.is_none_or(|c| c.is_whitespace());
1019 let is_punct =
1020 |c: Option<char>| c.is_some_and(|c| "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~".contains(c));
1021
1022 let mut delims: Vec<Delim> = Vec::new();
1024 let mut i = 0;
1025 while i < n {
1026 if chars[i] == '*' {
1027 let mut j = i;
1028 while j < n && chars[j] == '*' {
1029 j += 1;
1030 }
1031 let prev = (i > 0).then(|| chars[i - 1]);
1032 let next = (j < n).then(|| chars[j]);
1033 let left = !is_ws(next) && (!is_punct(next) || is_ws(prev) || is_punct(prev));
1034 let right = !is_ws(prev) && (!is_punct(prev) || is_ws(next) || is_punct(next));
1035 delims.push(Delim {
1036 pos: i,
1037 length: j - i,
1038 rem: j - i,
1039 can_open: left,
1040 can_close: right,
1041 });
1042 i = j;
1043 } else {
1044 i += 1;
1045 }
1046 }
1047
1048 let mut emph = vec![false; n];
1051 let mut consumed = vec![false; n];
1052 let mut ci = 0;
1053 while ci < delims.len() {
1054 if !(delims[ci].can_close && delims[ci].rem > 0) {
1055 ci += 1;
1056 continue;
1057 }
1058 let mut found: Option<usize> = None;
1059 let mut oi = ci as i64 - 1;
1060 while oi >= 0 {
1061 let o = &delims[oi as usize];
1062 let c = &delims[ci];
1063 if o.can_open && o.rem > 0 {
1064 let odd = (o.can_close || c.can_open)
1067 && (o.length + c.length) % 3 == 0
1068 && !(o.length % 3 == 0 && c.length % 3 == 0);
1069 if !odd {
1070 found = Some(oi as usize);
1071 break;
1072 }
1073 }
1074 oi -= 1;
1075 }
1076 let Some(fi) = found else {
1077 ci += 1;
1078 continue;
1079 };
1080 let use_ = if delims[fi].rem >= 2 && delims[ci].rem >= 2 {
1081 2
1082 } else {
1083 1
1084 };
1085 let oend = delims[fi].pos + delims[fi].rem;
1086 for c in consumed.iter_mut().take(oend).skip(oend - use_) {
1087 *c = true;
1088 }
1089 let cstart = delims[ci].pos + (delims[ci].length - delims[ci].rem);
1090 for c in consumed.iter_mut().take(cstart + use_).skip(cstart) {
1091 *c = true;
1092 }
1093 for e in emph.iter_mut().take(cstart).skip(oend) {
1094 *e = true;
1095 }
1096 delims[fi].rem -= use_;
1097 delims[ci].rem -= use_;
1098 delims.drain((fi + 1)..ci);
1099 ci = if delims[fi].rem == 0 { fi + 1 } else { fi };
1100 }
1101
1102 let mut records: Vec<(String, bool)> = Vec::new();
1104 let mut line = String::new();
1105 let mut line_italic = false;
1106 let push_line = |line: &mut String, italic: &mut bool, out: &mut Vec<(String, bool)>| {
1107 let text = std::mem::take(line);
1108 let ital = std::mem::replace(italic, false);
1109 let trimmed = text.trim();
1110 if trimmed.is_empty() {
1111 return;
1112 }
1113 let norm = if trimmed.len() >= 3 && trimmed.chars().all(|c| c == '_') {
1115 "_".repeat(10)
1116 } else {
1117 text
1118 };
1119 out.push((norm, ital));
1120 };
1121 for k in 0..n {
1122 if consumed[k] {
1123 continue;
1124 }
1125 if chars[k] == '\n' {
1126 push_line(&mut line, &mut line_italic, &mut records);
1127 } else {
1128 line.push(chars[k]);
1129 if emph[k] {
1130 line_italic = true;
1131 }
1132 }
1133 }
1134 push_line(&mut line, &mut line_italic, &mut records);
1135 records
1136}
1137
1138fn emit_inline_group(out: &mut Out, depth: i32, unwrapped: bool, runs: &[InlineRun]) {
1152 let has_styled = runs.iter().any(|r| !r.is_plain());
1153
1154 if unwrapped {
1155 for run in runs {
1156 if run.is_plain() {
1157 out.push(0, escape_text(&run.text));
1158 } else if run.formula {
1159 out.push(
1160 depth,
1161 format!("<formula>{}</formula>", escape_text(&run.text)),
1162 );
1163 } else {
1164 emit_styled(out, depth, &style_tags(run), &escape_text(&run.text));
1165 }
1166 }
1167 return;
1168 }
1169
1170 if !has_styled {
1173 let joined = runs
1174 .iter()
1175 .map(|r| escape_text(&r.text))
1176 .collect::<Vec<_>>()
1177 .join("\n");
1178 out.push(depth, format!("<text>{joined}\n</text>"));
1179 return;
1180 }
1181
1182 out.push(depth, "<text>".to_string());
1183 emit_inline_runs_body(out, depth + 1, runs);
1184 out.push(depth, "</text>".to_string());
1185}
1186
1187fn emit_inline_runs_body(out: &mut Out, depth: i32, runs: &[InlineRun]) {
1193 for (i, run) in runs.iter().enumerate() {
1194 if run.is_plain() {
1195 let e = escape_text(&run.text);
1196 let d = if e.starts_with("<content>") || i == 0 {
1197 depth
1198 } else {
1199 0
1200 };
1201 if e.starts_with("<![CDATA[") && i + 1 == runs.len() && d == 0 {
1202 out.push_glue(e);
1207 out.push(depth, "");
1208 } else {
1209 out.push(d, e);
1210 }
1211 } else if run.formula {
1212 out.push(
1213 depth,
1214 format!("<formula>{}</formula>", escape_text(&run.text)),
1215 );
1216 } else {
1217 emit_styled(out, depth, &style_tags(run), &escape_text(&run.text));
1218 }
1219 }
1220}
1221
1222fn style_tags(run: &InlineRun) -> Vec<&'static str> {
1226 let mut tags = Vec::new();
1227 match run.script {
1228 Script::Sub => tags.push("subscript"),
1229 Script::Super => tags.push("superscript"),
1230 Script::Baseline => {}
1231 }
1232 if run.strike {
1233 tags.push("strikethrough");
1234 }
1235 if run.underline {
1236 tags.push("underline");
1237 }
1238 if run.italic {
1239 tags.push("italic");
1240 }
1241 if run.bold {
1242 tags.push("bold");
1243 }
1244 if run.code {
1245 tags.push("code");
1246 }
1247 tags
1248}
1249
1250fn emit_styled(out: &mut Out, depth: i32, tags: &[&str], inner: &str) {
1255 match tags {
1256 [] => emit_text_node(out, depth, inner),
1257 [tag] => out.push(depth, format!("<{tag}>{inner}</{tag}>")),
1258 [tag, rest @ ..] => {
1259 out.push(depth, format!("<{tag}>"));
1260 emit_styled(out, depth + 1, rest, inner);
1261 out.push(depth, format!("</{tag}>"));
1262 }
1263 }
1264}
1265
1266fn emit_furniture(out: &mut Out, depth: i32, layer: ContentLayer, inner: &Node) {
1271 let token = format!("<layer value=\"{}\"/>", layer.value());
1272 match inner {
1273 Node::Heading { level, text } => {
1274 let open = if *level <= 1 {
1275 "heading".to_string()
1276 } else {
1277 format!("heading level=\"{level}\"")
1278 };
1279 out.push(depth, format!("<{open}>"));
1280 out.push(depth + 1, token);
1281 out.push(depth + 1, escape_text(text));
1282 out.push(depth, "</heading>".to_string());
1283 }
1284 Node::Paragraph { text } => {
1285 out.push(depth, "<text>".to_string());
1286 out.push(depth + 1, token);
1287 out.push(depth + 1, escape_text(text));
1288 out.push(depth, "</text>".to_string());
1289 }
1290 Node::Located { location, inner } => {
1293 if let Node::Paragraph { text } = &**inner {
1294 out.push(depth, "<text>".to_string());
1295 out.push(depth + 1, token);
1296 push_location(out, depth + 1, location);
1297 out.push(depth + 1, escape_text(text));
1298 out.push(depth, "</text>".to_string());
1299 } else {
1300 let mut i = 0usize;
1301 emit_nodes(out, depth, std::slice::from_ref(inner.as_ref()), &mut i, 0);
1302 }
1303 }
1304 Node::InlineGroup { runs, .. } => {
1308 out.push(depth, "<text>".to_string());
1309 for run in runs {
1310 out.push(depth + 1, token.clone());
1311 if run.is_plain() {
1312 out.push(depth + 1, escape_text(&run.text));
1313 } else if run.formula {
1314 out.push(
1315 depth + 1,
1316 format!("<formula>{}</formula>", escape_text(&run.text)),
1317 );
1318 } else {
1319 emit_styled(out, depth + 1, &style_tags(run), &escape_text(&run.text));
1320 }
1321 }
1322 out.push(depth, "</text>".to_string());
1323 }
1324 Node::Picture { caption, image, .. } => {
1330 let caption = caption.as_deref().filter(|c| !c.trim().is_empty());
1331 out.push(depth, "<picture>".to_string());
1332 out.push(depth + 1, token.clone());
1333 if let Some(img) = image {
1334 out.push(
1335 depth + 1,
1336 format!(
1337 "<src uri=\"data:image/png;base64,{}\"/>",
1338 crate::base64::encode(&img.data)
1339 ),
1340 );
1341 }
1342 if let Some(c) = caption {
1343 out.push(depth + 1, "<caption>".to_string());
1344 match inline_runs(c).into_iter().next() {
1345 Some(Run::Link { anchor, uri }) => {
1346 out.push(depth + 2, format!("<href uri=\"{}\"/>", attr_escape(&uri)));
1347 out.push(depth + 2, token);
1348 out.push(depth + 2, escape_text(&anchor));
1349 }
1350 _ => {
1351 out.push(depth + 2, token);
1352 out.push(depth + 2, escape_text(c));
1353 }
1354 }
1355 out.push(depth + 1, "</caption>".to_string());
1356 }
1357 out.push(depth, "</picture>".to_string());
1358 }
1359 Node::Table(table) => {
1362 out.push(depth, "<table>".to_string());
1363 out.push(depth + 1, token);
1364 emit_table_rows(out, depth, table);
1365 out.push(depth, "</table>".to_string());
1366 }
1367 other => {
1368 let mut i = 0usize;
1369 emit_nodes(out, depth, std::slice::from_ref(other), &mut i, 0);
1370 }
1371 }
1372}
1373
1374fn emit_picture(
1377 out: &mut Out,
1378 depth: i32,
1379 caption: Option<&str>,
1380 image: Option<&crate::document::PictureImage>,
1381 location: Option<&[u16; 4]>,
1382) {
1383 let caption = caption.filter(|c| !c.trim().is_empty());
1384 let src = image.map(|img| {
1391 let idx = out.pic_index;
1392 out.pic_index += 1;
1393 format!("assets/image_{idx:06}_{}.png", sha256_hex(&img.data))
1394 });
1395 if location.is_none() && caption.is_none() && src.is_none() {
1396 out.push(depth, "<picture></picture>".to_string());
1397 return;
1398 }
1399 out.push(depth, "<picture>".to_string());
1400 if let Some(loc) = location {
1401 push_location(out, depth + 1, loc);
1402 }
1403 if let Some(s) = src {
1404 out.push(depth + 1, format!("<src uri=\"{}\"/>", attr_escape(&s)));
1405 }
1406 if let Some(c) = caption {
1407 emit_caption(out, depth + 1, c);
1408 }
1409 out.push(depth, "</picture>".to_string());
1410}
1411
1412fn emit_caption(out: &mut Out, depth: i32, text: &str) {
1416 if let Some(Run::Link { anchor, uri }) = inline_runs(text).into_iter().next() {
1417 if inline_runs(text).len() == 1 {
1418 out.push(depth, "<caption>".to_string());
1419 out.push(depth + 1, format!("<href uri=\"{}\"/>", attr_escape(&uri)));
1420 out.push(depth + 1, escape_text(&anchor));
1421 out.push(depth, "</caption>".to_string());
1422 return;
1423 }
1424 }
1425 out.push(depth, format!("<caption>{}</caption>", escape_text(text)));
1426}
1427
1428fn strip_lone_link(text: &str) -> Cow<'_, str> {
1432 if let Some(rest) = text.strip_prefix('[') {
1433 if let Some(close) = rest.find("](") {
1434 if rest.ends_with(')') {
1435 let anchor = &rest[..close];
1436 let uri = &rest[close + 2..rest.len() - 1];
1437 if !anchor.contains(['[', ']']) && !uri.contains(['(', ')']) {
1438 return Cow::Owned(anchor.to_string());
1439 }
1440 }
1441 }
1442 }
1443 Cow::Borrowed(text)
1444}
1445
1446fn sha256_hex(bytes: &[u8]) -> String {
1448 use sha2::{Digest, Sha256};
1449 let mut h = Sha256::new();
1450 h.update(bytes);
1451 h.finalize().iter().map(|b| format!("{b:02x}")).collect()
1452}
1453
1454fn emit_located(out: &mut Out, depth: i32, location: &[u16; 4], inner: &Node) {
1457 match inner {
1458 Node::Heading { level, text } => {
1459 let open = if *level <= 1 {
1460 "heading".to_string()
1461 } else {
1462 format!("heading level=\"{level}\"")
1463 };
1464 emit_text_element(out, depth, &open, "heading", text, Some(location));
1465 }
1466 Node::Paragraph { text } => {
1467 emit_text_element(out, depth, "text", "text", text, Some(location));
1468 }
1469 Node::Picture { caption, image, .. } => {
1470 emit_picture(
1471 out,
1472 depth,
1473 caption.as_deref(),
1474 image.as_ref(),
1475 Some(location),
1476 );
1477 }
1478 Node::Table(t) => {
1479 let mut t = t.clone();
1481 t.location = Some(*location);
1482 emit_table(out, depth, &t);
1483 }
1484 Node::Code { language, text, .. } => {
1485 emit_code(out, depth, language.as_deref(), text, Some(location));
1486 }
1487 other => {
1491 let mut i = 0usize;
1492 emit_nodes(out, depth, std::slice::from_ref(other), &mut i, 0);
1493 }
1494 }
1495}
1496
1497fn emit_list(out: &mut Out, depth: i32, nodes: &[Node], i: &mut usize, level: u8) {
1498 let ordered = match &nodes[*i] {
1501 Node::ListItem { ordered, dclx, .. } => dclx.as_ref().map_or(*ordered, |d| d.ordered),
1502 _ => false,
1503 };
1504 let open = if ordered {
1505 "<list class=\"ordered\">"
1506 } else {
1507 "<list>"
1508 };
1509 out.push(depth, open.to_string());
1510 let start = *i;
1511 let mut prev_number: Option<u64> = None;
1512 while *i < nodes.len() {
1513 match &nodes[*i] {
1514 Node::ListItem {
1515 level: l,
1516 text,
1517 marker,
1518 ordered: o,
1519 number,
1520 first_in_list,
1521 location,
1522 dclx,
1523 href,
1524 layer,
1525 } if *l == level => {
1526 let eff_ordered = dclx.as_ref().map_or(*o, |d| d.ordered);
1529 let eff_marker = dclx.as_ref().map_or(marker.as_ref(), |d| d.marker.as_ref());
1530 if *i != start
1534 && (*first_in_list
1535 || eff_ordered != ordered
1536 || (ordered && Some(*number) != prev_number.map(|n| n + 1)))
1537 {
1538 break;
1539 }
1540 prev_number = Some(*number);
1541 let has_nested = {
1547 let mut found = false;
1548 let mut pn = Some(*number);
1549 let mut j = *i + 1;
1550 while let Some(Node::ListItem {
1551 level: nl,
1552 ordered: no,
1553 number: nn,
1554 first_in_list: nf,
1555 dclx: nd,
1556 ..
1557 }) = nodes.get(j)
1558 {
1559 if *nl > level {
1560 found = true;
1561 break;
1562 }
1563 if *nl < level {
1564 break;
1565 }
1566 let n_ordered = nd.as_ref().map_or(*no, |d| d.ordered);
1569 if *nf
1570 || n_ordered != ordered
1571 || (ordered && Some(*nn) != pn.map(|n| n + 1))
1572 {
1573 break;
1574 }
1575 pn = Some(*nn);
1576 j += 1;
1577 }
1578 found
1579 };
1580 match eff_marker {
1583 Some(m) => {
1584 out.push(depth + 1, "<ldiv>".to_string());
1585 out.push(depth + 2, format!("<marker>{}</marker>", escape_text(m)));
1586 out.push(depth + 1, "</ldiv>".to_string());
1587 }
1588 None => out.push(depth + 1, "<ldiv/>".to_string()),
1589 }
1590 if let Some(loc) = location {
1594 push_location(out, depth + 1, loc);
1595 }
1596 match dclx {
1597 Some(d) if !d.runs.is_empty() => {
1602 if has_nested {
1603 out.push(depth + 1, "<text>".to_string());
1604 emit_inline_runs_body(out, depth + 2, &d.runs);
1605 out.push(depth + 1, "</text>".to_string());
1606 } else {
1607 emit_inline_runs_body(out, depth + 1, &d.runs);
1608 }
1609 }
1610 Some(d) => emit_list_item_content(out, depth + 1, &d.text, has_nested),
1613 None => {
1614 let stripped = strip_lone_link(text);
1619 let eff_href = href
1620 .as_deref()
1621 .filter(|_| matches!(stripped, Cow::Owned(_)));
1622 if eff_href.is_some() || layer.is_some() {
1623 let content: &str = if eff_href.is_some() {
1624 stripped.as_ref()
1625 } else {
1626 text.as_str()
1627 };
1628 emit_list_item_with_head(
1629 out,
1630 depth + 1,
1631 content,
1632 has_nested,
1633 eff_href,
1634 *layer,
1635 );
1636 } else {
1637 emit_list_item_content(out, depth + 1, text, has_nested);
1638 }
1639 }
1640 }
1641 *i += 1;
1642 }
1643 Node::ListItem { level: l, .. } if *l > level => {
1644 emit_list(out, depth + 1, nodes, i, *l);
1645 }
1646 Node::Paragraph { text }
1652 if text.is_empty()
1653 && matches!(
1654 nodes.get(*i + 1),
1655 Some(Node::ListItem { level: nl, ordered: no, number: nn,
1656 first_in_list: nf, dclx: nd, .. })
1657 if *nl > level
1658 || (*nl == level
1659 && !*nf
1660 && nd.as_ref().map_or(*no, |d| d.ordered) == ordered
1661 && (!ordered
1662 || Some(*nn) == prev_number.map(|n| n + 1)))
1663 ) =>
1664 {
1665 *i += 1;
1666 }
1667 _ => break,
1668 }
1669 }
1670 out.push(depth, "</list>".to_string());
1671}
1672
1673fn emit_list_item_with_head(
1684 out: &mut Out,
1685 depth: i32,
1686 text: &str,
1687 has_nested: bool,
1688 href: Option<&str>,
1689 layer: Option<ContentLayer>,
1690) {
1691 let head = |out: &mut Out, d: i32| {
1692 if let Some(uri) = href {
1693 out.push(d, format!("<href uri=\"{}\"/>", attr_escape(uri)));
1694 }
1695 if let Some(l) = layer {
1696 out.push(d, format!("<layer value=\"{}\"/>", l.value()));
1697 }
1698 };
1699 if has_nested {
1700 out.push(depth, "<text>".to_string());
1701 head(out, depth + 1);
1702 emit_runs(out, depth + 1, inline_runs(text));
1703 out.push(depth, "</text>".to_string());
1704 } else {
1705 head(out, depth);
1706 emit_runs(out, depth, inline_runs(text));
1707 }
1708}
1709
1710fn emit_list_item_content(out: &mut Out, depth: i32, text: &str, has_nested: bool) {
1711 let runs = inline_runs_from_markdown(text);
1717 let single_plain = runs.len() <= 1 && runs.first().is_none_or(|r| r.is_plain());
1718 if single_plain {
1719 if has_nested {
1720 emit_text_element(out, depth, "text", "text", text, None);
1721 } else if !text.trim().is_empty() {
1722 emit_text_node(out, depth, text);
1726 }
1727 } else if has_nested {
1728 emit_inline_group(out, depth, false, &runs);
1729 } else {
1730 emit_inline_runs_body(out, depth, &runs);
1733 }
1734}
1735
1736fn emit_field_region(out: &mut Out, depth: i32, items: &[FieldItem]) {
1737 out.push(depth, "<field_region>".to_string());
1738 for item in items {
1739 out.push(depth + 1, "<field_item>".to_string());
1740 if let Some(m) = item.marker.as_ref().filter(|s| !s.is_empty()) {
1741 out.push(depth + 2, format!("<marker>{}</marker>", escape_text(m)));
1742 }
1743 if let Some(k) = item.key.as_ref().filter(|s| !s.is_empty()) {
1744 out.push(depth + 2, format!("<key>{}</key>", escape_text(k)));
1745 }
1746 if let Some(v) = item.value.as_ref().filter(|s| !s.is_empty()) {
1747 out.push(depth + 2, format!("<value>{}</value>", escape_text(v)));
1748 }
1749 out.push(depth + 1, "</field_item>".to_string());
1750 }
1751 out.push(depth, "</field_region>".to_string());
1752}
1753
1754#[cfg(test)]
1755mod tests {
1756 use super::*;
1757
1758 #[test]
1759 fn located_heading_emits_location_tokens_in_block_form() {
1760 let doclang = export_to_doclang(&[Node::Located {
1761 location: [44, 170, 340, 386],
1762 inner: Box::new(Node::Heading {
1763 level: 1,
1764 text: "X-Library".into(),
1765 }),
1766 }]);
1767 assert!(
1768 doclang.contains(
1769 "<heading>\n <location value=\"44\"/>\n <location value=\"170\"/>\n \
1770 <location value=\"340\"/>\n <location value=\"386\"/>\n X-Library\n </heading>"
1771 ),
1772 "got:\n{doclang}"
1773 );
1774 }
1775
1776 fn code(language: Option<&str>, text: &str) -> String {
1777 export_to_doclang(&[Node::Code {
1778 language: language.map(String::from),
1779 text: text.into(),
1780 orig: None,
1781 pretty: None,
1782 }])
1783 }
1784
1785 #[test]
1786 fn code_with_language_emits_linguist_label_block_form() {
1787 assert_eq!(
1790 code(Some("python"), "print(\"Hello world!\")"),
1791 "<doclang version=\"0.7\">\n <code>\n <label value=\"Python\"/>\n\
1792 <![CDATA[print(\"Hello world!\")]]> </code>\n</doclang>"
1793 );
1794 assert!(code(Some("bash"), "ls -la").contains("<label value=\"Shell\"/>"));
1796 }
1797
1798 fn plain(text: &str) -> InlineRun {
1799 InlineRun {
1800 text: text.into(),
1801 ..Default::default()
1802 }
1803 }
1804 fn bold(text: &str) -> InlineRun {
1805 InlineRun {
1806 text: text.into(),
1807 bold: true,
1808 ..Default::default()
1809 }
1810 }
1811 fn ig(unwrapped: bool, runs: Vec<InlineRun>) -> String {
1812 let body = export_to_doclang(&[Node::InlineGroup {
1813 unwrapped,
1814 runs,
1815 md_text: String::new(),
1816 }]);
1817 body.trim_start_matches("<doclang version=\"0.7\">\n")
1819 .trim_end_matches("\n</doclang>")
1820 .to_string()
1821 }
1822
1823 #[test]
1824 fn inline_group_matches_reference_layout() {
1825 assert_eq!(
1827 ig(
1828 false,
1829 vec![plain("This is a"), bold("bold"), plain("example")]
1830 ),
1831 " <text>\n This is a\n <bold>bold</bold>\nexample\n </text>"
1832 );
1833 assert_eq!(
1835 ig(
1836 true,
1837 vec![
1838 plain("aa"),
1839 bold("bb"),
1840 plain("cc"),
1841 bold("dd"),
1842 plain("ee")
1843 ]
1844 ),
1845 "aa\n <bold>bb</bold>\ncc\n <bold>dd</bold>\nee"
1846 );
1847 assert_eq!(
1849 ig(false, vec![plain("aa"), plain("bb")]),
1850 " <text>aa\nbb\n</text>"
1851 );
1852 assert_eq!(ig(false, vec![plain("aa")]), " <text>aa\n</text>");
1853 assert_eq!(
1855 ig(false, vec![bold("bb")]),
1856 " <text>\n <bold>bb</bold>\n </text>"
1857 );
1858 }
1859
1860 #[test]
1861 fn nested_styles_wrap_outermost_last_applied() {
1862 let bi = InlineRun {
1863 text: "bi".into(),
1864 bold: true,
1865 italic: true,
1866 ..Default::default()
1867 };
1868 assert_eq!(
1870 ig(true, vec![bi]),
1871 " <italic>\n <bold>bi</bold>\n </italic>"
1872 );
1873 let sub = InlineRun {
1874 text: "2".into(),
1875 script: Script::Sub,
1876 ..Default::default()
1877 };
1878 assert_eq!(ig(true, vec![sub]), " <subscript>2</subscript>");
1879 }
1880
1881 #[test]
1882 fn furniture_heading_gets_layer_head() {
1883 let out = export_to_doclang(&[Node::Furniture {
1884 layer: ContentLayer::Furniture,
1885 inner: Box::new(Node::Heading {
1886 level: 1,
1887 text: "Anchor Links Test".into(),
1888 }),
1889 }]);
1890 assert_eq!(
1891 out,
1892 "<doclang version=\"0.7\">\n <heading>\n <layer value=\"furniture\"/>\n Anchor Links Test\n </heading>\n</doclang>"
1893 );
1894 }
1895
1896 #[test]
1897 fn text_dump_reproduces_minidom_per_line_layout() {
1898 let text = "PATN\nWKU 1\nPAL K. \"Determination\"\nfollow-up\n*Note A\n_______________\nNote B*\nEND";
1903 let out = export_to_doclang(&[Node::TextDump(text.into())]);
1904 let expected = "<doclang version=\"0.7\">\n \
1905 <text>\n \
1906 PATN\nWKU 1\n\
1907 <![CDATA[PAL K. \"Determination\"]]> \n\
1908 follow-up\n \
1909 <italic>Note A</italic>\n \
1910 <italic>__________</italic>\n \
1911 <italic>Note B</italic>\n\
1912 END\n \
1913 </text>\n</doclang>";
1914 assert_eq!(out, expected, "got:\n{out}");
1915 }
1916
1917 #[test]
1918 fn code_without_language_stays_inline_and_unlabeled() {
1919 assert_eq!(
1920 code(None, "print(\"Hi!\")"),
1921 "<doclang version=\"0.7\">\n <code><![CDATA[print(\"Hi!\")]]></code>\n</doclang>"
1922 );
1923 assert!(!code(Some("brainfuck"), "+++.").contains("<label"));
1925 }
1926}