fix(canvas): expose native transform snapshots

Return a fresh native DOMMatrix snapshot from CanvasRenderingContext2D.getTransform and OffscreenCanvasRenderingContext2D.getTransform. Reuse the saved native canvas transform state and generated native receiver checks. Window and Worker regressions cover transform updates, save/restore, snapshot independence, dimension resets, mutated public constructors, cross-realm calls and invalid receivers. Expose the existing DOMMatrix and DOMPoint interface pairs in Workers so the snapshot return type and transformPoint result remain usable there.
This commit is contained in:
ldm0
2026-10-08 19:25:02 +08:00
committed by Donough Liu
parent b21b4f0561
commit e802e3790b
10 changed files with 314 additions and 9 deletions
@@ -197,6 +197,8 @@ mod state;
mod transform;
mod webgl;
pub(crate) use transform::install_canvas_transform_bindings;
pub(crate) use backing_store::{
attach_canvas_like_context_object, canvas_like_to_data_url,
reset_html_canvas_backing_store_for_dimension_assignment,
@@ -1,6 +1,66 @@
//! WebIDL conversion and DOMMatrix2DInit alias validation for Canvas transforms.
use crate::{util::throw_type_error, webidl};
use crate::{util::throw_type_error, web_api_interfaces, webidl};
use moli_geometry::DomMatrixComponents;
use moli_webapi_declare::WebApiFunctionTemplate;
#[derive(WebApiFunctionTemplate)]
#[webapi(interface = web_api_interfaces::CanvasRenderingContext2D, enumerable, receiver)]
struct CanvasTransformPrototypeDeclaration {
#[webapi(method = "getTransform", length = 0, callback = get_transform_callback)]
get_transform: (),
}
#[derive(WebApiFunctionTemplate)]
#[webapi(interface = web_api_interfaces::OffscreenCanvasRenderingContext2D, enumerable, receiver)]
struct OffscreenCanvasTransformPrototypeDeclaration {
#[webapi(method = "getTransform", length = 0, callback = get_transform_callback)]
get_transform: (),
}
pub(crate) fn install_canvas_transform_bindings<'s>(
scope: &mut v8::PinScope<'s, '_, ()>,
prototype: v8::Local<'s, v8::ObjectTemplate>,
interface: &str,
) {
match interface {
"CanvasRenderingContext2D" => {
CanvasTransformPrototypeDeclaration::initialize_prototype_template(scope, prototype);
}
"OffscreenCanvasRenderingContext2D" => {
OffscreenCanvasTransformPrototypeDeclaration::initialize_prototype_template(
scope, prototype,
);
}
_ => unreachable!("only 2D canvas contexts have transform bindings"),
}
}
fn get_transform_callback<'s>(
scope: &mut v8::PinScope<'s, '_>,
args: v8::FunctionCallbackArguments<'s>,
mut rv: v8::ReturnValue<'_, v8::Value>,
) {
let context = moli_webapi_declare::web_api_object_target(scope, args.this())
.expect("validated canvas receiver has native identity");
let [a, b, c, d, e, f] = super::state::canvas_path_state(scope, context)
.borrow()
.transform()
.coefficients;
let matrix = super::super::geometry_runtime::build_dom_matrix_object(
scope,
DomMatrixComponents {
m11: a,
m12: b,
m21: c,
m22: d,
m41: e,
m42: f,
..DomMatrixComponents::identity()
},
);
rv.set(matrix.into());
}
#[derive(Default, webidl::WebIdlDictionary)]
#[webidl(prefix = "DOMMatrix2DInit")]
@@ -21,6 +21,10 @@ pub(in crate::context_bootstrap) const STORAGE_INTERFACE_NAMES: &[&str] = &[
pub(in crate::context_bootstrap) const WORKER_SHARED_INTERFACE_NAMES: &[&str] = &[
"DOMRectReadOnly",
"DOMRect",
"DOMPointReadOnly",
"DOMPoint",
"DOMMatrixReadOnly",
"DOMMatrix",
"EventTarget",
"Event",
"CustomEvent",
@@ -109,9 +109,6 @@ macro_rules! dom_matrix_object_declaration {
}
}
fn identity() -> Self {
Self::from_components(DomMatrixComponents::identity())
}
}
};
}
@@ -566,7 +563,14 @@ pub(in crate::context_bootstrap) fn build_dom_point_object<'s>(
pub(in crate::context_bootstrap) fn build_dom_matrix_identity_object<'s>(
scope: &mut v8::PinScope<'s, '_>,
) -> v8::Local<'s, v8::Object> {
DomMatrixObjectDeclaration::identity()
build_dom_matrix_object(scope, DomMatrixComponents::identity())
}
pub(in crate::context_bootstrap) fn build_dom_matrix_object<'s>(
scope: &mut v8::PinScope<'s, '_>,
components: DomMatrixComponents,
) -> v8::Local<'s, v8::Object> {
DomMatrixObjectDeclaration::from_components(components)
.bind(scope)
.expect("DOMMatrix declaration should bind")
}
@@ -574,7 +578,7 @@ pub(in crate::context_bootstrap) fn build_dom_matrix_identity_object<'s>(
fn build_dom_matrix_readonly_identity_object<'s>(
scope: &mut v8::PinScope<'s, '_>,
) -> v8::Local<'s, v8::Object> {
DomMatrixReadOnlyObjectDeclaration::identity()
DomMatrixReadOnlyObjectDeclaration::from_components(DomMatrixComponents::identity())
.bind(scope)
.expect("DOMMatrixReadOnly declaration should bind")
}
@@ -1904,7 +1908,7 @@ fn initialize_dom_matrix_identity_object<'s>(
scope: &mut v8::PinScope<'s, '_>,
object: v8::Local<'s, v8::Object>,
) {
DomMatrixObjectDeclaration::identity()
DomMatrixObjectDeclaration::from_components(DomMatrixComponents::identity())
.initialize(scope, object)
.expect("DOMMatrix declaration should initialize object");
}
@@ -32,8 +32,9 @@ use super::canvas::{
canvas_context_stroke_style_setter_callback, canvas_context_stroke_text_callback,
canvas_context_transform_callback, canvas_context_translate_callback,
canvas_gradient_add_color_stop_callback, install_canvas_template_bindings,
offscreen_canvas_convert_to_blob_callback, offscreen_canvas_get_context_callback,
webgl_boolean_callback, webgl_check_framebuffer_status_callback, webgl_create_buffer_callback,
install_canvas_transform_bindings, offscreen_canvas_convert_to_blob_callback,
offscreen_canvas_get_context_callback, webgl_boolean_callback,
webgl_check_framebuffer_status_callback, webgl_create_buffer_callback,
webgl_create_framebuffer_callback, webgl_create_program_callback,
webgl_create_renderbuffer_callback, webgl_create_shader_callback,
webgl_get_attrib_location_callback, webgl_get_context_attributes_callback,
@@ -688,6 +689,7 @@ pub(super) fn install_media_file_template_bindings<'s>(
CanvasRenderingContext2dTemplateDeclaration::initialize_prototype_template(
scope, proto,
);
install_canvas_transform_bindings(scope, proto, spec_name);
}
"WebGLRenderingContext" => {
let proto = template.prototype_template(scope);
@@ -0,0 +1,115 @@
(function canvasContract(owner, callee, kind, tag) {
const checks = [];
const check = (name, run) => {
try { checks.push({name: 'canvas/' + tag + '/' + name, passed: run() === true}); }
catch (error) { checks.push({name: 'canvas/' + tag + '/' + name, passed: false, error: String(error)}); }
};
const interfaceName = kind === 'element' ? 'CanvasRenderingContext2D' : 'OffscreenCanvasRenderingContext2D';
const makeCanvas = () => kind === 'element' ? owner.document.createElement('canvas') : new owner.OffscreenCanvas(96, 96);
const canvas = makeCanvas(); canvas.width = canvas.height = 96;
const ctx = canvas.getContext('2d');
const prototype = callee[interfaceName].prototype;
const method = prototype.getTransform;
const Matrix = callee.DOMMatrix;
const matrixPrototype = Matrix.prototype;
const get = (receiver = ctx, ...args) => {
if (typeof method !== 'function') throw new Error('getTransform missing');
return method.call(receiver, ...args);
};
const components = matrix => ['a', 'b', 'c', 'd', 'e', 'f'].map(name => matrix[name]);
const equal = (actual, expected) => actual.length === expected.length && actual.every((value, index) =>
value === expected[index] || Math.abs(value - expected[index]) <= 1e-12 * Math.max(1, Math.abs(expected[index])));
const matches = expected => equal(components(get()), expected);
const identity = [1, 0, 0, 1, 0, 0];
const throwsTypeError = run => { try { run(); return false; } catch (error) { return error instanceof callee.TypeError; } };
check('own descriptor', () => {
const d = Object.getOwnPropertyDescriptor(prototype, 'getTransform');
return !!d && d.value === method && d.enumerable && d.writable && d.configurable;
});
check('function metadata', () => typeof method === 'function' && method.length === 0 && method.name === 'getTransform' && method instanceof callee.Function);
check('initial identity', () => matches(identity));
check('DOMMatrix prototype in callee realm', () => Object.getPrototypeOf(get()) === matrixPrototype);
check('mutable and readonly brands', () => get() instanceof Matrix && get() instanceof callee.DOMMatrixReadOnly);
check('initial 2D and identity flags', () => get().is2D && get().isIdentity);
check('fresh objects', () => get() !== get());
check('no own expando components', () => !Object.hasOwn(get(), 'a') && !Object.hasOwn(get(), 'm11'));
check('Float64 mapping and realm', () => {
const array = get().toFloat64Array();
return array instanceof callee.Float64Array && equal(Array.from(array), [1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1]);
});
check('Float32 mapping and realm', () => get().toFloat32Array() instanceof callee.Float32Array);
check('JSON mapping', () => get().toJSON().m44 === 1 && get().toJSON().is2D === true);
check('ignored extra arguments', () => {
let traps = 0;
const extra = new Proxy({}, {get() { traps++; throw new Error('argument converted'); }});
return equal(components(get(ctx, extra)), identity) && traps === 0;
});
for (const [name, receiver] of [
['null', null], ['undefined', undefined], ['number', 0], ['string', ''], ['boolean', false],
['plain', {}], ['prototype', prototype], ['forged prototype', Object.create(prototype)],
['inherited real object', Object.create(ctx)], ['author Proxy', new Proxy(ctx, {})]
]) check('receiver ' + name, () => throwsTypeError(() => {
if (typeof method !== 'function') throw new Error('getTransform missing');
method.call(receiver);
}));
const revoked = Proxy.revocable(ctx, {}); revoked.revoke();
check('revoked Proxy receiver', () => throwsTypeError(() => get(revoked.proxy)));
check('receiver validation without author traps', () => {
let traps = 0;
const fake = new Proxy(ctx, {get() { traps++; throw new Error('receiver trap'); }, getPrototypeOf() { traps++; return prototype; }});
return throwsTypeError(() => get(fake)) && traps === 0;
});
if (owner.document) check('other context interface rejected', () => {
const other = kind === 'element' ? new owner.OffscreenCanvas(96,96) : owner.document.createElement('canvas');
return throwsTypeError(() => get(other.getContext('2d')));
});
check('six scalar coefficients', () => { ctx.setTransform(1, 2, 3, 4, 5, 6); return matches([1,2,3,4,5,6]); });
check('six scalar 4D mapping', () => equal(Array.from(get().toFloat64Array()), [1,2,0,0,3,4,0,0,0,0,1,0,5,6,0,1]));
check('nonidentity flag', () => !get().isIdentity && get().is2D);
check('snapshot mutation leaves context unchanged', () => { const m = get(); m.a = 8; m.e = 30; return matches([1,2,3,4,5,6]); });
check('snapshot mutator leaves context unchanged', () => { get().translateSelf(20,30).scaleSelf(2); return matches([1,2,3,4,5,6]); });
check('context mutation leaves snapshot unchanged', () => { const m = get(); ctx.setTransform(2,0,0,3,10,20); return equal(components(m),[1,2,3,4,5,6]) && matches([2,0,0,3,10,20]); });
check('postmultiply translation', () => { ctx.translate(4,5); return matches([2,0,0,3,18,35]); });
check('postmultiply scale', () => { ctx.scale(4,5); return matches([8,0,0,15,18,35]); });
check('postmultiply rotation', () => { ctx.rotate(Math.PI/2); return matches([0,15,-8,0,18,35]); });
check('reset transform', () => { ctx.resetTransform(); return matches(identity); });
check('noncommutative multiplication', () => { ctx.setTransform(1,2,3,4,5,6); ctx.transform(7,8,9,10,11,12); return matches([31,46,39,58,52,76]); });
check('fractional double state', () => { const a=[1.125,0.0625,-0.03125,2.25,5.125,-7.75]; ctx.setTransform(...a); return matches(a); });
check('singular matrix', () => { ctx.setTransform(0,0,0,0,5,6); return matches([0,0,0,0,5,6]); });
const names = ['a','b','c','d','e','f'];
const aliases = ['m11','m12','m21','m22','m41','m42'];
for (let index=0; index<6; index++) {
check('dictionary component ' + names[index], () => { ctx.setTransform({[names[index]]:2}); const a=identity.slice(); a[index]=2; return matches(a); });
check('dictionary alias ' + aliases[index], () => { ctx.setTransform({[aliases[index]]:3}); const a=identity.slice(); a[index]=3; return matches(a); });
check('inconsistent aliases atomic ' + names[index], () => { ctx.setTransform(1,2,3,4,5,6); let failed=false; try { ctx.setTransform({[names[index]]:1,[aliases[index]]:2}); } catch (e) { failed=e instanceof owner.TypeError; } return failed && matches([1,2,3,4,5,6]); });
}
for (const [label,value] of [['empty',{}],['undefined',undefined],['null',null]])
check('dictionary identity ' + label, () => { ctx.translate(20,30); ctx.setTransform(value); return matches(identity); });
check('no argument identity', () => { ctx.translate(20,30); ctx.setTransform(); return matches(identity); });
check('dictionary from snapshot', () => { ctx.setTransform(1,2,3,4,5,6); const m=get(); ctx.resetTransform(); ctx.setTransform(m); return matches([1,2,3,4,5,6]); });
for (const [label,value] of [['NaN',NaN],['positive Infinity',Infinity],['negative Infinity',-Infinity]]) {
check('nonfinite scalar ignored ' + label, () => { ctx.setTransform(1,2,3,4,5,6); ctx.setTransform(value,0,0,1,0,0); return matches([1,2,3,4,5,6]); });
check('nonfinite dictionary ignored ' + label, () => { ctx.setTransform(1,2,3,4,5,6); ctx.setTransform({a:value,m11:value}); return matches([1,2,3,4,5,6]); });
}
check('getter exception preserves state and identity', () => {
ctx.setTransform(1,2,3,4,5,6); const sentinel={}; let same=false;
try { ctx.setTransform({a:8,get e(){throw sentinel;}}); } catch(e) {same=e===sentinel;}
return same && matches([1,2,3,4,5,6]);
});
check('beginPath preserves transform', () => { ctx.beginPath(); return matches([1,2,3,4,5,6]); });
check('fresh independent context', () => matches([1,2,3,4,5,6]) && equal(components(get(makeCanvas().getContext('2d'))),identity));
check('repeated getContext preserves state', () => canvas.getContext('2d') === ctx && matches([1,2,3,4,5,6]));
check('width reset', () => { canvas.width = canvas.width; return matches(identity); });
check('height reset', () => { ctx.translate(20,30); canvas.height = canvas.height; return matches(identity); });
check('snapshot survives bitmap reset', () => { ctx.setTransform(1,2,3,4,5,6); const m=get(); canvas.width=canvas.width+1; return equal(components(m),[1,2,3,4,5,6]) && matches(identity); });
check('global constructor spoofing', () => {
const original=callee.DOMMatrix; let calls=0;
try { callee.DOMMatrix=function(){calls++; throw new Error('author constructor');}; return Object.getPrototypeOf(get()) === matrixPrototype && calls === 0; }
finally {callee.DOMMatrix=original;}
});
return checks;
})
@@ -0,0 +1,92 @@
use super::*;
#[test]
fn canvas_transform_snapshots_copy_native_state_with_brands_and_realms() {
let mut vm = new_storage_page_task_executor_test_vm("https://canvas-transform.test/");
vm.eval("document.body.innerHTML = '<iframe id=child></iframe>'")
.unwrap();
let source = format!(
r#"(() => {{
const probe = {};
const realms = [globalThis, document.getElementById('child').contentWindow];
const checks = [];
for (let owner = 0; owner < 2; owner++) for (let callee = 0; callee < 2; callee++)
for (const kind of ['element', 'offscreen'])
checks.push(...probe(realms[owner], realms[callee], kind, 'window-' + owner + '-' + callee + '-' + kind));
globalThis.__canvasTransformChecks = checks;
return checks.length;
}})()"#,
include_str!("canvas_transform_snapshots.js"),
);
assert_eq!(vm.eval(&source).unwrap(), "600");
assert_eq!(
vm.eval("JSON.stringify(__canvasTransformChecks.filter(row => !row.passed))")
.unwrap(),
"[]"
);
}
#[test]
fn canvas_transform_snapshots_accept_registered_native_proxies_and_share_paint_state() {
let mut vm = new_storage_page_task_executor_test_vm("https://canvas-transform-native.test/");
vm.eval(
r#"document.body.innerHTML = '<iframe id=child></iframe>';
globalThis.nativeCanvasContexts = [document.createElement('canvas'), new OffscreenCanvas(96,96)]
.map(canvas => canvas.getContext('2d'));
for (const ctx of nativeCanvasContexts) ctx.setTransform(1,0,0,1,20,0);"#,
)
.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, "nativeCanvasContexts");
let value = global.get(scope, key.into()).unwrap();
let contexts = v8::Local::<v8::Array>::try_from(value).unwrap();
let proxies = v8::Array::new(scope, 0);
for index in 0..contexts.length() {
let value = contexts.get_index(scope, index).unwrap();
let context = v8::Local::<v8::Object>::try_from(value).unwrap();
let handler = crate::util::new_null_prototype_object(scope);
let proxy = v8::Proxy::new(scope, context, handler).unwrap();
moli_webapi_declare::register_web_api_proxy(scope, proxy).unwrap();
assert_eq!(proxies.set_index(scope, index, proxy.into()), Some(true));
}
let key = crate::util::v8str(scope, "nativeCanvasContextProxies");
assert_eq!(
global.create_data_property(scope, key.into(), proxies.into()),
Some(true)
);
Ok(())
})
.unwrap();
assert_eq!(
vm.eval(
r#"(() => {
const realms = [globalThis, document.getElementById('child').contentWindow];
for (let index=0;index<2;index++) {
const ctx=nativeCanvasContexts[index], proxy=nativeCanvasContextProxies[index];
const name=index===0?'CanvasRenderingContext2D':'OffscreenCanvasRenderingContext2D';
for (const realm of realms) {
const get=realm[name].prototype.getTransform;
const matrix=get.call(proxy);
if(matrix.e!==20 || !(matrix instanceof realm.DOMMatrix)) throw Error('native proxy snapshot');
matrix.e=80;
if(get.call(ctx).e!==20) throw Error('snapshot mutated paint state');
let rejected=false;
try {get.call(new Proxy(proxy,{}));} catch(error) {rejected=error instanceof realm.TypeError;}
if(!rejected) throw Error('author proxy accepted');
}
ctx.beginPath(); ctx.rect(0,0,10,10); ctx.fill();
if(ctx.getImageData(25,5,1,1).data[3]!==255 || ctx.getImageData(5,5,1,1).data[3]!==0)
throw Error('snapshot transform differs from native painting');
ctx.__moliCanvasPathState=0;
Object.setPrototypeOf(ctx,null); Object.freeze(ctx);
if(globalThis[name].prototype.getTransform.call(ctx).e!==20) throw Error('native identity lost');
}
return true;
})()"#,
)
.unwrap(),
"true"
);
}
@@ -2135,6 +2135,7 @@ mod browser_api;
mod cache_interfaces;
mod canvas_arguments;
mod canvas_paths;
mod canvas_transform_snapshots;
mod canvas_webgl;
mod child_dynamic_inline_scripts;
mod device_events;
@@ -0,0 +1,22 @@
use super::*;
#[tokio::test]
async fn worker_offscreen_canvas_transform_snapshots_preserve_native_state_and_identity() {
ensure_v8();
let source = format!(
r#"const probe = {};
const checks = probe(globalThis, globalThis, 'offscreen', 'worker');
const point = new DOMMatrix().translate(3, 4).transformPoint(new DOMPoint(1, 2));
if (!(point instanceof DOMPoint) || point.x !== 4 || point.y !== 6)
throw Error('worker transform result types');
postMessage({{total: checks.length, failures: checks.filter(row => !row.passed)}});
close();"#,
include_str!("../../../script_vm/tests/canvas_transform_snapshots.js"),
);
let mut worker = spawn_worker(source, "test://canvas_transform_snapshots".into());
let message = timeout(TIMEOUT, worker.recv())
.await
.expect("timed out")
.expect("channel closed");
assert_eq!(expect_post_json(message), r#"{"total":74,"failures":[]}"#);
}
@@ -1534,6 +1534,9 @@ mod strict_script_mime;
mod network;
mod postmessage;
mod canvas_transform_snapshots;
mod tls;
mod xhr_failure;