From 90ea322602f6205e3bd2fd2cf05ac671a7053d6e Mon Sep 17 00:00:00 2001 From: ldm0 Date: Wed, 7 Oct 2026 08:26:46 +0800 Subject: [PATCH] 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. --- .../context_bootstrap/svg_runtime/builders.rs | 140 ++++++++++++------ moli-renderer-v8/src/script_vm/tests/mod.rs | 1 + .../src/script_vm/tests/svg_geometry_units.js | 50 +++++++ .../src/script_vm/tests/svg_geometry_units.rs | 16 ++ 4 files changed, 162 insertions(+), 45 deletions(-) create mode 100644 moli-renderer-v8/src/script_vm/tests/svg_geometry_units.js create mode 100644 moli-renderer-v8/src/script_vm/tests/svg_geometry_units.rs diff --git a/moli-renderer-v8/src/context_bootstrap/svg_runtime/builders.rs b/moli-renderer-v8/src/context_bootstrap/svg_runtime/builders.rs index 02546acd4b..07a7893054 100644 --- a/moli-renderer-v8/src/context_bootstrap/svg_runtime/builders.rs +++ b/moli-renderer-v8/src/context_bootstrap/svg_runtime/builders.rs @@ -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 { + 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 { + 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) } diff --git a/moli-renderer-v8/src/script_vm/tests/mod.rs b/moli-renderer-v8/src/script_vm/tests/mod.rs index 9603e446b6..5381090cfd 100644 --- a/moli-renderer-v8/src/script_vm/tests/mod.rs +++ b/moli-renderer-v8/src/script_vm/tests/mod.rs @@ -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; diff --git a/moli-renderer-v8/src/script_vm/tests/svg_geometry_units.js b/moli-renderer-v8/src/script_vm/tests/svg_geometry_units.js new file mode 100644 index 0000000000..e95c9bbb77 --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/svg_geometry_units.js @@ -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('','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; +})() diff --git a/moli-renderer-v8/src/script_vm/tests/svg_geometry_units.rs b/moli-renderer-v8/src/script_vm/tests/svg_geometry_units.rs new file mode 100644 index 0000000000..a7815d0b04 --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/svg_geometry_units.rs @@ -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 = ''") + .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(), + "[]" + ); +}