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(), + "[]" + ); +}