fix(canvas): expose branded readonly TextMetrics width

This commit is contained in:
ldm0
2026-09-08 16:36:18 +08:00
parent 4c00e6dab6
commit 2439ebcbba
6 changed files with 178 additions and 5 deletions
@@ -283,6 +283,9 @@ pub(super) fn install_canvas_template_bindings<'s>(
"ImageBitmap" => {
image_bitmap::install_image_bitmap_template_bindings(scope, template);
}
"TextMetrics" => {
context2d::install_text_metrics_template_bindings(scope, template);
}
_ => {}
}
}
@@ -25,6 +25,7 @@ use std::str::FromStr;
const DEFAULT_IMAGE_SMOOTHING_QUALITY: &str = "low";
const CANVAS_CONTEXT_LINE_DASH_SLOT: &str = "__moliCanvasContextLineDash";
const TEXT_METRICS_WIDTH_SLOT: &str = "__moliTextMetricsWidth";
pub(super) fn reset_canvas_context_state<'s>(
scope: &mut v8::PinScope<'s, '_>,
@@ -37,12 +38,41 @@ pub(super) fn reset_canvas_context_state<'s>(
}
#[derive(WebApiObject)]
#[webapi(interface = "Object")]
#[webapi(interface = "TextMetrics")]
struct CanvasTextMetricsDeclaration {
#[webapi(data_property)]
#[webapi(slot = TEXT_METRICS_WIDTH_SLOT)]
width: f64,
}
#[derive(WebApiFunctionTemplate)]
#[webapi(name = "TextMetrics", enumerable)]
struct TextMetricsPrototypeDeclaration {
#[webapi(accessor_property, getter = text_metrics_width_getter)]
width: (),
}
pub(super) fn install_text_metrics_template_bindings<'s>(
scope: &mut v8::PinScope<'s, '_, ()>,
template: v8::Local<'s, v8::FunctionTemplate>,
) {
TextMetricsPrototypeDeclaration::initialize_prototype_template(
scope,
template.prototype_template(scope),
);
}
fn text_metrics_width_getter<'s>(
scope: &mut v8::PinScope<'s, '_>,
args: v8::FunctionCallbackArguments<'s>,
mut rv: v8::ReturnValue<'_, v8::Value>,
) {
let Some(width) = get_private_value(scope, args.this(), TEXT_METRICS_WIDTH_SLOT) else {
throw_type_error(scope, "TextMetrics.width called on incompatible receiver.");
return;
};
rv.set(width);
}
#[derive(
Clone, Copy, Debug, Eq, PartialEq, strum::EnumString, strum::IntoStaticStr, webidl::WebIdlEnum,
)]
@@ -1817,6 +1847,9 @@ pub(crate) fn canvas_context_measure_text_callback<'s>(
args: v8::FunctionCallbackArguments<'s>,
mut rv: v8::ReturnValue<'_, v8::Value>,
) {
if !require_canvas_context_receiver(scope, args.this(), "measureText") {
return;
}
let Some(parsed) = webidl::parse_args::<CanvasContextMeasureTextArgs>(scope, &args) else {
return;
};
@@ -1825,12 +1858,27 @@ pub(crate) fn canvas_context_measure_text_callback<'s>(
let declaration = CanvasTextMetricsDeclaration {
width: measure_text_width(&parsed.text, &font),
};
let Ok(metrics) = declaration.bind(scope) else {
let relevant_context = canvas_context_relevant_context(scope, args.this())
.unwrap_or_else(|| scope.get_current_context());
let target_scope = &mut v8::ContextScope::new(scope, relevant_context);
let Ok(metrics) = declaration.bind(target_scope) else {
return;
};
rv.set(metrics.into());
}
fn canvas_context_relevant_context<'s>(
scope: &mut v8::PinScope<'s, '_>,
context: v8::Local<'s, v8::Object>,
) -> Option<v8::Local<'s, v8::Context>> {
canvas_owner_from_context(scope, context)
.and_then(|canvas| {
crate::context_bootstrap::shared::node_owner_document_or_self(scope, canvas)
})
.and_then(|document| crate::native_bridge::node_relevant_context(scope, document))
.or_else(|| context.get_creation_context(scope))
}
pub(crate) fn canvas_context_create_image_data_callback<'s>(
scope: &mut v8::PinScope<'s, '_>,
args: v8::FunctionCallbackArguments<'s>,
@@ -2088,6 +2088,15 @@ impl JsContextHost {
constructor.get(scope, crate::util::v8str(scope, "prototype").into())
}
pub(crate) fn child_browsing_context_relevant_context<'s>(
&self,
scope: &mut v8::PinScope<'s, '_>,
child_handle: DomHandle,
) -> Option<v8::Local<'s, v8::Context>> {
let window = self.existing_child_browsing_context_window_wrapper(scope, child_handle)?;
window.get_creation_context(scope)
}
pub(crate) fn custom_elements_for_registry_key(
&self,
key: CustomElementRegistryKey,
+3 -2
View File
@@ -77,8 +77,9 @@ pub(crate) use element::{
pub(super) use helpers::*;
pub(crate) use node::{
current_or_live_delegate_node_arg_handle, node_or_foreign_arg_handle_allow_detached,
node_runtime_and_handle_from_object, node_runtime_and_handle_from_object_or_detached,
object_is_node_wrapper_or_detached, validate_pre_insert_handles,
node_relevant_context, node_runtime_and_handle_from_object,
node_runtime_and_handle_from_object_or_detached, object_is_node_wrapper_or_detached,
validate_pre_insert_handles,
};
pub(crate) use node::{install_character_data_template_bindings, install_node_template_bindings};
@@ -1307,6 +1307,39 @@ pub(super) fn node_runtime_and_handle_from_args_or_detached(
node_runtime_and_handle_from_object_or_detached(scope, this)
}
pub(super) fn node_owner_document_relevant_context<'s>(
scope: &mut v8::PinScope<'s, '_>,
runtime_ptr: *mut JsContextHost,
handle: DomHandle,
) -> Option<v8::Local<'s, v8::Context>> {
let document_handle = unsafe { &*runtime_ptr }
.dom_host()
.owner_document_handle(handle)?;
if let Some(child_handle) =
unsafe { &*runtime_ptr }.child_browsing_context_host_for_document_handle(document_handle)
&& let Some(context) =
unsafe { &*runtime_ptr }.child_browsing_context_relevant_context(scope, child_handle)
{
return Some(context);
}
let document = unsafe { &mut *runtime_ptr }
.native_bridge_mut()
.wrap_handle(scope, runtime_ptr, document_handle)?;
document.get_creation_context(scope)
}
pub(crate) fn node_relevant_context<'s>(
scope: &mut v8::PinScope<'s, '_>,
object: v8::Local<'s, v8::Object>,
) -> Option<v8::Local<'s, v8::Context>> {
node_runtime_and_handle_from_object(scope, object)
.ok()
.and_then(|(runtime_ptr, handle)| {
node_owner_document_relevant_context(scope, runtime_ptr, handle)
})
.or_else(|| object.get_creation_context(scope))
}
pub(super) fn node_arg_handle(
scope: &mut v8::PinScope<'_, '_>,
runtime_ptr: *mut JsContextHost,
@@ -580,6 +580,85 @@ fn html_canvas_2d_text_methods_are_available_for_fingerprinting_scripts() {
assert_eq!(result, "function|function|function|function|true|true");
}
#[test]
fn canvas_text_metrics_width_is_a_branded_readonly_snapshot() {
let mut vm = new_storage_test_vm("https://text-metrics.test/");
let result = vm
.eval(
r#"
(() => {
const contexts = [
document.createElement('canvas').getContext('2d'),
new OffscreenCanvas(1, 1).getContext('2d')
];
const descriptor = Object.getOwnPropertyDescriptor(TextMetrics.prototype, 'width');
const outcome = callback => {
try { callback(); return 'ok'; } catch (error) { return error.name; }
};
return JSON.stringify({
descriptor: [descriptor.get.name, descriptor.get.length,
descriptor.set === undefined, descriptor.enumerable, descriptor.configurable],
constructor: outcome(() => new TextMetrics()),
results: contexts.map(context => {
context.font = '10px Arial';
const metrics = context.measureText('Hello');
const width = metrics.width;
const ownNames = Object.getOwnPropertyNames(metrics);
metrics.width = -1;
metrics.__moliTextMetricsWidth = -2;
context.font = '20px Arial';
return [
metrics instanceof TextMetrics,
Object.prototype.toString.call(metrics),
ownNames.length, width > 0, metrics.width === width,
context.measureText('Hello').width > width,
context.measureText('').width,
outcome(() => { 'use strict'; metrics.width = 42; }),
outcome(() => descriptor.get.call(Object.create(metrics))),
outcome(() => context.measureText.call({}, 'Hello'))
];
}),
fakeReceivers: [null, undefined, {}, TextMetrics.prototype,
{ __moliTextMetricsWidth: 1 }].map(value => outcome(() => descriptor.get.call(value)))
});
})()
"#,
)
.expect("TextMetrics width contract should evaluate");
assert_eq!(
result,
r#"{"descriptor":["get width",0,true,true,true],"constructor":"TypeError","results":[[true,"[object TextMetrics]",0,true,true,true,0,"TypeError","TypeError","TypeError"],[true,"[object TextMetrics]",0,true,true,true,0,"TypeError","TypeError","TypeError"]],"fakeReceivers":["TypeError","TypeError","TypeError","TypeError","TypeError"]}"#
);
}
#[test]
fn canvas_text_metrics_uses_the_context_realm() {
let mut vm = new_storage_test_vm("https://text-metrics-realm.test/");
let result = vm
.eval(
r#"
(() => {
const frame = document.createElement('iframe');
document.appendChild(document.createElement('html')).appendChild(frame);
const child = frame.contentWindow;
const localContext = document.createElement('canvas').getContext('2d');
const childContext = child.document.createElement('canvas').getContext('2d');
const local = childContext.measureText.call(localContext, 'Hello');
const foreign = localContext.measureText.call(childContext, 'Hello');
const getter = Object.getOwnPropertyDescriptor(TextMetrics.prototype, 'width').get;
return [
Object.getPrototypeOf(local) === TextMetrics.prototype,
Object.getPrototypeOf(foreign) === child.TextMetrics.prototype,
getter.call(foreign) === foreign.width,
foreign.width > 0
].join('|');
})()
"#,
)
.expect("TextMetrics should use the canvas context's realm");
assert_eq!(result, "true|true|true|true");
}
#[test]
fn html_canvas_linear_gradient_surface_is_available_for_fingerprinting_scripts() {
let mut vm = new_storage_test_vm("https://canvas-linear-gradient-surface.test/");