use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::fmt::Write;
use tiptap_rusty_parser::{HtmlOptions, Mark, Node};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum UnknownNodePolicy {
#[default]
Transparent,
Html,
Skip,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct RenderOptions {
pub unknown_node: UnknownNodePolicy,
pub bullet: char,
pub emphasis: char,
}
impl Default for RenderOptions {
fn default() -> Self {
Self {
unknown_node: UnknownNodePolicy::Transparent,
bullet: '-',
emphasis: '*',
}
}
}
pub fn to_markdown(node: &Node) -> String {
to_markdown_with(node, &RenderOptions::default())
}
pub fn to_markdown_with(node: &Node, opts: &RenderOptions) -> String {
let mut r = Renderer::new(opts, approx_size(node));
let mut st = St::default();
match node.node_type.as_deref() {
None | Some("doc") => r.blocks(node.children(), Sep::Blank, &mut st),
_ => r.blocks(std::slice::from_ref(node), Sep::Blank, &mut st),
}
let keep = r.out.trim_end_matches('\n').len();
r.out.truncate(keep);
if !r.out.is_empty() {
r.out.push('\n');
}
r.out
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Sep {
Blank,
Tight,
}
#[derive(Clone, Copy, PartialEq, Eq, Default)]
enum Prev {
#[default]
None,
Bullet,
Ordered,
Other,
}
#[derive(Default)]
struct St {
prev: Prev,
alt: bool,
bullet: char,
task_line: bool,
item: bool,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Mode {
Normal,
Flat,
Cell,
}
struct Renderer<'a> {
opts: &'a RenderOptions,
bullet: char,
emphasis: char,
out: String,
prefix: String,
cs: usize,
bol: usize,
mode: Mode,
active: Vec<&'a Mark>,
code_buf: String,
ws: String,
hb: bool,
hb_marks: &'a [Mark],
ws_after: String,
hb_run: (usize, usize, usize),
amb_at: usize,
opened: (usize, usize, usize, Option<char>),
em_open: char,
bold_open: char,
closed_at: usize,
}
const CODE_RANK: u8 = 9;
const SPACES: &str = " ";
impl<'a> Renderer<'a> {
fn new(opts: &'a RenderOptions, cap: usize) -> Self {
Self {
opts,
bullet: if matches!(opts.bullet, '-' | '*' | '+') {
opts.bullet
} else {
'-'
},
emphasis: if opts.emphasis == '_' { '_' } else { '*' },
out: String::with_capacity(cap),
prefix: String::new(),
cs: 0,
bol: usize::MAX,
mode: Mode::Normal,
active: Vec::new(),
code_buf: String::new(),
ws: String::new(),
hb: false,
hb_marks: &[],
ws_after: String::new(),
hb_run: (0, usize::MAX, 0),
opened: (0, usize::MAX, 0, None),
amb_at: usize::MAX,
em_open: '*',
bold_open: '*',
closed_at: usize::MAX,
}
}
fn newline(&mut self) {
self.out.push('\n');
self.out.push_str(&self.prefix);
self.cs = self.out.len();
}
fn blank_line(&mut self) {
self.out.push('\n');
self.out.push_str(self.prefix.trim_end());
}
fn newline_for(&mut self, next_empty: bool) {
if next_empty {
self.blank_line();
self.cs = self.out.len();
} else {
self.newline();
}
}
fn at_line_start(&self) -> bool {
self.out
.as_bytes()
.get(self.cs..)
.is_some_and(|t| t.iter().all(|&b| b == b' '))
}
fn separate(&mut self, sep: Sep) {
if sep == Sep::Blank {
self.blank_line();
}
self.newline();
}
fn begin(&mut self, sep: Sep, st: &St) {
if st.prev != Prev::None {
self.separate(sep);
} else if st.task_line {
if self.out.ends_with(' ') {
self.out.pop();
}
self.newline();
}
}
fn raw(&mut self, s: &str) {
for (i, line) in s.split('\n').enumerate() {
if i > 0 {
self.newline_for(line.is_empty());
}
self.out.push_str(line);
}
}
fn html(&self, n: &Node) -> String {
n.to_html_with(&HtmlOptions {
unknown_node: tiptap_rusty_parser::UnknownNodePolicy::DataTypeDiv,
..HtmlOptions::default()
})
}
fn blocks(&mut self, nodes: &'a [Node], sep: Sep, st: &mut St) {
let mut i = 0;
while i < nodes.len() {
if is_inline_kind(&nodes[i]) {
let mut j = i + 1;
if nodes[i].node_type.as_deref() != Some("image") {
while j < nodes.len() && is_run_kind(&nodes[j]) {
j += 1;
}
}
self.paragraph(&nodes[i..j], sep, st);
i = j;
} else {
self.block(&nodes[i], sep, st);
i += 1;
}
}
}
fn paragraph(&mut self, nodes: &'a [Node], sep: Sep, st: &mut St) {
if nodes.is_empty() {
return;
}
if nodes.len() > 1 && !(st.item && st.prev == Prev::None) && is_hoistable_images(nodes) {
for n in nodes {
if n.node_type.as_deref() == Some("image") {
self.paragraph(std::slice::from_ref(n), sep, st);
}
}
return;
}
let (len, cs) = (self.out.len(), self.cs);
if st.prev != Prev::None {
self.separate(sep);
}
let at = self.out.len();
self.inline(nodes);
if self.out.len() == at {
self.out.truncate(len);
self.cs = cs;
} else {
st.prev = Prev::Other;
}
}
fn block(&mut self, n: &'a Node, sep: Sep, st: &mut St) {
let ty = match n.node_type.as_deref() {
Some(t) => t,
None => return self.blocks(n.children(), sep, st),
};
match ty {
"paragraph" => {
self.paragraph(n.children(), sep, st);
}
"heading" => {
self.begin(sep, st);
let level = attr_u64(n, "level").unwrap_or(1).clamp(1, 6) as usize;
for _ in 0..level {
self.out.push('#');
}
self.out.push(' ');
let content_at = self.out.len();
self.bol = content_at;
let prev = std::mem::replace(&mut self.mode, Mode::Flat);
self.inline(n.children());
self.mode = prev;
self.bol = usize::MAX;
if self.out.len() == content_at {
self.out.pop();
} else if self.out.ends_with('#') && !self.out.ends_with("\\#") {
let l = self.out.len() - 1;
self.out.insert(l, '\\');
}
st.prev = Prev::Other;
}
"blockquote" => {
self.begin(sep, st);
self.out.push_str("> ");
self.cs = self.out.len();
let at = self.out.len();
let pl = self.prefix.len();
self.prefix.push_str("> ");
let mut inner = St::default();
self.blocks(n.children(), Sep::Blank, &mut inner);
self.prefix.truncate(pl);
if self.out.len() == at {
self.out.pop();
}
st.prev = Prev::Other;
}
"horizontalRule" => {
self.begin(sep, st);
let line = &self.out.as_bytes()[self.out.rfind('\n').map_or(0, |i| i + 1)..];
let rule = if !line.contains(&b'-') {
"---"
} else if !line.contains(&b'*') {
"***"
} else {
"___"
};
self.out.push_str(rule);
st.prev = Prev::Other;
}
"codeBlock" => {
self.begin(sep, st);
self.code_block(n);
st.prev = Prev::Other;
}
"bulletList" | "taskList" => {
if n.children().is_empty() {
return;
}
self.begin(list_sep(n, false, sep), st);
let avoid = if st.prev == Prev::Bullet {
st.bullet
} else {
'\0'
};
st.bullet = self.list(n, false, false, avoid);
st.prev = Prev::Bullet;
}
"orderedList" => {
if n.children().is_empty() {
return;
}
self.begin(list_sep(n, true, sep), st);
let alt = st.prev == Prev::Ordered && !st.alt;
self.list(n, true, alt, '\0');
(st.prev, st.alt) = (Prev::Ordered, alt);
}
"table" => {
if n.children().is_empty() {
return;
}
self.begin(sep, st);
if pipe_table_ok(n) {
self.table(n);
} else {
let h = table_html(n);
self.raw(&h);
}
st.prev = Prev::Other;
}
"doc" | "listItem" | "taskItem" | "tableRow" | "tableCell" | "tableHeader" => {
self.blocks(n.children(), sep, st)
}
_ => match self.opts.unknown_node {
UnknownNodePolicy::Skip => {}
UnknownNodePolicy::Transparent => self.blocks(n.children(), sep, st),
UnknownNodePolicy::Html => {
self.begin(sep, st);
let h = self.html(n);
self.raw(&h);
st.prev = Prev::Other;
}
},
}
}
fn code_block(&mut self, n: &Node) {
let (mut cur, mut max, mut any) = ([0usize; 2], [0usize; 2], false);
walk_text(n, &mut |s| {
any |= !s.is_empty();
for b in s.bytes() {
let k = match b {
b'`' => 0,
b'~' => 1,
_ => {
cur = [0; 2];
continue;
}
};
cur[k] += 1;
cur[1 - k] = 0;
max[k] = max[k].max(cur[k]);
}
});
let lang = match attr(n, "language") {
Some(Value::String(l)) => l.split_whitespace().next().unwrap_or(""),
_ => "",
};
let (fc, run) = if lang.contains('`') {
('~', max[1])
} else {
('`', max[0])
};
let fence = (run + 1).max(3);
for _ in 0..fence {
self.out.push(fc);
}
let lb = lang.as_bytes();
for (i, c) in lang.char_indices() {
match c {
'\n' | '\r' => {}
'\\' => self.out.push_str("\\\\"),
'&' if entity_like(&lb[i + 1..]) => self.out.push_str("\\&"),
_ => self.out.push(c),
}
}
self.newline();
walk_text(n, &mut |s| {
for (i, seg) in s.split('\n').enumerate() {
if i > 0 {
self.newline_for(seg.is_empty());
}
self.out.push_str(seg);
}
});
if any {
self.newline();
}
for _ in 0..fence {
self.out.push(fc);
}
}
fn list(&mut self, n: &'a Node, ordered: bool, alt: bool, avoid: char) -> char {
let items = n.children();
let sep = if is_tight(items) {
Sep::Tight
} else {
Sep::Blank
};
let mut num = attr_u64(n, "start").unwrap_or(1);
let other = if self.bullet == '-' { '*' } else { '-' };
let bullet = if ordered {
self.bullet
} else {
let line = &self.out.as_bytes()[self.out.rfind('\n').map_or(0, |i| i + 1)..];
let count = |c: char| line.iter().filter(|&&b| b == c as u8).count();
[self.bullet, other, '-', '*', '+']
.into_iter()
.find(|&c| c != avoid && count(c) < 2)
.unwrap_or(other)
};
let delim = if alt { ')' } else { '.' };
for (i, item) in items.iter().enumerate() {
if i > 0 {
self.separate(sep);
}
let kids: &[Node] = if is_item(item) {
item.children()
} else {
std::slice::from_ref(item)
};
let before = self.out.len();
if ordered {
let _ = write!(self.out, "{num}{delim} ");
num = num.wrapping_add(1);
} else {
self.out.push(bullet);
self.out.push(' ');
}
let width = self.out.len() - before;
let marker_end = self.out.len();
if item.node_type.as_deref() == Some("taskItem") {
let checked = attr(item, "checked").and_then(Value::as_bool) == Some(true);
self.out.push_str(if checked { "[x] " } else { "[ ] " });
}
self.cs = self.out.len();
let pl = self.prefix.len();
for _ in 0..width / SPACES.len() {
self.prefix.push_str(SPACES);
}
self.prefix.push_str(&SPACES[..width % SPACES.len()]);
let mut inner = St {
task_line: item.node_type.as_deref() == Some("taskItem"),
item: true,
..St::default()
};
self.blocks(kids, sep, &mut inner);
self.prefix.truncate(pl);
if self.out.len() == marker_end {
self.out.pop(); }
}
bullet
}
fn table(&mut self, n: &'a Node) {
let rows = n.children();
let cols = rows.iter().map(|r| r.children().len()).max().unwrap_or(0);
for (ri, row) in rows.iter().enumerate() {
if ri > 0 {
self.newline();
}
self.out.push('|');
let cells = row.children();
for ci in 0..cols {
self.out.push(' ');
if let Some(cell) = cells.get(ci) {
self.bol = self.out.len();
let prev = std::mem::replace(&mut self.mode, Mode::Cell);
if let Some(p) = cell.children().first() {
self.inline(p.children());
}
self.mode = prev;
self.bol = usize::MAX;
}
self.out.push_str(" |");
}
if ri == 0 {
self.newline();
self.out.push('|');
for _ in 0..cols {
self.out.push_str(" --- |");
}
}
}
}
fn inline(&mut self, nodes: &'a [Node]) {
let start = self.out.len();
self.active.clear();
self.ws.clear();
self.ws_after.clear();
self.hb = false;
self.items(nodes);
self.close_not_in(&[], true);
let ascii_ws = [' ', '\t', '\n', '\r'];
let keep = self.ws_after.trim_end_matches(ascii_ws).len();
if self.hb && keep > 0 {
self.ws_after.truncate(keep);
self.flush_pending();
} else {
let keep = self.ws.trim_end_matches(ascii_ws).len();
if keep > 0 {
self.ws.truncate(keep);
self.flush_ws();
}
}
let mut dropped = self.take_hb_run().is_some();
let tail = self.prefix.len() + 2;
while self.out.len() >= start + tail
&& self.out.ends_with(self.prefix.as_str())
&& self.out.as_bytes()[..self.out.len() - self.prefix.len()].ends_with(b"\\\n")
&& ends_unescaped(&self.out[..self.out.len() - tail + 1], b'\\')
{
self.out.truncate(self.out.len() - tail);
dropped = true;
}
if dropped {
let keep = self.out.trim_end_matches([' ', '\t']).len().max(start);
self.out.truncate(keep);
}
self.ws.clear();
self.ws_after.clear();
self.hb = false;
}
fn items(&mut self, nodes: &'a [Node]) {
let mut i = 0;
while i < nodes.len() {
let n = &nodes[i];
i += 1;
match n.node_type.as_deref() {
Some("text") => {
let marks = n.marks.as_deref().unwrap_or(&[]);
let flat = self.mode == Mode::Flat;
let same = |m: &Node| {
matches!(m.node_type.as_deref(), Some("text"))
|| (flat && m.node_type.as_deref() == Some("hardBreak"))
} && m.marks.as_deref().unwrap_or(&[]) == marks;
let j = i + nodes[i..].iter().take_while(|m| same(m)).count();
if j == i {
self.text_item(n.text.as_deref().unwrap_or(""), marks);
} else {
let mut s = String::from(n.text.as_deref().unwrap_or(""));
for m in &nodes[i..j] {
s.push_str(m.text.as_deref().unwrap_or(" "));
}
self.text_item(&s, marks);
i = j;
}
}
Some("hardBreak") => self.hard_break(n),
Some("image") => self.image(n),
None => self.items(n.children()),
Some(ty) if is_known_container(ty) => self.items(n.children()),
Some(_) => match self.opts.unknown_node {
UnknownNodePolicy::Skip => {}
UnknownNodePolicy::Transparent => self.items(n.children()),
UnknownNodePolicy::Html => {
self.close_not_in(&[], false);
self.flush_pending();
let h = self.html(n);
let prev = self.mode;
if prev == Mode::Normal {
self.raw(&h);
} else {
self.out.push_str(&h.replace('\n', " "));
}
}
},
}
}
}
fn text_item(&mut self, text: &str, marks: &'a [Mark]) {
if text.is_empty() {
return;
}
if marks.iter().any(|m| m.mark_type == "code") {
self.close_not_in(marks, true);
if self.active.last().is_some_and(|a| a.mark_type == "code")
&& marks.iter().any(|m| {
m.mark_type != "code"
&& mark_rank(m).is_some()
&& !self.active.iter().any(|a| a.mark_type == m.mark_type)
})
{
if let Some(c) = self.active.pop() {
self.close_mark(c);
}
}
self.open_after_pending(marks, true, "", '`');
self.code_buf.extend(
text.chars()
.map(|c| if matches!(c, '\n' | '\r') { ' ' } else { c }),
);
return;
}
let b = text.as_bytes();
let plain = |x: u8| x.is_ascii_graphic();
let (lead, body, core) = if plain(b[0]) && plain(b[b.len() - 1]) {
("", text, text) } else {
let body = text.trim_start_matches(is_ws);
let lead = &text[..text.len() - body.len()];
(lead, body, body.trim_end_matches(is_ws))
};
self.close_not_in(marks, true);
if core.is_empty() {
let link = marks
.iter()
.find(|m| m.mark_type == "link" && mark_rank(m).is_some());
if let Some(link) = link {
if !self.active.iter().any(|a| a.mark_type == "link") {
self.open_after_pending(std::slice::from_ref(link), true, "", '[');
self.escape_text(text);
return;
}
}
if self.hb {
self.ws_after.push_str(text);
} else {
self.ws.push_str(text);
}
return;
}
self.open_after_pending(marks, true, lead, core.chars().next().unwrap_or(' '));
self.escape_text(core);
self.ws.push_str(&body[core.len()..]);
}
fn hard_break(&mut self, n: &'a Node) {
let marks = n.marks.as_deref().unwrap_or(&[]);
self.close_not_in(marks, false);
match self.mode {
Mode::Normal => {
if self.hb {
if self.hb_marks == marks {
self.open_after_pending(marks, false, "", '\\');
} else {
self.flush_pending();
}
}
let link = marks
.iter()
.find(|m| m.mark_type == "link" && mark_rank(m).is_some());
if let Some(link) = link {
if !self.active.iter().any(|a| a.mark_type == "link") {
self.open_after_pending(std::slice::from_ref(link), false, "", '[');
}
}
self.hb = true;
self.hb_marks = marks;
}
Mode::Flat if self.out.len() == self.bol && self.ws.is_empty() => {}
Mode::Flat => self.ws.push(' '),
Mode::Cell => {
self.flush_pending();
self.out.push_str("<br>");
}
}
}
fn image(&mut self, n: &'a Node) {
let marks = n.marks.as_deref().unwrap_or(&[]);
self.close_not_in(marks, false);
self.open_after_pending(marks, false, "", '!');
self.out.push_str(";
write_dest(
&mut self.out,
attr(n, "src").and_then(Value::as_str).unwrap_or(""),
);
if let Some(t) = attr(n, "title").and_then(Value::as_str) {
write_title(&mut self.out, t);
}
self.out.push(')');
}
fn open_after_pending(&mut self, marks: &'a [Mark], text: bool, lead: &str, next: char) {
if marks.is_empty() && !self.hb && self.ws.is_empty() && lead.is_empty() {
return;
}
self.flush_ws();
let from = self.collect_opens(marks, text);
let mut mid = from;
if self.hb {
let hbm = self.hb_marks;
let carried = |m: &Mark| m.mark_type != "code" && hbm.contains(m);
let new = &mut self.active[from..];
if new.iter().any(|m| carried(m)) {
new.sort_by_key(|m| !carried(m));
mid += new.iter().take_while(|m| carried(m)).count();
self.write_opens(from, mid, '\\');
}
}
self.flush_hb();
if !lead.is_empty() {
if self.active.get(mid).is_some_and(|m| m.mark_type == "link") {
self.write_opens(mid, mid + 1, ' ');
mid += 1;
}
self.escape_text(lead);
}
self.write_opens(mid, self.active.len(), next);
}
fn flush_pending(&mut self) {
let start = self.out.len();
self.flush_ws();
self.flush_hb();
if self.hb_run.1 == self.out.len() && self.hb_run.0 > start {
self.hb_run.0 = start;
}
}
fn flush_ws(&mut self) {
if !self.ws.is_empty() {
let w = std::mem::take(&mut self.ws);
self.escape_text(&w);
self.ws = w;
self.ws.clear();
}
}
fn flush_hb(&mut self) {
self.emit_hb(false);
}
fn emit_hb(&mut self, spaces: bool) {
if self.hb {
self.hb = false;
if self.out.len() != self.hb_run.1 {
self.hb_run.0 = self.out.len();
self.hb_run.2 = self.cs;
}
self.out.push_str(if spaces { " " } else { "\\" });
self.newline();
self.hb_run.1 = self.out.len();
if !self.ws_after.is_empty() {
let w = std::mem::take(&mut self.ws_after);
self.escape_text(&w);
if !w.contains(|c: char| !matches!(c, ' ' | '\t' | '\n' | '\r')) {
self.hb_run.1 = self.out.len();
}
self.ws_after = w;
self.ws_after.clear();
}
}
}
fn take_hb_run(&mut self) -> Option<String> {
if self.out.len() != self.hb_run.1 {
return None;
}
let tail = self.out.split_off(self.hb_run.0);
self.cs = self.hb_run.2;
self.hb_run.1 = usize::MAX;
Some(tail)
}
fn close_not_in(&mut self, marks: &[Mark], text: bool) {
let mut i = 0;
while i < self.active.len() {
let a = self.active[i];
if (text || a.mark_type != "code") && marks.iter().any(|m| m == a) {
i += 1;
} else {
break;
}
}
if i == self.active.len() {
return;
}
let delim = self
.active
.last()
.is_some_and(|m| matches!(m.mark_type.as_str(), "bold" | "italic" | "strike"));
let breaks = if self.active.len() > i && delim {
self.take_hb_run()
} else {
None
};
let (os, oe, of, restore) = self.opened;
if breaks.is_some() && self.out.len() == oe && of >= i && of < self.active.len() {
self.out.truncate(os);
if let Some(p) = restore {
self.out.push(p);
}
self.active.truncate(of);
}
while self.active.len() > i {
if let Some(m) = self.active.pop() {
if m.mark_type == "link" {
if self.ws.contains(['\n', '\r']) {
self.ws = self.ws.replace(['\n', '\r'], " ");
}
self.flush_ws();
if self.hb && self.hb_marks.contains(m) {
let spaces = self.out.len() == self.closed_at;
self.emit_hb(spaces);
}
}
self.close_mark(m);
}
}
if let Some(b) = breaks {
self.hb_run.0 = self.out.len();
self.out.push_str(&b);
self.cs = self.out.len();
self.hb_run.1 = self.out.len();
}
}
fn collect_opens(&mut self, marks: &'a [Mark], text: bool) -> usize {
let from = self.active.len();
for m in marks {
let Some(rank) = mark_rank(m) else { continue };
if rank == CODE_RANK && !text {
continue;
}
if self.active.iter().any(|a| a.mark_type == m.mark_type) {
continue;
}
self.active.push(m);
}
if self.active.len() - from > 1 {
self.active[from..].sort_by_key(|m| mark_rank(m).unwrap_or(0));
}
from
}
fn write_opens(&mut self, from: usize, to: usize, next: char) {
if from >= to {
return;
}
let start = self.out.len();
for i in from..to {
let m = self.active[i];
self.open_mark(m);
}
self.opened = (start, self.out.len(), from, None);
let b = &self.out.as_bytes()[start..];
let Some(&c) = b.first().filter(|c| matches!(c, b'*' | b'_' | b'~')) else {
return;
};
let run = b.iter().take_while(|&&x| x == c).count();
let n = b.get(run).map_or(next, |&x| x as char);
if let Some(p) = self.out[..start].chars().next_back() {
if maybe_word(p) && (maybe_punct(n) || c == b'_') {
let at = start - p.len_utf8();
let undo = self.encode_before(at, p);
self.closed_at = usize::MAX;
let end = if undo.is_some() {
self.out.len()
} else {
usize::MAX
};
self.opened = (at, end, from, undo);
}
}
}
fn encode_before(&mut self, mut at: usize, mut p: char) -> Option<char> {
let mut undo = Some(p);
loop {
let mut e = String::with_capacity(12);
let _ = write!(e, "&#{};", p as u32);
self.out.replace_range(at..at + p.len_utf8(), &e);
let b = self.out.as_bytes();
let Some(&d) = b[..at].last().filter(|d| matches!(d, b'*' | b'_' | b'~')) else {
return undo;
};
if b[..at - 1]
.iter()
.rev()
.take_while(|&&x| x == b'\\')
.count()
% 2
== 1
{
return undo; }
if d == b'_' && at >= 2 && b[at - 2].is_ascii_alphanumeric() {
self.out.insert(at - 1, '\\');
return None;
}
let run = b[..at].iter().rev().take_while(|&&x| x == d).count();
match self.out[..at - run].chars().next_back() {
Some(q) if maybe_word(q) => {
at = at - run - q.len_utf8();
p = q;
undo = None;
}
_ => return undo,
}
}
}
fn open_mark(&mut self, m: &Mark) {
match m.mark_type.as_str() {
"link" => {
if ends_unescaped(&self.out, b'!') {
let l = self.out.len() - 1;
self.out.insert(l, '\\');
}
self.out.push('[');
}
"bold" => {
let c = if self.out.len() == self.closed_at && self.out.ends_with('*') {
'_'
} else {
'*'
};
self.bold_open = c;
self.out.push(c);
self.out.push(c);
}
"italic" => {
let mut c = self.emphasis;
if self.out.len() == self.closed_at && self.out.ends_with(c) {
c = if c == '*' { '_' } else { '*' };
}
self.em_open = c;
self.out.push(c);
}
"strike" => self.out.push_str("~~"),
"underline" => self.out.push_str("<u>"),
"subscript" => self.out.push_str("<sub>"),
"superscript" => self.out.push_str("<sup>"),
"code" => self.code_buf.clear(),
_ => {}
}
}
fn close_mark(&mut self, m: &Mark) {
match m.mark_type.as_str() {
"link" => {
self.out.push_str("](");
write_dest(&mut self.out, mark_attr(m, "href").unwrap_or(""));
if let Some(t) = mark_attr(m, "title") {
write_title(&mut self.out, t);
}
self.out.push(')');
}
"bold" => {
self.out.push(self.bold_open);
self.out.push(self.bold_open);
self.closed_at = self.out.len();
}
"italic" => {
self.out.push(self.em_open);
self.closed_at = self.out.len();
}
"strike" => {
self.out.push_str("~~");
self.closed_at = self.out.len();
}
"underline" => self.out.push_str("</u>"),
"subscript" => self.out.push_str("</sub>"),
"superscript" => self.out.push_str("</sup>"),
"code" => self.flush_code(),
_ => {}
}
}
fn flush_code(&mut self) {
let s = std::mem::take(&mut self.code_buf);
let (mut run, mut seen) = (0u32, 0u64);
for b in s.bytes().chain(std::iter::once(b' ')) {
if b == b'`' {
run += 1;
} else {
if run > 0 && run < 64 {
seen |= 1 << run;
}
run = 0;
}
}
let n = (1..64).find(|k| seen & (1 << k) == 0).unwrap_or(1);
let pad = s.starts_with('`')
|| s.ends_with('`')
|| (s.starts_with(' ') && s.ends_with(' ') && s.bytes().any(|b| b != b' '));
for _ in 0..n {
self.out.push('`');
}
if pad {
self.out.push(' ');
}
if self.mode == Mode::Cell && s.contains('|') {
self.out.push_str(&s.replace('|', "\\|"));
} else {
self.out.push_str(&s);
}
if pad {
self.out.push(' ');
}
for _ in 0..n {
self.out.push('`');
}
self.code_buf = s;
self.code_buf.clear();
}
fn escape_text(&mut self, s: &str) {
let b = s.as_bytes();
let mut ls = self.at_line_start();
let mut lead = ls || self.out.len() == self.bol;
let (mut last, mut i) = (0, 0);
let mut enc_end = 0;
if self.amb_at != usize::MAX && self.amb_at + 1 == self.out.len() {
let bad = match self.out.as_bytes()[self.amb_at] {
b'<' => b.first().is_some_and(|&x| starts_tag(x)),
b'&' => entity_like(b),
_ => false,
};
if bad {
self.out.insert(self.amb_at, '\\');
}
}
self.amb_at = usize::MAX;
if self.out.len() == self.closed_at {
if let Some(n) = s.chars().next().filter(|&n| maybe_word(n)) {
let ob = self.out.as_bytes();
let c = ob[ob.len() - 1];
let run = ob.iter().rev().take_while(|&&x| x == c).count();
let p = self.out[..ob.len() - run].chars().next_back();
if c == b'_' || p.is_some_and(maybe_punct) {
let _ = write!(self.out, "&#{};", n as u32);
(last, i, lead) = (n.len_utf8(), n.len_utf8(), false);
enc_end = i;
}
}
}
while i < b.len() {
if !ls && !lead {
match b[i..].iter().position(|&x| MID_LINE[x as usize]) {
Some(k) => i += k,
None => break,
}
}
let c = b[i];
if lead && self.mode != Mode::Normal && (c == b'\n' || c == b'\r') {
self.out.push_str(&s[last..i]);
last = i + 1;
i += 1;
continue;
}
if lead && (c == b' ' || c == b'\t') {
self.out.push_str(&s[last..i]);
self.out.push_str(if c == b' ' { " " } else { "	" });
(last, ls, lead) = (i + 1, false, false);
i += 1;
continue;
}
lead = false;
let esc = match c {
b'\\' | b'*' | b'`' | b'[' | b']' | b'~' => true,
b'_' => {
let w = |x: Option<&u8>| x.is_some_and(|x| x.is_ascii_alphanumeric());
!(i > 0 && i != enc_end && w(b.get(i - 1)) && w(b.get(i + 1)))
}
b'<' => b.get(i + 1).is_some_and(|&x| starts_tag(x)),
b'&' => entity_like(&b[i + 1..]),
b'|' => self.mode == Mode::Cell,
b'#' if ls => {
let mut j = i;
while j < b.len() && b[j] == b'#' {
j += 1;
}
j - i <= 6
&& b.get(j)
.is_none_or(|x| matches!(x, b' ' | b'\t' | b'\n' | b'\r'))
}
b'>' if ls => true,
b'-' | b'+' if ls => b.get(i + 1).is_none_or(|x| {
matches!(x, b' ' | b'\t' | b'\n' | b'\r') || (c == b'-' && *x == b'-')
}),
b'=' if ls => b
.get(i + 1)
.is_none_or(|x| matches!(x, b' ' | b'\t' | b'=' | b'\n' | b'\r')),
b'0'..=b'9' if ls => {
let mut j = i;
while j < b.len() && b[j].is_ascii_digit() {
j += 1;
}
if j - i <= 9
&& matches!(b.get(j), Some(b'.' | b')'))
&& b.get(j + 1)
.is_none_or(|x| matches!(x, b' ' | b'\t' | b'\n' | b'\r'))
{
self.out.push_str(&s[last..j]);
self.out.push('\\');
last = j;
}
ls = false;
i = j;
continue;
}
b'\r' if b.get(i + 1) == Some(&b'\n') => {
self.out.push_str(&s[last..i]);
last = i + 1;
i += 1;
continue;
}
b'\n' | b'\r' => {
self.out.push_str(&s[last..i]);
last = i + 1;
if self.mode == Mode::Normal {
if self.out.len() != self.hb_run.1 {
self.hb_run.0 = self.out.len();
self.hb_run.2 = self.cs;
}
self.out.push('\\');
self.newline();
self.hb_run.1 = self.out.len();
(ls, lead) = (true, true);
} else {
self.out.push(' ');
}
i += 1;
continue;
}
_ => false,
};
if esc {
self.out.push_str(&s[last..i]);
self.out.push('\\');
last = i;
}
if c != b' ' && c != b'\t' {
ls = false;
}
i += 1;
}
self.out.push_str(&s[last..]);
if matches!(b.last(), Some(b'<' | b'&')) && last < b.len() {
self.amb_at = self.out.len() - 1;
}
}
}
fn ends_unescaped(out: &str, c: u8) -> bool {
let b = out.as_bytes();
let Some((&last, rest)) = b.split_last() else {
return false;
};
last == c && rest.iter().rev().take_while(|&&x| x == b'\\').count() % 2 == 0
}
fn maybe_word(c: char) -> bool {
!c.is_whitespace() && !c.is_ascii_punctuation()
}
fn maybe_punct(c: char) -> bool {
!c.is_whitespace() && !c.is_alphanumeric()
}
fn is_ws(c: char) -> bool {
c.is_whitespace()
}
fn is_hoistable_images(nodes: &[Node]) -> bool {
let mut any = false;
for c in nodes {
match c.node_type.as_deref() {
Some("image")
if c.marks.as_ref().is_none_or(|m| {
m.iter()
.all(|m| m.mark_type == "code" || mark_rank(m).is_none())
}) =>
{
any = true
}
Some("hardBreak") => {}
Some("text") if c.text.as_deref().is_none_or(|t| t.trim().is_empty()) => {}
_ => return false,
}
}
any
}
fn is_inline_kind(n: &Node) -> bool {
matches!(n.node_type.as_deref(), Some("text" | "hardBreak" | "image"))
}
fn is_run_kind(n: &Node) -> bool {
matches!(n.node_type.as_deref(), Some("text" | "hardBreak"))
}
fn table_html(n: &Node) -> String {
let mut o = HtmlOptions {
unknown_node: tiptap_rusty_parser::UnknownNodePolicy::DataTypeDiv,
spread_attrs: true,
..HtmlOptions::default()
};
for (k, v) in [
("table", "table"),
("tableRow", "tr"),
("tableCell", "td"),
("tableHeader", "th"),
] {
o.node_tags.insert(k.into(), v.into());
}
n.to_html_with(&o)
}
fn is_item(n: &Node) -> bool {
matches!(n.node_type.as_deref(), Some("listItem" | "taskItem"))
}
fn is_list(n: &Node) -> bool {
matches!(
n.node_type.as_deref(),
Some("bulletList" | "orderedList" | "taskList")
)
}
fn is_known_container(ty: &str) -> bool {
matches!(
ty,
"doc"
| "paragraph"
| "heading"
| "blockquote"
| "bulletList"
| "orderedList"
| "taskList"
| "listItem"
| "taskItem"
| "codeBlock"
| "table"
| "tableRow"
| "tableCell"
| "tableHeader"
)
}
fn is_tight(items: &[Node]) -> bool {
items.iter().all(|item| {
let kids: &[Node] = if is_item(item) {
item.children()
} else {
return false;
};
kids.iter()
.filter(|k| !renders_empty(k))
.enumerate()
.all(|(i, k)| is_list(k) || (i == 0 && k.node_type.as_deref() == Some("paragraph")))
})
}
fn renders_empty(p: &Node) -> bool {
p.node_type.as_deref() == Some("paragraph")
&& p.children().iter().all(|c| {
let text = c.node_type.as_deref() == Some("text");
let kept = c.marks.as_deref().is_some_and(|m| {
m.iter().any(|m| {
(text && m.mark_type == "code")
|| (m.mark_type == "link" && mark_rank(m).is_some())
})
});
!kept
&& match c.node_type.as_deref() {
Some("hardBreak") => true,
Some("text") => c
.text
.as_deref()
.is_none_or(|t| t.trim_matches([' ', '\t', '\n', '\r']).is_empty()),
_ => false,
}
})
}
fn list_sep(list: &Node, ordered: bool, sep: Sep) -> Sep {
if sep == Sep::Blank {
return sep;
}
let empty_first = list.children().first().is_some_and(|it| {
it.node_type.as_deref() == Some("listItem") && it.children().iter().all(renders_empty)
});
if empty_first || (ordered && attr_u64(list, "start").unwrap_or(1) != 1) {
Sep::Blank
} else {
sep
}
}
fn pipe_table_ok(table: &Node) -> bool {
let span_ok = |c: &Node| {
["colspan", "rowspan"]
.iter()
.all(|k| attr_u64(c, k).is_none_or(|v| v <= 1))
};
table.children().iter().all(|row| {
row.node_type.as_deref() == Some("tableRow")
&& !row.children().is_empty()
&& row.children().iter().all(|cell| {
matches!(cell.node_type.as_deref(), Some("tableCell" | "tableHeader"))
&& span_ok(cell)
&& match cell.children() {
[] => true,
[p] => {
p.node_type.as_deref() == Some("paragraph")
&& p.children().iter().all(is_inline_kind)
}
_ => false,
}
})
})
}
fn attr_u64(n: &Node, key: &str) -> Option<u64> {
match attr(n, key)? {
Value::Number(x) => x.as_u64().or_else(|| x.as_f64().map(|f| f.max(0.0) as u64)),
Value::String(s) => s.trim().parse().ok(),
_ => None,
}
}
fn attr<'n>(n: &'n Node, key: &str) -> Option<&'n Value> {
n.attrs
.as_ref()?
.iter()
.find_map(|(k, v)| (k == key).then_some(v))
}
fn mark_attr<'m>(m: &'m Mark, key: &str) -> Option<&'m str> {
m.attrs
.as_ref()?
.iter()
.find_map(|(k, v)| (k == key).then_some(v))?
.as_str()
.filter(|s| !s.is_empty())
}
fn mark_rank(m: &Mark) -> Option<u8> {
Some(match m.mark_type.as_str() {
"link" => {
m.attrs
.as_ref()?
.iter()
.find_map(|(k, v)| (k == "href").then_some(v))?
.as_str()?;
0
}
"underline" => 1,
"subscript" | "superscript" => 2,
"strike" => 3,
"bold" => 4,
"italic" => 5,
"code" => CODE_RANK,
_ => return None,
})
}
fn walk_text(n: &Node, f: &mut impl FnMut(&str)) {
if let Some(t) = &n.text {
f(t);
}
for c in n.children() {
walk_text(c, f);
}
}
fn approx_size(n: &Node) -> usize {
fn go(n: &Node) -> usize {
8 + n.text.as_ref().map_or(0, String::len) + n.children().iter().map(go).sum::<usize>()
}
let g = go(n);
g + g / 8 + 16
}
const MID_LINE: [bool; 256] = {
let mut t = [false; 256];
let s = b"\\*`[]~_<&|\n\r";
let mut i = 0;
while i < s.len() {
t[s[i] as usize] = true;
i += 1;
}
t
};
fn starts_tag(x: u8) -> bool {
x.is_ascii_alphabetic() || matches!(x, b'/' | b'!' | b'?')
}
fn entity_like(rest: &[u8]) -> bool {
for (n, &x) in rest.iter().take(33).enumerate() {
if x == b';' {
return n > 0;
}
if !(x.is_ascii_alphanumeric() || (n == 0 && x == b'#')) {
return false;
}
}
false
}
fn write_dest(out: &mut String, href: &str) {
let mut depth = 0i32;
let mut balanced = true;
for b in href.bytes() {
match b {
b'(' => depth += 1,
b')' => {
depth -= 1;
balanced &= depth >= 0;
}
_ => {}
}
}
let angle = href.is_empty()
|| href.starts_with('<')
|| depth != 0
|| !balanced
|| href.bytes().any(|b| b <= b' ' || b == 0x7f);
if angle {
out.push('<');
for c in href.chars() {
match c {
'<' | '>' | '\\' => {
out.push('\\');
out.push(c);
}
'\n' => out.push_str("%0A"),
'\r' => out.push_str("%0D"),
_ => out.push(c),
}
}
out.push('>');
} else {
for c in href.chars() {
if c == '\\' {
out.push('\\');
}
out.push(c);
}
}
}
fn write_title(out: &mut String, t: &str) {
if t.is_empty() {
return;
}
out.push_str(" \"");
for c in t.chars() {
match c {
'"' | '\\' => {
out.push('\\');
out.push(c);
}
'\n' | '\r' => out.push(' '),
_ => out.push(c),
}
}
out.push('"');
}