diff --git a/Cargo.lock b/Cargo.lock index 5511d8c..5ddd4c4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -590,12 +590,12 @@ dependencies = [ "log", "rangemap", "rustc-hash 1.1.0", - "rustybuzz 0.14.1", + "rustybuzz", "self_cell", "smol_str", "swash", "sys-locale", - "ttf-parser 0.21.1", + "ttf-parser 0.25.1", "unicode-bidi", "unicode-linebreak", "unicode-script", @@ -2750,23 +2750,6 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" -[[package]] -name = "rustybuzz" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfb9cf8877777222e4a3bc7eb247e398b56baba500c38c1c46842431adc8b55c" -dependencies = [ - "bitflags", - "bytemuck", - "libm", - "smallvec", - "ttf-parser 0.21.1", - "unicode-bidi-mirroring 0.2.0", - "unicode-ccc 0.2.0", - "unicode-properties", - "unicode-script", -] - [[package]] name = "rustybuzz" version = "0.20.1" @@ -2779,8 +2762,8 @@ dependencies = [ "log", "smallvec", "ttf-parser 0.25.1", - "unicode-bidi-mirroring 0.4.0", - "unicode-ccc 0.4.0", + "unicode-bidi-mirroring", + "unicode-ccc", "unicode-properties", "unicode-script", ] @@ -3576,12 +3559,6 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "17f77d76d837a7830fe1d4f12b7b4ba4192c1888001c7164257e4bc6d21d96b4" -[[package]] -name = "ttf-parser" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c591d83f69777866b9126b24c6dd9a18351f177e49d625920d19f989fd31cf8" - [[package]] name = "ttf-parser" version = "0.25.1" @@ -3640,24 +3617,12 @@ version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" -[[package]] -name = "unicode-bidi-mirroring" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23cb788ffebc92c5948d0e997106233eeb1d8b9512f93f41651f52b6c5f5af86" - [[package]] name = "unicode-bidi-mirroring" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe" -[[package]] -name = "unicode-ccc" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1df77b101bcc4ea3d78dafc5ad7e4f58ceffe0b2b16bf446aeb50b6cb4157656" - [[package]] name = "unicode-ccc" version = "0.4.0" @@ -3771,7 +3736,7 @@ dependencies = [ "log", "pico-args", "roxmltree", - "rustybuzz 0.20.1", + "rustybuzz", "simplecss", "siphasher", "strict-num", diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index b7eb400..33a82d5 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -8278,6 +8278,7 @@ globalThis.atob = globalThis.atob || ((s) => { (() => { const stack = [{state: null, url: undefined}]; // initial entry; url=undefined means "use document URL" let idx = 0; + const historyToken = Symbol("History"); const resolveOrFallback = (url) => { // A missing url (pushState/replaceState called with < 3 args) keeps the // current document URL per the HTML spec — capture it so the entry does not @@ -8301,10 +8302,19 @@ globalThis.atob = globalThis.atob || ((s) => { } } catch {} }; - globalThis.history = { - get length() { return stack.length; }, - get state() { return stack[idx].state; }, - scrollRestoration: "auto", + class History { + constructor(token) { + if (token !== historyToken) throw new TypeError("Illegal constructor"); + } + get length() { return stack.length; } + get state() { return stack[idx].state; } + get scrollRestoration() { return this._scrollRestoration || "auto"; } + set scrollRestoration(value) { + const normalized = String(value); + if (normalized === "auto" || normalized === "manual") { + this._scrollRestoration = normalized; + } + } pushState(state, _title, url) { const prevUrl = __currentUrl(); const resolved = resolveOrFallback(url); @@ -8315,14 +8325,14 @@ globalThis.atob = globalThis.atob || ((s) => { idx = stack.length - 1; applyVirtual(); fireHashChangeIfNeeded(prevUrl); - }, + } replaceState(state, _title, url) { const prevUrl = __currentUrl(); const resolved = resolveOrFallback(url); stack[idx] = {state: state ?? null, url: resolved}; applyVirtual(); fireHashChangeIfNeeded(prevUrl); - }, + } go(n) { n = (n | 0); if (n === 0) return; // real spec: go(0) reloads. We don't reload SPAs. @@ -8337,10 +8347,17 @@ globalThis.atob = globalThis.atob || ((s) => { globalThis.dispatchEvent(ev); } catch {} fireHashChangeIfNeeded(prevUrl); - }, - back() { this.go(-1); }, - forward() { this.go(1); }, - }; + } + back() { this.go(-1); } + forward() { this.go(1); } + } + Object.defineProperty(History.prototype, Symbol.toStringTag, {value: "History"}); + Object.defineProperty(globalThis, "History", { + value: History, writable: true, configurable: true, + }); + Object.defineProperty(globalThis, "history", { + value: new History(historyToken), writable: true, configurable: true, + }); })(); // Navigation API. New framework routers increasingly prefer `navigation` @@ -12075,7 +12092,10 @@ globalThis.__obscura_init = function() { ? globalThis.__obscura_viewport_h : sh - 80; globalThis.screen = new Screen(sw, sh); globalThis.visualViewport = { width:vw, height:vh, offsetLeft:0, offsetTop:0, scale:1, addEventListener(){}, removeEventListener(){} }; - globalThis.devicePixelRatio = sw >= 2560 ? 2 : 1; + // Screen dimensions do not determine the output device scale. The embedding + // browser applies an explicit device metric after page initialization; the + // standalone runtime has the same 1x default as Obscura's render surface. + globalThis.devicePixelRatio = 1; globalThis.innerWidth = vw; globalThis.innerHeight = vh; globalThis.outerWidth = sw; globalThis.outerHeight = sh - 40; diff --git a/crates/obscura-js/src/runtime.rs b/crates/obscura-js/src/runtime.rs index b15717e..e3e19da 100644 --- a/crates/obscura-js/src/runtime.rs +++ b/crates/obscura-js/src/runtime.rs @@ -2706,6 +2706,35 @@ mod tests { assert_eq!(path, serde_json::json!("/a")); } + #[test] + fn history_exposes_the_web_platform_constructor_and_prototype() { + let mut rt = setup_runtime("
"); + let result = rt + .evaluate( + r#"(function(){ + const original = history.replaceState; + History.prototype.replaceState.call(history, {ok:true}, "", "/prototype"); + let illegal = false; + try { new History(); } catch (error) { illegal = error instanceof TypeError; } + return { + instance: history instanceof History, + prototype: Object.getPrototypeOf(history) === History.prototype, + method: original === History.prototype.replaceState, + tag: Object.prototype.toString.call(history), + path: location.pathname, + illegal, + }; + })()"#, + ) + .unwrap(); + assert_eq!(result["instance"], serde_json::json!(true)); + assert_eq!(result["prototype"], serde_json::json!(true)); + assert_eq!(result["method"], serde_json::json!(true)); + assert_eq!(result["tag"], serde_json::json!("[object History]")); + assert_eq!(result["path"], serde_json::json!("/prototype")); + assert_eq!(result["illegal"], serde_json::json!(true)); + } + #[test] fn style_attribute_parses_into_style_object() { // Inline styles present in the parsed HTML must be visible via el.style.* @@ -6634,6 +6663,54 @@ mod tests { assert!(later >= initial + 8.0, "initial={initial}, later={later}"); } + #[cfg(feature = "render")] + #[test] + fn fixed_animation_capture_is_invariant_after_geometry_flush() { + let make_runtime = || { + let mut rt = ObscuraJsRuntime::new(); + rt.set_dom(parse_html( + r#" + "#, + )); + rt.set_url("http://example.test/github-like-shell"); + rt.set_viewport(160.0, 100.0); + rt.run_page_init(); + rt.evaluate("document.getElementById('shell').className='dismissed'") + .unwrap(); + rt + }; + let fixed = obscura_render::AnimationSample::local_override(750.0); + let direct_rt = make_runtime(); + assert!(direct_rt.set_animation_sample(fixed)); + let direct = direct_rt + .screenshot_prepared((160.0, 100.0), Some("http://example.test/github-like-shell")) + .expect("direct fixed-time capture"); + + let mut geometry_rt = make_runtime(); + let rect = geometry_rt + .evaluate("document.getElementById('content').getBoundingClientRect().toJSON()") + .expect("geometry flush before capture"); + assert_eq!(rect["width"].as_f64(), Some(120.0)); + assert!(geometry_rt.set_animation_sample(fixed)); + let after_geometry = geometry_rt + .screenshot_prepared((160.0, 100.0), Some("http://example.test/github-like-shell")) + .expect("fixed-time capture after geometry"); + + assert_eq!( + direct, after_geometry, + "a CSSOM geometry flush must not change fixed-time capture output" + ); + } + #[cfg(feature = "render")] #[test] fn cssom_animation_sample_is_frozen_within_one_javascript_task() { @@ -7058,6 +7135,29 @@ mod tests { assert_eq!(values[2], values[3]); } + #[cfg(feature = "render")] + #[tokio::test(flavor = "current_thread")] + async fn fingerprinted_screen_does_not_invent_a_device_scale_factor() { + let mut rt = ObscuraJsRuntime::new(); + rt.set_dom(parse_html("")); + rt.set_viewport(300.0, 200.0); + // Force the fingerprint seed whose screen-pool entry is 2560x1440. + // That physical screen must not silently turn a 1x render surface into + // a 2x devicePixelContentBoxSize surface. + rt.execute_script( + "deterministic-high-resolution-screen", + "Date.now = () => 0; Math.random = () => 2 / 0xFFFFFFFF;", + ) + .unwrap(); + rt.run_page_init(); + + assert_eq!( + rt.evaluate("[screen.width, screen.height, devicePixelRatio]") + .unwrap(), + serde_json::json!([2560, 1440, 1]) + ); + } + #[cfg(feature = "render")] #[tokio::test(flavor = "current_thread")] async fn resize_observer_reports_real_boxes_only_when_selected_size_changes() { diff --git a/crates/obscura-render/src/css.rs b/crates/obscura-render/src/css.rs index 1806ece..c0ab983 100644 --- a/crates/obscura-render/src/css.rs +++ b/crates/obscura-render/src/css.rs @@ -2310,6 +2310,7 @@ pub struct Stylesheet { /// generated text and positioned decorative boxes. before_rules: PseudoRuleMap, after_rules: PseudoRuleMap, + placeholder_rules: PseudoRuleMap, } const MAX_STYLESHEET_CACHE_SOURCE_BYTES: usize = 8 * 1024 * 1024; @@ -2357,8 +2358,10 @@ impl StylesheetCache { let source_bytes = sources .iter() .try_fold(0usize, |total, source| total.checked_add(source.len())); - let compiled_rules = - sheet.rules.len() + sheet.before_rules.rules.len() + sheet.after_rules.rules.len(); + let compiled_rules = sheet.rules.len() + + sheet.before_rules.rules.len() + + sheet.after_rules.rules.len() + + sheet.placeholder_rules.rules.len(); if source_bytes.is_some_and(|bytes| bytes <= MAX_STYLESHEET_CACHE_SOURCE_BYTES) && compiled_rules <= MAX_STYLESHEET_CACHE_RULES { @@ -2422,6 +2425,7 @@ impl Stylesheet { .rules .iter() .chain(&self.after_rules.rules) + .chain(&self.placeholder_rules.rules) .any(|rule| rule.container_condition_id != ContainerConditionId::NONE) } @@ -2508,6 +2512,9 @@ impl Stylesheet { nid, ) }); + let supports_placeholder = node.as_element().is_some_and(|element| { + matches!(element.local.as_ref(), "input" | "textarea") + }); normal_match || self .before_rules @@ -2515,6 +2522,10 @@ impl Stylesheet { || self .after_rules .node_matches_container_query_rule(tree, matcher, nid) + || (supports_placeholder + && self + .placeholder_rules + .node_matches_container_query_rule(tree, matcher, nid)) } pub(crate) fn container_condition_depth(&self) -> usize { @@ -2578,6 +2589,7 @@ impl Stylesheet { candidate_slot_count: 0, before_rules: PseudoRuleMap::default(), after_rules: PseudoRuleMap::default(), + placeholder_rules: PseudoRuleMap::default(), }; let mut order = 0usize; let mut layers = LayerRegistry::default(); @@ -2717,6 +2729,45 @@ impl Stylesheet { order += 1; continue; } + if let Some(base) = strip_pseudo_element(sel_trim, "placeholder") { + if let Some(sel) = tree.compile_rule_selector(base) { + note_selector_for_invalidation( + &mut sheet.invalidation_map, + base, + order, + ); + note_declaration_attribute_dependencies( + &mut sheet.invalidation_map, + &decls, + order, + ); + let (normal_decls, important_decls) = + crate::style::partition_declarations(&decls); + let normal_flags = declaration_stream_flags(&normal_decls); + let important_flags = declaration_stream_flags(&important_decls); + let specificity = sel.specificity(); + sheet.placeholder_rules.push(PseudoRule { + sel, + specificity, + normal_decls, + important_decls, + normal_flags, + important_flags, + candidate_slot: NO_CANDIDATE_SLOT, + order, + container_condition_id, + layer, + }); + } else if selector_requires_conservative_tracking(base) { + note_selector_for_invalidation( + &mut sheet.invalidation_map, + base, + order, + ); + } + order += 1; + continue; + } let Some(sel) = tree.compile_rule_selector(&selector) else { if selector_requires_conservative_tracking(&selector) { note_selector_for_invalidation( @@ -2793,19 +2844,25 @@ impl Stylesheet { props: &HashMapTools and
"); + let copy = tree.get_element_by_id("copy").unwrap(); + let mut style = LayoutStyle::default(); + style.display = Display::Block; + style.font_family = Some("Obscura VF Test".into()); + style.font_weight = Some("700".into()); + style.font_size = Some(32.0); + style.font_optical_sizing = Some(crate::FontOpticalSizing::Auto); + let item = engine + .try_build(&tree, copy, &HashMap::from([(copy, style)])) + .unwrap(); + engine.finalize(item, (0.0, 0.0), 600.0, None); + let shaped_text = engine.item_text(item).to_string(); + let space_advance = engine.items[item] + .buffer + .layout_runs() + .flat_map(|run| run.glyphs.iter()) + .find(|glyph| &shaped_text[glyph.start..glyph.end] == " ") + .map(|glyph| glyph.w) + .expect("shaped space glyph"); + assert!( + space_advance > 5.0, + "setting wght and opsz must not repeatedly remap avar coordinates and collapse the space advance: {space_advance}" + ); + } + fn isolated_fallback_engine() -> (TextEngine, cosmic_text::fontdb::ID, cosmic_text::fontdb::ID) { let mut engine = TextEngine::new_with_web_fonts(&[ diff --git a/crates/obscura-render/src/lib.rs b/crates/obscura-render/src/lib.rs index fa0c39c..cfdc25e 100644 --- a/crates/obscura-render/src/lib.rs +++ b/crates/obscura-render/src/lib.rs @@ -1198,6 +1198,9 @@ pub struct LayoutStyle { /// positioned decorative boxes for layout-independent painting. pub before_pseudo: Option