Fix hydration, placeholders, and variable font geometry
This commit is contained in:
1 parent
66f04d0f43
commit
b4d11b4c9e
9 files changed
+386
-95
No files matched your search
Generated
+5
-40
@@ -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",
|
||||
|
||||
Vendored
+31
-11
@@ -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;
|
||||
|
||||
|
||||
@@ -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("<html><body></body></html>");
|
||||
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#"<html style="margin:0"><head><style>
|
||||
@keyframes dismiss {
|
||||
from { opacity:1; transform:translateY(0) }
|
||||
to { opacity:0; transform:translateY(-80px) }
|
||||
}
|
||||
body { margin:0; width:160px; height:100px; background:#f5f7fa }
|
||||
#content { width:120px; height:50px; margin:20px; background:#1769aa }
|
||||
#shell { position:fixed; inset:0; background:#111827 }
|
||||
#shell.dismissed { animation:dismiss 600ms linear forwards }
|
||||
</style></head><body><div id="content"></div>
|
||||
<div id="shell"></div></body></html>"#,
|
||||
));
|
||||
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("<html><body></body></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() {
|
||||
|
||||
@@ -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<String, String>,
|
||||
host_style: &LayoutStyle,
|
||||
) -> (Option<LayoutStyle>, Option<LayoutStyle>) {
|
||||
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<String, String>,
|
||||
host_style: &LayoutStyle,
|
||||
evaluator: &mut ContainerQueryEvaluator<'_>,
|
||||
) -> (Option<LayoutStyle>, Option<LayoutStyle>) {
|
||||
self.pseudo_styles_internal(tree, matcher, nid, props, host_style, Some(evaluator))
|
||||
evaluator: Option<&mut ContainerQueryEvaluator<'_>>,
|
||||
) -> (
|
||||
Option<LayoutStyle>,
|
||||
Option<LayoutStyle>,
|
||||
Option<LayoutStyle>,
|
||||
) {
|
||||
self.pseudo_styles_internal(tree, matcher, nid, props, host_style, evaluator)
|
||||
}
|
||||
|
||||
fn pseudo_styles_internal(
|
||||
@@ -2816,8 +2873,12 @@ impl Stylesheet {
|
||||
props: &HashMap<String, String>,
|
||||
host_style: &LayoutStyle,
|
||||
mut evaluator: Option<&mut ContainerQueryEvaluator<'_>>,
|
||||
) -> (Option<LayoutStyle>, Option<LayoutStyle>) {
|
||||
let mut build = |rules: &PseudoRuleMap, matcher: &mut Matcher| {
|
||||
) -> (
|
||||
Option<LayoutStyle>,
|
||||
Option<LayoutStyle>,
|
||||
Option<LayoutStyle>,
|
||||
) {
|
||||
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(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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("<p id='copy'>Tools and</p>");
|
||||
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(&[
|
||||
|
||||
@@ -1198,6 +1198,9 @@ pub struct LayoutStyle {
|
||||
/// positioned decorative boxes for layout-independent painting.
|
||||
pub before_pseudo: Option<Box<LayoutStyle>>,
|
||||
pub after_pseudo: Option<Box<LayoutStyle>>,
|
||||
/// Computed author style for the native text-control `::placeholder`
|
||||
/// pseudo-element. Its anonymous glyphs are painted by the control.
|
||||
pub placeholder_pseudo: Option<Box<LayoutStyle>>,
|
||||
|
||||
/// True for `inline-block`/`inline-flex`/`inline-grid`: participates in
|
||||
/// the surrounding inline flow from the outside, like plain `inline`
|
||||
|
||||
@@ -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#"<html><head><style>
|
||||
html,body { margin:0; background:white }
|
||||
input { display:block; box-sizing:border-box; width:180px; height:30px;
|
||||
padding:0; border:0; font-size:20px; background:white }
|
||||
#colored::placeholder { color:rgb(255,0,0) }
|
||||
#hidden::placeholder { opacity:0 }
|
||||
#inherited { color:rgb(0,0,255) }
|
||||
#inherited::placeholder { color:inherit; opacity:.5 }
|
||||
</style></head><body>
|
||||
<input id="default" placeholder="default">
|
||||
<input id="colored" placeholder="colored">
|
||||
<input id="hidden" placeholder="hidden">
|
||||
<input id="filled" placeholder="must not paint" value="actual">
|
||||
<input id="inherited" placeholder="inherited">
|
||||
</body></html>"#,
|
||||
);
|
||||
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));
|
||||
|
||||
Vendored
+2
-4
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user