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 {
783 language,
784 text,
785 orig: _,
786 } => {
787 emit_code(out, depth, language.as_deref(), text, None);
788 *i += 1;
789 }
790 Node::Formula {
793 latex, location, ..
794 } => {
795 if let Some(loc) = location {
796 out.push(depth, "<formula>".to_string());
797 push_location(out, depth + 1, loc);
798 if !latex.is_empty() {
799 out.push(depth + 1, escape_text(latex));
800 }
801 out.push(depth, "</formula>".to_string());
802 } else {
803 out.push(depth, format!("<formula>{}</formula>", escape_text(latex)));
804 }
805 *i += 1;
806 }
807 Node::PageFurniture {
808 footer,
809 location,
810 text,
811 } => {
812 let tag = if *footer {
813 "page_footer"
814 } else {
815 "page_header"
816 };
817 out.push(depth, format!("<{tag}>"));
818 out.push(depth + 1, "<layer value=\"furniture\"/>".to_string());
819 push_location(out, depth + 1, location);
820 if !text.is_empty() {
821 out.push(depth + 1, escape_text(text));
822 }
823 out.push(depth, format!("</{tag}>"));
824 *i += 1;
825 }
826 Node::Table(t) => {
827 emit_table(out, depth, t);
828 *i += 1;
829 }
830 Node::Picture { caption, image, .. } => {
833 emit_picture(out, depth, caption.as_deref(), image.as_ref(), None);
834 *i += 1;
835 }
836 Node::Chart {
837 kind,
838 table,
839 caption,
840 location,
841 } => {
842 emit_chart(
843 out,
844 depth,
845 kind,
846 table,
847 caption.as_deref(),
848 location.as_ref(),
849 );
850 *i += 1;
851 }
852 Node::DoclangOnly(inner) => {
853 let mut j = 0;
854 emit_nodes(out, depth, std::slice::from_ref(inner), &mut j, level);
855 *i += 1;
856 }
857 Node::ListItem { level: l, .. } => {
858 if *l < level {
859 return; }
861 emit_list(out, depth, nodes, i, *l);
862 }
863 Node::Group { children, .. } => {
864 let mut j = 0usize;
865 emit_nodes(out, depth, children, &mut j, 0);
866 *i += 1;
867 }
868 Node::FieldRegion { items } => {
869 emit_field_region(out, depth, items);
870 *i += 1;
871 }
872 Node::InlineGroup {
873 unwrapped, runs, ..
874 } => {
875 emit_inline_group(out, depth, *unwrapped, runs);
876 *i += 1;
877 }
878 Node::Furniture { layer, inner } => {
879 emit_furniture(out, depth, *layer, inner);
880 *i += 1;
881 }
882 Node::Located { location, inner } => {
883 emit_located(out, depth, location, inner);
884 *i += 1;
885 }
886 Node::PageBreak => {
887 out.push(depth, "<page_break/>".to_string());
888 *i += 1;
889 }
890 Node::PageInfo { .. } => {
893 *i += 1;
894 }
895 Node::TextDump(text) => {
896 emit_text_dump(out, depth, text);
897 *i += 1;
898 }
899 }
900 }
901}
902
903enum DumpNode {
906 Text(String),
907 Cdata(String),
908 Elem(String),
909}
910
911fn emit_text_dump(out: &mut Out, depth: i32, text: &str) {
923 let records = dump_records(text);
924 if records.is_empty() {
925 out.push(depth, "<text></text>".to_string());
926 return;
927 }
928 let mut nodes: Vec<DumpNode> = Vec::new();
932 let mut buf = String::new();
933 for (r, (line, italic)) in records.iter().enumerate() {
934 if r > 0 {
935 buf.push('\n'); }
937 let raw = unescape_stored(line);
938 let s = raw.as_ref();
939 let is_cdata = s.contains(['"', '\'', '&', '<', '>']);
940 if *italic || is_cdata {
941 if !buf.is_empty() {
942 nodes.push(DumpNode::Text(std::mem::take(&mut buf)));
943 }
944 let inner = if is_cdata {
945 format!("<![CDATA[{s}]]>")
946 } else {
947 s.to_string()
948 };
949 if *italic {
950 nodes.push(DumpNode::Elem(format!("<italic>{inner}</italic>")));
951 } else {
952 nodes.push(DumpNode::Cdata(inner));
953 }
954 } else {
955 buf.push_str(s);
956 }
957 }
958 if !buf.is_empty() {
959 nodes.push(DumpNode::Text(buf));
960 }
961
962 if let [DumpNode::Text(d)] = nodes.as_slice() {
964 out.push(depth, format!("<text>{d}\n</text>"));
965 return;
966 }
967
968 let ind_child = INDENT.repeat((depth + 1).max(0) as usize);
971 let ind_self = INDENT.repeat(depth.max(0) as usize);
972 let mut raw = String::new();
973 for node in &nodes {
974 match node {
975 DumpNode::Text(d) => {
976 raw.push_str(&ind_child);
977 raw.push_str(d);
978 raw.push('\n');
979 }
980 DumpNode::Cdata(b) => raw.push_str(b),
981 DumpNode::Elem(b) => {
982 raw.push_str(&ind_child);
983 raw.push_str(b);
984 raw.push('\n');
985 }
986 }
987 }
988 let full = format!("{ind_self}<text>\n{raw}{ind_self}</text>");
989 for line in full.split('\n') {
990 if !line.trim().is_empty() {
991 out.push(0, line.to_string());
992 }
993 }
994}
995
996fn dump_records(text: &str) -> Vec<(String, bool)> {
1001 let chars: Vec<char> = text.chars().collect();
1002 let n = chars.len();
1003
1004 struct Delim {
1005 pos: usize,
1006 length: usize,
1007 rem: usize,
1008 can_open: bool,
1009 can_close: bool,
1010 }
1011 let is_ws = |c: Option<char>| c.is_none_or(|c| c.is_whitespace());
1012 let is_punct =
1013 |c: Option<char>| c.is_some_and(|c| "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~".contains(c));
1014
1015 let mut delims: Vec<Delim> = Vec::new();
1017 let mut i = 0;
1018 while i < n {
1019 if chars[i] == '*' {
1020 let mut j = i;
1021 while j < n && chars[j] == '*' {
1022 j += 1;
1023 }
1024 let prev = (i > 0).then(|| chars[i - 1]);
1025 let next = (j < n).then(|| chars[j]);
1026 let left = !is_ws(next) && (!is_punct(next) || is_ws(prev) || is_punct(prev));
1027 let right = !is_ws(prev) && (!is_punct(prev) || is_ws(next) || is_punct(next));
1028 delims.push(Delim {
1029 pos: i,
1030 length: j - i,
1031 rem: j - i,
1032 can_open: left,
1033 can_close: right,
1034 });
1035 i = j;
1036 } else {
1037 i += 1;
1038 }
1039 }
1040
1041 let mut emph = vec![false; n];
1044 let mut consumed = vec![false; n];
1045 let mut ci = 0;
1046 while ci < delims.len() {
1047 if !(delims[ci].can_close && delims[ci].rem > 0) {
1048 ci += 1;
1049 continue;
1050 }
1051 let mut found: Option<usize> = None;
1052 let mut oi = ci as i64 - 1;
1053 while oi >= 0 {
1054 let o = &delims[oi as usize];
1055 let c = &delims[ci];
1056 if o.can_open && o.rem > 0 {
1057 let odd = (o.can_close || c.can_open)
1060 && (o.length + c.length) % 3 == 0
1061 && !(o.length % 3 == 0 && c.length % 3 == 0);
1062 if !odd {
1063 found = Some(oi as usize);
1064 break;
1065 }
1066 }
1067 oi -= 1;
1068 }
1069 let Some(fi) = found else {
1070 ci += 1;
1071 continue;
1072 };
1073 let use_ = if delims[fi].rem >= 2 && delims[ci].rem >= 2 {
1074 2
1075 } else {
1076 1
1077 };
1078 let oend = delims[fi].pos + delims[fi].rem;
1079 for c in consumed.iter_mut().take(oend).skip(oend - use_) {
1080 *c = true;
1081 }
1082 let cstart = delims[ci].pos + (delims[ci].length - delims[ci].rem);
1083 for c in consumed.iter_mut().take(cstart + use_).skip(cstart) {
1084 *c = true;
1085 }
1086 for e in emph.iter_mut().take(cstart).skip(oend) {
1087 *e = true;
1088 }
1089 delims[fi].rem -= use_;
1090 delims[ci].rem -= use_;
1091 delims.drain((fi + 1)..ci);
1092 ci = if delims[fi].rem == 0 { fi + 1 } else { fi };
1093 }
1094
1095 let mut records: Vec<(String, bool)> = Vec::new();
1097 let mut line = String::new();
1098 let mut line_italic = false;
1099 let push_line = |line: &mut String, italic: &mut bool, out: &mut Vec<(String, bool)>| {
1100 let text = std::mem::take(line);
1101 let ital = std::mem::replace(italic, false);
1102 let trimmed = text.trim();
1103 if trimmed.is_empty() {
1104 return;
1105 }
1106 let norm = if trimmed.len() >= 3 && trimmed.chars().all(|c| c == '_') {
1108 "_".repeat(10)
1109 } else {
1110 text
1111 };
1112 out.push((norm, ital));
1113 };
1114 for k in 0..n {
1115 if consumed[k] {
1116 continue;
1117 }
1118 if chars[k] == '\n' {
1119 push_line(&mut line, &mut line_italic, &mut records);
1120 } else {
1121 line.push(chars[k]);
1122 if emph[k] {
1123 line_italic = true;
1124 }
1125 }
1126 }
1127 push_line(&mut line, &mut line_italic, &mut records);
1128 records
1129}
1130
1131fn emit_inline_group(out: &mut Out, depth: i32, unwrapped: bool, runs: &[InlineRun]) {
1145 let has_styled = runs.iter().any(|r| !r.is_plain());
1146
1147 if unwrapped {
1148 for run in runs {
1149 if run.is_plain() {
1150 out.push(0, escape_text(&run.text));
1151 } else if run.formula {
1152 out.push(
1153 depth,
1154 format!("<formula>{}</formula>", escape_text(&run.text)),
1155 );
1156 } else {
1157 emit_styled(out, depth, &style_tags(run), &escape_text(&run.text));
1158 }
1159 }
1160 return;
1161 }
1162
1163 if !has_styled {
1166 let joined = runs
1167 .iter()
1168 .map(|r| escape_text(&r.text))
1169 .collect::<Vec<_>>()
1170 .join("\n");
1171 out.push(depth, format!("<text>{joined}\n</text>"));
1172 return;
1173 }
1174
1175 out.push(depth, "<text>".to_string());
1176 emit_inline_runs_body(out, depth + 1, runs);
1177 out.push(depth, "</text>".to_string());
1178}
1179
1180fn emit_inline_runs_body(out: &mut Out, depth: i32, runs: &[InlineRun]) {
1186 for (i, run) in runs.iter().enumerate() {
1187 if run.is_plain() {
1188 let e = escape_text(&run.text);
1189 let d = if e.starts_with("<content>") || i == 0 {
1190 depth
1191 } else {
1192 0
1193 };
1194 if e.starts_with("<![CDATA[") && i + 1 == runs.len() && d == 0 {
1195 out.push_glue(e);
1200 out.push(depth, "");
1201 } else {
1202 out.push(d, e);
1203 }
1204 } else if run.formula {
1205 out.push(
1206 depth,
1207 format!("<formula>{}</formula>", escape_text(&run.text)),
1208 );
1209 } else {
1210 emit_styled(out, depth, &style_tags(run), &escape_text(&run.text));
1211 }
1212 }
1213}
1214
1215fn style_tags(run: &InlineRun) -> Vec<&'static str> {
1219 let mut tags = Vec::new();
1220 match run.script {
1221 Script::Sub => tags.push("subscript"),
1222 Script::Super => tags.push("superscript"),
1223 Script::Baseline => {}
1224 }
1225 if run.strike {
1226 tags.push("strikethrough");
1227 }
1228 if run.underline {
1229 tags.push("underline");
1230 }
1231 if run.italic {
1232 tags.push("italic");
1233 }
1234 if run.bold {
1235 tags.push("bold");
1236 }
1237 if run.code {
1238 tags.push("code");
1239 }
1240 tags
1241}
1242
1243fn emit_styled(out: &mut Out, depth: i32, tags: &[&str], inner: &str) {
1248 match tags {
1249 [] => emit_text_node(out, depth, inner),
1250 [tag] => out.push(depth, format!("<{tag}>{inner}</{tag}>")),
1251 [tag, rest @ ..] => {
1252 out.push(depth, format!("<{tag}>"));
1253 emit_styled(out, depth + 1, rest, inner);
1254 out.push(depth, format!("</{tag}>"));
1255 }
1256 }
1257}
1258
1259fn emit_furniture(out: &mut Out, depth: i32, layer: ContentLayer, inner: &Node) {
1264 let token = format!("<layer value=\"{}\"/>", layer.value());
1265 match inner {
1266 Node::Heading { level, text } => {
1267 let open = if *level <= 1 {
1268 "heading".to_string()
1269 } else {
1270 format!("heading level=\"{level}\"")
1271 };
1272 out.push(depth, format!("<{open}>"));
1273 out.push(depth + 1, token);
1274 out.push(depth + 1, escape_text(text));
1275 out.push(depth, "</heading>".to_string());
1276 }
1277 Node::Paragraph { text } => {
1278 out.push(depth, "<text>".to_string());
1279 out.push(depth + 1, token);
1280 out.push(depth + 1, escape_text(text));
1281 out.push(depth, "</text>".to_string());
1282 }
1283 Node::Located { location, inner } => {
1286 if let Node::Paragraph { text } = &**inner {
1287 out.push(depth, "<text>".to_string());
1288 out.push(depth + 1, token);
1289 push_location(out, depth + 1, location);
1290 out.push(depth + 1, escape_text(text));
1291 out.push(depth, "</text>".to_string());
1292 } else {
1293 let mut i = 0usize;
1294 emit_nodes(out, depth, std::slice::from_ref(inner.as_ref()), &mut i, 0);
1295 }
1296 }
1297 Node::InlineGroup { runs, .. } => {
1301 out.push(depth, "<text>".to_string());
1302 for run in runs {
1303 out.push(depth + 1, token.clone());
1304 if run.is_plain() {
1305 out.push(depth + 1, escape_text(&run.text));
1306 } else if run.formula {
1307 out.push(
1308 depth + 1,
1309 format!("<formula>{}</formula>", escape_text(&run.text)),
1310 );
1311 } else {
1312 emit_styled(out, depth + 1, &style_tags(run), &escape_text(&run.text));
1313 }
1314 }
1315 out.push(depth, "</text>".to_string());
1316 }
1317 Node::Picture { caption, image, .. } => {
1323 let caption = caption.as_deref().filter(|c| !c.trim().is_empty());
1324 out.push(depth, "<picture>".to_string());
1325 out.push(depth + 1, token.clone());
1326 if let Some(img) = image {
1327 out.push(
1328 depth + 1,
1329 format!(
1330 "<src uri=\"data:image/png;base64,{}\"/>",
1331 crate::base64::encode(&img.data)
1332 ),
1333 );
1334 }
1335 if let Some(c) = caption {
1336 out.push(depth + 1, "<caption>".to_string());
1337 match inline_runs(c).into_iter().next() {
1338 Some(Run::Link { anchor, uri }) => {
1339 out.push(depth + 2, format!("<href uri=\"{}\"/>", attr_escape(&uri)));
1340 out.push(depth + 2, token.clone());
1341 out.push(depth + 2, escape_text(&anchor));
1342 }
1343 _ => {
1344 out.push(depth + 2, token.clone());
1345 out.push(depth + 2, escape_text(c));
1346 }
1347 }
1348 out.push(depth + 1, "</caption>".to_string());
1349 }
1350 out.push(depth, "</picture>".to_string());
1351 }
1352 Node::Table(table) => {
1355 out.push(depth, "<table>".to_string());
1356 out.push(depth + 1, token);
1357 emit_table_rows(out, depth, table);
1358 out.push(depth, "</table>".to_string());
1359 }
1360 other => {
1361 let mut i = 0usize;
1362 emit_nodes(out, depth, std::slice::from_ref(other), &mut i, 0);
1363 }
1364 }
1365}
1366
1367fn emit_picture(
1370 out: &mut Out,
1371 depth: i32,
1372 caption: Option<&str>,
1373 image: Option<&crate::document::PictureImage>,
1374 location: Option<&[u16; 4]>,
1375) {
1376 let caption = caption.filter(|c| !c.trim().is_empty());
1377 let src = image.map(|img| {
1384 let idx = out.pic_index;
1385 out.pic_index += 1;
1386 format!("assets/image_{idx:06}_{}.png", sha256_hex(&img.data))
1387 });
1388 if location.is_none() && caption.is_none() && src.is_none() {
1389 out.push(depth, "<picture></picture>".to_string());
1390 return;
1391 }
1392 out.push(depth, "<picture>".to_string());
1393 if let Some(loc) = location {
1394 push_location(out, depth + 1, loc);
1395 }
1396 if let Some(s) = src {
1397 out.push(depth + 1, format!("<src uri=\"{}\"/>", attr_escape(&s)));
1398 }
1399 if let Some(c) = caption {
1400 emit_caption(out, depth + 1, c);
1401 }
1402 out.push(depth, "</picture>".to_string());
1403}
1404
1405fn emit_caption(out: &mut Out, depth: i32, text: &str) {
1409 if let Some(Run::Link { anchor, uri }) = inline_runs(text).into_iter().next() {
1410 if inline_runs(text).len() == 1 {
1411 out.push(depth, "<caption>".to_string());
1412 out.push(depth + 1, format!("<href uri=\"{}\"/>", attr_escape(&uri)));
1413 out.push(depth + 1, escape_text(&anchor));
1414 out.push(depth, "</caption>".to_string());
1415 return;
1416 }
1417 }
1418 out.push(depth, format!("<caption>{}</caption>", escape_text(text)));
1419}
1420
1421fn strip_lone_link(text: &str) -> Cow<'_, str> {
1425 if let Some(rest) = text.strip_prefix('[') {
1426 if let Some(close) = rest.find("](") {
1427 if rest.ends_with(')') {
1428 let anchor = &rest[..close];
1429 let uri = &rest[close + 2..rest.len() - 1];
1430 if !anchor.contains(['[', ']']) && !uri.contains(['(', ')']) {
1431 return Cow::Owned(anchor.to_string());
1432 }
1433 }
1434 }
1435 }
1436 Cow::Borrowed(text)
1437}
1438
1439fn sha256_hex(bytes: &[u8]) -> String {
1441 use sha2::{Digest, Sha256};
1442 let mut h = Sha256::new();
1443 h.update(bytes);
1444 h.finalize().iter().map(|b| format!("{b:02x}")).collect()
1445}
1446
1447fn emit_located(out: &mut Out, depth: i32, location: &[u16; 4], inner: &Node) {
1450 match inner {
1451 Node::Heading { level, text } => {
1452 let open = if *level <= 1 {
1453 "heading".to_string()
1454 } else {
1455 format!("heading level=\"{level}\"")
1456 };
1457 emit_text_element(out, depth, &open, "heading", text, Some(location));
1458 }
1459 Node::Paragraph { text } => {
1460 emit_text_element(out, depth, "text", "text", text, Some(location));
1461 }
1462 Node::Picture { caption, image, .. } => {
1463 emit_picture(
1464 out,
1465 depth,
1466 caption.as_deref(),
1467 image.as_ref(),
1468 Some(location),
1469 );
1470 }
1471 Node::Table(t) => {
1472 let mut t = t.clone();
1474 t.location = Some(*location);
1475 emit_table(out, depth, &t);
1476 }
1477 Node::Code { language, text, .. } => {
1478 emit_code(out, depth, language.as_deref(), text, Some(location));
1479 }
1480 other => {
1484 let mut i = 0usize;
1485 emit_nodes(out, depth, std::slice::from_ref(other), &mut i, 0);
1486 }
1487 }
1488}
1489
1490fn emit_list(out: &mut Out, depth: i32, nodes: &[Node], i: &mut usize, level: u8) {
1491 let ordered = match &nodes[*i] {
1494 Node::ListItem { ordered, dclx, .. } => dclx.as_ref().map_or(*ordered, |d| d.ordered),
1495 _ => false,
1496 };
1497 let open = if ordered {
1498 "<list class=\"ordered\">"
1499 } else {
1500 "<list>"
1501 };
1502 out.push(depth, open.to_string());
1503 let start = *i;
1504 let mut prev_number: Option<u64> = None;
1505 while *i < nodes.len() {
1506 match &nodes[*i] {
1507 Node::ListItem {
1508 level: l,
1509 text,
1510 marker,
1511 ordered: o,
1512 number,
1513 first_in_list,
1514 location,
1515 dclx,
1516 href,
1517 layer,
1518 } if *l == level => {
1519 let eff_ordered = dclx.as_ref().map_or(*o, |d| d.ordered);
1522 let eff_marker = dclx.as_ref().map_or(marker.as_ref(), |d| d.marker.as_ref());
1523 if *i != start
1527 && (*first_in_list
1528 || eff_ordered != ordered
1529 || (ordered && Some(*number) != prev_number.map(|n| n + 1)))
1530 {
1531 break;
1532 }
1533 prev_number = Some(*number);
1534 let has_nested = {
1540 let mut found = false;
1541 let mut pn = Some(*number);
1542 let mut j = *i + 1;
1543 while let Some(Node::ListItem {
1544 level: nl,
1545 ordered: no,
1546 number: nn,
1547 first_in_list: nf,
1548 dclx: nd,
1549 ..
1550 }) = nodes.get(j)
1551 {
1552 if *nl > level {
1553 found = true;
1554 break;
1555 }
1556 if *nl < level {
1557 break;
1558 }
1559 let n_ordered = nd.as_ref().map_or(*no, |d| d.ordered);
1562 if *nf
1563 || n_ordered != ordered
1564 || (ordered && Some(*nn) != pn.map(|n| n + 1))
1565 {
1566 break;
1567 }
1568 pn = Some(*nn);
1569 j += 1;
1570 }
1571 found
1572 };
1573 match eff_marker {
1576 Some(m) => {
1577 out.push(depth + 1, "<ldiv>".to_string());
1578 out.push(depth + 2, format!("<marker>{}</marker>", escape_text(m)));
1579 out.push(depth + 1, "</ldiv>".to_string());
1580 }
1581 None => out.push(depth + 1, "<ldiv/>".to_string()),
1582 }
1583 if let Some(loc) = location {
1587 push_location(out, depth + 1, loc);
1588 }
1589 match dclx {
1590 Some(d) if !d.runs.is_empty() => {
1595 if has_nested {
1596 out.push(depth + 1, "<text>".to_string());
1597 emit_inline_runs_body(out, depth + 2, &d.runs);
1598 out.push(depth + 1, "</text>".to_string());
1599 } else {
1600 emit_inline_runs_body(out, depth + 1, &d.runs);
1601 }
1602 }
1603 Some(d) => emit_list_item_content(out, depth + 1, &d.text, has_nested),
1606 None => {
1607 let stripped = strip_lone_link(text);
1612 let eff_href = href
1613 .as_deref()
1614 .filter(|_| matches!(stripped, Cow::Owned(_)));
1615 if eff_href.is_some() || layer.is_some() {
1616 let content: &str = if eff_href.is_some() {
1617 stripped.as_ref()
1618 } else {
1619 text.as_str()
1620 };
1621 emit_list_item_with_head(
1622 out,
1623 depth + 1,
1624 content,
1625 has_nested,
1626 eff_href,
1627 *layer,
1628 );
1629 } else {
1630 emit_list_item_content(out, depth + 1, text, has_nested);
1631 }
1632 }
1633 }
1634 *i += 1;
1635 }
1636 Node::ListItem { level: l, .. } if *l > level => {
1637 emit_list(out, depth + 1, nodes, i, *l);
1638 }
1639 Node::Paragraph { text }
1645 if text.is_empty()
1646 && matches!(
1647 nodes.get(*i + 1),
1648 Some(Node::ListItem { level: nl, ordered: no, number: nn,
1649 first_in_list: nf, dclx: nd, .. })
1650 if *nl > level
1651 || (*nl == level
1652 && !*nf
1653 && nd.as_ref().map_or(*no, |d| d.ordered) == ordered
1654 && (!ordered
1655 || Some(*nn) == prev_number.map(|n| n + 1)))
1656 ) =>
1657 {
1658 *i += 1;
1659 }
1660 _ => break,
1661 }
1662 }
1663 out.push(depth, "</list>".to_string());
1664}
1665
1666fn emit_list_item_with_head(
1677 out: &mut Out,
1678 depth: i32,
1679 text: &str,
1680 has_nested: bool,
1681 href: Option<&str>,
1682 layer: Option<ContentLayer>,
1683) {
1684 let head = |out: &mut Out, d: i32| {
1685 if let Some(uri) = href {
1686 out.push(d, format!("<href uri=\"{}\"/>", attr_escape(uri)));
1687 }
1688 if let Some(l) = layer {
1689 out.push(d, format!("<layer value=\"{}\"/>", l.value()));
1690 }
1691 };
1692 if has_nested {
1693 out.push(depth, "<text>".to_string());
1694 head(out, depth + 1);
1695 emit_runs(out, depth + 1, inline_runs(text));
1696 out.push(depth, "</text>".to_string());
1697 } else {
1698 head(out, depth);
1699 emit_runs(out, depth, inline_runs(text));
1700 }
1701}
1702
1703fn emit_list_item_content(out: &mut Out, depth: i32, text: &str, has_nested: bool) {
1704 let runs = inline_runs_from_markdown(text);
1710 let single_plain = runs.len() <= 1 && runs.first().is_none_or(|r| r.is_plain());
1711 if single_plain {
1712 if has_nested {
1713 emit_text_element(out, depth, "text", "text", text, None);
1714 } else if !text.trim().is_empty() {
1715 emit_text_node(out, depth, text);
1719 }
1720 } else if has_nested {
1721 emit_inline_group(out, depth, false, &runs);
1722 } else {
1723 emit_inline_runs_body(out, depth, &runs);
1726 }
1727}
1728
1729fn emit_field_region(out: &mut Out, depth: i32, items: &[FieldItem]) {
1730 out.push(depth, "<field_region>".to_string());
1731 for item in items {
1732 out.push(depth + 1, "<field_item>".to_string());
1733 if let Some(m) = item.marker.as_ref().filter(|s| !s.is_empty()) {
1734 out.push(depth + 2, format!("<marker>{}</marker>", escape_text(m)));
1735 }
1736 if let Some(k) = item.key.as_ref().filter(|s| !s.is_empty()) {
1737 out.push(depth + 2, format!("<key>{}</key>", escape_text(k)));
1738 }
1739 if let Some(v) = item.value.as_ref().filter(|s| !s.is_empty()) {
1740 out.push(depth + 2, format!("<value>{}</value>", escape_text(v)));
1741 }
1742 out.push(depth + 1, "</field_item>".to_string());
1743 }
1744 out.push(depth, "</field_region>".to_string());
1745}
1746
1747#[cfg(test)]
1748mod tests {
1749 use super::*;
1750
1751 #[test]
1752 fn located_heading_emits_location_tokens_in_block_form() {
1753 let doclang = export_to_doclang(&[Node::Located {
1754 location: [44, 170, 340, 386],
1755 inner: Box::new(Node::Heading {
1756 level: 1,
1757 text: "X-Library".into(),
1758 }),
1759 }]);
1760 assert!(
1761 doclang.contains(
1762 "<heading>\n <location value=\"44\"/>\n <location value=\"170\"/>\n \
1763 <location value=\"340\"/>\n <location value=\"386\"/>\n X-Library\n </heading>"
1764 ),
1765 "got:\n{doclang}"
1766 );
1767 }
1768
1769 fn code(language: Option<&str>, text: &str) -> String {
1770 export_to_doclang(&[Node::Code {
1771 language: language.map(String::from),
1772 text: text.into(),
1773 orig: None,
1774 }])
1775 }
1776
1777 #[test]
1778 fn code_with_language_emits_linguist_label_block_form() {
1779 assert_eq!(
1782 code(Some("python"), "print(\"Hello world!\")"),
1783 "<doclang version=\"0.7\">\n <code>\n <label value=\"Python\"/>\n\
1784 <![CDATA[print(\"Hello world!\")]]> </code>\n</doclang>"
1785 );
1786 assert!(code(Some("bash"), "ls -la").contains("<label value=\"Shell\"/>"));
1788 }
1789
1790 fn plain(text: &str) -> InlineRun {
1791 InlineRun {
1792 text: text.into(),
1793 ..Default::default()
1794 }
1795 }
1796 fn bold(text: &str) -> InlineRun {
1797 InlineRun {
1798 text: text.into(),
1799 bold: true,
1800 ..Default::default()
1801 }
1802 }
1803 fn ig(unwrapped: bool, runs: Vec<InlineRun>) -> String {
1804 let body = export_to_doclang(&[Node::InlineGroup {
1805 unwrapped,
1806 runs,
1807 md_text: String::new(),
1808 }]);
1809 body.trim_start_matches("<doclang version=\"0.7\">\n")
1811 .trim_end_matches("\n</doclang>")
1812 .to_string()
1813 }
1814
1815 #[test]
1816 fn inline_group_matches_reference_layout() {
1817 assert_eq!(
1819 ig(
1820 false,
1821 vec![plain("This is a"), bold("bold"), plain("example")]
1822 ),
1823 " <text>\n This is a\n <bold>bold</bold>\nexample\n </text>"
1824 );
1825 assert_eq!(
1827 ig(
1828 true,
1829 vec![
1830 plain("aa"),
1831 bold("bb"),
1832 plain("cc"),
1833 bold("dd"),
1834 plain("ee")
1835 ]
1836 ),
1837 "aa\n <bold>bb</bold>\ncc\n <bold>dd</bold>\nee"
1838 );
1839 assert_eq!(
1841 ig(false, vec![plain("aa"), plain("bb")]),
1842 " <text>aa\nbb\n</text>"
1843 );
1844 assert_eq!(ig(false, vec![plain("aa")]), " <text>aa\n</text>");
1845 assert_eq!(
1847 ig(false, vec![bold("bb")]),
1848 " <text>\n <bold>bb</bold>\n </text>"
1849 );
1850 }
1851
1852 #[test]
1853 fn nested_styles_wrap_outermost_last_applied() {
1854 let bi = InlineRun {
1855 text: "bi".into(),
1856 bold: true,
1857 italic: true,
1858 ..Default::default()
1859 };
1860 assert_eq!(
1862 ig(true, vec![bi]),
1863 " <italic>\n <bold>bi</bold>\n </italic>"
1864 );
1865 let sub = InlineRun {
1866 text: "2".into(),
1867 script: Script::Sub,
1868 ..Default::default()
1869 };
1870 assert_eq!(ig(true, vec![sub]), " <subscript>2</subscript>");
1871 }
1872
1873 #[test]
1874 fn furniture_heading_gets_layer_head() {
1875 let out = export_to_doclang(&[Node::Furniture {
1876 layer: ContentLayer::Furniture,
1877 inner: Box::new(Node::Heading {
1878 level: 1,
1879 text: "Anchor Links Test".into(),
1880 }),
1881 }]);
1882 assert_eq!(
1883 out,
1884 "<doclang version=\"0.7\">\n <heading>\n <layer value=\"furniture\"/>\n Anchor Links Test\n </heading>\n</doclang>"
1885 );
1886 }
1887
1888 #[test]
1889 fn text_dump_reproduces_minidom_per_line_layout() {
1890 let text = "PATN\nWKU 1\nPAL K. \"Determination\"\nfollow-up\n*Note A\n_______________\nNote B*\nEND";
1895 let out = export_to_doclang(&[Node::TextDump(text.into())]);
1896 let expected = "<doclang version=\"0.7\">\n \
1897 <text>\n \
1898 PATN\nWKU 1\n\
1899 <![CDATA[PAL K. \"Determination\"]]> \n\
1900 follow-up\n \
1901 <italic>Note A</italic>\n \
1902 <italic>__________</italic>\n \
1903 <italic>Note B</italic>\n\
1904 END\n \
1905 </text>\n</doclang>";
1906 assert_eq!(out, expected, "got:\n{out}");
1907 }
1908
1909 #[test]
1910 fn code_without_language_stays_inline_and_unlabeled() {
1911 assert_eq!(
1912 code(None, "print(\"Hi!\")"),
1913 "<doclang version=\"0.7\">\n <code><![CDATA[print(\"Hi!\")]]></code>\n</doclang>"
1914 );
1915 assert!(!code(Some("brainfuck"), "+++.").contains("<label"));
1917 }
1918}