Connect document font faces to rendering
This commit is contained in:
1 parent
0acf4e1b06
commit
5c34e74f10
5 files changed
+698
-24
No files matched your search
@@ -111,6 +111,11 @@ pub struct ObscuraState {
|
||||
/// relayout of the same document reuses it without refetching.
|
||||
#[cfg(feature = "render")]
|
||||
pub render_resources: obscura_render::RenderResourceCache,
|
||||
/// Script-created faces in this document's `FontFaceSet`. This is separate
|
||||
/// from the DOM so the bridge does not manufacture a selector-visible
|
||||
/// `<style>` element merely to feed the renderer.
|
||||
#[cfg(feature = "render")]
|
||||
pub dynamic_fonts: Vec<obscura_render::DynamicFontFace>,
|
||||
#[cfg(feature = "render")]
|
||||
pub viewport: (f32, f32),
|
||||
/// Root scrolling offset in CSS pixels. With render enabled this is
|
||||
@@ -148,6 +153,8 @@ impl ObscuraState {
|
||||
#[cfg(feature = "render")]
|
||||
render_resources: obscura_render::RenderResourceCache::default(),
|
||||
#[cfg(feature = "render")]
|
||||
dynamic_fonts: Vec::new(),
|
||||
#[cfg(feature = "render")]
|
||||
viewport: (1280.0, 720.0),
|
||||
#[cfg(feature = "render")]
|
||||
scroll_offset: (0.0, 0.0),
|
||||
@@ -2019,6 +2026,65 @@ fn op_url_encode_query(#[string] query: &str, #[string] label: &str, special: bo
|
||||
obscura_net::url_encode_query(query, label, special).unwrap_or_else(|| query.to_string())
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DynamicFontFaceInput {
|
||||
family: String,
|
||||
source: String,
|
||||
style: String,
|
||||
weight: String,
|
||||
unicode_range: String,
|
||||
}
|
||||
|
||||
/// Replace the native snapshot of `document.fonts`. The JS implementation
|
||||
/// remains the source of truth for set semantics; this narrow bridge only
|
||||
/// supplies resource descriptors to the render preparation path.
|
||||
#[cfg(feature = "render")]
|
||||
#[op2(fast)]
|
||||
fn op_set_dynamic_fonts(state: &OpState, #[string] registrations: &str) -> bool {
|
||||
let Ok(inputs) = serde_json::from_str::<Vec<DynamicFontFaceInput>>(registrations) else {
|
||||
return false;
|
||||
};
|
||||
// Keep the observable registry broad enough for generated font families
|
||||
// (large applications commonly register dozens of subset faces). The
|
||||
// renderer independently caps decoded resources after ASCII filtering and
|
||||
// URL deduplication. BufferSource faces arrive as data URLs, so cap their
|
||||
// aggregate descriptor payload as well as each entry.
|
||||
if inputs.len() > 256
|
||||
|| inputs
|
||||
.iter()
|
||||
.try_fold(0usize, |total, face| total.checked_add(face.source.len()))
|
||||
.map_or(true, |total| total > 64 * 1024 * 1024)
|
||||
|| inputs.iter().any(|face| {
|
||||
face.family.len() > 1024
|
||||
|| face.source.len() > 12 * 1024 * 1024
|
||||
|| face.style.len() > 256
|
||||
|| face.weight.len() > 256
|
||||
|| face.unicode_range.len() > 4096
|
||||
})
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let fonts = inputs
|
||||
.into_iter()
|
||||
.map(|face| obscura_render::DynamicFontFace {
|
||||
family: face.family,
|
||||
source: face.source,
|
||||
style: face.style,
|
||||
weight: face.weight,
|
||||
unicode_range: face.unicode_range,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let shared = state.borrow::<SharedState>().clone();
|
||||
let mut state = shared.borrow_mut();
|
||||
if state.dynamic_fonts != fonts {
|
||||
state.dynamic_fonts = fonts;
|
||||
state.prepared_render = None;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub fn build_extension() -> Extension {
|
||||
let mut ops = vec![
|
||||
op_dom(),
|
||||
@@ -2048,6 +2114,7 @@ pub fn build_extension() -> Extension {
|
||||
// probes with typeof before calling, so the op's absence is a clean fallback.
|
||||
#[cfg(feature = "render")]
|
||||
{
|
||||
ops.push(op_set_dynamic_fonts());
|
||||
ops.push(op_image_metadata());
|
||||
ops.push(op_layout_geometry());
|
||||
ops.push(op_layout_metrics());
|
||||
@@ -2091,11 +2158,12 @@ pub(crate) fn ensure_prepared_render(
|
||||
if stale {
|
||||
let prepared = {
|
||||
let dom = state.dom.as_ref()?;
|
||||
obscura_render::prepare_dom(
|
||||
obscura_render::prepare_dom_with_dynamic_fonts(
|
||||
dom,
|
||||
viewport,
|
||||
base_url.as_deref(),
|
||||
&mut state.render_resources,
|
||||
&state.dynamic_fonts,
|
||||
)?
|
||||
};
|
||||
state.prepared_render = Some(prepared);
|
||||
|
||||
@@ -197,6 +197,7 @@ impl ObscuraJsRuntime {
|
||||
{
|
||||
gs.prepared_render = None;
|
||||
gs.render_resources = obscura_render::RenderResourceCache::default();
|
||||
gs.dynamic_fonts.clear();
|
||||
gs.scroll_offset = (0.0, 0.0);
|
||||
}
|
||||
}
|
||||
@@ -1120,6 +1121,7 @@ impl ObscuraJsRuntime {
|
||||
{
|
||||
state.prepared_render = None;
|
||||
state.render_resources = obscura_render::RenderResourceCache::default();
|
||||
state.dynamic_fonts.clear();
|
||||
}
|
||||
state.dom.take()
|
||||
}
|
||||
@@ -1956,6 +1958,129 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_face_set_tracks_authored_and_script_created_faces() {
|
||||
let mut rt = setup_runtime(
|
||||
r#"<html><head><style>
|
||||
@font-face {
|
||||
font-family: "Authored One";
|
||||
src: url("https://assets.test/one.woff2") format("woff2");
|
||||
font-weight: 350 650;
|
||||
}
|
||||
@font-face {
|
||||
font-family: AuthoredTwo;
|
||||
src: url(data:font/woff2;base64,d09GMg==);
|
||||
font-style: italic;
|
||||
}
|
||||
</style></head><body></body></html>"#,
|
||||
);
|
||||
let result = rt
|
||||
.evaluate(
|
||||
r#"(() => {
|
||||
const authored = Array.from(document.fonts);
|
||||
const cssDelete = document.fonts.delete(authored[0]);
|
||||
const dynamic = new FontFace("Dynamic", "url('/dynamic.ttf')", {
|
||||
style: "oblique 12deg",
|
||||
weight: "700",
|
||||
stretch: "condensed",
|
||||
unicodeRange: "U+20-7E",
|
||||
display: "swap"
|
||||
});
|
||||
const addResult = document.fonts.add(dynamic);
|
||||
const visited = [];
|
||||
document.fonts.forEach((value, key, set) => {
|
||||
visited.push(value === key && set === document.fonts);
|
||||
});
|
||||
const afterAdd = [
|
||||
document.fonts.size,
|
||||
document.fonts.has(dynamic),
|
||||
addResult === document.fonts,
|
||||
dynamic.family,
|
||||
dynamic.style,
|
||||
dynamic.weight,
|
||||
dynamic.stretch,
|
||||
dynamic.unicodeRange,
|
||||
dynamic.display,
|
||||
visited.every(Boolean)
|
||||
];
|
||||
const deleted = document.fonts.delete(dynamic);
|
||||
document.fonts.clear();
|
||||
const bytes = new Uint8Array([0, 1, 2, 253, 254, 255]);
|
||||
const binary = new FontFace("Binary", bytes, { weight: 600 });
|
||||
return [
|
||||
authored.length,
|
||||
authored.map(face => face.family),
|
||||
cssDelete,
|
||||
afterAdd,
|
||||
deleted,
|
||||
document.fonts.size,
|
||||
binary.status,
|
||||
binary.loaded === binary.load()
|
||||
];
|
||||
})()"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
result,
|
||||
serde_json::json!([
|
||||
2,
|
||||
["Authored One", "AuthoredTwo"],
|
||||
false,
|
||||
[3, true, true, "Dynamic", "oblique 12deg", "700", "condensed", "U+20-7E", "swap", true],
|
||||
true,
|
||||
2,
|
||||
"loaded",
|
||||
true
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn font_face_load_updates_status_set_readiness_and_matching() {
|
||||
let mut rt = setup_runtime("<html><body></body></html>");
|
||||
rt.execute_script(
|
||||
"font-face-lifecycle",
|
||||
r#"
|
||||
globalThis.__fontEvents = [];
|
||||
const face = new FontFace("Lifecycle", "url('/lifecycle.woff2')", {
|
||||
weight: "700"
|
||||
});
|
||||
document.fonts.onloading = event => __fontEvents.push([event.type, event.fontfaces.length]);
|
||||
document.fonts.onloadingdone = event => __fontEvents.push([event.type, event.fontfaces.length]);
|
||||
document.fonts.add(face);
|
||||
globalThis.__fontBefore = [
|
||||
face.status,
|
||||
document.fonts.status,
|
||||
document.fonts.check("700 16px Lifecycle")
|
||||
];
|
||||
globalThis.__fontLoadResult = "pending";
|
||||
document.fonts.load("700 16px Lifecycle").then(faces => {
|
||||
__fontLoadResult = [faces.length, faces[0] === face, face.status,
|
||||
document.fonts.check("700 16px Lifecycle")];
|
||||
});
|
||||
document.fonts.ready.then(set => {
|
||||
globalThis.__fontReady = set === document.fonts;
|
||||
});
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
rt.run_event_loop_bounded(100).await.unwrap();
|
||||
let result = rt
|
||||
.evaluate(
|
||||
"return [__fontBefore, __fontLoadResult, __fontReady, __fontEvents];",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
result,
|
||||
serde_json::json!([
|
||||
["unloaded", "loaded", false],
|
||||
[1, true, "loaded", true],
|
||||
true,
|
||||
[["loading", 1], ["loadingdone", 1]]
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn animation_frame_requires_a_callable_callback() {
|
||||
let mut rt = setup_runtime("<html><body></body></html>");
|
||||
@@ -3154,6 +3279,66 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
#[test]
|
||||
fn script_registered_url_font_reaches_render_resource_collection() {
|
||||
let loads = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
|
||||
let loader_loads = std::sync::Arc::clone(&loads);
|
||||
let font = include_bytes!("../../obscura-render/assets/liberation-serif.ttf").to_vec();
|
||||
let mut rt = parser_image_runtime(
|
||||
r#"<html><body style="margin:0">
|
||||
<span id="sample" style="display:inline-block;width:max-content;
|
||||
font-family:DynamicFixture;font-size:40px;white-space:nowrap">WWWWiiii</span>
|
||||
</body></html>"#,
|
||||
move |url: &str| {
|
||||
loader_loads.lock().expect("font loads").push(url.to_string());
|
||||
(url == "http://example.com/fonts/dynamic.ttf").then(|| font.clone())
|
||||
},
|
||||
);
|
||||
rt.set_viewport(400.0, 100.0);
|
||||
let before = rt
|
||||
.evaluate("document.getElementById('sample').getBoundingClientRect().width")
|
||||
.unwrap()
|
||||
.as_f64()
|
||||
.expect("fallback width");
|
||||
assert!(loads.lock().expect("initial loads").is_empty());
|
||||
|
||||
let registered = rt
|
||||
.evaluate(
|
||||
r#"(() => {
|
||||
const face = new FontFace("DynamicFixture",
|
||||
"url('../fonts/dynamic.ttf') format('truetype')",
|
||||
{ weight: "normal", style: "normal", unicodeRange: "U+20-7E" });
|
||||
return [document.fonts.add(face) === document.fonts,
|
||||
document.fonts.size, document.fonts.has(face)];
|
||||
})()"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(registered, serde_json::json!([true, 1, true]));
|
||||
{
|
||||
let state = rt.state.borrow();
|
||||
assert_eq!(state.dynamic_fonts.len(), 1);
|
||||
assert!(
|
||||
state.prepared_render.is_none(),
|
||||
"changing the document font registry must invalidate prepared text geometry"
|
||||
);
|
||||
}
|
||||
|
||||
let after = rt
|
||||
.evaluate("document.getElementById('sample').getBoundingClientRect().width")
|
||||
.unwrap()
|
||||
.as_f64()
|
||||
.expect("dynamic font width");
|
||||
assert_ne!(before, after, "registered face must affect final text geometry");
|
||||
assert_eq!(
|
||||
*loads.lock().expect("dynamic font loads"),
|
||||
vec!["http://example.com/fonts/dynamic.ttf".to_string()]
|
||||
);
|
||||
rt.screenshot_prepared((400.0, 100.0), Some("http://example.com/page/index.html"))
|
||||
.expect("dynamic font screenshot");
|
||||
assert_eq!(loads.lock().expect("repeated font loads").len(), 1);
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
#[test]
|
||||
fn rendered_layout_cache_is_invalidated_by_style_mutations() {
|
||||
|
||||
Reference in new issue
Block a user