From 24df393c3635efa1d27c8edb2fa028af39e26f27 Mon Sep 17 00:00:00 2001 From: Kayshen-X Date: Thu, 14 May 2026 15:25:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(shell-core/codegen):=20HTML=20generator=20?= =?UTF-8?q?=E2=80=94=202=20of=209=20targets=20shipped?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `codegen::Html` alongside the existing `CssVariables` generator. Walks `doc.pages[active].children` and emits absolute-positioned `
` per Rect/Frame/Group + `` 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 `
` with left/top/width/height + `rgb(r,g,b)` fill - Text emits `` 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. --- crates/openpencil-shell-core/src/codegen.rs | 157 +++++++++++++++++++- 1 file changed, 156 insertions(+), 1 deletion(-) diff --git a/crates/openpencil-shell-core/src/codegen.rs b/crates/openpencil-shell-core/src/codegen.rs index 768eeb93a..5f56ced52 100644 --- a/crates/openpencil-shell-core/src/codegen.rs +++ b/crates/openpencil-shell-core/src/codegen.rs @@ -6,7 +6,8 @@ //! the same `Codegen` trait so a single dispatcher (eventually a //! `op codegen ` 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 `
` per Rect/Frame/Group with absolute positioning +/// and inline style; ellipses get `border-radius: 50%`; text nodes +/// emit ``. 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("\n"); + out.push_str("\n\n\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("\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}\">\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!("\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("")); + assert!(html.contains("")); + assert!(html.contains("")); + 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 & ".into()); + page.children.push(t); + let html = Html.generate(&doc); + assert!(html.contains("