use std::collections::HashMap;
use devup_editor_core::{
Block, BlockId, Document, DocumentExport, DocumentImport, IdGenerator, Mark, TextSpan,
};
use serde_json::Value;
use crate::HtmlError;
use crate::clipboard::{CopiedBlocks, DEVUP_PROPS_ATTR, encode_props};
use crate::import::parse_html;
pub struct Html;
impl DocumentExport for Html {
type Output = String;
type Error = HtmlError;
fn export(doc: &Document) -> Result<String, HtmlError> {
let copied = document_to_copied_blocks(doc);
Ok(serialize_roots(&copied.roots, &copied.by_id))
}
}
impl DocumentImport for Html {
type Input = String;
type Error = HtmlError;
fn import(input: String, id_gen: &mut dyn IdGenerator) -> Result<Document, HtmlError> {
let copied = parse_html(&input, id_gen);
let mut doc = Document::new();
for root in copied.roots {
doc.push_root_block(root);
}
Ok(doc)
}
}
fn populate_map(doc: &Document, block: &Block, by_id: &mut HashMap<BlockId, Block>) {
if by_id.contains_key(&block.id) {
return;
}
by_id.insert(block.id.clone(), block.clone());
for child_id in &block.children {
if let Some(child) = doc.get_block(child_id) {
populate_map(doc, child, by_id);
}
}
}
#[must_use]
#[allow(clippy::implicit_hasher)]
pub fn document_to_copied_blocks(doc: &Document) -> CopiedBlocks {
let mut by_id: HashMap<BlockId, Block> = HashMap::new();
let roots: Vec<Block> = doc
.root_block_ids()
.iter()
.filter_map(|id| doc.get_block(id).cloned())
.collect();
for block in &roots {
populate_map(doc, block, &mut by_id);
}
CopiedBlocks { roots, by_id }
}
#[must_use]
#[allow(clippy::implicit_hasher)]
pub fn blocks_to_html(roots: &[Block], by_id: &HashMap<BlockId, Block>) -> String {
serialize_roots(roots, by_id)
}
#[must_use]
pub fn copied_blocks_to_html(copied: &CopiedBlocks) -> String {
blocks_to_html(&copied.roots, &copied.by_id)
}
fn serialize_roots(roots: &[Block], by_id: &HashMap<BlockId, Block>) -> String {
let mut cursor = 0usize;
let mut out = String::new();
emit_siblings(roots, by_id, &mut cursor, 0, &mut out);
out
}
fn emit_siblings(
roots: &[Block],
by_id: &HashMap<BlockId, Block>,
cursor: &mut usize,
stop_indent: i64,
out: &mut String,
) {
while *cursor < roots.len() {
let block = &roots[*cursor];
let indent = block.indent_level().max(0);
if indent < stop_indent {
break;
}
*cursor += 1;
if block.ty == "toggle" {
let title = render_inline(&block.content);
out.push_str(r#"<ul class="toggle"><li><details open=""><summary>"#);
out.push_str(&title);
out.push_str("</summary>");
emit_siblings(roots, by_id, cursor, indent + 1, out);
out.push_str("</details></li></ul>");
} else {
write_block_html(block, by_id, out);
}
}
}
fn write_block_html(block: &Block, by_id: &HashMap<BlockId, Block>, out: &mut String) {
match block.ty.as_str() {
"heading" => {
let level = block
.props
.get("level")
.and_then(Value::as_u64)
.unwrap_or(1)
.clamp(1, 6);
out.push('<');
out.push('h');
out.push(digit(level));
out.push('>');
out.push_str(&render_inline(&block.content));
out.push_str("</h");
out.push(digit(level));
out.push('>');
}
"quote" => {
out.push_str("<blockquote>");
out.push_str(&render_inline(&block.content));
out.push_str("</blockquote>");
}
"todo" => {
let checked = block
.props
.get("checked")
.and_then(Value::as_bool)
.unwrap_or(false);
out.push_str("<p data-type=\"todo\" data-checked=\"");
out.push_str(if checked { "true" } else { "false" });
out.push_str("\">");
out.push_str(&render_inline(&block.content));
out.push_str("</p>");
}
"list" => {
let style = block
.props
.get("style")
.and_then(Value::as_str)
.unwrap_or("unordered");
let tag = if style.starts_with("ordered") {
"ol"
} else {
"ul"
};
out.push('<');
out.push_str(tag);
out.push_str("><li>");
out.push_str(&render_inline(&block.content));
out.push_str("</li></");
out.push_str(tag);
out.push('>');
}
"code" => {
let lang = block
.props
.get("language")
.and_then(Value::as_str)
.unwrap_or("");
let plain = block.plain_text();
out.push_str("<pre><code");
if !lang.is_empty() {
out.push_str(" class=\"language-");
out.push_str(&escape_attr(lang));
out.push('"');
}
out.push('>');
out.push_str(&escape_text(&plain));
out.push_str("</code></pre>");
}
"divider" => {
out.push_str("<hr>");
}
"table" => {
write_table(block, by_id, out);
}
_ => {
out.push_str("<p>");
out.push_str(&render_inline(&block.content));
out.push_str("</p>");
}
}
}
fn digit(n: u64) -> char {
match n {
1 => '1',
2 => '2',
3 => '3',
4 => '4',
5 => '5',
_ => '6',
}
}
fn write_table(table: &Block, by_id: &HashMap<BlockId, Block>, out: &mut String) {
let mut rows_html = String::new();
for row_id in &table.children {
let Some(row) = by_id.get(row_id) else {
continue;
};
let mut cells_html = String::new();
for cell_id in &row.children {
let Some(cell) = by_id.get(cell_id) else {
continue;
};
write_cell(cell, &mut cells_html);
}
write_row(row, &cells_html, &mut rows_html);
}
let colgroup = serialize_colgroup(table);
let mut attrs = TableAttrs::new();
attrs.push_style(&inline_cell_style(&table.props));
attrs.push_marker(&encode_props(Some(&table.props)));
out.push_str("<table");
attrs.write_into(out);
out.push('>');
out.push_str(&colgroup);
out.push_str("<tbody>");
out.push_str(&rows_html);
out.push_str("</tbody></table>");
}
fn write_row(row: &Block, inner_cells: &str, out: &mut String) {
let mut attrs = TableAttrs::new();
let height = match row.props.get("height") {
Some(Value::Number(n)) => n.as_f64().map(format_px),
Some(Value::String(s)) => Some(s.clone()),
_ => None,
};
if let Some(h) = height {
attrs.push_style(&format!("height:{h}"));
}
attrs.push_marker(&encode_props(Some(&row.props)));
out.push_str("<tr");
attrs.write_into(out);
out.push('>');
out.push_str(inner_cells);
out.push_str("</tr>");
}
fn write_cell(cell: &Block, out: &mut String) {
let mut attrs = TableAttrs::new();
if let Some(n) = cell.props.get("colspan").and_then(Value::as_u64)
&& n > 1
{
attrs.push_raw(&format!("colspan=\"{n}\""));
}
if let Some(n) = cell.props.get("rowspan").and_then(Value::as_u64)
&& n > 1
{
attrs.push_raw(&format!("rowspan=\"{n}\""));
}
attrs.push_style(&inline_cell_style(&cell.props));
attrs.push_marker(&encode_props(Some(&cell.props)));
out.push_str("<td");
attrs.write_into(out);
out.push('>');
out.push_str(&render_inline(&cell.content));
out.push_str("</td>");
}
fn serialize_colgroup(table: &Block) -> String {
let Some(Value::Array(cols)) = table.props.get("columns") else {
return String::new();
};
if cols.is_empty() {
return String::new();
}
let mut s = String::from("<colgroup>");
for col in cols {
let width = col.get("width").and_then(|v| match v {
Value::Number(n) => n.as_f64().map(format_px),
Value::String(raw) => Some(raw.clone()),
_ => None,
});
match width {
Some(w) => {
s.push_str("<col style=\"width:");
s.push_str(&escape_attr(&w));
s.push_str("\">");
}
None => s.push_str("<col>"),
}
}
s.push_str("</colgroup>");
s
}
struct TableAttrs {
parts: Vec<String>,
styles: Vec<String>,
marker: String,
}
impl TableAttrs {
fn new() -> Self {
Self {
parts: Vec::new(),
styles: Vec::new(),
marker: String::new(),
}
}
fn push_style(&mut self, s: &str) {
if !s.is_empty() {
self.styles.push(s.to_string());
}
}
fn push_marker(&mut self, marker: &str) {
if !marker.is_empty() {
self.marker = marker.to_string();
}
}
fn push_raw(&mut self, raw: &str) {
self.parts.push(raw.to_string());
}
fn write_into(&self, out: &mut String) {
for p in &self.parts {
out.push(' ');
out.push_str(p);
}
if !self.styles.is_empty() {
out.push_str(" style=\"");
out.push_str(&escape_attr(&self.styles.join(";")));
out.push('"');
}
if !self.marker.is_empty() {
out.push(' ');
out.push_str(DEVUP_PROPS_ATTR);
out.push_str("=\"");
out.push_str(&escape_attr(&self.marker));
out.push('"');
}
}
}
fn inline_cell_style(props: &serde_json::Map<String, Value>) -> String {
let mut parts: Vec<String> = Vec::new();
if let Some(v) = props.get("backgroundColor").and_then(Value::as_str) {
parts.push(format!("background-color:{v}"));
}
if let Some(v) = props.get("borderColor").and_then(Value::as_str) {
parts.push(format!("border-color:{v}"));
}
if let Some(v) = props.get("borderWidth").and_then(Value::as_str) {
parts.push(format!("border-width:{v}"));
}
if let Some(v) = props.get("borderStyle").and_then(Value::as_str) {
parts.push(format!("border-style:{v}"));
}
if let Some(v) = props.get("verticalAlign").and_then(Value::as_str) {
parts.push(format!("vertical-align:{v}"));
}
if let Some(v) = props.get("padding") {
let as_str = match v {
Value::String(s) => Some(s.clone()),
Value::Number(n) => n.as_f64().map(format_px),
_ => None,
};
if let Some(s) = as_str {
parts.push(format!("padding:{s}"));
}
}
parts.join(";")
}
fn format_px(v: f64) -> String {
#[allow(clippy::float_cmp)]
let is_integral = v == v.trunc();
if is_integral {
#[allow(clippy::cast_possible_truncation)]
let as_int = v as i64;
format!("{as_int}px")
} else {
format!("{v}px")
}
}
fn render_inline(spans: &[TextSpan]) -> String {
let mut out = String::new();
for span in spans {
out.push_str(&apply_marks(&span.text, &span.marks));
}
out
}
fn apply_marks(text: &str, marks: &[Mark]) -> String {
let escaped = escape_text(text).replace('\n', "<br>");
let mut out = escaped;
let has = |t: &str| marks.iter().any(|m| m.ty == t);
if has("code") {
out = format!("<code>{out}</code>");
}
if has("strike") {
out = format!("<s>{out}</s>");
}
if has("underline") {
out = format!("<u>{out}</u>");
}
if has("italic") {
out = format!("<em>{out}</em>");
}
if has("bold") {
out = format!("<strong>{out}</strong>");
}
let mut style_parts: Vec<String> = Vec::new();
if let Some(c) = style_value(marks, "color", "color") {
style_parts.push(format!("color:{}", sanitize_css(c)));
}
if let Some(bg) = style_value(marks, "highlight", "backgroundColor") {
style_parts.push(format!("background-color:{}", sanitize_css(bg)));
}
if !style_parts.is_empty() {
out = format!(
"<span style=\"{}\">{out}</span>",
escape_attr(&style_parts.join(";"))
);
}
if let Some(href) = link_href(marks) {
out = format!(
"<a href=\"{}\" rel=\"noopener noreferrer\">{out}</a>",
escape_attr(href)
);
}
for mark in marks {
if !is_known_mark(&mark.ty) {
out = format!("<span data-mark=\"{}\">{out}</span>", escape_attr(&mark.ty));
}
}
out
}
fn is_known_mark(ty: &str) -> bool {
matches!(
ty,
"bold" | "italic" | "underline" | "strike" | "code" | "link" | "color" | "highlight"
)
}
fn style_value<'a>(marks: &'a [Mark], mark_type: &str, key: &str) -> Option<&'a str> {
marks.iter().find(|m| m.ty == mark_type).and_then(|mark| {
mark.style()
.and_then(|style| style.get(key))
.and_then(Value::as_str)
})
}
fn link_href(marks: &[Mark]) -> Option<&str> {
marks.iter().find(|m| m.ty == "link").and_then(|mark| {
mark.attrs
.get("href")
.and_then(Value::as_str)
.filter(|href| is_safe_href(href))
})
}
pub(crate) fn is_safe_href(href: &str) -> bool {
let trimmed = href.trim().to_ascii_lowercase();
if trimmed.is_empty() {
return false;
}
if trimmed.starts_with("javascript:")
|| trimmed.starts_with("vbscript:")
|| trimmed.starts_with("file:")
{
return false;
}
if trimmed.starts_with("data:") && !trimmed.starts_with("data:image/") {
return false;
}
true
}
fn sanitize_css(s: &str) -> String {
s.chars()
.filter(|c| *c != '"' && *c != '\\' && *c != '\n' && *c != '\r')
.collect()
}
fn escape_text(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
_ => out.push(c),
}
}
out
}
fn escape_attr(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
_ => out.push(c),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use devup_editor_core::{TextSpan, model::block::Block};
#[test]
fn document_to_copied_blocks_flat_document() {
let mut doc = Document::new();
let mut p1 = Block::new_paragraph(BlockId::new("p1"));
p1.content = vec![TextSpan::plain("first")];
let mut p2 = Block::new_paragraph(BlockId::new("p2"));
p2.content = vec![TextSpan::plain("second")];
doc.push_root_block(p1);
doc.push_root_block(p2);
let copied = document_to_copied_blocks(&doc);
assert_eq!(copied.roots.len(), 2);
assert_eq!(copied.by_id.len(), 2);
assert!(copied.by_id.contains_key(&BlockId::new("p1")));
assert!(copied.by_id.contains_key(&BlockId::new("p2")));
}
#[test]
fn document_to_copied_blocks_preserves_table_children() {
let mut doc = Document::new();
let cell_id = BlockId::new("c1");
let row_id = BlockId::new("r1");
let table_id = BlockId::new("t1");
let mut cell = Block::new(cell_id.clone(), "table_cell");
cell.content = vec![TextSpan::plain("hi")];
cell.parent = Some(row_id.clone());
let mut row = Block::new(row_id.clone(), "table_row");
row.children = vec![cell_id.clone()];
row.parent = Some(table_id.clone());
let mut table = Block::new(table_id.clone(), "table");
table.children = vec![row_id.clone()];
doc.push_root_block(table);
doc.push_root_block(row);
doc.push_root_block(cell);
let copied = document_to_copied_blocks(&doc);
assert!(copied.by_id.contains_key(&table_id));
assert!(copied.by_id.contains_key(&row_id));
assert!(copied.by_id.contains_key(&cell_id));
}
#[test]
fn document_to_copied_blocks_empty() {
let doc = Document::new();
let copied = document_to_copied_blocks(&doc);
assert!(copied.roots.is_empty());
assert!(copied.by_id.is_empty());
}
#[test]
fn document_to_copied_blocks_cycle_safe() {
let mut doc = Document::new();
let mut b = Block::new(BlockId::new("x"), "paragraph");
b.children = vec![BlockId::new("x")];
doc.push_root_block(b);
let copied = document_to_copied_blocks(&doc);
assert_eq!(copied.by_id.len(), 1);
}
}