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#"
Ship beautiful designs
+x
"); + } + html.push_str("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