feat(svg): implement structured path data access

This commit is contained in:
ldm0
2026-10-11 18:06:48 +08:00
parent 5d193eb730
commit 82bfb29590
8 changed files with 660 additions and 2 deletions
@@ -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)
}
@@ -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<f32>,
}
impl PathSegment {
fn new(command: char, values: impl Into<Vec<f32>>) -> 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<webidl::Dictionary<PathSegment>>,
}
#[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::<GetArgs>(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<f32> = 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<v8::Local<v8::Value>> = 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::<SetArgs>(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<Item = PathSegment>) -> 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<CoordPair>) -> [f32; 2] {
let point = point.into();
[point.x, point.y]
}
fn control(point: ControlPoint<SVGPathPosition, f32>) -> [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<PathSegment> {
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
}
@@ -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;
@@ -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('<svg xmlns="'+ns+'"/>', '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;
})()
@@ -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 = '<iframe></iframe>'")
.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='<svg><path id="path" d="M0 0L3 4"/></svg><iframe></iframe>';
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::<v8::Object>::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");
}
+1
View File
@@ -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,
+95 -1
View File
@@ -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<f64>,
}
/// Read complete commands until the first grammar error, without CSS clamping.
pub fn parse_path_segments(raw: &str) -> impl Iterator<Item = SvgPathSegment> + '_ {
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<BezPath> {
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::<String>(),
"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());
}
}
+6 -1
View File
@@ -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.