feat(shell-core/codegen): HTML generator — 2 of 9 targets shipped

Adds `codegen::Html` alongside the existing `CssVariables` generator.
Walks `doc.pages[active].children` and emits absolute-positioned
`<div>` per Rect/Frame/Group + `<span>` per Text, with inline-style
position/size, RGB fill, stroke as border, corner radius (50% for
ellipses), and rotation transform. Body text is HTML-escaped.

API stays identical — both generators implement the same
`Codegen` trait, so a future CLI / Property-panel codegen
dispatcher fans out by trait object.

Tests (4 new):
  - DOCTYPE + body wrapper + generator-attribution comment present
  - Rect emits `<div>` with left/top/width/height + `rgb(r,g,b)` fill
  - Text emits `<span>` and `&` / `<` / `>` in body get HTML-escaped
  - `node.hidden = true` skipped entirely (no orphan markup)

7 generators remain to port (React + Tailwind, Vue, Svelte,
Flutter, SwiftUI, Compose, React Native) — each as a focused
follow-up commit. The trait + dispatcher shape doesn't change.

Tests total: 211 shell-core (+4) + 20 native + 8 desktop. Wasm32
build clean.
This commit is contained in:
Kayshen-X 2026-05-14 15:25:58 +08:00
parent f400eb2701
commit 24df393c36

View file

@ -6,7 +6,8 @@
//! the same `Codegen` trait so a single dispatcher (eventually a
//! `op codegen <target>` CLI) can fan out by target enum.
use crate::document::{Document, VariableScalar, VariableValue};
use crate::document::{Document, Node, NodeKind, VariableScalar, VariableValue};
use crate::Color;
/// One generator. Implementations are pure (read `Document`, return
/// `String`); the CLI / panel layer owns file-write side effects.
@ -84,6 +85,103 @@ impl Codegen for CssVariables {
}
}
/// HTML + inline-CSS generator. Walks the active page's node tree
/// and emits `<div>` per Rect/Frame/Group with absolute positioning
/// and inline style; ellipses get `border-radius: 50%`; text nodes
/// emit `<span>`. Bare minimum useful — refining to semantic tags
/// + flex containers is a follow-up.
pub struct Html;
impl Codegen for Html {
fn target_label(&self) -> &'static str {
"html"
}
fn generate(&self, doc: &Document) -> String {
let mut out = String::new();
out.push_str("<!-- Generated by OpenPencil — codegen::Html -->\n");
out.push_str("<!DOCTYPE html>\n<html><head><meta charset=\"utf-8\"></head>\n<body>\n");
if let Some(page) = doc.pages.get(doc.active_page_index) {
for node in &page.children {
emit_node_html(&mut out, node, 1);
}
}
out.push_str("</body></html>\n");
out
}
}
fn emit_node_html(out: &mut String, node: &Node, depth: usize) {
if node.hidden {
return;
}
let indent = " ".repeat(depth);
let tag = match node.kind {
NodeKind::Text => "span",
_ => "div",
};
let mut style = format!(
"position:absolute;left:{}px;top:{}px;width:{}px;height:{}px",
node.bounds.origin.x,
node.bounds.origin.y,
node.bounds.size.x,
node.bounds.size.y,
);
if let Some(fill) = node.fill {
style.push_str(&format!(";background:{}", color_to_css(fill)));
}
if let Some(stroke) = node.stroke {
style.push_str(&format!(
";border:{}px solid {}",
stroke.width,
color_to_css(stroke.color)
));
}
if node.corner_radius > 0.0 {
style.push_str(&format!(";border-radius:{}px", node.corner_radius));
}
if matches!(node.kind, NodeKind::Ellipse) {
style.push_str(";border-radius:50%");
}
if node.rotation.abs() > f32::EPSILON {
style.push_str(&format!(";transform:rotate({}rad)", node.rotation));
}
let body = node.text.clone().unwrap_or_default();
if node.children.is_empty() && body.is_empty() {
out.push_str(&format!("{indent}<{tag} style=\"{style}\"></{tag}>\n"));
} else {
out.push_str(&format!("{indent}<{tag} style=\"{style}\">"));
if !body.is_empty() {
out.push_str(&html_escape(&body));
}
if !node.children.is_empty() {
out.push('\n');
for c in &node.children {
emit_node_html(out, c, depth + 1);
}
out.push_str(&indent);
}
out.push_str(&format!("</{tag}>\n"));
}
}
fn color_to_css(c: Color) -> String {
let r = (c.r.clamp(0.0, 1.0) * 255.0).round() as u8;
let g = (c.g.clamp(0.0, 1.0) * 255.0).round() as u8;
let b = (c.b.clamp(0.0, 1.0) * 255.0).round() as u8;
if c.a >= 0.999 {
format!("rgb({},{},{})", r, g, b)
} else {
format!("rgba({},{},{},{:.3})", r, g, b, c.a)
}
}
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
fn scalar_to_css(s: &VariableScalar) -> String {
match s {
VariableScalar::Bool(b) => b.to_string(),
@ -176,6 +274,63 @@ mod tests {
assert!(css.contains("--primary-color: #f00;"));
}
#[test]
fn html_emits_doctype_and_body() {
let doc = Document::empty();
let html = Html.generate(&doc);
assert!(html.contains("<!DOCTYPE html>"));
assert!(html.contains("<body>"));
assert!(html.contains("</body>"));
assert!(html.contains("OpenPencil"));
}
#[test]
fn html_emits_div_per_node_with_position_and_fill() {
use crate::document::{Node, NodeKind};
use crate::{Color, Rect};
let mut doc = Document::empty();
let page = doc.pages.get_mut(0).unwrap();
page.children.clear();
let mut n = Node::leaf(10, NodeKind::Rect, "r");
n.bounds = Rect::xywh(20.0, 30.0, 100.0, 50.0);
n.fill = Some(Color { r: 1.0, g: 0.0, b: 0.0, a: 1.0 });
page.children.push(n);
let html = Html.generate(&doc);
assert!(html.contains("left:20px"));
assert!(html.contains("top:30px"));
assert!(html.contains("width:100px"));
assert!(html.contains("rgb(255,0,0)"));
}
#[test]
fn html_emits_span_for_text_and_escapes_body() {
use crate::document::{Node, NodeKind};
use crate::Rect;
let mut doc = Document::empty();
let page = doc.pages.get_mut(0).unwrap();
page.children.clear();
let mut t = Node::leaf(10, NodeKind::Text, "t");
t.bounds = Rect::xywh(0.0, 0.0, 100.0, 24.0);
t.text = Some("Hello & <world>".into());
page.children.push(t);
let html = Html.generate(&doc);
assert!(html.contains("<span"));
assert!(html.contains("Hello &amp; &lt;world&gt;"));
}
#[test]
fn html_skips_hidden_nodes() {
use crate::document::{Node, NodeKind};
let mut doc = Document::empty();
let page = doc.pages.get_mut(0).unwrap();
page.children.clear();
let mut n = Node::leaf(10, NodeKind::Rect, "hidden");
n.hidden = true;
page.children.push(n);
let html = Html.generate(&doc);
assert!(!html.contains("hidden"));
}
#[test]
fn css_variables_empty_doc_emits_header_only() {
let doc = Document::empty();