From 82bfb295906370e2e7eb8c3d32aaf2d3cf97dc06 Mon Sep 17 00:00:00 2001 From: ldm0 Date: Wed, 7 Oct 2026 12:42:37 +0800 Subject: [PATCH] feat(svg): implement structured path data access --- .../src/context_bootstrap/svg_runtime/mod.rs | 2 + .../context_bootstrap/svg_runtime/path_api.rs | 343 ++++++++++++++++++ moli-renderer-v8/src/script_vm/tests/mod.rs | 1 + .../src/script_vm/tests/svg_path_data.js | 142 ++++++++ .../src/script_vm/tests/svg_path_data.rs | 70 ++++ moli-svg/src/lib.rs | 1 + moli-svg/src/path.rs | 96 ++++- moli-webidl/src/lib.rs | 7 +- 8 files changed, 660 insertions(+), 2 deletions(-) create mode 100644 moli-renderer-v8/src/context_bootstrap/svg_runtime/path_api.rs create mode 100644 moli-renderer-v8/src/script_vm/tests/svg_path_data.js create mode 100644 moli-renderer-v8/src/script_vm/tests/svg_path_data.rs diff --git a/moli-renderer-v8/src/context_bootstrap/svg_runtime/mod.rs b/moli-renderer-v8/src/context_bootstrap/svg_runtime/mod.rs index d3a0806f86..1330bd8ea3 100644 --- a/moli-renderer-v8/src/context_bootstrap/svg_runtime/mod.rs +++ b/moli-renderer-v8/src/context_bootstrap/svg_runtime/mod.rs @@ -25,6 +25,7 @@ mod callbacks; mod cors; mod filters; mod number_list_attributes; +mod path_api; mod path_data; mod rect; mod redraw; @@ -1250,6 +1251,7 @@ pub(in crate::context_bootstrap) fn install_svg_template_bindings<'s>( "SVGMatrix" => bindings::install_svg_matrix_bindings(scope, template), "SVGGraphicsElement" => bindings::install_svg_graphics_element_bindings(scope, template), "SVGGeometryElement" => bindings::install_svg_geometry_element_bindings(scope, template), + "SVGPathElement" => path_api::install_bindings(scope, template), "SVGTextContentElement" => { bindings::install_svg_text_content_element_bindings(scope, template) } diff --git a/moli-renderer-v8/src/context_bootstrap/svg_runtime/path_api.rs b/moli-renderer-v8/src/context_bootstrap/svg_runtime/path_api.rs new file mode 100644 index 0000000000..df1d9a6841 --- /dev/null +++ b/moli-renderer-v8/src/context_bootstrap/svg_runtime/path_api.rs @@ -0,0 +1,343 @@ +//! Structured access to the base `d` attribute, independent of CSS and animation. + +use kurbo::{Arc, Point, SvgArc, Vec2}; +use moli_webapi_declare::{WebApiFunctionTemplate, WebApiObject}; +use std::fmt::Write; +use style::values::generics::basic_shape::ControlPoint; +use style::values::specified::svg_path::{CoordPair, PathCommand, SVGPathData, SVGPathPosition}; + +use crate::{ + native_bridge::node_runtime_and_handle_from_object_or_detached, util::v8_string, + web_api_interfaces, webidl, +}; + +#[derive(webidl::WebIdlDictionary)] +#[webidl(prefix = "SVGPathSegment")] +struct PathSegment { + #[webidl(required)] + r#type: String, + #[webidl(required, sequence, converter = "unrestricted_float")] + values: Vec, +} + +impl PathSegment { + fn new(command: char, values: impl Into>) -> Self { + Self { + r#type: command.to_string(), + values: values.into(), + } + } +} + +#[derive(webidl::WebIdlDictionary)] +struct PathDataSettings { + #[webidl(default = false)] + normalize: bool, +} + +#[derive(webidl::WebIdlArgs)] +#[webidl(prefix = "SVGPathElement.getPathData")] +struct GetArgs { + #[webidl(dictionary)] + settings: PathDataSettings, +} + +#[derive(webidl::WebIdlArgs)] +#[webidl(prefix = "SVGPathElement.setPathData")] +struct SetArgs { + #[webidl(required, converter = "raw")] + path_data: webidl::Sequence>, +} + +#[derive(WebApiObject)] +#[webapi(plain, data_properties, enumerable)] +struct SegmentDictionary<'s> { + r#type: v8::Local<'s, v8::String>, + values: v8::Local<'s, v8::Array>, +} + +#[derive(WebApiFunctionTemplate)] +#[webapi(interface = web_api_interfaces::SVGPathElement, enumerable, receiver)] +struct PathMethods { + #[webapi(method = "getPathData", length = 0, callback = get_path_data)] + get_path_data: (), + #[webapi(method = "setPathData", length = 1, callback = set_path_data)] + set_path_data: (), +} + +pub(super) fn install_bindings<'s>( + scope: &mut v8::PinScope<'s, '_, ()>, + template: v8::Local<'s, v8::FunctionTemplate>, +) { + let prototype = template.prototype_template(scope); + PathMethods::initialize_prototype_template(scope, prototype); +} + +fn get_path_data<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'_, v8::Value>, +) { + let Some(parsed) = webidl::parse_args::(scope, &args) else { + return; + }; + let receiver = moli_webapi_declare::web_api_object_target(scope, args.this()) + .expect("generated SVGPathElement receiver check validates native identity"); + let raw = super::builders::svg_owner_attribute_value(scope, receiver, "d").unwrap_or_default(); + let segments: Vec<_> = moli_svg::parse_path_segments(&raw) + .map_while(|segment| { + let values: Vec = segment + .values + .into_iter() + .map(|value| value as f32) + .collect(); + values + .iter() + .all(|value| value.is_finite()) + .then(|| PathSegment::new(segment.command, values)) + }) + .collect(); + let segments = if parsed.settings.normalize { + // Only validated binary32 commands reach the CSS normalizer: its parser + // clamps larger finite doubles, which SVG DOM error recovery must reject. + let raw = serialize_valid_prefix(segments); + let (path, _) = SVGPathData::parse_bytes(raw.as_bytes()); + normalized_segments(&path) + } else { + segments + }; + let dictionaries: Vec> = segments + .into_iter() + .map(|segment| { + let values: Vec<_> = segment + .values + .into_iter() + .map(|value| v8::Number::new(scope, f64::from(value)).into()) + .collect(); + let values = v8::Array::new_with_elements(scope, &values); + let command = + v8_string(scope, &segment.r#type).expect("an SVG command string should allocate"); + SegmentDictionary::new(command, values) + .bind(scope) + .expect("path segment dictionary should bind") + .into() + }) + .collect(); + rv.set(v8::Array::new_with_elements(scope, &dictionaries).into()); +} + +fn set_path_data<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + _rv: v8::ReturnValue<'_, v8::Value>, +) { + // Convert the entire iterable before semantic validation or mutation. Even + // a getter after an invalid command can throw and must leave `d` untouched. + let Some(parsed) = webidl::parse_args::(scope, &args) else { + return; + }; + let segments = parsed.path_data.0.into_iter().map(|segment| segment.0); + let value = serialize_valid_prefix(segments); + let receiver = moli_webapi_declare::web_api_object_target(scope, args.this()) + .expect("generated SVGPathElement receiver check validates native identity"); + let (runtime_ptr, handle) = node_runtime_and_handle_from_object_or_detached(scope, receiver) + .expect("native SVG path has a node handle"); + let runtime = unsafe { &mut *runtime_ptr }; + if value.is_empty() { + let _ = runtime.remove_attribute(scope, runtime_ptr, handle, "d"); + } else { + let _ = runtime.set_attribute(scope, runtime_ptr, handle, "d", &value); + } +} + +fn serialize_valid_prefix(segments: impl IntoIterator) -> String { + let mut output = String::new(); + for segment in segments { + let [original] = segment.r#type.as_bytes() else { + break; + }; + let command = original.to_ascii_uppercase(); + let arity = match command { + b'M' | b'L' | b'T' => 2, + b'H' | b'V' => 1, + b'C' => 6, + b'S' | b'Q' => 4, + b'A' => 7, + b'Z' => 0, + _ => break, + }; + if segment.values.len() != arity + || segment.values.iter().any(|value| !value.is_finite()) + || (output.is_empty() && command != b'M') + { + break; + } + if !output.is_empty() { + output.push(' '); + } + output.push(if command == b'Z' { + 'Z' + } else { + *original as char + }); + for (index, value) in segment.values.into_iter().enumerate() { + let value = if command == b'A' && matches!(index, 3 | 4) { + if value == 0.0 { 0.0 } else { 1.0 } + } else { + value + }; + // Preserve the converted binary32 coordinate when writing the base + // attribute; display-oriented CSS serialization loses precision. + write!(output, " {}", f64::from(value)) + .expect("writing a path attribute to a String is infallible"); + } + } + output +} + +fn endpoint(point: impl Into) -> [f32; 2] { + let point = point.into(); + [point.x, point.y] +} + +fn control(point: ControlPoint) -> [f32; 2] { + match point { + ControlPoint::Absolute(point) => [point.horizontal, point.vertical], + ControlPoint::Relative(point) => [point.coord.x, point.coord.y], + } +} + +fn segment_from_command(command: &PathCommand) -> PathSegment { + let letter = |absolute: bool, command: char| { + if absolute { + command + } else { + command.to_ascii_lowercase() + } + }; + match *command { + PathCommand::Move { point } => { + PathSegment::new(letter(point.is_abs(), 'M'), endpoint(point)) + } + PathCommand::Line { point } => { + PathSegment::new(letter(point.is_abs(), 'L'), endpoint(point)) + } + PathCommand::HLine { x } => PathSegment::new(letter(x.is_abs(), 'H'), [f32::from(x)]), + PathCommand::VLine { y } => PathSegment::new(letter(y.is_abs(), 'V'), [f32::from(y)]), + PathCommand::CubicCurve { + point, + control1, + control2, + } => { + let [x, y] = endpoint(point); + let [x1, y1] = control(control1); + let [x2, y2] = control(control2); + PathSegment::new(letter(point.is_abs(), 'C'), [x1, y1, x2, y2, x, y]) + } + PathCommand::SmoothCubic { point, control2 } => { + let [x, y] = endpoint(point); + let [x2, y2] = control(control2); + PathSegment::new(letter(point.is_abs(), 'S'), [x2, y2, x, y]) + } + PathCommand::QuadCurve { point, control1 } => { + let [x, y] = endpoint(point); + let [x1, y1] = control(control1); + PathSegment::new(letter(point.is_abs(), 'Q'), [x1, y1, x, y]) + } + PathCommand::SmoothQuad { point } => { + PathSegment::new(letter(point.is_abs(), 'T'), endpoint(point)) + } + PathCommand::Arc { + point, + radii, + arc_sweep, + arc_size, + rotate, + } => { + let [x, y] = endpoint(point); + PathSegment::new( + letter(point.is_abs(), 'A'), + [ + radii.rx, + radii.ry.as_ref().copied().unwrap_or(radii.rx), + rotate, + arc_size as u8 as f32, + arc_sweep as u8 as f32, + x, + y, + ], + ) + } + PathCommand::Close => PathSegment::new('Z', []), + } +} + +fn normalized_segments(path: &SVGPathData) -> Vec { + let path = path.normalize(true); + let mut segments = Vec::new(); + let mut position = Point::ZERO; + let mut start = Point::ZERO; + for command in path.commands() { + if let PathCommand::Arc { + point, + radii, + arc_sweep, + arc_size, + rotate, + } = *command + { + let [x, y] = endpoint(point); + let end = Point::new(f64::from(x), f64::from(y)); + let svg_arc = SvgArc { + from: position, + to: end, + radii: Vec2::new( + f64::from(radii.rx), + f64::from(radii.ry.as_ref().copied().unwrap_or(radii.rx)), + ), + x_rotation: f64::from(rotate).to_radians(), + large_arc: arc_size as u8 != 0, + sweep: arc_sweep as u8 != 0, + }; + if let Some(arc) = Arc::from_svg_arc(&svg_arc) { + // Kurbo's minimum subdivision is one cubic per quadrant. A + // radius-relative tolerance retains that canonical reduction. + arc.to_cubic_beziers(arc.radii.x.max(arc.radii.y), |p1, p2, p3| { + segments.push(PathSegment::new( + 'C', + [ + p1.x as f32, + p1.y as f32, + p2.x as f32, + p2.y as f32, + p3.x as f32, + p3.y as f32, + ], + )); + }); + } else { + segments.push(PathSegment::new('L', [x, y])); + } + position = end; + continue; + } + let segment = segment_from_command(command); + match segment.r#type.as_str() { + "M" => { + position = Point::new(f64::from(segment.values[0]), f64::from(segment.values[1])); + start = position; + } + "L" | "C" => { + let values = &segment.values; + position = Point::new( + f64::from(values[values.len() - 2]), + f64::from(values[values.len() - 1]), + ); + } + "Z" => position = start, + _ => unreachable!("Stylo reduction produces only M/L/C/A/Z"), + } + segments.push(segment); + } + segments +} diff --git a/moli-renderer-v8/src/script_vm/tests/mod.rs b/moli-renderer-v8/src/script_vm/tests/mod.rs index 8a11432dcf..dbd28da704 100644 --- a/moli-renderer-v8/src/script_vm/tests/mod.rs +++ b/moli-renderer-v8/src/script_vm/tests/mod.rs @@ -2342,6 +2342,7 @@ mod svg_geometry_receivers; mod svg_geometry_units; mod svg_length_validation; mod svg_number_validation; +mod svg_path_data; mod svg_redraw_methods; mod svg_root_factory_receivers; mod svg_switch_mpath_interfaces; diff --git a/moli-renderer-v8/src/script_vm/tests/svg_path_data.js b/moli-renderer-v8/src/script_vm/tests/svg_path_data.js new file mode 100644 index 0000000000..e92b8645eb --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/svg_path_data.js @@ -0,0 +1,142 @@ +(async () => { + const checks = []; + const check = (name, fn) => { + try { checks.push({name, passed:fn() === true}); } + catch (error) { checks.push({name, passed:false, error:String(error)}); } + }; + const ns = 'http://www.w3.org/2000/svg'; + const realms = [window, document.querySelector('iframe').contentWindow]; + const equal = (a, b) => JSON.stringify(a) === JSON.stringify(b); + const errorIn = (realm, fn) => { + try { fn(); } catch (error) { return Object.getPrototypeOf(error) === realm.TypeError.prototype; } + return false; + }; + const rawCases = [ + ['M1 2 3 4L5 6 7 8z', [['M',[1,2]],['L',[3,4]],['L',[5,6]],['L',[7,8]],['Z',[]]]], + ['m1 2h3v4c1 2 3 4 5 6s7 8 9 10q1 2 3 4t5 6a2 3 45 1 0 4 5z', [['m',[1,2]],['h',[3]],['v',[4]],['c',[1,2,3,4,5,6]],['s',[7,8,9,10]],['q',[1,2,3,4]],['t',[5,6]],['a',[2,3,45,1,0,4,5]],['Z',[]]]], + ['M1 2H3V4C1 2 3 4 5 6S7 8 9 10Q1 2 3 4T5 6A2 3 45 1 0 4 5', [['M',[1,2]],['H',[3]],['V',[4]],['C',[1,2,3,4,5,6]],['S',[7,8,9,10]],['Q',[1,2,3,4]],['T',[5,6]],['A',[2,3,45,1,0,4,5]]]], + ['M.5-.25L1e1-2e-1', [['M',[.5,-.25]],['L',[10,Math.fround(-.2)]]]], + ['M0 0L1. 2L3 4', [['M',[0,0]]]], + ['M0 0L1e40 2L3 4', [['M',[0,0]]]], + ['M0 0L3 4X0 0', [['M',[0,0]],['L',[3,4]]]], + ['M0 0ZL1 2', [['M',[0,0]],['Z',[]],['L',[1,2]]]], + ['L1 2', []], ['Z', []], ['', []], ['M1', []] + ]; + const normalizedCases = [ + ['m1 2h3v4l-3 0z', [['M',[1,2]],['L',[4,2]],['L',[4,6]],['L',[1,6]],['Z',[]]]], + ['M0 0Q3 3 6 0T12 0', [['M',[0,0]],['C',[2,2,4,2,6,0]],['C',[8,-2,10,-2,12,0]]]], + ['M0 0C1 2 3 4 5 6S7 8 9 10', [['M',[0,0]],['C',[1,2,3,4,5,6]],['C',[7,8,7,8,9,10]]]], + ['M0 0A0 2 0 0 1 3 4', [['M',[0,0]],['L',[3,4]]]], + ['M0 0A2 2 0 0 1 0 0', [['M',[0,0]],['L',[0,0]]]], + ['M1 2Zl3 4m5 6l1 1', [['M',[1,2]],['Z',[]],['L',[4,6]],['M',[9,12]],['L',[10,13]]]] + ]; + const packed = data => data.map(s => [s.type, s.values]); + 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 = `${ownerIndex}/${docIndex}/${calleeIndex}/`; + const path = doc.createElementNS(ns, 'path'); + const get = callee.SVGPathElement.prototype.getPathData; + const set = callee.SVGPathElement.prototype.setPathData; + const put = data => set.call(path, data); + const read = settings => get.call(path, settings); + const baseline = () => path.setAttribute('d', 'M 99 99'); + for (const [method, arity] of [['getPathData',0],['setPathData',1]]) check(prefix+method+'/metadata', () => { + const d = Object.getOwnPropertyDescriptor(callee.SVGPathElement.prototype, method); + return typeof d.value === 'function' && d.enumerable && d.configurable && d.writable && d.value.name === method && d.value.length === arity && !Object.hasOwn(path,method); + }); + for (const [d, expected] of rawCases) check(prefix+'raw/'+d, () => { + path.setAttribute('d',d); return equal(packed(read()),expected); + }); + for (const [d, expected] of normalizedCases) check(prefix+'normalized/'+d, () => { + path.setAttribute('d',d); return equal(packed(read({normalize:true})),expected); + }); + check(prefix+'arc normalization', () => { + path.setAttribute('d','M6 10A10 10 10 0 0 15 10'); + const data=read({normalize:true}), expected=[8.8305,11.4263,12.1695,11.4263,15,10]; + return data.length===2 && data[0].type==='M' && data[1].type==='C' && data[1].values.every((x,i)=>Math.abs(x-expected[i])<.0005); + }); + check(prefix+'large arc cubics', () => { + path.setAttribute('d','M0 0A10 10 0 1 0 20 0'); + const data=read({normalize:true}), end=data.at(-1).values.slice(-2); + return data.length>2 && data[0].type==='M' && data.slice(1).every(x=>x.type==='C') && Math.abs(end[0]-20)<.0005 && Math.abs(end[1])<.0005; + }); + check(prefix+'fresh mutable dictionaries', () => { + path.setAttribute('d','M1 2L3 4'); + const a=read(), b=read(); a[0].type='Z'; a[0].values[0]=900; + const d=Object.getOwnPropertyDescriptor(b[0],'type'); + return a!==b && a[0]!==b[0] && a[0].values!==b[0].values && equal(packed(b),[['M',[1,2]],['L',[3,4]]]) && d.enumerable && d.configurable && d.writable && equal(Object.keys(b[0]),['type','values']) && equal(packed(read()),packed(b)); + }); + check(prefix+'base attribute ignores CSS d', () => { + path.setAttribute('d','M1 2h3'); path.style.setProperty('d','path("M0 0L100 100")'); + const result=equal(packed(read()),[['M',[1,2]],['h',[3]]]); path.style.removeProperty('d'); return result; + }); + for (const settings of [undefined,null,{}, {normalize:false}]) check(prefix+'default settings/'+JSON.stringify(settings), () => { + path.setAttribute('d','m1 2h3'); return equal(packed(read(settings)),[['m',[1,2]],['h',[3]]]); + }); + check(prefix+'settings getter once', () => { + path.setAttribute('d','m1 2h3'); let n=0; + const data=read({get normalize(){n++;return true;}}); return n===1 && equal(packed(data),[['M',[1,2]],['L',[4,2]]]); + }); + check(prefix+'settings Boolean object has no coercion', () => { + path.setAttribute('d','m1 2h3'); let n=0; + const data=read({normalize:{valueOf(){n++;throw 42;},toString(){n++;throw 42;}}}); + return n===0 && equal(packed(data),[['M',[1,2]],['L',[4,2]]]); + }); + check(prefix+'settings getter exception', () => { + const sentinel={}; try {read({get normalize(){throw sentinel;}});} catch(e) {return e===sentinel;} return false; + }); + check(prefix+'set plain dictionaries', () => put([{type:'M',values:[1,2]},{type:'L',values:[3,4]}])===undefined && path.getAttribute('d')==='M 1 2 L 3 4'); + check(prefix+'generator and typed array values', () => { + put((function*(){yield {type:'m',values:new Float32Array([1,2])};yield {type:'l',values:new Float64Array([3,4])};yield {type:'z',values:[]};})()); + return path.getAttribute('d')==='m 1 2 l 3 4 Z'; + }); + check(prefix+'inherited dictionary members', () => { + put([Object.create({type:'M',values:[1,2]})]); return path.getAttribute('d')==='M 1 2'; + }); + check(prefix+'dictionary and numeric conversion order', () => { + const trace=[]; + put([{get values(){trace.push('values');return [{valueOf(){trace.push('x');return 1;}},{valueOf(){trace.push('y');return 2;}}];},get type(){trace.push('type');return {toString(){trace.push('string');return 'M';}};}}]); + return trace.join()==='type,string,values,x,y' && path.getAttribute('d')==='M 1 2'; + }); + check(prefix+'arc flags use nonzero truth', () => { + put([{type:'M',values:[0,0]},{type:'A',values:[-2,3,45,2,-5,4,5]}]); + return path.getAttribute('d')==='M 0 0 A -2 3 45 1 1 4 5'; + }); + for (const [name, invalid] of [['unknown',{type:'X',values:[]}],['multi-letter',{type:'MM',values:[3,4]}],['arity',{type:'L',values:[3]}],['extra',{type:'L',values:[3,4,5]}],['close-extra',{type:'Z',values:[1]}],['nan',{type:'L',values:[NaN,1]}],['infinity',{type:'L',values:[Infinity,1]}],['overflow',{type:'L',values:[1e40,1]}]]) check(prefix+'valid prefix/'+name, () => { + put([{type:'M',values:[1,2]},invalid,{type:'L',values:[8,9]}]); return path.getAttribute('d')==='M 1 2'; + }); + for (const input of [[],[{type:'L',values:[1,2]}],[{type:'M',values:[1]}],[{type:'M',values:[NaN,1]}]]) check(prefix+'empty removes/'+JSON.stringify(input), () => { + baseline();put(input);return !path.hasAttribute('d') && read().length===0; + }); + for (const input of [undefined,null,{},1,'M',[{}],[{type:'M'}],[{values:[1,2]}],[{type:'M',values:{0:1,1:2,length:2}}],[{type:'M',values:[Symbol(),0]}],[{type:'M',values:[1n,0]}]]) check(prefix+'conversion error/'+String(input), () => { + baseline(); return errorIn(callee,()=>put(input)) && path.getAttribute('d')==='M 99 99'; + }); + check(prefix+'required sequence argument', () => errorIn(callee,()=>set.call(path))); + check(prefix+'late dictionary exception is atomic even after invalid segment', () => { + baseline(); const sentinel={}; + try {put([{type:'X',values:[]},{get type(){throw sentinel;},values:[]}]);} catch(e) {return e===sentinel && path.getAttribute('d')==='M 99 99';} return false; + }); + check(prefix+'late numeric exception is atomic', () => { + baseline(); const sentinel={}; let n=0; + try {put([{type:'M',values:[1,2]},{type:'L',values:[{valueOf(){n++;throw sentinel;}},0]}]);} catch(e) {return e===sentinel && n===1 && path.getAttribute('d')==='M 99 99';} return false; + }); + check(prefix+'attribute MutationObserver', () => { + baseline(); const mo=new owner.MutationObserver(()=>{});mo.observe(path,{attributes:true,attributeOldValue:true}); + put([{type:'M',values:[1,2]}]); const first=mo.takeRecords();put([]);const second=mo.takeRecords();mo.disconnect(); + return first.length===1 && second.length===1 && first[0].attributeName==='d' && first[0].oldValue==='M 99 99' && second[0].oldValue==='M 1 2' && !path.hasAttribute('d'); + }); + let traps=0; const author=new owner.Proxy(path,{get(){traps++;throw 42;},getPrototypeOf(){traps++;throw 42;}}); + const revoked=owner.Proxy.revocable(path,{});revoked.revoke(); + for (const [index, receiver] of [{},Object.create(callee.SVGPathElement.prototype),Object.create(path),author,revoked.proxy,doc,doc.createElementNS(ns,'rect'),doc.createElement('div')].entries()) { + check(prefix+'get receiver/'+index, () => {let reads=0;return errorIn(callee,()=>get.call(receiver,{get normalize(){reads++;return true;}})) && reads===0 && traps===0;}); + check(prefix+'set receiver/'+index, () => {let reads=0;return errorIn(callee,()=>set.call(receiver,{get [Symbol.iterator](){reads++;throw 42;}})) && reads===0 && traps===0;}); + } + } + } + globalThis.__svgPathDataResults={complete:true,total:checks.length,passed:checks.filter(x=>x.passed).length,checks}; + globalThis.__uiEventResults=globalThis.__svgPathDataResults; + return true; +})() diff --git a/moli-renderer-v8/src/script_vm/tests/svg_path_data.rs b/moli-renderer-v8/src/script_vm/tests/svg_path_data.rs new file mode 100644 index 0000000000..dbcc3f86fd --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/svg_path_data.rs @@ -0,0 +1,70 @@ +use super::*; + +#[test] +fn svg_path_data_converts_dictionaries_and_preserves_base_path_mutations() { + let mut vm = new_storage_page_task_executor_test_vm("https://svg-path-data.test/"); + vm.eval("document.body.innerHTML = ''") + .unwrap(); + vm.eval(include_str!("svg_path_data.js")).unwrap(); + assert_eq!(vm.eval("__svgPathDataResults.complete").unwrap(), "true"); + assert_eq!(vm.eval("__svgPathDataResults.total").unwrap(), "1264"); + assert_eq!( + vm.eval("JSON.stringify(__svgPathDataResults.checks.filter(row => !row.passed))") + .unwrap(), + "[]" + ); +} + +#[test] +fn svg_path_data_accepts_registered_native_proxies_and_updates_geometry() { + let mut vm = new_storage_page_task_executor_test_vm("https://svg-path-data-proxy.test/"); + vm.eval( + r#" + document.body.innerHTML=''; + globalThis.child=document.querySelector('iframe').contentWindow; + "#, + ) + .unwrap(); + let context_ptr = &vm.page_default_context as *const _; + vm.with_context_scope_by_ptr_and_checkpoint_for_test(context_ptr, |scope, _| { + let global = scope.get_current_context().global(scope); + let key = crate::util::v8str(scope, "path"); + let path = + v8::Local::::try_from(global.get(scope, key.into()).unwrap()).unwrap(); + let handler = crate::util::new_null_prototype_object(scope); + let proxy = v8::Proxy::new(scope, path, handler).unwrap(); + moli_webapi_declare::register_web_api_proxy(scope, proxy).unwrap(); + let key = crate::util::v8str(scope, "nativeProxy"); + assert_eq!( + global.create_data_property(scope, key.into(), proxy.into()), + Some(true) + ); + Ok(()) + }) + .unwrap(); + assert_eq!(vm.eval(r#"(() => { + const path=document.querySelector('#path'); + for (const realm of [window, child]) { + const get=realm.SVGPathElement.prototype.getPathData; + const set=realm.SVGPathElement.prototype.setPathData; + path.setAttribute('d','M0 0L3 4'); + if (path.getTotalLength()!==5 || get.call(nativeProxy)[1].type!=='L') throw Error('proxy read'); + const input=[{type:'M',values:[0,0]},{type:'L',values:[6,8]}]; + set.call(nativeProxy,input); + input[1].values[0]=900; + if (path.getTotalLength()!==10 || get.call(nativeProxy)[1].values[0]!==6) throw Error('path mutation'); + let reads=0, traps=0; + const author=new Proxy(nativeProxy,{get(){traps++;throw 42;},getPrototypeOf(){traps++;throw 42;}}); + const revoked=Proxy.revocable(nativeProxy,{});revoked.revoke(); + for (const receiver of [author,revoked.proxy,Object.create(nativeProxy)]) { + for (const method of [get,set]) { + let error; + const poison=new Proxy({}, {get(){reads++;throw 42;}}); + try {method.call(receiver,poison);} catch(e) {error=e;} + if (!error || Object.getPrototypeOf(error)!==realm.TypeError.prototype || reads || traps) throw Error('author receiver accepted'); + } + } + } + return true; + })()"#).unwrap(), "true"); +} diff --git a/moli-svg/src/lib.rs b/moli-svg/src/lib.rs index 1ed33075c4..d60d9e1c35 100644 --- a/moli-svg/src/lib.rs +++ b/moli-svg/src/lib.rs @@ -15,6 +15,7 @@ pub use length::{ parse_point_list, }; pub use matrix::{SvgMatrixComponents, serialize_number}; +pub use path::{SvgPathSegment, parse_path_segments}; pub use transform::{ SvgTransform, SvgTransformKind, consolidate_transform_matrices, parse_transform_attribute, serialize_transform_list, diff --git a/moli-svg/src/path.rs b/moli-svg/src/path.rs index 3c2065a649..a6a565fbd6 100644 --- a/moli-svg/src/path.rs +++ b/moli-svg/src/path.rs @@ -1,5 +1,67 @@ use kurbo::BezPath; -use svgtypes::{SimplePathSegment, SimplifyingPathParser}; +use svgtypes::{PathParser, PathSegment, SimplePathSegment, SimplifyingPathParser}; + +/// A parsed SVG command retaining its relative form and original coordinates. +/// Consumers choose their own numeric boundary, such as SVG DOM binary32. +#[derive(Debug, Clone, PartialEq)] +pub struct SvgPathSegment { + pub command: char, + pub values: Vec, +} + +/// Read complete commands until the first grammar error, without CSS clamping. +pub fn parse_path_segments(raw: &str) -> impl Iterator + '_ { + PathParser::from(path_data_before_invalid_number(raw)) + .map_while(Result::ok) + .map(|segment| { + let command = segment.command() as char; + let values = match segment { + PathSegment::MoveTo { x, y, .. } + | PathSegment::LineTo { x, y, .. } + | PathSegment::SmoothQuadratic { x, y, .. } => vec![x, y], + PathSegment::HorizontalLineTo { x, .. } => vec![x], + PathSegment::VerticalLineTo { y, .. } => vec![y], + PathSegment::CurveTo { + x1, + y1, + x2, + y2, + x, + y, + .. + } => vec![x1, y1, x2, y2, x, y], + PathSegment::SmoothCurveTo { x2, y2, x, y, .. } => vec![x2, y2, x, y], + PathSegment::Quadratic { x1, y1, x, y, .. } => vec![x1, y1, x, y], + PathSegment::EllipticalArc { + rx, + ry, + x_axis_rotation, + large_arc, + sweep, + x, + y, + .. + } => vec![ + rx, + ry, + x_axis_rotation, + f64::from(u8::from(large_arc)), + f64::from(u8::from(sweep)), + x, + y, + ], + PathSegment::ClosePath { .. } => vec![], + }; + SvgPathSegment { + command: if command.eq_ignore_ascii_case(&'z') { + 'Z' + } else { + command + }, + values, + } + }) +} pub(crate) fn path_geometry(raw: &str) -> Option { let mut path = BezPath::new(); @@ -79,3 +141,35 @@ fn path_data_before_invalid_number(raw: &str) -> &str { } raw } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn raw_path_segments_preserve_commands_and_stop_at_invalid_suffixes() { + let segments: Vec<_> = + parse_path_segments("m1 2 3 4h5v6c1 2 3 4 5 6s7 8 9 10q1 2 3 4t5 6a2 3 45 1 0 7 8z") + .collect(); + assert_eq!( + segments.iter().map(|s| s.command).collect::(), + "mlhvcsqtaZ" + ); + assert_eq!(segments[1].values, vec![3.0, 4.0]); + assert_eq!(segments[8].values, vec![2.0, 3.0, 45.0, 1.0, 0.0, 7.0, 8.0]); + assert!(segments[9].values.is_empty()); + for raw in ["M1 2L", "M1 2L1. 2L3 4", "M1 2L1e 2L3 4", "M1 2X3 4"] { + let parsed: Vec<_> = parse_path_segments(raw).collect(); + assert_eq!(parsed.len(), 1, "{raw}"); + assert_eq!(parsed[0].command, 'M'); + assert_eq!(parsed[0].values, vec![1.0, 2.0]); + } + assert_eq!(parse_path_segments("Z").count(), 0); + assert_eq!(parse_path_segments("L1 2").count(), 0); + // Geometry users retain double coordinates; DOM users must reject, + // rather than clamp, a value that overflows their binary32 boundary. + let huge: Vec<_> = parse_path_segments("M0 0L1e40 2").collect(); + assert_eq!(huge[1].values[0], 1e40); + assert!(!(huge[1].values[0] as f32).is_finite()); + } +} diff --git a/moli-webidl/src/lib.rs b/moli-webidl/src/lib.rs index 4bd37ebeba..6fb4e9d156 100644 --- a/moli-webidl/src/lib.rs +++ b/moli-webidl/src/lib.rs @@ -98,7 +98,12 @@ pub use moli_webidl_callback::{ pub use moli_webidl_derive::{WebIdlArgs, WebIdlDictionary, WebIdlEnum}; pub use traits::{ParseOutcome, WebIdlArguments, WebIdlConverter, WebIdlDictionary, WebIdlEnum}; pub use types::{ - Boolean, BufferSource, ByteString, ClampedUnsignedLong, ClampedUnsignedShort, Dictionary, DomString, DomString16, Double, EnforceRangeLong, EnforceRangeLongLong, EnforceRangeUnsignedLong, EnforceRangeUnsignedLongLong, EnumValue, EventListenerOptions, InterfaceObject, InterfaceOptions, Long, Record, Sequence, Short, StringOptions, Uint8ArrayOptions, UnrestrictedDouble, UnrestrictedFloat, UnsignedLong, UnsignedLongLong, UnsignedShort, UsvString, + Boolean, BufferSource, ByteString, ClampedUnsignedLong, ClampedUnsignedShort, Dictionary, + DomString, DomString16, Double, EnforceRangeLong, EnforceRangeLongLong, + EnforceRangeUnsignedLong, EnforceRangeUnsignedLongLong, EnumValue, EventListenerOptions, + InterfaceObject, InterfaceOptions, Long, Record, Sequence, Short, StringOptions, + Uint8ArrayOptions, UnrestrictedDouble, UnrestrictedFloat, UnsignedLong, UnsignedLongLong, + UnsignedShort, UsvString, }; /// Restricted WebIDL float represented as a finite binary32 value.