fix(svg): resolve geometry lengths in their SVG user space

Share native SVG length resolution between geometry and animated values. Resolve physical units, independently specified viewport dimensions, nested percentage viewports and viewBox user coordinates. Cover live/windowless owner and callee realms, absolute-unit detached geometry and viewport mutations without a layout backend.
This commit is contained in:
ldm0
2026-10-11 18:06:48 +08:00
parent c58817e80a
commit 90ea322602
4 changed files with 162 additions and 45 deletions
@@ -1439,14 +1439,7 @@ fn svg_geometry_length_attribute_for_handle(
handle: crate::document_runtime::DomHandle,
attribute: &str,
) -> f64 {
runtime
.dom_host()
.get_attribute(handle, attribute)
.as_deref()
.and_then(parse_svg_length_value)
.filter(|parsed| parsed.value.is_finite())
.map(|parsed| parsed.value)
.unwrap_or(0.0)
svg_geometry_optional_length_attribute_for_handle(runtime, handle, attribute).unwrap_or(0.0)
}
fn svg_geometry_rect_radius_attribute_for_handle(
@@ -1473,7 +1466,10 @@ fn svg_geometry_optional_length_attribute_for_handle(
.as_deref()
.and_then(parse_svg_length_value)
.filter(|parsed| parsed.value.is_finite())
.map(|parsed| parsed.value)
.and_then(|parsed| {
resolve_svg_length_for_handle(runtime, handle, attribute, parsed.value, parsed.unit)
})
.filter(|value| value.is_finite())
}
pub(super) fn svg_graphics_bounding_box<'s>(
@@ -3918,16 +3914,30 @@ fn resolve_svg_length_user_value_for_unit<'s>(
let (runtime_ptr, handle) =
crate::native_bridge::node_runtime_and_handle_from_object_or_detached(scope, owner).ok()?;
let runtime = unsafe { &*runtime_ptr };
let connected = runtime
.dom_host()
.node(handle)
.is_some_and(|node| node.is_connected());
resolve_svg_length_for_handle(runtime, handle, &attribute, value, unit)
}
fn resolve_svg_length_for_handle(
runtime: &crate::native_bridge::JsContextHost,
handle: crate::document_runtime::DomHandle,
attribute: &str,
value: f64,
unit: SvgLengthUnit,
) -> Option<f64> {
if let Some(value) = resolve_svg_absolute_length(value, unit) {
return Some(value);
}
let connected = runtime.dom_host().is_connected_to_document(handle);
let basis = if connected {
svg_length_percentage_basis(runtime, handle, &attribute)
svg_length_percentage_basis(runtime, handle, attribute)
} else {
100.0
};
let context = svg_length_numeric_context(runtime, handle, unit, connected);
let context = if matches!(unit, SvgLengthUnit::Percentage) {
moli_css_parse::CssNumericContext::default()
} else {
svg_length_numeric_context(runtime, handle, unit, connected)
};
resolve_svg_length_in_context(value, unit, basis, context)
}
@@ -4070,13 +4080,27 @@ fn svg_length_percentage_basis(
handle: crate::document_runtime::DomHandle,
attribute: &str,
) -> f64 {
let (width, height) = svg_nearest_viewport_dimensions(runtime, handle).unwrap_or_else(|| {
let viewport = runtime.style_viewport();
(
viewport.width.unwrap_or(100.0),
viewport.height.unwrap_or(100.0),
)
});
// The viewport element's own position and size are relative to its parent
// viewport. Its descendants use the coordinate system it establishes.
let node = runtime.dom_host().node(handle);
let viewport_handle = if matches!(attribute, "x" | "y" | "width" | "height")
&& node.is_some_and(|node| {
node.as_element()
.is_some_and(|element| element.is_svg_element("svg"))
}) {
node.and_then(|node| node.parent_node())
} else {
Some(handle)
};
let (width, height) = viewport_handle
.and_then(|handle| svg_nearest_viewport_dimensions(runtime, handle))
.unwrap_or_else(|| {
let viewport = runtime.style_viewport();
(
viewport.width.unwrap_or(100.0),
viewport.height.unwrap_or(100.0),
)
});
match attribute {
"x" | "x1" | "x2" | "cx" | "rx" | "width" | "markerWidth" | "refX" => width,
"y" | "y1" | "y2" | "cy" | "ry" | "height" | "markerHeight" | "refY" => height,
@@ -4090,36 +4114,62 @@ fn svg_nearest_viewport_dimensions(
) -> Option<(f64, f64)> {
let dom = runtime.dom_host();
let mut current = Some(handle);
let mut viewports = Vec::new();
while let Some(candidate) = current {
let node = dom.node(candidate)?;
if node
.as_element()
.is_some_and(|element| element.is_svg_element("svg"))
{
if let Some(view_box) = dom.get_attribute(candidate, "viewBox")
&& let Some([_, _, width, height]) = parse_svg_view_box_value(&view_box)
&& width > 0.0
&& height > 0.0
{
return Some((width, height));
}
let width = dom
.get_attribute(candidate, "width")
.as_deref()
.and_then(|value| {
moli_css_parse::parse_px_length(value, moli_css_parse::UnitlessLength::Any)
});
let height = dom
.get_attribute(candidate, "height")
.as_deref()
.and_then(|value| {
moli_css_parse::parse_px_length(value, moli_css_parse::UnitlessLength::Any)
});
if let (Some(width), Some(height)) = (width, height) {
return Some((width, height));
}
viewports.push(candidate);
}
current = node.parent_node();
}
None
if viewports.is_empty() {
return None;
}
let viewport = runtime.style_viewport();
let (mut width, mut height) = (
viewport.width.unwrap_or(100.0),
viewport.height.unwrap_or(100.0),
);
// Resolve nested viewport percentages against the enclosing user space,
// and preserve each independently specified axis.
for viewport in viewports.into_iter().rev() {
if let Some(view_box) = dom.get_attribute(viewport, "viewBox")
&& let Some([_, _, view_width, view_height]) = parse_svg_view_box_value(&view_box)
&& view_width > 0.0
&& view_height > 0.0
{
(width, height) = (view_width, view_height);
continue;
}
width = resolve_svg_viewport_dimension(runtime, viewport, "width", width).unwrap_or(width);
height =
resolve_svg_viewport_dimension(runtime, viewport, "height", height).unwrap_or(height);
}
Some((width, height))
}
fn resolve_svg_viewport_dimension(
runtime: &crate::native_bridge::JsContextHost,
viewport: crate::document_runtime::DomHandle,
attribute: &str,
basis: f64,
) -> Option<f64> {
let dom = runtime.dom_host();
let parsed = dom
.get_attribute(viewport, attribute)
.as_deref()
.and_then(parse_svg_length_value)?;
let context = if resolve_svg_absolute_length(parsed.value, parsed.unit).is_some()
|| matches!(parsed.unit, SvgLengthUnit::Percentage)
{
moli_css_parse::CssNumericContext::default()
} else {
let connected = dom.is_connected_to_document(viewport);
svg_length_numeric_context(runtime, viewport, parsed.unit, connected)
};
resolve_svg_length_in_context(parsed.value, parsed.unit, basis, context)
.filter(|value| value.is_finite() && *value >= 0.0)
}
@@ -2334,6 +2334,7 @@ mod svg_filter_enumerations;
mod svg_filter_interfaces;
mod svg_geometry_methods;
mod svg_geometry_receivers;
mod svg_geometry_units;
mod svg_length_validation;
mod svg_number_validation;
mod svg_root_factory_receivers;
@@ -0,0 +1,50 @@
(() => {
const checks=[],ns='http://www.w3.org/2000/svg';
const check=(name,fn)=>{try {checks.push({name,passed:fn()===true});}catch(error){checks.push({name,passed:false,error:String(error)});}};
const near=(a,b)=>Math.abs(a-b)<1e-5;
const realms=[window,document.querySelector('iframe').contentWindow];
const lengths=['96','96px','1in','2.54cm','25.4mm','101.6Q','72pt','6pc'];
for (const [ownerIndex,owner] of realms.entries()) {
const docs=[owner.document,owner.document.implementation.createHTMLDocument(''),owner.document.implementation.createDocument(ns,'svg'),new owner.DOMParser().parseFromString('<svg xmlns="'+ns+'"/>','image/svg+xml')];
for (const [docIndex,doc] of docs.entries()) {
for (const [calleeIndex,callee] of realms.entries()) {
const prefix=`units/${ownerIndex}/${docIndex}/${calleeIndex}`;
const point=callee.SVGGeometryElement.prototype.getPointAtLength,total=callee.SVGGeometryElement.prototype.getTotalLength;
const root=doc.createElementNS(ns,'svg');root.setAttribute('width','240');root.setAttribute('height','120');(doc.body||doc.documentElement).appendChild(root);
for (const [index,raw] of lengths.entries()) {
const line=doc.createElementNS(ns,'line');line.setAttribute('x1','0.5in');line.setAttribute('y1','1pc');line.setAttribute('x2',raw);line.setAttribute('y2','1pc');root.appendChild(line);
check(prefix+'/absolute/'+index+'/total',()=>total.call(line)===48);
check(prefix+'/absolute/'+index+'/point',()=>{const p=point.call(line,24);return near(p.x,72)&&near(p.y,16);});
check(prefix+'/absolute/'+index+'/hidden',()=>{line.style.display='none';return total.call(line)===48;});
check(prefix+'/absolute/'+index+'/detached',()=>{line.remove();const p=point.call(line,24);return total.call(line)===48&&near(p.x,72)&&near(p.y,16);});
}
const cases=[
['viewport',{},null,300,120],
['viewbox',{viewBox:'0 0 600 300'},null,750,300],
['partial-height',{height:'50',width:null},null,200,50],
['partial-width',{width:'150',height:null},null,400,150],
['nested-percent',{}, {width:'50%',height:'50%'},150,60],
['nested-viewbox',{}, {width:'50%',height:'50%',viewBox:'0 0 100 200'},200,50],
['nested-absolute',{}, {width:'2.54cm',height:'25.4mm'},144,48],
];
for(const [name,attributes,nestedAttributes,expected,first] of cases) {
root.setAttribute('width','240');root.setAttribute('height','120');root.removeAttribute('viewBox');for(const [key,value] of Object.entries(attributes))if(value===null)root.removeAttribute(key);else root.setAttribute(key,value);
let viewport=root,nested;
if(nestedAttributes){nested=doc.createElementNS(ns,'svg');for(const [key,value] of Object.entries(nestedAttributes))nested.setAttribute(key,value);root.appendChild(nested);viewport=nested;}
const rect=doc.createElementNS(ns,'rect');rect.setAttribute('width',name==='partial-height'?'50':name==='partial-width'?'100%':'50%');rect.setAttribute('height',name==='partial-height'?'100%':name==='partial-width'?'50':'25%');viewport.appendChild(rect);
check(prefix+'/'+name+'/total',()=>total.call(rect)===expected);
check(prefix+'/'+name+'/point',()=>{const p=point.call(rect,first);return near(p.x,first)&&near(p.y,0);});
check(prefix+'/'+name+'/mutation',()=>{if(name==='partial-height'){rect.setAttribute('height','50%');return total.call(rect)===150;}if(name==='partial-width'){rect.setAttribute('width','50%');return total.call(rect)===250;}rect.setAttribute('width','25%');return total.call(rect)===expected-first;});
rect.remove();if(nested)nested.remove();
}
root.setAttribute('width','240');root.setAttribute('height','120');root.removeAttribute('viewBox');
const line=doc.createElementNS(ns,'line');line.setAttribute('x2','100%');line.setAttribute('y2','100%');root.appendChild(line);
check(prefix+'/percent-axes',()=>total.call(line)===Math.fround(Math.hypot(240,120)));
check(prefix+'/viewport-mutation',()=>{root.setAttribute('width','120');root.setAttribute('height','160');return total.call(line)===200;});
root.setAttribute('viewBox','0 0 30 40');check(prefix+'/viewbox-mutation',()=>total.call(line)===50);
root.remove();
}
}
}
globalThis.__svgUnitsResults={complete:true,total:checks.length,passed:checks.filter(row=>row.passed).length,checks};globalThis.__uiEventResults=globalThis.__svgUnitsResults;return true;
})()
@@ -0,0 +1,16 @@
use super::*;
#[test]
fn svg_geometry_resolves_length_units_and_nested_viewports() {
let mut vm = new_storage_page_task_executor_test_vm("https://svg-geometry-units.test/");
vm.eval("document.body.innerHTML = '<iframe></iframe>'")
.unwrap();
vm.eval(include_str!("svg_geometry_units.js")).unwrap();
assert_eq!(vm.eval("__svgUnitsResults.complete").unwrap(), "true");
assert_eq!(vm.eval("__svgUnitsResults.total").unwrap(), "896");
assert_eq!(
vm.eval("JSON.stringify(__svgUnitsResults.checks.filter(row => !row.passed))")
.unwrap(),
"[]"
);
}