Skip to main content

tiptap_rusty_markdown/
to_md.rs

1//! JSONContent -> Markdown (`to_markdown`).
2//!
3//! Tiptap StarterKit names (`paragraph`, `heading`, `bulletList`, ...) map to
4//! CommonMark + GFM. Schema-agnostic like the rest of the workspace: unknown
5//! nodes follow [`UnknownNodePolicy`], unknown marks and unknown attrs are
6//! ignored, malformed input never panics.
7//!
8//! Design notes:
9//! - One output `String`; line prefixes (`> `, list indentation) live on a
10//!   prefix stack and are applied when a newline is emitted. No per-line
11//!   buffers, no post-processing passes, no node clones.
12//! - Marks are opened/closed across a whole inline run (so adjacent text nodes
13//!   sharing a mark yield one `**...**`), and whitespace at mark boundaries is
14//!   moved outside the delimiters.
15//! - Text is escaped so the output re-parses to the same text; line-start
16//!   escapes (`#`, `-`, `1.`, ...) are only applied where they matter.
17//!
18//! ```
19//! use tiptap_rusty_markdown::{to_markdown, Document};
20//! let doc = Document::from_json_str(
21//!     r#"{"type":"doc","content":[
22//!         {"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"Hi"}]},
23//!         {"type":"paragraph","content":[
24//!             {"type":"text","text":"a ","marks":[]},
25//!             {"type":"text","text":"b","marks":[{"type":"bold"}]},
26//!             {"type":"text","text":"c","marks":[{"type":"bold"}]}]}]}"#,
27//! ).unwrap();
28//! assert_eq!(to_markdown(&doc), "## Hi\n\na **bc**\n");
29//! ```
30
31use serde::{Deserialize, Serialize};
32use serde_json::Value;
33use std::fmt::Write;
34use tiptap_rusty_parser::{HtmlOptions, Mark, Node};
35
36/// How to render a node with no Markdown equivalent.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
38#[serde(rename_all = "camelCase")]
39pub enum UnknownNodePolicy {
40    /// Render its children only.
41    #[default]
42    Transparent,
43    /// Emit the node as HTML via the parser's `to_html`.
44    Html,
45    /// Drop it and its children.
46    Skip,
47}
48
49/// Export options.
50#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
51#[serde(rename_all = "camelCase", default)]
52pub struct RenderOptions {
53    /// How to render unknown node types.
54    pub unknown_node: UnknownNodePolicy,
55    /// Bullet list marker: `'-'` (default), `'*'` or `'+'`. Anything else
56    /// falls back to `'-'`. Adjacent bullet lists alternate with another
57    /// marker so they do not merge on re-parse.
58    pub bullet: char,
59    /// Italic delimiter: `'*'` (default) or `'_'`. Anything else falls back
60    /// to `'*'`.
61    pub emphasis: char,
62}
63
64impl Default for RenderOptions {
65    fn default() -> Self {
66        Self {
67            unknown_node: UnknownNodePolicy::Transparent,
68            bullet: '-',
69            emphasis: '*',
70        }
71    }
72}
73
74/// Render `node` to Markdown with default options.
75///
76/// ```
77/// use tiptap_rusty_markdown::{to_markdown, Document};
78/// let doc = Document::from_json_str(
79///     r#"{"type":"doc","content":[{"type":"bulletList","content":[
80///         {"type":"listItem","content":[{"type":"paragraph","content":[
81///             {"type":"text","text":"1. not a list"}]}]}]}]}"#,
82/// ).unwrap();
83/// assert_eq!(to_markdown(&doc), "- 1\\. not a list\n");
84/// ```
85pub fn to_markdown(node: &Node) -> String {
86    to_markdown_with(node, &RenderOptions::default())
87}
88
89/// Render `node` to Markdown with `opts`.
90///
91/// The output is empty for an empty document, otherwise it ends with exactly
92/// one `"\n"`.
93pub fn to_markdown_with(node: &Node, opts: &RenderOptions) -> String {
94    let mut r = Renderer::new(opts, approx_size(node));
95    let mut st = St::default();
96    match node.node_type.as_deref() {
97        None | Some("doc") => r.blocks(node.children(), Sep::Blank, &mut st),
98        _ => r.blocks(std::slice::from_ref(node), Sep::Blank, &mut st),
99    }
100    let keep = r.out.trim_end_matches('\n').len();
101    r.out.truncate(keep);
102    if !r.out.is_empty() {
103        r.out.push('\n');
104    }
105    r.out
106}
107
108// ---- state --------------------------------------------------------------
109
110/// Block separator inside a container.
111#[derive(Clone, Copy, PartialEq, Eq)]
112enum Sep {
113    /// Blank line between blocks.
114    Blank,
115    /// Single newline (tight list items).
116    Tight,
117}
118
119/// Kind of the previous sibling block (for separators and list alternation).
120#[derive(Clone, Copy, PartialEq, Eq, Default)]
121enum Prev {
122    #[default]
123    None,
124    Bullet,
125    Ordered,
126    Other,
127}
128
129#[derive(Default)]
130struct St {
131    prev: Prev,
132    /// Whether the previous sibling ordered list used the alternate delimiter.
133    alt: bool,
134    /// Marker of the previous sibling bullet list.
135    bullet: char,
136    /// Task item: a first block other than a paragraph must start on the
137    /// next line (`- [x] - a` would be text).
138    task_line: bool,
139    /// Inside a list item (its first paragraph keeps lone images inline).
140    item: bool,
141}
142
143#[derive(Clone, Copy, PartialEq, Eq)]
144enum Mode {
145    /// Normal paragraph text.
146    Normal,
147    /// Single-line context (heading, image alt): newlines become spaces.
148    Flat,
149    /// Table cell: single line, `|` escaped, hard break is `<br>`.
150    Cell,
151}
152
153struct Renderer<'a> {
154    opts: &'a RenderOptions,
155    bullet: char,
156    emphasis: char,
157    out: String,
158    /// Continuation prefix applied after every emitted newline.
159    prefix: String,
160    /// Byte offset where the current line's content starts (after prefix/marker).
161    cs: usize,
162    /// Content start of the current heading / table cell (leading whitespace
163    /// there is stripped on re-parse, like at a line start).
164    bol: usize,
165    mode: Mode,
166    /// Marks currently open in the inline run, outermost first.
167    active: Vec<&'a Mark>,
168    /// Pending content of an open `code` mark.
169    code_buf: String,
170    /// Whitespace held back until we know whether marks close first.
171    ws: String,
172    /// Hard break held back (dropped at the end of a run).
173    hb: bool,
174    /// Marks of the held-back hard break.
175    hb_marks: &'a [Mark],
176    /// Whitespace-only text held back after the held-back hard break.
177    ws_after: String,
178    /// Span (start, end, cs before) of the last run of emitted hard breaks;
179    /// `out.len() == end` means nothing followed it yet.
180    hb_run: (usize, usize, usize),
181    /// Offset of an unescaped `<` / `&` that ended the last escaped piece:
182    /// escaped later if the next piece, written right after it, would turn
183    /// it into a tag / autolink / entity.
184    amb_at: usize,
185    /// Last `write_opens`: (out start, out end, first `active` index).
186    /// (`restore`: the char before them, if it was entity-encoded.)
187    opened: (usize, usize, usize, Option<char>),
188    /// Delimiter of the open `italic` mark (differs from `emphasis` when the
189    /// opener would abut a closing `*`/`_` run and merge with it).
190    em_open: char,
191    /// Delimiter of the open `bold` mark (`_` when `**` would merge with a
192    /// preceding `*` closer).
193    bold_open: char,
194    /// `out.len()` right after the last emphasis closer.
195    closed_at: usize,
196}
197
198const CODE_RANK: u8 = 9;
199const SPACES: &str = "                                ";
200
201impl<'a> Renderer<'a> {
202    fn new(opts: &'a RenderOptions, cap: usize) -> Self {
203        Self {
204            opts,
205            bullet: if matches!(opts.bullet, '-' | '*' | '+') {
206                opts.bullet
207            } else {
208                '-'
209            },
210            emphasis: if opts.emphasis == '_' { '_' } else { '*' },
211            out: String::with_capacity(cap),
212            prefix: String::new(),
213            cs: 0,
214            bol: usize::MAX,
215            mode: Mode::Normal,
216            active: Vec::new(),
217            code_buf: String::new(),
218            ws: String::new(),
219            hb: false,
220            hb_marks: &[],
221            ws_after: String::new(),
222            hb_run: (0, usize::MAX, 0),
223            opened: (0, usize::MAX, 0, None),
224            amb_at: usize::MAX,
225            em_open: '*',
226            bold_open: '*',
227            closed_at: usize::MAX,
228        }
229    }
230
231    // ---- lines ----------------------------------------------------------
232
233    fn newline(&mut self) {
234        self.out.push('\n');
235        self.out.push_str(&self.prefix);
236        self.cs = self.out.len();
237    }
238
239    /// Empty line; prefix trailing spaces trimmed (`>` not `> `).
240    fn blank_line(&mut self) {
241        self.out.push('\n');
242        self.out.push_str(self.prefix.trim_end());
243    }
244
245    /// Newline, but without trailing prefix whitespace when the next line is empty.
246    fn newline_for(&mut self, next_empty: bool) {
247        if next_empty {
248            self.blank_line();
249            self.cs = self.out.len();
250        } else {
251            self.newline();
252        }
253    }
254
255    fn at_line_start(&self) -> bool {
256        self.out
257            .as_bytes()
258            .get(self.cs..)
259            .is_some_and(|t| t.iter().all(|&b| b == b' '))
260    }
261
262    fn separate(&mut self, sep: Sep) {
263        if sep == Sep::Blank {
264            self.blank_line();
265        }
266        self.newline();
267    }
268
269    fn begin(&mut self, sep: Sep, st: &St) {
270        if st.prev != Prev::None {
271            self.separate(sep);
272        } else if st.task_line {
273            if self.out.ends_with(' ') {
274                self.out.pop();
275            }
276            self.newline();
277        }
278    }
279
280    /// Write multi-line raw text, applying the prefix on each line.
281    fn raw(&mut self, s: &str) {
282        for (i, line) in s.split('\n').enumerate() {
283            if i > 0 {
284                self.newline_for(line.is_empty());
285            }
286            self.out.push_str(line);
287        }
288    }
289
290    fn html(&self, n: &Node) -> String {
291        n.to_html_with(&HtmlOptions {
292            unknown_node: tiptap_rusty_parser::UnknownNodePolicy::DataTypeDiv,
293            ..HtmlOptions::default()
294        })
295    }
296
297    // ---- blocks ---------------------------------------------------------
298
299    fn blocks(&mut self, nodes: &'a [Node], sep: Sep, st: &mut St) {
300        let mut i = 0;
301        while i < nodes.len() {
302            if is_inline_kind(&nodes[i]) {
303                // a block-level image is its own paragraph
304                let mut j = i + 1;
305                if nodes[i].node_type.as_deref() != Some("image") {
306                    while j < nodes.len() && is_run_kind(&nodes[j]) {
307                        j += 1;
308                    }
309                }
310                self.paragraph(&nodes[i..j], sep, st);
311                i = j;
312            } else {
313                self.block(&nodes[i], sep, st);
314                i += 1;
315            }
316        }
317    }
318
319    /// An inline run as a paragraph; leaves no trace when it renders to nothing.
320    fn paragraph(&mut self, nodes: &'a [Node], sep: Sep, st: &mut St) {
321        if nodes.is_empty() {
322            return;
323        }
324        // The importer turns a paragraph of only images (and breaks /
325        // whitespace) into block images, except as an item's first block:
326        // write them as such so a re-export is stable.
327        if nodes.len() > 1 && !(st.item && st.prev == Prev::None) && is_hoistable_images(nodes) {
328            for n in nodes {
329                if n.node_type.as_deref() == Some("image") {
330                    self.paragraph(std::slice::from_ref(n), sep, st);
331                }
332            }
333            return;
334        }
335        let (len, cs) = (self.out.len(), self.cs);
336        if st.prev != Prev::None {
337            self.separate(sep);
338        }
339        let at = self.out.len();
340        self.inline(nodes);
341        if self.out.len() == at {
342            self.out.truncate(len);
343            self.cs = cs;
344        } else {
345            st.prev = Prev::Other;
346        }
347    }
348
349    fn block(&mut self, n: &'a Node, sep: Sep, st: &mut St) {
350        let ty = match n.node_type.as_deref() {
351            Some(t) => t,
352            None => return self.blocks(n.children(), sep, st),
353        };
354        match ty {
355            "paragraph" => {
356                self.paragraph(n.children(), sep, st);
357            }
358            "heading" => {
359                self.begin(sep, st);
360                let level = attr_u64(n, "level").unwrap_or(1).clamp(1, 6) as usize;
361                for _ in 0..level {
362                    self.out.push('#');
363                }
364                self.out.push(' ');
365                let content_at = self.out.len();
366                self.bol = content_at;
367                let prev = std::mem::replace(&mut self.mode, Mode::Flat);
368                self.inline(n.children());
369                self.mode = prev;
370                self.bol = usize::MAX;
371                if self.out.len() == content_at {
372                    self.out.pop();
373                } else if self.out.ends_with('#') && !self.out.ends_with("\\#") {
374                    // a trailing `#` run would read as a closing sequence
375                    let l = self.out.len() - 1;
376                    self.out.insert(l, '\\');
377                }
378                st.prev = Prev::Other;
379            }
380            "blockquote" => {
381                self.begin(sep, st);
382                self.out.push_str("> ");
383                self.cs = self.out.len();
384                let at = self.out.len();
385                let pl = self.prefix.len();
386                self.prefix.push_str("> ");
387                let mut inner = St::default();
388                self.blocks(n.children(), Sep::Blank, &mut inner);
389                self.prefix.truncate(pl);
390                if self.out.len() == at {
391                    self.out.pop();
392                }
393                st.prev = Prev::Other;
394            }
395            "horizontalRule" => {
396                self.begin(sep, st);
397                // `- ---` / `* * **` read as one thematic break: use a rule
398                // char absent from the markers on this line.
399                let line = &self.out.as_bytes()[self.out.rfind('\n').map_or(0, |i| i + 1)..];
400                let rule = if !line.contains(&b'-') {
401                    "---"
402                } else if !line.contains(&b'*') {
403                    "***"
404                } else {
405                    "___"
406                };
407                self.out.push_str(rule);
408                st.prev = Prev::Other;
409            }
410            "codeBlock" => {
411                self.begin(sep, st);
412                self.code_block(n);
413                st.prev = Prev::Other;
414            }
415            "bulletList" | "taskList" => {
416                if n.children().is_empty() {
417                    return;
418                }
419                self.begin(list_sep(n, false, sep), st);
420                // differ from an adjacent bullet list's marker (or they merge)
421                let avoid = if st.prev == Prev::Bullet {
422                    st.bullet
423                } else {
424                    '\0'
425                };
426                st.bullet = self.list(n, false, false, avoid);
427                st.prev = Prev::Bullet;
428            }
429            "orderedList" => {
430                if n.children().is_empty() {
431                    return;
432                }
433                self.begin(list_sep(n, true, sep), st);
434                let alt = st.prev == Prev::Ordered && !st.alt;
435                self.list(n, true, alt, '\0');
436                (st.prev, st.alt) = (Prev::Ordered, alt);
437            }
438            "table" => {
439                if n.children().is_empty() {
440                    return;
441                }
442                self.begin(sep, st);
443                if pipe_table_ok(n) {
444                    self.table(n);
445                } else {
446                    let h = table_html(n);
447                    self.raw(&h);
448                }
449                st.prev = Prev::Other;
450            }
451            // structural containers outside their parent: just their content
452            "doc" | "listItem" | "taskItem" | "tableRow" | "tableCell" | "tableHeader" => {
453                self.blocks(n.children(), sep, st)
454            }
455            _ => match self.opts.unknown_node {
456                UnknownNodePolicy::Skip => {}
457                UnknownNodePolicy::Transparent => self.blocks(n.children(), sep, st),
458                UnknownNodePolicy::Html => {
459                    self.begin(sep, st);
460                    let h = self.html(n);
461                    self.raw(&h);
462                    st.prev = Prev::Other;
463                }
464            },
465        }
466    }
467
468    fn code_block(&mut self, n: &Node) {
469        // longest backtick / tilde run across the content (carried over piece borders)
470        let (mut cur, mut max, mut any) = ([0usize; 2], [0usize; 2], false);
471        walk_text(n, &mut |s| {
472            any |= !s.is_empty();
473            for b in s.bytes() {
474                let k = match b {
475                    b'`' => 0,
476                    b'~' => 1,
477                    _ => {
478                        cur = [0; 2];
479                        continue;
480                    }
481                };
482                cur[k] += 1;
483                cur[1 - k] = 0;
484                max[k] = max[k].max(cur[k]);
485            }
486        });
487        // the importer keeps the first info-string word only
488        let lang = match attr(n, "language") {
489            Some(Value::String(l)) => l.split_whitespace().next().unwrap_or(""),
490            _ => "",
491        };
492        // a backtick fence's info string cannot contain backticks
493        let (fc, run) = if lang.contains('`') {
494            ('~', max[1])
495        } else {
496            ('`', max[0])
497        };
498        let fence = (run + 1).max(3);
499        for _ in 0..fence {
500            self.out.push(fc);
501        }
502        let lb = lang.as_bytes();
503        for (i, c) in lang.char_indices() {
504            match c {
505                '\n' | '\r' => {}
506                '\\' => self.out.push_str("\\\\"),
507                '&' if entity_like(&lb[i + 1..]) => self.out.push_str("\\&"),
508                _ => self.out.push(c),
509            }
510        }
511        self.newline();
512        walk_text(n, &mut |s| {
513            for (i, seg) in s.split('\n').enumerate() {
514                if i > 0 {
515                    self.newline_for(seg.is_empty());
516                }
517                self.out.push_str(seg);
518            }
519        });
520        if any {
521            self.newline();
522        }
523        for _ in 0..fence {
524            self.out.push(fc);
525        }
526    }
527
528    // ---- lists ----------------------------------------------------------
529
530    /// Returns the bullet char used (bullet lists).
531    fn list(&mut self, n: &'a Node, ordered: bool, alt: bool, avoid: char) -> char {
532        let items = n.children();
533        let sep = if is_tight(items) {
534            Sep::Tight
535        } else {
536            Sep::Blank
537        };
538        let mut num = attr_u64(n, "start").unwrap_or(1);
539        let other = if self.bullet == '-' { '*' } else { '-' };
540        // not `avoid`, and `- - -` (nested lists opened on one line) would
541        // read as a thematic break
542        let bullet = if ordered {
543            self.bullet
544        } else {
545            let line = &self.out.as_bytes()[self.out.rfind('\n').map_or(0, |i| i + 1)..];
546            let count = |c: char| line.iter().filter(|&&b| b == c as u8).count();
547            [self.bullet, other, '-', '*', '+']
548                .into_iter()
549                .find(|&c| c != avoid && count(c) < 2)
550                .unwrap_or(other)
551        };
552        let delim = if alt { ')' } else { '.' };
553        for (i, item) in items.iter().enumerate() {
554            if i > 0 {
555                self.separate(sep);
556            }
557            let kids: &[Node] = if is_item(item) {
558                item.children()
559            } else {
560                std::slice::from_ref(item)
561            };
562            let before = self.out.len();
563            if ordered {
564                let _ = write!(self.out, "{num}{delim} ");
565                num = num.wrapping_add(1);
566            } else {
567                self.out.push(bullet);
568                self.out.push(' ');
569            }
570            let width = self.out.len() - before;
571            let marker_end = self.out.len();
572            if item.node_type.as_deref() == Some("taskItem") {
573                let checked = attr(item, "checked").and_then(Value::as_bool) == Some(true);
574                self.out.push_str(if checked { "[x] " } else { "[ ] " });
575            }
576            self.cs = self.out.len();
577            let pl = self.prefix.len();
578            for _ in 0..width / SPACES.len() {
579                self.prefix.push_str(SPACES);
580            }
581            self.prefix.push_str(&SPACES[..width % SPACES.len()]);
582            let mut inner = St {
583                task_line: item.node_type.as_deref() == Some("taskItem"),
584                item: true,
585                ..St::default()
586            };
587            self.blocks(kids, sep, &mut inner);
588            self.prefix.truncate(pl);
589            if self.out.len() == marker_end {
590                self.out.pop(); // empty item: `-` without trailing space
591            }
592        }
593        bullet
594    }
595
596    // ---- tables ---------------------------------------------------------
597
598    fn table(&mut self, n: &'a Node) {
599        let rows = n.children();
600        let cols = rows.iter().map(|r| r.children().len()).max().unwrap_or(0);
601        for (ri, row) in rows.iter().enumerate() {
602            if ri > 0 {
603                self.newline();
604            }
605            self.out.push('|');
606            let cells = row.children();
607            for ci in 0..cols {
608                self.out.push(' ');
609                if let Some(cell) = cells.get(ci) {
610                    self.bol = self.out.len();
611                    let prev = std::mem::replace(&mut self.mode, Mode::Cell);
612                    if let Some(p) = cell.children().first() {
613                        self.inline(p.children());
614                    }
615                    self.mode = prev;
616                    self.bol = usize::MAX;
617                }
618                self.out.push_str(" |");
619            }
620            if ri == 0 {
621                self.newline();
622                self.out.push('|');
623                for _ in 0..cols {
624                    self.out.push_str(" --- |");
625                }
626            }
627        }
628    }
629
630    // ---- inline runs ----------------------------------------------------
631
632    fn inline(&mut self, nodes: &'a [Node]) {
633        let start = self.out.len();
634        self.active.clear();
635        self.ws.clear();
636        self.ws_after.clear();
637        self.hb = false;
638        self.items(nodes);
639        self.close_not_in(&[], true);
640        // trailing whitespace is stripped on re-parse, except non-ASCII (NBSP, ...)
641        let ascii_ws = [' ', '\t', '\n', '\r'];
642        let keep = self.ws_after.trim_end_matches(ascii_ws).len();
643        if self.hb && keep > 0 {
644            self.ws_after.truncate(keep);
645            self.flush_pending();
646        } else {
647            let keep = self.ws.trim_end_matches(ascii_ws).len();
648            if keep > 0 {
649                self.ws.truncate(keep);
650                self.flush_ws();
651            }
652        }
653        // breaks at the end of the run would leave a literal `\\` (and the
654        // whitespace before them would dangle)
655        let mut dropped = self.take_hb_run().is_some();
656        let tail = self.prefix.len() + 2;
657        while self.out.len() >= start + tail
658            && self.out.ends_with(self.prefix.as_str())
659            && self.out.as_bytes()[..self.out.len() - self.prefix.len()].ends_with(b"\\\n")
660            && ends_unescaped(&self.out[..self.out.len() - tail + 1], b'\\')
661        {
662            // (left by marks opened between breaks)
663            self.out.truncate(self.out.len() - tail);
664            dropped = true;
665        }
666        if dropped {
667            let keep = self.out.trim_end_matches([' ', '\t']).len().max(start);
668            self.out.truncate(keep);
669        }
670        self.ws.clear();
671        self.ws_after.clear();
672        self.hb = false;
673    }
674
675    fn items(&mut self, nodes: &'a [Node]) {
676        let mut i = 0;
677        while i < nodes.len() {
678            let n = &nodes[i];
679            i += 1;
680            match n.node_type.as_deref() {
681                Some("text") => {
682                    // Adjacent texts with equal marks re-import as one node:
683                    // escape them as one (line-start / `<` / `&` lookahead).
684                    // (In a single-line context a break is a space: absorb it.)
685                    let marks = n.marks.as_deref().unwrap_or(&[]);
686                    let flat = self.mode == Mode::Flat;
687                    let same = |m: &Node| {
688                        matches!(m.node_type.as_deref(), Some("text"))
689                            || (flat && m.node_type.as_deref() == Some("hardBreak"))
690                    } && m.marks.as_deref().unwrap_or(&[]) == marks;
691                    let j = i + nodes[i..].iter().take_while(|m| same(m)).count();
692                    if j == i {
693                        self.text_item(n.text.as_deref().unwrap_or(""), marks);
694                    } else {
695                        let mut s = String::from(n.text.as_deref().unwrap_or(""));
696                        for m in &nodes[i..j] {
697                            s.push_str(m.text.as_deref().unwrap_or(" "));
698                        }
699                        self.text_item(&s, marks);
700                        i = j;
701                    }
702                }
703                Some("hardBreak") => self.hard_break(n),
704                Some("image") => self.image(n),
705                None => self.items(n.children()),
706                Some(ty) if is_known_container(ty) => self.items(n.children()),
707                Some(_) => match self.opts.unknown_node {
708                    UnknownNodePolicy::Skip => {}
709                    UnknownNodePolicy::Transparent => self.items(n.children()),
710                    UnknownNodePolicy::Html => {
711                        self.close_not_in(&[], false);
712                        self.flush_pending();
713                        let h = self.html(n);
714                        let prev = self.mode;
715                        if prev == Mode::Normal {
716                            self.raw(&h);
717                        } else {
718                            self.out.push_str(&h.replace('\n', " "));
719                        }
720                    }
721                },
722            }
723        }
724    }
725
726    fn text_item(&mut self, text: &str, marks: &'a [Mark]) {
727        if text.is_empty() {
728            return;
729        }
730        if marks.iter().any(|m| m.mark_type == "code") {
731            self.close_not_in(marks, true);
732            // code is innermost: a mark opening now must not land inside it
733            if self.active.last().is_some_and(|a| a.mark_type == "code")
734                && marks.iter().any(|m| {
735                    m.mark_type != "code"
736                        && mark_rank(m).is_some()
737                        && !self.active.iter().any(|a| a.mark_type == m.mark_type)
738                })
739            {
740                if let Some(c) = self.active.pop() {
741                    self.close_mark(c);
742                }
743            }
744            self.open_after_pending(marks, true, "", '`');
745            self.code_buf.extend(
746                text.chars()
747                    .map(|c| if matches!(c, '\n' | '\r') { ' ' } else { c }),
748            );
749            return;
750        }
751        let b = text.as_bytes();
752        let plain = |x: u8| x.is_ascii_graphic();
753        let (lead, body, core) = if plain(b[0]) && plain(b[b.len() - 1]) {
754            ("", text, text) // fast path: no edge whitespace
755        } else {
756            let body = text.trim_start_matches(is_ws);
757            let lead = &text[..text.len() - body.len()];
758            (lead, body, body.trim_end_matches(is_ws))
759        };
760        self.close_not_in(marks, true);
761        if core.is_empty() {
762            // `[ ](u)` is still a link: open it around the whitespace
763            let link = marks
764                .iter()
765                .find(|m| m.mark_type == "link" && mark_rank(m).is_some());
766            if let Some(link) = link {
767                if !self.active.iter().any(|a| a.mark_type == "link") {
768                    self.open_after_pending(std::slice::from_ref(link), true, "", '[');
769                    self.escape_text(text);
770                    return;
771                }
772            }
773            if self.hb {
774                self.ws_after.push_str(text);
775            } else {
776                self.ws.push_str(text);
777            }
778            return;
779        }
780        self.open_after_pending(marks, true, lead, core.chars().next().unwrap_or(' '));
781        self.escape_text(core);
782        self.ws.push_str(&body[core.len()..]);
783    }
784
785    fn hard_break(&mut self, n: &'a Node) {
786        let marks = n.marks.as_deref().unwrap_or(&[]);
787        self.close_not_in(marks, false);
788        match self.mode {
789            Mode::Normal => {
790                if self.hb {
791                    // consecutive breaks: emit the earlier one (and the
792                    // whitespace before it) now, inside the marks both carry
793                    if self.hb_marks == marks {
794                        self.open_after_pending(marks, false, "", '\\');
795                    } else {
796                        self.flush_pending();
797                    }
798                }
799                // a break inside link text opens the link (no flanking rules)
800                let link = marks
801                    .iter()
802                    .find(|m| m.mark_type == "link" && mark_rank(m).is_some());
803                if let Some(link) = link {
804                    if !self.active.iter().any(|a| a.mark_type == "link") {
805                        self.open_after_pending(std::slice::from_ref(link), false, "", '[');
806                    }
807                }
808                self.hb = true;
809                self.hb_marks = marks;
810            }
811            // nothing before it: the space would only be stripped
812            Mode::Flat if self.out.len() == self.bol && self.ws.is_empty() => {}
813            Mode::Flat => self.ws.push(' '),
814            Mode::Cell => {
815                self.flush_pending();
816                self.out.push_str("<br>");
817            }
818        }
819    }
820
821    fn image(&mut self, n: &'a Node) {
822        let marks = n.marks.as_deref().unwrap_or(&[]);
823        self.close_not_in(marks, false);
824        self.open_after_pending(marks, false, "", '!');
825        self.out.push_str("![");
826        if let Some(alt) = attr(n, "alt").and_then(Value::as_str) {
827            let prev = self.mode;
828            if prev == Mode::Normal {
829                self.mode = Mode::Flat;
830            }
831            self.escape_text(alt);
832            self.mode = prev;
833        }
834        self.out.push_str("](");
835        write_dest(
836            &mut self.out,
837            attr(n, "src").and_then(Value::as_str).unwrap_or(""),
838        );
839        if let Some(t) = attr(n, "title").and_then(Value::as_str) {
840            write_title(&mut self.out, t);
841        }
842        self.out.push(')');
843    }
844
845    /// Flush held-back whitespace / hard break, then `lead` and the opens of
846    /// `marks` (`next` = first char after them). A held-back hard break that
847    /// carries every newly opened mark goes inside them (`**\\\nb**`).
848    fn open_after_pending(&mut self, marks: &'a [Mark], text: bool, lead: &str, next: char) {
849        if marks.is_empty() && !self.hb && self.ws.is_empty() && lead.is_empty() {
850            return;
851        }
852        self.flush_ws();
853        let from = self.collect_opens(marks, text);
854        let mut mid = from;
855        if self.hb {
856            // new marks the break also carries open (outermost) before it;
857            // delimiter nesting order is free, import re-canonicalizes it
858            let hbm = self.hb_marks;
859            let carried = |m: &Mark| m.mark_type != "code" && hbm.contains(m);
860            let new = &mut self.active[from..];
861            if new.iter().any(|m| carried(m)) {
862                new.sort_by_key(|m| !carried(m));
863                mid += new.iter().take_while(|m| carried(m)).count();
864                self.write_opens(from, mid, '\\');
865            }
866        }
867        self.flush_hb();
868        if !lead.is_empty() {
869            // leading whitespace stays inside a link (only delimiters need
870            // it outside)
871            if self.active.get(mid).is_some_and(|m| m.mark_type == "link") {
872                self.write_opens(mid, mid + 1, ' ');
873                mid += 1;
874            }
875            self.escape_text(lead);
876        }
877        self.write_opens(mid, self.active.len(), next);
878    }
879
880    fn flush_pending(&mut self) {
881        let start = self.out.len();
882        self.flush_ws();
883        self.flush_hb();
884        if self.hb_run.1 == self.out.len() && self.hb_run.0 > start {
885            // whitespace right before a break run is dropped with it
886            self.hb_run.0 = start;
887        }
888    }
889
890    fn flush_ws(&mut self) {
891        if !self.ws.is_empty() {
892            let w = std::mem::take(&mut self.ws);
893            self.escape_text(&w);
894            self.ws = w;
895            self.ws.clear();
896        }
897    }
898
899    fn flush_hb(&mut self) {
900        self.emit_hb(false);
901    }
902
903    /// Emit the held-back break as `\\` + newline (or two trailing spaces).
904    fn emit_hb(&mut self, spaces: bool) {
905        if self.hb {
906            self.hb = false;
907            if self.out.len() != self.hb_run.1 {
908                self.hb_run.0 = self.out.len();
909                self.hb_run.2 = self.cs;
910            }
911            self.out.push_str(if spaces { "  " } else { "\\" });
912            self.newline();
913            self.hb_run.1 = self.out.len();
914            if !self.ws_after.is_empty() {
915                let w = std::mem::take(&mut self.ws_after);
916                self.escape_text(&w);
917                if !w.contains(|c: char| !matches!(c, ' ' | '\t' | '\n' | '\r')) {
918                    // plain whitespace: dropped with the run if nothing follows
919                    self.hb_run.1 = self.out.len();
920                }
921                self.ws_after = w;
922                self.ws_after.clear();
923            }
924        }
925    }
926
927    // ---- marks ----------------------------------------------------------
928
929    /// Remove a run of hard breaks nothing followed yet; returns it.
930    fn take_hb_run(&mut self) -> Option<String> {
931        if self.out.len() != self.hb_run.1 {
932            return None;
933        }
934        let tail = self.out.split_off(self.hb_run.0);
935        self.cs = self.hb_run.2;
936        self.hb_run.1 = usize::MAX;
937        Some(tail)
938    }
939
940    /// Close open marks (innermost first) from the first one `marks` lacks.
941    fn close_not_in(&mut self, marks: &[Mark], text: bool) {
942        let mut i = 0;
943        while i < self.active.len() {
944            let a = self.active[i];
945            if (text || a.mark_type != "code") && marks.iter().any(|m| m == a) {
946                i += 1;
947            } else {
948                break;
949            }
950        }
951        if i == self.active.len() {
952            return;
953        }
954        // a delimiter closer at a line start (right after a break) would not
955        // close: close before the breaks (`](u)` / `</u>` / code are fine)
956        let delim = self
957            .active
958            .last()
959            .is_some_and(|m| matches!(m.mark_type.as_str(), "bold" | "italic" | "strike"));
960        let breaks = if self.active.len() > i && delim {
961            self.take_hb_run()
962        } else {
963            None
964        };
965        // marks whose only content was those breaks: drop their openers
966        let (os, oe, of, restore) = self.opened;
967        if breaks.is_some() && self.out.len() == oe && of >= i && of < self.active.len() {
968            self.out.truncate(os);
969            if let Some(p) = restore {
970                self.out.push(p);
971            }
972            self.active.truncate(of);
973        }
974        while self.active.len() > i {
975            if let Some(m) = self.active.pop() {
976                if m.mark_type == "link" {
977                    // whitespace (and a break) held back from inside the link
978                    // text stays inside: `[a ](u)` is fine, unlike `**a **`.
979                    // A `\\` break right before `]` after a delimiter closer is
980                    // read as text, so newlines become spaces / the break uses
981                    // trailing spaces.
982                    if self.ws.contains(['\n', '\r']) {
983                        self.ws = self.ws.replace(['\n', '\r'], " ");
984                    }
985                    self.flush_ws();
986                    if self.hb && self.hb_marks.contains(m) {
987                        let spaces = self.out.len() == self.closed_at;
988                        self.emit_hb(spaces);
989                    }
990                }
991                self.close_mark(m);
992            }
993        }
994        if let Some(b) = breaks {
995            self.hb_run.0 = self.out.len();
996
997            self.out.push_str(&b);
998            self.cs = self.out.len();
999            self.hb_run.1 = self.out.len();
1000        }
1001    }
1002
1003    /// Push marks of the node not yet open; returns the index of the first new one.
1004    fn collect_opens(&mut self, marks: &'a [Mark], text: bool) -> usize {
1005        let from = self.active.len();
1006        for m in marks {
1007            let Some(rank) = mark_rank(m) else { continue };
1008            if rank == CODE_RANK && !text {
1009                continue;
1010            }
1011            if self.active.iter().any(|a| a.mark_type == m.mark_type) {
1012                continue;
1013            }
1014            self.active.push(m);
1015        }
1016        if self.active.len() - from > 1 {
1017            self.active[from..].sort_by_key(|m| mark_rank(m).unwrap_or(0));
1018        }
1019        from
1020    }
1021
1022    /// Open `active[from..to]`; `next` is the first char written after them.
1023    fn write_opens(&mut self, from: usize, to: usize, next: char) {
1024        if from >= to {
1025            return;
1026        }
1027        let start = self.out.len();
1028        for i in from..to {
1029            let m = self.active[i];
1030            self.open_mark(m);
1031        }
1032        self.opened = (start, self.out.len(), from, None);
1033        // The leading delimiter run must be left-flanking (and for `_` not
1034        // also right-flanking): a word char before it breaks that when the
1035        // run is followed by punctuation, or always for `_`. Encode that char.
1036        let b = &self.out.as_bytes()[start..];
1037        let Some(&c) = b.first().filter(|c| matches!(c, b'*' | b'_' | b'~')) else {
1038            return;
1039        };
1040        let run = b.iter().take_while(|&&x| x == c).count();
1041        let n = b.get(run).map_or(next, |&x| x as char);
1042        if let Some(p) = self.out[..start].chars().next_back() {
1043            if maybe_word(p) && (maybe_punct(n) || c == b'_') {
1044                let at = start - p.len_utf8();
1045                let undo = self.encode_before(at, p);
1046                self.closed_at = usize::MAX;
1047                // an undo is only exact when nothing before `at` changed
1048                let end = if undo.is_some() {
1049                    self.out.len()
1050                } else {
1051                    usize::MAX
1052                };
1053                self.opened = (at, end, from, undo);
1054            }
1055        }
1056    }
1057
1058    /// Entity-encode `p` at `at`. The `&…;` is punctuation, which can break
1059    /// the flanking of a delimiter run right before it (repeat there) and turn
1060    /// an intraword `_` into a delimiter (escape it). Returns the char to put
1061    /// back at `at` to undo, if nothing else changed.
1062    fn encode_before(&mut self, mut at: usize, mut p: char) -> Option<char> {
1063        let mut undo = Some(p);
1064        loop {
1065            let mut e = String::with_capacity(12);
1066            let _ = write!(e, "&#{};", p as u32);
1067            self.out.replace_range(at..at + p.len_utf8(), &e);
1068            let b = self.out.as_bytes();
1069            let Some(&d) = b[..at].last().filter(|d| matches!(d, b'*' | b'_' | b'~')) else {
1070                return undo;
1071            };
1072            if b[..at - 1]
1073                .iter()
1074                .rev()
1075                .take_while(|&&x| x == b'\\')
1076                .count()
1077                % 2
1078                == 1
1079            {
1080                return undo; // escaped: text
1081            }
1082            if d == b'_' && at >= 2 && b[at - 2].is_ascii_alphanumeric() {
1083                // an intraword `_` of text (a delimiter there could not flank)
1084                self.out.insert(at - 1, '\\');
1085                return None;
1086            }
1087            let run = b[..at].iter().rev().take_while(|&&x| x == d).count();
1088            match self.out[..at - run].chars().next_back() {
1089                Some(q) if maybe_word(q) => {
1090                    at = at - run - q.len_utf8();
1091                    p = q;
1092                    undo = None;
1093                }
1094                _ => return undo,
1095            }
1096        }
1097    }
1098
1099    fn open_mark(&mut self, m: &Mark) {
1100        match m.mark_type.as_str() {
1101            "link" => {
1102                // `!` + `[` would read as an image
1103                if ends_unescaped(&self.out, b'!') {
1104                    let l = self.out.len() - 1;
1105                    self.out.insert(l, '\\');
1106                }
1107                self.out.push('[');
1108            }
1109            "bold" => {
1110                // `*a*` + `**b**` would merge into one `***` run
1111                let c = if self.out.len() == self.closed_at && self.out.ends_with('*') {
1112                    '_'
1113                } else {
1114                    '*'
1115                };
1116                self.bold_open = c;
1117                self.out.push(c);
1118                self.out.push(c);
1119            }
1120            "italic" => {
1121                // `**a*b****c*`: a closer run directly followed by an opener
1122                // of the same char merges into one run; switch delimiter.
1123                let mut c = self.emphasis;
1124                if self.out.len() == self.closed_at && self.out.ends_with(c) {
1125                    c = if c == '*' { '_' } else { '*' };
1126                }
1127                self.em_open = c;
1128                self.out.push(c);
1129            }
1130            "strike" => self.out.push_str("~~"),
1131            "underline" => self.out.push_str("<u>"),
1132            "subscript" => self.out.push_str("<sub>"),
1133            "superscript" => self.out.push_str("<sup>"),
1134            "code" => self.code_buf.clear(),
1135            _ => {}
1136        }
1137    }
1138
1139    fn close_mark(&mut self, m: &Mark) {
1140        match m.mark_type.as_str() {
1141            "link" => {
1142                self.out.push_str("](");
1143                write_dest(&mut self.out, mark_attr(m, "href").unwrap_or(""));
1144                if let Some(t) = mark_attr(m, "title") {
1145                    write_title(&mut self.out, t);
1146                }
1147                self.out.push(')');
1148            }
1149            "bold" => {
1150                self.out.push(self.bold_open);
1151                self.out.push(self.bold_open);
1152                self.closed_at = self.out.len();
1153            }
1154            "italic" => {
1155                self.out.push(self.em_open);
1156                self.closed_at = self.out.len();
1157            }
1158            "strike" => {
1159                self.out.push_str("~~");
1160                self.closed_at = self.out.len();
1161            }
1162            "underline" => self.out.push_str("</u>"),
1163            "subscript" => self.out.push_str("</sub>"),
1164            "superscript" => self.out.push_str("</sup>"),
1165            "code" => self.flush_code(),
1166            _ => {}
1167        }
1168    }
1169
1170    fn flush_code(&mut self) {
1171        let s = std::mem::take(&mut self.code_buf);
1172        // smallest backtick count that does not equal any run in the content
1173        let (mut run, mut seen) = (0u32, 0u64);
1174        for b in s.bytes().chain(std::iter::once(b' ')) {
1175            if b == b'`' {
1176                run += 1;
1177            } else {
1178                if run > 0 && run < 64 {
1179                    seen |= 1 << run;
1180                }
1181                run = 0;
1182            }
1183        }
1184        let n = (1..64).find(|k| seen & (1 << k) == 0).unwrap_or(1);
1185        let pad = s.starts_with('`')
1186            || s.ends_with('`')
1187            || (s.starts_with(' ') && s.ends_with(' ') && s.bytes().any(|b| b != b' '));
1188        for _ in 0..n {
1189            self.out.push('`');
1190        }
1191        if pad {
1192            self.out.push(' ');
1193        }
1194        if self.mode == Mode::Cell && s.contains('|') {
1195            self.out.push_str(&s.replace('|', "\\|"));
1196        } else {
1197            self.out.push_str(&s);
1198        }
1199        if pad {
1200            self.out.push(' ');
1201        }
1202        for _ in 0..n {
1203            self.out.push('`');
1204        }
1205        self.code_buf = s;
1206        self.code_buf.clear();
1207    }
1208
1209    // ---- text -----------------------------------------------------------
1210
1211    fn escape_text(&mut self, s: &str) {
1212        let b = s.as_bytes();
1213        let mut ls = self.at_line_start();
1214        // leading whitespace would be stripped: encode the first char
1215        let mut lead = ls || self.out.len() == self.bol;
1216        let (mut last, mut i) = (0, 0);
1217        // end of a first char written as an entity (no longer a word char)
1218        let mut enc_end = 0;
1219        if self.amb_at != usize::MAX && self.amb_at + 1 == self.out.len() {
1220            let bad = match self.out.as_bytes()[self.amb_at] {
1221                b'<' => b.first().is_some_and(|&x| starts_tag(x)),
1222                b'&' => entity_like(b),
1223                _ => false,
1224            };
1225            if bad {
1226                self.out.insert(self.amb_at, '\\');
1227            }
1228        }
1229        self.amb_at = usize::MAX;
1230        if self.out.len() == self.closed_at {
1231            // The delimiter run just closed must stay right-flanking (and for
1232            // `_` not also left-flanking): a word char after it breaks that
1233            // when the run follows punctuation, or always for `_`.
1234            if let Some(n) = s.chars().next().filter(|&n| maybe_word(n)) {
1235                let ob = self.out.as_bytes();
1236                let c = ob[ob.len() - 1];
1237                let run = ob.iter().rev().take_while(|&&x| x == c).count();
1238                let p = self.out[..ob.len() - run].chars().next_back();
1239                if c == b'_' || p.is_some_and(maybe_punct) {
1240                    let _ = write!(self.out, "&#{};", n as u32);
1241                    (last, i, lead) = (n.len_utf8(), n.len_utf8(), false);
1242                    enc_end = i;
1243                }
1244            }
1245        }
1246        while i < b.len() {
1247            if !ls && !lead {
1248                // mid-line only these bytes can need an escape
1249                match b[i..].iter().position(|&x| MID_LINE[x as usize]) {
1250                    Some(k) => i += k,
1251                    None => break,
1252                }
1253            }
1254            let c = b[i];
1255            if lead && self.mode != Mode::Normal && (c == b'\n' || c == b'\r') {
1256                // would be a stripped space here
1257                self.out.push_str(&s[last..i]);
1258                last = i + 1;
1259                i += 1;
1260                continue;
1261            }
1262            if lead && (c == b' ' || c == b'\t') {
1263                self.out.push_str(&s[last..i]);
1264                self.out.push_str(if c == b' ' { "&#32;" } else { "&#9;" });
1265                (last, ls, lead) = (i + 1, false, false);
1266                i += 1;
1267                continue;
1268            }
1269            lead = false;
1270            let esc = match c {
1271                b'\\' | b'*' | b'`' | b'[' | b']' | b'~' => true,
1272                b'_' => {
1273                    let w = |x: Option<&u8>| x.is_some_and(|x| x.is_ascii_alphanumeric());
1274                    !(i > 0 && i != enc_end && w(b.get(i - 1)) && w(b.get(i + 1)))
1275                }
1276                // at the end of the piece: decided by the next one (`amb_at`)
1277                b'<' => b.get(i + 1).is_some_and(|&x| starts_tag(x)),
1278                b'&' => entity_like(&b[i + 1..]),
1279                b'|' => self.mode == Mode::Cell,
1280                b'#' if ls => {
1281                    let mut j = i;
1282                    while j < b.len() && b[j] == b'#' {
1283                        j += 1;
1284                    }
1285                    j - i <= 6
1286                        && b.get(j)
1287                            .is_none_or(|x| matches!(x, b' ' | b'\t' | b'\n' | b'\r'))
1288                }
1289                b'>' if ls => true,
1290                b'-' | b'+' if ls => b.get(i + 1).is_none_or(|x| {
1291                    matches!(x, b' ' | b'\t' | b'\n' | b'\r') || (c == b'-' && *x == b'-')
1292                }),
1293                b'=' if ls => b
1294                    .get(i + 1)
1295                    .is_none_or(|x| matches!(x, b' ' | b'\t' | b'=' | b'\n' | b'\r')),
1296                b'0'..=b'9' if ls => {
1297                    let mut j = i;
1298                    while j < b.len() && b[j].is_ascii_digit() {
1299                        j += 1;
1300                    }
1301                    if j - i <= 9
1302                        && matches!(b.get(j), Some(b'.' | b')'))
1303                        && b.get(j + 1)
1304                            .is_none_or(|x| matches!(x, b' ' | b'\t' | b'\n' | b'\r'))
1305                    {
1306                        self.out.push_str(&s[last..j]);
1307                        self.out.push('\\');
1308                        last = j;
1309                    }
1310                    ls = false;
1311                    i = j;
1312                    continue;
1313                }
1314                b'\r' if b.get(i + 1) == Some(&b'\n') => {
1315                    // `\r\n`: one line break, emitted at the `\n`
1316                    self.out.push_str(&s[last..i]);
1317                    last = i + 1;
1318                    i += 1;
1319                    continue;
1320                }
1321                b'\n' | b'\r' => {
1322                    self.out.push_str(&s[last..i]);
1323                    last = i + 1;
1324                    if self.mode == Mode::Normal {
1325                        // a bare newline would reimport as a space
1326                        if self.out.len() != self.hb_run.1 {
1327                            self.hb_run.0 = self.out.len();
1328                            self.hb_run.2 = self.cs;
1329                        }
1330                        self.out.push('\\');
1331                        self.newline();
1332                        self.hb_run.1 = self.out.len();
1333                        (ls, lead) = (true, true);
1334                    } else {
1335                        self.out.push(' ');
1336                    }
1337                    i += 1;
1338                    continue;
1339                }
1340                _ => false,
1341            };
1342            if esc {
1343                self.out.push_str(&s[last..i]);
1344                self.out.push('\\');
1345                last = i;
1346            }
1347            if c != b' ' && c != b'\t' {
1348                ls = false;
1349            }
1350            i += 1;
1351        }
1352        self.out.push_str(&s[last..]);
1353        if matches!(b.last(), Some(b'<' | b'&')) && last < b.len() {
1354            self.amb_at = self.out.len() - 1;
1355        }
1356    }
1357}
1358
1359// ---- helpers ------------------------------------------------------------
1360
1361/// `out` ends with `c` not preceded by an odd number of backslashes.
1362fn ends_unescaped(out: &str, c: u8) -> bool {
1363    let b = out.as_bytes();
1364    let Some((&last, rest)) = b.split_last() else {
1365        return false;
1366    };
1367    last == c && rest.iter().rev().take_while(|&&x| x == b'\\').count() % 2 == 0
1368}
1369
1370/// Not certainly whitespace or punctuation (for delimiter flanking).
1371fn maybe_word(c: char) -> bool {
1372    !c.is_whitespace() && !c.is_ascii_punctuation()
1373}
1374
1375/// Not certainly whitespace or alphanumeric (for delimiter flanking).
1376fn maybe_punct(c: char) -> bool {
1377    !c.is_whitespace() && !c.is_alphanumeric()
1378}
1379
1380/// Whitespace for delimiter flanking (Unicode, like CommonMark).
1381fn is_ws(c: char) -> bool {
1382    c.is_whitespace()
1383}
1384
1385/// Unmarked images, hard breaks and whitespace only, with at least one image
1386/// (mirrors the importer's hoisting rule).
1387fn is_hoistable_images(nodes: &[Node]) -> bool {
1388    let mut any = false;
1389    for c in nodes {
1390        match c.node_type.as_deref() {
1391            // marks the export does not render (code on an image, unknown)
1392            // do not come back
1393            Some("image")
1394                if c.marks.as_ref().is_none_or(|m| {
1395                    m.iter()
1396                        .all(|m| m.mark_type == "code" || mark_rank(m).is_none())
1397                }) =>
1398            {
1399                any = true
1400            }
1401            Some("hardBreak") => {}
1402            Some("text") if c.text.as_deref().is_none_or(|t| t.trim().is_empty()) => {}
1403            _ => return false,
1404        }
1405    }
1406    any
1407}
1408
1409fn is_inline_kind(n: &Node) -> bool {
1410    matches!(n.node_type.as_deref(), Some("text" | "hardBreak" | "image"))
1411}
1412
1413/// Kinds that continue a paragraph run (everything inline except images).
1414fn is_run_kind(n: &Node) -> bool {
1415    matches!(n.node_type.as_deref(), Some("text" | "hardBreak"))
1416}
1417
1418/// Real HTML table for tables GFM cannot express (spans, block cells).
1419fn table_html(n: &Node) -> String {
1420    let mut o = HtmlOptions {
1421        unknown_node: tiptap_rusty_parser::UnknownNodePolicy::DataTypeDiv,
1422        spread_attrs: true,
1423        ..HtmlOptions::default()
1424    };
1425    for (k, v) in [
1426        ("table", "table"),
1427        ("tableRow", "tr"),
1428        ("tableCell", "td"),
1429        ("tableHeader", "th"),
1430    ] {
1431        o.node_tags.insert(k.into(), v.into());
1432    }
1433    n.to_html_with(&o)
1434}
1435
1436fn is_item(n: &Node) -> bool {
1437    matches!(n.node_type.as_deref(), Some("listItem" | "taskItem"))
1438}
1439
1440fn is_list(n: &Node) -> bool {
1441    matches!(
1442        n.node_type.as_deref(),
1443        Some("bulletList" | "orderedList" | "taskList")
1444    )
1445}
1446
1447/// Node types with built-in block handling (flattened when met inside an inline run).
1448fn is_known_container(ty: &str) -> bool {
1449    matches!(
1450        ty,
1451        "doc"
1452            | "paragraph"
1453            | "heading"
1454            | "blockquote"
1455            | "bulletList"
1456            | "orderedList"
1457            | "taskList"
1458            | "listItem"
1459            | "taskItem"
1460            | "codeBlock"
1461            | "table"
1462            | "tableRow"
1463            | "tableCell"
1464            | "tableHeader"
1465    )
1466}
1467
1468/// Tight: every item is at most one paragraph (first) followed only by nested lists.
1469fn is_tight(items: &[Node]) -> bool {
1470    items.iter().all(|item| {
1471        let kids: &[Node] = if is_item(item) {
1472            item.children()
1473        } else {
1474            return false;
1475        };
1476        kids.iter()
1477            .filter(|k| !renders_empty(k))
1478            .enumerate()
1479            .all(|(i, k)| is_list(k) || (i == 0 && k.node_type.as_deref() == Some("paragraph")))
1480    })
1481}
1482
1483/// A paragraph the export writes as nothing (breaks / plain whitespace only).
1484fn renders_empty(p: &Node) -> bool {
1485    p.node_type.as_deref() == Some("paragraph")
1486        && p.children().iter().all(|c| {
1487            // whitespace in a link or code span is written
1488            let text = c.node_type.as_deref() == Some("text");
1489            let kept = c.marks.as_deref().is_some_and(|m| {
1490                m.iter().any(|m| {
1491                    (text && m.mark_type == "code")
1492                        || (m.mark_type == "link" && mark_rank(m).is_some())
1493                })
1494            });
1495            !kept
1496                && match c.node_type.as_deref() {
1497                    Some("hardBreak") => true,
1498                    Some("text") => c
1499                        .text
1500                        .as_deref()
1501                        .is_none_or(|t| t.trim_matches([' ', '\t', '\n', '\r']).is_empty()),
1502                    _ => false,
1503                }
1504        })
1505}
1506
1507/// Separator before a list: a list starting with an empty item or (ordered)
1508/// not at 1 cannot interrupt a paragraph, so it needs a blank line.
1509fn list_sep(list: &Node, ordered: bool, sep: Sep) -> Sep {
1510    if sep == Sep::Blank {
1511        return sep;
1512    }
1513    let empty_first = list.children().first().is_some_and(|it| {
1514        it.node_type.as_deref() == Some("listItem") && it.children().iter().all(renders_empty)
1515    });
1516    if empty_first || (ordered && attr_u64(list, "start").unwrap_or(1) != 1) {
1517        Sep::Blank
1518    } else {
1519        sep
1520    }
1521}
1522
1523fn pipe_table_ok(table: &Node) -> bool {
1524    let span_ok = |c: &Node| {
1525        ["colspan", "rowspan"]
1526            .iter()
1527            .all(|k| attr_u64(c, k).is_none_or(|v| v <= 1))
1528    };
1529    table.children().iter().all(|row| {
1530        row.node_type.as_deref() == Some("tableRow")
1531            && !row.children().is_empty()
1532            && row.children().iter().all(|cell| {
1533                matches!(cell.node_type.as_deref(), Some("tableCell" | "tableHeader"))
1534                    && span_ok(cell)
1535                    && match cell.children() {
1536                        [] => true,
1537                        [p] => {
1538                            p.node_type.as_deref() == Some("paragraph")
1539                                && p.children().iter().all(is_inline_kind)
1540                        }
1541                        _ => false,
1542                    }
1543            })
1544    })
1545}
1546
1547fn attr_u64(n: &Node, key: &str) -> Option<u64> {
1548    match attr(n, key)? {
1549        Value::Number(x) => x.as_u64().or_else(|| x.as_f64().map(|f| f.max(0.0) as u64)),
1550        Value::String(s) => s.trim().parse().ok(),
1551        _ => None,
1552    }
1553}
1554
1555/// Attr lookup by a scan: attrs maps are tiny, hashing the key costs more.
1556fn attr<'n>(n: &'n Node, key: &str) -> Option<&'n Value> {
1557    n.attrs
1558        .as_ref()?
1559        .iter()
1560        .find_map(|(k, v)| (k == key).then_some(v))
1561}
1562
1563fn mark_attr<'m>(m: &'m Mark, key: &str) -> Option<&'m str> {
1564    m.attrs
1565        .as_ref()?
1566        .iter()
1567        .find_map(|(k, v)| (k == key).then_some(v))?
1568        .as_str()
1569        .filter(|s| !s.is_empty())
1570}
1571
1572/// Nesting order (outermost first). `None` = unknown mark, rendered transparently.
1573fn mark_rank(m: &Mark) -> Option<u8> {
1574    Some(match m.mark_type.as_str() {
1575        "link" => {
1576            // `[a]()` is a link too: only a missing / non-string href is not
1577            m.attrs
1578                .as_ref()?
1579                .iter()
1580                .find_map(|(k, v)| (k == "href").then_some(v))?
1581                .as_str()?;
1582            0
1583        }
1584        "underline" => 1,
1585        "subscript" | "superscript" => 2,
1586        "strike" => 3,
1587        "bold" => 4,
1588        "italic" => 5,
1589        "code" => CODE_RANK,
1590        _ => return None,
1591    })
1592}
1593
1594/// Visit descendant text (raw, marks ignored).
1595fn walk_text(n: &Node, f: &mut impl FnMut(&str)) {
1596    if let Some(t) = &n.text {
1597        f(t);
1598    }
1599    for c in n.children() {
1600        walk_text(c, f);
1601    }
1602}
1603
1604fn approx_size(n: &Node) -> usize {
1605    fn go(n: &Node) -> usize {
1606        8 + n.text.as_ref().map_or(0, String::len) + n.children().iter().map(go).sum::<usize>()
1607    }
1608    let g = go(n);
1609    g + g / 8 + 16
1610}
1611
1612/// Bytes `escape_text` must look at away from a line start.
1613const MID_LINE: [bool; 256] = {
1614    let mut t = [false; 256];
1615    let s = b"\\*`[]~_<&|\n\r";
1616    let mut i = 0;
1617    while i < s.len() {
1618        t[s[i] as usize] = true;
1619        i += 1;
1620    }
1621    t
1622};
1623
1624/// `<` followed by this may open a tag or autolink.
1625fn starts_tag(x: u8) -> bool {
1626    x.is_ascii_alphabetic() || matches!(x, b'/' | b'!' | b'?')
1627}
1628
1629/// `&` followed by something that reads as a character reference.
1630fn entity_like(rest: &[u8]) -> bool {
1631    for (n, &x) in rest.iter().take(33).enumerate() {
1632        if x == b';' {
1633            return n > 0;
1634        }
1635        if !(x.is_ascii_alphanumeric() || (n == 0 && x == b'#')) {
1636            return false;
1637        }
1638    }
1639    false
1640}
1641
1642fn write_dest(out: &mut String, href: &str) {
1643    let mut depth = 0i32;
1644    let mut balanced = true;
1645    for b in href.bytes() {
1646        match b {
1647            b'(' => depth += 1,
1648            b')' => {
1649                depth -= 1;
1650                balanced &= depth >= 0;
1651            }
1652            _ => {}
1653        }
1654    }
1655    // a bare destination cannot start with `<` (it would open the `<…>` form)
1656    let angle = href.is_empty()
1657        || href.starts_with('<')
1658        || depth != 0
1659        || !balanced
1660        || href.bytes().any(|b| b <= b' ' || b == 0x7f);
1661    if angle {
1662        out.push('<');
1663        for c in href.chars() {
1664            match c {
1665                '<' | '>' | '\\' => {
1666                    out.push('\\');
1667                    out.push(c);
1668                }
1669                '\n' => out.push_str("%0A"),
1670                '\r' => out.push_str("%0D"),
1671                _ => out.push(c),
1672            }
1673        }
1674        out.push('>');
1675    } else {
1676        for c in href.chars() {
1677            if c == '\\' {
1678                out.push('\\');
1679            }
1680            out.push(c);
1681        }
1682    }
1683}
1684
1685fn write_title(out: &mut String, t: &str) {
1686    if t.is_empty() {
1687        return;
1688    }
1689    out.push_str(" \"");
1690    for c in t.chars() {
1691        match c {
1692            '"' | '\\' => {
1693                out.push('\\');
1694                out.push(c);
1695            }
1696            '\n' | '\r' => out.push(' '),
1697            _ => out.push(c),
1698        }
1699    }
1700    out.push('"');
1701}