feat(html): map containers, visual styles and layout heuristics to frames

This commit is contained in:
Kayshen-X 2026-07-18 14:16:42 +08:00
parent 623e503617
commit 90c8b3154e
2 changed files with 906 additions and 0 deletions

View file

@ -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<f64>, 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::<f64>().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<f64>, 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");
}
}

View file

@ -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<String>,
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<PenNode> {
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 {
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::<f64>().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<f64> {
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<f64> {
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<SizingBehavior> {
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<Padding> {
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<JustifyContent> {
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<Vec<PenFill>> {
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<String> {
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<PenFill> {
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<Vec<_>> = 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<f32> {
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<f32>)> {
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::<f32>().ok())
.map(|percent| percent / 100.0);
Some((color, offset))
}
fn map_stroke(style: &ComputedStyle, context: &mut MapCtx<'_>) -> Option<PenStroke> {
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<CornerRadius> {
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::<Option<Vec<_>>>()?;
if radii.iter().all(|radius| *radius == radii[0]) {
Some(CornerRadius::Uniform(radii[0]))
} else {
Some(CornerRadius::PerCorner([
radii[0], radii[1], radii[2], radii[3],
]))
}
}
fn map_shadows(value: &str) -> Option<Vec<PenEffect>> {
let effects: Vec<_> = split_top_level(value, ',')
.into_iter()
.filter_map(map_shadow)
.map(PenEffect::Shadow)
.collect();
(!effects.is_empty()).then_some(effects)
}
fn map_shadow(value: &str) -> Option<ShadowBody> {
let mut inner = false;
let mut color = "#000000".to_string();
let mut lengths = Vec::new();
for token in split_whitespace_top_level(value) {
if token == "inset" {
inner = true;
} else if let Some(parsed) = parse_css_color(token) {
color = parsed;
} else if let Some(length) = parse_shadow_length(token) {
lengths.push(length);
}
}
if lengths.len() < 2 {
return None;
}
Some(ShadowBody {
inner: inner.then_some(true),
offset_x: lengths[0],
offset_y: lengths[1],
blur: lengths.get(2).copied().unwrap_or(0.0),
spread: lengths.get(3).copied().unwrap_or(0.0),
color,
})
}
fn parse_shadow_length(value: &str) -> Option<f32> {
let number = value.strip_suffix("px").unwrap_or(value);
number.parse().ok()
}
fn split_top_level(input: &str, delimiter: char) -> Vec<&str> {
let mut result = Vec::new();
let mut start = 0;
let mut depth = 0u32;
for (index, ch) in input.char_indices() {
match ch {
'(' => depth += 1,
')' => depth = depth.saturating_sub(1),
_ if ch == delimiter && depth == 0 => {
result.push(input[start..index].trim());
start = index + ch.len_utf8();
}
_ => {}
}
}
result.push(input[start..].trim());
result
}
fn split_whitespace_top_level(input: &str) -> Vec<&str> {
let mut result = Vec::new();
let mut start = None;
let mut depth = 0u32;
for (index, ch) in input.char_indices() {
match ch {
'(' => {
depth += 1;
start.get_or_insert(index);
}
')' => depth = depth.saturating_sub(1),
_ if ch.is_whitespace() && depth == 0 => {
if let Some(token_start) = start.take() {
result.push(&input[token_start..index]);
}
}
_ => {
start.get_or_insert(index);
}
}
}
if let Some(token_start) = start {
result.push(&input[token_start..]);
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use crate::css::cascade::parse_stylesheet;
use jian_ops_schema::node::container::{
AlignItems, CornerRadius, JustifyContent, LayoutMode, Padding,
};
use jian_ops_schema::node::PenNode;
use jian_ops_schema::style::{PenEffect, PenFill, StrokeThickness};
fn map_one(html_element: crate::dom::DomElement, css: &str) -> Option<PenNode> {
let (rules, _) = parse_stylesheet(css, 1000);
let options = crate::HtmlImportOptions::default();
let mut context = MapCtx {
opts: &options,
rules: &rules,
warnings: Vec::new(),
next_id: 0,
node_count: 0,
};
map_element(&mut context, &[&html_element], None)
}
#[test]
fn flex_row_maps_to_horizontal_frame() {
let element = crate::dom::DomElement {
tag: "div".into(),
attrs: vec![(
"style".into(),
"display:flex;gap:12px;justify-content:space-between;align-items:center;padding:16px"
.into(),
)],
children: Vec::new(),
};
let Some(PenNode::Frame(frame)) = map_one(element, "") else {
panic!("expected frame")
};
assert_eq!(frame.container.layout, Some(LayoutMode::Horizontal));
assert!(matches!(
frame.container.gap,
Some(jian_ops_schema::node::base::NumberOrExpression::Number(value)) if value == 12.0
));
assert_eq!(
frame.container.justify_content,
Some(JustifyContent::SpaceBetween)
);
assert_eq!(frame.container.align_items, Some(AlignItems::Center));
assert!(matches!(
frame.container.padding,
Some(Padding::Uniform(value)) if value == 16.0
));
}
#[test]
fn visual_styles_map_to_fill_stroke_effects() {
let element = crate::dom::DomElement {
tag: "div".into(),
attrs: vec![(
"style".into(),
"background-color:#102030;border:2px solid #ff0000;border-radius:8px;\
box-shadow:0 4px 8px rgba(0,0,0,0.25);overflow:hidden"
.into(),
)],
children: Vec::new(),
};
let Some(PenNode::Frame(frame)) = map_one(element, "") else {
panic!()
};
let fills = frame.container.fill.as_ref().unwrap();
assert!(matches!(&fills[0], PenFill::Solid(solid) if solid.color == "#102030"));
let stroke = frame.container.stroke.as_ref().unwrap();
assert!(matches!(
stroke.thickness,
StrokeThickness::Uniform(width) if width == 2.0
));
assert!(matches!(
frame.container.corner_radius,
Some(CornerRadius::Uniform(radius)) if radius == 8.0
));
let effects = frame.container.effects.as_ref().unwrap();
assert!(matches!(&effects[0], PenEffect::Shadow(shadow)
if shadow.offset_y == 4.0 && shadow.blur == 8.0 && shadow.color == "#00000040"));
assert_eq!(frame.container.clip_content, Some(true));
}
#[test]
fn linear_gradient_fill() {
let element = crate::dom::DomElement {
tag: "div".into(),
attrs: vec![(
"style".into(),
"background:linear-gradient(90deg,#000000,#ffffff)".into(),
)],
children: Vec::new(),
};
let Some(PenNode::Frame(frame)) = map_one(element, "") else {
panic!()
};
let fills = frame.container.fill.as_ref().unwrap();
let PenFill::LinearGradient(gradient) = &fills[0] else {
panic!("expected gradient")
};
assert_eq!(gradient.angle, Some(90.0));
assert_eq!(gradient.stops.len(), 2);
assert_eq!(gradient.stops[1].color, "#ffffff");
assert_eq!(gradient.stops[1].offset, 1.0);
}
#[test]
fn display_none_is_skipped() {
let element = crate::dom::DomElement {
tag: "div".into(),
attrs: vec![("style".into(), "display:none".into())],
children: Vec::new(),
};
assert!(map_one(element, "").is_none());
}
#[test]
fn gap_mode_from_margins() {
let make_style = |margin_top: &str, margin_bottom: &str| {
let mut style = ComputedStyle {
props: Default::default(),
font_size: 16.0,
};
style.props.insert("margin-top".into(), margin_top.into());
style
.props
.insert("margin-bottom".into(), margin_bottom.into());
style
};
let first = make_style("0", "8px");
let second = make_style("8px", "8px");
let third = make_style("8px", "16px");
let fourth = make_style("8px", "0");
let styles = vec![&first, &second, &third, &fourth];
let (gap, deviated) = infer_gap_from_margins(&styles, 16.0);
assert_eq!(gap, Some(16.0));
assert!(deviated);
}
#[test]
fn block_child_defaults_to_fill_width_and_frames_hug_height() {
let parent = crate::dom::DomElement {
tag: "div".into(),
attrs: vec![],
children: vec![crate::dom::DomNode::Element(crate::dom::DomElement {
tag: "div".into(),
attrs: vec![],
children: Vec::new(),
})],
};
let Some(PenNode::Frame(frame)) = map_one(parent, "") else {
panic!()
};
let PenNode::Frame(child) = &frame.children.as_ref().unwrap()[0] else {
panic!()
};
use jian_ops_schema::sizing::{SizingBehavior, SizingKeyword};
assert_eq!(
child.container.width,
Some(SizingBehavior::Keyword(SizingKeyword::FillContainer))
);
assert_eq!(
child.container.height,
Some(SizingBehavior::Keyword(SizingKeyword::FitContent))
);
}
#[test]
fn absolute_positioning_lands_on_base_xy() {
let element = crate::dom::DomElement {
tag: "div".into(),
attrs: vec![(
"style".into(),
"position:absolute;left:24px;top:48px".into(),
)],
children: Vec::new(),
};
let Some(PenNode::Frame(frame)) = map_one(element, "") else {
panic!()
};
assert_eq!(frame.base.x, Some(24.0));
assert_eq!(frame.base.y, Some(48.0));
}
#[test]
fn grid_degrades_with_single_warning() {
let (rules, _) = crate::css::cascade::parse_stylesheet("", 1000);
let options = crate::HtmlImportOptions::default();
let mut context = MapCtx {
opts: &options,
rules: &rules,
warnings: Vec::new(),
next_id: 0,
node_count: 0,
};
for _ in 0..2 {
let element = crate::dom::DomElement {
tag: "div".into(),
attrs: vec![("style".into(), "display:grid".into())],
children: Vec::new(),
};
map_element(&mut context, &[&element], None);
}
assert_eq!(
context
.warnings
.iter()
.filter(|warning| warning.contains("grid"))
.count(),
1
);
}
}