From e741cb3095b4657834ec7ba3d559e03e19350c49 Mon Sep 17 00:00:00 2001 From: ldm0 Date: Tue, 22 Sep 2026 18:07:50 +0800 Subject: [PATCH 1/4] fix(content): parse counters in alternative text --- style/values/generics/counters.rs | 4 +- style/values/specified/counters.rs | 107 +++++++++++++++++++++++++++-- 2 files changed, 103 insertions(+), 8 deletions(-) diff --git a/style/values/generics/counters.rs b/style/values/generics/counters.rs index 2f8ce57819..4e979507be 100644 --- a/style/values/generics/counters.rs +++ b/style/values/generics/counters.rs @@ -200,8 +200,8 @@ fn is_decimal(counter_type: &CounterStyle) -> bool { )] #[repr(C)] pub struct GenericContentItems { - /// The actual content items. Note that, past the alt marker, only some subset (strings, - /// attr(), counter()) + /// The actual content items. Past the alt marker, only strings, attr(), counter(), + /// and counters() are allowed. pub items: thin_vec::ThinVec>, /// The index at which alt text starts, always non-zero. If equal to items.len(), no alt text /// exists. diff --git a/style/values/specified/counters.rs b/style/values/specified/counters.rs index 4526417f00..f289cf66b3 100644 --- a/style/values/specified/counters.rs +++ b/style/values/specified/counters.rs @@ -280,16 +280,13 @@ impl Parse for Content { )); }, Token::Function(ref name) => { - // FIXME(emilio): counter() / counters() should be valid per spec past - // the alt marker, but it's likely non-trivial to support and other - // browsers don't support it either, so restricting it for now. let result = match_ignore_ascii_case! { &name, - "counter" if alt_start.is_none() => input.parse_nested_block(|input| { + "counter" => input.parse_nested_block(|input| { let name = CustomIdent::parse(input, &[])?; let style = Content::parse_counter_style(context, input); Ok(generics::ContentItem::Counter(name, style)) }), - "counters" if alt_start.is_none() => input.parse_nested_block(|input| { + "counters" => input.parse_nested_block(|input| { let name = CustomIdent::parse(input, &[])?; input.expect_comma()?; let separator = input.expect_string()?.as_ref().to_owned().into(); @@ -344,7 +341,7 @@ impl Parse for Content { }, } } - if items.is_empty() { + if items.is_empty() || alt_start == Some(items.len()) { return Err(input.new_custom_error(StyleParseErrorKind::UnspecifiedError)); } let alt_start = alt_start.unwrap_or(items.len()); @@ -354,3 +351,101 @@ impl Parse for Content { })) } } + +#[cfg(all(test, feature = "servo"))] +mod tests { + use super::*; + use crate::context::QuirksMode; + use crate::custom_properties::AttrTaint; + use crate::stylesheets::{CssRuleType, Origin, UrlExtraData}; + use cssparser::ParserInput; + use style_traits::ParsingMode; + + fn parse_content(value: &str) -> Option { + static_prefs::set_pref!("layout.css.content.alt-text.enabled", true); + let url_data = UrlExtraData::from(url::Url::parse("https://example.com/").unwrap()); + let context = ParserContext::new( + Origin::Author, + &url_data, + Some(CssRuleType::Style), + ParsingMode::DEFAULT, + QuirksMode::NoQuirks, + Default::default(), + None, + None, + AttrTaint::default(), + ); + let mut input = ParserInput::new(value); + Parser::new(&mut input) + .parse_entirely(|input| Content::parse(&context, input)) + .ok() + } + + #[test] + fn content_alt_counters_parse_and_serialize() { + for (input, expected, main_count) in [ + ( + r#""Chapter " / "Chapter " counter(chapter)"#, + r#""Chapter " / "Chapter " counter(chapter)"#, + 1, + ), + ( + r#""main / label" / /* alt */ COUNTER(chapter, DECIMAL)"#, + r#""main / label" / counter(chapter)"#, + 1, + ), + ( + r#"counters(chapter, "/") " " / counters(chapter, "/", upper-roman)"#, + r#"counters(chapter, "/") " " / counters(chapter, "/", upper-roman)"#, + 2, + ), + ( + r#"url("https://example.com/icon.svg") / attr(data-label) counter(chapter)"#, + r#"url("https://example.com/icon.svg") / attr(data-label) counter(chapter)"#, + 1, + ), + ( + r#""label" / c\6f unter(chapter)"#, + r#""label" / counter(chapter)"#, + 1, + ), + (r#""label" / """#, r#""label" / """#, 1), + ] { + let parsed = parse_content(input).unwrap_or_else(|| panic!("{input} should parse")); + let Content::Items(ref items) = parsed else { + panic!("{input} should produce content items"); + }; + assert_eq!(items.alt_start, main_count, "{input}"); + assert!(items.items.len() > items.alt_start, "{input}"); + assert_eq!(parsed.to_css_string(), expected, "{input}"); + assert_eq!(parse_content(expected), Some(parsed), "{input}"); + } + } + + #[test] + fn content_alt_rejects_invalid_components_and_counter_arguments() { + for input in [ + r#"none / counter(chapter)"#, + r#"normal / counter(chapter)"#, + r#"/ counter(chapter)"#, + r#""label" /"#, + r#""label" / /* empty alt */"#, + r#""label" / counter()"#, + r#""label" / counter(1)"#, + r#""label" / counter(chapter,)"#, + r#""label" / counter(chapter, decimal, extra)"#, + r#""label" / counters(chapter)"#, + r#""label" / counters(chapter, 1)"#, + r#""label" / attr() counter(chapter)"#, + r#""label" / url(alt.svg) counter(chapter)"#, + r#""label" / open-quote counter(chapter)"#, + r#""label" / close-quote counter(chapter)"#, + r#""label" / no-open-quote counter(chapter)"#, + r#""label" / no-close-quote counter(chapter)"#, + r#""label" / counter(chapter) / "extra""#, + r#""label" / counter(chapter) }"#, + ] { + assert!(parse_content(input).is_none(), "{input} should be invalid"); + } + } +} From 3c8bbb450a52f9a4dcb2de51061216a1d2727def Mon Sep 17 00:00:00 2001 From: ldm0 Date: Tue, 22 Sep 2026 19:21:11 +0800 Subject: [PATCH 2/4] docs(content): clarify alternative counter support scope --- style/values/specified/counters.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/style/values/specified/counters.rs b/style/values/specified/counters.rs index f289cf66b3..55d90bea8e 100644 --- a/style/values/specified/counters.rs +++ b/style/values/specified/counters.rs @@ -280,6 +280,14 @@ impl Parse for Content { )); }, Token::Function(ref name) => { + // CSS Content permits counter() and counters() after the alt marker. + // Moli enables this grammar for native declarations and CSSOM serialization. + // The resulting style values retain counter expressions; this parser does + // not resolve them into accessible text. Embedders must resolve each + // element's counter scope and refresh accessibility text when DOM, styles, + // or counter values change. + // TODO(moli): Validate downstream alt-counter evaluation and invalidation, + // including Gecko consumers of this shared parser. let result = match_ignore_ascii_case! { &name, "counter" => input.parse_nested_block(|input| { let name = CustomIdent::parse(input, &[])?; From 6755e187acf231b93d743278039eee726e4199f7 Mon Sep 17 00:00:00 2001 From: kevin Date: Mon, 21 Sep 2026 22:19:15 +0800 Subject: [PATCH 3/4] feat(css): expose preferred text scale through the Servo environment --- style/custom_properties.rs | 12 ++++++++++++ style/device/servo.rs | 14 ++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/style/custom_properties.rs b/style/custom_properties.rs index 8ac9e51dd5..810670d7b3 100644 --- a/style/custom_properties.rs +++ b/style/custom_properties.rs @@ -227,6 +227,18 @@ impl CssEnvironment { return None; } + #[cfg(feature = "servo")] + if *name == Atom::from("preferred-text-scale") { + return Some(VariableValue::from_token( + Token::Number { + has_sign: false, + value: device.preferred_text_scale(), + int_value: None, + }, + url_data, + )); + } + if let Some(var) = ENVIRONMENT_VARIABLES.iter().find(|var| var.name == *name) { return Some((var.evaluator)(device, url_data)); } diff --git a/style/device/servo.rs b/style/device/servo.rs index 10dba0298d..736c51b27a 100644 --- a/style/device/servo.rs +++ b/style/device/servo.rs @@ -84,6 +84,8 @@ impl Default for ServoMediaFeaturePreferences { pub(super) struct ExtraDeviceData { /// The current media type used by de device. media_type: MediaType, + /// User preferred text scale exposed through CSS env(). + preferred_text_scale: f32, /// The current viewport size, in CSS pixels. viewport_size: Size2D, /// The current screen size, in device pixels. @@ -148,6 +150,7 @@ impl Device { body_text_color: AtomicU32::new(AbsoluteColor::BLACK.to_nscolor()), extra: ExtraDeviceData { media_type, + preferred_text_scale: 1.0, viewport_size, device_size, device_pixel_ratio, @@ -162,6 +165,17 @@ impl Device { } } + /// User preferred text scale exposed by `env(preferred-text-scale)`. + pub fn preferred_text_scale(&self) -> f32 { + self.extra.preferred_text_scale + } + + /// Set the CSS environment text scale. The embedder must invalidate styles + /// that depend on environment variables when this value changes. + pub fn set_preferred_text_scale(&mut self, scale: f32) { + self.extra.preferred_text_scale = scale; + } + /// Returns the computed line-height for the font in a given computed values instance. /// /// If you pass down an element, then the used line-height is returned. From 3d44ed30808997861946a3f4b84a20318c4d451c Mon Sep 17 00:00:00 2001 From: lanyue-llk <270302213+lanyue-llk@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:10:39 +0800 Subject: [PATCH 4/4] fix(style): validate preferred text scale at device boundary --- style/device/servo.rs | 68 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/style/device/servo.rs b/style/device/servo.rs index 736c51b27a..0c734d5d80 100644 --- a/style/device/servo.rs +++ b/style/device/servo.rs @@ -173,6 +173,10 @@ impl Device { /// Set the CSS environment text scale. The embedder must invalidate styles /// that depend on environment variables when this value changes. pub fn set_preferred_text_scale(&mut self, scale: f32) { + assert!( + scale.is_finite() && scale > 0.0, + "preferred text scale must be finite and positive" + ); self.extra.preferred_text_scale = scale; } @@ -585,6 +589,70 @@ fn used_color_scheme_is_dark( mod tests { use super::*; + #[derive(Debug)] + struct TestFontMetrics; + + impl FontMetricsProvider for TestFontMetrics { + fn query_font_metrics( + &self, + _vertical: bool, + _font: &Font, + _base_size: CSSPixelLength, + _flags: QueryFontMetricsFlags, + ) -> FontMetrics { + FontMetrics::default() + } + + fn base_size_for_generic(&self, _generic: GenericFontFamily) -> Length { + Length::new(16.0) + } + } + + #[test] + fn preferred_text_scale_environment_tracks_device_and_rejects_invalid_values() { + let mut device = Device::new( + MediaType::screen(), + QuirksMode::NoQuirks, + euclid::size2(100.0, 100.0), + euclid::size2(100.0, 100.0), + Scale::new(1.0), + Box::new(TestFontMetrics), + ComputedValues::initial_values_with_font_override(Font::initial_values()), + PrefersColorScheme::Light, + PointerCapabilities::default(), + PointerCapabilities::default(), + ); + let url_data = crate::stylesheets::UrlExtraData::from( + url::Url::parse("https://example.test/").unwrap(), + ); + let text_scale = crate::Atom::from("preferred-text-scale"); + let value = |device: &Device| { + device + .environment() + .get(&text_scale, device, &url_data) + .unwrap() + .css + .parse::() + .unwrap() + }; + assert_eq!(value(&device), 1.0); + device.set_preferred_text_scale(1.5); + assert_eq!(value(&device), 1.5); + device.set_preferred_text_scale(2.0); + assert_eq!(value(&device), 2.0); + assert!(device + .environment() + .get(&crate::Atom::from("unknown"), &device, &url_data) + .is_none()); + for invalid in [0.0, -1.0, f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { + assert!(std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + device.set_preferred_text_scale(invalid); + })) + .is_err()); + assert_eq!(value(&device), 2.0); + } + } + #[test] fn used_color_scheme_honors_supported_schemes_and_user_preference() { assert!(!used_color_scheme_is_dark(