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:
parent
f400eb2701
commit
24df393c36
|
|
@ -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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
}
|
||||
|
||||
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 & <world>"));
|
||||
}
|
||||
|
||||
#[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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue