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 let mut prev_projected = false;
1516 while *i < nodes.len() {
1517 match &nodes[*i] {
1518 Node::ListItem {
1519 level: l,
1520 text,
1521 marker,
1522 ordered: o,
1523 number,
1524 first_in_list,
1525 location,
1526 dclx,
1527 href,
1528 layer,
1529 } if *l == level => {
1530 let eff_ordered = dclx.as_ref().map_or(*o, |d| d.ordered);
1533 let eff_marker = dclx.as_ref().map_or(marker.as_ref(), |d| d.marker.as_ref());
1534 if *i != start
1538 && (*first_in_list
1539 || eff_ordered != ordered
1540 || (ordered
1541 && !prev_projected
1542 && Some(*number) != prev_number.map(|n| n + 1)))
1543 {
1544 break;
1545 }
1546 prev_number = Some(*number);
1547 prev_projected = eff_ordered && !*o;
1548 let has_nested = {
1554 let mut found = false;
1555 let mut pn = Some(*number);
1556 let mut j = *i + 1;
1557 while let Some(Node::ListItem {
1558 level: nl,
1559 ordered: no,
1560 number: nn,
1561 first_in_list: nf,
1562 dclx: nd,
1563 ..
1564 }) = nodes.get(j)
1565 {
1566 if *nl > level {
1567 found = true;
1568 break;
1569 }
1570 if *nl < level {
1571 break;
1572 }
1573 let n_ordered = nd.as_ref().map_or(*no, |d| d.ordered);
1576 if *nf
1577 || n_ordered != ordered
1578 || (ordered && Some(*nn) != pn.map(|n| n + 1))
1579 {
1580 break;
1581 }
1582 pn = Some(*nn);
1583 j += 1;
1584 }
1585 found
1586 };
1587 match eff_marker {
1590 Some(m) => {
1591 out.push(depth + 1, "<ldiv>".to_string());
1592 out.push(depth + 2, format!("<marker>{}</marker>", escape_text(m)));
1593 out.push(depth + 1, "</ldiv>".to_string());
1594 }
1595 None => out.push(depth + 1, "<ldiv/>".to_string()),
1596 }
1597 if let Some(loc) = location {
1601 push_location(out, depth + 1, loc);
1602 }
1603 match dclx {
1604 Some(d) if !d.runs.is_empty() => {
1609 if has_nested {
1610 out.push(depth + 1, "<text>".to_string());
1611 emit_inline_runs_body(out, depth + 2, &d.runs);
1612 out.push(depth + 1, "</text>".to_string());
1613 } else {
1614 emit_inline_runs_body(out, depth + 1, &d.runs);
1615 }
1616 }
1617 Some(d) => emit_list_item_content(out, depth + 1, &d.text, has_nested),
1620 None => {
1621 let stripped = strip_lone_link(text);
1626 let eff_href = href
1627 .as_deref()
1628 .filter(|_| matches!(stripped, Cow::Owned(_)));
1629 if eff_href.is_some() || layer.is_some() {
1630 let content: &str = if eff_href.is_some() {
1631 stripped.as_ref()
1632 } else {
1633 text.as_str()
1634 };
1635 emit_list_item_with_head(
1636 out,
1637 depth + 1,
1638 content,
1639 has_nested,
1640 eff_href,
1641 *layer,
1642 );
1643 } else {
1644 emit_list_item_content(out, depth + 1, text, has_nested);
1645 }
1646 }
1647 }
1648 *i += 1;
1649 }
1650 Node::ListItem { level: l, .. } if *l > level => {
1651 emit_list(out, depth + 1, nodes, i, *l);
1652 }
1653 Node::Paragraph { text }
1659 if text.is_empty()
1660 && matches!(
1661 nodes.get(*i + 1),
1662 Some(Node::ListItem { level: nl, ordered: no, number: nn,
1663 first_in_list: nf, dclx: nd, .. })
1664 if *nl > level
1665 || (*nl == level
1666 && !*nf
1667 && nd.as_ref().map_or(*no, |d| d.ordered) == ordered
1668 && (!ordered
1669 || Some(*nn) == prev_number.map(|n| n + 1)))
1670 ) =>
1671 {
1672 *i += 1;
1673 }
1674 _ => break,
1675 }
1676 }
1677 out.push(depth, "</list>".to_string());
1678}
1679
1680fn emit_list_item_with_head(
1691 out: &mut Out,
1692 depth: i32,
1693 text: &str,
1694 has_nested: bool,
1695 href: Option<&str>,
1696 layer: Option<ContentLayer>,
1697) {
1698 let head = |out: &mut Out, d: i32| {
1699 if let Some(uri) = href {
1700 out.push(d, format!("<href uri=\"{}\"/>", attr_escape(uri)));
1701 }
1702 if let Some(l) = layer {
1703 out.push(d, format!("<layer value=\"{}\"/>", l.value()));
1704 }
1705 };
1706 if has_nested {
1707 out.push(depth, "<text>".to_string());
1708 head(out, depth + 1);
1709 emit_runs(out, depth + 1, inline_runs(text));
1710 out.push(depth, "</text>".to_string());
1711 } else {
1712 head(out, depth);
1713 emit_runs(out, depth, inline_runs(text));
1714 }
1715}
1716
1717fn emit_list_item_content(out: &mut Out, depth: i32, text: &str, has_nested: bool) {
1718 let runs = inline_runs_from_markdown(text);
1724 let single_plain = runs.len() <= 1 && runs.first().is_none_or(|r| r.is_plain());
1725 if single_plain {
1726 if has_nested {
1727 emit_text_element(out, depth, "text", "text", text, None);
1728 } else if !text.trim().is_empty() {
1729 emit_text_node(out, depth, text);
1733 }
1734 } else if has_nested {
1735 emit_inline_group(out, depth, false, &runs);
1736 } else {
1737 emit_inline_runs_body(out, depth, &runs);
1740 }
1741}
1742
1743fn emit_field_region(out: &mut Out, depth: i32, items: &[FieldItem]) {
1744 out.push(depth, "<field_region>".to_string());
1745 for item in items {
1746 out.push(depth + 1, "<field_item>".to_string());
1747 if let Some(m) = item.marker.as_ref().filter(|s| !s.is_empty()) {
1748 out.push(depth + 2, format!("<marker>{}</marker>", escape_text(m)));
1749 }
1750 if let Some(k) = item.key.as_ref().filter(|s| !s.is_empty()) {
1751 out.push(depth + 2, format!("<key>{}</key>", escape_text(k)));
1752 }
1753 if let Some(v) = item.value.as_ref().filter(|s| !s.is_empty()) {
1754 out.push(depth + 2, format!("<value>{}</value>", escape_text(v)));
1755 }
1756 out.push(depth + 1, "</field_item>".to_string());
1757 }
1758 out.push(depth, "</field_region>".to_string());
1759}
1760
1761#[cfg(test)]
1762mod tests {
1763 use super::*;
1764
1765 #[test]
1766 fn located_heading_emits_location_tokens_in_block_form() {
1767 let doclang = export_to_doclang(&[Node::Located {
1768 location: [44, 170, 340, 386],
1769 inner: Box::new(Node::Heading {
1770 level: 1,
1771 text: "X-Library".into(),
1772 }),
1773 }]);
1774 assert!(
1775 doclang.contains(
1776 "<heading>\n <location value=\"44\"/>\n <location value=\"170\"/>\n \
1777 <location value=\"340\"/>\n <location value=\"386\"/>\n X-Library\n </heading>"
1778 ),
1779 "got:\n{doclang}"
1780 );
1781 }
1782
1783 fn code(language: Option<&str>, text: &str) -> String {
1784 export_to_doclang(&[Node::Code {
1785 language: language.map(String::from),
1786 text: text.into(),
1787 orig: None,
1788 pretty: None,
1789 }])
1790 }
1791
1792 #[test]
1793 fn code_with_language_emits_linguist_label_block_form() {
1794 assert_eq!(
1797 code(Some("python"), "print(\"Hello world!\")"),
1798 "<doclang version=\"0.7\">\n <code>\n <label value=\"Python\"/>\n\
1799 <![CDATA[print(\"Hello world!\")]]> </code>\n</doclang>"
1800 );
1801 assert!(code(Some("bash"), "ls -la").contains("<label value=\"Shell\"/>"));
1803 }
1804
1805 fn plain(text: &str) -> InlineRun {
1806 InlineRun {
1807 text: text.into(),
1808 ..Default::default()
1809 }
1810 }
1811 fn bold(text: &str) -> InlineRun {
1812 InlineRun {
1813 text: text.into(),
1814 bold: true,
1815 ..Default::default()
1816 }
1817 }
1818 fn ig(unwrapped: bool, runs: Vec<InlineRun>) -> String {
1819 let body = export_to_doclang(&[Node::InlineGroup {
1820 unwrapped,
1821 runs,
1822 md_text: String::new(),
1823 }]);
1824 body.trim_start_matches("<doclang version=\"0.7\">\n")
1826 .trim_end_matches("\n</doclang>")
1827 .to_string()
1828 }
1829
1830 #[test]
1831 fn inline_group_matches_reference_layout() {
1832 assert_eq!(
1834 ig(
1835 false,
1836 vec![plain("This is a"), bold("bold"), plain("example")]
1837 ),
1838 " <text>\n This is a\n <bold>bold</bold>\nexample\n </text>"
1839 );
1840 assert_eq!(
1842 ig(
1843 true,
1844 vec![
1845 plain("aa"),
1846 bold("bb"),
1847 plain("cc"),
1848 bold("dd"),
1849 plain("ee")
1850 ]
1851 ),
1852 "aa\n <bold>bb</bold>\ncc\n <bold>dd</bold>\nee"
1853 );
1854 assert_eq!(
1856 ig(false, vec![plain("aa"), plain("bb")]),
1857 " <text>aa\nbb\n</text>"
1858 );
1859 assert_eq!(ig(false, vec![plain("aa")]), " <text>aa\n</text>");
1860 assert_eq!(
1862 ig(false, vec![bold("bb")]),
1863 " <text>\n <bold>bb</bold>\n </text>"
1864 );
1865 }
1866
1867 #[test]
1868 fn nested_styles_wrap_outermost_last_applied() {
1869 let bi = InlineRun {
1870 text: "bi".into(),
1871 bold: true,
1872 italic: true,
1873 ..Default::default()
1874 };
1875 assert_eq!(
1877 ig(true, vec![bi]),
1878 " <italic>\n <bold>bi</bold>\n </italic>"
1879 );
1880 let sub = InlineRun {
1881 text: "2".into(),
1882 script: Script::Sub,
1883 ..Default::default()
1884 };
1885 assert_eq!(ig(true, vec![sub]), " <subscript>2</subscript>");
1886 }
1887
1888 #[test]
1889 fn furniture_heading_gets_layer_head() {
1890 let out = export_to_doclang(&[Node::Furniture {
1891 layer: ContentLayer::Furniture,
1892 inner: Box::new(Node::Heading {
1893 level: 1,
1894 text: "Anchor Links Test".into(),
1895 }),
1896 }]);
1897 assert_eq!(
1898 out,
1899 "<doclang version=\"0.7\">\n <heading>\n <layer value=\"furniture\"/>\n Anchor Links Test\n </heading>\n</doclang>"
1900 );
1901 }
1902
1903 #[test]
1904 fn text_dump_reproduces_minidom_per_line_layout() {
1905 let text = "PATN\nWKU 1\nPAL K. \"Determination\"\nfollow-up\n*Note A\n_______________\nNote B*\nEND";
1910 let out = export_to_doclang(&[Node::TextDump(text.into())]);
1911 let expected = "<doclang version=\"0.7\">\n \
1912 <text>\n \
1913 PATN\nWKU 1\n\
1914 <![CDATA[PAL K. \"Determination\"]]> \n\
1915 follow-up\n \
1916 <italic>Note A</italic>\n \
1917 <italic>__________</italic>\n \
1918 <italic>Note B</italic>\n\
1919 END\n \
1920 </text>\n</doclang>";
1921 assert_eq!(out, expected, "got:\n{out}");
1922 }
1923
1924 #[test]
1925 fn code_without_language_stays_inline_and_unlabeled() {
1926 assert_eq!(
1927 code(None, "print(\"Hi!\")"),
1928 "<doclang version=\"0.7\">\n <code><![CDATA[print(\"Hi!\")]]></code>\n</doclang>"
1929 );
1930 assert!(!code(Some("brainfuck"), "+++.").contains("<label"));
1932 }
1933}