mirror of
https://github.com/lexmount/moli.git
synced 2026-11-03 16:07:36 +00:00
feat(svg): implement structured path data access
This commit is contained in:
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user