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: &HashMap, host_style: &LayoutStyle, ) -> (Option, Option) { - self.pseudo_styles_internal(tree, matcher, nid, props, host_style, None) + let (before, after, _) = + self.pseudo_styles_internal(tree, matcher, nid, props, host_style, None); + (before, after) } - pub(crate) fn pseudo_styles_with_container_queries( + pub(crate) fn all_pseudo_styles( &self, tree: &DomTree, matcher: &mut Matcher, nid: NodeId, props: &HashMap, host_style: &LayoutStyle, - evaluator: &mut ContainerQueryEvaluator<'_>, - ) -> (Option, Option) { - self.pseudo_styles_internal(tree, matcher, nid, props, host_style, Some(evaluator)) + evaluator: Option<&mut ContainerQueryEvaluator<'_>>, + ) -> ( + Option, + Option, + Option, + ) { + self.pseudo_styles_internal(tree, matcher, nid, props, host_style, evaluator) } fn pseudo_styles_internal( @@ -2816,8 +2873,12 @@ impl Stylesheet { props: &HashMap, host_style: &LayoutStyle, mut evaluator: Option<&mut ContainerQueryEvaluator<'_>>, - ) -> (Option, Option) { - let mut build = |rules: &PseudoRuleMap, matcher: &mut Matcher| { + ) -> ( + Option, + Option, + Option, + ) { + let mut build = |rules: &PseudoRuleMap, matcher: &mut Matcher, is_placeholder: bool| { let mut normal_matched: Vec<(u32, usize, usize)> = Vec::new(); let mut important_matched: Vec<(u32, usize, usize)> = Vec::new(); if rules.candidate_slot_count != 0 { @@ -2945,6 +3006,11 @@ impl Stylesheet { ..Default::default() }; style.color_scheme_dark = host_style.color_scheme_dark; + if is_placeholder { + // Chromium's light native-control placeholder color. Author + // declarations cascade over this UA-origin initial value. + style.color = Some([117, 117, 117, 255]); + } let inherited_color_scheme_dark = host_style.color_scheme_dark; let mut generated_content = None; for &(_, _, index) in &normal_matched { @@ -3001,15 +3067,33 @@ impl Stylesheet { .as_ref() .map(|items| generated_content_with_zero_counters(items)); style.generated_content = generated_content; - if style.generated_content.is_some() || style.content_image.is_some() { + if is_placeholder { + // `color` is inherited on the pseudo. The declaration parser + // represents `inherit` as None, so resolve it against the + // originating control after the author cascade. + if style.color.is_none() { + style.color = host_style.color; + } + Some(style) + } else if style.generated_content.is_some() || style.content_image.is_some() { Some(style) } else { None } }; + let supports_placeholder = tree + .get_node(nid) + .is_some_and(|node| { + node.as_element().is_some_and(|element| { + matches!(element.local.as_ref(), "input" | "textarea") + }) + }); ( - build(&self.before_rules, matcher), - build(&self.after_rules, matcher), + build(&self.before_rules, matcher, false), + build(&self.after_rules, matcher, false), + supports_placeholder + .then(|| build(&self.placeholder_rules, matcher, true)) + .flatten(), ) } diff --git a/crates/obscura-render/src/dom.rs b/crates/obscura-render/src/dom.rs index 6f84d69..b8602b1 100644 --- a/crates/obscura-render/src/dom.rs +++ b/crates/obscura-render/src/dom.rs @@ -2266,19 +2266,14 @@ fn cascade_walk( custom_properties.insert(id, this_props.clone()); style.is_replaced_box |= style.content_image.is_some(); style.has_replaced_sizing |= style.content_image.is_some(); - let (mut before_pseudo, mut after_pseudo) = - if let Some(evaluator) = container_evaluator.as_deref_mut() { - sheet.pseudo_styles_with_container_queries( - tree, - matcher, - id, - &this_props, - &style, - evaluator, - ) - } else { - sheet.pseudo_styles(tree, matcher, id, &this_props, &style) - }; + let (mut before_pseudo, mut after_pseudo, placeholder_pseudo) = sheet.all_pseudo_styles( + tree, + matcher, + id, + &this_props, + &style, + container_evaluator.as_deref_mut(), + ); for pseudo in [&mut before_pseudo, &mut after_pseudo] .into_iter() .flatten() @@ -2296,6 +2291,7 @@ fn cascade_walk( .and_then(|pseudo| pseudo.before_content.clone()); style.before_pseudo = before_pseudo.map(Box::new); style.after_pseudo = after_pseudo.map(Box::new); + style.placeholder_pseudo = placeholder_pseudo.map(Box::new); descendant_color_scheme_dark = style.color_scheme_dark; styles.insert(id, style); } diff --git a/crates/obscura-render/src/inline.rs b/crates/obscura-render/src/inline.rs index 4952a46..efe2a0a 100644 --- a/crates/obscura-render/src/inline.rs +++ b/crates/obscura-render/src/inline.rs @@ -4120,6 +4120,40 @@ mod tests { wuff::decompress_woff2(&compressed).expect("decompress variable-font fixture") } + #[test] + fn variable_font_multi_axis_shaping_preserves_space_advance() { + let mut engine = TextEngine::new_with_web_fonts(&[WebFont { + data: variable_font_fixture(), + family: Some("Obscura VF Test".into()), + weight: Some((100, 900)), + italic: Some(false), + }]); + let tree = obscura_dom::parse_html("

Tools 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>, pub after_pseudo: Option>, + /// Computed author style for the native text-control `::placeholder` + /// pseudo-element. Its anonymous glyphs are painted by the control. + pub placeholder_pseudo: Option>, /// True for `inline-block`/`inline-flex`/`inline-grid`: participates in /// the surrounding inline flow from the outside, like plain `inline` diff --git a/crates/obscura-render/src/paint.rs b/crates/obscura-render/src/paint.rs index 7bed4a2..ade6d1d 100644 --- a/crates/obscura-render/src/paint.rs +++ b/crates/obscura-render/src/paint.rs @@ -3669,27 +3669,40 @@ fn paint_laid_dom_scrolled( let has_value = node .get_attribute("value") .map(|v| !v.is_empty()) - .unwrap_or(false); + .unwrap_or(false) + || (name.local.as_ref() == "textarea" + && !tree.text_content(nid).is_empty()); if !has_value { if let Some(placeholder) = node.get_attribute("placeholder") { if !placeholder.is_empty() { let fsize = style.font_size.unwrap_or(16.0); let text_x = rect.x + style.padding.left + style.border.left; let text_y = rect.y + style.padding.top + style.border.top; - draw_text( - &mut pixmap, - placeholder, - text_x, - text_y, - [117, 117, 117, 255], - fsize, - false, - style.font_family.as_deref(), - style.letter_spacing.unwrap_or(0.0), - clip, - element_clip_mask, - raster_scale, - ); + let placeholder_style = style.placeholder_pseudo.as_deref(); + let mut color = placeholder_style + .and_then(|pseudo| pseudo.color) + .unwrap_or([117, 117, 117, 255]); + let opacity = placeholder_style + .and_then(|pseudo| pseudo.opacity) + .unwrap_or(1.0) + .clamp(0.0, 1.0); + color[3] = ((color[3] as f32) * opacity).round() as u8; + if color[3] != 0 { + draw_text( + &mut pixmap, + placeholder, + text_x, + text_y, + color, + fsize, + false, + style.font_family.as_deref(), + style.letter_spacing.unwrap_or(0.0), + clip, + element_clip_mask, + raster_scale, + ); + } } } } @@ -9139,6 +9152,84 @@ mod tests { use crate::dom::layout_dom_with_web_fonts; use obscura_dom::tree_sink::parse_html; + #[test] + fn native_placeholders_honor_default_author_color_opacity_and_value_state() { + let tree = parse_html( + r#" + + + + + + "#, + ); + let mut resources = RenderResourceCache::default(); + let mut prepared = prepare_dom(&tree, (200.0, 130.0), None, &mut resources) + .expect("placeholder layout"); + let node = |selector| tree.query_selector(selector).unwrap().unwrap(); + + assert!( + prepared.layout().styles[&node("#default")] + .placeholder_pseudo + .is_none(), + "the native default must not require an authored pseudo rule" + ); + assert_eq!( + prepared.layout().styles[&node("#colored")] + .placeholder_pseudo + .as_deref() + .and_then(|style| style.color), + Some([255, 0, 0, 255]) + ); + assert_eq!( + prepared.layout().styles[&node("#hidden")] + .placeholder_pseudo + .as_deref() + .and_then(|style| style.opacity), + Some(0.0) + ); + let inherited = prepared.layout().styles[&node("#inherited")] + .placeholder_pseudo + .as_deref() + .expect("inherited placeholder style"); + assert_eq!(inherited.color, Some([0, 0, 255, 255])); + assert_eq!(inherited.opacity, Some(0.5)); + + let pixmap = paint_prepared(&tree, &mut prepared, &mut resources, (0.0, 0.0)) + .expect("placeholder paint"); + let non_white = |top: u32| { + (top..top + 30) + .flat_map(|y| (0..180).map(move |x| (x, y))) + .filter(|&(x, y)| { + let pixel = pixmap.pixel(x, y).unwrap(); + pixel.red() < 245 || pixel.green() < 245 || pixel.blue() < 245 + }) + .count() + }; + assert!(non_white(0) > 10, "the native default placeholder must paint"); + assert!( + (30..60).any(|y| (0..180).any(|x| { + let pixel = pixmap.pixel(x, y).unwrap(); + pixel.red() > 200 && pixel.green() < 80 && pixel.blue() < 80 + })), + "the authored placeholder color must reach glyph paint" + ); + assert_eq!(non_white(60), 0, "opacity:0 must suppress placeholder glyphs"); + assert_eq!( + non_white(90), + 0, + "a non-empty control value must suppress placeholder glyphs" + ); + } + #[test] fn cache_only_mode_does_not_load_or_negative_cache_unknown_urls() { let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); diff --git a/vendor/cosmic-text/Cargo.toml b/vendor/cosmic-text/Cargo.toml index fd7f5f7..022ae8d 100644 --- a/vendor/cosmic-text/Cargo.toml +++ b/vendor/cosmic-text/Cargo.toml @@ -42,7 +42,6 @@ fontconfig = [ ] monospace_fallback = [] no_std = [ - "rustybuzz/libm", "hashbrown", "dep:libm", ] @@ -129,8 +128,7 @@ version = "1.1.0" default-features = false [dependencies.rustybuzz] -version = "0.14" -features = ["libm"] +version = "0.20" default-features = false [dependencies.self_cell] @@ -158,7 +156,7 @@ version = "0.3.1" optional = true [dependencies.ttf-parser] -version = "0.21" +version = "0.25" default-features = false [dependencies.unicode-bidi]