fix(svg): enforce float distance conversion and empty-path errors

This commit is contained in:
ldm0
2026-10-12 16:06:01 +08:00
committed by Donough Liu
parent d9928760ed
commit da7a9b254b
6 changed files with 152 additions and 4 deletions
@@ -3951,7 +3951,7 @@ pub(super) fn svg_geometry_get_total_length_callback<'s>(
let length = svg_geometry_segments(scope, receiver)
.iter()
.fold(0.0, |total, segment| total + segment.length());
rv.set(v8::Number::new(scope, length).into());
rv.set(v8::Number::new(scope, f64::from(length as f32)).into());
}
pub(super) fn svg_geometry_get_point_at_length_callback<'s>(
@@ -3965,7 +3965,16 @@ pub(super) fn svg_geometry_get_point_at_length_callback<'s>(
return;
};
let segments = svg_geometry_segments(scope, receiver);
let point = svg_geometry::point_at_length(&segments, parsed.distance);
if segments.is_empty() {
throw_dom_exception(
scope,
"InvalidStateError",
11,
"The SVG geometry has no point on its path.",
);
return;
}
let point = svg_geometry::point_at_length(&segments, f64::from(parsed.distance));
rv.set(build_dom_point_like(scope, point.x, point.y).into());
}
@@ -368,8 +368,8 @@ struct SvgAngleConvertToSpecifiedUnitsArgs {
#[derive(webidl::WebIdlArgs)]
#[webidl(prefix = "SVGGeometryElement.getPointAtLength")]
struct SvgGeometryPointAtLengthArgs {
#[webidl(required, converter = "double")]
distance: f64,
#[webidl(required, converter = "float")]
distance: f32,
}
#[derive(webidl::WebIdlArgs)]
@@ -0,0 +1,46 @@
(() => {
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) <= Math.max(1e-12,Math.abs(b)*2e-14);
const realms = [window,document.querySelector('iframe').contentWindow];
const values = [0,-0,1,1+2**-24,1+3*2**-24,16777217,16777219,1e-46,-1,1e20,-1e20,Math.fround(3.4028234e38),null,true,'1.0000001788139343'];
const invalid = [undefined,NaN,Infinity,-Infinity,Number.MAX_VALUE,-Number.MAX_VALUE,2**128,-(2**128),Symbol(),1n];
const typeError = (realm,fn) => {try {fn();} catch(error) {return Object.getPrototypeOf(error)===realm.TypeError.prototype;} return false;};
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()) {
const root=doc.createElementNS(ns,'svg');root.setAttribute('width','200');root.setAttribute('height','100');(doc.body||doc.documentElement).appendChild(root);
for (const [calleeIndex,callee] of realms.entries()) {
const prefix=`float/${ownerIndex}/${docIndex}/${calleeIndex}`;
const point=callee.SVGGeometryElement.prototype.getPointAtLength, total=callee.SVGGeometryElement.prototype.getTotalLength;
const path=doc.createElementNS(ns,'path');path.setAttribute('d','M0 0L134217728 0');root.appendChild(path);
for (const [index,value] of values.entries()) {
check(prefix+'/distance/'+index,()=>{const p=point.call(path,value);return near(p.x,Math.min(134217728,Math.max(0,Math.fround(Number(value)))))&&p.y===0;});
check(prefix+'/object-distance/'+index,()=>{let n=0;const p=point.call(path,{valueOf(){n++;return value;}});return n===1&&near(p.x,Math.min(134217728,Math.max(0,Math.fround(Number(value)))))&&p.y===0;});
}
const empty=doc.createElementNS(ns,'path');root.appendChild(empty);
for (const [index,value] of invalid.entries()) {
for (const [tag,element] of [['path',path],['empty',empty]]) {
check(prefix+'/'+tag+'/invalid/'+index,()=>typeError(callee,()=>point.call(element,value)));
}
}
check(prefix+'/arity',()=>typeError(callee,()=>point.call(path)));
check(prefix+'/conversion-exception',()=>{const sentinel={};let n=0;try {point.call(path,{valueOf(){n++;throw sentinel;}});} catch(error) {return error===sentinel&&n===1;}return false;});
check(prefix+'/conversion-mutates-path',()=>{let n=0;const p=point.call(path,{valueOf(){n++;path.setAttribute('d','M7 9L134217735 9');return 16777217;}});return n===1&&near(p.x,16777223)&&p.y===9;});
for (const [index,value] of [0.1,1.00000007,16777217,16777219,1000.00001].entries()) {
const line=doc.createElementNS(ns,'line');line.setAttribute('x2',String(value));root.appendChild(line);
check(prefix+'/total-float/'+index,()=>Object.is(total.call(line),Math.fround(value)));
line.remove();
}
let conversions=0,traps=0;
const author=new Proxy(path,{get(){traps++;throw 42;},getPrototypeOf(){traps++;throw 42;}}),revoked=Proxy.revocable(path,{});revoked.revoke();
for (const [index,receiver] of [{},Object.create(path),Object.create(callee.SVGGeometryElement.prototype),author,revoked.proxy,doc.createElementNS(ns,'g')].entries()) {
check(prefix+'/brand/'+index,()=>typeError(callee,()=>point.call(receiver,{valueOf(){conversions++;return Number.MAX_VALUE;}}))&&conversions===0&&traps===0);
}
path.remove();empty.remove();
}
root.remove();
}
}
globalThis.__svgFloatResults={complete:true,total:checks.length,passed:checks.filter(row=>row.passed).length,checks};globalThis.__uiEventResults=globalThis.__svgFloatResults;return true;
})()
@@ -90,3 +90,34 @@ fn svg_geometry_methods_use_registered_proxy_geometry_without_author_traps() {
"true"
);
}
#[test]
fn svg_distance_queries_distinguish_empty_paths_from_zero_length_geometry() {
let mut vm = new_storage_page_task_executor_test_vm("https://svg-empty-paths.test/");
vm.eval("document.body.innerHTML = '<iframe></iframe>'")
.unwrap();
vm.eval(include_str!("svg_geometry_path_states.js"))
.unwrap();
assert_eq!(vm.eval("__svgGeometryResults.complete").unwrap(), "true");
assert_eq!(vm.eval("__svgGeometryResults.total").unwrap(), "3616");
assert_eq!(
vm.eval("JSON.stringify(__svgGeometryResults.checks.filter(row => !row.passed))")
.unwrap(),
"[]",
);
}
#[test]
fn svg_distance_queries_use_webidl_float_conversion() {
let mut vm = new_storage_page_task_executor_test_vm("https://svg-geometry-float.test/");
vm.eval("document.body.innerHTML = '<iframe></iframe>'")
.unwrap();
vm.eval(include_str!("svg_geometry_float.js")).unwrap();
assert_eq!(vm.eval("__svgFloatResults.complete").unwrap(), "true");
assert_eq!(vm.eval("__svgFloatResults.total").unwrap(), "1024");
assert_eq!(
vm.eval("JSON.stringify(__svgFloatResults.checks.filter(row => !row.passed))")
.unwrap(),
"[]"
);
}
@@ -0,0 +1,60 @@
(() => {
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 definitions = [
['path/missing','path',{},null,0], ['path/empty','path',{d:''},null,0],
['path/none','path',{d:'none'},null,0], ['path/invalid-first','path',{d:'L 7 9'},null,0],
['path/moveto','path',{d:'M 7 9'},[7,9],0],
['path/multiple-moveto','path',{d:'M 7 9 M 12 13'},[7,9],0],
['path/zero-line','path',{d:'M 7 9 L 7 9'},[7,9],0],
['path/invalid-tail','path',{d:'M 7 9 X 1 2'},[7,9],0],
['line/missing','line',{},[0,0],0], ['line/zero','line',{x1:7,y1:9,x2:7,y2:9},[7,9],0],
['rect/missing','rect',{},null,0], ['rect/zero','rect',{x:7,y:9,width:0,height:0},null,0],
['rect/zero-width','rect',{x:7,y:9,width:0,height:10},[7,9],20],
['rect/zero-height','rect',{x:7,y:9,width:10,height:0},[7,9],20],
['circle/missing','circle',{},null,0], ['circle/zero','circle',{cx:7,cy:9,r:0},null,0],
['ellipse/missing','ellipse',{},null,0], ['ellipse/zero','ellipse',{cx:7,cy:9,rx:0,ry:0},null,0],
['ellipse/zero-rx','ellipse',{cx:7,cy:9,rx:0,ry:10},[7,9],40],
['ellipse/zero-ry','ellipse',{cx:7,cy:9,rx:10,ry:0},[17,9],40],
['polyline/missing','polyline',{},null,0], ['polygon/missing','polygon',{},null,0],
['polyline/one-point','polyline',{points:'7 9'},[7,9],0],
['polygon/one-point','polygon',{points:'7 9'},[7,9],0],
];
const errorIn = (realm,name,fn) => {try {fn();} catch(error) {return Object.getPrototypeOf(error) === realm[name==='TypeError'?'TypeError':'DOMException'].prototype && error.name===name && (name==='TypeError'||error.code===11);} return false;};
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()) {
const root=doc.createElementNS(ns,'svg');(doc.body||doc.documentElement).appendChild(root);
for(const [calleeIndex,callee] of realms.entries()) {
const point=callee.SVGGeometryElement.prototype.getPointAtLength, length=callee.SVGGeometryElement.prototype.getTotalLength;
for(const [name,tag,attrs,expected,total] of definitions) {
const prefix=`${ownerIndex}/${docIndex}/${calleeIndex}/${name}`;
const element=doc.createElementNS(ns,tag);for(const [key,value] of Object.entries(attrs))element.setAttribute(key,value);root.appendChild(element);
for(const display of ['','none']) {
element.style.display=display;
check(prefix+'/length/'+display,()=>total===0?Object.is(length.call(element),0):Math.abs(length.call(element)-total)<1e-6);
check(prefix+'/point/'+display,()=> {
if(expected===null)return errorIn(callee,'InvalidStateError',()=>point.call(element,300));
const value=point.call(element,300);return Object.getPrototypeOf(value)===callee.DOMPoint.prototype&&Math.abs(value.x-expected[0])<1e-7&&Math.abs(value.y-expected[1])<1e-7&&value.z===0&&value.w===1;
});
}
check(prefix+'/detach',()=> {element.remove();if(expected===null)return errorIn(callee,'InvalidStateError',()=>point.call(element,300));const p=point.call(element,300);return Math.abs(p.x-expected[0])<1e-7&&Math.abs(p.y-expected[1])<1e-7;});
if(expected===null) {
for(const argument of [NaN,Infinity,-Infinity,Symbol(),1n])check(prefix+'/conversion/'+typeof argument+'/'+String(argument),()=>errorIn(callee,'TypeError',()=>point.call(element,argument)));
check(prefix+'/missing-argument',()=>errorIn(callee,'TypeError',()=>point.call(element)));
check(prefix+'/conversion-exception',()=> {const sentinel={};let n=0;try{point.call(element,{valueOf(){n++;throw sentinel;}});}catch(error){return error===sentinel&&n===1;}return false;});
check(prefix+'/conversion-before-empty',()=> {let n=0;return errorIn(callee,'InvalidStateError',()=>point.call(element,{valueOf(){n++;return 0;}}))&&n===1;});
}
}
const path=doc.createElementNS(ns,'path');root.appendChild(path);
check(`${ownerIndex}/${docIndex}/${calleeIndex}/conversion-creates-path`,()=> {let n=0;const p=point.call(path,{valueOf(){n++;path.setAttribute('d','M 7 9 L 17 9');return 5;}});return n===1&&p.x===12&&p.y===9;});
check(`${ownerIndex}/${docIndex}/${calleeIndex}/conversion-removes-path`,()=> {let n=0;return errorIn(callee,'InvalidStateError',()=>point.call(path,{valueOf(){n++;path.removeAttribute('d');return 5;}}))&&n===1;});
let traps=0;const proxy=new Proxy(path,{get(){traps++;throw 42;},getPrototypeOf(){traps++;throw 42;}});const revoked=Proxy.revocable(path,{});revoked.revoke();
for(const [index,receiver] of [{},Object.create(callee.SVGGeometryElement.prototype),Object.create(path),proxy,revoked.proxy,doc,doc.createElement('div'),doc.createElementNS(ns,'g')].entries())check(`${ownerIndex}/${docIndex}/${calleeIndex}/brand/${index}`,()=> {let n=0;return errorIn(callee,'TypeError',()=>point.call(receiver,{valueOf(){n++;return 0;}}))&&n===0&&traps===0;});
}
}
}
globalThis.__svgGeometryResults={complete:true,total:checks.length,passed:checks.filter(x=>x.passed).length,checks};globalThis.__uiEventResults=globalThis.__svgGeometryResults;return true;
})()
@@ -85,6 +85,8 @@ test(function () {
test(function () {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
rect.setAttribute("width", "10");
rect.setAttribute("height", "10");
svg.appendChild(rect);
const point = rect.getPointAtLength(0);