feat(html): wire full import pipeline with root frame and defense limits

This commit is contained in:
Kayshen-X 2026-07-18 14:16:43 +08:00
parent a0b202b97a
commit 97f1cf6b44
2 changed files with 213 additions and 5 deletions

View file

@ -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#"<html><head><title>Acme</title><style>
.hero { display:flex; flex-direction:column; align-items:center; gap:24px;
padding:64px; background:linear-gradient(180deg,#0b1220,#1a2740); }
.hero h1 { color:#ffffff; margin:0 }
.cta { background-color:#3b82f6; color:#ffffff; padding:12px 24px; border-radius:8px }
.row { display:flex; gap:16px }
</style></head><body>
<section class="hero">
<h1>Build faster</h1>
<p style="color:#94a3b8">Ship <b>beautiful</b> designs</p>
<div class="row">
<button class="cta">Start</button>
<input type="text" placeholder="Email"/>
</div>
</section>
</body></html>"#;
#[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("<div>");
for _ in 0..25_000 {
html.push_str("<p>x</p>");
}
html.push_str("</div>");
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(
"<html><head><title>T</title></head><body><p>x</p></body></html>",
&options,
);
let PenNode::Frame(root) = &result.nodes[0] else {
panic!()
};
assert_eq!(root.base.name.as_deref(), Some("Custom"));
}

View file

@ -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<String>) -> &'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<dom::DomNode>, 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::*;