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 bytes: Vec<char> = text.chars().collect();
125 let n = bytes.len();
126 let mut i = 0;
127 let find = |open: usize, pat: &str| -> Option<usize> {
128 let hay: String = bytes[open..].iter().collect();
129 hay.find(pat).map(|p| open + hay[..p].chars().count())
130 };
131 while i < n {
132 let rest: String = bytes[i..].iter().collect();
133 let take = |runs: &mut Vec<Run>, plain: &mut String, r: Run| {
134 if !plain.is_empty() {
135 runs.push(Run::Plain(std::mem::take(plain)));
136 }
137 runs.push(r);
138 };
139 if rest.starts_with("***") {
140 if let Some(end) = find(i + 3, "***") {
141 let inner: String = bytes[i + 3..end].iter().collect();
142 take(&mut runs, &mut plain, Run::BoldItalic(inner));
143 i = end + 3;
144 continue;
145 }
146 }
147 if rest.starts_with("**") {
148 if let Some(end) = find(i + 2, "**") {
149 let inner: String = bytes[i + 2..end].iter().collect();
150 take(&mut runs, &mut plain, Run::Bold(inner));
151 i = end + 2;
152 continue;
153 }
154 }
155 if rest.starts_with('*') && !rest.starts_with("**") {
156 if let Some(end) = find(i + 1, "*") {
157 let inner: String = bytes[i + 1..end].iter().collect();
158 if !inner.is_empty() {
159 take(&mut runs, &mut plain, Run::Italic(inner));
160 i = end + 1;
161 continue;
162 }
163 }
164 }
165 if rest.starts_with('`') {
166 if let Some(end) = find(i + 1, "`") {
167 let inner: String = bytes[i + 1..end].iter().collect();
168 take(&mut runs, &mut plain, Run::Code(inner));
169 i = end + 1;
170 continue;
171 }
172 }
173 if rest.starts_with('[') {
174 if let (Some(close), true) = (find(i + 1, "]("), true) {
175 if let Some(endp) = find(close + 2, ")") {
176 let anchor: String = bytes[i + 1..close].iter().collect();
177 let uri: String = bytes[close + 2..endp].iter().collect();
178 take(&mut runs, &mut plain, Run::Link { anchor, uri });
179 i = endp + 1;
180 continue;
181 }
182 }
183 }
184 plain.push(bytes[i]);
185 i += 1;
186 }
187 if !plain.is_empty() {
188 runs.push(Run::Plain(plain));
189 }
190 runs
191}
192
193pub fn inline_runs_from_markdown(text: &str) -> Vec<InlineRun> {
200 let mut out = Vec::new();
201 parse_md_runs(
202 &text.chars().collect::<Vec<_>>(),
203 InlineRun::default(),
204 &mut out,
205 );
206 out
207}
208
209fn flush_md_plain(buf: &mut String, style: &InlineRun, out: &mut Vec<InlineRun>) {
213 let text = std::mem::take(buf);
214 let text = text.trim();
215 if !text.is_empty() {
216 out.push(InlineRun {
217 text: text.to_string(),
218 ..style.clone()
219 });
220 }
221}
222
223fn parse_md_runs(chars: &[char], style: InlineRun, out: &mut Vec<InlineRun>) {
226 let n = chars.len();
227 let mut i = 0;
228 let mut plain = String::new();
229 let find = |open: usize, pat: &str| -> Option<usize> {
230 let hay: String = chars[open..].iter().collect();
231 hay.find(pat).map(|p| open + hay[..p].chars().count())
232 };
233 let sub = |a: usize, b: usize| -> Vec<char> { chars[a..b].to_vec() };
234 while i < n {
235 let rest: String = chars[i..].iter().collect();
236 if rest.starts_with("***") {
238 if let Some(end) = find(i + 3, "***") {
239 flush_md_plain(&mut plain, &style, out);
240 parse_md_runs(
241 &sub(i + 3, end),
242 InlineRun {
243 bold: true,
244 italic: true,
245 ..style.clone()
246 },
247 out,
248 );
249 i = end + 3;
250 continue;
251 }
252 }
253 if rest.starts_with("**") {
254 if let Some(end) = find(i + 2, "**") {
255 flush_md_plain(&mut plain, &style, out);
256 parse_md_runs(
257 &sub(i + 2, end),
258 InlineRun {
259 bold: true,
260 ..style.clone()
261 },
262 out,
263 );
264 i = end + 2;
265 continue;
266 }
267 }
268 if rest.starts_with('*') {
269 if let Some(end) = find(i + 1, "*") {
270 if end > i + 1 {
271 flush_md_plain(&mut plain, &style, out);
272 parse_md_runs(
273 &sub(i + 1, end),
274 InlineRun {
275 italic: true,
276 ..style.clone()
277 },
278 out,
279 );
280 i = end + 1;
281 continue;
282 }
283 }
284 }
285 if rest.starts_with("~~") {
286 if let Some(end) = find(i + 2, "~~") {
287 flush_md_plain(&mut plain, &style, out);
288 parse_md_runs(
289 &sub(i + 2, end),
290 InlineRun {
291 strike: true,
292 ..style.clone()
293 },
294 out,
295 );
296 i = end + 2;
297 continue;
298 }
299 }
300 if rest.starts_with('`') {
301 if let Some(end) = find(i + 1, "`") {
302 flush_md_plain(&mut plain, &style, out);
303 let inner: String = sub(i + 1, end).iter().collect();
304 let inner = inner.trim();
305 if !inner.is_empty() {
306 out.push(InlineRun {
307 text: inner.to_string(),
308 code: true,
309 ..style.clone()
310 });
311 }
312 i = end + 1;
313 continue;
314 }
315 }
316 if rest.starts_with('[') {
317 if let Some(close) = find(i + 1, "](") {
318 if let Some(endp) = find(close + 2, ")") {
319 flush_md_plain(&mut plain, &style, out);
320 parse_md_runs(&sub(i + 1, close), style.clone(), out);
322 i = endp + 1;
323 continue;
324 }
325 }
326 }
327 plain.push(chars[i]);
328 i += 1;
329 }
330 flush_md_plain(&mut plain, &style, out);
331}
332
333fn attr_escape(v: &str) -> String {
335 v.replace('&', "&").replace('"', """)
336}
337
338fn emit_text_element(
345 out: &mut Out,
346 depth: i32,
347 tag_open: &str,
348 tag: &str,
349 text: &str,
350 location: Option<&[u16; 4]>,
351) {
352 if let Some(loc) = location {
355 out.push(depth, format!("<{tag_open}>"));
356 push_location(out, depth + 1, loc);
357 if !text.is_empty() {
358 emit_runs(out, depth + 1, inline_runs(text));
359 }
360 out.push(depth, format!("</{tag}>"));
361 return;
362 }
363 if text.is_empty() {
366 out.push(depth, format!("<{tag_open}></{tag}>"));
367 return;
368 }
369 let runs = inline_runs(text);
370 let only_plain = runs.len() == 1 && matches!(runs[0], Run::Plain(_));
371 if runs.len() == 1 {
374 if let Run::Link { anchor, uri } = &runs[0] {
375 out.push(depth, format!("<{tag_open}>"));
376 out.push(depth + 1, format!("<href uri=\"{}\"/>", attr_escape(uri)));
377 if !anchor.trim().is_empty() {
378 emit_runs(out, depth + 1, inline_runs(anchor));
379 }
380 out.push(depth, format!("</{tag}>"));
381 return;
382 }
383 }
384 if only_plain {
385 let body = escape_text(text);
386 if body.starts_with("<content>") {
390 out.push(depth, format!("<{tag_open}>"));
391 out.push(depth + 1, body);
392 out.push(depth, format!("</{tag}>"));
393 } else {
394 out.push(depth, format!("<{tag_open}>{body}</{tag}>"));
395 }
396 return;
397 }
398 out.push(depth, format!("<{tag_open}>"));
399 emit_runs(out, depth + 1, runs);
400 out.push(depth, format!("</{tag}>"));
401}
402
403fn emit_runs(out: &mut Out, depth: i32, runs: Vec<Run>) {
404 for run in runs {
405 match run {
406 Run::Plain(t) => {
407 let t = t.trim_matches('\n');
408 if !t.is_empty() {
409 emit_text_node(out, depth, t);
410 }
411 }
412 Run::Bold(t) => out.push(depth, format!("<bold>{}</bold>", escape_text(&t))),
413 Run::Italic(t) => out.push(depth, format!("<italic>{}</italic>", escape_text(&t))),
414 Run::BoldItalic(t) => {
415 out.push(depth, "<italic>".to_string());
416 out.push(depth + 1, format!("<bold>{}</bold>", escape_text(&t)));
417 out.push(depth, "</italic>".to_string());
418 }
419 Run::Code(t) => out.push(depth, format!("<code>{}</code>", escape_text(&t))),
420 Run::Link { anchor, .. } => {
421 if !anchor.is_empty() {
423 emit_text_node(out, depth, &anchor);
424 }
425 }
426 }
427 }
428}
429
430fn emit_text_node(out: &mut Out, depth: i32, text: &str) {
434 let e = escape_text(text);
435 if e.starts_with("<![CDATA[") {
436 out.push_glue(e);
437 } else {
438 out.push(depth, e);
439 }
440}
441
442fn code_lang_label(lang: &str) -> Option<&'static str> {
447 let lang = crate::json::code_language(Some(lang));
451 Some(match lang {
452 "Bash" => "Shell",
454 "FORTRAN" => "Fortran",
455 "Latex" => "TeX",
456 "Lisp" => "Common Lisp",
457 "Matlab" | "Octave" => "MATLAB",
458 "ObjectiveC" => "Objective-C",
459 "SML" => "Standard ML",
460 "VisualBasic" => "Visual Basic .NET",
461 "DocLang" => "XML",
462 "bc" | "dc" | "Tikz" => "other",
464 "Ada" | "Awk" | "C" | "C#" | "C++" | "CMake" | "COBOL" | "CSS" | "Ceylon" | "Clojure"
466 | "Crystal" | "Cuda" | "Cython" | "D" | "Dart" | "Dockerfile" | "Elixir" | "Erlang"
467 | "Forth" | "Go" | "HTML" | "Haskell" | "Haxe" | "Java" | "JavaScript" | "JSON"
468 | "Julia" | "Kotlin" | "Lua" | "MoonScript" | "Nim" | "OCaml" | "PHP" | "Pascal"
469 | "Perl" | "Prolog" | "Python" | "Racket" | "Ruby" | "Rust" | "SQL" | "Scala"
470 | "Scheme" | "Swift" | "TypeScript" | "XML" | "YAML" => {
471 return Some(IDENTITY_LABELS[IDENTITY_LABELS.iter().position(|&x| x == lang).unwrap()])
472 }
473 _ => return None, })
475}
476
477static IDENTITY_LABELS: &[&str] = &[
480 "Ada",
481 "Awk",
482 "C",
483 "C#",
484 "C++",
485 "CMake",
486 "COBOL",
487 "CSS",
488 "Ceylon",
489 "Clojure",
490 "Crystal",
491 "Cuda",
492 "Cython",
493 "D",
494 "Dart",
495 "Dockerfile",
496 "Elixir",
497 "Erlang",
498 "Forth",
499 "Go",
500 "HTML",
501 "Haskell",
502 "Haxe",
503 "Java",
504 "JavaScript",
505 "JSON",
506 "Julia",
507 "Kotlin",
508 "Lua",
509 "MoonScript",
510 "Nim",
511 "OCaml",
512 "PHP",
513 "Pascal",
514 "Perl",
515 "Prolog",
516 "Python",
517 "Racket",
518 "Ruby",
519 "Rust",
520 "SQL",
521 "Scala",
522 "Scheme",
523 "Swift",
524 "TypeScript",
525 "XML",
526 "YAML",
527];
528
529fn emit_code(
534 out: &mut Out,
535 depth: i32,
536 language: Option<&str>,
537 text: &str,
538 location: Option<&[u16; 4]>,
539) {
540 let label = language.and_then(code_lang_label);
541 let escaped = escape_text(text);
542 let is_content_element = escaped.starts_with("<content>");
543 if let Some(loc) = location {
546 out.push(depth, "<code>".to_string());
547 push_location(out, depth + 1, loc);
548 if let Some(l) = label {
549 out.push(depth + 1, format!("<label value=\"{}\"/>", attr_escape(l)));
550 }
551 if is_content_element {
552 out.push(depth + 1, escaped);
553 } else {
554 out.push_glue(escaped);
555 }
556 out.push(depth, "</code>".to_string());
557 return;
558 }
559 match (label, is_content_element) {
560 (None, false) => out.push(depth, format!("<code>{escaped}</code>")),
561 (None, true) => {
562 out.push(depth, "<code>".to_string());
563 out.push(depth + 1, escaped);
564 out.push(depth, "</code>".to_string());
565 }
566 (Some(l), false) => {
567 out.push(depth, "<code>".to_string());
568 out.push(depth + 1, format!("<label value=\"{}\"/>", attr_escape(l)));
569 out.push_glue(escaped);
571 out.push(depth, "</code>".to_string());
572 }
573 (Some(l), true) => {
574 out.push(depth, "<code>".to_string());
575 out.push(depth + 1, format!("<label value=\"{}\"/>", attr_escape(l)));
576 out.push(depth + 1, escaped);
577 out.push(depth, "</code>".to_string());
578 }
579 }
580}
581
582fn push_location(out: &mut Out, depth: i32, loc: &[u16; 4]) {
585 for v in loc {
586 out.push(depth, format!("<location value=\"{v}\"/>"));
587 }
588}
589
590fn emit_table(out: &mut Out, depth: i32, table: &Table) {
591 out.push(depth, "<table>".to_string());
592 emit_table_rows(out, depth, table);
593 out.push(depth, "</table>".to_string());
594}
595
596fn emit_chart(
600 out: &mut Out,
601 depth: i32,
602 kind: &str,
603 table: &Table,
604 caption: Option<&str>,
605 location: Option<&[u16; 4]>,
606) {
607 out.pic_index += 1;
608 out.push(depth, "<picture class=\"chart\">".to_string());
609 out.push(
610 depth + 1,
611 format!("<label value=\"{}\"/>", attr_escape(kind)),
612 );
613 if let Some(loc) = location {
614 push_location(out, depth + 1, loc);
615 }
616 if let Some(cap) = caption {
617 out.push(
618 depth + 1,
619 format!("<caption>{}</caption>", escape_text(cap)),
620 );
621 }
622 out.push(depth + 1, "<tabular>".to_string());
623 emit_table_rows(out, depth + 1, table);
624 out.push(depth + 1, "</tabular>".to_string());
625 out.push(depth, "</picture>".to_string());
626}
627
628fn emit_table_rows(out: &mut Out, depth: i32, table: &Table) {
631 if let Some(loc) = &table.location {
634 push_location(out, depth + 1, loc);
635 }
636 for (ri, row) in table.rows.iter().enumerate() {
637 for (ci, cell) in row.iter().enumerate() {
638 let cont = |grid: &Vec<Vec<bool>>| {
642 grid.get(ri)
643 .and_then(|r| r.get(ci))
644 .copied()
645 .unwrap_or(false)
646 };
647 let is_lcel = table
648 .structure
649 .as_ref()
650 .map(|s| cont(&s.col_continuation))
651 .unwrap_or(false);
652 let is_ucel = table
653 .structure
654 .as_ref()
655 .map(|s| cont(&s.row_continuation))
656 .unwrap_or(false);
657 let is_header = match &table.structure {
658 Some(s) if !s.col_header.is_empty() => s
659 .col_header
660 .get(ri)
661 .and_then(|r| r.get(ci))
662 .copied()
663 .unwrap_or(false),
664 Some(s) => s.header_row.get(ri).copied().unwrap_or(false),
665 None => ri == 0,
666 };
667 let is_row_header = table
668 .structure
669 .as_ref()
670 .map(|s| {
671 s.row_header
672 .get(ri)
673 .and_then(|r| r.get(ci))
674 .copied()
675 .unwrap_or(false)
676 })
677 .unwrap_or(false);
678 let tok = if is_lcel && is_ucel {
679 "<xcel/>"
681 } else if is_lcel {
682 "<lcel/>"
683 } else if is_ucel {
684 "<ucel/>"
685 } else if cell.trim().is_empty() {
686 "<ecel/>"
687 } else if is_header {
688 "<ched/>"
689 } else if is_row_header {
690 "<rhed/>"
691 } else {
692 "<fcel/>"
693 };
694 out.push(depth + 1, tok.to_string());
695 if !is_lcel && !is_ucel {
696 let blocks = table
700 .cell_blocks
701 .as_ref()
702 .and_then(|b| b.get(ri))
703 .and_then(|r| r.get(ci))
704 .filter(|b| !b.is_empty());
705 if let Some(blocks) = blocks {
706 let mut bi = 0;
707 emit_nodes(out, depth + 1, blocks, &mut bi, 0);
708 } else if !cell.trim().is_empty() {
709 emit_cell_text(out, depth + 1, cell);
710 }
711 }
712 }
713 out.push(depth + 1, "<nl/>".to_string());
714 }
715}
716
717fn emit_cell_text(out: &mut Out, depth: i32, text: &str) {
719 let runs = inline_runs(text.trim());
720 emit_runs(out, depth, runs);
721}
722
723pub fn export_to_doclang(nodes: &[Node]) -> String {
725 let mut out = Out {
726 lines: Vec::new(),
727 pic_index: 0,
728 };
729 out.push(0, "<doclang version=\"0.7\">".to_string());
730 let mut i = 0usize;
731 emit_nodes(&mut out, 1, nodes, &mut i, 0);
732 out.push(0, "</doclang>".to_string());
733 out.finish()
734}
735
736fn emit_nodes(out: &mut Out, depth: i32, nodes: &[Node], i: &mut usize, level: u8) {
739 while *i < nodes.len() {
740 match &nodes[*i] {
741 Node::Heading { level, text } => {
742 let open = if *level <= 1 {
743 "heading".to_string()
744 } else {
745 format!("heading level=\"{level}\"")
746 };
747 emit_text_element(out, depth, &open, "heading", text, None);
748 *i += 1;
749 }
750 Node::Paragraph { text } => {
751 if let Some(latex) = text
755 .strip_prefix("$$")
756 .and_then(|t| t.strip_suffix("$$"))
757 .filter(|t| !t.is_empty())
758 {
759 out.push(depth, format!("<formula>{}</formula>", escape_text(latex)));
760 } else {
761 emit_text_element(out, depth, "text", "text", text, None);
762 }
763 *i += 1;
764 }
765 Node::CheckboxItem { checked, text } => {
766 let class = if *checked { "selected" } else { "unselected" };
769 out.push(depth, "<text>".to_string());
770 out.push(depth + 1, format!("<checkbox class=\"{class}\"/>"));
771 if !text.is_empty() {
772 out.push(depth + 1, escape_text(text));
773 }
774 out.push(depth, "</text>".to_string());
775 *i += 1;
776 }
777 Node::Code {
778 language,
779 text,
780 orig: _,
781 } => {
782 emit_code(out, depth, language.as_deref(), text, None);
783 *i += 1;
784 }
785 Node::Formula {
788 latex, location, ..
789 } => {
790 if let Some(loc) = location {
791 out.push(depth, "<formula>".to_string());
792 push_location(out, depth + 1, loc);
793 if !latex.is_empty() {
794 out.push(depth + 1, escape_text(latex));
795 }
796 out.push(depth, "</formula>".to_string());
797 } else {
798 out.push(depth, format!("<formula>{}</formula>", escape_text(latex)));
799 }
800 *i += 1;
801 }
802 Node::PageFurniture {
803 footer,
804 location,
805 text,
806 } => {
807 let tag = if *footer {
808 "page_footer"
809 } else {
810 "page_header"
811 };
812 out.push(depth, format!("<{tag}>"));
813 out.push(depth + 1, "<layer value=\"furniture\"/>".to_string());
814 push_location(out, depth + 1, location);
815 if !text.is_empty() {
816 out.push(depth + 1, escape_text(text));
817 }
818 out.push(depth, format!("</{tag}>"));
819 *i += 1;
820 }
821 Node::Table(t) => {
822 emit_table(out, depth, t);
823 *i += 1;
824 }
825 Node::Picture { caption, image, .. } => {
828 emit_picture(out, depth, caption.as_deref(), image.as_ref(), None);
829 *i += 1;
830 }
831 Node::Chart {
832 kind,
833 table,
834 caption,
835 location,
836 } => {
837 emit_chart(
838 out,
839 depth,
840 kind,
841 table,
842 caption.as_deref(),
843 location.as_ref(),
844 );
845 *i += 1;
846 }
847 Node::DoclangOnly(inner) => {
848 let mut j = 0;
849 emit_nodes(out, depth, std::slice::from_ref(inner), &mut j, level);
850 *i += 1;
851 }
852 Node::ListItem { level: l, .. } => {
853 if *l < level {
854 return; }
856 emit_list(out, depth, nodes, i, *l);
857 }
858 Node::Group { children, .. } => {
859 let mut j = 0usize;
860 emit_nodes(out, depth, children, &mut j, 0);
861 *i += 1;
862 }
863 Node::FieldRegion { items } => {
864 emit_field_region(out, depth, items);
865 *i += 1;
866 }
867 Node::InlineGroup {
868 unwrapped, runs, ..
869 } => {
870 emit_inline_group(out, depth, *unwrapped, runs);
871 *i += 1;
872 }
873 Node::Furniture { layer, inner } => {
874 emit_furniture(out, depth, *layer, inner);
875 *i += 1;
876 }
877 Node::Located { location, inner } => {
878 emit_located(out, depth, location, inner);
879 *i += 1;
880 }
881 Node::PageBreak => {
882 out.push(depth, "<page_break/>".to_string());
883 *i += 1;
884 }
885 Node::TextDump(text) => {
886 emit_text_dump(out, depth, text);
887 *i += 1;
888 }
889 }
890 }
891}
892
893enum DumpNode {
896 Text(String),
897 Cdata(String),
898 Elem(String),
899}
900
901fn emit_text_dump(out: &mut Out, depth: i32, text: &str) {
913 let records = dump_records(text);
914 if records.is_empty() {
915 out.push(depth, "<text></text>".to_string());
916 return;
917 }
918 let mut nodes: Vec<DumpNode> = Vec::new();
922 let mut buf = String::new();
923 for (r, (line, italic)) in records.iter().enumerate() {
924 if r > 0 {
925 buf.push('\n'); }
927 let raw = unescape_stored(line);
928 let s = raw.as_ref();
929 let is_cdata = s.contains(['"', '\'', '&', '<', '>']);
930 if *italic || is_cdata {
931 if !buf.is_empty() {
932 nodes.push(DumpNode::Text(std::mem::take(&mut buf)));
933 }
934 let inner = if is_cdata {
935 format!("<![CDATA[{s}]]>")
936 } else {
937 s.to_string()
938 };
939 if *italic {
940 nodes.push(DumpNode::Elem(format!("<italic>{inner}</italic>")));
941 } else {
942 nodes.push(DumpNode::Cdata(inner));
943 }
944 } else {
945 buf.push_str(s);
946 }
947 }
948 if !buf.is_empty() {
949 nodes.push(DumpNode::Text(buf));
950 }
951
952 if let [DumpNode::Text(d)] = nodes.as_slice() {
954 out.push(depth, format!("<text>{d}\n</text>"));
955 return;
956 }
957
958 let ind_child = INDENT.repeat((depth + 1).max(0) as usize);
961 let ind_self = INDENT.repeat(depth.max(0) as usize);
962 let mut raw = String::new();
963 for node in &nodes {
964 match node {
965 DumpNode::Text(d) => {
966 raw.push_str(&ind_child);
967 raw.push_str(d);
968 raw.push('\n');
969 }
970 DumpNode::Cdata(b) => raw.push_str(b),
971 DumpNode::Elem(b) => {
972 raw.push_str(&ind_child);
973 raw.push_str(b);
974 raw.push('\n');
975 }
976 }
977 }
978 let full = format!("{ind_self}<text>\n{raw}{ind_self}</text>");
979 for line in full.split('\n') {
980 if !line.trim().is_empty() {
981 out.push(0, line.to_string());
982 }
983 }
984}
985
986fn dump_records(text: &str) -> Vec<(String, bool)> {
991 let chars: Vec<char> = text.chars().collect();
992 let n = chars.len();
993
994 struct Delim {
995 pos: usize,
996 length: usize,
997 rem: usize,
998 can_open: bool,
999 can_close: bool,
1000 }
1001 let is_ws = |c: Option<char>| c.is_none_or(|c| c.is_whitespace());
1002 let is_punct =
1003 |c: Option<char>| c.is_some_and(|c| "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~".contains(c));
1004
1005 let mut delims: Vec<Delim> = Vec::new();
1007 let mut i = 0;
1008 while i < n {
1009 if chars[i] == '*' {
1010 let mut j = i;
1011 while j < n && chars[j] == '*' {
1012 j += 1;
1013 }
1014 let prev = (i > 0).then(|| chars[i - 1]);
1015 let next = (j < n).then(|| chars[j]);
1016 let left = !is_ws(next) && (!is_punct(next) || is_ws(prev) || is_punct(prev));
1017 let right = !is_ws(prev) && (!is_punct(prev) || is_ws(next) || is_punct(next));
1018 delims.push(Delim {
1019 pos: i,
1020 length: j - i,
1021 rem: j - i,
1022 can_open: left,
1023 can_close: right,
1024 });
1025 i = j;
1026 } else {
1027 i += 1;
1028 }
1029 }
1030
1031 let mut emph = vec![false; n];
1034 let mut consumed = vec![false; n];
1035 let mut ci = 0;
1036 while ci < delims.len() {
1037 if !(delims[ci].can_close && delims[ci].rem > 0) {
1038 ci += 1;
1039 continue;
1040 }
1041 let mut found: Option<usize> = None;
1042 let mut oi = ci as i64 - 1;
1043 while oi >= 0 {
1044 let o = &delims[oi as usize];
1045 let c = &delims[ci];
1046 if o.can_open && o.rem > 0 {
1047 let odd = (o.can_close || c.can_open)
1050 && (o.length + c.length) % 3 == 0
1051 && !(o.length % 3 == 0 && c.length % 3 == 0);
1052 if !odd {
1053 found = Some(oi as usize);
1054 break;
1055 }
1056 }
1057 oi -= 1;
1058 }
1059 let Some(fi) = found else {
1060 ci += 1;
1061 continue;
1062 };
1063 let use_ = if delims[fi].rem >= 2 && delims[ci].rem >= 2 {
1064 2
1065 } else {
1066 1
1067 };
1068 let oend = delims[fi].pos + delims[fi].rem;
1069 for c in consumed.iter_mut().take(oend).skip(oend - use_) {
1070 *c = true;
1071 }
1072 let cstart = delims[ci].pos + (delims[ci].length - delims[ci].rem);
1073 for c in consumed.iter_mut().take(cstart + use_).skip(cstart) {
1074 *c = true;
1075 }
1076 for e in emph.iter_mut().take(cstart).skip(oend) {
1077 *e = true;
1078 }
1079 delims[fi].rem -= use_;
1080 delims[ci].rem -= use_;
1081 delims.drain((fi + 1)..ci);
1082 ci = if delims[fi].rem == 0 { fi + 1 } else { fi };
1083 }
1084
1085 let mut records: Vec<(String, bool)> = Vec::new();
1087 let mut line = String::new();
1088 let mut line_italic = false;
1089 let push_line = |line: &mut String, italic: &mut bool, out: &mut Vec<(String, bool)>| {
1090 let text = std::mem::take(line);
1091 let ital = std::mem::replace(italic, false);
1092 let trimmed = text.trim();
1093 if trimmed.is_empty() {
1094 return;
1095 }
1096 let norm = if trimmed.len() >= 3 && trimmed.chars().all(|c| c == '_') {
1098 "_".repeat(10)
1099 } else {
1100 text
1101 };
1102 out.push((norm, ital));
1103 };
1104 for k in 0..n {
1105 if consumed[k] {
1106 continue;
1107 }
1108 if chars[k] == '\n' {
1109 push_line(&mut line, &mut line_italic, &mut records);
1110 } else {
1111 line.push(chars[k]);
1112 if emph[k] {
1113 line_italic = true;
1114 }
1115 }
1116 }
1117 push_line(&mut line, &mut line_italic, &mut records);
1118 records
1119}
1120
1121fn emit_inline_group(out: &mut Out, depth: i32, unwrapped: bool, runs: &[InlineRun]) {
1135 let has_styled = runs.iter().any(|r| !r.is_plain());
1136
1137 if unwrapped {
1138 for run in runs {
1139 if run.is_plain() {
1140 out.push(0, escape_text(&run.text));
1141 } else if run.formula {
1142 out.push(
1143 depth,
1144 format!("<formula>{}</formula>", escape_text(&run.text)),
1145 );
1146 } else {
1147 emit_styled(out, depth, &style_tags(run), &escape_text(&run.text));
1148 }
1149 }
1150 return;
1151 }
1152
1153 if !has_styled {
1156 let joined = runs
1157 .iter()
1158 .map(|r| escape_text(&r.text))
1159 .collect::<Vec<_>>()
1160 .join("\n");
1161 out.push(depth, format!("<text>{joined}\n</text>"));
1162 return;
1163 }
1164
1165 out.push(depth, "<text>".to_string());
1166 emit_inline_runs_body(out, depth + 1, runs);
1167 out.push(depth, "</text>".to_string());
1168}
1169
1170fn emit_inline_runs_body(out: &mut Out, depth: i32, runs: &[InlineRun]) {
1176 for (i, run) in runs.iter().enumerate() {
1177 if run.is_plain() {
1178 let e = escape_text(&run.text);
1179 let d = if e.starts_with("<content>") || i == 0 {
1180 depth
1181 } else {
1182 0
1183 };
1184 if e.starts_with("<![CDATA[") && i + 1 == runs.len() && d == 0 {
1185 out.push_glue(e);
1190 out.push(depth, "");
1191 } else {
1192 out.push(d, e);
1193 }
1194 } else if run.formula {
1195 out.push(
1196 depth,
1197 format!("<formula>{}</formula>", escape_text(&run.text)),
1198 );
1199 } else {
1200 emit_styled(out, depth, &style_tags(run), &escape_text(&run.text));
1201 }
1202 }
1203}
1204
1205fn style_tags(run: &InlineRun) -> Vec<&'static str> {
1209 let mut tags = Vec::new();
1210 match run.script {
1211 Script::Sub => tags.push("subscript"),
1212 Script::Super => tags.push("superscript"),
1213 Script::Baseline => {}
1214 }
1215 if run.strike {
1216 tags.push("strikethrough");
1217 }
1218 if run.underline {
1219 tags.push("underline");
1220 }
1221 if run.italic {
1222 tags.push("italic");
1223 }
1224 if run.bold {
1225 tags.push("bold");
1226 }
1227 if run.code {
1228 tags.push("code");
1229 }
1230 tags
1231}
1232
1233fn emit_styled(out: &mut Out, depth: i32, tags: &[&str], inner: &str) {
1238 match tags {
1239 [] => emit_text_node(out, depth, inner),
1240 [tag] => out.push(depth, format!("<{tag}>{inner}</{tag}>")),
1241 [tag, rest @ ..] => {
1242 out.push(depth, format!("<{tag}>"));
1243 emit_styled(out, depth + 1, rest, inner);
1244 out.push(depth, format!("</{tag}>"));
1245 }
1246 }
1247}
1248
1249fn emit_furniture(out: &mut Out, depth: i32, layer: ContentLayer, inner: &Node) {
1254 let token = format!("<layer value=\"{}\"/>", layer.value());
1255 match inner {
1256 Node::Heading { level, text } => {
1257 let open = if *level <= 1 {
1258 "heading".to_string()
1259 } else {
1260 format!("heading level=\"{level}\"")
1261 };
1262 out.push(depth, format!("<{open}>"));
1263 out.push(depth + 1, token);
1264 out.push(depth + 1, escape_text(text));
1265 out.push(depth, "</heading>".to_string());
1266 }
1267 Node::Paragraph { text } => {
1268 out.push(depth, "<text>".to_string());
1269 out.push(depth + 1, token);
1270 out.push(depth + 1, escape_text(text));
1271 out.push(depth, "</text>".to_string());
1272 }
1273 Node::Located { location, inner } => {
1276 if let Node::Paragraph { text } = &**inner {
1277 out.push(depth, "<text>".to_string());
1278 out.push(depth + 1, token);
1279 push_location(out, depth + 1, location);
1280 out.push(depth + 1, escape_text(text));
1281 out.push(depth, "</text>".to_string());
1282 } else {
1283 let mut i = 0usize;
1284 emit_nodes(out, depth, std::slice::from_ref(inner.as_ref()), &mut i, 0);
1285 }
1286 }
1287 Node::InlineGroup { runs, .. } => {
1291 out.push(depth, "<text>".to_string());
1292 for run in runs {
1293 out.push(depth + 1, token.clone());
1294 if run.is_plain() {
1295 out.push(depth + 1, escape_text(&run.text));
1296 } else if run.formula {
1297 out.push(
1298 depth + 1,
1299 format!("<formula>{}</formula>", escape_text(&run.text)),
1300 );
1301 } else {
1302 emit_styled(out, depth + 1, &style_tags(run), &escape_text(&run.text));
1303 }
1304 }
1305 out.push(depth, "</text>".to_string());
1306 }
1307 Node::Picture { caption, image, .. } => {
1313 let caption = caption.as_deref().filter(|c| !c.trim().is_empty());
1314 out.push(depth, "<picture>".to_string());
1315 out.push(depth + 1, token.clone());
1316 if let Some(img) = image {
1317 out.push(
1318 depth + 1,
1319 format!(
1320 "<src uri=\"data:image/png;base64,{}\"/>",
1321 crate::base64::encode(&img.data)
1322 ),
1323 );
1324 }
1325 if let Some(c) = caption {
1326 out.push(depth + 1, "<caption>".to_string());
1327 match inline_runs(c).into_iter().next() {
1328 Some(Run::Link { anchor, uri }) => {
1329 out.push(depth + 2, format!("<href uri=\"{}\"/>", attr_escape(&uri)));
1330 out.push(depth + 2, token.clone());
1331 out.push(depth + 2, escape_text(&anchor));
1332 }
1333 _ => {
1334 out.push(depth + 2, token.clone());
1335 out.push(depth + 2, escape_text(c));
1336 }
1337 }
1338 out.push(depth + 1, "</caption>".to_string());
1339 }
1340 out.push(depth, "</picture>".to_string());
1341 }
1342 Node::Table(table) => {
1345 out.push(depth, "<table>".to_string());
1346 out.push(depth + 1, token);
1347 emit_table_rows(out, depth, table);
1348 out.push(depth, "</table>".to_string());
1349 }
1350 other => {
1351 let mut i = 0usize;
1352 emit_nodes(out, depth, std::slice::from_ref(other), &mut i, 0);
1353 }
1354 }
1355}
1356
1357fn emit_picture(
1360 out: &mut Out,
1361 depth: i32,
1362 caption: Option<&str>,
1363 image: Option<&crate::document::PictureImage>,
1364 location: Option<&[u16; 4]>,
1365) {
1366 let caption = caption.filter(|c| !c.trim().is_empty());
1367 let src = image.map(|img| {
1374 let idx = out.pic_index;
1375 out.pic_index += 1;
1376 format!("assets/image_{idx:06}_{}.png", sha256_hex(&img.data))
1377 });
1378 if location.is_none() && caption.is_none() && src.is_none() {
1379 out.push(depth, "<picture></picture>".to_string());
1380 return;
1381 }
1382 out.push(depth, "<picture>".to_string());
1383 if let Some(loc) = location {
1384 push_location(out, depth + 1, loc);
1385 }
1386 if let Some(s) = src {
1387 out.push(depth + 1, format!("<src uri=\"{}\"/>", attr_escape(&s)));
1388 }
1389 if let Some(c) = caption {
1390 emit_caption(out, depth + 1, c);
1391 }
1392 out.push(depth, "</picture>".to_string());
1393}
1394
1395fn emit_caption(out: &mut Out, depth: i32, text: &str) {
1399 if let Some(Run::Link { anchor, uri }) = inline_runs(text).into_iter().next() {
1400 if inline_runs(text).len() == 1 {
1401 out.push(depth, "<caption>".to_string());
1402 out.push(depth + 1, format!("<href uri=\"{}\"/>", attr_escape(&uri)));
1403 out.push(depth + 1, escape_text(&anchor));
1404 out.push(depth, "</caption>".to_string());
1405 return;
1406 }
1407 }
1408 out.push(depth, format!("<caption>{}</caption>", escape_text(text)));
1409}
1410
1411fn strip_lone_link(text: &str) -> Cow<'_, str> {
1415 if let Some(rest) = text.strip_prefix('[') {
1416 if let Some(close) = rest.find("](") {
1417 if rest.ends_with(')') {
1418 let anchor = &rest[..close];
1419 let uri = &rest[close + 2..rest.len() - 1];
1420 if !anchor.contains(['[', ']']) && !uri.contains(['(', ')']) {
1421 return Cow::Owned(anchor.to_string());
1422 }
1423 }
1424 }
1425 }
1426 Cow::Borrowed(text)
1427}
1428
1429fn sha256_hex(bytes: &[u8]) -> String {
1431 use sha2::{Digest, Sha256};
1432 let mut h = Sha256::new();
1433 h.update(bytes);
1434 h.finalize().iter().map(|b| format!("{b:02x}")).collect()
1435}
1436
1437fn emit_located(out: &mut Out, depth: i32, location: &[u16; 4], inner: &Node) {
1440 match inner {
1441 Node::Heading { level, text } => {
1442 let open = if *level <= 1 {
1443 "heading".to_string()
1444 } else {
1445 format!("heading level=\"{level}\"")
1446 };
1447 emit_text_element(out, depth, &open, "heading", text, Some(location));
1448 }
1449 Node::Paragraph { text } => {
1450 emit_text_element(out, depth, "text", "text", text, Some(location));
1451 }
1452 Node::Picture { caption, image, .. } => {
1453 emit_picture(
1454 out,
1455 depth,
1456 caption.as_deref(),
1457 image.as_ref(),
1458 Some(location),
1459 );
1460 }
1461 Node::Table(t) => {
1462 let mut t = t.clone();
1464 t.location = Some(*location);
1465 emit_table(out, depth, &t);
1466 }
1467 Node::Code { language, text, .. } => {
1468 emit_code(out, depth, language.as_deref(), text, Some(location));
1469 }
1470 other => {
1474 let mut i = 0usize;
1475 emit_nodes(out, depth, std::slice::from_ref(other), &mut i, 0);
1476 }
1477 }
1478}
1479
1480fn emit_list(out: &mut Out, depth: i32, nodes: &[Node], i: &mut usize, level: u8) {
1481 let ordered = match &nodes[*i] {
1484 Node::ListItem { ordered, dclx, .. } => dclx.as_ref().map_or(*ordered, |d| d.ordered),
1485 _ => false,
1486 };
1487 let open = if ordered {
1488 "<list class=\"ordered\">"
1489 } else {
1490 "<list>"
1491 };
1492 out.push(depth, open.to_string());
1493 let start = *i;
1494 let mut prev_number: Option<u64> = None;
1495 while *i < nodes.len() {
1496 match &nodes[*i] {
1497 Node::ListItem {
1498 level: l,
1499 text,
1500 marker,
1501 ordered: o,
1502 number,
1503 first_in_list,
1504 location,
1505 dclx,
1506 href,
1507 layer,
1508 } if *l == level => {
1509 let eff_ordered = dclx.as_ref().map_or(*o, |d| d.ordered);
1512 let eff_marker = dclx.as_ref().map_or(marker.as_ref(), |d| d.marker.as_ref());
1513 if *i != start
1517 && (*first_in_list
1518 || eff_ordered != ordered
1519 || (ordered && Some(*number) != prev_number.map(|n| n + 1)))
1520 {
1521 break;
1522 }
1523 prev_number = Some(*number);
1524 let has_nested = {
1530 let mut found = false;
1531 let mut pn = Some(*number);
1532 let mut j = *i + 1;
1533 while let Some(Node::ListItem {
1534 level: nl,
1535 ordered: no,
1536 number: nn,
1537 first_in_list: nf,
1538 dclx: nd,
1539 ..
1540 }) = nodes.get(j)
1541 {
1542 if *nl > level {
1543 found = true;
1544 break;
1545 }
1546 if *nl < level {
1547 break;
1548 }
1549 let n_ordered = nd.as_ref().map_or(*no, |d| d.ordered);
1552 if *nf
1553 || n_ordered != ordered
1554 || (ordered && Some(*nn) != pn.map(|n| n + 1))
1555 {
1556 break;
1557 }
1558 pn = Some(*nn);
1559 j += 1;
1560 }
1561 found
1562 };
1563 match eff_marker {
1566 Some(m) => {
1567 out.push(depth + 1, "<ldiv>".to_string());
1568 out.push(depth + 2, format!("<marker>{}</marker>", escape_text(m)));
1569 out.push(depth + 1, "</ldiv>".to_string());
1570 }
1571 None => out.push(depth + 1, "<ldiv/>".to_string()),
1572 }
1573 if let Some(loc) = location {
1577 push_location(out, depth + 1, loc);
1578 }
1579 match dclx {
1580 Some(d) if !d.runs.is_empty() => {
1585 if has_nested {
1586 out.push(depth + 1, "<text>".to_string());
1587 emit_inline_runs_body(out, depth + 2, &d.runs);
1588 out.push(depth + 1, "</text>".to_string());
1589 } else {
1590 emit_inline_runs_body(out, depth + 1, &d.runs);
1591 }
1592 }
1593 Some(d) => emit_list_item_content(out, depth + 1, &d.text, has_nested),
1596 None => {
1597 let stripped = strip_lone_link(text);
1602 let eff_href = href
1603 .as_deref()
1604 .filter(|_| matches!(stripped, Cow::Owned(_)));
1605 if eff_href.is_some() || layer.is_some() {
1606 let content: &str = if eff_href.is_some() {
1607 stripped.as_ref()
1608 } else {
1609 text.as_str()
1610 };
1611 emit_list_item_with_head(
1612 out,
1613 depth + 1,
1614 content,
1615 has_nested,
1616 eff_href,
1617 *layer,
1618 );
1619 } else {
1620 emit_list_item_content(out, depth + 1, text, has_nested);
1621 }
1622 }
1623 }
1624 *i += 1;
1625 }
1626 Node::ListItem { level: l, .. } if *l > level => {
1627 emit_list(out, depth + 1, nodes, i, *l);
1628 }
1629 Node::Paragraph { text }
1635 if text.is_empty()
1636 && matches!(
1637 nodes.get(*i + 1),
1638 Some(Node::ListItem { level: nl, ordered: no, number: nn,
1639 first_in_list: nf, dclx: nd, .. })
1640 if *nl > level
1641 || (*nl == level
1642 && !*nf
1643 && nd.as_ref().map_or(*no, |d| d.ordered) == ordered
1644 && (!ordered
1645 || Some(*nn) == prev_number.map(|n| n + 1)))
1646 ) =>
1647 {
1648 *i += 1;
1649 }
1650 _ => break,
1651 }
1652 }
1653 out.push(depth, "</list>".to_string());
1654}
1655
1656fn emit_list_item_with_head(
1667 out: &mut Out,
1668 depth: i32,
1669 text: &str,
1670 has_nested: bool,
1671 href: Option<&str>,
1672 layer: Option<ContentLayer>,
1673) {
1674 let head = |out: &mut Out, d: i32| {
1675 if let Some(uri) = href {
1676 out.push(d, format!("<href uri=\"{}\"/>", attr_escape(uri)));
1677 }
1678 if let Some(l) = layer {
1679 out.push(d, format!("<layer value=\"{}\"/>", l.value()));
1680 }
1681 };
1682 if has_nested {
1683 out.push(depth, "<text>".to_string());
1684 head(out, depth + 1);
1685 emit_runs(out, depth + 1, inline_runs(text));
1686 out.push(depth, "</text>".to_string());
1687 } else {
1688 head(out, depth);
1689 emit_runs(out, depth, inline_runs(text));
1690 }
1691}
1692
1693fn emit_list_item_content(out: &mut Out, depth: i32, text: &str, has_nested: bool) {
1694 let runs = inline_runs_from_markdown(text);
1700 let single_plain = runs.len() <= 1 && runs.first().is_none_or(|r| r.is_plain());
1701 if single_plain {
1702 if has_nested {
1703 emit_text_element(out, depth, "text", "text", text, None);
1704 } else if !text.trim().is_empty() {
1705 emit_text_node(out, depth, text);
1709 }
1710 } else if has_nested {
1711 emit_inline_group(out, depth, false, &runs);
1712 } else {
1713 emit_inline_runs_body(out, depth, &runs);
1716 }
1717}
1718
1719fn emit_field_region(out: &mut Out, depth: i32, items: &[FieldItem]) {
1720 out.push(depth, "<field_region>".to_string());
1721 for item in items {
1722 out.push(depth + 1, "<field_item>".to_string());
1723 if let Some(m) = item.marker.as_ref().filter(|s| !s.is_empty()) {
1724 out.push(depth + 2, format!("<marker>{}</marker>", escape_text(m)));
1725 }
1726 if let Some(k) = item.key.as_ref().filter(|s| !s.is_empty()) {
1727 out.push(depth + 2, format!("<key>{}</key>", escape_text(k)));
1728 }
1729 if let Some(v) = item.value.as_ref().filter(|s| !s.is_empty()) {
1730 out.push(depth + 2, format!("<value>{}</value>", escape_text(v)));
1731 }
1732 out.push(depth + 1, "</field_item>".to_string());
1733 }
1734 out.push(depth, "</field_region>".to_string());
1735}
1736
1737#[cfg(test)]
1738mod tests {
1739 use super::*;
1740
1741 #[test]
1742 fn located_heading_emits_location_tokens_in_block_form() {
1743 let doclang = export_to_doclang(&[Node::Located {
1744 location: [44, 170, 340, 386],
1745 inner: Box::new(Node::Heading {
1746 level: 1,
1747 text: "X-Library".into(),
1748 }),
1749 }]);
1750 assert!(
1751 doclang.contains(
1752 "<heading>\n <location value=\"44\"/>\n <location value=\"170\"/>\n \
1753 <location value=\"340\"/>\n <location value=\"386\"/>\n X-Library\n </heading>"
1754 ),
1755 "got:\n{doclang}"
1756 );
1757 }
1758
1759 fn code(language: Option<&str>, text: &str) -> String {
1760 export_to_doclang(&[Node::Code {
1761 language: language.map(String::from),
1762 text: text.into(),
1763 orig: None,
1764 }])
1765 }
1766
1767 #[test]
1768 fn code_with_language_emits_linguist_label_block_form() {
1769 assert_eq!(
1772 code(Some("python"), "print(\"Hello world!\")"),
1773 "<doclang version=\"0.7\">\n <code>\n <label value=\"Python\"/>\n\
1774 <![CDATA[print(\"Hello world!\")]]> </code>\n</doclang>"
1775 );
1776 assert!(code(Some("bash"), "ls -la").contains("<label value=\"Shell\"/>"));
1778 }
1779
1780 fn plain(text: &str) -> InlineRun {
1781 InlineRun {
1782 text: text.into(),
1783 ..Default::default()
1784 }
1785 }
1786 fn bold(text: &str) -> InlineRun {
1787 InlineRun {
1788 text: text.into(),
1789 bold: true,
1790 ..Default::default()
1791 }
1792 }
1793 fn ig(unwrapped: bool, runs: Vec<InlineRun>) -> String {
1794 let body = export_to_doclang(&[Node::InlineGroup {
1795 unwrapped,
1796 runs,
1797 md_text: String::new(),
1798 }]);
1799 body.trim_start_matches("<doclang version=\"0.7\">\n")
1801 .trim_end_matches("\n</doclang>")
1802 .to_string()
1803 }
1804
1805 #[test]
1806 fn inline_group_matches_reference_layout() {
1807 assert_eq!(
1809 ig(
1810 false,
1811 vec![plain("This is a"), bold("bold"), plain("example")]
1812 ),
1813 " <text>\n This is a\n <bold>bold</bold>\nexample\n </text>"
1814 );
1815 assert_eq!(
1817 ig(
1818 true,
1819 vec![
1820 plain("aa"),
1821 bold("bb"),
1822 plain("cc"),
1823 bold("dd"),
1824 plain("ee")
1825 ]
1826 ),
1827 "aa\n <bold>bb</bold>\ncc\n <bold>dd</bold>\nee"
1828 );
1829 assert_eq!(
1831 ig(false, vec![plain("aa"), plain("bb")]),
1832 " <text>aa\nbb\n</text>"
1833 );
1834 assert_eq!(ig(false, vec![plain("aa")]), " <text>aa\n</text>");
1835 assert_eq!(
1837 ig(false, vec![bold("bb")]),
1838 " <text>\n <bold>bb</bold>\n </text>"
1839 );
1840 }
1841
1842 #[test]
1843 fn nested_styles_wrap_outermost_last_applied() {
1844 let bi = InlineRun {
1845 text: "bi".into(),
1846 bold: true,
1847 italic: true,
1848 ..Default::default()
1849 };
1850 assert_eq!(
1852 ig(true, vec![bi]),
1853 " <italic>\n <bold>bi</bold>\n </italic>"
1854 );
1855 let sub = InlineRun {
1856 text: "2".into(),
1857 script: Script::Sub,
1858 ..Default::default()
1859 };
1860 assert_eq!(ig(true, vec![sub]), " <subscript>2</subscript>");
1861 }
1862
1863 #[test]
1864 fn furniture_heading_gets_layer_head() {
1865 let out = export_to_doclang(&[Node::Furniture {
1866 layer: ContentLayer::Furniture,
1867 inner: Box::new(Node::Heading {
1868 level: 1,
1869 text: "Anchor Links Test".into(),
1870 }),
1871 }]);
1872 assert_eq!(
1873 out,
1874 "<doclang version=\"0.7\">\n <heading>\n <layer value=\"furniture\"/>\n Anchor Links Test\n </heading>\n</doclang>"
1875 );
1876 }
1877
1878 #[test]
1879 fn text_dump_reproduces_minidom_per_line_layout() {
1880 let text = "PATN\nWKU 1\nPAL K. \"Determination\"\nfollow-up\n*Note A\n_______________\nNote B*\nEND";
1885 let out = export_to_doclang(&[Node::TextDump(text.into())]);
1886 let expected = "<doclang version=\"0.7\">\n \
1887 <text>\n \
1888 PATN\nWKU 1\n\
1889 <![CDATA[PAL K. \"Determination\"]]> \n\
1890 follow-up\n \
1891 <italic>Note A</italic>\n \
1892 <italic>__________</italic>\n \
1893 <italic>Note B</italic>\n\
1894 END\n \
1895 </text>\n</doclang>";
1896 assert_eq!(out, expected, "got:\n{out}");
1897 }
1898
1899 #[test]
1900 fn code_without_language_stays_inline_and_unlabeled() {
1901 assert_eq!(
1902 code(None, "print(\"Hi!\")"),
1903 "<doclang version=\"0.7\">\n <code><![CDATA[print(\"Hi!\")]]></code>\n</doclang>"
1904 );
1905 assert!(!code(Some("brainfuck"), "+++.").contains("<label"));
1907 }
1908}