Files
ElyOffice/apps/ely-mark/src/output/html.rs
T
ZacharyZhang-NYandClaude Opus 5.5 5958e73ade R6.7: ElyMark keeps a single line break on screen
Component 93efa7f draws soft breaks as lines (Typora's default when
writing); exports and copies read with MarkdownReader::joined, as GFM.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 12:10:41 -04:00

487 lines
17 KiB
Rust

//! Markdown as HTML, as the drawing reads it.
use std::{
cmp::Reverse,
collections::{HashSet, VecDeque},
io,
ops::Range,
};
use ely_gpui_component::{
documents::{
AlertKind, Inline, MarkdownBlock, MarkdownReader, Style, alert, diagram_svg, is_toc,
},
typography::formula_svg,
};
use markdown::mdast::{AlignKind, Node};
use super::{Page, address::safe, attribute, escape, pictures::Placed};
/// Writes `tree`, read from `source`, into `page`.
pub(super) fn write(
tree: &Node,
source: &str,
placed: &mut Placed,
page: &mut Page,
) -> io::Result<()> {
let mut writer = Writer {
placed,
page,
source,
reader: MarkdownReader::joined(source, tree),
anchored: HashSet::new(),
};
writer.root(tree)
}
struct Writer<'a, 'b, 'c> {
placed: &'b mut Placed<'a>,
page: &'c mut Page,
source: &'c str,
reader: MarkdownReader<'c>,
/// Notes whose definition has its anchor.
anchored: HashSet<usize>,
}
/// What a read area leaves out, in reading order.
#[derive(Default)]
struct Atoms {
/// Each formula's line.
formulas: VecDeque<usize>,
/// Each footnote reference's number.
notes: VecDeque<usize>,
}
/// A stretch of read text's mark.
enum Mark<'a> {
Style(Style),
Link(&'a str),
}
impl Writer<'_, '_, '_> {
fn push(&mut self, text: &str) {
self.page.html.push_str(text);
}
/// Root blocks: alerts and `[toc]` live only here.
fn root(&mut self, tree: &Node) -> io::Result<()> {
for child in tree.children().into_iter().flatten() {
match child {
Node::Blockquote(quote) => match alert(quote) {
Some((kind, body)) => self.alert(kind, &body)?,
None => self.block(child, false)?,
},
_ if is_toc(child, self.source) => self.toc(),
_ => self.block(child, false)?,
}
}
Ok(())
}
fn blocks(&mut self, node: &Node, tight: bool) -> io::Result<()> {
for child in node.children().into_iter().flatten() {
self.block(child, tight)?;
}
Ok(())
}
fn block(&mut self, node: &Node, tight: bool) -> io::Result<()> {
match node {
Node::Paragraph(_) | Node::Heading(_) | Node::Table(_) | Node::Html(_) => {
self.area(node, tight)?
}
Node::Blockquote(_) => {
self.push("<blockquote>");
self.blocks(node, false)?;
self.push("</blockquote>");
}
Node::List(list) => {
let tag = if list.ordered { "ol" } else { "ul" };
match list.start.filter(|start| list.ordered && *start != 1) {
Some(start) => self.push(&format!("<ol start=\"{start}\">")),
None => self.push(&format!("<{tag}>")),
}
for item in &list.children {
let Node::ListItem(each) = item else { continue };
self.push("<li>");
if let Some(checked) = each.checked {
let mark = if checked { " checked" } else { "" };
self.push(&format!("<input type=\"checkbox\" disabled{mark}> "));
}
self.blocks(item, !list.spread && !each.spread)?;
self.push("</li>");
}
self.push(&format!("</{tag}>"));
}
Node::Code(code) if code.lang.as_deref() == Some("mermaid") => {
match diagram_svg(&code.value) {
Ok(svg) => self.push(&format!("<div class=\"diagram\">{}</div>", bare(&svg))),
Err(_) => {
self.page.diagrams.push(line_of(node));
self.code(Some("mermaid"), &code.value);
}
}
}
Node::Code(code) => self.code(code.lang.as_deref(), &code.value),
Node::Math(math) => self.math(line_of(node), &math.value, true),
Node::ThematicBreak(_) => self.push("<hr>"),
Node::FootnoteDefinition(note) => {
// A reference reaches its note's first definition.
let id = (self.reader.note(&note.identifier))
.filter(|number| self.anchored.insert(*number))
.map_or_else(String::new, |number| format!(" id=\"fn-{number}\""));
self.push(&format!(
"<div{id} style=\"display:flex;gap:.5em;font-size:.9em;color:#59636e\"><span>[{}]</span><div>",
escape(&self.reader.label(note))
));
self.blocks(node, false)?;
self.push("</div></div>");
}
// Never written.
Node::Yaml(_) | Node::Toml(_) | Node::Definition(_) => {}
other => unreachable!("{other:?} is no block outside MDX"),
}
Ok(())
}
/// A paragraph, heading, table or HTML block, read as drawn.
fn area(&mut self, node: &Node, tight: bool) -> io::Result<()> {
let mut read = Vec::new();
self.reader.block(node, &mut read);
if let Node::Table(_) = node {
let [MarkdownBlock::Table { aligns, head, rows }] = read.as_slice() else {
unreachable!("a table reads as one table");
};
return self.table(node, aligns, head, rows);
}
// A heading's pictures follow it whole.
let split = !matches!(node, Node::Heading(_));
let stretches = self.stretches(node, split);
self.run(&read, node, tight, split, stretches)
}
/// Pieces in order; with `split`, each picture ends a stretch.
fn run(
&mut self,
pieces: &[MarkdownBlock],
node: &Node,
tight: bool,
split: bool,
mut stretches: VecDeque<Atoms>,
) -> io::Result<()> {
let mut written = false;
for piece in pieces {
match piece {
MarkdownBlock::Image { alt, url, link, .. } => {
self.picture(url, alt, link.as_deref())?;
if split {
retire(stretches.pop_front(), written);
written = false;
}
}
_ => {
let atoms = stretches.front_mut().expect("a stretch per text");
self.piece(piece, node, tight, atoms);
written = true;
}
}
}
retire(stretches.pop_front(), written);
assert!(stretches.is_empty(), "a stretch per picture");
Ok(())
}
/// Formulas and notes by stretch; pictures part them.
fn stretches(&self, node: &Node, split: bool) -> VecDeque<Atoms> {
let mut out = VecDeque::from([Atoms::default()]);
self.gather(node, split, &mut out);
out
}
fn gather(&self, node: &Node, split: bool, out: &mut VecDeque<Atoms>) {
match node {
Node::InlineMath(_) => {
(out.back_mut().expect("a stretch").formulas).push_back(line_of(node))
}
Node::FootnoteReference(call) => {
let number = self
.reader
.note(&call.identifier)
.expect("a reference names a note");
out.back_mut().expect("a stretch").notes.push_back(number);
}
Node::Image(_) | Node::ImageReference(_) if split => out.push_back(Atoms::default()),
_ => {}
}
for child in node.children().into_iter().flatten() {
self.gather(child, split, out);
}
}
fn table(
&mut self,
node: &Node,
aligns: &[AlignKind],
head: &[Vec<MarkdownBlock>],
rows: &[Vec<Vec<MarkdownBlock>>],
) -> io::Result<()> {
let written = node.children().expect("a table holds rows");
let read: Vec<&[Vec<MarkdownBlock>]> = std::iter::once(head)
.chain(rows.iter().map(Vec::as_slice))
.collect();
assert_eq!(read.len(), written.len(), "a read row per row");
self.push("<table>");
for (at, (row, cells)) in read.into_iter().zip(written).enumerate() {
let tag = if at == 0 { "th" } else { "td" };
let cells = cells.children().expect("a row holds cells");
assert_eq!(row.len(), cells.len(), "a read cell per cell");
self.push("<tr>");
for (column, (pieces, cell)) in row.iter().zip(cells).enumerate() {
let side = match aligns.get(column) {
Some(AlignKind::Left) => " style=\"text-align:left\"",
Some(AlignKind::Right) => " style=\"text-align:right\"",
Some(AlignKind::Center) => " style=\"text-align:center\"",
_ => "",
};
self.push(&format!("<{tag}{side}>"));
let stretches = self.stretches(cell, true);
self.run(pieces, cell, true, true, stretches)?;
self.push(&format!("</{tag}>"));
}
self.push("</tr>");
}
self.push("</table>");
Ok(())
}
fn piece(&mut self, piece: &MarkdownBlock, node: &Node, tight: bool, atoms: &mut Atoms) {
match piece {
MarkdownBlock::Paragraph(text) if tight => self.inline(text, atoms),
MarkdownBlock::Paragraph(text) => {
self.push("<p>");
self.inline(text, atoms);
self.push("</p>");
}
MarkdownBlock::Heading(depth, text) => {
let at = node.position().expect("a parsed node has its place");
self.push(&format!("<h{depth} id=\"h-{}\">", at.start.offset));
self.inline(text, atoms);
self.push(&format!("</h{depth}>"));
}
MarkdownBlock::Math(tex) => {
let line = atoms.formulas.pop_front().expect("a formula's line");
self.math(line, tex, true);
}
other => unreachable!("an area's text reads as text or math, not {other:?}"),
}
}
/// Read text; its marks open and close as a stack.
fn inline(&mut self, read: &Inline, atoms: &mut Atoms) {
let mut marks: Vec<(&Range<usize>, Mark)> = (read.styles.iter())
.filter(|(_, style)| *style != Style::Link)
.map(|(range, style)| (range, Mark::Style(*style)))
.chain(
read.links
.iter()
.map(|(range, url)| (range, Mark::Link(url))),
)
.collect();
// Outer first; a formula or note is its own text.
marks.sort_by_key(|(range, mark)| {
let whole = matches!(mark, Mark::Style(Style::Math | Style::Footnote));
(range.start, Reverse(range.end), whole)
});
let (mut open, mut at): (Vec<(usize, &str)>, usize) = (Vec::new(), 0);
let mut links = 0;
for (range, mark) in marks {
links -= self.close_to(read, range.start, &mut open, &mut at);
assert!(
range.start >= at && open.last().is_none_or(|(end, _)| range.end <= *end),
"read marks nest"
);
self.text(&read.text[at..range.start]);
at = range.start;
match mark {
Mark::Style(Style::Math) => {
let line = atoms.formulas.pop_front().expect("a formula's line");
self.math(line, &read.text[range.clone()], false);
at = range.end;
}
Mark::Style(Style::Footnote) => {
let number = atoms.notes.pop_front().expect("a note's number");
// Links never nest.
match links > 0 {
true => self.push(&format!("<sup>{number}</sup>")),
false => {
self.push(&format!("<sup><a href=\"#fn-{number}\">{number}</a></sup>"))
}
}
at = range.end;
}
Mark::Style(style) => {
let name = tag(style);
self.push(&format!("<{name}>"));
open.push((range.end, name));
}
Mark::Link(url) => {
match safe(url) {
true => self.push(&format!("<a href=\"{}\">", attribute(url))),
false => self.push("<a>"),
}
open.push((range.end, "a"));
links += 1;
}
}
}
self.close_to(read, read.text.len(), &mut open, &mut at);
self.text(&read.text[at..]);
}
/// Closes each mark ending by `to`; counts closed links.
fn close_to(
&mut self,
read: &Inline,
to: usize,
open: &mut Vec<(usize, &str)>,
at: &mut usize,
) -> usize {
let mut links = 0;
while let Some(&(end, name)) = open.last().filter(|(end, _)| *end <= to) {
self.text(&read.text[*at..end]);
self.push(&format!("</{name}>"));
*at = end;
open.pop();
links += usize::from(name == "a");
}
links
}
fn text(&mut self, text: &str) {
self.push(&escape(text).replace('\n', "<br>"));
}
fn code(&mut self, lang: Option<&str>, value: &str) {
let class = lang.map_or_else(String::new, |lang| {
format!(" class=\"language-{}\"", attribute(lang))
});
self.push(&format!("<pre><code{class}>{}</code></pre>", escape(value)));
}
fn math(&mut self, line: usize, value: &str, display: bool) {
match (formula_svg(value, display), display) {
(Ok(svg), true) => self.push(&format!("<div class=\"math\">{}</div>", bare(&svg))),
(Ok(svg), false) => self.push(&format!("<span class=\"math\">{}</span>", bare(&svg))),
(Err(error), _) => {
self.page.formulas.push((line, error.to_string()));
match display {
true => self.code(Some("math"), value),
false => self.push(&format!("<code>{}</code>", escape(value))),
}
}
}
}
/// A GitHub alert: its kind's title, then its blocks.
fn alert(&mut self, kind: AlertKind, body: &[Node]) -> io::Result<()> {
let (title, hue) = (kind.title(), hue(kind));
self.push(&format!(
"<div style=\"padding:.25em 1em;margin:1em 0;border-left:.25em solid {hue}\"><p style=\"font-weight:600;color:{hue}\">{title}</p>"
));
for node in body {
self.block(node, false)?;
}
self.push("</div>");
Ok(())
}
/// `[toc]`: each heading, linked to its place.
fn toc(&mut self) {
let items: String = (self.reader.headings.iter())
.map(|heading| {
format!(
"<li style=\"margin-left:{}em\"><a href=\"#h-{}\">{}</a></li>",
heading.depth - 1,
heading.at,
escape(&heading.title)
)
})
.collect();
self.push(&format!(
"<ul style=\"list-style:none;padding-left:0\">{items}</ul>"
));
}
fn picture(&mut self, url: &str, alt: &str, link: Option<&str>) -> io::Result<()> {
let src = self.placed.place(url)?;
if src.is_none() {
self.page.missing.push(if url.is_empty() {
alt.to_owned()
} else {
url.to_owned()
});
}
// A PDF shows what it left out as words.
let Some(src) = src.or_else(|| (!self.placed.pdf()).then(|| url.to_owned())) else {
self.push(&format!("[{}]", escape(alt)));
return Ok(());
};
let image = format!(
"<img src=\"{}\" alt=\"{}\">",
attribute(&src),
attribute(alt)
);
match link {
Some(link) if safe(link) => {
self.push(&format!("<a href=\"{}\">{image}</a>", attribute(link)))
}
Some(_) => self.push(&format!("<a>{image}</a>")),
None => self.push(&image),
}
Ok(())
}
}
/// A stretch done: all read, or blank and dropped.
fn retire(atoms: Option<Atoms>, written: bool) {
let atoms = atoms.expect("a stretch per picture");
assert!(
atoms.notes.is_empty() && (!written || atoms.formulas.is_empty()),
"every formula and note is read"
);
}
/// GitHub's color for an alert kind.
fn hue(kind: AlertKind) -> &'static str {
match kind {
AlertKind::Note => "#0969da",
AlertKind::Tip => "#1a7f37",
AlertKind::Important => "#8250df",
AlertKind::Warning => "#9a6700",
AlertKind::Caution => "#d1242f",
}
}
/// The tag a read style writes.
fn tag(style: Style) -> &'static str {
match style {
Style::Strong => "strong",
Style::Emphasis => "em",
Style::Strike => "del",
Style::Code => "code",
Style::Underline => "u",
Style::Mark => "mark",
Style::Link | Style::Math | Style::Footnote => unreachable!("{style:?} writes no tag"),
}
}
/// The line a node starts on, from one.
fn line_of(node: &Node) -> usize {
node.position().map_or(0, |at| at.start.line)
}
/// An SVG without its XML declaration.
fn bare(svg: &str) -> &str {
svg.find("<svg").map_or(svg, |at| &svg[at..])
}