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 if let Some(cap) = &table.caption {
593 out.push(depth + 1, format!("<caption>{cap}</caption>"));
596 }
597 emit_table_rows(out, depth, table);
598 out.push(depth, "</table>".to_string());
599}
600
601fn emit_chart(
605 out: &mut Out,
606 depth: i32,
607 kind: &str,
608 table: &Table,
609 caption: Option<&str>,
610 location: Option<&[u16; 4]>,
611) {
612 out.pic_index += 1;
613 out.push(depth, "<picture class=\"chart\">".to_string());
614 out.push(
615 depth + 1,
616 format!("<label value=\"{}\"/>", attr_escape(kind)),
617 );
618 if let Some(loc) = location {
619 push_location(out, depth + 1, loc);
620 }
621 if let Some(cap) = caption {
622 out.push(
623 depth + 1,
624 format!("<caption>{}</caption>", escape_text(cap)),
625 );
626 }
627 out.push(depth + 1, "<tabular>".to_string());
628 emit_table_rows(out, depth + 1, table);
629 out.push(depth + 1, "</tabular>".to_string());
630 out.push(depth, "</picture>".to_string());
631}
632
633fn emit_table_rows(out: &mut Out, depth: i32, table: &Table) {
636 if let Some(loc) = &table.location {
639 push_location(out, depth + 1, loc);
640 }
641 for (ri, row) in table.rows.iter().enumerate() {
642 for (ci, cell) in row.iter().enumerate() {
643 let cont = |grid: &Vec<Vec<bool>>| {
647 grid.get(ri)
648 .and_then(|r| r.get(ci))
649 .copied()
650 .unwrap_or(false)
651 };
652 let is_lcel = table
653 .structure
654 .as_ref()
655 .map(|s| cont(&s.col_continuation))
656 .unwrap_or(false);
657 let is_ucel = table
658 .structure
659 .as_ref()
660 .map(|s| cont(&s.row_continuation))
661 .unwrap_or(false);
662 let is_header = match &table.structure {
663 Some(s) if !s.col_header.is_empty() => s
664 .col_header
665 .get(ri)
666 .and_then(|r| r.get(ci))
667 .copied()
668 .unwrap_or(false),
669 Some(s) => s.header_row.get(ri).copied().unwrap_or(false),
670 None => ri == 0,
671 };
672 let is_row_header = table
673 .structure
674 .as_ref()
675 .map(|s| {
676 s.row_header
677 .get(ri)
678 .and_then(|r| r.get(ci))
679 .copied()
680 .unwrap_or(false)
681 })
682 .unwrap_or(false);
683 let tok = if is_lcel && is_ucel {
684 "<xcel/>"
686 } else if is_lcel {
687 "<lcel/>"
688 } else if is_ucel {
689 "<ucel/>"
690 } else if cell.trim().is_empty() {
691 "<ecel/>"
692 } else if is_header {
693 "<ched/>"
694 } else if is_row_header {
695 "<rhed/>"
696 } else {
697 "<fcel/>"
698 };
699 out.push(depth + 1, tok.to_string());
700 if !is_lcel && !is_ucel {
701 let blocks = table
705 .cell_blocks
706 .as_ref()
707 .and_then(|b| b.get(ri))
708 .and_then(|r| r.get(ci))
709 .filter(|b| !b.is_empty());
710 if let Some(blocks) = blocks {
711 let mut bi = 0;
712 emit_nodes(out, depth + 1, blocks, &mut bi, 0);
713 } else if !cell.trim().is_empty() {
714 emit_cell_text(out, depth + 1, cell);
715 }
716 }
717 }
718 out.push(depth + 1, "<nl/>".to_string());
719 }
720}
721
722fn emit_cell_text(out: &mut Out, depth: i32, text: &str) {
724 let runs = inline_runs(text.trim());
725 emit_runs(out, depth, runs);
726}
727
728pub fn export_to_doclang(nodes: &[Node]) -> String {
730 let mut out = Out {
731 lines: Vec::new(),
732 pic_index: 0,
733 };
734 out.push(0, "<doclang version=\"0.7\">".to_string());
735 let mut i = 0usize;
736 emit_nodes(&mut out, 1, nodes, &mut i, 0);
737 out.push(0, "</doclang>".to_string());
738 out.finish()
739}
740
741fn emit_nodes(out: &mut Out, depth: i32, nodes: &[Node], i: &mut usize, level: u8) {
744 while *i < nodes.len() {
745 match &nodes[*i] {
746 Node::Heading { level, text } => {
747 let open = if *level <= 1 {
748 "heading".to_string()
749 } else {
750 format!("heading level=\"{level}\"")
751 };
752 emit_text_element(out, depth, &open, "heading", text, None);
753 *i += 1;
754 }
755 Node::Paragraph { text } => {
756 if let Some(latex) = text
760 .strip_prefix("$$")
761 .and_then(|t| t.strip_suffix("$$"))
762 .filter(|t| !t.is_empty())
763 {
764 out.push(depth, format!("<formula>{}</formula>", escape_text(latex)));
765 } else {
766 emit_text_element(out, depth, "text", "text", text, None);
767 }
768 *i += 1;
769 }
770 Node::CheckboxItem { checked, text } => {
771 let class = if *checked { "selected" } else { "unselected" };
774 out.push(depth, "<text>".to_string());
775 out.push(depth + 1, format!("<checkbox class=\"{class}\"/>"));
776 if !text.is_empty() {
777 out.push(depth + 1, escape_text(text));
778 }
779 out.push(depth, "</text>".to_string());
780 *i += 1;
781 }
782 Node::Code { language, text, .. } => {
783 emit_code(out, depth, language.as_deref(), text, None);
784 *i += 1;
785 }
786 Node::Formula {
789 latex, location, ..
790 } => {
791 if let Some(loc) = location {
792 out.push(depth, "<formula>".to_string());
793 push_location(out, depth + 1, loc);
794 if !latex.is_empty() {
795 out.push(depth + 1, escape_text(latex));
796 }
797 out.push(depth, "</formula>".to_string());
798 } else {
799 out.push(depth, format!("<formula>{}</formula>", escape_text(latex)));
800 }
801 *i += 1;
802 }
803 Node::PageFurniture {
804 footer,
805 location,
806 text,
807 } => {
808 let tag = if *footer {
809 "page_footer"
810 } else {
811 "page_header"
812 };
813 out.push(depth, format!("<{tag}>"));
814 out.push(depth + 1, "<layer value=\"furniture\"/>".to_string());
815 push_location(out, depth + 1, location);
816 if !text.is_empty() {
817 out.push(depth + 1, escape_text(text));
818 }
819 out.push(depth, format!("</{tag}>"));
820 *i += 1;
821 }
822 Node::Table(t) => {
823 emit_table(out, depth, t);
824 *i += 1;
825 }
826 Node::Picture { caption, image, .. } => {
829 emit_picture(out, depth, caption.as_deref(), image.as_ref(), None);
830 *i += 1;
831 }
832 Node::Chart {
833 kind,
834 table,
835 caption,
836 location,
837 } => {
838 emit_chart(
839 out,
840 depth,
841 kind,
842 table,
843 caption.as_deref(),
844 location.as_ref(),
845 );
846 *i += 1;
847 }
848 Node::DoclangOnly(inner) => {
849 let mut j = 0;
850 emit_nodes(out, depth, std::slice::from_ref(inner), &mut j, level);
851 *i += 1;
852 }
853 Node::ListItem { level: l, .. } => {
854 if *l < level {
855 return; }
857 emit_list(out, depth, nodes, i, *l);
858 }
859 Node::Group { children, .. } => {
860 let mut j = 0usize;
861 emit_nodes(out, depth, children, &mut j, 0);
862 *i += 1;
863 }
864 Node::FieldRegion { items } => {
865 emit_field_region(out, depth, items);
866 *i += 1;
867 }
868 Node::InlineGroup {
869 unwrapped, runs, ..
870 } => {
871 emit_inline_group(out, depth, *unwrapped, runs);
872 *i += 1;
873 }
874 Node::Furniture { layer, inner } => {
875 emit_furniture(out, depth, *layer, inner);
876 *i += 1;
877 }
878 Node::Located { location, inner } => {
879 emit_located(out, depth, location, inner);
880 *i += 1;
881 }
882 Node::PageBreak => {
883 out.push(depth, "<page_break/>".to_string());
884 *i += 1;
885 }
886 Node::PageInfo { .. } => {
889 *i += 1;
890 }
891 Node::TextDump(text) => {
892 emit_text_dump(out, depth, text);
893 *i += 1;
894 }
895 }
896 }
897}
898
899enum DumpNode {
902 Text(String),
903 Cdata(String),
904 Elem(String),
905}
906
907fn emit_text_dump(out: &mut Out, depth: i32, text: &str) {
919 let records = dump_records(text);
920 if records.is_empty() {
921 out.push(depth, "<text></text>".to_string());
922 return;
923 }
924 let mut nodes: Vec<DumpNode> = Vec::new();
928 let mut buf = String::new();
929 for (r, (line, italic)) in records.iter().enumerate() {
930 if r > 0 {
931 buf.push('\n'); }
933 let raw = unescape_stored(line);
934 let s = raw.as_ref();
935 let is_cdata = s.contains(['"', '\'', '&', '<', '>']);
936 if *italic || is_cdata {
937 if !buf.is_empty() {
938 nodes.push(DumpNode::Text(std::mem::take(&mut buf)));
939 }
940 let inner = if is_cdata {
941 format!("<![CDATA[{s}]]>")
942 } else {
943 s.to_string()
944 };
945 if *italic {
946 nodes.push(DumpNode::Elem(format!("<italic>{inner}</italic>")));
947 } else {
948 nodes.push(DumpNode::Cdata(inner));
949 }
950 } else {
951 buf.push_str(s);
952 }
953 }
954 if !buf.is_empty() {
955 nodes.push(DumpNode::Text(buf));
956 }
957
958 if let [DumpNode::Text(d)] = nodes.as_slice() {
960 out.push(depth, format!("<text>{d}\n</text>"));
961 return;
962 }
963
964 let ind_child = INDENT.repeat((depth + 1).max(0) as usize);
967 let ind_self = INDENT.repeat(depth.max(0) as usize);
968 let mut raw = String::new();
969 for node in &nodes {
970 match node {
971 DumpNode::Text(d) => {
972 raw.push_str(&ind_child);
973 raw.push_str(d);
974 raw.push('\n');
975 }
976 DumpNode::Cdata(b) => raw.push_str(b),
977 DumpNode::Elem(b) => {
978 raw.push_str(&ind_child);
979 raw.push_str(b);
980 raw.push('\n');
981 }
982 }
983 }
984 let full = format!("{ind_self}<text>\n{raw}{ind_self}</text>");
985 for line in full.split('\n') {
986 if !line.trim().is_empty() {
987 out.push(0, line.to_string());
988 }
989 }
990}
991
992fn dump_records(text: &str) -> Vec<(String, bool)> {
997 let chars: Vec<char> = text.chars().collect();
998 let n = chars.len();
999
1000 struct Delim {
1001 pos: usize,
1002 length: usize,
1003 rem: usize,
1004 can_open: bool,
1005 can_close: bool,
1006 }
1007 let is_ws = |c: Option<char>| c.is_none_or(|c| c.is_whitespace());
1008 let is_punct =
1009 |c: Option<char>| c.is_some_and(|c| "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~".contains(c));
1010
1011 let mut delims: Vec<Delim> = Vec::new();
1013 let mut i = 0;
1014 while i < n {
1015 if chars[i] == '*' {
1016 let mut j = i;
1017 while j < n && chars[j] == '*' {
1018 j += 1;
1019 }
1020 let prev = (i > 0).then(|| chars[i - 1]);
1021 let next = (j < n).then(|| chars[j]);
1022 let left = !is_ws(next) && (!is_punct(next) || is_ws(prev) || is_punct(prev));
1023 let right = !is_ws(prev) && (!is_punct(prev) || is_ws(next) || is_punct(next));
1024 delims.push(Delim {
1025 pos: i,
1026 length: j - i,
1027 rem: j - i,
1028 can_open: left,
1029 can_close: right,
1030 });
1031 i = j;
1032 } else {
1033 i += 1;
1034 }
1035 }
1036
1037 let mut emph = vec![false; n];
1040 let mut consumed = vec![false; n];
1041 let mut ci = 0;
1042 while ci < delims.len() {
1043 if !(delims[ci].can_close && delims[ci].rem > 0) {
1044 ci += 1;
1045 continue;
1046 }
1047 let mut found: Option<usize> = None;
1048 let mut oi = ci as i64 - 1;
1049 while oi >= 0 {
1050 let o = &delims[oi as usize];
1051 let c = &delims[ci];
1052 if o.can_open && o.rem > 0 {
1053 let odd = (o.can_close || c.can_open)
1056 && (o.length + c.length) % 3 == 0
1057 && !(o.length % 3 == 0 && c.length % 3 == 0);
1058 if !odd {
1059 found = Some(oi as usize);
1060 break;
1061 }
1062 }
1063 oi -= 1;
1064 }
1065 let Some(fi) = found else {
1066 ci += 1;
1067 continue;
1068 };
1069 let use_ = if delims[fi].rem >= 2 && delims[ci].rem >= 2 {
1070 2
1071 } else {
1072 1
1073 };
1074 let oend = delims[fi].pos + delims[fi].rem;
1075 for c in consumed.iter_mut().take(oend).skip(oend - use_) {
1076 *c = true;
1077 }
1078 let cstart = delims[ci].pos + (delims[ci].length - delims[ci].rem);
1079 for c in consumed.iter_mut().take(cstart + use_).skip(cstart) {
1080 *c = true;
1081 }
1082 for e in emph.iter_mut().take(cstart).skip(oend) {
1083 *e = true;
1084 }
1085 delims[fi].rem -= use_;
1086 delims[ci].rem -= use_;
1087 delims.drain((fi + 1)..ci);
1088 ci = if delims[fi].rem == 0 { fi + 1 } else { fi };
1089 }
1090
1091 let mut records: Vec<(String, bool)> = Vec::new();
1093 let mut line = String::new();
1094 let mut line_italic = false;
1095 let push_line = |line: &mut String, italic: &mut bool, out: &mut Vec<(String, bool)>| {
1096 let text = std::mem::take(line);
1097 let ital = std::mem::replace(italic, false);
1098 let trimmed = text.trim();
1099 if trimmed.is_empty() {
1100 return;
1101 }
1102 let norm = if trimmed.len() >= 3 && trimmed.chars().all(|c| c == '_') {
1104 "_".repeat(10)
1105 } else {
1106 text
1107 };
1108 out.push((norm, ital));
1109 };
1110 for k in 0..n {
1111 if consumed[k] {
1112 continue;
1113 }
1114 if chars[k] == '\n' {
1115 push_line(&mut line, &mut line_italic, &mut records);
1116 } else {
1117 line.push(chars[k]);
1118 if emph[k] {
1119 line_italic = true;
1120 }
1121 }
1122 }
1123 push_line(&mut line, &mut line_italic, &mut records);
1124 records
1125}
1126
1127fn emit_inline_group(out: &mut Out, depth: i32, unwrapped: bool, runs: &[InlineRun]) {
1141 let has_styled = runs.iter().any(|r| !r.is_plain());
1142
1143 if unwrapped {
1144 for run in runs {
1145 if run.is_plain() {
1146 out.push(0, escape_text(&run.text));
1147 } else if run.formula {
1148 out.push(
1149 depth,
1150 format!("<formula>{}</formula>", escape_text(&run.text)),
1151 );
1152 } else {
1153 emit_styled(out, depth, &style_tags(run), &escape_text(&run.text));
1154 }
1155 }
1156 return;
1157 }
1158
1159 if !has_styled {
1162 let joined = runs
1163 .iter()
1164 .map(|r| escape_text(&r.text))
1165 .collect::<Vec<_>>()
1166 .join("\n");
1167 out.push(depth, format!("<text>{joined}\n</text>"));
1168 return;
1169 }
1170
1171 out.push(depth, "<text>".to_string());
1172 emit_inline_runs_body(out, depth + 1, runs);
1173 out.push(depth, "</text>".to_string());
1174}
1175
1176fn emit_inline_runs_body(out: &mut Out, depth: i32, runs: &[InlineRun]) {
1182 for (i, run) in runs.iter().enumerate() {
1183 if run.is_plain() {
1184 let e = escape_text(&run.text);
1185 let d = if e.starts_with("<content>") || i == 0 {
1186 depth
1187 } else {
1188 0
1189 };
1190 if e.starts_with("<![CDATA[") && i + 1 == runs.len() && d == 0 {
1191 out.push_glue(e);
1196 out.push(depth, "");
1197 } else {
1198 out.push(d, e);
1199 }
1200 } else if run.formula {
1201 out.push(
1202 depth,
1203 format!("<formula>{}</formula>", escape_text(&run.text)),
1204 );
1205 } else {
1206 emit_styled(out, depth, &style_tags(run), &escape_text(&run.text));
1207 }
1208 }
1209}
1210
1211fn style_tags(run: &InlineRun) -> Vec<&'static str> {
1215 let mut tags = Vec::new();
1216 match run.script {
1217 Script::Sub => tags.push("subscript"),
1218 Script::Super => tags.push("superscript"),
1219 Script::Baseline => {}
1220 }
1221 if run.strike {
1222 tags.push("strikethrough");
1223 }
1224 if run.underline {
1225 tags.push("underline");
1226 }
1227 if run.italic {
1228 tags.push("italic");
1229 }
1230 if run.bold {
1231 tags.push("bold");
1232 }
1233 if run.code {
1234 tags.push("code");
1235 }
1236 tags
1237}
1238
1239fn emit_styled(out: &mut Out, depth: i32, tags: &[&str], inner: &str) {
1244 match tags {
1245 [] => emit_text_node(out, depth, inner),
1246 [tag] => out.push(depth, format!("<{tag}>{inner}</{tag}>")),
1247 [tag, rest @ ..] => {
1248 out.push(depth, format!("<{tag}>"));
1249 emit_styled(out, depth + 1, rest, inner);
1250 out.push(depth, format!("</{tag}>"));
1251 }
1252 }
1253}
1254
1255fn emit_furniture(out: &mut Out, depth: i32, layer: ContentLayer, inner: &Node) {
1260 let token = format!("<layer value=\"{}\"/>", layer.value());
1261 match inner {
1262 Node::Heading { level, text } => {
1263 let open = if *level <= 1 {
1264 "heading".to_string()
1265 } else {
1266 format!("heading level=\"{level}\"")
1267 };
1268 out.push(depth, format!("<{open}>"));
1269 out.push(depth + 1, token);
1270 out.push(depth + 1, escape_text(text));
1271 out.push(depth, "</heading>".to_string());
1272 }
1273 Node::Paragraph { text } => {
1274 out.push(depth, "<text>".to_string());
1275 out.push(depth + 1, token);
1276 out.push(depth + 1, escape_text(text));
1277 out.push(depth, "</text>".to_string());
1278 }
1279 Node::Located { location, inner } => {
1282 if let Node::Paragraph { text } = &**inner {
1283 out.push(depth, "<text>".to_string());
1284 out.push(depth + 1, token);
1285 push_location(out, depth + 1, location);
1286 out.push(depth + 1, escape_text(text));
1287 out.push(depth, "</text>".to_string());
1288 } else {
1289 let mut i = 0usize;
1290 emit_nodes(out, depth, std::slice::from_ref(inner.as_ref()), &mut i, 0);
1291 }
1292 }
1293 Node::InlineGroup { runs, .. } => {
1297 out.push(depth, "<text>".to_string());
1298 for run in runs {
1299 out.push(depth + 1, token.clone());
1300 if run.is_plain() {
1301 out.push(depth + 1, escape_text(&run.text));
1302 } else if run.formula {
1303 out.push(
1304 depth + 1,
1305 format!("<formula>{}</formula>", escape_text(&run.text)),
1306 );
1307 } else {
1308 emit_styled(out, depth + 1, &style_tags(run), &escape_text(&run.text));
1309 }
1310 }
1311 out.push(depth, "</text>".to_string());
1312 }
1313 Node::Picture { caption, image, .. } => {
1319 let caption = caption.as_deref().filter(|c| !c.trim().is_empty());
1320 out.push(depth, "<picture>".to_string());
1321 out.push(depth + 1, token.clone());
1322 if let Some(img) = image {
1323 out.push(
1324 depth + 1,
1325 format!(
1326 "<src uri=\"data:image/png;base64,{}\"/>",
1327 crate::base64::encode(&img.data)
1328 ),
1329 );
1330 }
1331 if let Some(c) = caption {
1332 out.push(depth + 1, "<caption>".to_string());
1333 match inline_runs(c).into_iter().next() {
1334 Some(Run::Link { anchor, uri }) => {
1335 out.push(depth + 2, format!("<href uri=\"{}\"/>", attr_escape(&uri)));
1336 out.push(depth + 2, token.clone());
1337 out.push(depth + 2, escape_text(&anchor));
1338 }
1339 _ => {
1340 out.push(depth + 2, token.clone());
1341 out.push(depth + 2, escape_text(c));
1342 }
1343 }
1344 out.push(depth + 1, "</caption>".to_string());
1345 }
1346 out.push(depth, "</picture>".to_string());
1347 }
1348 Node::Table(table) => {
1351 out.push(depth, "<table>".to_string());
1352 out.push(depth + 1, token);
1353 emit_table_rows(out, depth, table);
1354 out.push(depth, "</table>".to_string());
1355 }
1356 other => {
1357 let mut i = 0usize;
1358 emit_nodes(out, depth, std::slice::from_ref(other), &mut i, 0);
1359 }
1360 }
1361}
1362
1363fn emit_picture(
1366 out: &mut Out,
1367 depth: i32,
1368 caption: Option<&str>,
1369 image: Option<&crate::document::PictureImage>,
1370 location: Option<&[u16; 4]>,
1371) {
1372 let caption = caption.filter(|c| !c.trim().is_empty());
1373 let src = image.map(|img| {
1380 let idx = out.pic_index;
1381 out.pic_index += 1;
1382 format!("assets/image_{idx:06}_{}.png", sha256_hex(&img.data))
1383 });
1384 if location.is_none() && caption.is_none() && src.is_none() {
1385 out.push(depth, "<picture></picture>".to_string());
1386 return;
1387 }
1388 out.push(depth, "<picture>".to_string());
1389 if let Some(loc) = location {
1390 push_location(out, depth + 1, loc);
1391 }
1392 if let Some(s) = src {
1393 out.push(depth + 1, format!("<src uri=\"{}\"/>", attr_escape(&s)));
1394 }
1395 if let Some(c) = caption {
1396 emit_caption(out, depth + 1, c);
1397 }
1398 out.push(depth, "</picture>".to_string());
1399}
1400
1401fn emit_caption(out: &mut Out, depth: i32, text: &str) {
1405 if let Some(Run::Link { anchor, uri }) = inline_runs(text).into_iter().next() {
1406 if inline_runs(text).len() == 1 {
1407 out.push(depth, "<caption>".to_string());
1408 out.push(depth + 1, format!("<href uri=\"{}\"/>", attr_escape(&uri)));
1409 out.push(depth + 1, escape_text(&anchor));
1410 out.push(depth, "</caption>".to_string());
1411 return;
1412 }
1413 }
1414 out.push(depth, format!("<caption>{}</caption>", escape_text(text)));
1415}
1416
1417fn strip_lone_link(text: &str) -> Cow<'_, str> {
1421 if let Some(rest) = text.strip_prefix('[') {
1422 if let Some(close) = rest.find("](") {
1423 if rest.ends_with(')') {
1424 let anchor = &rest[..close];
1425 let uri = &rest[close + 2..rest.len() - 1];
1426 if !anchor.contains(['[', ']']) && !uri.contains(['(', ')']) {
1427 return Cow::Owned(anchor.to_string());
1428 }
1429 }
1430 }
1431 }
1432 Cow::Borrowed(text)
1433}
1434
1435fn sha256_hex(bytes: &[u8]) -> String {
1437 use sha2::{Digest, Sha256};
1438 let mut h = Sha256::new();
1439 h.update(bytes);
1440 h.finalize().iter().map(|b| format!("{b:02x}")).collect()
1441}
1442
1443fn emit_located(out: &mut Out, depth: i32, location: &[u16; 4], inner: &Node) {
1446 match inner {
1447 Node::Heading { level, text } => {
1448 let open = if *level <= 1 {
1449 "heading".to_string()
1450 } else {
1451 format!("heading level=\"{level}\"")
1452 };
1453 emit_text_element(out, depth, &open, "heading", text, Some(location));
1454 }
1455 Node::Paragraph { text } => {
1456 emit_text_element(out, depth, "text", "text", text, Some(location));
1457 }
1458 Node::Picture { caption, image, .. } => {
1459 emit_picture(
1460 out,
1461 depth,
1462 caption.as_deref(),
1463 image.as_ref(),
1464 Some(location),
1465 );
1466 }
1467 Node::Table(t) => {
1468 let mut t = t.clone();
1470 t.location = Some(*location);
1471 emit_table(out, depth, &t);
1472 }
1473 Node::Code { language, text, .. } => {
1474 emit_code(out, depth, language.as_deref(), text, Some(location));
1475 }
1476 other => {
1480 let mut i = 0usize;
1481 emit_nodes(out, depth, std::slice::from_ref(other), &mut i, 0);
1482 }
1483 }
1484}
1485
1486fn emit_list(out: &mut Out, depth: i32, nodes: &[Node], i: &mut usize, level: u8) {
1487 let ordered = match &nodes[*i] {
1490 Node::ListItem { ordered, dclx, .. } => dclx.as_ref().map_or(*ordered, |d| d.ordered),
1491 _ => false,
1492 };
1493 let open = if ordered {
1494 "<list class=\"ordered\">"
1495 } else {
1496 "<list>"
1497 };
1498 out.push(depth, open.to_string());
1499 let start = *i;
1500 let mut prev_number: Option<u64> = None;
1501 while *i < nodes.len() {
1502 match &nodes[*i] {
1503 Node::ListItem {
1504 level: l,
1505 text,
1506 marker,
1507 ordered: o,
1508 number,
1509 first_in_list,
1510 location,
1511 dclx,
1512 href,
1513 layer,
1514 } if *l == level => {
1515 let eff_ordered = dclx.as_ref().map_or(*o, |d| d.ordered);
1518 let eff_marker = dclx.as_ref().map_or(marker.as_ref(), |d| d.marker.as_ref());
1519 if *i != start
1523 && (*first_in_list
1524 || eff_ordered != ordered
1525 || (ordered && Some(*number) != prev_number.map(|n| n + 1)))
1526 {
1527 break;
1528 }
1529 prev_number = Some(*number);
1530 let has_nested = {
1536 let mut found = false;
1537 let mut pn = Some(*number);
1538 let mut j = *i + 1;
1539 while let Some(Node::ListItem {
1540 level: nl,
1541 ordered: no,
1542 number: nn,
1543 first_in_list: nf,
1544 dclx: nd,
1545 ..
1546 }) = nodes.get(j)
1547 {
1548 if *nl > level {
1549 found = true;
1550 break;
1551 }
1552 if *nl < level {
1553 break;
1554 }
1555 let n_ordered = nd.as_ref().map_or(*no, |d| d.ordered);
1558 if *nf
1559 || n_ordered != ordered
1560 || (ordered && Some(*nn) != pn.map(|n| n + 1))
1561 {
1562 break;
1563 }
1564 pn = Some(*nn);
1565 j += 1;
1566 }
1567 found
1568 };
1569 match eff_marker {
1572 Some(m) => {
1573 out.push(depth + 1, "<ldiv>".to_string());
1574 out.push(depth + 2, format!("<marker>{}</marker>", escape_text(m)));
1575 out.push(depth + 1, "</ldiv>".to_string());
1576 }
1577 None => out.push(depth + 1, "<ldiv/>".to_string()),
1578 }
1579 if let Some(loc) = location {
1583 push_location(out, depth + 1, loc);
1584 }
1585 match dclx {
1586 Some(d) if !d.runs.is_empty() => {
1591 if has_nested {
1592 out.push(depth + 1, "<text>".to_string());
1593 emit_inline_runs_body(out, depth + 2, &d.runs);
1594 out.push(depth + 1, "</text>".to_string());
1595 } else {
1596 emit_inline_runs_body(out, depth + 1, &d.runs);
1597 }
1598 }
1599 Some(d) => emit_list_item_content(out, depth + 1, &d.text, has_nested),
1602 None => {
1603 let stripped = strip_lone_link(text);
1608 let eff_href = href
1609 .as_deref()
1610 .filter(|_| matches!(stripped, Cow::Owned(_)));
1611 if eff_href.is_some() || layer.is_some() {
1612 let content: &str = if eff_href.is_some() {
1613 stripped.as_ref()
1614 } else {
1615 text.as_str()
1616 };
1617 emit_list_item_with_head(
1618 out,
1619 depth + 1,
1620 content,
1621 has_nested,
1622 eff_href,
1623 *layer,
1624 );
1625 } else {
1626 emit_list_item_content(out, depth + 1, text, has_nested);
1627 }
1628 }
1629 }
1630 *i += 1;
1631 }
1632 Node::ListItem { level: l, .. } if *l > level => {
1633 emit_list(out, depth + 1, nodes, i, *l);
1634 }
1635 Node::Paragraph { text }
1641 if text.is_empty()
1642 && matches!(
1643 nodes.get(*i + 1),
1644 Some(Node::ListItem { level: nl, ordered: no, number: nn,
1645 first_in_list: nf, dclx: nd, .. })
1646 if *nl > level
1647 || (*nl == level
1648 && !*nf
1649 && nd.as_ref().map_or(*no, |d| d.ordered) == ordered
1650 && (!ordered
1651 || Some(*nn) == prev_number.map(|n| n + 1)))
1652 ) =>
1653 {
1654 *i += 1;
1655 }
1656 _ => break,
1657 }
1658 }
1659 out.push(depth, "</list>".to_string());
1660}
1661
1662fn emit_list_item_with_head(
1673 out: &mut Out,
1674 depth: i32,
1675 text: &str,
1676 has_nested: bool,
1677 href: Option<&str>,
1678 layer: Option<ContentLayer>,
1679) {
1680 let head = |out: &mut Out, d: i32| {
1681 if let Some(uri) = href {
1682 out.push(d, format!("<href uri=\"{}\"/>", attr_escape(uri)));
1683 }
1684 if let Some(l) = layer {
1685 out.push(d, format!("<layer value=\"{}\"/>", l.value()));
1686 }
1687 };
1688 if has_nested {
1689 out.push(depth, "<text>".to_string());
1690 head(out, depth + 1);
1691 emit_runs(out, depth + 1, inline_runs(text));
1692 out.push(depth, "</text>".to_string());
1693 } else {
1694 head(out, depth);
1695 emit_runs(out, depth, inline_runs(text));
1696 }
1697}
1698
1699fn emit_list_item_content(out: &mut Out, depth: i32, text: &str, has_nested: bool) {
1700 let runs = inline_runs_from_markdown(text);
1706 let single_plain = runs.len() <= 1 && runs.first().is_none_or(|r| r.is_plain());
1707 if single_plain {
1708 if has_nested {
1709 emit_text_element(out, depth, "text", "text", text, None);
1710 } else if !text.trim().is_empty() {
1711 emit_text_node(out, depth, text);
1715 }
1716 } else if has_nested {
1717 emit_inline_group(out, depth, false, &runs);
1718 } else {
1719 emit_inline_runs_body(out, depth, &runs);
1722 }
1723}
1724
1725fn emit_field_region(out: &mut Out, depth: i32, items: &[FieldItem]) {
1726 out.push(depth, "<field_region>".to_string());
1727 for item in items {
1728 out.push(depth + 1, "<field_item>".to_string());
1729 if let Some(m) = item.marker.as_ref().filter(|s| !s.is_empty()) {
1730 out.push(depth + 2, format!("<marker>{}</marker>", escape_text(m)));
1731 }
1732 if let Some(k) = item.key.as_ref().filter(|s| !s.is_empty()) {
1733 out.push(depth + 2, format!("<key>{}</key>", escape_text(k)));
1734 }
1735 if let Some(v) = item.value.as_ref().filter(|s| !s.is_empty()) {
1736 out.push(depth + 2, format!("<value>{}</value>", escape_text(v)));
1737 }
1738 out.push(depth + 1, "</field_item>".to_string());
1739 }
1740 out.push(depth, "</field_region>".to_string());
1741}
1742
1743#[cfg(test)]
1744mod tests {
1745 use super::*;
1746
1747 #[test]
1748 fn located_heading_emits_location_tokens_in_block_form() {
1749 let doclang = export_to_doclang(&[Node::Located {
1750 location: [44, 170, 340, 386],
1751 inner: Box::new(Node::Heading {
1752 level: 1,
1753 text: "X-Library".into(),
1754 }),
1755 }]);
1756 assert!(
1757 doclang.contains(
1758 "<heading>\n <location value=\"44\"/>\n <location value=\"170\"/>\n \
1759 <location value=\"340\"/>\n <location value=\"386\"/>\n X-Library\n </heading>"
1760 ),
1761 "got:\n{doclang}"
1762 );
1763 }
1764
1765 fn code(language: Option<&str>, text: &str) -> String {
1766 export_to_doclang(&[Node::Code {
1767 language: language.map(String::from),
1768 text: text.into(),
1769 orig: None,
1770 pretty: None,
1771 }])
1772 }
1773
1774 #[test]
1775 fn code_with_language_emits_linguist_label_block_form() {
1776 assert_eq!(
1779 code(Some("python"), "print(\"Hello world!\")"),
1780 "<doclang version=\"0.7\">\n <code>\n <label value=\"Python\"/>\n\
1781 <![CDATA[print(\"Hello world!\")]]> </code>\n</doclang>"
1782 );
1783 assert!(code(Some("bash"), "ls -la").contains("<label value=\"Shell\"/>"));
1785 }
1786
1787 fn plain(text: &str) -> InlineRun {
1788 InlineRun {
1789 text: text.into(),
1790 ..Default::default()
1791 }
1792 }
1793 fn bold(text: &str) -> InlineRun {
1794 InlineRun {
1795 text: text.into(),
1796 bold: true,
1797 ..Default::default()
1798 }
1799 }
1800 fn ig(unwrapped: bool, runs: Vec<InlineRun>) -> String {
1801 let body = export_to_doclang(&[Node::InlineGroup {
1802 unwrapped,
1803 runs,
1804 md_text: String::new(),
1805 }]);
1806 body.trim_start_matches("<doclang version=\"0.7\">\n")
1808 .trim_end_matches("\n</doclang>")
1809 .to_string()
1810 }
1811
1812 #[test]
1813 fn inline_group_matches_reference_layout() {
1814 assert_eq!(
1816 ig(
1817 false,
1818 vec![plain("This is a"), bold("bold"), plain("example")]
1819 ),
1820 " <text>\n This is a\n <bold>bold</bold>\nexample\n </text>"
1821 );
1822 assert_eq!(
1824 ig(
1825 true,
1826 vec![
1827 plain("aa"),
1828 bold("bb"),
1829 plain("cc"),
1830 bold("dd"),
1831 plain("ee")
1832 ]
1833 ),
1834 "aa\n <bold>bb</bold>\ncc\n <bold>dd</bold>\nee"
1835 );
1836 assert_eq!(
1838 ig(false, vec![plain("aa"), plain("bb")]),
1839 " <text>aa\nbb\n</text>"
1840 );
1841 assert_eq!(ig(false, vec![plain("aa")]), " <text>aa\n</text>");
1842 assert_eq!(
1844 ig(false, vec![bold("bb")]),
1845 " <text>\n <bold>bb</bold>\n </text>"
1846 );
1847 }
1848
1849 #[test]
1850 fn nested_styles_wrap_outermost_last_applied() {
1851 let bi = InlineRun {
1852 text: "bi".into(),
1853 bold: true,
1854 italic: true,
1855 ..Default::default()
1856 };
1857 assert_eq!(
1859 ig(true, vec![bi]),
1860 " <italic>\n <bold>bi</bold>\n </italic>"
1861 );
1862 let sub = InlineRun {
1863 text: "2".into(),
1864 script: Script::Sub,
1865 ..Default::default()
1866 };
1867 assert_eq!(ig(true, vec![sub]), " <subscript>2</subscript>");
1868 }
1869
1870 #[test]
1871 fn furniture_heading_gets_layer_head() {
1872 let out = export_to_doclang(&[Node::Furniture {
1873 layer: ContentLayer::Furniture,
1874 inner: Box::new(Node::Heading {
1875 level: 1,
1876 text: "Anchor Links Test".into(),
1877 }),
1878 }]);
1879 assert_eq!(
1880 out,
1881 "<doclang version=\"0.7\">\n <heading>\n <layer value=\"furniture\"/>\n Anchor Links Test\n </heading>\n</doclang>"
1882 );
1883 }
1884
1885 #[test]
1886 fn text_dump_reproduces_minidom_per_line_layout() {
1887 let text = "PATN\nWKU 1\nPAL K. \"Determination\"\nfollow-up\n*Note A\n_______________\nNote B*\nEND";
1892 let out = export_to_doclang(&[Node::TextDump(text.into())]);
1893 let expected = "<doclang version=\"0.7\">\n \
1894 <text>\n \
1895 PATN\nWKU 1\n\
1896 <![CDATA[PAL K. \"Determination\"]]> \n\
1897 follow-up\n \
1898 <italic>Note A</italic>\n \
1899 <italic>__________</italic>\n \
1900 <italic>Note B</italic>\n\
1901 END\n \
1902 </text>\n</doclang>";
1903 assert_eq!(out, expected, "got:\n{out}");
1904 }
1905
1906 #[test]
1907 fn code_without_language_stays_inline_and_unlabeled() {
1908 assert_eq!(
1909 code(None, "print(\"Hi!\")"),
1910 "<doclang version=\"0.7\">\n <code><![CDATA[print(\"Hi!\")]]></code>\n</doclang>"
1911 );
1912 assert!(!code(Some("brainfuck"), "+++.").contains("<label"));
1914 }
1915}