use crate::document::{ContentLayer, FieldItem, InlineRun, Node, Script, Table};
use std::borrow::Cow;
const INDENT: &str = " ";
struct Out {
lines: Vec<(i32, String, bool)>,
pic_index: usize,
}
impl Out {
fn push(&mut self, depth: i32, s: impl Into<String>) {
self.lines.push((depth, s.into(), true));
}
fn push_glue(&mut self, s: impl Into<String>) {
self.lines.push((0, s.into(), false));
}
fn finish(self) -> String {
let mut s = String::new();
for (d, line, nl) in self.lines {
for _ in 0..d {
s.push_str(INDENT);
}
s.push_str(&line);
if nl {
s.push('\n');
}
}
if s.ends_with('\n') {
s.pop();
}
s
}
}
fn unescape_stored(text: &str) -> Cow<'_, str> {
if !text.contains('&') && !text.contains('\\') {
return Cow::Borrowed(text);
}
Cow::Owned(
text.replace("<", "<")
.replace(">", ">")
.replace("&", "&")
.replace("\\_", "_"),
)
}
fn escape_text(text: &str) -> String {
let raw = unescape_stored(text);
let text = raw.as_ref();
let needs_cdata = text.contains(['"', '\'', '&', '<', '>']);
let needs_content = text != text.trim() || text.contains('\n');
let mut t = if needs_cdata {
format!("<![CDATA[{text}]]>")
} else {
text.to_string()
};
if needs_content {
t = format!("<content>{t}</content>");
}
t
}
enum Run {
Plain(String),
Bold(String),
Italic(String),
BoldItalic(String),
Code(String),
Link {
anchor: String,
uri: String,
},
}
fn inline_runs(text: &str) -> Vec<Run> {
let mut runs = Vec::new();
let mut plain = String::new();
let bytes: Vec<char> = text.chars().collect();
let n = bytes.len();
let mut i = 0;
let find = |open: usize, pat: &str| -> Option<usize> {
let hay: String = bytes[open..].iter().collect();
hay.find(pat).map(|p| open + hay[..p].chars().count())
};
while i < n {
let rest: String = bytes[i..].iter().collect();
let take = |runs: &mut Vec<Run>, plain: &mut String, r: Run| {
if !plain.is_empty() {
runs.push(Run::Plain(std::mem::take(plain)));
}
runs.push(r);
};
if rest.starts_with("***") {
if let Some(end) = find(i + 3, "***") {
let inner: String = bytes[i + 3..end].iter().collect();
take(&mut runs, &mut plain, Run::BoldItalic(inner));
i = end + 3;
continue;
}
}
if rest.starts_with("**") {
if let Some(end) = find(i + 2, "**") {
let inner: String = bytes[i + 2..end].iter().collect();
take(&mut runs, &mut plain, Run::Bold(inner));
i = end + 2;
continue;
}
}
if rest.starts_with('*') && !rest.starts_with("**") {
if let Some(end) = find(i + 1, "*") {
let inner: String = bytes[i + 1..end].iter().collect();
if !inner.is_empty() {
take(&mut runs, &mut plain, Run::Italic(inner));
i = end + 1;
continue;
}
}
}
if rest.starts_with('`') {
if let Some(end) = find(i + 1, "`") {
let inner: String = bytes[i + 1..end].iter().collect();
take(&mut runs, &mut plain, Run::Code(inner));
i = end + 1;
continue;
}
}
if rest.starts_with('[') {
if let (Some(close), true) = (find(i + 1, "]("), true) {
if let Some(endp) = find(close + 2, ")") {
let anchor: String = bytes[i + 1..close].iter().collect();
let uri: String = bytes[close + 2..endp].iter().collect();
take(&mut runs, &mut plain, Run::Link { anchor, uri });
i = endp + 1;
continue;
}
}
}
plain.push(bytes[i]);
i += 1;
}
if !plain.is_empty() {
runs.push(Run::Plain(plain));
}
runs
}
pub fn inline_runs_from_markdown(text: &str) -> Vec<InlineRun> {
let mut out = Vec::new();
parse_md_runs(
&text.chars().collect::<Vec<_>>(),
InlineRun::default(),
&mut out,
);
out
}
fn flush_md_plain(buf: &mut String, style: &InlineRun, out: &mut Vec<InlineRun>) {
let text = std::mem::take(buf);
let text = text.trim();
if !text.is_empty() {
out.push(InlineRun {
text: text.to_string(),
..style.clone()
});
}
}
fn parse_md_runs(chars: &[char], style: InlineRun, out: &mut Vec<InlineRun>) {
let n = chars.len();
let mut i = 0;
let mut plain = String::new();
let find = |open: usize, pat: &str| -> Option<usize> {
let hay: String = chars[open..].iter().collect();
hay.find(pat).map(|p| open + hay[..p].chars().count())
};
let sub = |a: usize, b: usize| -> Vec<char> { chars[a..b].to_vec() };
while i < n {
let rest: String = chars[i..].iter().collect();
if rest.starts_with("***") {
if let Some(end) = find(i + 3, "***") {
flush_md_plain(&mut plain, &style, out);
parse_md_runs(
&sub(i + 3, end),
InlineRun {
bold: true,
italic: true,
..style.clone()
},
out,
);
i = end + 3;
continue;
}
}
if rest.starts_with("**") {
if let Some(end) = find(i + 2, "**") {
flush_md_plain(&mut plain, &style, out);
parse_md_runs(
&sub(i + 2, end),
InlineRun {
bold: true,
..style.clone()
},
out,
);
i = end + 2;
continue;
}
}
if rest.starts_with('*') {
if let Some(end) = find(i + 1, "*") {
if end > i + 1 {
flush_md_plain(&mut plain, &style, out);
parse_md_runs(
&sub(i + 1, end),
InlineRun {
italic: true,
..style.clone()
},
out,
);
i = end + 1;
continue;
}
}
}
if rest.starts_with("~~") {
if let Some(end) = find(i + 2, "~~") {
flush_md_plain(&mut plain, &style, out);
parse_md_runs(
&sub(i + 2, end),
InlineRun {
strike: true,
..style.clone()
},
out,
);
i = end + 2;
continue;
}
}
if rest.starts_with('`') {
if let Some(end) = find(i + 1, "`") {
flush_md_plain(&mut plain, &style, out);
let inner: String = sub(i + 1, end).iter().collect();
let inner = inner.trim();
if !inner.is_empty() {
out.push(InlineRun {
text: inner.to_string(),
code: true,
..style.clone()
});
}
i = end + 1;
continue;
}
}
if rest.starts_with('[') {
if let Some(close) = find(i + 1, "](") {
if let Some(endp) = find(close + 2, ")") {
flush_md_plain(&mut plain, &style, out);
parse_md_runs(&sub(i + 1, close), style.clone(), out);
i = endp + 1;
continue;
}
}
}
plain.push(chars[i]);
i += 1;
}
flush_md_plain(&mut plain, &style, out);
}
fn attr_escape(v: &str) -> String {
v.replace('&', "&").replace('"', """)
}
fn emit_text_element(
out: &mut Out,
depth: i32,
tag_open: &str,
tag: &str,
text: &str,
location: Option<&[u16; 4]>,
) {
if let Some(loc) = location {
out.push(depth, format!("<{tag_open}>"));
push_location(out, depth + 1, loc);
if !text.is_empty() {
emit_runs(out, depth + 1, inline_runs(text));
}
out.push(depth, format!("</{tag}>"));
return;
}
if text.is_empty() {
out.push(depth, format!("<{tag_open}></{tag}>"));
return;
}
let runs = inline_runs(text);
let only_plain = runs.len() == 1 && matches!(runs[0], Run::Plain(_));
if runs.len() == 1 {
if let Run::Link { anchor, uri } = &runs[0] {
out.push(depth, format!("<{tag_open}>"));
out.push(depth + 1, format!("<href uri=\"{}\"/>", attr_escape(uri)));
if !anchor.trim().is_empty() {
emit_runs(out, depth + 1, inline_runs(anchor));
}
out.push(depth, format!("</{tag}>"));
return;
}
}
if only_plain {
let body = escape_text(text);
if body.starts_with("<content>") {
out.push(depth, format!("<{tag_open}>"));
out.push(depth + 1, body);
out.push(depth, format!("</{tag}>"));
} else {
out.push(depth, format!("<{tag_open}>{body}</{tag}>"));
}
return;
}
out.push(depth, format!("<{tag_open}>"));
emit_runs(out, depth + 1, runs);
out.push(depth, format!("</{tag}>"));
}
fn emit_runs(out: &mut Out, depth: i32, runs: Vec<Run>) {
for run in runs {
match run {
Run::Plain(t) => {
let t = t.trim_matches('\n');
if !t.is_empty() {
emit_text_node(out, depth, t);
}
}
Run::Bold(t) => out.push(depth, format!("<bold>{}</bold>", escape_text(&t))),
Run::Italic(t) => out.push(depth, format!("<italic>{}</italic>", escape_text(&t))),
Run::BoldItalic(t) => {
out.push(depth, "<italic>".to_string());
out.push(depth + 1, format!("<bold>{}</bold>", escape_text(&t)));
out.push(depth, "</italic>".to_string());
}
Run::Code(t) => out.push(depth, format!("<code>{}</code>", escape_text(&t))),
Run::Link { anchor, .. } => {
if !anchor.is_empty() {
emit_text_node(out, depth, &anchor);
}
}
}
}
}
fn emit_text_node(out: &mut Out, depth: i32, text: &str) {
let e = escape_text(text);
if e.starts_with("<![CDATA[") {
out.push_glue(e);
} else {
out.push(depth, e);
}
}
fn code_lang_label(lang: &str) -> Option<&'static str> {
let lang = crate::json::code_language(Some(lang));
Some(match lang {
"Bash" => "Shell",
"FORTRAN" => "Fortran",
"Latex" => "TeX",
"Lisp" => "Common Lisp",
"Matlab" | "Octave" => "MATLAB",
"ObjectiveC" => "Objective-C",
"SML" => "Standard ML",
"VisualBasic" => "Visual Basic .NET",
"DocLang" => "XML",
"bc" | "dc" | "Tikz" => "other",
"Ada" | "Awk" | "C" | "C#" | "C++" | "CMake" | "COBOL" | "CSS" | "Ceylon" | "Clojure"
| "Crystal" | "Cuda" | "Cython" | "D" | "Dart" | "Dockerfile" | "Elixir" | "Erlang"
| "Forth" | "Go" | "HTML" | "Haskell" | "Haxe" | "Java" | "JavaScript" | "JSON"
| "Julia" | "Kotlin" | "Lua" | "MoonScript" | "Nim" | "OCaml" | "PHP" | "Pascal"
| "Perl" | "Prolog" | "Python" | "Racket" | "Ruby" | "Rust" | "SQL" | "Scala"
| "Scheme" | "Swift" | "TypeScript" | "XML" | "YAML" => {
return Some(IDENTITY_LABELS[IDENTITY_LABELS.iter().position(|&x| x == lang).unwrap()])
}
_ => return None, })
}
static IDENTITY_LABELS: &[&str] = &[
"Ada",
"Awk",
"C",
"C#",
"C++",
"CMake",
"COBOL",
"CSS",
"Ceylon",
"Clojure",
"Crystal",
"Cuda",
"Cython",
"D",
"Dart",
"Dockerfile",
"Elixir",
"Erlang",
"Forth",
"Go",
"HTML",
"Haskell",
"Haxe",
"Java",
"JavaScript",
"JSON",
"Julia",
"Kotlin",
"Lua",
"MoonScript",
"Nim",
"OCaml",
"PHP",
"Pascal",
"Perl",
"Prolog",
"Python",
"Racket",
"Ruby",
"Rust",
"SQL",
"Scala",
"Scheme",
"Swift",
"TypeScript",
"XML",
"YAML",
];
fn emit_code(
out: &mut Out,
depth: i32,
language: Option<&str>,
text: &str,
location: Option<&[u16; 4]>,
) {
let label = language.and_then(code_lang_label);
let escaped = escape_text(text);
let is_content_element = escaped.starts_with("<content>");
if let Some(loc) = location {
out.push(depth, "<code>".to_string());
push_location(out, depth + 1, loc);
if let Some(l) = label {
out.push(depth + 1, format!("<label value=\"{}\"/>", attr_escape(l)));
}
if is_content_element {
out.push(depth + 1, escaped);
} else {
out.push_glue(escaped);
}
out.push(depth, "</code>".to_string());
return;
}
match (label, is_content_element) {
(None, false) => out.push(depth, format!("<code>{escaped}</code>")),
(None, true) => {
out.push(depth, "<code>".to_string());
out.push(depth + 1, escaped);
out.push(depth, "</code>".to_string());
}
(Some(l), false) => {
out.push(depth, "<code>".to_string());
out.push(depth + 1, format!("<label value=\"{}\"/>", attr_escape(l)));
out.push_glue(escaped);
out.push(depth, "</code>".to_string());
}
(Some(l), true) => {
out.push(depth, "<code>".to_string());
out.push(depth + 1, format!("<label value=\"{}\"/>", attr_escape(l)));
out.push(depth + 1, escaped);
out.push(depth, "</code>".to_string());
}
}
}
fn push_location(out: &mut Out, depth: i32, loc: &[u16; 4]) {
for v in loc {
out.push(depth, format!("<location value=\"{v}\"/>"));
}
}
fn emit_table(out: &mut Out, depth: i32, table: &Table) {
out.push(depth, "<table>".to_string());
emit_table_rows(out, depth, table);
out.push(depth, "</table>".to_string());
}
fn emit_chart(
out: &mut Out,
depth: i32,
kind: &str,
table: &Table,
caption: Option<&str>,
location: Option<&[u16; 4]>,
) {
out.pic_index += 1;
out.push(depth, "<picture class=\"chart\">".to_string());
out.push(
depth + 1,
format!("<label value=\"{}\"/>", attr_escape(kind)),
);
if let Some(loc) = location {
push_location(out, depth + 1, loc);
}
if let Some(cap) = caption {
out.push(
depth + 1,
format!("<caption>{}</caption>", escape_text(cap)),
);
}
out.push(depth + 1, "<tabular>".to_string());
emit_table_rows(out, depth + 1, table);
out.push(depth + 1, "</tabular>".to_string());
out.push(depth, "</picture>".to_string());
}
fn emit_table_rows(out: &mut Out, depth: i32, table: &Table) {
if let Some(loc) = &table.location {
push_location(out, depth + 1, loc);
}
for (ri, row) in table.rows.iter().enumerate() {
for (ci, cell) in row.iter().enumerate() {
let cont = |grid: &Vec<Vec<bool>>| {
grid.get(ri)
.and_then(|r| r.get(ci))
.copied()
.unwrap_or(false)
};
let is_lcel = table
.structure
.as_ref()
.map(|s| cont(&s.col_continuation))
.unwrap_or(false);
let is_ucel = table
.structure
.as_ref()
.map(|s| cont(&s.row_continuation))
.unwrap_or(false);
let is_header = match &table.structure {
Some(s) if !s.col_header.is_empty() => s
.col_header
.get(ri)
.and_then(|r| r.get(ci))
.copied()
.unwrap_or(false),
Some(s) => s.header_row.get(ri).copied().unwrap_or(false),
None => ri == 0,
};
let is_row_header = table
.structure
.as_ref()
.map(|s| {
s.row_header
.get(ri)
.and_then(|r| r.get(ci))
.copied()
.unwrap_or(false)
})
.unwrap_or(false);
let tok = if is_lcel && is_ucel {
"<xcel/>"
} else if is_lcel {
"<lcel/>"
} else if is_ucel {
"<ucel/>"
} else if cell.trim().is_empty() {
"<ecel/>"
} else if is_header {
"<ched/>"
} else if is_row_header {
"<rhed/>"
} else {
"<fcel/>"
};
out.push(depth + 1, tok.to_string());
if !is_lcel && !is_ucel {
let blocks = table
.cell_blocks
.as_ref()
.and_then(|b| b.get(ri))
.and_then(|r| r.get(ci))
.filter(|b| !b.is_empty());
if let Some(blocks) = blocks {
let mut bi = 0;
emit_nodes(out, depth + 1, blocks, &mut bi, 0);
} else if !cell.trim().is_empty() {
emit_cell_text(out, depth + 1, cell);
}
}
}
out.push(depth + 1, "<nl/>".to_string());
}
}
fn emit_cell_text(out: &mut Out, depth: i32, text: &str) {
let runs = inline_runs(text.trim());
emit_runs(out, depth, runs);
}
pub fn export_to_doclang(nodes: &[Node]) -> String {
let mut out = Out {
lines: Vec::new(),
pic_index: 0,
};
out.push(0, "<doclang version=\"0.7\">".to_string());
let mut i = 0usize;
emit_nodes(&mut out, 1, nodes, &mut i, 0);
out.push(0, "</doclang>".to_string());
out.finish()
}
fn emit_nodes(out: &mut Out, depth: i32, nodes: &[Node], i: &mut usize, level: u8) {
while *i < nodes.len() {
match &nodes[*i] {
Node::Heading { level, text } => {
let open = if *level <= 1 {
"heading".to_string()
} else {
format!("heading level=\"{level}\"")
};
emit_text_element(out, depth, &open, "heading", text, None);
*i += 1;
}
Node::Paragraph { text } => {
if let Some(latex) = text
.strip_prefix("$$")
.and_then(|t| t.strip_suffix("$$"))
.filter(|t| !t.is_empty())
{
out.push(depth, format!("<formula>{}</formula>", escape_text(latex)));
} else {
emit_text_element(out, depth, "text", "text", text, None);
}
*i += 1;
}
Node::CheckboxItem { checked, text } => {
let class = if *checked { "selected" } else { "unselected" };
out.push(depth, "<text>".to_string());
out.push(depth + 1, format!("<checkbox class=\"{class}\"/>"));
if !text.is_empty() {
out.push(depth + 1, escape_text(text));
}
out.push(depth, "</text>".to_string());
*i += 1;
}
Node::Code {
language,
text,
orig: _,
} => {
emit_code(out, depth, language.as_deref(), text, None);
*i += 1;
}
Node::Formula {
latex, location, ..
} => {
if let Some(loc) = location {
out.push(depth, "<formula>".to_string());
push_location(out, depth + 1, loc);
if !latex.is_empty() {
out.push(depth + 1, escape_text(latex));
}
out.push(depth, "</formula>".to_string());
} else {
out.push(depth, format!("<formula>{}</formula>", escape_text(latex)));
}
*i += 1;
}
Node::PageFurniture {
footer,
location,
text,
} => {
let tag = if *footer {
"page_footer"
} else {
"page_header"
};
out.push(depth, format!("<{tag}>"));
out.push(depth + 1, "<layer value=\"furniture\"/>".to_string());
push_location(out, depth + 1, location);
if !text.is_empty() {
out.push(depth + 1, escape_text(text));
}
out.push(depth, format!("</{tag}>"));
*i += 1;
}
Node::Table(t) => {
emit_table(out, depth, t);
*i += 1;
}
Node::Picture { caption, image, .. } => {
emit_picture(out, depth, caption.as_deref(), image.as_ref(), None);
*i += 1;
}
Node::Chart {
kind,
table,
caption,
location,
} => {
emit_chart(
out,
depth,
kind,
table,
caption.as_deref(),
location.as_ref(),
);
*i += 1;
}
Node::DoclangOnly(inner) => {
let mut j = 0;
emit_nodes(out, depth, std::slice::from_ref(inner), &mut j, level);
*i += 1;
}
Node::ListItem { level: l, .. } => {
if *l < level {
return; }
emit_list(out, depth, nodes, i, *l);
}
Node::Group { children, .. } => {
let mut j = 0usize;
emit_nodes(out, depth, children, &mut j, 0);
*i += 1;
}
Node::FieldRegion { items } => {
emit_field_region(out, depth, items);
*i += 1;
}
Node::InlineGroup {
unwrapped, runs, ..
} => {
emit_inline_group(out, depth, *unwrapped, runs);
*i += 1;
}
Node::Furniture { layer, inner } => {
emit_furniture(out, depth, *layer, inner);
*i += 1;
}
Node::Located { location, inner } => {
emit_located(out, depth, location, inner);
*i += 1;
}
Node::PageBreak => {
out.push(depth, "<page_break/>".to_string());
*i += 1;
}
Node::TextDump(text) => {
emit_text_dump(out, depth, text);
*i += 1;
}
}
}
}
enum DumpNode {
Text(String),
Cdata(String),
Elem(String),
}
fn emit_text_dump(out: &mut Out, depth: i32, text: &str) {
let records = dump_records(text);
if records.is_empty() {
out.push(depth, "<text></text>".to_string());
return;
}
let mut nodes: Vec<DumpNode> = Vec::new();
let mut buf = String::new();
for (r, (line, italic)) in records.iter().enumerate() {
if r > 0 {
buf.push('\n'); }
let raw = unescape_stored(line);
let s = raw.as_ref();
let is_cdata = s.contains(['"', '\'', '&', '<', '>']);
if *italic || is_cdata {
if !buf.is_empty() {
nodes.push(DumpNode::Text(std::mem::take(&mut buf)));
}
let inner = if is_cdata {
format!("<![CDATA[{s}]]>")
} else {
s.to_string()
};
if *italic {
nodes.push(DumpNode::Elem(format!("<italic>{inner}</italic>")));
} else {
nodes.push(DumpNode::Cdata(inner));
}
} else {
buf.push_str(s);
}
}
if !buf.is_empty() {
nodes.push(DumpNode::Text(buf));
}
if let [DumpNode::Text(d)] = nodes.as_slice() {
out.push(depth, format!("<text>{d}\n</text>"));
return;
}
let ind_child = INDENT.repeat((depth + 1).max(0) as usize);
let ind_self = INDENT.repeat(depth.max(0) as usize);
let mut raw = String::new();
for node in &nodes {
match node {
DumpNode::Text(d) => {
raw.push_str(&ind_child);
raw.push_str(d);
raw.push('\n');
}
DumpNode::Cdata(b) => raw.push_str(b),
DumpNode::Elem(b) => {
raw.push_str(&ind_child);
raw.push_str(b);
raw.push('\n');
}
}
}
let full = format!("{ind_self}<text>\n{raw}{ind_self}</text>");
for line in full.split('\n') {
if !line.trim().is_empty() {
out.push(0, line.to_string());
}
}
}
fn dump_records(text: &str) -> Vec<(String, bool)> {
let chars: Vec<char> = text.chars().collect();
let n = chars.len();
struct Delim {
pos: usize,
length: usize,
rem: usize,
can_open: bool,
can_close: bool,
}
let is_ws = |c: Option<char>| c.is_none_or(|c| c.is_whitespace());
let is_punct =
|c: Option<char>| c.is_some_and(|c| "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~".contains(c));
let mut delims: Vec<Delim> = Vec::new();
let mut i = 0;
while i < n {
if chars[i] == '*' {
let mut j = i;
while j < n && chars[j] == '*' {
j += 1;
}
let prev = (i > 0).then(|| chars[i - 1]);
let next = (j < n).then(|| chars[j]);
let left = !is_ws(next) && (!is_punct(next) || is_ws(prev) || is_punct(prev));
let right = !is_ws(prev) && (!is_punct(prev) || is_ws(next) || is_punct(next));
delims.push(Delim {
pos: i,
length: j - i,
rem: j - i,
can_open: left,
can_close: right,
});
i = j;
} else {
i += 1;
}
}
let mut emph = vec![false; n];
let mut consumed = vec![false; n];
let mut ci = 0;
while ci < delims.len() {
if !(delims[ci].can_close && delims[ci].rem > 0) {
ci += 1;
continue;
}
let mut found: Option<usize> = None;
let mut oi = ci as i64 - 1;
while oi >= 0 {
let o = &delims[oi as usize];
let c = &delims[ci];
if o.can_open && o.rem > 0 {
let odd = (o.can_close || c.can_open)
&& (o.length + c.length) % 3 == 0
&& !(o.length % 3 == 0 && c.length % 3 == 0);
if !odd {
found = Some(oi as usize);
break;
}
}
oi -= 1;
}
let Some(fi) = found else {
ci += 1;
continue;
};
let use_ = if delims[fi].rem >= 2 && delims[ci].rem >= 2 {
2
} else {
1
};
let oend = delims[fi].pos + delims[fi].rem;
for c in consumed.iter_mut().take(oend).skip(oend - use_) {
*c = true;
}
let cstart = delims[ci].pos + (delims[ci].length - delims[ci].rem);
for c in consumed.iter_mut().take(cstart + use_).skip(cstart) {
*c = true;
}
for e in emph.iter_mut().take(cstart).skip(oend) {
*e = true;
}
delims[fi].rem -= use_;
delims[ci].rem -= use_;
delims.drain((fi + 1)..ci);
ci = if delims[fi].rem == 0 { fi + 1 } else { fi };
}
let mut records: Vec<(String, bool)> = Vec::new();
let mut line = String::new();
let mut line_italic = false;
let push_line = |line: &mut String, italic: &mut bool, out: &mut Vec<(String, bool)>| {
let text = std::mem::take(line);
let ital = std::mem::replace(italic, false);
let trimmed = text.trim();
if trimmed.is_empty() {
return;
}
let norm = if trimmed.len() >= 3 && trimmed.chars().all(|c| c == '_') {
"_".repeat(10)
} else {
text
};
out.push((norm, ital));
};
for k in 0..n {
if consumed[k] {
continue;
}
if chars[k] == '\n' {
push_line(&mut line, &mut line_italic, &mut records);
} else {
line.push(chars[k]);
if emph[k] {
line_italic = true;
}
}
}
push_line(&mut line, &mut line_italic, &mut records);
records
}
fn emit_inline_group(out: &mut Out, depth: i32, unwrapped: bool, runs: &[InlineRun]) {
let has_styled = runs.iter().any(|r| !r.is_plain());
if unwrapped {
for run in runs {
if run.is_plain() {
out.push(0, escape_text(&run.text));
} else if run.formula {
out.push(
depth,
format!("<formula>{}</formula>", escape_text(&run.text)),
);
} else {
emit_styled(out, depth, &style_tags(run), &escape_text(&run.text));
}
}
return;
}
if !has_styled {
let joined = runs
.iter()
.map(|r| escape_text(&r.text))
.collect::<Vec<_>>()
.join("\n");
out.push(depth, format!("<text>{joined}\n</text>"));
return;
}
out.push(depth, "<text>".to_string());
emit_inline_runs_body(out, depth + 1, runs);
out.push(depth, "</text>".to_string());
}
fn emit_inline_runs_body(out: &mut Out, depth: i32, runs: &[InlineRun]) {
for (i, run) in runs.iter().enumerate() {
if run.is_plain() {
let e = escape_text(&run.text);
let d = if e.starts_with("<content>") || i == 0 {
depth
} else {
0
};
if e.starts_with("<![CDATA[") && i + 1 == runs.len() && d == 0 {
out.push_glue(e);
out.push(depth, "");
} else {
out.push(d, e);
}
} else if run.formula {
out.push(
depth,
format!("<formula>{}</formula>", escape_text(&run.text)),
);
} else {
emit_styled(out, depth, &style_tags(run), &escape_text(&run.text));
}
}
}
fn style_tags(run: &InlineRun) -> Vec<&'static str> {
let mut tags = Vec::new();
match run.script {
Script::Sub => tags.push("subscript"),
Script::Super => tags.push("superscript"),
Script::Baseline => {}
}
if run.strike {
tags.push("strikethrough");
}
if run.underline {
tags.push("underline");
}
if run.italic {
tags.push("italic");
}
if run.bold {
tags.push("bold");
}
if run.code {
tags.push("code");
}
tags
}
fn emit_styled(out: &mut Out, depth: i32, tags: &[&str], inner: &str) {
match tags {
[] => emit_text_node(out, depth, inner),
[tag] => out.push(depth, format!("<{tag}>{inner}</{tag}>")),
[tag, rest @ ..] => {
out.push(depth, format!("<{tag}>"));
emit_styled(out, depth + 1, rest, inner);
out.push(depth, format!("</{tag}>"));
}
}
}
fn emit_furniture(out: &mut Out, depth: i32, layer: ContentLayer, inner: &Node) {
let token = format!("<layer value=\"{}\"/>", layer.value());
match inner {
Node::Heading { level, text } => {
let open = if *level <= 1 {
"heading".to_string()
} else {
format!("heading level=\"{level}\"")
};
out.push(depth, format!("<{open}>"));
out.push(depth + 1, token);
out.push(depth + 1, escape_text(text));
out.push(depth, "</heading>".to_string());
}
Node::Paragraph { text } => {
out.push(depth, "<text>".to_string());
out.push(depth + 1, token);
out.push(depth + 1, escape_text(text));
out.push(depth, "</text>".to_string());
}
Node::Located { location, inner } => {
if let Node::Paragraph { text } = &**inner {
out.push(depth, "<text>".to_string());
out.push(depth + 1, token);
push_location(out, depth + 1, location);
out.push(depth + 1, escape_text(text));
out.push(depth, "</text>".to_string());
} else {
let mut i = 0usize;
emit_nodes(out, depth, std::slice::from_ref(inner.as_ref()), &mut i, 0);
}
}
Node::InlineGroup { runs, .. } => {
out.push(depth, "<text>".to_string());
for run in runs {
out.push(depth + 1, token.clone());
if run.is_plain() {
out.push(depth + 1, escape_text(&run.text));
} else if run.formula {
out.push(
depth + 1,
format!("<formula>{}</formula>", escape_text(&run.text)),
);
} else {
emit_styled(out, depth + 1, &style_tags(run), &escape_text(&run.text));
}
}
out.push(depth, "</text>".to_string());
}
Node::Picture { caption, image, .. } => {
let caption = caption.as_deref().filter(|c| !c.trim().is_empty());
out.push(depth, "<picture>".to_string());
out.push(depth + 1, token.clone());
if let Some(img) = image {
out.push(
depth + 1,
format!(
"<src uri=\"data:image/png;base64,{}\"/>",
crate::base64::encode(&img.data)
),
);
}
if let Some(c) = caption {
out.push(depth + 1, "<caption>".to_string());
match inline_runs(c).into_iter().next() {
Some(Run::Link { anchor, uri }) => {
out.push(depth + 2, format!("<href uri=\"{}\"/>", attr_escape(&uri)));
out.push(depth + 2, token.clone());
out.push(depth + 2, escape_text(&anchor));
}
_ => {
out.push(depth + 2, token.clone());
out.push(depth + 2, escape_text(c));
}
}
out.push(depth + 1, "</caption>".to_string());
}
out.push(depth, "</picture>".to_string());
}
Node::Table(table) => {
out.push(depth, "<table>".to_string());
out.push(depth + 1, token);
emit_table_rows(out, depth, table);
out.push(depth, "</table>".to_string());
}
other => {
let mut i = 0usize;
emit_nodes(out, depth, std::slice::from_ref(other), &mut i, 0);
}
}
}
fn emit_picture(
out: &mut Out,
depth: i32,
caption: Option<&str>,
image: Option<&crate::document::PictureImage>,
location: Option<&[u16; 4]>,
) {
let caption = caption.filter(|c| !c.trim().is_empty());
let src = image.map(|img| {
let idx = out.pic_index;
out.pic_index += 1;
format!("assets/image_{idx:06}_{}.png", sha256_hex(&img.data))
});
if location.is_none() && caption.is_none() && src.is_none() {
out.push(depth, "<picture></picture>".to_string());
return;
}
out.push(depth, "<picture>".to_string());
if let Some(loc) = location {
push_location(out, depth + 1, loc);
}
if let Some(s) = src {
out.push(depth + 1, format!("<src uri=\"{}\"/>", attr_escape(&s)));
}
if let Some(c) = caption {
emit_caption(out, depth + 1, c);
}
out.push(depth, "</picture>".to_string());
}
fn emit_caption(out: &mut Out, depth: i32, text: &str) {
if let Some(Run::Link { anchor, uri }) = inline_runs(text).into_iter().next() {
if inline_runs(text).len() == 1 {
out.push(depth, "<caption>".to_string());
out.push(depth + 1, format!("<href uri=\"{}\"/>", attr_escape(&uri)));
out.push(depth + 1, escape_text(&anchor));
out.push(depth, "</caption>".to_string());
return;
}
}
out.push(depth, format!("<caption>{}</caption>", escape_text(text)));
}
fn strip_lone_link(text: &str) -> Cow<'_, str> {
if let Some(rest) = text.strip_prefix('[') {
if let Some(close) = rest.find("](") {
if rest.ends_with(')') {
let anchor = &rest[..close];
let uri = &rest[close + 2..rest.len() - 1];
if !anchor.contains(['[', ']']) && !uri.contains(['(', ')']) {
return Cow::Owned(anchor.to_string());
}
}
}
}
Cow::Borrowed(text)
}
fn sha256_hex(bytes: &[u8]) -> String {
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
h.update(bytes);
h.finalize().iter().map(|b| format!("{b:02x}")).collect()
}
fn emit_located(out: &mut Out, depth: i32, location: &[u16; 4], inner: &Node) {
match inner {
Node::Heading { level, text } => {
let open = if *level <= 1 {
"heading".to_string()
} else {
format!("heading level=\"{level}\"")
};
emit_text_element(out, depth, &open, "heading", text, Some(location));
}
Node::Paragraph { text } => {
emit_text_element(out, depth, "text", "text", text, Some(location));
}
Node::Picture { caption, image, .. } => {
emit_picture(
out,
depth,
caption.as_deref(),
image.as_ref(),
Some(location),
);
}
Node::Table(t) => {
let mut t = t.clone();
t.location = Some(*location);
emit_table(out, depth, &t);
}
Node::Code { language, text, .. } => {
emit_code(out, depth, language.as_deref(), text, Some(location));
}
other => {
let mut i = 0usize;
emit_nodes(out, depth, std::slice::from_ref(other), &mut i, 0);
}
}
}
fn emit_list(out: &mut Out, depth: i32, nodes: &[Node], i: &mut usize, level: u8) {
let ordered = match &nodes[*i] {
Node::ListItem { ordered, dclx, .. } => dclx.as_ref().map_or(*ordered, |d| d.ordered),
_ => false,
};
let open = if ordered {
"<list class=\"ordered\">"
} else {
"<list>"
};
out.push(depth, open.to_string());
let start = *i;
let mut prev_number: Option<u64> = None;
while *i < nodes.len() {
match &nodes[*i] {
Node::ListItem {
level: l,
text,
marker,
ordered: o,
number,
first_in_list,
location,
dclx,
href,
layer,
} if *l == level => {
let eff_ordered = dclx.as_ref().map_or(*o, |d| d.ordered);
let eff_marker = dclx.as_ref().map_or(marker.as_ref(), |d| d.marker.as_ref());
if *i != start
&& (*first_in_list
|| eff_ordered != ordered
|| (ordered && Some(*number) != prev_number.map(|n| n + 1)))
{
break;
}
prev_number = Some(*number);
let has_nested = {
let mut found = false;
let mut pn = Some(*number);
let mut j = *i + 1;
while let Some(Node::ListItem {
level: nl,
ordered: no,
number: nn,
first_in_list: nf,
dclx: nd,
..
}) = nodes.get(j)
{
if *nl > level {
found = true;
break;
}
if *nl < level {
break;
}
let n_ordered = nd.as_ref().map_or(*no, |d| d.ordered);
if *nf
|| n_ordered != ordered
|| (ordered && Some(*nn) != pn.map(|n| n + 1))
{
break;
}
pn = Some(*nn);
j += 1;
}
found
};
match eff_marker {
Some(m) => {
out.push(depth + 1, "<ldiv>".to_string());
out.push(depth + 2, format!("<marker>{}</marker>", escape_text(m)));
out.push(depth + 1, "</ldiv>".to_string());
}
None => out.push(depth + 1, "<ldiv/>".to_string()),
}
if let Some(loc) = location {
push_location(out, depth + 1, loc);
}
match dclx {
Some(d) if !d.runs.is_empty() => {
if has_nested {
out.push(depth + 1, "<text>".to_string());
emit_inline_runs_body(out, depth + 2, &d.runs);
out.push(depth + 1, "</text>".to_string());
} else {
emit_inline_runs_body(out, depth + 1, &d.runs);
}
}
Some(d) => emit_list_item_content(out, depth + 1, &d.text, has_nested),
None => {
let stripped = strip_lone_link(text);
let eff_href = href
.as_deref()
.filter(|_| matches!(stripped, Cow::Owned(_)));
if eff_href.is_some() || layer.is_some() {
let content: &str = if eff_href.is_some() {
stripped.as_ref()
} else {
text.as_str()
};
emit_list_item_with_head(
out,
depth + 1,
content,
has_nested,
eff_href,
*layer,
);
} else {
emit_list_item_content(out, depth + 1, text, has_nested);
}
}
}
*i += 1;
}
Node::ListItem { level: l, .. } if *l > level => {
emit_list(out, depth + 1, nodes, i, *l);
}
Node::Paragraph { text }
if text.is_empty()
&& matches!(
nodes.get(*i + 1),
Some(Node::ListItem { level: nl, ordered: no, number: nn,
first_in_list: nf, dclx: nd, .. })
if *nl > level
|| (*nl == level
&& !*nf
&& nd.as_ref().map_or(*no, |d| d.ordered) == ordered
&& (!ordered
|| Some(*nn) == prev_number.map(|n| n + 1)))
) =>
{
*i += 1;
}
_ => break,
}
}
out.push(depth, "</list>".to_string());
}
fn emit_list_item_with_head(
out: &mut Out,
depth: i32,
text: &str,
has_nested: bool,
href: Option<&str>,
layer: Option<ContentLayer>,
) {
let head = |out: &mut Out, d: i32| {
if let Some(uri) = href {
out.push(d, format!("<href uri=\"{}\"/>", attr_escape(uri)));
}
if let Some(l) = layer {
out.push(d, format!("<layer value=\"{}\"/>", l.value()));
}
};
if has_nested {
out.push(depth, "<text>".to_string());
head(out, depth + 1);
emit_runs(out, depth + 1, inline_runs(text));
out.push(depth, "</text>".to_string());
} else {
head(out, depth);
emit_runs(out, depth, inline_runs(text));
}
}
fn emit_list_item_content(out: &mut Out, depth: i32, text: &str, has_nested: bool) {
let runs = inline_runs_from_markdown(text);
let single_plain = runs.len() <= 1 && runs.first().is_none_or(|r| r.is_plain());
if single_plain {
if has_nested {
emit_text_element(out, depth, "text", "text", text, None);
} else if !text.trim().is_empty() {
emit_text_node(out, depth, text);
}
} else if has_nested {
emit_inline_group(out, depth, false, &runs);
} else {
emit_inline_runs_body(out, depth, &runs);
}
}
fn emit_field_region(out: &mut Out, depth: i32, items: &[FieldItem]) {
out.push(depth, "<field_region>".to_string());
for item in items {
out.push(depth + 1, "<field_item>".to_string());
if let Some(m) = item.marker.as_ref().filter(|s| !s.is_empty()) {
out.push(depth + 2, format!("<marker>{}</marker>", escape_text(m)));
}
if let Some(k) = item.key.as_ref().filter(|s| !s.is_empty()) {
out.push(depth + 2, format!("<key>{}</key>", escape_text(k)));
}
if let Some(v) = item.value.as_ref().filter(|s| !s.is_empty()) {
out.push(depth + 2, format!("<value>{}</value>", escape_text(v)));
}
out.push(depth + 1, "</field_item>".to_string());
}
out.push(depth, "</field_region>".to_string());
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn located_heading_emits_location_tokens_in_block_form() {
let doclang = export_to_doclang(&[Node::Located {
location: [44, 170, 340, 386],
inner: Box::new(Node::Heading {
level: 1,
text: "X-Library".into(),
}),
}]);
assert!(
doclang.contains(
"<heading>\n <location value=\"44\"/>\n <location value=\"170\"/>\n \
<location value=\"340\"/>\n <location value=\"386\"/>\n X-Library\n </heading>"
),
"got:\n{doclang}"
);
}
fn code(language: Option<&str>, text: &str) -> String {
export_to_doclang(&[Node::Code {
language: language.map(String::from),
text: text.into(),
orig: None,
}])
}
#[test]
fn code_with_language_emits_linguist_label_block_form() {
assert_eq!(
code(Some("python"), "print(\"Hello world!\")"),
"<doclang version=\"0.7\">\n <code>\n <label value=\"Python\"/>\n\
<![CDATA[print(\"Hello world!\")]]> </code>\n</doclang>"
);
assert!(code(Some("bash"), "ls -la").contains("<label value=\"Shell\"/>"));
}
fn plain(text: &str) -> InlineRun {
InlineRun {
text: text.into(),
..Default::default()
}
}
fn bold(text: &str) -> InlineRun {
InlineRun {
text: text.into(),
bold: true,
..Default::default()
}
}
fn ig(unwrapped: bool, runs: Vec<InlineRun>) -> String {
let body = export_to_doclang(&[Node::InlineGroup {
unwrapped,
runs,
md_text: String::new(),
}]);
body.trim_start_matches("<doclang version=\"0.7\">\n")
.trim_end_matches("\n</doclang>")
.to_string()
}
#[test]
fn inline_group_matches_reference_layout() {
assert_eq!(
ig(
false,
vec![plain("This is a"), bold("bold"), plain("example")]
),
" <text>\n This is a\n <bold>bold</bold>\nexample\n </text>"
);
assert_eq!(
ig(
true,
vec![
plain("aa"),
bold("bb"),
plain("cc"),
bold("dd"),
plain("ee")
]
),
"aa\n <bold>bb</bold>\ncc\n <bold>dd</bold>\nee"
);
assert_eq!(
ig(false, vec![plain("aa"), plain("bb")]),
" <text>aa\nbb\n</text>"
);
assert_eq!(ig(false, vec![plain("aa")]), " <text>aa\n</text>");
assert_eq!(
ig(false, vec![bold("bb")]),
" <text>\n <bold>bb</bold>\n </text>"
);
}
#[test]
fn nested_styles_wrap_outermost_last_applied() {
let bi = InlineRun {
text: "bi".into(),
bold: true,
italic: true,
..Default::default()
};
assert_eq!(
ig(true, vec![bi]),
" <italic>\n <bold>bi</bold>\n </italic>"
);
let sub = InlineRun {
text: "2".into(),
script: Script::Sub,
..Default::default()
};
assert_eq!(ig(true, vec![sub]), " <subscript>2</subscript>");
}
#[test]
fn furniture_heading_gets_layer_head() {
let out = export_to_doclang(&[Node::Furniture {
layer: ContentLayer::Furniture,
inner: Box::new(Node::Heading {
level: 1,
text: "Anchor Links Test".into(),
}),
}]);
assert_eq!(
out,
"<doclang version=\"0.7\">\n <heading>\n <layer value=\"furniture\"/>\n Anchor Links Test\n </heading>\n</doclang>"
);
}
#[test]
fn text_dump_reproduces_minidom_per_line_layout() {
let text = "PATN\nWKU 1\nPAL K. \"Determination\"\nfollow-up\n*Note A\n_______________\nNote B*\nEND";
let out = export_to_doclang(&[Node::TextDump(text.into())]);
let expected = "<doclang version=\"0.7\">\n \
<text>\n \
PATN\nWKU 1\n\
<![CDATA[PAL K. \"Determination\"]]> \n\
follow-up\n \
<italic>Note A</italic>\n \
<italic>__________</italic>\n \
<italic>Note B</italic>\n\
END\n \
</text>\n</doclang>";
assert_eq!(out, expected, "got:\n{out}");
}
#[test]
fn code_without_language_stays_inline_and_unlabeled() {
assert_eq!(
code(None, "print(\"Hi!\")"),
"<doclang version=\"0.7\">\n <code><![CDATA[print(\"Hi!\")]]></code>\n</doclang>"
);
assert!(!code(Some("brainfuck"), "+++.").contains("<label"));
}
}