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::PageInfo { .. } => {
888 *i += 1;
889 }
890 Node::TextDump(text) => {
891 emit_text_dump(out, depth, text);
892 *i += 1;
893 }
894 }
895 }
896}
897
898enum DumpNode {
901 Text(String),
902 Cdata(String),
903 Elem(String),
904}
905
906fn emit_text_dump(out: &mut Out, depth: i32, text: &str) {
918 let records = dump_records(text);
919 if records.is_empty() {
920 out.push(depth, "<text></text>".to_string());
921 return;
922 }
923 let mut nodes: Vec<DumpNode> = Vec::new();
927 let mut buf = String::new();
928 for (r, (line, italic)) in records.iter().enumerate() {
929 if r > 0 {
930 buf.push('\n'); }
932 let raw = unescape_stored(line);
933 let s = raw.as_ref();
934 let is_cdata = s.contains(['"', '\'', '&', '<', '>']);
935 if *italic || is_cdata {
936 if !buf.is_empty() {
937 nodes.push(DumpNode::Text(std::mem::take(&mut buf)));
938 }
939 let inner = if is_cdata {
940 format!("<![CDATA[{s}]]>")
941 } else {
942 s.to_string()
943 };
944 if *italic {
945 nodes.push(DumpNode::Elem(format!("<italic>{inner}</italic>")));
946 } else {
947 nodes.push(DumpNode::Cdata(inner));
948 }
949 } else {
950 buf.push_str(s);
951 }
952 }
953 if !buf.is_empty() {
954 nodes.push(DumpNode::Text(buf));
955 }
956
957 if let [DumpNode::Text(d)] = nodes.as_slice() {
959 out.push(depth, format!("<text>{d}\n</text>"));
960 return;
961 }
962
963 let ind_child = INDENT.repeat((depth + 1).max(0) as usize);
966 let ind_self = INDENT.repeat(depth.max(0) as usize);
967 let mut raw = String::new();
968 for node in &nodes {
969 match node {
970 DumpNode::Text(d) => {
971 raw.push_str(&ind_child);
972 raw.push_str(d);
973 raw.push('\n');
974 }
975 DumpNode::Cdata(b) => raw.push_str(b),
976 DumpNode::Elem(b) => {
977 raw.push_str(&ind_child);
978 raw.push_str(b);
979 raw.push('\n');
980 }
981 }
982 }
983 let full = format!("{ind_self}<text>\n{raw}{ind_self}</text>");
984 for line in full.split('\n') {
985 if !line.trim().is_empty() {
986 out.push(0, line.to_string());
987 }
988 }
989}
990
991fn dump_records(text: &str) -> Vec<(String, bool)> {
996 let chars: Vec<char> = text.chars().collect();
997 let n = chars.len();
998
999 struct Delim {
1000 pos: usize,
1001 length: usize,
1002 rem: usize,
1003 can_open: bool,
1004 can_close: bool,
1005 }
1006 let is_ws = |c: Option<char>| c.is_none_or(|c| c.is_whitespace());
1007 let is_punct =
1008 |c: Option<char>| c.is_some_and(|c| "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~".contains(c));
1009
1010 let mut delims: Vec<Delim> = Vec::new();
1012 let mut i = 0;
1013 while i < n {
1014 if chars[i] == '*' {
1015 let mut j = i;
1016 while j < n && chars[j] == '*' {
1017 j += 1;
1018 }
1019 let prev = (i > 0).then(|| chars[i - 1]);
1020 let next = (j < n).then(|| chars[j]);
1021 let left = !is_ws(next) && (!is_punct(next) || is_ws(prev) || is_punct(prev));
1022 let right = !is_ws(prev) && (!is_punct(prev) || is_ws(next) || is_punct(next));
1023 delims.push(Delim {
1024 pos: i,
1025 length: j - i,
1026 rem: j - i,
1027 can_open: left,
1028 can_close: right,
1029 });
1030 i = j;
1031 } else {
1032 i += 1;
1033 }
1034 }
1035
1036 let mut emph = vec![false; n];
1039 let mut consumed = vec![false; n];
1040 let mut ci = 0;
1041 while ci < delims.len() {
1042 if !(delims[ci].can_close && delims[ci].rem > 0) {
1043 ci += 1;
1044 continue;
1045 }
1046 let mut found: Option<usize> = None;
1047 let mut oi = ci as i64 - 1;
1048 while oi >= 0 {
1049 let o = &delims[oi as usize];
1050 let c = &delims[ci];
1051 if o.can_open && o.rem > 0 {
1052 let odd = (o.can_close || c.can_open)
1055 && (o.length + c.length) % 3 == 0
1056 && !(o.length % 3 == 0 && c.length % 3 == 0);
1057 if !odd {
1058 found = Some(oi as usize);
1059 break;
1060 }
1061 }
1062 oi -= 1;
1063 }
1064 let Some(fi) = found else {
1065 ci += 1;
1066 continue;
1067 };
1068 let use_ = if delims[fi].rem >= 2 && delims[ci].rem >= 2 {
1069 2
1070 } else {
1071 1
1072 };
1073 let oend = delims[fi].pos + delims[fi].rem;
1074 for c in consumed.iter_mut().take(oend).skip(oend - use_) {
1075 *c = true;
1076 }
1077 let cstart = delims[ci].pos + (delims[ci].length - delims[ci].rem);
1078 for c in consumed.iter_mut().take(cstart + use_).skip(cstart) {
1079 *c = true;
1080 }
1081 for e in emph.iter_mut().take(cstart).skip(oend) {
1082 *e = true;
1083 }
1084 delims[fi].rem -= use_;
1085 delims[ci].rem -= use_;
1086 delims.drain((fi + 1)..ci);
1087 ci = if delims[fi].rem == 0 { fi + 1 } else { fi };
1088 }
1089
1090 let mut records: Vec<(String, bool)> = Vec::new();
1092 let mut line = String::new();
1093 let mut line_italic = false;
1094 let push_line = |line: &mut String, italic: &mut bool, out: &mut Vec<(String, bool)>| {
1095 let text = std::mem::take(line);
1096 let ital = std::mem::replace(italic, false);
1097 let trimmed = text.trim();
1098 if trimmed.is_empty() {
1099 return;
1100 }
1101 let norm = if trimmed.len() >= 3 && trimmed.chars().all(|c| c == '_') {
1103 "_".repeat(10)
1104 } else {
1105 text
1106 };
1107 out.push((norm, ital));
1108 };
1109 for k in 0..n {
1110 if consumed[k] {
1111 continue;
1112 }
1113 if chars[k] == '\n' {
1114 push_line(&mut line, &mut line_italic, &mut records);
1115 } else {
1116 line.push(chars[k]);
1117 if emph[k] {
1118 line_italic = true;
1119 }
1120 }
1121 }
1122 push_line(&mut line, &mut line_italic, &mut records);
1123 records
1124}
1125
1126fn emit_inline_group(out: &mut Out, depth: i32, unwrapped: bool, runs: &[InlineRun]) {
1140 let has_styled = runs.iter().any(|r| !r.is_plain());
1141
1142 if unwrapped {
1143 for run in runs {
1144 if run.is_plain() {
1145 out.push(0, escape_text(&run.text));
1146 } else if run.formula {
1147 out.push(
1148 depth,
1149 format!("<formula>{}</formula>", escape_text(&run.text)),
1150 );
1151 } else {
1152 emit_styled(out, depth, &style_tags(run), &escape_text(&run.text));
1153 }
1154 }
1155 return;
1156 }
1157
1158 if !has_styled {
1161 let joined = runs
1162 .iter()
1163 .map(|r| escape_text(&r.text))
1164 .collect::<Vec<_>>()
1165 .join("\n");
1166 out.push(depth, format!("<text>{joined}\n</text>"));
1167 return;
1168 }
1169
1170 out.push(depth, "<text>".to_string());
1171 emit_inline_runs_body(out, depth + 1, runs);
1172 out.push(depth, "</text>".to_string());
1173}
1174
1175fn emit_inline_runs_body(out: &mut Out, depth: i32, runs: &[InlineRun]) {
1181 for (i, run) in runs.iter().enumerate() {
1182 if run.is_plain() {
1183 let e = escape_text(&run.text);
1184 let d = if e.starts_with("<content>") || i == 0 {
1185 depth
1186 } else {
1187 0
1188 };
1189 if e.starts_with("<![CDATA[") && i + 1 == runs.len() && d == 0 {
1190 out.push_glue(e);
1195 out.push(depth, "");
1196 } else {
1197 out.push(d, e);
1198 }
1199 } else if run.formula {
1200 out.push(
1201 depth,
1202 format!("<formula>{}</formula>", escape_text(&run.text)),
1203 );
1204 } else {
1205 emit_styled(out, depth, &style_tags(run), &escape_text(&run.text));
1206 }
1207 }
1208}
1209
1210fn style_tags(run: &InlineRun) -> Vec<&'static str> {
1214 let mut tags = Vec::new();
1215 match run.script {
1216 Script::Sub => tags.push("subscript"),
1217 Script::Super => tags.push("superscript"),
1218 Script::Baseline => {}
1219 }
1220 if run.strike {
1221 tags.push("strikethrough");
1222 }
1223 if run.underline {
1224 tags.push("underline");
1225 }
1226 if run.italic {
1227 tags.push("italic");
1228 }
1229 if run.bold {
1230 tags.push("bold");
1231 }
1232 if run.code {
1233 tags.push("code");
1234 }
1235 tags
1236}
1237
1238fn emit_styled(out: &mut Out, depth: i32, tags: &[&str], inner: &str) {
1243 match tags {
1244 [] => emit_text_node(out, depth, inner),
1245 [tag] => out.push(depth, format!("<{tag}>{inner}</{tag}>")),
1246 [tag, rest @ ..] => {
1247 out.push(depth, format!("<{tag}>"));
1248 emit_styled(out, depth + 1, rest, inner);
1249 out.push(depth, format!("</{tag}>"));
1250 }
1251 }
1252}
1253
1254fn emit_furniture(out: &mut Out, depth: i32, layer: ContentLayer, inner: &Node) {
1259 let token = format!("<layer value=\"{}\"/>", layer.value());
1260 match inner {
1261 Node::Heading { level, text } => {
1262 let open = if *level <= 1 {
1263 "heading".to_string()
1264 } else {
1265 format!("heading level=\"{level}\"")
1266 };
1267 out.push(depth, format!("<{open}>"));
1268 out.push(depth + 1, token);
1269 out.push(depth + 1, escape_text(text));
1270 out.push(depth, "</heading>".to_string());
1271 }
1272 Node::Paragraph { text } => {
1273 out.push(depth, "<text>".to_string());
1274 out.push(depth + 1, token);
1275 out.push(depth + 1, escape_text(text));
1276 out.push(depth, "</text>".to_string());
1277 }
1278 Node::Located { location, inner } => {
1281 if let Node::Paragraph { text } = &**inner {
1282 out.push(depth, "<text>".to_string());
1283 out.push(depth + 1, token);
1284 push_location(out, depth + 1, location);
1285 out.push(depth + 1, escape_text(text));
1286 out.push(depth, "</text>".to_string());
1287 } else {
1288 let mut i = 0usize;
1289 emit_nodes(out, depth, std::slice::from_ref(inner.as_ref()), &mut i, 0);
1290 }
1291 }
1292 Node::InlineGroup { runs, .. } => {
1296 out.push(depth, "<text>".to_string());
1297 for run in runs {
1298 out.push(depth + 1, token.clone());
1299 if run.is_plain() {
1300 out.push(depth + 1, escape_text(&run.text));
1301 } else if run.formula {
1302 out.push(
1303 depth + 1,
1304 format!("<formula>{}</formula>", escape_text(&run.text)),
1305 );
1306 } else {
1307 emit_styled(out, depth + 1, &style_tags(run), &escape_text(&run.text));
1308 }
1309 }
1310 out.push(depth, "</text>".to_string());
1311 }
1312 Node::Picture { caption, image, .. } => {
1318 let caption = caption.as_deref().filter(|c| !c.trim().is_empty());
1319 out.push(depth, "<picture>".to_string());
1320 out.push(depth + 1, token.clone());
1321 if let Some(img) = image {
1322 out.push(
1323 depth + 1,
1324 format!(
1325 "<src uri=\"data:image/png;base64,{}\"/>",
1326 crate::base64::encode(&img.data)
1327 ),
1328 );
1329 }
1330 if let Some(c) = caption {
1331 out.push(depth + 1, "<caption>".to_string());
1332 match inline_runs(c).into_iter().next() {
1333 Some(Run::Link { anchor, uri }) => {
1334 out.push(depth + 2, format!("<href uri=\"{}\"/>", attr_escape(&uri)));
1335 out.push(depth + 2, token.clone());
1336 out.push(depth + 2, escape_text(&anchor));
1337 }
1338 _ => {
1339 out.push(depth + 2, token.clone());
1340 out.push(depth + 2, escape_text(c));
1341 }
1342 }
1343 out.push(depth + 1, "</caption>".to_string());
1344 }
1345 out.push(depth, "</picture>".to_string());
1346 }
1347 Node::Table(table) => {
1350 out.push(depth, "<table>".to_string());
1351 out.push(depth + 1, token);
1352 emit_table_rows(out, depth, table);
1353 out.push(depth, "</table>".to_string());
1354 }
1355 other => {
1356 let mut i = 0usize;
1357 emit_nodes(out, depth, std::slice::from_ref(other), &mut i, 0);
1358 }
1359 }
1360}
1361
1362fn emit_picture(
1365 out: &mut Out,
1366 depth: i32,
1367 caption: Option<&str>,
1368 image: Option<&crate::document::PictureImage>,
1369 location: Option<&[u16; 4]>,
1370) {
1371 let caption = caption.filter(|c| !c.trim().is_empty());
1372 let src = image.map(|img| {
1379 let idx = out.pic_index;
1380 out.pic_index += 1;
1381 format!("assets/image_{idx:06}_{}.png", sha256_hex(&img.data))
1382 });
1383 if location.is_none() && caption.is_none() && src.is_none() {
1384 out.push(depth, "<picture></picture>".to_string());
1385 return;
1386 }
1387 out.push(depth, "<picture>".to_string());
1388 if let Some(loc) = location {
1389 push_location(out, depth + 1, loc);
1390 }
1391 if let Some(s) = src {
1392 out.push(depth + 1, format!("<src uri=\"{}\"/>", attr_escape(&s)));
1393 }
1394 if let Some(c) = caption {
1395 emit_caption(out, depth + 1, c);
1396 }
1397 out.push(depth, "</picture>".to_string());
1398}
1399
1400fn emit_caption(out: &mut Out, depth: i32, text: &str) {
1404 if let Some(Run::Link { anchor, uri }) = inline_runs(text).into_iter().next() {
1405 if inline_runs(text).len() == 1 {
1406 out.push(depth, "<caption>".to_string());
1407 out.push(depth + 1, format!("<href uri=\"{}\"/>", attr_escape(&uri)));
1408 out.push(depth + 1, escape_text(&anchor));
1409 out.push(depth, "</caption>".to_string());
1410 return;
1411 }
1412 }
1413 out.push(depth, format!("<caption>{}</caption>", escape_text(text)));
1414}
1415
1416fn strip_lone_link(text: &str) -> Cow<'_, str> {
1420 if let Some(rest) = text.strip_prefix('[') {
1421 if let Some(close) = rest.find("](") {
1422 if rest.ends_with(')') {
1423 let anchor = &rest[..close];
1424 let uri = &rest[close + 2..rest.len() - 1];
1425 if !anchor.contains(['[', ']']) && !uri.contains(['(', ')']) {
1426 return Cow::Owned(anchor.to_string());
1427 }
1428 }
1429 }
1430 }
1431 Cow::Borrowed(text)
1432}
1433
1434fn sha256_hex(bytes: &[u8]) -> String {
1436 use sha2::{Digest, Sha256};
1437 let mut h = Sha256::new();
1438 h.update(bytes);
1439 h.finalize().iter().map(|b| format!("{b:02x}")).collect()
1440}
1441
1442fn emit_located(out: &mut Out, depth: i32, location: &[u16; 4], inner: &Node) {
1445 match inner {
1446 Node::Heading { level, text } => {
1447 let open = if *level <= 1 {
1448 "heading".to_string()
1449 } else {
1450 format!("heading level=\"{level}\"")
1451 };
1452 emit_text_element(out, depth, &open, "heading", text, Some(location));
1453 }
1454 Node::Paragraph { text } => {
1455 emit_text_element(out, depth, "text", "text", text, Some(location));
1456 }
1457 Node::Picture { caption, image, .. } => {
1458 emit_picture(
1459 out,
1460 depth,
1461 caption.as_deref(),
1462 image.as_ref(),
1463 Some(location),
1464 );
1465 }
1466 Node::Table(t) => {
1467 let mut t = t.clone();
1469 t.location = Some(*location);
1470 emit_table(out, depth, &t);
1471 }
1472 Node::Code { language, text, .. } => {
1473 emit_code(out, depth, language.as_deref(), text, Some(location));
1474 }
1475 other => {
1479 let mut i = 0usize;
1480 emit_nodes(out, depth, std::slice::from_ref(other), &mut i, 0);
1481 }
1482 }
1483}
1484
1485fn emit_list(out: &mut Out, depth: i32, nodes: &[Node], i: &mut usize, level: u8) {
1486 let ordered = match &nodes[*i] {
1489 Node::ListItem { ordered, dclx, .. } => dclx.as_ref().map_or(*ordered, |d| d.ordered),
1490 _ => false,
1491 };
1492 let open = if ordered {
1493 "<list class=\"ordered\">"
1494 } else {
1495 "<list>"
1496 };
1497 out.push(depth, open.to_string());
1498 let start = *i;
1499 let mut prev_number: Option<u64> = None;
1500 while *i < nodes.len() {
1501 match &nodes[*i] {
1502 Node::ListItem {
1503 level: l,
1504 text,
1505 marker,
1506 ordered: o,
1507 number,
1508 first_in_list,
1509 location,
1510 dclx,
1511 href,
1512 layer,
1513 } if *l == level => {
1514 let eff_ordered = dclx.as_ref().map_or(*o, |d| d.ordered);
1517 let eff_marker = dclx.as_ref().map_or(marker.as_ref(), |d| d.marker.as_ref());
1518 if *i != start
1522 && (*first_in_list
1523 || eff_ordered != ordered
1524 || (ordered && Some(*number) != prev_number.map(|n| n + 1)))
1525 {
1526 break;
1527 }
1528 prev_number = Some(*number);
1529 let has_nested = {
1535 let mut found = false;
1536 let mut pn = Some(*number);
1537 let mut j = *i + 1;
1538 while let Some(Node::ListItem {
1539 level: nl,
1540 ordered: no,
1541 number: nn,
1542 first_in_list: nf,
1543 dclx: nd,
1544 ..
1545 }) = nodes.get(j)
1546 {
1547 if *nl > level {
1548 found = true;
1549 break;
1550 }
1551 if *nl < level {
1552 break;
1553 }
1554 let n_ordered = nd.as_ref().map_or(*no, |d| d.ordered);
1557 if *nf
1558 || n_ordered != ordered
1559 || (ordered && Some(*nn) != pn.map(|n| n + 1))
1560 {
1561 break;
1562 }
1563 pn = Some(*nn);
1564 j += 1;
1565 }
1566 found
1567 };
1568 match eff_marker {
1571 Some(m) => {
1572 out.push(depth + 1, "<ldiv>".to_string());
1573 out.push(depth + 2, format!("<marker>{}</marker>", escape_text(m)));
1574 out.push(depth + 1, "</ldiv>".to_string());
1575 }
1576 None => out.push(depth + 1, "<ldiv/>".to_string()),
1577 }
1578 if let Some(loc) = location {
1582 push_location(out, depth + 1, loc);
1583 }
1584 match dclx {
1585 Some(d) if !d.runs.is_empty() => {
1590 if has_nested {
1591 out.push(depth + 1, "<text>".to_string());
1592 emit_inline_runs_body(out, depth + 2, &d.runs);
1593 out.push(depth + 1, "</text>".to_string());
1594 } else {
1595 emit_inline_runs_body(out, depth + 1, &d.runs);
1596 }
1597 }
1598 Some(d) => emit_list_item_content(out, depth + 1, &d.text, has_nested),
1601 None => {
1602 let stripped = strip_lone_link(text);
1607 let eff_href = href
1608 .as_deref()
1609 .filter(|_| matches!(stripped, Cow::Owned(_)));
1610 if eff_href.is_some() || layer.is_some() {
1611 let content: &str = if eff_href.is_some() {
1612 stripped.as_ref()
1613 } else {
1614 text.as_str()
1615 };
1616 emit_list_item_with_head(
1617 out,
1618 depth + 1,
1619 content,
1620 has_nested,
1621 eff_href,
1622 *layer,
1623 );
1624 } else {
1625 emit_list_item_content(out, depth + 1, text, has_nested);
1626 }
1627 }
1628 }
1629 *i += 1;
1630 }
1631 Node::ListItem { level: l, .. } if *l > level => {
1632 emit_list(out, depth + 1, nodes, i, *l);
1633 }
1634 Node::Paragraph { text }
1640 if text.is_empty()
1641 && matches!(
1642 nodes.get(*i + 1),
1643 Some(Node::ListItem { level: nl, ordered: no, number: nn,
1644 first_in_list: nf, dclx: nd, .. })
1645 if *nl > level
1646 || (*nl == level
1647 && !*nf
1648 && nd.as_ref().map_or(*no, |d| d.ordered) == ordered
1649 && (!ordered
1650 || Some(*nn) == prev_number.map(|n| n + 1)))
1651 ) =>
1652 {
1653 *i += 1;
1654 }
1655 _ => break,
1656 }
1657 }
1658 out.push(depth, "</list>".to_string());
1659}
1660
1661fn emit_list_item_with_head(
1672 out: &mut Out,
1673 depth: i32,
1674 text: &str,
1675 has_nested: bool,
1676 href: Option<&str>,
1677 layer: Option<ContentLayer>,
1678) {
1679 let head = |out: &mut Out, d: i32| {
1680 if let Some(uri) = href {
1681 out.push(d, format!("<href uri=\"{}\"/>", attr_escape(uri)));
1682 }
1683 if let Some(l) = layer {
1684 out.push(d, format!("<layer value=\"{}\"/>", l.value()));
1685 }
1686 };
1687 if has_nested {
1688 out.push(depth, "<text>".to_string());
1689 head(out, depth + 1);
1690 emit_runs(out, depth + 1, inline_runs(text));
1691 out.push(depth, "</text>".to_string());
1692 } else {
1693 head(out, depth);
1694 emit_runs(out, depth, inline_runs(text));
1695 }
1696}
1697
1698fn emit_list_item_content(out: &mut Out, depth: i32, text: &str, has_nested: bool) {
1699 let runs = inline_runs_from_markdown(text);
1705 let single_plain = runs.len() <= 1 && runs.first().is_none_or(|r| r.is_plain());
1706 if single_plain {
1707 if has_nested {
1708 emit_text_element(out, depth, "text", "text", text, None);
1709 } else if !text.trim().is_empty() {
1710 emit_text_node(out, depth, text);
1714 }
1715 } else if has_nested {
1716 emit_inline_group(out, depth, false, &runs);
1717 } else {
1718 emit_inline_runs_body(out, depth, &runs);
1721 }
1722}
1723
1724fn emit_field_region(out: &mut Out, depth: i32, items: &[FieldItem]) {
1725 out.push(depth, "<field_region>".to_string());
1726 for item in items {
1727 out.push(depth + 1, "<field_item>".to_string());
1728 if let Some(m) = item.marker.as_ref().filter(|s| !s.is_empty()) {
1729 out.push(depth + 2, format!("<marker>{}</marker>", escape_text(m)));
1730 }
1731 if let Some(k) = item.key.as_ref().filter(|s| !s.is_empty()) {
1732 out.push(depth + 2, format!("<key>{}</key>", escape_text(k)));
1733 }
1734 if let Some(v) = item.value.as_ref().filter(|s| !s.is_empty()) {
1735 out.push(depth + 2, format!("<value>{}</value>", escape_text(v)));
1736 }
1737 out.push(depth + 1, "</field_item>".to_string());
1738 }
1739 out.push(depth, "</field_region>".to_string());
1740}
1741
1742#[cfg(test)]
1743mod tests {
1744 use super::*;
1745
1746 #[test]
1747 fn located_heading_emits_location_tokens_in_block_form() {
1748 let doclang = export_to_doclang(&[Node::Located {
1749 location: [44, 170, 340, 386],
1750 inner: Box::new(Node::Heading {
1751 level: 1,
1752 text: "X-Library".into(),
1753 }),
1754 }]);
1755 assert!(
1756 doclang.contains(
1757 "<heading>\n <location value=\"44\"/>\n <location value=\"170\"/>\n \
1758 <location value=\"340\"/>\n <location value=\"386\"/>\n X-Library\n </heading>"
1759 ),
1760 "got:\n{doclang}"
1761 );
1762 }
1763
1764 fn code(language: Option<&str>, text: &str) -> String {
1765 export_to_doclang(&[Node::Code {
1766 language: language.map(String::from),
1767 text: text.into(),
1768 orig: None,
1769 }])
1770 }
1771
1772 #[test]
1773 fn code_with_language_emits_linguist_label_block_form() {
1774 assert_eq!(
1777 code(Some("python"), "print(\"Hello world!\")"),
1778 "<doclang version=\"0.7\">\n <code>\n <label value=\"Python\"/>\n\
1779 <![CDATA[print(\"Hello world!\")]]> </code>\n</doclang>"
1780 );
1781 assert!(code(Some("bash"), "ls -la").contains("<label value=\"Shell\"/>"));
1783 }
1784
1785 fn plain(text: &str) -> InlineRun {
1786 InlineRun {
1787 text: text.into(),
1788 ..Default::default()
1789 }
1790 }
1791 fn bold(text: &str) -> InlineRun {
1792 InlineRun {
1793 text: text.into(),
1794 bold: true,
1795 ..Default::default()
1796 }
1797 }
1798 fn ig(unwrapped: bool, runs: Vec<InlineRun>) -> String {
1799 let body = export_to_doclang(&[Node::InlineGroup {
1800 unwrapped,
1801 runs,
1802 md_text: String::new(),
1803 }]);
1804 body.trim_start_matches("<doclang version=\"0.7\">\n")
1806 .trim_end_matches("\n</doclang>")
1807 .to_string()
1808 }
1809
1810 #[test]
1811 fn inline_group_matches_reference_layout() {
1812 assert_eq!(
1814 ig(
1815 false,
1816 vec![plain("This is a"), bold("bold"), plain("example")]
1817 ),
1818 " <text>\n This is a\n <bold>bold</bold>\nexample\n </text>"
1819 );
1820 assert_eq!(
1822 ig(
1823 true,
1824 vec![
1825 plain("aa"),
1826 bold("bb"),
1827 plain("cc"),
1828 bold("dd"),
1829 plain("ee")
1830 ]
1831 ),
1832 "aa\n <bold>bb</bold>\ncc\n <bold>dd</bold>\nee"
1833 );
1834 assert_eq!(
1836 ig(false, vec![plain("aa"), plain("bb")]),
1837 " <text>aa\nbb\n</text>"
1838 );
1839 assert_eq!(ig(false, vec![plain("aa")]), " <text>aa\n</text>");
1840 assert_eq!(
1842 ig(false, vec![bold("bb")]),
1843 " <text>\n <bold>bb</bold>\n </text>"
1844 );
1845 }
1846
1847 #[test]
1848 fn nested_styles_wrap_outermost_last_applied() {
1849 let bi = InlineRun {
1850 text: "bi".into(),
1851 bold: true,
1852 italic: true,
1853 ..Default::default()
1854 };
1855 assert_eq!(
1857 ig(true, vec![bi]),
1858 " <italic>\n <bold>bi</bold>\n </italic>"
1859 );
1860 let sub = InlineRun {
1861 text: "2".into(),
1862 script: Script::Sub,
1863 ..Default::default()
1864 };
1865 assert_eq!(ig(true, vec![sub]), " <subscript>2</subscript>");
1866 }
1867
1868 #[test]
1869 fn furniture_heading_gets_layer_head() {
1870 let out = export_to_doclang(&[Node::Furniture {
1871 layer: ContentLayer::Furniture,
1872 inner: Box::new(Node::Heading {
1873 level: 1,
1874 text: "Anchor Links Test".into(),
1875 }),
1876 }]);
1877 assert_eq!(
1878 out,
1879 "<doclang version=\"0.7\">\n <heading>\n <layer value=\"furniture\"/>\n Anchor Links Test\n </heading>\n</doclang>"
1880 );
1881 }
1882
1883 #[test]
1884 fn text_dump_reproduces_minidom_per_line_layout() {
1885 let text = "PATN\nWKU 1\nPAL K. \"Determination\"\nfollow-up\n*Note A\n_______________\nNote B*\nEND";
1890 let out = export_to_doclang(&[Node::TextDump(text.into())]);
1891 let expected = "<doclang version=\"0.7\">\n \
1892 <text>\n \
1893 PATN\nWKU 1\n\
1894 <![CDATA[PAL K. \"Determination\"]]> \n\
1895 follow-up\n \
1896 <italic>Note A</italic>\n \
1897 <italic>__________</italic>\n \
1898 <italic>Note B</italic>\n\
1899 END\n \
1900 </text>\n</doclang>";
1901 assert_eq!(out, expected, "got:\n{out}");
1902 }
1903
1904 #[test]
1905 fn code_without_language_stays_inline_and_unlabeled() {
1906 assert_eq!(
1907 code(None, "print(\"Hi!\")"),
1908 "<doclang version=\"0.7\">\n <code><![CDATA[print(\"Hi!\")]]></code>\n</doclang>"
1909 );
1910 assert!(!code(Some("brainfuck"), "+++.").contains("<label"));
1912 }
1913}