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 sanitize_xml_illegal(text: &str) -> std::borrow::Cow<'_, str> {
78 let illegal = |c: char| matches!(c, '\u{00}'..='\u{08}' | '\u{0B}' | '\u{0C}' | '\u{0E}'..='\u{1F}' | '\u{FFFE}' | '\u{FFFF}');
79 if !text.contains(illegal) {
80 return std::borrow::Cow::Borrowed(text);
81 }
82 let mut out = String::with_capacity(text.len() + 8);
83 for c in text.chars() {
84 if illegal(c) {
85 out.push_str(&format!("[U+{:04X}]", c as u32));
86 } else {
87 out.push(c);
88 }
89 }
90 std::borrow::Cow::Owned(out)
91}
92
93fn unescape_stored(text: &str) -> Cow<'_, str> {
95 if !text.contains('&') && !text.contains('\\') {
96 return Cow::Borrowed(text);
97 }
98 Cow::Owned(
99 text.replace("<", "<")
100 .replace(">", ">")
101 .replace("&", "&")
102 .replace("\\_", "_"),
103 )
104}
105
106fn escape_text(text: &str) -> String {
115 let raw = unescape_stored(text);
116 let text = sanitize_xml_illegal(raw.as_ref());
117 let text = text.as_ref();
118 let needs_cdata = text.contains(['"', '\'', '&', '<', '>']);
119 let needs_content = text != text.trim() || text.contains('\n');
120 let mut t = if needs_cdata {
121 format!("<![CDATA[{}]]>", text.replace("]]>", "]]]]><![CDATA[>"))
122 } else {
123 text.to_string()
124 };
125 if needs_content {
126 t = format!("<content>{t}</content>");
127 }
128 t
129}
130
131enum Run {
133 Plain(String),
134 Bold(String),
135 Italic(String),
136 BoldItalic(String),
137 Code(String),
138 Link {
142 anchor: String,
143 uri: String,
144 },
145}
146
147fn inline_runs(text: &str) -> Vec<Run> {
150 let mut runs = Vec::new();
151 let mut plain = String::new();
152 let chars: Vec<char> = text.chars().collect();
153 let n = chars.len();
154 let mut i = 0;
155 let find = |open: usize, pat: &[char]| -> Option<usize> {
159 chars
160 .get(open..)?
161 .windows(pat.len())
162 .position(|w| w == pat)
163 .map(|p| open + p)
164 };
165 let starts = |at: usize, pat: &[char]| chars[at..].starts_with(pat);
166 while i < n {
167 let take = |runs: &mut Vec<Run>, plain: &mut String, r: Run| {
168 if !plain.is_empty() {
169 runs.push(Run::Plain(std::mem::take(plain)));
170 }
171 runs.push(r);
172 };
173 if starts(i, &['*', '*', '*']) {
174 if let Some(end) = find(i + 3, &['*', '*', '*']) {
175 let inner: String = chars[i + 3..end].iter().collect();
176 take(&mut runs, &mut plain, Run::BoldItalic(inner));
177 i = end + 3;
178 continue;
179 }
180 }
181 if starts(i, &['*', '*']) {
182 if let Some(end) = find(i + 2, &['*', '*']) {
183 let inner: String = chars[i + 2..end].iter().collect();
184 take(&mut runs, &mut plain, Run::Bold(inner));
185 i = end + 2;
186 continue;
187 }
188 }
189 if chars[i] == '*' && !starts(i, &['*', '*']) {
190 if let Some(end) = find(i + 1, &['*']) {
191 let inner: String = chars[i + 1..end].iter().collect();
192 if !inner.is_empty() {
193 take(&mut runs, &mut plain, Run::Italic(inner));
194 i = end + 1;
195 continue;
196 }
197 }
198 }
199 if chars[i] == '`' {
200 if let Some(end) = find(i + 1, &['`']) {
201 let inner: String = chars[i + 1..end].iter().collect();
202 take(&mut runs, &mut plain, Run::Code(inner));
203 i = end + 1;
204 continue;
205 }
206 }
207 if chars[i] == '[' {
208 if let Some(close) = find(i + 1, &[']', '(']) {
209 if let Some(endp) = find(close + 2, &[')']) {
210 let anchor: String = chars[i + 1..close].iter().collect();
211 let uri: String = chars[close + 2..endp].iter().collect();
212 take(&mut runs, &mut plain, Run::Link { anchor, uri });
213 i = endp + 1;
214 continue;
215 }
216 }
217 }
218 plain.push(chars[i]);
219 i += 1;
220 }
221 if !plain.is_empty() {
222 runs.push(Run::Plain(plain));
223 }
224 runs
225}
226
227pub fn inline_runs_from_markdown(text: &str) -> Vec<InlineRun> {
234 let mut out = Vec::new();
235 parse_md_runs(
236 &text.chars().collect::<Vec<_>>(),
237 InlineRun::default(),
238 &mut out,
239 );
240 out
241}
242
243fn flush_md_plain(buf: &mut String, style: &InlineRun, out: &mut Vec<InlineRun>) {
247 let text = std::mem::take(buf);
248 let text = text.trim();
249 if !text.is_empty() {
250 out.push(InlineRun {
251 text: text.to_string(),
252 ..style.clone()
253 });
254 }
255}
256
257fn parse_md_runs(chars: &[char], style: InlineRun, out: &mut Vec<InlineRun>) {
260 let n = chars.len();
261 let mut i = 0;
262 let mut plain = String::new();
263 let find = |open: usize, pat: &[char]| -> Option<usize> {
266 chars
267 .get(open..)?
268 .windows(pat.len())
269 .position(|w| w == pat)
270 .map(|p| open + p)
271 };
272 let starts = |at: usize, pat: &[char]| chars[at..].starts_with(pat);
273 let sub = |a: usize, b: usize| -> Vec<char> { chars[a..b].to_vec() };
274 while i < n {
275 if starts(i, &['*', '*', '*']) {
277 if let Some(end) = find(i + 3, &['*', '*', '*']) {
278 flush_md_plain(&mut plain, &style, out);
279 parse_md_runs(
280 &sub(i + 3, end),
281 InlineRun {
282 bold: true,
283 italic: true,
284 ..style.clone()
285 },
286 out,
287 );
288 i = end + 3;
289 continue;
290 }
291 }
292 if starts(i, &['*', '*']) {
293 if let Some(end) = find(i + 2, &['*', '*']) {
294 flush_md_plain(&mut plain, &style, out);
295 parse_md_runs(
296 &sub(i + 2, end),
297 InlineRun {
298 bold: true,
299 ..style.clone()
300 },
301 out,
302 );
303 i = end + 2;
304 continue;
305 }
306 }
307 if chars[i] == '*' {
308 if let Some(end) = find(i + 1, &['*']) {
309 if end > i + 1 {
310 flush_md_plain(&mut plain, &style, out);
311 parse_md_runs(
312 &sub(i + 1, end),
313 InlineRun {
314 italic: true,
315 ..style.clone()
316 },
317 out,
318 );
319 i = end + 1;
320 continue;
321 }
322 }
323 }
324 if starts(i, &['~', '~']) {
325 if let Some(end) = find(i + 2, &['~', '~']) {
326 flush_md_plain(&mut plain, &style, out);
327 parse_md_runs(
328 &sub(i + 2, end),
329 InlineRun {
330 strike: true,
331 ..style.clone()
332 },
333 out,
334 );
335 i = end + 2;
336 continue;
337 }
338 }
339 if chars[i] == '`' {
340 if let Some(end) = find(i + 1, &['`']) {
341 flush_md_plain(&mut plain, &style, out);
342 let inner: String = sub(i + 1, end).iter().collect();
343 let inner = inner.trim();
344 if !inner.is_empty() {
345 out.push(InlineRun {
346 text: inner.to_string(),
347 code: true,
348 ..style.clone()
349 });
350 }
351 i = end + 1;
352 continue;
353 }
354 }
355 if chars[i] == '[' {
356 if let Some(close) = find(i + 1, &[']', '(']) {
357 if let Some(endp) = find(close + 2, &[')']) {
358 flush_md_plain(&mut plain, &style, out);
359 parse_md_runs(&sub(i + 1, close), style.clone(), out);
361 i = endp + 1;
362 continue;
363 }
364 }
365 }
366 plain.push(chars[i]);
367 i += 1;
368 }
369 flush_md_plain(&mut plain, &style, out);
370}
371
372fn attr_escape(v: &str) -> String {
374 v.replace('&', "&").replace('"', """)
375}
376
377fn emit_text_element(
384 out: &mut Out,
385 depth: i32,
386 tag_open: &str,
387 tag: &str,
388 text: &str,
389 location: Option<&[u16; 4]>,
390) {
391 if let Some(loc) = location {
394 out.push(depth, format!("<{tag_open}>"));
395 push_location(out, depth + 1, loc);
396 if !text.is_empty() {
397 emit_runs(out, depth + 1, inline_runs(text));
398 }
399 out.push(depth, format!("</{tag}>"));
400 return;
401 }
402 if text.is_empty() {
405 out.push(depth, format!("<{tag_open}></{tag}>"));
406 return;
407 }
408 let runs = inline_runs(text);
409 let only_plain = runs.len() == 1 && matches!(runs[0], Run::Plain(_));
410 if runs.len() == 1 {
413 if let Run::Link { anchor, uri } = &runs[0] {
414 out.push(depth, format!("<{tag_open}>"));
415 out.push(depth + 1, format!("<href uri=\"{}\"/>", attr_escape(uri)));
416 if !anchor.trim().is_empty() {
417 emit_runs(out, depth + 1, inline_runs(anchor));
418 }
419 out.push(depth, format!("</{tag}>"));
420 return;
421 }
422 }
423 if only_plain {
424 let body = escape_text(text);
425 if body.starts_with("<content>") {
429 out.push(depth, format!("<{tag_open}>"));
430 out.push(depth + 1, body);
431 out.push(depth, format!("</{tag}>"));
432 } else {
433 out.push(depth, format!("<{tag_open}>{body}</{tag}>"));
434 }
435 return;
436 }
437 out.push(depth, format!("<{tag_open}>"));
438 emit_runs(out, depth + 1, runs);
439 out.push(depth, format!("</{tag}>"));
440}
441
442fn emit_runs(out: &mut Out, depth: i32, runs: Vec<Run>) {
443 for run in runs {
444 match run {
445 Run::Plain(t) => {
446 let t = t.trim_matches('\n');
447 if !t.is_empty() {
448 emit_text_node(out, depth, t);
449 }
450 }
451 Run::Bold(t) => out.push(depth, format!("<bold>{}</bold>", escape_text(&t))),
452 Run::Italic(t) => out.push(depth, format!("<italic>{}</italic>", escape_text(&t))),
453 Run::BoldItalic(t) => {
454 out.push(depth, "<italic>".to_string());
455 out.push(depth + 1, format!("<bold>{}</bold>", escape_text(&t)));
456 out.push(depth, "</italic>".to_string());
457 }
458 Run::Code(t) => out.push(depth, format!("<code>{}</code>", escape_text(&t))),
459 Run::Link { anchor, .. } => {
460 if !anchor.is_empty() {
462 emit_text_node(out, depth, &anchor);
463 }
464 }
465 }
466 }
467}
468
469fn emit_text_node(out: &mut Out, depth: i32, text: &str) {
473 let e = escape_text(text);
474 if e.starts_with("<![CDATA[") {
475 out.push_glue(e);
476 } else {
477 out.push(depth, e);
478 }
479}
480
481fn code_lang_label(lang: &str) -> Option<&'static str> {
486 let lang = crate::json::code_language(Some(lang));
490 Some(match lang {
491 "Bash" => "Shell",
493 "FORTRAN" => "Fortran",
494 "Latex" => "TeX",
495 "Lisp" => "Common Lisp",
496 "Matlab" | "Octave" => "MATLAB",
497 "ObjectiveC" => "Objective-C",
498 "SML" => "Standard ML",
499 "VisualBasic" => "Visual Basic .NET",
500 "DocLang" => "XML",
501 "bc" | "dc" | "Tikz" => "other",
503 "Ada" | "Awk" | "C" | "C#" | "C++" | "CMake" | "COBOL" | "CSS" | "Ceylon" | "Clojure"
505 | "Crystal" | "Cuda" | "Cython" | "D" | "Dart" | "Dockerfile" | "Elixir" | "Erlang"
506 | "Forth" | "Go" | "HTML" | "Haskell" | "Haxe" | "Java" | "JavaScript" | "JSON"
507 | "Julia" | "Kotlin" | "Lua" | "MoonScript" | "Nim" | "OCaml" | "PHP" | "Pascal"
508 | "Perl" | "Prolog" | "Python" | "Racket" | "Ruby" | "Rust" | "SQL" | "Scala"
509 | "Scheme" | "Swift" | "TypeScript" | "XML" | "YAML" => {
510 return Some(IDENTITY_LABELS[IDENTITY_LABELS.iter().position(|&x| x == lang).unwrap()])
511 }
512 _ => return None, })
514}
515
516static IDENTITY_LABELS: &[&str] = &[
519 "Ada",
520 "Awk",
521 "C",
522 "C#",
523 "C++",
524 "CMake",
525 "COBOL",
526 "CSS",
527 "Ceylon",
528 "Clojure",
529 "Crystal",
530 "Cuda",
531 "Cython",
532 "D",
533 "Dart",
534 "Dockerfile",
535 "Elixir",
536 "Erlang",
537 "Forth",
538 "Go",
539 "HTML",
540 "Haskell",
541 "Haxe",
542 "Java",
543 "JavaScript",
544 "JSON",
545 "Julia",
546 "Kotlin",
547 "Lua",
548 "MoonScript",
549 "Nim",
550 "OCaml",
551 "PHP",
552 "Pascal",
553 "Perl",
554 "Prolog",
555 "Python",
556 "Racket",
557 "Ruby",
558 "Rust",
559 "SQL",
560 "Scala",
561 "Scheme",
562 "Swift",
563 "TypeScript",
564 "XML",
565 "YAML",
566];
567
568fn emit_code(
573 out: &mut Out,
574 depth: i32,
575 language: Option<&str>,
576 text: &str,
577 location: Option<&[u16; 4]>,
578) {
579 let label = language.and_then(code_lang_label);
580 let escaped = escape_text(text);
581 let is_content_element = escaped.starts_with("<content>");
582 if let Some(loc) = location {
585 out.push(depth, "<code>".to_string());
586 push_location(out, depth + 1, loc);
587 if let Some(l) = label {
588 out.push(depth + 1, format!("<label value=\"{}\"/>", attr_escape(l)));
589 }
590 if is_content_element {
591 out.push(depth + 1, escaped);
592 } else {
593 out.push_glue(escaped);
594 }
595 out.push(depth, "</code>".to_string());
596 return;
597 }
598 match (label, is_content_element) {
599 (None, false) => out.push(depth, format!("<code>{escaped}</code>")),
600 (None, true) => {
601 out.push(depth, "<code>".to_string());
602 out.push(depth + 1, escaped);
603 out.push(depth, "</code>".to_string());
604 }
605 (Some(l), false) => {
606 out.push(depth, "<code>".to_string());
607 out.push(depth + 1, format!("<label value=\"{}\"/>", attr_escape(l)));
608 out.push_glue(escaped);
610 out.push(depth, "</code>".to_string());
611 }
612 (Some(l), true) => {
613 out.push(depth, "<code>".to_string());
614 out.push(depth + 1, format!("<label value=\"{}\"/>", attr_escape(l)));
615 out.push(depth + 1, escaped);
616 out.push(depth, "</code>".to_string());
617 }
618 }
619}
620
621fn push_location(out: &mut Out, depth: i32, loc: &[u16; 4]) {
624 for v in loc {
625 out.push(depth, format!("<location value=\"{v}\"/>"));
626 }
627}
628
629fn emit_table(out: &mut Out, depth: i32, table: &Table) {
630 out.push(depth, "<table>".to_string());
631 if let Some(cap) = &table.caption {
632 out.push(depth + 1, format!("<caption>{cap}</caption>"));
635 }
636 emit_table_rows(out, depth, table);
637 out.push(depth, "</table>".to_string());
638}
639
640fn emit_chart(
644 out: &mut Out,
645 depth: i32,
646 kind: &str,
647 table: &Table,
648 caption: Option<&str>,
649 location: Option<&[u16; 4]>,
650) {
651 out.pic_index += 1;
652 out.push(depth, "<picture class=\"chart\">".to_string());
653 out.push(
654 depth + 1,
655 format!("<label value=\"{}\"/>", attr_escape(kind)),
656 );
657 if let Some(loc) = location {
658 push_location(out, depth + 1, loc);
659 }
660 if let Some(cap) = caption {
661 out.push(
662 depth + 1,
663 format!("<caption>{}</caption>", escape_text(cap)),
664 );
665 }
666 out.push(depth + 1, "<tabular>".to_string());
667 emit_table_rows(out, depth + 1, table);
668 out.push(depth + 1, "</tabular>".to_string());
669 out.push(depth, "</picture>".to_string());
670}
671
672fn emit_table_rows(out: &mut Out, depth: i32, table: &Table) {
675 if let Some(loc) = &table.location {
678 push_location(out, depth + 1, loc);
679 }
680 for (ri, row) in table.rows.iter().enumerate() {
681 for (ci, cell) in row.iter().enumerate() {
682 let cont = |grid: &Vec<Vec<bool>>| {
686 grid.get(ri)
687 .and_then(|r| r.get(ci))
688 .copied()
689 .unwrap_or(false)
690 };
691 let is_lcel = table
692 .structure
693 .as_ref()
694 .map(|s| cont(&s.col_continuation))
695 .unwrap_or(false);
696 let is_ucel = table
697 .structure
698 .as_ref()
699 .map(|s| cont(&s.row_continuation))
700 .unwrap_or(false);
701 let is_header = match &table.structure {
702 Some(s) if !s.col_header.is_empty() => s
703 .col_header
704 .get(ri)
705 .and_then(|r| r.get(ci))
706 .copied()
707 .unwrap_or(false),
708 Some(s) => s.header_row.get(ri).copied().unwrap_or(false),
709 None => ri == 0,
710 };
711 let is_row_header = table
712 .structure
713 .as_ref()
714 .map(|s| {
715 s.row_header
716 .get(ri)
717 .and_then(|r| r.get(ci))
718 .copied()
719 .unwrap_or(false)
720 })
721 .unwrap_or(false);
722 let tok = if is_lcel && is_ucel {
723 "<xcel/>"
725 } else if is_lcel {
726 "<lcel/>"
727 } else if is_ucel {
728 "<ucel/>"
729 } else if cell.trim().is_empty() {
730 "<ecel/>"
731 } else if is_header {
732 "<ched/>"
733 } else if is_row_header {
734 "<rhed/>"
735 } else {
736 "<fcel/>"
737 };
738 out.push(depth + 1, tok.to_string());
739 if !is_lcel && !is_ucel {
740 let blocks = table
744 .cell_blocks
745 .as_ref()
746 .and_then(|b| b.get(ri))
747 .and_then(|r| r.get(ci))
748 .filter(|b| !b.is_empty());
749 if let Some(blocks) = blocks {
750 let mut bi = 0;
751 emit_nodes(out, depth + 1, blocks, &mut bi, 0);
752 } else if !cell.trim().is_empty() {
753 emit_cell_text(out, depth + 1, cell);
754 }
755 }
756 }
757 out.push(depth + 1, "<nl/>".to_string());
758 }
759}
760
761fn emit_cell_text(out: &mut Out, depth: i32, text: &str) {
763 let runs = inline_runs(text.trim());
764 emit_runs(out, depth, runs);
765}
766
767pub fn export_to_doclang(nodes: &[Node]) -> String {
769 let mut out = Out {
770 lines: Vec::new(),
771 pic_index: 0,
772 };
773 out.push(0, "<doclang version=\"0.7\">".to_string());
774 let mut i = 0usize;
775 emit_nodes(&mut out, 1, nodes, &mut i, 0);
776 out.push(0, "</doclang>".to_string());
777 out.finish()
778}
779
780fn emit_nodes(out: &mut Out, depth: i32, nodes: &[Node], i: &mut usize, level: u8) {
783 while *i < nodes.len() {
784 match &nodes[*i] {
785 Node::Heading { level, text } => {
786 let open = if *level <= 1 {
787 "heading".to_string()
788 } else {
789 format!("heading level=\"{}\"", (*level).min(6))
792 };
793 emit_text_element(out, depth, &open, "heading", text, None);
794 *i += 1;
795 }
796 Node::Paragraph { text } => {
797 if let Some(latex) = text
801 .strip_prefix("$$")
802 .and_then(|t| t.strip_suffix("$$"))
803 .filter(|t| !t.is_empty())
804 {
805 out.push(depth, format!("<formula>{}</formula>", escape_text(latex)));
806 } else {
807 emit_text_element(out, depth, "text", "text", text, None);
808 }
809 *i += 1;
810 }
811 Node::CheckboxItem { checked, text } => {
812 let class = if *checked { "selected" } else { "unselected" };
815 out.push(depth, "<text>".to_string());
816 out.push(depth + 1, format!("<checkbox class=\"{class}\"/>"));
817 if !text.is_empty() {
818 out.push(depth + 1, escape_text(text));
819 }
820 out.push(depth, "</text>".to_string());
821 *i += 1;
822 }
823 Node::Code { language, text, .. } => {
824 emit_code(out, depth, language.as_deref(), text, None);
825 *i += 1;
826 }
827 Node::Formula {
830 latex, location, ..
831 } => {
832 if let Some(loc) = location {
833 out.push(depth, "<formula>".to_string());
834 push_location(out, depth + 1, loc);
835 if !latex.is_empty() {
836 out.push(depth + 1, escape_text(latex));
837 }
838 out.push(depth, "</formula>".to_string());
839 } else {
840 out.push(depth, format!("<formula>{}</formula>", escape_text(latex)));
841 }
842 *i += 1;
843 }
844 Node::PageFurniture {
845 footer,
846 location,
847 text,
848 } => {
849 let tag = if *footer {
850 "page_footer"
851 } else {
852 "page_header"
853 };
854 out.push(depth, format!("<{tag}>"));
855 out.push(depth + 1, "<layer value=\"furniture\"/>".to_string());
856 push_location(out, depth + 1, location);
857 if !text.is_empty() {
858 out.push(depth + 1, escape_text(text));
859 }
860 out.push(depth, format!("</{tag}>"));
861 *i += 1;
862 }
863 Node::Table(t) => {
864 emit_table(out, depth, t);
865 *i += 1;
866 }
867 Node::Picture { caption, image, .. } => {
870 emit_picture(out, depth, caption.as_deref(), image.as_ref(), None);
871 *i += 1;
872 }
873 Node::Chart {
874 kind,
875 table,
876 caption,
877 location,
878 } => {
879 emit_chart(
880 out,
881 depth,
882 kind,
883 table,
884 caption.as_deref(),
885 location.as_ref(),
886 );
887 *i += 1;
888 }
889 Node::DoclangOnly(inner) => {
890 let mut j = 0;
891 emit_nodes(out, depth, std::slice::from_ref(inner), &mut j, level);
892 *i += 1;
893 }
894 Node::ListItem { level: l, .. } => {
895 if *l < level {
896 return; }
898 emit_list(out, depth, nodes, i, *l);
899 }
900 Node::Group { children, .. } => {
901 let mut j = 0usize;
902 emit_nodes(out, depth, children, &mut j, 0);
903 *i += 1;
904 }
905 Node::FieldRegion { items } => {
906 emit_field_region(out, depth, items);
907 *i += 1;
908 }
909 Node::InlineGroup {
910 unwrapped, runs, ..
911 } => {
912 emit_inline_group(out, depth, *unwrapped, runs);
913 *i += 1;
914 }
915 Node::Furniture { layer, inner } => {
916 emit_furniture(out, depth, *layer, inner);
917 *i += 1;
918 }
919 Node::Located { location, inner } => {
920 emit_located(out, depth, location, inner);
921 *i += 1;
922 }
923 Node::PageBreak => {
924 out.push(depth, "<page_break/>".to_string());
925 *i += 1;
926 }
927 Node::PageInfo { .. } => {
930 *i += 1;
931 }
932 Node::TextDump(text) => {
933 emit_text_dump(out, depth, text);
934 *i += 1;
935 }
936 }
937 }
938}
939
940enum DumpNode {
943 Text(String),
944 Cdata(String),
945 Elem(String),
946}
947
948fn emit_text_dump(out: &mut Out, depth: i32, text: &str) {
960 let records = dump_records(text);
961 if records.is_empty() {
962 out.push(depth, "<text></text>".to_string());
963 return;
964 }
965 let mut nodes: Vec<DumpNode> = Vec::new();
969 let mut buf = String::new();
970 for (r, (line, italic)) in records.iter().enumerate() {
971 if r > 0 {
972 buf.push('\n'); }
974 let raw = unescape_stored(line);
975 let s = raw.as_ref();
976 let is_cdata = s.contains(['"', '\'', '&', '<', '>']);
977 if *italic || is_cdata {
978 if !buf.is_empty() {
979 nodes.push(DumpNode::Text(std::mem::take(&mut buf)));
980 }
981 let inner = if is_cdata {
982 format!("<![CDATA[{s}]]>")
983 } else {
984 s.to_string()
985 };
986 if *italic {
987 nodes.push(DumpNode::Elem(format!("<italic>{inner}</italic>")));
988 } else {
989 nodes.push(DumpNode::Cdata(inner));
990 }
991 } else {
992 buf.push_str(s);
993 }
994 }
995 if !buf.is_empty() {
996 nodes.push(DumpNode::Text(buf));
997 }
998
999 if let [DumpNode::Text(d)] = nodes.as_slice() {
1001 out.push(depth, format!("<text>{d}\n</text>"));
1002 return;
1003 }
1004
1005 let ind_child = INDENT.repeat((depth + 1).max(0) as usize);
1008 let ind_self = INDENT.repeat(depth.max(0) as usize);
1009 let mut raw = String::new();
1010 for node in &nodes {
1011 match node {
1012 DumpNode::Text(d) => {
1013 raw.push_str(&ind_child);
1014 raw.push_str(d);
1015 raw.push('\n');
1016 }
1017 DumpNode::Cdata(b) => raw.push_str(b),
1018 DumpNode::Elem(b) => {
1019 raw.push_str(&ind_child);
1020 raw.push_str(b);
1021 raw.push('\n');
1022 }
1023 }
1024 }
1025 let full = format!("{ind_self}<text>\n{raw}{ind_self}</text>");
1026 for line in full.split('\n') {
1027 if !line.trim().is_empty() {
1028 out.push(0, line.to_string());
1029 }
1030 }
1031}
1032
1033fn dump_records(text: &str) -> Vec<(String, bool)> {
1038 let chars: Vec<char> = text.chars().collect();
1039 let n = chars.len();
1040
1041 struct Delim {
1042 pos: usize,
1043 length: usize,
1044 rem: usize,
1045 can_open: bool,
1046 can_close: bool,
1047 }
1048 let is_ws = |c: Option<char>| c.is_none_or(|c| c.is_whitespace());
1049 let is_punct =
1050 |c: Option<char>| c.is_some_and(|c| "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~".contains(c));
1051
1052 let mut delims: Vec<Delim> = Vec::new();
1054 let mut i = 0;
1055 while i < n {
1056 if chars[i] == '*' {
1057 let mut j = i;
1058 while j < n && chars[j] == '*' {
1059 j += 1;
1060 }
1061 let prev = (i > 0).then(|| chars[i - 1]);
1062 let next = (j < n).then(|| chars[j]);
1063 let left = !is_ws(next) && (!is_punct(next) || is_ws(prev) || is_punct(prev));
1064 let right = !is_ws(prev) && (!is_punct(prev) || is_ws(next) || is_punct(next));
1065 delims.push(Delim {
1066 pos: i,
1067 length: j - i,
1068 rem: j - i,
1069 can_open: left,
1070 can_close: right,
1071 });
1072 i = j;
1073 } else {
1074 i += 1;
1075 }
1076 }
1077
1078 let mut emph = vec![false; n];
1081 let mut consumed = vec![false; n];
1082 let mut ci = 0;
1083 while ci < delims.len() {
1084 if !(delims[ci].can_close && delims[ci].rem > 0) {
1085 ci += 1;
1086 continue;
1087 }
1088 let mut found: Option<usize> = None;
1089 let mut oi = ci as i64 - 1;
1090 while oi >= 0 {
1091 let o = &delims[oi as usize];
1092 let c = &delims[ci];
1093 if o.can_open && o.rem > 0 {
1094 let odd = (o.can_close || c.can_open)
1097 && (o.length + c.length) % 3 == 0
1098 && !(o.length % 3 == 0 && c.length % 3 == 0);
1099 if !odd {
1100 found = Some(oi as usize);
1101 break;
1102 }
1103 }
1104 oi -= 1;
1105 }
1106 let Some(fi) = found else {
1107 ci += 1;
1108 continue;
1109 };
1110 let use_ = if delims[fi].rem >= 2 && delims[ci].rem >= 2 {
1111 2
1112 } else {
1113 1
1114 };
1115 let oend = delims[fi].pos + delims[fi].rem;
1116 for c in consumed.iter_mut().take(oend).skip(oend - use_) {
1117 *c = true;
1118 }
1119 let cstart = delims[ci].pos + (delims[ci].length - delims[ci].rem);
1120 for c in consumed.iter_mut().take(cstart + use_).skip(cstart) {
1121 *c = true;
1122 }
1123 for e in emph.iter_mut().take(cstart).skip(oend) {
1124 *e = true;
1125 }
1126 delims[fi].rem -= use_;
1127 delims[ci].rem -= use_;
1128 delims.drain((fi + 1)..ci);
1129 ci = if delims[fi].rem == 0 { fi + 1 } else { fi };
1130 }
1131
1132 let mut records: Vec<(String, bool)> = Vec::new();
1134 let mut line = String::new();
1135 let mut line_italic = false;
1136 let push_line = |line: &mut String, italic: &mut bool, out: &mut Vec<(String, bool)>| {
1137 let text = std::mem::take(line);
1138 let ital = std::mem::replace(italic, false);
1139 let trimmed = text.trim();
1140 if trimmed.is_empty() {
1141 return;
1142 }
1143 let norm = if trimmed.len() >= 3 && trimmed.chars().all(|c| c == '_') {
1145 "_".repeat(10)
1146 } else {
1147 text
1148 };
1149 out.push((norm, ital));
1150 };
1151 for k in 0..n {
1152 if consumed[k] {
1153 continue;
1154 }
1155 if chars[k] == '\n' {
1156 push_line(&mut line, &mut line_italic, &mut records);
1157 } else {
1158 line.push(chars[k]);
1159 if emph[k] {
1160 line_italic = true;
1161 }
1162 }
1163 }
1164 push_line(&mut line, &mut line_italic, &mut records);
1165 records
1166}
1167
1168fn emit_inline_group(out: &mut Out, depth: i32, unwrapped: bool, runs: &[InlineRun]) {
1182 let has_styled = runs.iter().any(|r| !r.is_plain());
1183
1184 if unwrapped {
1185 for run in runs {
1186 if run.is_plain() {
1187 out.push(0, escape_text(&run.text));
1188 } else if run.formula {
1189 out.push(
1190 depth,
1191 format!("<formula>{}</formula>", escape_text(&run.text)),
1192 );
1193 } else {
1194 emit_styled(out, depth, &style_tags(run), &escape_text(&run.text));
1195 }
1196 }
1197 return;
1198 }
1199
1200 if !has_styled {
1203 let joined = runs
1204 .iter()
1205 .map(|r| escape_text(&r.text))
1206 .collect::<Vec<_>>()
1207 .join("\n");
1208 out.push(depth, format!("<text>{joined}\n</text>"));
1209 return;
1210 }
1211
1212 out.push(depth, "<text>".to_string());
1213 emit_inline_runs_body(out, depth + 1, runs);
1214 out.push(depth, "</text>".to_string());
1215}
1216
1217fn emit_inline_runs_body(out: &mut Out, depth: i32, runs: &[InlineRun]) {
1223 for (i, run) in runs.iter().enumerate() {
1224 if run.is_plain() {
1225 let e = escape_text(&run.text);
1226 let d = if e.starts_with("<content>") || i == 0 {
1227 depth
1228 } else {
1229 0
1230 };
1231 if e.starts_with("<![CDATA[") && i + 1 == runs.len() && d == 0 {
1232 out.push_glue(e);
1237 out.push(depth, "");
1238 } else {
1239 out.push(d, e);
1240 }
1241 } else if run.formula {
1242 out.push(
1243 depth,
1244 format!("<formula>{}</formula>", escape_text(&run.text)),
1245 );
1246 } else {
1247 emit_styled(out, depth, &style_tags(run), &escape_text(&run.text));
1248 }
1249 }
1250}
1251
1252fn style_tags(run: &InlineRun) -> Vec<&'static str> {
1256 let mut tags = Vec::new();
1257 match run.script {
1258 Script::Sub => tags.push("subscript"),
1259 Script::Super => tags.push("superscript"),
1260 Script::Baseline => {}
1261 }
1262 if run.strike {
1263 tags.push("strikethrough");
1264 }
1265 if run.underline {
1266 tags.push("underline");
1267 }
1268 if run.italic {
1269 tags.push("italic");
1270 }
1271 if run.bold {
1272 tags.push("bold");
1273 }
1274 if run.code {
1275 tags.push("code");
1276 }
1277 tags
1278}
1279
1280fn emit_styled(out: &mut Out, depth: i32, tags: &[&str], inner: &str) {
1285 match tags {
1286 [] => emit_text_node(out, depth, inner),
1287 [tag] => out.push(depth, format!("<{tag}>{inner}</{tag}>")),
1288 [tag, rest @ ..] => {
1289 out.push(depth, format!("<{tag}>"));
1290 emit_styled(out, depth + 1, rest, inner);
1291 out.push(depth, format!("</{tag}>"));
1292 }
1293 }
1294}
1295
1296fn emit_furniture(out: &mut Out, depth: i32, layer: ContentLayer, inner: &Node) {
1301 let token = format!("<layer value=\"{}\"/>", layer.value());
1302 match inner {
1303 Node::Heading { level, text } => {
1304 let open = if *level <= 1 {
1305 "heading".to_string()
1306 } else {
1307 format!("heading level=\"{}\"", (*level).min(6))
1310 };
1311 out.push(depth, format!("<{open}>"));
1312 out.push(depth + 1, token);
1313 out.push(depth + 1, escape_text(text));
1314 out.push(depth, "</heading>".to_string());
1315 }
1316 Node::Paragraph { text } => {
1317 out.push(depth, "<text>".to_string());
1318 out.push(depth + 1, token);
1319 out.push(depth + 1, escape_text(text));
1320 out.push(depth, "</text>".to_string());
1321 }
1322 Node::Located { location, inner } => {
1325 if let Node::Paragraph { text } = &**inner {
1326 out.push(depth, "<text>".to_string());
1327 out.push(depth + 1, token);
1328 push_location(out, depth + 1, location);
1329 out.push(depth + 1, escape_text(text));
1330 out.push(depth, "</text>".to_string());
1331 } else {
1332 let mut i = 0usize;
1333 emit_nodes(out, depth, std::slice::from_ref(inner.as_ref()), &mut i, 0);
1334 }
1335 }
1336 Node::InlineGroup { runs, .. } => {
1340 out.push(depth, "<text>".to_string());
1341 for run in runs {
1342 out.push(depth + 1, token.clone());
1343 if run.is_plain() {
1344 out.push(depth + 1, escape_text(&run.text));
1345 } else if run.formula {
1346 out.push(
1347 depth + 1,
1348 format!("<formula>{}</formula>", escape_text(&run.text)),
1349 );
1350 } else {
1351 emit_styled(out, depth + 1, &style_tags(run), &escape_text(&run.text));
1352 }
1353 }
1354 out.push(depth, "</text>".to_string());
1355 }
1356 Node::Picture { caption, image, .. } => {
1362 let caption = caption.as_deref().filter(|c| !c.trim().is_empty());
1363 out.push(depth, "<picture>".to_string());
1364 out.push(depth + 1, token.clone());
1365 if let Some(img) = image {
1366 out.push(
1367 depth + 1,
1368 format!(
1369 "<src uri=\"data:image/png;base64,{}\"/>",
1370 crate::base64::encode(&img.data)
1371 ),
1372 );
1373 }
1374 if let Some(c) = caption {
1375 out.push(depth + 1, "<caption>".to_string());
1376 match inline_runs(c).into_iter().next() {
1377 Some(Run::Link { anchor, uri }) => {
1378 out.push(depth + 2, format!("<href uri=\"{}\"/>", attr_escape(&uri)));
1379 out.push(depth + 2, token);
1380 out.push(depth + 2, escape_text(&anchor));
1381 }
1382 _ => {
1383 out.push(depth + 2, token);
1384 out.push(depth + 2, escape_text(c));
1385 }
1386 }
1387 out.push(depth + 1, "</caption>".to_string());
1388 }
1389 out.push(depth, "</picture>".to_string());
1390 }
1391 Node::Table(table) => {
1394 out.push(depth, "<table>".to_string());
1395 out.push(depth + 1, token);
1396 emit_table_rows(out, depth, table);
1397 out.push(depth, "</table>".to_string());
1398 }
1399 other => {
1400 let mut i = 0usize;
1401 emit_nodes(out, depth, std::slice::from_ref(other), &mut i, 0);
1402 }
1403 }
1404}
1405
1406fn emit_picture(
1409 out: &mut Out,
1410 depth: i32,
1411 caption: Option<&str>,
1412 image: Option<&crate::document::PictureImage>,
1413 location: Option<&[u16; 4]>,
1414) {
1415 let caption = caption.filter(|c| !c.trim().is_empty());
1416 let src = image.map(|img| {
1423 let idx = out.pic_index;
1424 out.pic_index += 1;
1425 format!("assets/image_{idx:06}_{}.png", sha256_hex(&img.data))
1426 });
1427 if location.is_none() && caption.is_none() && src.is_none() {
1428 out.push(depth, "<picture></picture>".to_string());
1429 return;
1430 }
1431 out.push(depth, "<picture>".to_string());
1432 if let Some(loc) = location {
1433 push_location(out, depth + 1, loc);
1434 }
1435 if let Some(s) = src {
1436 out.push(depth + 1, format!("<src uri=\"{}\"/>", attr_escape(&s)));
1437 }
1438 if let Some(c) = caption {
1439 emit_caption(out, depth + 1, c);
1440 }
1441 out.push(depth, "</picture>".to_string());
1442}
1443
1444fn emit_caption(out: &mut Out, depth: i32, text: &str) {
1448 if let Some(Run::Link { anchor, uri }) = inline_runs(text).into_iter().next() {
1449 if inline_runs(text).len() == 1 {
1450 out.push(depth, "<caption>".to_string());
1451 out.push(depth + 1, format!("<href uri=\"{}\"/>", attr_escape(&uri)));
1452 out.push(depth + 1, escape_text(&anchor));
1453 out.push(depth, "</caption>".to_string());
1454 return;
1455 }
1456 }
1457 out.push(depth, format!("<caption>{}</caption>", escape_text(text)));
1458}
1459
1460fn strip_lone_link(text: &str) -> Cow<'_, str> {
1464 if let Some(rest) = text.strip_prefix('[') {
1465 if let Some(close) = rest.find("](") {
1466 if rest.ends_with(')') {
1467 let anchor = &rest[..close];
1468 let uri = &rest[close + 2..rest.len() - 1];
1469 if !anchor.contains(['[', ']']) && !uri.contains(['(', ')']) {
1470 return Cow::Owned(anchor.to_string());
1471 }
1472 }
1473 }
1474 }
1475 Cow::Borrowed(text)
1476}
1477
1478fn sha256_hex(bytes: &[u8]) -> String {
1480 use sha2::{Digest, Sha256};
1481 let mut h = Sha256::new();
1482 h.update(bytes);
1483 h.finalize().iter().map(|b| format!("{b:02x}")).collect()
1484}
1485
1486fn emit_located(out: &mut Out, depth: i32, location: &[u16; 4], inner: &Node) {
1489 match inner {
1490 Node::Heading { level, text } => {
1491 let open = if *level <= 1 {
1492 "heading".to_string()
1493 } else {
1494 format!("heading level=\"{}\"", (*level).min(6))
1497 };
1498 emit_text_element(out, depth, &open, "heading", text, Some(location));
1499 }
1500 Node::Paragraph { text } => {
1501 emit_text_element(out, depth, "text", "text", text, Some(location));
1502 }
1503 Node::Picture { caption, image, .. } => {
1504 emit_picture(
1505 out,
1506 depth,
1507 caption.as_deref(),
1508 image.as_ref(),
1509 Some(location),
1510 );
1511 }
1512 Node::Table(t) => {
1513 let mut t = t.clone();
1515 t.location = Some(*location);
1516 emit_table(out, depth, &t);
1517 }
1518 Node::Code { language, text, .. } => {
1519 emit_code(out, depth, language.as_deref(), text, Some(location));
1520 }
1521 other => {
1525 let mut i = 0usize;
1526 emit_nodes(out, depth, std::slice::from_ref(other), &mut i, 0);
1527 }
1528 }
1529}
1530
1531fn emit_list(out: &mut Out, depth: i32, nodes: &[Node], i: &mut usize, level: u8) {
1532 let ordered = match &nodes[*i] {
1535 Node::ListItem { ordered, dclx, .. } => dclx.as_ref().map_or(*ordered, |d| d.ordered),
1536 _ => false,
1537 };
1538 let open = if ordered {
1539 "<list class=\"ordered\">"
1540 } else {
1541 "<list>"
1542 };
1543 out.push(depth, open.to_string());
1544 let start = *i;
1545 let mut prev_number: Option<u64> = None;
1546 let mut prev_projected = false;
1550 while *i < nodes.len() {
1551 match &nodes[*i] {
1552 Node::ListItem {
1553 level: l,
1554 text,
1555 marker,
1556 ordered: o,
1557 number,
1558 first_in_list,
1559 location,
1560 dclx,
1561 href,
1562 layer,
1563 } if *l == level => {
1564 let eff_ordered = dclx.as_ref().map_or(*o, |d| d.ordered);
1567 let eff_marker = dclx.as_ref().map_or(marker.as_ref(), |d| d.marker.as_ref());
1568 if *i != start
1572 && (*first_in_list
1573 || eff_ordered != ordered
1574 || (ordered
1575 && !prev_projected
1576 && Some(*number) != prev_number.map(|n| n + 1)))
1577 {
1578 break;
1579 }
1580 prev_number = Some(*number);
1581 prev_projected = eff_ordered && !*o;
1582 let has_nested = {
1588 let mut found = false;
1589 let mut pn = Some(*number);
1590 let mut j = *i + 1;
1591 while let Some(Node::ListItem {
1592 level: nl,
1593 ordered: no,
1594 number: nn,
1595 first_in_list: nf,
1596 dclx: nd,
1597 ..
1598 }) = nodes.get(j)
1599 {
1600 if *nl > level {
1601 found = true;
1602 break;
1603 }
1604 if *nl < level {
1605 break;
1606 }
1607 let n_ordered = nd.as_ref().map_or(*no, |d| d.ordered);
1610 if *nf
1611 || n_ordered != ordered
1612 || (ordered && Some(*nn) != pn.map(|n| n + 1))
1613 {
1614 break;
1615 }
1616 pn = Some(*nn);
1617 j += 1;
1618 }
1619 found
1620 };
1621 match eff_marker {
1624 Some(m) => {
1625 out.push(depth + 1, "<ldiv>".to_string());
1626 out.push(depth + 2, format!("<marker>{}</marker>", escape_text(m)));
1627 out.push(depth + 1, "</ldiv>".to_string());
1628 }
1629 None => out.push(depth + 1, "<ldiv/>".to_string()),
1630 }
1631 if let Some(loc) = location {
1635 push_location(out, depth + 1, loc);
1636 }
1637 match dclx {
1638 Some(d) if !d.runs.is_empty() => {
1643 if has_nested {
1644 out.push(depth + 1, "<text>".to_string());
1645 emit_inline_runs_body(out, depth + 2, &d.runs);
1646 out.push(depth + 1, "</text>".to_string());
1647 } else {
1648 emit_inline_runs_body(out, depth + 1, &d.runs);
1649 }
1650 }
1651 Some(d) => emit_list_item_content(out, depth + 1, &d.text, has_nested),
1654 None => {
1655 let stripped = strip_lone_link(text);
1660 let eff_href = href
1661 .as_deref()
1662 .filter(|_| matches!(stripped, Cow::Owned(_)));
1663 if eff_href.is_some() || layer.is_some() {
1664 let content: &str = if eff_href.is_some() {
1665 stripped.as_ref()
1666 } else {
1667 text.as_str()
1668 };
1669 emit_list_item_with_head(
1670 out,
1671 depth + 1,
1672 content,
1673 has_nested,
1674 eff_href,
1675 *layer,
1676 );
1677 } else {
1678 emit_list_item_content(out, depth + 1, text, has_nested);
1679 }
1680 }
1681 }
1682 *i += 1;
1683 }
1684 Node::ListItem { level: l, .. } if *l > level => {
1685 emit_list(out, depth + 1, nodes, i, *l);
1686 }
1687 Node::Paragraph { text }
1693 if text.is_empty()
1694 && matches!(
1695 nodes.get(*i + 1),
1696 Some(Node::ListItem { level: nl, ordered: no, number: nn,
1697 first_in_list: nf, dclx: nd, .. })
1698 if *nl > level
1699 || (*nl == level
1700 && !*nf
1701 && nd.as_ref().map_or(*no, |d| d.ordered) == ordered
1702 && (!ordered
1703 || Some(*nn) == prev_number.map(|n| n + 1)))
1704 ) =>
1705 {
1706 *i += 1;
1707 }
1708 _ => break,
1709 }
1710 }
1711 out.push(depth, "</list>".to_string());
1712}
1713
1714fn emit_list_item_with_head(
1725 out: &mut Out,
1726 depth: i32,
1727 text: &str,
1728 has_nested: bool,
1729 href: Option<&str>,
1730 layer: Option<ContentLayer>,
1731) {
1732 let head = |out: &mut Out, d: i32| {
1733 if let Some(uri) = href {
1734 out.push(d, format!("<href uri=\"{}\"/>", attr_escape(uri)));
1735 }
1736 if let Some(l) = layer {
1737 out.push(d, format!("<layer value=\"{}\"/>", l.value()));
1738 }
1739 };
1740 if has_nested {
1741 out.push(depth, "<text>".to_string());
1742 head(out, depth + 1);
1743 emit_runs(out, depth + 1, inline_runs(text));
1744 out.push(depth, "</text>".to_string());
1745 } else {
1746 head(out, depth);
1747 emit_runs(out, depth, inline_runs(text));
1748 }
1749}
1750
1751fn emit_list_item_content(out: &mut Out, depth: i32, text: &str, has_nested: bool) {
1752 let runs = inline_runs_from_markdown(text);
1758 let single_plain = runs.len() <= 1 && runs.first().is_none_or(|r| r.is_plain());
1759 if single_plain {
1760 if has_nested {
1761 emit_text_element(out, depth, "text", "text", text, None);
1762 } else if !text.trim().is_empty() {
1763 emit_text_node(out, depth, text);
1767 }
1768 } else if has_nested {
1769 emit_inline_group(out, depth, false, &runs);
1770 } else {
1771 emit_inline_runs_body(out, depth, &runs);
1774 }
1775}
1776
1777fn emit_field_region(out: &mut Out, depth: i32, items: &[FieldItem]) {
1778 out.push(depth, "<field_region>".to_string());
1779 for item in items {
1780 out.push(depth + 1, "<field_item>".to_string());
1781 if let Some(m) = item.marker.as_ref().filter(|s| !s.is_empty()) {
1782 out.push(depth + 2, format!("<marker>{}</marker>", escape_text(m)));
1783 }
1784 if let Some(k) = item.key.as_ref().filter(|s| !s.is_empty()) {
1785 out.push(depth + 2, format!("<key>{}</key>", escape_text(k)));
1786 }
1787 if let Some(v) = item.value.as_ref().filter(|s| !s.is_empty()) {
1788 out.push(depth + 2, format!("<value>{}</value>", escape_text(v)));
1789 }
1790 out.push(depth + 1, "</field_item>".to_string());
1791 }
1792 out.push(depth, "</field_region>".to_string());
1793}
1794
1795#[cfg(test)]
1796mod tests {
1797 use super::*;
1798
1799 #[test]
1803 fn xml_illegal_characters_become_visible_markers() {
1804 let doclang = export_to_doclang(&[Node::Paragraph {
1805 text: "Before break\u{0B}After\u{FFFF} tab\tok".into(),
1806 }]);
1807 assert!(
1808 doclang.contains("Before break[U+000B]After[U+FFFF] tab\tok"),
1809 "got:\n{doclang}"
1810 );
1811 }
1812
1813 #[test]
1817 fn cdata_closing_delimiter_splits_sections() {
1818 let doclang = export_to_doclang(&[Node::Paragraph {
1819 text: "a]]>b & c".into(),
1820 }]);
1821 assert!(
1822 doclang.contains("<![CDATA[a]]]]><![CDATA[>b & c]]>"),
1823 "got:\n{doclang}"
1824 );
1825 }
1826
1827 #[test]
1830 fn deep_heading_levels_clamp_to_six() {
1831 let doclang = export_to_doclang(&[
1832 Node::Heading {
1833 level: 6,
1834 text: "Deep".into(),
1835 },
1836 Node::Heading {
1837 level: 42,
1838 text: "Deeper".into(),
1839 },
1840 ]);
1841 assert!(doclang.contains("<heading level=\"6\">Deep</heading>"));
1842 assert!(
1843 doclang.contains("<heading level=\"6\">Deeper</heading>"),
1844 "got:\n{doclang}"
1845 );
1846 }
1847
1848 #[test]
1849 fn located_heading_emits_location_tokens_in_block_form() {
1850 let doclang = export_to_doclang(&[Node::Located {
1851 location: [44, 170, 340, 386],
1852 inner: Box::new(Node::Heading {
1853 level: 1,
1854 text: "X-Library".into(),
1855 }),
1856 }]);
1857 assert!(
1858 doclang.contains(
1859 "<heading>\n <location value=\"44\"/>\n <location value=\"170\"/>\n \
1860 <location value=\"340\"/>\n <location value=\"386\"/>\n X-Library\n </heading>"
1861 ),
1862 "got:\n{doclang}"
1863 );
1864 }
1865
1866 fn code(language: Option<&str>, text: &str) -> String {
1867 export_to_doclang(&[Node::Code {
1868 language: language.map(String::from),
1869 text: text.into(),
1870 orig: None,
1871 pretty: None,
1872 }])
1873 }
1874
1875 #[test]
1876 fn code_with_language_emits_linguist_label_block_form() {
1877 assert_eq!(
1880 code(Some("python"), "print(\"Hello world!\")"),
1881 "<doclang version=\"0.7\">\n <code>\n <label value=\"Python\"/>\n\
1882 <![CDATA[print(\"Hello world!\")]]> </code>\n</doclang>"
1883 );
1884 assert!(code(Some("bash"), "ls -la").contains("<label value=\"Shell\"/>"));
1886 }
1887
1888 fn plain(text: &str) -> InlineRun {
1889 InlineRun {
1890 text: text.into(),
1891 ..Default::default()
1892 }
1893 }
1894 fn bold(text: &str) -> InlineRun {
1895 InlineRun {
1896 text: text.into(),
1897 bold: true,
1898 ..Default::default()
1899 }
1900 }
1901 fn ig(unwrapped: bool, runs: Vec<InlineRun>) -> String {
1902 let body = export_to_doclang(&[Node::InlineGroup {
1903 unwrapped,
1904 runs,
1905 md_text: String::new(),
1906 }]);
1907 body.trim_start_matches("<doclang version=\"0.7\">\n")
1909 .trim_end_matches("\n</doclang>")
1910 .to_string()
1911 }
1912
1913 #[test]
1914 fn inline_group_matches_reference_layout() {
1915 assert_eq!(
1917 ig(
1918 false,
1919 vec![plain("This is a"), bold("bold"), plain("example")]
1920 ),
1921 " <text>\n This is a\n <bold>bold</bold>\nexample\n </text>"
1922 );
1923 assert_eq!(
1925 ig(
1926 true,
1927 vec![
1928 plain("aa"),
1929 bold("bb"),
1930 plain("cc"),
1931 bold("dd"),
1932 plain("ee")
1933 ]
1934 ),
1935 "aa\n <bold>bb</bold>\ncc\n <bold>dd</bold>\nee"
1936 );
1937 assert_eq!(
1939 ig(false, vec![plain("aa"), plain("bb")]),
1940 " <text>aa\nbb\n</text>"
1941 );
1942 assert_eq!(ig(false, vec![plain("aa")]), " <text>aa\n</text>");
1943 assert_eq!(
1945 ig(false, vec![bold("bb")]),
1946 " <text>\n <bold>bb</bold>\n </text>"
1947 );
1948 }
1949
1950 #[test]
1951 fn nested_styles_wrap_outermost_last_applied() {
1952 let bi = InlineRun {
1953 text: "bi".into(),
1954 bold: true,
1955 italic: true,
1956 ..Default::default()
1957 };
1958 assert_eq!(
1960 ig(true, vec![bi]),
1961 " <italic>\n <bold>bi</bold>\n </italic>"
1962 );
1963 let sub = InlineRun {
1964 text: "2".into(),
1965 script: Script::Sub,
1966 ..Default::default()
1967 };
1968 assert_eq!(ig(true, vec![sub]), " <subscript>2</subscript>");
1969 }
1970
1971 #[test]
1972 fn furniture_heading_gets_layer_head() {
1973 let out = export_to_doclang(&[Node::Furniture {
1974 layer: ContentLayer::Furniture,
1975 inner: Box::new(Node::Heading {
1976 level: 1,
1977 text: "Anchor Links Test".into(),
1978 }),
1979 }]);
1980 assert_eq!(
1981 out,
1982 "<doclang version=\"0.7\">\n <heading>\n <layer value=\"furniture\"/>\n Anchor Links Test\n </heading>\n</doclang>"
1983 );
1984 }
1985
1986 #[test]
1987 fn text_dump_reproduces_minidom_per_line_layout() {
1988 let text = "PATN\nWKU 1\nPAL K. \"Determination\"\nfollow-up\n*Note A\n_______________\nNote B*\nEND";
1993 let out = export_to_doclang(&[Node::TextDump(text.into())]);
1994 let expected = "<doclang version=\"0.7\">\n \
1995 <text>\n \
1996 PATN\nWKU 1\n\
1997 <![CDATA[PAL K. \"Determination\"]]> \n\
1998 follow-up\n \
1999 <italic>Note A</italic>\n \
2000 <italic>__________</italic>\n \
2001 <italic>Note B</italic>\n\
2002 END\n \
2003 </text>\n</doclang>";
2004 assert_eq!(out, expected, "got:\n{out}");
2005 }
2006
2007 #[test]
2008 fn code_without_language_stays_inline_and_unlabeled() {
2009 assert_eq!(
2010 code(None, "print(\"Hi!\")"),
2011 "<doclang version=\"0.7\">\n <code><![CDATA[print(\"Hi!\")]]></code>\n</doclang>"
2012 );
2013 assert!(!code(Some("brainfuck"), "+++.").contains("<label"));
2015 }
2016}