diff --git a/crates/op-html/src/e2e_tests.rs b/crates/op-html/src/e2e_tests.rs new file mode 100644 index 000000000..21361eee6 --- /dev/null +++ b/crates/op-html/src/e2e_tests.rs @@ -0,0 +1,79 @@ +use crate::{import_html, HtmlImportOptions}; +use jian_ops_schema::node::container::LayoutMode; +use jian_ops_schema::node::PenNode; +use jian_ops_schema::sizing::{SizingBehavior, SizingKeyword}; + +const LANDING: &str = r#"Acme +
+

Build faster

+

Ship beautiful designs

+
+ + +
+
+"#; + +#[test] +fn landing_page_imports_as_editable_tree() { + let result = import_html(LANDING, &HtmlImportOptions::default()); + assert_eq!(result.nodes.len(), 1); + let PenNode::Frame(root) = &result.nodes[0] else { + panic!("root must be frame") + }; + assert_eq!(root.base.name.as_deref(), Some("Acme")); + assert_eq!(root.container.width, Some(SizingBehavior::Number(1440.0))); + assert_eq!( + root.container.height, + Some(SizingBehavior::Keyword(SizingKeyword::FitContent)) + ); + let PenNode::Frame(hero) = &root.children.as_ref().unwrap()[0] else { + panic!() + }; + assert_eq!(hero.container.layout, Some(LayoutMode::Vertical)); + let children = hero.children.as_ref().unwrap(); + assert!(children.len() >= 3); + let PenNode::Frame(row) = children.last().unwrap() else { + panic!("row") + }; + let row_children = row.children.as_ref().unwrap(); + assert!(matches!(&row_children[0], PenNode::Frame(button) + if button.base.role.as_deref() == Some("button"))); + assert!(matches!(&row_children[1], PenNode::TextInput(_))); +} + +#[test] +fn node_limit_truncates_with_warning() { + let mut html = String::from("
"); + for _ in 0..25_000 { + html.push_str("

x

"); + } + html.push_str("
"); + let result = import_html(&html, &HtmlImportOptions::default()); + assert!(result + .warnings + .iter() + .any(|warning| warning.contains("node limit"))); +} + +#[test] +fn document_name_option_overrides_title() { + let options = HtmlImportOptions { + document_name: Some("Custom".into()), + ..Default::default() + }; + let result = import_html( + "T

x

", + &options, + ); + let PenNode::Frame(root) = &result.nodes[0] else { + panic!() + }; + assert_eq!(root.base.name.as_deref(), Some("Custom")); +} diff --git a/crates/op-html/src/lib.rs b/crates/op-html/src/lib.rs index 1a78e72f1..0c93a6beb 100644 --- a/crates/op-html/src/lib.rs +++ b/crates/op-html/src/lib.rs @@ -1,6 +1,21 @@ //! HTML → PenNode importer (structured path, CSS-subset cascade). -use jian_ops_schema::node::PenNode; +use jian_ops_schema::node::base::PenNodeBase; +use jian_ops_schema::node::container::LayoutMode; +use jian_ops_schema::node::{FrameNode, PenNode}; +use jian_ops_schema::sizing::{SizingBehavior, SizingKeyword}; +use jian_ops_schema::style::{PenFill, SolidFillBody}; + +pub mod color; +pub mod css; +pub mod dom; +pub mod length; +pub mod mapper; +pub mod special; +pub mod text; + +#[cfg(test)] +mod e2e_tests; pub struct HtmlImportOptions { pub viewport_width: f64, @@ -24,7 +39,6 @@ pub struct HtmlImportResult { } pub fn import_html(source: &str, opts: &HtmlImportOptions) -> HtmlImportResult { - let _ = opts; let mut warnings = Vec::new(); if source.trim().is_empty() { warnings.push("no importable content: input HTML is empty".to_string()); @@ -33,14 +47,129 @@ pub fn import_html(source: &str, opts: &HtmlImportOptions) -> HtmlImportResult { warnings, }; } - // Pipeline lands in later tasks; non-empty input is wired up in Task 11. - warnings.push("no importable content: importer pipeline not yet implemented".to_string()); + let source = truncate_source(source, &mut warnings); + let mut parsed = dom::parse_dom(source); + if parsed.body.is_empty() { + warnings.push("no importable content: input HTML produced an empty body".to_string()); + return HtmlImportResult { + nodes: Vec::new(), + warnings, + }; + } + let mut remaining = MAX_OUTPUT_NODES - 1; + if truncate_dom_nodes(&mut parsed.body, &mut remaining) { + warnings.push("node limit reached (20000), remaining content dropped".to_string()); + } + + let (mut rules, ua_warnings) = css::cascade::parse_stylesheet(css::cascade::UA_STYLESHEET, 0); + warnings.extend(ua_warnings); + for (index, stylesheet) in parsed.style_blocks.iter().enumerate() { + let (author_rules, stylesheet_warnings) = + css::cascade::parse_stylesheet(stylesheet, 1000 + index * 10_000); + rules.extend(author_rules); + warnings.extend(stylesheet_warnings); + } + + let body = dom::DomElement { + tag: "body".to_string(), + attrs: Vec::new(), + children: parsed.body, + }; + let body_style = css::cascade::compute_style(&[&body], &rules, None, opts.base_font_size); + let mut context = mapper::MapCtx { + opts, + rules: &rules, + warnings: Vec::new(), + next_id: 0, + node_count: 1, + }; + let root_id = context.generate_id(); + let mut container = mapper::container_props_from(&body_style, &mut context); + container.width = Some(SizingBehavior::Number(opts.viewport_width)); + container.height = Some(SizingBehavior::Keyword(SizingKeyword::FitContent)); + container.layout = Some(LayoutMode::Vertical); + if container.fill.is_none() { + container.fill = Some(vec![solid_fill("#ffffff")]); + } + let children = text::map_children(&mut context, &[&body], &body_style, &body.children); + let name = opts + .document_name + .clone() + .or(parsed.title) + .unwrap_or_else(|| "HTML Import".to_string()); + let root = PenNode::Frame(FrameNode { + base: PenNodeBase { + id: root_id, + name: Some(name), + ..Default::default() + }, + 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, + }); + warnings.extend(context.warnings); HtmlImportResult { - nodes: Vec::new(), + nodes: vec![root], warnings, } } +const MAX_INPUT_BYTES: usize = 10 * 1024 * 1024; +const MAX_OUTPUT_NODES: usize = 20_000; + +fn truncate_source<'a>(source: &'a str, warnings: &mut Vec) -> &'a str { + if source.len() <= MAX_INPUT_BYTES { + return source; + } + let mut end = MAX_INPUT_BYTES; + while !source.is_char_boundary(end) { + end -= 1; + } + warnings.push("input HTML exceeded 10MB and was truncated".to_string()); + &source[..end] +} + +fn truncate_dom_nodes(nodes: &mut Vec, remaining: &mut usize) -> bool { + let original_len = nodes.len(); + let mut keep = 0; + let mut truncated = false; + for node in nodes.iter_mut() { + if *remaining == 0 { + truncated = true; + break; + } + *remaining -= 1; + keep += 1; + if let dom::DomNode::Element(element) = node { + truncated |= truncate_dom_nodes(&mut element.children, remaining); + } + } + if keep < original_len { + nodes.truncate(keep); + truncated = true; + } + truncated +} + +fn solid_fill(color: &str) -> PenFill { + PenFill::Solid(SolidFillBody { + color: color.to_string(), + explain: None, + opacity: None, + blend_mode: None, + }) +} + #[cfg(test)] mod tests { use super::*;