diff --git a/crates/op-html/src/layout_heuristics.rs b/crates/op-html/src/layout_heuristics.rs
new file mode 100644
index 000000000..a15e7a750
--- /dev/null
+++ b/crates/op-html/src/layout_heuristics.rs
@@ -0,0 +1,161 @@
+use std::cmp::Ordering;
+
+use jian_ops_schema::node::base::PenNodeBase;
+use jian_ops_schema::node::container::{ContainerProps, LayoutMode};
+use jian_ops_schema::sizing::{SizingBehavior, SizingKeyword};
+
+use crate::css::cascade::ComputedStyle;
+use crate::length::{parse_length, CssLength, LengthCtx};
+
+use super::MapCtx;
+
+pub fn infer_gap_from_margins(
+ child_styles: &[&ComputedStyle],
+ context_font: f64,
+) -> (Option, bool) {
+ if child_styles.len() < 2 {
+ return (None, false);
+ }
+ let context = LengthCtx {
+ font_size: context_font,
+ root_font_size: context_font,
+ viewport_w: 0.0,
+ viewport_h: 0.0,
+ };
+ let margin = |style: &ComputedStyle, name: &str| {
+ style
+ .get(name)
+ .and_then(|value| parse_length(value, &context))
+ .and_then(|length| match length {
+ CssLength::Px(value) => Some(value),
+ CssLength::Percent(_) => None,
+ })
+ .unwrap_or(0.0)
+ };
+ let mut gaps: Vec<_> = child_styles
+ .windows(2)
+ .map(|pair| margin(pair[0], "margin-bottom") + margin(pair[1], "margin-top"))
+ .collect();
+ gaps.sort_by(|left, right| left.partial_cmp(right).unwrap_or(Ordering::Equal));
+ let mut modes: Vec<(f64, usize)> = Vec::new();
+ for gap in &gaps {
+ if let Some((_, count)) = modes.iter_mut().find(|(value, _)| *value == *gap) {
+ *count += 1;
+ } else {
+ modes.push((*gap, 1));
+ }
+ }
+ let mode = modes
+ .into_iter()
+ .max_by(|(left_value, left_count), (right_value, right_count)| {
+ left_count.cmp(right_count).then_with(|| {
+ right_value
+ .partial_cmp(left_value)
+ .unwrap_or(Ordering::Equal)
+ })
+ })
+ .map(|(value, _)| value);
+ let deviated = mode.is_some_and(|mode| gaps.iter().any(|gap| *gap != mode));
+ (mode, deviated)
+}
+
+pub(super) fn apply_sizing_defaults(
+ container: &mut ContainerProps,
+ style: &ComputedStyle,
+ parent: Option<&ComputedStyle>,
+) {
+ let parent_layout = parent.map(layout_for).unwrap_or(LayoutMode::Vertical);
+ if container.width.is_none() && parent.is_some() && parent_layout == LayoutMode::Vertical {
+ container.width = Some(SizingBehavior::Keyword(SizingKeyword::FillContainer));
+ }
+ if container.height.is_none() {
+ container.height = Some(SizingBehavior::Keyword(SizingKeyword::FitContent));
+ }
+ if style
+ .get("flex-grow")
+ .and_then(|value| value.parse::().ok())
+ .is_some_and(|value| value > 0.0)
+ {
+ let fill = Some(SizingBehavior::Keyword(SizingKeyword::FillContainer));
+ if parent_layout == LayoutMode::Horizontal {
+ container.width = fill;
+ } else {
+ container.height = fill;
+ }
+ }
+}
+
+fn layout_for(style: &ComputedStyle) -> LayoutMode {
+ match (style.get("display"), style.get("flex-direction")) {
+ (Some("flex" | "inline-flex"), Some("column" | "column-reverse")) => LayoutMode::Vertical,
+ (Some("flex" | "inline-flex"), _) => LayoutMode::Horizontal,
+ _ => LayoutMode::Vertical,
+ }
+}
+
+pub(super) fn apply_position(
+ base: &mut PenNodeBase,
+ style: &ComputedStyle,
+ context: &mut MapCtx<'_>,
+) {
+ if !matches!(style.get("position"), Some("absolute" | "fixed")) {
+ return;
+ }
+ let (x, x_percent) = position_value(
+ style.get("left"),
+ style.font_size,
+ context.opts.viewport_width,
+ context.opts.base_font_size,
+ );
+ let (y, y_percent) = position_value(
+ style.get("top"),
+ style.font_size,
+ context.opts.viewport_width * 0.625,
+ context.opts.base_font_size,
+ );
+ base.x = x;
+ base.y = y;
+ if x_percent || y_percent {
+ context.warn_once("percentage absolute offsets approximated against the import viewport");
+ }
+}
+
+fn position_value(
+ value: Option<&str>,
+ font_size: f64,
+ viewport: f64,
+ root_font_size: f64,
+) -> (Option, bool) {
+ let Some(value) = value else {
+ return (None, false);
+ };
+ let length = parse_length(
+ value,
+ &LengthCtx {
+ font_size,
+ root_font_size,
+ viewport_w: viewport,
+ viewport_h: viewport,
+ },
+ );
+ match length {
+ Some(CssLength::Px(value)) => (Some(value), false),
+ Some(CssLength::Percent(percent)) => (Some(viewport * percent / 100.0), true),
+ None => (None, false),
+ }
+}
+
+pub(super) fn warn_for_degradations(style: &ComputedStyle, context: &mut MapCtx<'_>) {
+ if style.get("display") == Some("grid") {
+ context.warn_once("CSS grid approximated as a vertical auto-layout frame");
+ }
+ if matches!(style.get("flex-wrap"), Some("wrap" | "wrap-reverse")) {
+ context.warn_once("flex-wrap ignored; imported auto-layout does not wrap");
+ }
+ if style
+ .get("float")
+ .is_some_and(|value| value != "none" && value != "initial")
+ {
+ context.warn_once("CSS float ignored during structured HTML import");
+ }
+}
diff --git a/crates/op-html/src/mapper.rs b/crates/op-html/src/mapper.rs
new file mode 100644
index 000000000..9b0c61076
--- /dev/null
+++ b/crates/op-html/src/mapper.rs
@@ -0,0 +1,745 @@
+use jian_ops_schema::node::base::{NumberOrExpression, PenNodeBase};
+use jian_ops_schema::node::container::{
+ AlignItems, ContainerProps, CornerRadius, JustifyContent, LayoutMode, Padding,
+};
+use jian_ops_schema::node::{FrameNode, ImageSrc, PenNode};
+use jian_ops_schema::sizing::{SizingBehavior, SizingKeyword};
+use jian_ops_schema::style::{
+ GradientStop, ImageFillBody, LinearGradientBody, PenEffect, PenFill, PenStroke,
+ RadialGradientBody, ShadowBody, SolidFillBody, StrokeThickness,
+};
+
+use crate::color::parse_css_color;
+use crate::css::cascade::{compute_style, ComputedStyle, StyleRule};
+use crate::dom::{DomElement, DomNode};
+use crate::length::{parse_length, CssLength, LengthCtx};
+use crate::HtmlImportOptions;
+
+#[path = "layout_heuristics.rs"]
+mod layout_heuristics;
+pub use layout_heuristics::infer_gap_from_margins;
+
+pub struct MapCtx<'a> {
+ pub opts: &'a HtmlImportOptions,
+ pub rules: &'a [StyleRule],
+ pub warnings: Vec,
+ pub next_id: u32,
+ pub node_count: usize,
+}
+
+impl MapCtx<'_> {
+ pub fn generate_id(&mut self) -> String {
+ let id = format!("html_{}", self.next_id);
+ self.next_id = self.next_id.saturating_add(1);
+ id
+ }
+
+ pub fn warn_once(&mut self, message: &str) {
+ if !self.warnings.iter().any(|warning| warning == message) {
+ self.warnings.push(message.to_string());
+ }
+ }
+}
+
+pub fn map_element(
+ context: &mut MapCtx<'_>,
+ path: &[&DomElement],
+ parent_style: Option<&ComputedStyle>,
+) -> Option {
+ let element = *path.last()?;
+ let style = compute_style(
+ path,
+ context.rules,
+ parent_style,
+ context.opts.base_font_size,
+ );
+ if style.get("display") == Some("none") {
+ return None;
+ }
+ layout_heuristics::warn_for_degradations(&style, context);
+ if let Some(mapped) = crate::special::map_radio_group(context, path, &style)
+ .or_else(|| crate::special::map_special(context, element, &style))
+ {
+ return mapped;
+ }
+ let children = crate::text::map_children(context, path, &style, &element.children);
+ let mut container = container_props_from(&style, context);
+ if container.gap.is_none() {
+ let child_styles: Vec<_> = element
+ .children
+ .iter()
+ .filter_map(|child| match child {
+ DomNode::Element(child_element) => {
+ let mut child_path = path.to_vec();
+ child_path.push(child_element);
+ Some(compute_style(
+ &child_path,
+ context.rules,
+ Some(&style),
+ context.opts.base_font_size,
+ ))
+ }
+ DomNode::Text(_) => None,
+ })
+ .collect();
+ let refs: Vec<_> = child_styles.iter().collect();
+ let (gap, deviated) = infer_gap_from_margins(&refs, style.font_size);
+ container.gap = gap.map(NumberOrExpression::Number);
+ if deviated {
+ context.warn_once("mixed adjacent margins approximated using the most common gap");
+ }
+ }
+ layout_heuristics::apply_sizing_defaults(&mut container, &style, parent_style);
+ let mut base = PenNodeBase {
+ id: context.generate_id(),
+ name: Some(element.tag.clone()),
+ role: ((element.tag == "button") || element.attr("role") == Some("button"))
+ .then_some("button".to_string()),
+ ..Default::default()
+ };
+ apply_base_style(&mut base, &style, context);
+ context.node_count += 1;
+ Some(frame(base, container, children))
+}
+
+pub fn container_props_from(style: &ComputedStyle, context: &mut MapCtx<'_>) -> ContainerProps {
+ let layout = match (style.get("display"), style.get("flex-direction")) {
+ (Some("flex" | "inline-flex"), Some("column" | "column-reverse")) => LayoutMode::Vertical,
+ (Some("flex" | "inline-flex"), _) => LayoutMode::Horizontal,
+ _ => LayoutMode::Vertical,
+ };
+ let gap = style
+ .get("gap")
+ .and_then(|value| length_px(value, style.font_size, context.opts))
+ .map(NumberOrExpression::Number);
+ let padding = map_padding(style, context.opts);
+ let justify_content = style.get("justify-content").and_then(map_justify);
+ let align_items = style.get("align-items").and_then(AlignItems::from_css);
+ let width = style
+ .get("width")
+ .and_then(|value| map_sizing(value, style.font_size, context.opts));
+ let height = style
+ .get("height")
+ .and_then(|value| map_sizing(value, style.font_size, context.opts));
+ let fill = map_fill(style);
+ let stroke = map_stroke(style, context);
+ let corner_radius = style
+ .get("border-radius")
+ .and_then(|value| map_corner_radius(value, style.font_size, context.opts));
+ let effects = style.get("box-shadow").and_then(map_shadows);
+ ContainerProps {
+ width,
+ height,
+ layout: Some(layout),
+ gap,
+ padding,
+ justify_content,
+ align_items,
+ clip_content: (style.get("overflow") == Some("hidden")).then_some(true),
+ corner_radius,
+ fill,
+ stroke,
+ effects,
+ }
+}
+
+fn frame(base: PenNodeBase, container: ContainerProps, children: Vec) -> PenNode {
+ PenNode::Frame(FrameNode {
+ base,
+ container,
+ children: Some(children),
+ image_search_query: None,
+ reusable: None,
+ slot: None,
+ state: None,
+ bindings: None,
+ events: None,
+ lifecycle: None,
+ semantics: None,
+ gestures: None,
+ route: None,
+ screen: None,
+ })
+}
+
+fn apply_base_style(base: &mut PenNodeBase, style: &ComputedStyle, context: &mut MapCtx<'_>) {
+ base.opacity = style
+ .get("opacity")
+ .and_then(|value| value.parse::().ok())
+ .map(NumberOrExpression::Number);
+ if let Some(transform) = style.get("transform") {
+ if let Some(degrees) = parse_rotate(transform) {
+ base.rotation = Some(degrees);
+ } else if transform != "none" {
+ context.warn_once("unsupported CSS transform ignored (only rotate is imported)");
+ }
+ }
+ layout_heuristics::apply_position(base, style, context);
+}
+
+fn parse_rotate(value: &str) -> Option {
+ let inner = value.trim().strip_prefix("rotate(")?.strip_suffix(')')?;
+ inner.strip_suffix("deg")?.trim().parse().ok()
+}
+
+fn length_context(font_size: f64, options: &HtmlImportOptions) -> LengthCtx {
+ LengthCtx {
+ font_size,
+ root_font_size: options.base_font_size,
+ viewport_w: options.viewport_width,
+ viewport_h: options.viewport_width * 0.625,
+ }
+}
+
+fn length_px(value: &str, font_size: f64, options: &HtmlImportOptions) -> Option {
+ match parse_length(value, &length_context(font_size, options))? {
+ CssLength::Px(value) => Some(value),
+ CssLength::Percent(_) => None,
+ }
+}
+
+fn map_sizing(value: &str, font_size: f64, options: &HtmlImportOptions) -> Option {
+ match parse_length(value, &length_context(font_size, options))? {
+ CssLength::Px(value) => Some(SizingBehavior::Number(value)),
+ CssLength::Percent(100.0) => Some(SizingBehavior::Keyword(SizingKeyword::FillContainer)),
+ CssLength::Percent(_) => None,
+ }
+}
+
+fn map_padding(style: &ComputedStyle, options: &HtmlImportOptions) -> Option {
+ let names = [
+ "padding-top",
+ "padding-right",
+ "padding-bottom",
+ "padding-left",
+ ];
+ if !names.iter().any(|name| style.get(name).is_some()) {
+ return None;
+ }
+ let values = names.map(|name| {
+ style
+ .get(name)
+ .and_then(|value| length_px(value, style.font_size, options))
+ .unwrap_or(0.0)
+ });
+ if values.iter().all(|value| *value == values[0]) {
+ Some(Padding::Uniform(values[0]))
+ } else {
+ Some(Padding::LtrB(values))
+ }
+}
+
+fn map_justify(value: &str) -> Option {
+ match value.trim().to_ascii_lowercase().as_str() {
+ "flex-start" | "start" | "left" => Some(JustifyContent::Start),
+ "center" => Some(JustifyContent::Center),
+ "flex-end" | "end" | "right" => Some(JustifyContent::End),
+ "space-between" => Some(JustifyContent::SpaceBetween),
+ "space-around" | "space-evenly" => Some(JustifyContent::SpaceAround),
+ _ => None,
+ }
+}
+
+fn solid_fill(color: String) -> PenFill {
+ PenFill::Solid(SolidFillBody {
+ color,
+ explain: None,
+ opacity: None,
+ blend_mode: None,
+ })
+}
+
+fn map_fill(style: &ComputedStyle) -> Option> {
+ let mut fills = Vec::new();
+ if let Some(color) = style.get("background-color").and_then(parse_css_color) {
+ fills.push(solid_fill(color));
+ }
+ if let Some(image) = style.get("background-image") {
+ if let Some(gradient) = map_gradient(image) {
+ fills.push(gradient);
+ } else if let Some(url) = extract_url(image) {
+ fills.push(PenFill::Image(ImageFillBody {
+ url: ImageSrc::from(url),
+ mode: None,
+ original_size: None,
+ transform: None,
+ explain: None,
+ opacity: None,
+ exposure: None,
+ contrast: None,
+ saturation: None,
+ temperature: None,
+ tint: None,
+ highlights: None,
+ shadows: None,
+ }));
+ }
+ }
+ (!fills.is_empty()).then_some(fills)
+}
+
+fn extract_url(value: &str) -> Option {
+ let start = value.find("url(")? + 4;
+ let end = value[start..].find(')')? + start;
+ Some(
+ value[start..end]
+ .trim()
+ .trim_matches(['\'', '"'])
+ .to_string(),
+ )
+}
+
+fn map_gradient(value: &str) -> Option {
+ let (radial, body) = if let Some(body) = value.strip_prefix("linear-gradient(") {
+ (false, body.strip_suffix(')')?)
+ } else if let Some(body) = value.strip_prefix("radial-gradient(") {
+ (true, body.strip_suffix(')')?)
+ } else {
+ return None;
+ };
+ let mut parts = split_top_level(body, ',');
+ let mut angle = None;
+ if !radial {
+ angle = parts.first().and_then(|part| parse_gradient_angle(part));
+ if angle.is_some() {
+ parts.remove(0);
+ }
+ } else if parts
+ .first()
+ .is_some_and(|part| parse_color_stop(part).is_none())
+ {
+ parts.remove(0);
+ }
+ let stop_count = parts.len();
+ if stop_count < 2 {
+ return None;
+ }
+ let stops: Option> = parts
+ .iter()
+ .enumerate()
+ .map(|(index, part)| {
+ let (color, explicit) = parse_color_stop(part)?;
+ let offset = explicit.unwrap_or_else(|| index as f32 / (stop_count - 1) as f32);
+ Some(GradientStop { offset, color })
+ })
+ .collect();
+ let stops = stops?;
+ if radial {
+ Some(PenFill::RadialGradient(RadialGradientBody {
+ cx: None,
+ cy: None,
+ radius: None,
+ stops,
+ explain: None,
+ opacity: None,
+ blend_mode: None,
+ }))
+ } else {
+ Some(PenFill::LinearGradient(LinearGradientBody {
+ angle: Some(angle.unwrap_or(180.0)),
+ stops,
+ explain: None,
+ opacity: None,
+ blend_mode: None,
+ }))
+ }
+}
+
+fn parse_gradient_angle(value: &str) -> Option {
+ let value = value.trim().to_ascii_lowercase();
+ if let Some(degrees) = value.strip_suffix("deg") {
+ return degrees.trim().parse().ok();
+ }
+ match value.as_str() {
+ "to top" => Some(0.0),
+ "to right" => Some(90.0),
+ "to bottom" => Some(180.0),
+ "to left" => Some(270.0),
+ _ => None,
+ }
+}
+
+fn parse_color_stop(value: &str) -> Option<(String, Option)> {
+ let value = value.trim();
+ if let Some(color) = parse_css_color(value) {
+ return Some((color, None));
+ }
+ let (color_source, remainder) = if value.starts_with("rgb") || value.starts_with("hsl") {
+ let end = value.find(')')? + 1;
+ (&value[..end], value[end..].trim())
+ } else {
+ value
+ .split_once(char::is_whitespace)
+ .map_or((value, ""), |(color, rest)| (color, rest.trim()))
+ };
+ let color = parse_css_color(color_source)?;
+ let offset = remainder
+ .strip_suffix('%')
+ .and_then(|number| number.trim().parse::().ok())
+ .map(|percent| percent / 100.0);
+ Some((color, offset))
+}
+
+fn map_stroke(style: &ComputedStyle, context: &mut MapCtx<'_>) -> Option {
+ let widths = ["top", "right", "bottom", "left"].map(|side| {
+ style
+ .get(&format!("border-{side}-width"))
+ .or_else(|| style.get("border-width"))
+ .and_then(|value| length_px(value, style.font_size, context.opts))
+ .unwrap_or(0.0) as f32
+ });
+ let max_width = widths.iter().copied().fold(0.0_f32, f32::max);
+ if max_width <= 0.0 {
+ return None;
+ }
+ if widths.iter().any(|width| *width != widths[0]) {
+ context.warn_once("per-side border widths approximated using the widest side");
+ }
+ let color = style
+ .get("border-color")
+ .or_else(|| {
+ ["top", "right", "bottom", "left"]
+ .iter()
+ .find_map(|side| style.get(&format!("border-{side}-color")))
+ })
+ .or_else(|| style.get("color"))
+ .and_then(parse_css_color)
+ .unwrap_or_else(|| "#000000".to_string());
+ Some(PenStroke {
+ thickness: StrokeThickness::Uniform(max_width),
+ align: None,
+ join: None,
+ cap: None,
+ dash_pattern: None,
+ dash_offset: None,
+ fill: Some(vec![solid_fill(color)]),
+ })
+}
+
+fn map_corner_radius(
+ value: &str,
+ font_size: f64,
+ options: &HtmlImportOptions,
+) -> Option {
+ let parts: Vec<_> = value.split_whitespace().collect();
+ let values = match parts.as_slice() {
+ [all] => [*all, *all, *all, *all],
+ [vertical, horizontal] => [*vertical, *horizontal, *vertical, *horizontal],
+ [top, horizontal, bottom] => [*top, *horizontal, *bottom, *horizontal],
+ [top, right, bottom, left] => [*top, *right, *bottom, *left],
+ _ => return None,
+ };
+ let radii = values
+ .map(|part| length_px(part, font_size, options))
+ .into_iter()
+ .collect::