Static engine: pixel-sample image backgrounds behind text

The analytic walk skips the contrast check when a url() image layer backs
text, so CLI scans, CI gates, and the hook had no answer for image-backed
text. Outside a browser there is no CORS and no canvas taint: the static
engine now reads the image itself, a local file next to the markup or a
base64 data URI, decoded with the pure-Rust image crate, sampled on a fixed
6x6 grid, and measured against the text. Static scans have no layout, so
the verdict is coarse by design: the finding fires only when the 90th
percentile of the grid fails. Uniform tint washes composite exactly,
varying scrims keep the skip, no-repeat icons count as decoration, and a
remote URL is never fetched.

The cascade carries backgroundRepeat / backgroundSize for the decoration
gate, stored beside the background shorthand expansion rather than inside
it, because the frozen vectors pin that expansion.

Fixture sampled-image-contrast.html with generated PNG, JPEG, WebP, alpha
and icon images; goldens recorded for the new cases, and the directory
sweeps re-recorded (only the fixture's eight findings moved), noted in
tests/oracle/DELTAS.md.

Refs #560.

AI assistance: Claude Code (Claude Fable 5.1), on maintainer instruction.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Abdul Wahab
2026-09-10 10:08:00 +05:00
co-authored by Claude Fable 5.1
parent 67d018fe05
commit a82233736c
34 changed files with 1165 additions and 43 deletions
Generated
+3
View File
@@ -509,6 +509,7 @@ dependencies = [
"image-webp",
"moxcms",
"num-traits",
"png",
"zune-core",
"zune-jpeg",
]
@@ -671,9 +672,11 @@ dependencies = [
name = "impeccable-html"
version = "0.1.5"
dependencies = [
"base64",
"cssparser",
"ego-tree",
"html5ever",
"image",
"impeccable-common",
"impeccable-core",
"impeccable-detect",
+1 -1
View File
@@ -465,7 +465,7 @@ npx impeccable ignores add-value overused-font Inter --reason "Brand font"
The detector catches 61 deterministic issues across AI slop (side-tab borders, purple gradients, bounce easing, dark glows) and general design quality (line length, cramped padding, small touch targets, skipped headings, and more).
Human-readable findings are diagnostics written to stderr, so redirect them with `2> findings.txt`. Use `--json` for machine-readable results on stdout. Exit `0` means the scan completed without primary findings, exit `2` means it completed with primary findings, and exit `1` means at least one requested target could not be scanned; operational failure takes precedence for a partial multi-target scan. URL scans inspect the rendered DOM, computed layout, and accessible linked stylesheets; browser security still prevents reading cross-origin CSS without CORS. A clean detector run is evidence, not proof of visual or accessibility quality: it does not replace inspecting the rendered experience across relevant viewports.
Human-readable findings are diagnostics written to stderr, so redirect them with `2> findings.txt`. Use `--json` for machine-readable results on stdout. Exit `0` means the scan completed without primary findings, exit `2` means it completed with primary findings, and exit `1` means at least one requested target could not be scanned; operational failure takes precedence for a partial multi-target scan. URL scans inspect the rendered DOM, computed layout, and accessible linked stylesheets; browser security still prevents reading cross-origin CSS without CORS. File scans read the pixels of local background images behind text, so white copy on a near-white photo fails a CI gate without a browser; a remote image is never fetched. A clean detector run is evidence, not proof of visual or accessibility quality: it does not replace inspecting the rendered experience across relevant viewports.
By default, `detect` respects the same `.impeccable/config.json` and `.impeccable/config.local.json` detector config as the design hook: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. Hook lifecycle settings such as `hook.enabled` only affect automatic hook execution.
+4
View File
@@ -29,6 +29,9 @@
//! checkNumberedSectionLabels, checkEmDashOveruse, isRepeatedTextContainer.
//! Open (selector and tag lists, thresholds, the two text parsers):
//! `impeccable_foundation::rules::text`.
//! - `sampled_contrast`: the static engine's pixel-sampled verdict for text
//! over a `url()` background (#560): grid, decoration gate, wash and
//! compositing rules, the percentile verdict. No JS ancestor.
//!
//! Element/document adapters (`checkElement*`, `*DOM`, `*FromDoc`) are NOT in
//! core: the static ones live in the `html` crate against its DOM model, the
@@ -43,6 +46,7 @@ pub mod css_scan;
pub mod html_patterns;
pub mod measures;
pub mod rules;
pub mod sampled_contrast;
pub mod text_rules;
#[cfg(feature = "vectors")]
+19 -10
View File
@@ -135,21 +135,30 @@ fn is_heading_123(tag: &str) -> bool {
matches!(tag, "h1" | "h2" | "h3")
}
/// The `SAFE_TAGS` skip at the top of `checkColors`: a link, control, or
/// quote is measured only when it paints its own surface. Shared with the
/// sampled-contrast path so both skip the same elements.
pub fn safe_tag_unstyled(opts: &ColorOpts) -> bool {
if !set_has(SAFE_TAGS, opts.tag.as_str()) {
return false;
}
let bg_image = opts.bg_image.as_deref().unwrap_or("");
let own_bg = opts
.bg_color
.map_or(false, |c| c.a.map_or(false, |a| a > 0.5));
let own_gradient = !bg_image.is_empty() && GRADIENT_CI.is_match(bg_image);
let is_styled_control =
opts.has_direct_text && (own_bg || own_gradient) && opts.font_size >= 9.0;
!is_styled_control
}
/// JS: checks.mjs#checkColors
pub fn check_colors(opts: &ColorOpts) -> Vec<RuleHit> {
let tag = opts.tag.as_str();
let bg_image = opts.bg_image.as_deref().unwrap_or("");
let bg_clip = opts.bg_clip.as_deref().unwrap_or("");
if set_has(SAFE_TAGS, tag) {
let own_bg = opts
.bg_color
.map_or(false, |c| c.a.map_or(false, |a| a > 0.5));
let own_gradient = !bg_image.is_empty() && GRADIENT_CI.is_match(bg_image);
let is_styled_control =
opts.has_direct_text && (own_bg || own_gradient) && opts.font_size >= 9.0;
if !is_styled_control {
return Vec::new();
}
if safe_tag_unstyled(opts) {
return Vec::new();
}
let mut findings = Vec::new();
+293
View File
@@ -0,0 +1,293 @@
//! Pixel-sampled contrast for text over a `url()` background image in the
//! static engine (#560). Outside a browser there is no CORS and no canvas
//! taint, so the engine reads the image itself; what it cannot know is
//! layout. The verdict is therefore coarse: a fixed grid over the whole
//! image, and a finding only when nearly all of it fails. A photo with a dark
//! third under the headline passes; white text on a near-white texture does
//! not, wherever the text sits.
//!
//! This module is the decisions: grid geometry, the decoration gate, the
//! wash and compositing rules, the percentile verdict and its label. Reading
//! and decoding the image is the html crate's job.
use crate::checks::rules::{safe_tag_unstyled, ColorOpts, RuleHit};
use crate::color::{color_to_hex, composite_color_over, contrast_ratio, Rgba};
use crate::constants::{WCAG_LARGE_BOLD_TEXT_PX, WCAG_LARGE_TEXT_PX};
use crate::js::{number_to_string, parse_float, to_fixed};
/// Grid points per axis: 6x6 = 36 samples, sampling rather than scanning.
const GRID: usize = 6;
/// A `no-repeat` layer painted smaller than this on a non-repeating axis is
/// an icon, badge, or rule, not the ground the text sits on.
const DECORATION_MAX_PX: f64 = 160.0;
/// The share of the grid that has to resolve to a color before a verdict.
const MIN_SAMPLE_SHARE: f64 = 0.75;
/// The finding fires when this share of the samples fails: the text is
/// somewhere on the image, so only a near-uniform failure is a failure.
const FAIL_PERCENTILE: f64 = 0.9;
/// The color rule's own gates, so the sampler never fires where
/// `check_colors` would not have measured a resolved background.
pub fn applies(opts: &ColorOpts) -> bool {
opts.has_direct_text
&& opts.text_color.is_some()
&& !opts.is_emoji_only
&& opts.bg_clip.as_deref() != Some("text")
&& !safe_tag_unstyled(opts)
}
/// The sample positions over a `width` x `height` raster: cell centers of
/// the `GRID`, row-major.
pub fn grid_points(width: usize, height: usize) -> Vec<(usize, usize)> {
if width == 0 || height == 0 {
return Vec::new();
}
let at = |i: usize, extent: usize| -> usize {
let v = ((i as f64 + 0.5) / GRID as f64 * extent as f64).floor() as usize;
v.min(extent - 1)
};
let mut points = Vec::with_capacity(GRID * GRID);
for gy in 0..GRID {
for gx in 0..GRID {
points.push((at(gx, width), at(gy, height)));
}
}
points
}
/// Whether a layer with this `background-repeat` and `background-size`
/// (the layer's own comma-list entries) paints too little to be the ground:
/// a non-repeating axis whose painted extent is under
/// `DECORATION_MAX_PX`. Repeating, `cover`, `contain`, and percentage sizes
/// fill the box and are never decoration.
pub fn is_decorative_layer(repeat: &str, size: &str, intrinsic_w: f64, intrinsic_h: f64) -> bool {
let repeat = repeat.trim().to_ascii_lowercase();
let tokens: Vec<&str> = repeat.split_whitespace().collect();
let (repeat_x, repeat_y) = match tokens.as_slice() {
["no-repeat"] => (false, false),
["repeat-x"] => (true, false),
["repeat-y"] => (false, true),
[x, y] => (*x != "no-repeat", *y != "no-repeat"),
_ => (true, true),
};
if repeat_x && repeat_y {
return false;
}
let size = size.trim().to_ascii_lowercase();
if size == "cover" || size == "contain" || size.contains('%') {
return false;
}
let tokens: Vec<&str> = size.split_whitespace().collect();
let px = |t: Option<&&str>| t.filter(|t| t.ends_with("px")).map(|t| parse_float(t));
let w = px(tokens.first()).unwrap_or(intrinsic_w);
let h = px(tokens.get(1)).unwrap_or_else(|| {
// `background-size: 40px` scales the height with the width.
if tokens.len() == 1 && px(tokens.first()).is_some() && intrinsic_w > 0.0 {
intrinsic_h * (w / intrinsic_w)
} else {
intrinsic_h
}
});
(!repeat_x && w < DECORATION_MAX_PX) || (!repeat_y && h < DECORATION_MAX_PX)
}
/// A translucent gradient whose stops are all one color is a tint over the
/// image and composites exactly; a gradient that varies is a scrim placed
/// under the text on purpose, and without layout the engine cannot say
/// which stop the text sits on. `None` for the varying case.
pub fn uniform_wash(stops: &[Rgba]) -> Option<Rgba> {
let first = *stops.first()?;
let same = |s: &Rgba| {
s.r == first.r
&& s.g == first.g
&& s.b == first.b
&& (s.alpha_or_one() - first.alpha_or_one()).abs() < 1e-9
};
stops.iter().all(same).then_some(first)
}
/// One grid sample's ground color: the pixel, composited over `under` when
/// it is translucent, then under every `overlays` layer (top to bottom, as
/// the walk collected them). `None` when a translucent pixel has nothing
/// known beneath it.
pub fn composite_sample(pixel: Rgba, under: Option<Rgba>, overlays: &[Rgba]) -> Option<Rgba> {
let mut color = if pixel.alpha_or_one() >= 0.99 {
Rgba::new(pixel.r, pixel.g, pixel.b, 1.0)
} else {
composite_color_over(&pixel, &under?)
};
for overlay in overlays.iter().rev() {
color = composite_color_over(overlay, &color);
}
Some(color)
}
/// The verdict over the resolved `samples` of a grid of `grid_total` points,
/// against the text of `opts`. `ground` names the image in the snippet (its
/// file name, or `data:image/png`).
pub fn sampled_contrast(
opts: &ColorOpts,
ground: &str,
samples: &[Rgba],
grid_total: usize,
) -> Option<RuleHit> {
if !applies(opts) {
return None;
}
let text = opts.text_color?;
let needed = ((grid_total as f64) * MIN_SAMPLE_SHARE).ceil().max(1.0) as usize;
if samples.len() < needed {
return None;
}
let mut ratios: Vec<f64> = samples
.iter()
.map(|bg| {
let fg = if text.alpha_or_one() < 1.0 {
composite_color_over(&text, bg)
} else {
text
};
contrast_ratio(&fg, bg)
})
.collect();
ratios.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let n = ratios.len();
let pick = |share: f64| ratios[((share * n as f64).floor() as usize).min(n - 1)];
let measured = pick(FAIL_PERCENTILE);
let median = pick(0.5);
let is_large_text = opts.font_size >= WCAG_LARGE_TEXT_PX
|| (opts.font_size >= WCAG_LARGE_BOLD_TEXT_PX && opts.font_weight >= 700.0);
let threshold = if is_large_text { 3.0 } else { 4.5 };
if measured >= threshold {
return None;
}
let ratio_label = if to_fixed(measured, 1) == to_fixed(threshold, 1) {
to_fixed(measured, 2)
} else {
to_fixed(measured, 1)
};
Some(RuleHit::new(
"low-contrast",
format!(
"sampled (coarse) {}:1 (need {}:1) — text {} on {}; p90 of {} samples, median {}:1",
ratio_label,
number_to_string(threshold),
color_to_hex(Some(&text)),
ground,
n,
to_fixed(median, 1)
),
))
}
#[cfg(test)]
mod tests {
use super::*;
fn rgba(r: f64, g: f64, b: f64, a: f64) -> Rgba {
Rgba::new(r, g, b, a)
}
fn opts(text: Rgba, font_size: f64) -> ColorOpts {
ColorOpts {
tag: "p".into(),
text_color: Some(text),
font_size,
font_weight: 400.0,
has_direct_text: true,
..Default::default()
}
}
#[test]
fn grid_covers_cell_centers_and_clamps() {
let points = grid_points(96, 64);
assert_eq!(points.len(), 36);
assert_eq!(points[0], (8, 5));
assert_eq!(points[35], (88, 58));
assert_eq!(grid_points(1, 1), vec![(0, 0); 36]);
assert!(grid_points(0, 10).is_empty());
}
#[test]
fn decoration_gate() {
assert!(is_decorative_layer("no-repeat", "auto", 24.0, 24.0));
assert!(is_decorative_layer("no-repeat", "40px", 1200.0, 800.0));
assert!(is_decorative_layer("repeat-y", "auto", 8.0, 400.0));
assert!(is_decorative_layer("repeat-x", "auto", 1200.0, 80.0));
assert!(!is_decorative_layer("no-repeat", "cover", 24.0, 24.0));
assert!(!is_decorative_layer("no-repeat", "100% auto", 24.0, 24.0));
assert!(!is_decorative_layer("no-repeat", "auto", 1600.0, 900.0));
assert!(!is_decorative_layer("repeat", "auto", 4.0, 4.0));
assert!(!is_decorative_layer("", "", 16.0, 16.0));
assert!(!is_decorative_layer(
"repeat no-repeat",
"auto",
1200.0,
300.0
));
assert!(is_decorative_layer(
"repeat no-repeat",
"auto",
1200.0,
30.0
));
}
#[test]
fn washes_and_compositing() {
let tint = rgba(0.0, 0.0, 0.0, 0.4);
assert_eq!(uniform_wash(&[tint, tint]), Some(tint));
assert_eq!(uniform_wash(&[tint, rgba(0.0, 0.0, 0.0, 0.0)]), None);
assert_eq!(uniform_wash(&[]), None);
let light = rgba(240.0, 240.0, 240.0, 1.0);
assert_eq!(composite_sample(light, None, &[]), Some(light));
let dimmed = composite_sample(light, None, &[tint]).unwrap();
assert_eq!((dimmed.r, dimmed.g, dimmed.b), (144.0, 144.0, 144.0));
let translucent = rgba(240.0, 240.0, 240.0, 0.25);
assert_eq!(composite_sample(translucent, None, &[]), None);
let over_dark =
composite_sample(translucent, Some(rgba(20.0, 20.0, 20.0, 1.0)), &[]).unwrap();
assert_eq!((over_dark.r, over_dark.a), (75.0, Some(1.0)));
}
#[test]
fn verdict_needs_a_near_uniform_failure() {
let white = rgba(253.0, 253.0, 253.0, 1.0);
let light = rgba(243.0, 239.0, 230.0, 1.0);
let dark = rgba(26.0, 24.0, 22.0, 1.0);
let all_light: Vec<Rgba> = vec![light; 36];
let hit = sampled_contrast(&opts(white, 16.0), "hero.png", &all_light, 36).unwrap();
assert_eq!(hit.id, "low-contrast");
assert_eq!(
hit.snippet,
"sampled (coarse) 1.1:1 (need 4.5:1) — text #fdfdfd on hero.png; p90 of 36 samples, median 1.1:1"
);
// Half the image is dark: the text may well sit there.
let split: Vec<Rgba> = (0..36)
.map(|i| if i % 2 == 0 { light } else { dark })
.collect();
assert!(sampled_contrast(&opts(white, 16.0), "hero.png", &split, 36).is_none());
// Three dark samples out of 36 are not enough to save it.
let mostly: Vec<Rgba> = (0..36).map(|i| if i < 3 { dark } else { light }).collect();
assert!(sampled_contrast(&opts(white, 16.0), "hero.png", &mostly, 36).is_some());
// Too few resolved samples: no verdict.
assert!(sampled_contrast(&opts(white, 16.0), "hero.png", &all_light[..20], 36).is_none());
// Large text lowers the bar, and the snippet says so.
let mid = rgba(150.0, 150.0, 150.0, 1.0);
let on_mid: Vec<Rgba> = vec![mid; 36];
assert!(
sampled_contrast(&opts(white, 28.0), "hero.png", &on_mid, 36)
.unwrap()
.snippet
.contains("(need 3:1)")
);
// Safe tags and gradient-clipped text never sample.
let mut anchor = opts(white, 16.0);
anchor.tag = "a".into();
assert!(sampled_contrast(&anchor, "hero.png", &all_light, 36).is_none());
let mut clipped = opts(white, 16.0);
clipped.bg_clip = Some("text".into());
assert!(sampled_contrast(&clipped, "hero.png", &all_light, 36).is_none());
}
}
+5
View File
@@ -26,6 +26,11 @@ html5ever = "0.39"
selectors = "0.38"
cssparser = "0.37"
thiserror = { workspace = true }
# Decoding the background images behind text for the sampled-contrast path
# (#560): pure-Rust decoders only (the comp crate's set plus `png`), so the
# wasm `detect` build keeps working and nothing native is linked.
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp"] }
base64 = "0.22"
[dev-dependencies]
impeccable-core = { workspace = true, features = ["vectors"] }
+49 -5
View File
@@ -6,11 +6,13 @@
//! and the computed style and hands plain data over.
use crate::background::{
a_ge, a_gt, read_own_background_color, resolve_background, resolve_background_info,
resolve_border_radius_px, resolve_gradient_stops, sv, sv_opt, CustomPropMap,
a_ge, a_gt, find_image_ground, read_own_background_color, resolve_background,
resolve_background_info, resolve_border_radius_px, resolve_gradient_stops, sv, sv_opt,
CustomPropMap,
};
use crate::cascade::StyleValues;
use crate::dom::{StaticDocument, StaticElement};
use crate::image_sampling::{ground_label, ImageSampler};
use crate::quality::{collapse_ws, pf0, resolve_font_size_px};
use impeccable_core::checks::measures::{
self, border_colors_from_style, border_widths_from_style, check_gpt_thin_border_wide_shadow,
@@ -24,13 +26,14 @@ use impeccable_core::checks::rules::{
GlowOpts, HeroEyebrowOpts, HoverContrastOpts, IconTileOpts, ItalicSerifOpts, KickerCandidate,
MotionOpts, RuleHit, Sides,
};
use impeccable_core::checks::sampled_contrast;
use impeccable_core::checks::text_rules::{
check_numbered_section_labels, is_kicker_candidate, is_numbered_section_label_candidate,
parse_numbered_label_text, KickerCandidateInput, NumberedLabelCandidate,
NumberedLabelCandidateInput, HEADING_TAGS, KICKER_CARD_CONTEXT_SELECTOR, KICKER_SKIP_SELECTOR,
POSITIONED_CHILD_INTERACTIVE_SELECTOR,
};
use impeccable_core::color::{composite_color_over, parse_any_color, parse_rgb};
use impeccable_core::color::{composite_color_over, parse_any_color, parse_rgb, Rgba};
use impeccable_core::js::{self, parse_float, parse_int};
use impeccable_core::js_ext_a::num_truthy;
use impeccable_core::js_ext_b::slice_utf16_prefix;
@@ -461,11 +464,15 @@ pub fn check_element_borders(
}
/// JS: checks.mjs#checkElementColors(el, style, tag, window, customPropMap, hasAnchorInheritRule)
///
/// `images` serves the one branch the JS never had: text whose ground is a
/// `url()` layer is measured against the image's pixels (#560).
pub fn check_element_colors(
el: &StaticElement<'_>,
style: &StyleValues,
tag: &str,
custom_props: CustomPropMap<'_>,
images: &ImageSampler,
) -> Vec<RuleHit> {
if sv_opt(style, "visibility") == Some("hidden") {
return Vec::new();
@@ -539,7 +546,7 @@ pub fn check_element_colors(
sv(style, "backgroundClip")
}
};
check_colors(&ColorOpts {
let opts = ColorOpts {
tag: tag.to_string(),
text_color,
bg_color: own_bg,
@@ -557,7 +564,44 @@ pub fn check_element_colors(
bg_image: Some(sv(style, "backgroundImage").to_string()),
class_list: Some(el.class_name().to_string()),
detector_is_browser: false,
})
};
let mut findings = check_colors(&opts);
if surface_unresolved {
findings.extend(sampled_image_contrast(el, &opts, images));
}
findings
}
/// The sampled-contrast path (#560): when the analytic walk gave up at a
/// `url()` layer, read that image and measure the text against a grid of
/// its pixels. Every gate that needs no IO runs first, so a container
/// without direct text never touches the file.
fn sampled_image_contrast(
el: &StaticElement<'_>,
opts: &ColorOpts,
images: &ImageSampler,
) -> Option<RuleHit> {
if !sampled_contrast::applies(opts) {
return None;
}
let ground = find_image_ground(el)?;
let raster = images.load(&ground.url)?;
if sampled_contrast::is_decorative_layer(
&ground.repeat,
&ground.size,
raster.intrinsic_width as f64,
raster.intrinsic_height as f64,
) {
return None;
}
let points = sampled_contrast::grid_points(raster.width as usize, raster.height as usize);
let samples: Vec<Rgba> = points
.iter()
.filter_map(|&(x, y)| {
sampled_contrast::composite_sample(raster.pixel(x, y), ground.under, &ground.overlays)
})
.collect();
sampled_contrast::sampled_contrast(opts, &ground_label(&ground.url), &samples, points.len())
}
/// JS: checks.mjs#checkElementHoverContrast(el, style, tag, window)
+145
View File
@@ -4,9 +4,11 @@
//! `compositeGradientStops`, `resolveBorderRadiusPx`), static-engine
//! branches only (`DETECTOR_IS_BROWSER === false`).
use crate::cascade::csstree::strings::decode_url;
use crate::cascade::StyleValues;
use crate::dom::StaticElement;
use impeccable_core::checks::measures::{parse_color_resolved, parse_radius_to_px, CustomProps};
use impeccable_core::checks::sampled_contrast::uniform_wash;
use impeccable_core::color::{
composite_color_over, is_no_paint_color_value, parse_any_color, parse_gradient_colors,
parse_rgb, split_top_level_commas, Rgba,
@@ -331,3 +333,146 @@ pub fn composite_gradient_stops(
pub fn resolve_border_radius_px(style: &StyleValues, width_px: f64) -> f64 {
parse_radius_to_px(sv_opt(style, "borderRadius"), width_px).unwrap_or(0.0)
}
// ─── the image ground (#560) ────────────────────────────────────────────────
/// The image a text element sits on when the walk ends at a `url()` layer:
/// what the sampled-contrast path reads instead of skipping.
#[derive(Debug, Clone, PartialEq)]
pub struct ImageGround {
/// The layer's `url()` argument, unescaped and unquoted.
pub url: String,
/// The layer's own `background-repeat` and `background-size` entries.
pub repeat: String,
pub size: String,
/// Translucent paint between the text and the image, top to bottom:
/// tinted surfaces up the chain and uniform gradient washes.
pub overlays: Vec<Rgba>,
/// What a translucent pixel composites over, when the walk can say.
pub under: Option<Rgba>,
}
/// One entry of a comma-separated background list, with the CSS wraparound
/// for a list shorter than the image list.
fn layer_entry(value: &str, index: usize) -> String {
let parts = split_top_level_commas(value);
if parts.is_empty() {
return String::new();
}
js::trim(&parts[index % parts.len()]).to_string()
}
/// The `url()` argument of one background layer: `url(x\ y.png)` from the
/// css-tree generator, `url("x y.png")` from a style attribute. The layer
/// may carry the rest of a shorthand after the call (`url(x)center/cover`),
/// so only the call itself is decoded.
fn layer_url(layer: &str) -> String {
let layer = js::trim(layer);
let open = layer.find('(').map(|i| i + 1).unwrap_or(layer.len());
let mut depth = 1usize;
let mut quote: Option<char> = None;
let mut close = layer.len();
for (i, ch) in layer[open..].char_indices() {
match quote {
Some(q) if ch == q => quote = None,
Some(_) => {}
None if ch == '"' || ch == '\'' => quote = Some(ch),
None if ch == '(' => depth += 1,
None if ch == ')' => {
depth -= 1;
if depth == 0 {
close = open + i + 1;
break;
}
}
None => {}
}
}
let decoded = decode_url(&layer[..close]);
let trimmed = js::trim(&decoded);
let unquoted = trimmed
.strip_prefix('"')
.and_then(|s| s.strip_suffix('"'))
.or_else(|| {
trimmed
.strip_prefix('\'')
.and_then(|s| s.strip_suffix('\''))
})
.unwrap_or(trimmed);
unquoted.to_string()
}
/// The mirror of [`resolve_background_info`]'s walk for the case it reports
/// as unresolved because a `url()` layer is the ground: that layer plus
/// everything painted between the text and it. `None` whenever the analytic
/// walk would have resolved a color or given up for another reason (an
/// opaque gradient, an unparseable color, a varying scrim).
pub fn find_image_ground(el: &StaticElement<'_>) -> Option<ImageGround> {
let mut current = Some(*el);
let mut overlays: Vec<Rgba> = Vec::new();
while let Some(cur) = current {
let style = cur.style();
let mut bg = read_cascade_background_color(&cur, style, None);
if (bg.is_none() || bg.as_ref().is_some_and(|c| a_lt(c, 0.1)))
&& CURRENTCOLOR_RE.is_match(js::trim(sv(style, "backgroundColor")))
{
let color = sv_opt(style, "color");
bg = parse_rgb(color).or_else(|| parse_color_resolved(color, None));
}
if bg.as_ref().is_some_and(|c| a_ge(c, 0.99)) {
return None;
}
if bg.is_none() && !is_no_paint_color_value(sv_opt(style, "backgroundColor")) {
return None;
}
let bg_image = sv(style, "backgroundImage");
let layers: Vec<String> = if bg_image.is_empty() || bg_image == "none" {
Vec::new()
} else {
split_top_level_commas(bg_image)
};
for (index, layer) in layers.iter().enumerate() {
let layer = js::trim(layer);
if URL_START_RE.is_match(layer) {
// A layer beneath the image is unknown paint; otherwise the
// element's own color over its parent's ground, white at the
// root like the analytic walk.
let under = if index + 1 < layers.len() {
None
} else {
let parent_ground = match cur.parent_element() {
Some(p) => resolve_background(&p, None),
None => Some(Rgba::new(255.0, 255.0, 255.0, 1.0)),
};
match bg {
Some(c) if a_gt(&c, 0.1) => {
parent_ground.map(|p| composite_color_over(&c, &p))
}
_ => parent_ground,
}
};
return Some(ImageGround {
url: layer_url(layer),
repeat: layer_entry(sv(style, "backgroundRepeat"), index),
size: layer_entry(sv(style, "backgroundSize"), index),
overlays,
under,
});
}
if GRADIENT_CALL_RE.is_match(layer) {
let stops = parse_gradient_colors(Some(layer));
if stops.is_empty() || stops.iter().all(|s| s.alpha_or_one() >= 0.99) {
return None;
}
overlays.push(uniform_wash(&stops)?);
continue;
}
return None;
}
if let Some(c) = bg.filter(|c| a_gt(c, 0.1)) {
overlays.push(c);
}
current = cur.parent_element();
}
None
}
+22 -8
View File
@@ -10,10 +10,11 @@
use super::checks_shim::CustomProps;
use super::{
apply_static_declaration, collect_static_css_rules, compare_static_priority,
is_static_inherited_prop, make_default_style, normalize_static_css_value,
parse_static_style_attribute, static_default_style, CssRule, DeclMeta, SpecifiedDecl,
SpecifiedStore, StyleValues, STATIC_DEFAULT_STYLE,
apply_static_declaration, apply_static_longhand, background_longhands,
collect_static_css_rules, compare_static_priority, is_static_inherited_prop,
make_default_style, normalize_static_css_value, parse_static_style_attribute,
static_default_style, CssRule, DeclMeta, SpecifiedDecl, SpecifiedStore, StyleValues,
STATIC_DEFAULT_STYLE,
};
use crate::dom::StaticDocument;
use crate::profile::{self, Meta, ProfileSink};
@@ -39,7 +40,7 @@ static REMOTE_HREF_RE: Lazy<Regex> =
/// Cache-busting (styles.css?v=3) and root-relative (/static/app.css) hrefs
/// must not resolve as OS-absolute paths; otherwise the whole stylesheet is
/// invisible to every element-level check.
fn resolve_linked_css_path(file_dir: &str, href: &str) -> String {
pub(crate) fn resolve_linked_css_path(file_dir: &str, href: &str) -> String {
let stripped = href.split(['?', '#']).next().unwrap_or("");
let root_relative = stripped.starts_with('/') && !stripped.starts_with("//");
if !root_relative {
@@ -96,8 +97,9 @@ pub fn collect_static_css_text(
profile: Option<&dyn ProfileSink>,
file_path: &str,
warn: Option<&dyn Fn(&str)>,
) -> String {
) -> (String, Vec<String>) {
let mut style_texts: Vec<String> = Vec::new();
let mut sheet_dirs: Vec<String> = Vec::new();
let mut warned_missing_stylesheets: std::collections::HashSet<String> =
std::collections::HashSet::new();
for style_el in doc.query_selector_all("style") {
@@ -117,7 +119,13 @@ pub fn collect_static_css_text(
|| std::fs::read(&css_path),
);
match read {
Ok(bytes) => style_texts.push(String::from_utf8_lossy(&bytes).into_owned()),
Ok(bytes) => {
style_texts.push(String::from_utf8_lossy(&bytes).into_owned());
let dir = jsp::dirname(&css_path);
if !sheet_dirs.contains(&dir) {
sheet_dirs.push(dir);
}
}
Err(_) => {
if warned_missing_stylesheets.insert(css_path.clone()) {
if let Some(warn) = warn {
@@ -129,7 +137,7 @@ pub fn collect_static_css_text(
}
}
}
style_texts.join("\n")
(style_texts.join("\n"), sheet_dirs)
}
static PSEUDO_RULE_RE: Lazy<Regex> = Lazy::new(|| {
@@ -308,6 +316,9 @@ pub fn build_static_style_map(
inline: false,
};
apply_static_declaration(store, node, &decl.prop, &decl.value, &meta);
for (prop, value) in background_longhands(&decl.prop, &decl.value) {
apply_static_longhand(store, node, &prop, &value, &meta);
}
}
}
}
@@ -331,6 +342,9 @@ pub fn build_static_style_map(
inline: true,
};
apply_static_declaration(&mut specified, node, &decl.prop, &decl.value, &meta);
for (prop, value) in background_longhands(&decl.prop, &decl.value) {
apply_static_longhand(&mut specified, node, &prop, &value, &meta);
}
}
inline_order += 1000;
}
+33 -9
View File
@@ -187,15 +187,39 @@ pub fn apply_static_declaration<K: Hash + Eq>(
) {
let map = specified.map.entry(node).or_default();
for (expanded_prop, expanded_value) in expand_static_declaration(prop, value) {
let existing = map.get(&expanded_prop).map(|d| &d.meta);
if compare_static_priority(existing, meta) {
let next = SpecifiedDecl {
meta: meta.clone(),
prop: expanded_prop.clone(),
value: expanded_value,
};
map.insert(expanded_prop, next);
}
apply_expanded(map, &expanded_prop, &expanded_value, meta);
}
}
/// Stores one already-expanded longhand for `node` under the cascade's
/// priority rule, the way `apply_static_declaration` stores each pair the
/// shorthand expansion yields. For the longhands that ride beside that
/// expansion rather than inside it (`background_longhands`).
pub fn apply_static_longhand<K: Hash + Eq>(
specified: &mut SpecifiedStore<K>,
node: K,
prop: &str,
value: &str,
meta: &DeclMeta,
) {
let map = specified.map.entry(node).or_default();
apply_expanded(map, prop, value, meta);
}
fn apply_expanded(
map: &mut IndexMap<String, SpecifiedDecl>,
prop: &str,
value: &str,
meta: &DeclMeta,
) {
let existing = map.get(prop).map(|d| &d.meta);
if compare_static_priority(existing, meta) {
let next = SpecifiedDecl {
meta: meta.clone(),
prop: prop.to_string(),
value: value.to_string(),
};
map.insert(prop.to_string(), next);
}
}
+82
View File
@@ -193,6 +193,88 @@ pub fn parse_static_animation(value: &str) -> StaticAnimation {
}
}
static BG_REPEAT_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?i)^(?:repeat|no-repeat|repeat-x|repeat-y|space|round)$").expect("BG_REPEAT_RE")
});
static BG_SIZE_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?i)^(?:auto|cover|contain|-?[0-9.]+[a-z%]*)$").expect("BG_SIZE_RE"));
/// The per-layer `background-repeat` and `background-size` lists of a
/// `background` shorthand (`url(a) center / cover no-repeat, url(b)` ->
/// `no-repeat, repeat` and `cover, auto`). The css-tree generator may glue
/// the first keyword to the image (`url(a)center/cover`), so a token that
/// holds a function call is read up to its closing paren and the rest kept.
fn parse_static_background_layers(value: &str) -> (String, String) {
let mut repeats: Vec<String> = Vec::new();
let mut sizes: Vec<String> = Vec::new();
for layer in split_css_list(value) {
let mut repeat: Vec<String> = Vec::new();
let mut size: Vec<String> = Vec::new();
let mut in_size = false;
for raw in split_css_tokens(&layer) {
let token = match raw.rfind(')') {
Some(end) if raw.contains('(') => raw[end + 1..].to_string(),
_ => raw,
};
if token.is_empty() {
in_size = false;
continue;
}
// `center/cover`, `center / cover`, or `center/ cover`: the size
// is what follows the slash, one or two values.
if let Some((_, after)) = token.split_once('/') {
in_size = true;
size.clear();
if !after.is_empty() && BG_SIZE_RE.is_match(after) {
size.push(js::to_lower_case(after));
}
continue;
}
if in_size && size.len() < 2 && BG_SIZE_RE.is_match(&token) {
size.push(js::to_lower_case(&token));
continue;
}
in_size = false;
if repeat.len() < 2 && BG_REPEAT_RE.is_match(&token) {
repeat.push(js::to_lower_case(&token));
}
}
repeats.push(if repeat.is_empty() {
"repeat".into()
} else {
repeat.join(" ")
});
sizes.push(if size.is_empty() {
"auto".into()
} else {
size.join(" ")
});
}
(repeats.join(", "), sizes.join(", "))
}
/// The `backgroundRepeat` / `backgroundSize` pairs a declaration sets. Not
/// part of `expand_static_declaration`, whose output the recorded vectors
/// pin; the cascade stores these beside it (`apply_static_longhand`) so the
/// sampled-contrast path (#560) can tell a tiled or cover image from a
/// no-repeat icon. A `background` shorthand with an image resets both to
/// what it names, as in CSS.
pub fn background_longhands(prop: &str, value: &str) -> Vec<Expanded> {
let v = js::trim(value);
match js::to_lower_case(prop).as_str() {
"background" if BG_IMAGE_RE.is_match(v) => {
let (repeat, size) = parse_static_background_layers(v);
vec![
("backgroundRepeat".into(), repeat),
("backgroundSize".into(), size),
]
}
"background-repeat" if !v.is_empty() => vec![("backgroundRepeat".into(), v.to_string())],
"background-size" if !v.is_empty() => vec![("backgroundSize".into(), v.to_string())],
_ => Vec::new(),
}
}
static BG_IMAGE_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?i)gradient|url\(").expect("BG_IMAGE_RE"));
static BG_IMAGE_SPLIT_RE: Lazy<Regex> = Lazy::new(|| {
+13 -5
View File
@@ -20,6 +20,7 @@ use crate::adapters::{
use crate::background::{resolve_background, resolve_border_radius_px, sv};
use crate::cascade::{build_static_style_map, collect_static_css_text};
use crate::dom::{StaticDocument, StaticElement};
use crate::image_sampling::ImageSampler;
use crate::page::{
check_cream_palette, check_page_layout, check_repeated_container_text_from_doc,
check_static_page_typography,
@@ -119,14 +120,19 @@ const STATIC_ELEMENT_RULES: &[(&str, &str)] = &[
("radial-spotlight-glow", "*"),
];
fn run_rule(rule_id: &str, el: &StaticElement<'_>, tag: &str) -> Vec<RuleHit> {
fn run_rule(
rule_id: &str,
el: &StaticElement<'_>,
tag: &str,
images: &ImageSampler,
) -> Vec<RuleHit> {
let style = el.style();
match rule_id {
"border-rules" => {
let radius = resolve_border_radius_px(style, pf0(sv(style, "width")));
check_element_borders(tag, style, radius, el)
}
"color-rules" => check_element_colors(el, style, tag, None),
"color-rules" => check_element_colors(el, style, tag, None, images),
"hover-color-rules" => check_element_hover_contrast(el, style, tag),
"dark-glow" => {
let base = el.parent_element().unwrap_or(*el);
@@ -207,9 +213,11 @@ pub fn detect_html_source(
Meta::new("parse-html", "parse-document", fp),
|| StaticDocument::parse(html),
);
let css_text = collect_static_css_text(&doc, &file_dir, profile, fp, options.warn);
let (css_text, sheet_dirs) =
collect_static_css_text(&doc, &file_dir, profile, fp, options.warn);
build_static_style_map(&mut doc, css_text.as_str(), profile, fp);
let doc = doc;
let images = ImageSampler::new(&file_dir.to_string_lossy(), &sheet_dirs);
let mut findings: Vec<Finding> = Vec::new();
let mk = |id: &str, snippet: &str| try_finding(id, fp, snippet, 0.0);
@@ -222,7 +230,7 @@ pub fn detect_html_source(
profile,
Meta::new("element", rule_id, fp),
|h: &RuleHit| h.id.as_str(),
|| run_rule(rule_id, el, &tag),
|| run_rule(rule_id, el, &tag, &images),
);
for h in hits {
if scoped_ignore_active(el, &h.id) {
@@ -394,7 +402,7 @@ pub fn unsupported_selectors(html: &str, file_path: &Path) -> Vec<String> {
.map(|p| p.to_path_buf())
.unwrap_or_default();
let mut doc = StaticDocument::parse(html);
let css_text = collect_static_css_text(&doc, &file_dir, None, &file_str, None);
let (css_text, _) = collect_static_css_text(&doc, &file_dir, None, &file_str, None);
build_static_style_map(&mut doc, &css_text, None, &file_str);
doc.unsupported_selectors()
}
+252
View File
@@ -0,0 +1,252 @@
//! The pixels behind image-backed text for the static engine (#560). A
//! `url()` resolves to bytes from a local file next to the markup or from a
//! base64 data URI, never from the network; the pure-Rust decoders turn them
//! into a raster no larger than the browser overlay's 640px canvas; and the
//! raster is cached for the rest of the process, so a directory scan decodes
//! each hero once. Anything unreadable, remote, oversized, or undecodable is
//! `None`, and the caller keeps today's skip.
use crate::cascade::resolve_linked_css_path;
use base64::Engine;
use impeccable_common::jsp;
use impeccable_core::color::Rgba;
use std::cell::RefCell;
use std::collections::HashMap;
use std::io::Cursor;
use std::rc::Rc;
/// The browser overlay draws to a canvas no larger than this on a side.
const MAX_RASTER_SIDE: u32 = 640;
/// Files above this are not read: a hero is a few megabytes, and the hook
/// runs on every edit.
const MAX_FILE_BYTES: u64 = 24 * 1024 * 1024;
const MAX_IMAGE_SIDE: u32 = 8192;
const MAX_DECODE_BYTES: u64 = 128 * 1024 * 1024;
/// Decoded rasters kept per process; the map is cleared when full.
const CACHE_ENTRIES: usize = 24;
/// A decoded image, RGBA8, at most [`MAX_RASTER_SIDE`] on a side.
pub struct Raster {
pub width: u32,
pub height: u32,
/// The size before the downscale: what `background-size: auto` paints.
pub intrinsic_width: u32,
pub intrinsic_height: u32,
rgba: Vec<u8>,
}
impl Raster {
pub fn pixel(&self, x: usize, y: usize) -> Rgba {
let i = (y.min(self.height as usize - 1) * self.width as usize
+ x.min(self.width as usize - 1))
* 4;
let p = &self.rgba[i..i + 4];
Rgba::new(p[0] as f64, p[1] as f64, p[2] as f64, p[3] as f64 / 255.0)
}
}
thread_local! {
static RASTERS: RefCell<HashMap<String, Option<Rc<Raster>>>> = RefCell::new(HashMap::new());
}
/// Resolves and decodes the `url()` grounds of one document. The bases are
/// the document's directory followed by every linked stylesheet's, since a
/// relative `url()` inside a sheet is relative to the sheet.
pub struct ImageSampler {
bases: Vec<String>,
}
impl ImageSampler {
pub fn new(html_dir: &str, stylesheet_dirs: &[String]) -> Self {
let mut bases = vec![html_dir.to_string()];
for dir in stylesheet_dirs {
if !bases.contains(dir) {
bases.push(dir.clone());
}
}
ImageSampler { bases }
}
/// The raster behind a `url()` argument, or `None` when it cannot be
/// read here: a remote URL, a missing or oversized file, a format the
/// decoders do not cover (SVG), or an `svg`/text data URI.
pub fn load(&self, url: &str) -> Option<Rc<Raster>> {
let url = url.trim();
if url.is_empty() {
return None;
}
let key = if is_data_uri(url) {
url.to_string()
} else {
self.resolve_file(url)?
};
if let Some(hit) = RASTERS.with(|c| c.borrow().get(&key).cloned()) {
return hit;
}
let bytes = if is_data_uri(url) {
data_uri_bytes(url)
} else {
read_bounded(&key)
};
let raster = bytes.and_then(|b| decode(&b)).map(Rc::new);
RASTERS.with(|c| {
let mut cache = c.borrow_mut();
if cache.len() >= CACHE_ENTRIES {
cache.clear();
}
cache.insert(key, raster.clone());
});
raster
}
fn resolve_file(&self, url: &str) -> Option<String> {
if url.starts_with("//") || url.contains("://") {
return None;
}
self.bases
.iter()
.map(|base| resolve_linked_css_path(base, url))
.find(|path| {
std::fs::metadata(path)
.map(|m| m.is_file())
.unwrap_or(false)
})
}
}
fn is_data_uri(url: &str) -> bool {
url.len() > 5 && url[..5].eq_ignore_ascii_case("data:")
}
/// The name a finding gives the image: the file name, or `data:<mime>`.
pub fn ground_label(url: &str) -> String {
let url = url.trim();
if is_data_uri(url) {
let header = url[5..].split(',').next().unwrap_or("");
let mime = header
.split(';')
.next()
.unwrap_or("")
.trim()
.to_ascii_lowercase();
return if mime.is_empty() {
"data:image".into()
} else {
format!("data:{mime}")
};
}
let stripped = url.split(['?', '#']).next().unwrap_or("");
jsp::basename(stripped)
}
fn data_uri_bytes(url: &str) -> Option<Vec<u8>> {
let (header, payload) = url[5..].split_once(',')?;
if !header
.split(';')
.any(|p| p.trim().eq_ignore_ascii_case("base64"))
{
return None;
}
let compact: String = payload.chars().filter(|c| !c.is_whitespace()).collect();
base64::engine::general_purpose::STANDARD
.decode(&compact)
.or_else(|_| base64::engine::general_purpose::STANDARD_NO_PAD.decode(&compact))
.ok()
}
fn read_bounded(path: &str) -> Option<Vec<u8>> {
let meta = std::fs::metadata(path).ok()?;
if !meta.is_file() || meta.len() > MAX_FILE_BYTES {
return None;
}
std::fs::read(path).ok()
}
fn decode(bytes: &[u8]) -> Option<Raster> {
let mut reader = image::ImageReader::new(Cursor::new(bytes))
.with_guessed_format()
.ok()?;
let mut limits = image::Limits::default();
limits.max_image_width = Some(MAX_IMAGE_SIDE);
limits.max_image_height = Some(MAX_IMAGE_SIDE);
limits.max_alloc = Some(MAX_DECODE_BYTES);
reader.limits(limits);
let decoded = reader.decode().ok()?;
let (intrinsic_width, intrinsic_height) = (decoded.width(), decoded.height());
if intrinsic_width == 0 || intrinsic_height == 0 {
return None;
}
// `thumbnail` also upscales, so only images past the budget go through it.
let scaled = if intrinsic_width.max(intrinsic_height) > MAX_RASTER_SIDE {
decoded.thumbnail(MAX_RASTER_SIDE, MAX_RASTER_SIDE)
} else {
decoded
};
let rgba = scaled.to_rgba8();
Some(Raster {
width: rgba.width(),
height: rgba.height(),
intrinsic_width,
intrinsic_height,
rgba: rgba.into_raw(),
})
}
#[cfg(test)]
mod tests {
use super::*;
fn png_bytes(width: u32, height: u32, rgba: [u8; 4]) -> Vec<u8> {
let img = image::RgbaImage::from_pixel(width, height, image::Rgba(rgba));
let mut out = Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(img)
.write_to(&mut out, image::ImageFormat::Png)
.unwrap();
out.into_inner()
}
#[test]
fn data_uri_decodes_and_labels() {
let bytes = png_bytes(8, 8, [240, 236, 228, 255]);
let uri = format!(
"data:image/png;base64,{}",
base64::engine::general_purpose::STANDARD.encode(&bytes)
);
let sampler = ImageSampler::new("/nonexistent", &[]);
let raster = sampler.load(&uri).expect("png data uri decodes");
assert_eq!((raster.width, raster.height), (8, 8));
assert_eq!(raster.pixel(3, 3).r, 240.0);
assert_eq!(ground_label(&uri), "data:image/png");
assert!(sampler.load("data:image/svg+xml;utf8,<svg/>").is_none());
assert!(sampler.load("https://example.com/hero.jpg").is_none());
assert!(sampler.load("//cdn.example.com/hero.jpg").is_none());
assert_eq!(ground_label("img/hero.jpg?v=3"), "hero.jpg");
}
#[test]
fn files_resolve_against_every_base_and_downscale() {
let dir = std::env::temp_dir().join(format!("impeccable-sampler-{}", std::process::id()));
let sheet_dir = dir.join("css");
std::fs::create_dir_all(&sheet_dir).unwrap();
std::fs::write(
sheet_dir.join("wide.png"),
png_bytes(1280, 320, [20, 20, 20, 255]),
)
.unwrap();
let html_dir = dir.to_string_lossy().into_owned();
let sampler = ImageSampler::new(&html_dir, &[sheet_dir.to_string_lossy().into_owned()]);
assert!(sampler.load("missing.png").is_none());
let raster = sampler
.load("wide.png")
.expect("resolves against the sheet dir");
assert_eq!((raster.width, raster.height), (640, 160));
assert_eq!(
(raster.intrinsic_width, raster.intrinsic_height),
(1280, 320)
);
assert_eq!(raster.pixel(639, 159).g, 20.0);
// The second load is the cached raster.
assert!(Rc::ptr_eq(&raster, &sampler.load("wide.png").unwrap()));
std::fs::remove_dir_all(&dir).ok();
}
}
+1
View File
@@ -9,6 +9,7 @@ pub mod background;
pub mod cascade;
pub mod dom;
pub mod engine;
pub mod image_sampling;
pub mod page;
pub mod profile;
pub mod quality;
+82
View File
@@ -209,3 +209,85 @@ fn checks_shim_helpers() {
assert_eq!(resolve_length_px("50%", 10.0), Some(5.0));
assert_eq!(resolve_length_px("1.5", 10.0), Some(15.0));
}
#[test]
fn background_longhands_ride_beside_the_expansion() {
use impeccable_html::cascade::rules::apply_static_longhand;
use impeccable_html::cascade::shorthand::background_longhands;
// The expansion itself is pinned by the recorded vectors and stays
// image-and-color only; repeat and size come from the side channel.
assert_eq!(
background_longhands(
"background",
"url(a.png) center / cover no-repeat, url(b.png)"
),
vec![
(
"backgroundRepeat".to_string(),
"no-repeat, repeat".to_string()
),
("backgroundSize".to_string(), "cover, auto".to_string()),
]
);
// The css-tree generator glues the first keyword to the call.
assert_eq!(
background_longhands("background", "url(a.png)center/cover no-repeat"),
vec![
("backgroundRepeat".to_string(), "no-repeat".to_string()),
("backgroundSize".to_string(), "cover".to_string()),
]
);
assert_eq!(
background_longhands("background", "#fff url(a.png) repeat-x"),
vec![
("backgroundRepeat".to_string(), "repeat-x".to_string()),
("backgroundSize".to_string(), "auto".to_string()),
]
);
assert_eq!(
background_longhands("Background-Size", "100% 32px"),
vec![("backgroundSize".to_string(), "100% 32px".to_string())]
);
assert!(background_longhands("background", "#fff").is_empty());
assert!(background_longhands("color", "red").is_empty());
// A later shorthand with an image resets an earlier longhand, and a
// later longhand overrides a shorthand, under the cascade's priority.
let mut specified: SpecifiedStore<&str> = SpecifiedStore::new();
let node = "n1";
let mut apply = |prop: &str, value: &str, m: DeclMeta| {
apply_static_declaration(&mut specified, node, prop, value, &m);
for (p, v) in background_longhands(prop, value) {
apply_static_longhand(&mut specified, node, &p, &v, &m);
}
};
apply(
"background-repeat",
"no-repeat",
meta(false, [0, 1, 0], 0, false),
);
apply(
"background",
"url(hero.jpg) center / cover",
meta(false, [0, 1, 0], 1, false),
);
apply(
"background-size",
"contain",
meta(false, [0, 1, 0], 2, false),
);
let map = specified.get(&node).expect("node entry");
assert_eq!(
map.get("backgroundRepeat").map(|d| d.value.as_str()),
Some("repeat")
);
assert_eq!(
map.get("backgroundSize").map(|d| d.value.as_str()),
Some("contain")
);
assert_eq!(
map.get("backgroundImage").map(|d| d.value.as_str()),
Some("url(hero.jpg) center / cover")
);
}
+1
View File
@@ -332,6 +332,7 @@ Optional keys added later by engines (appended after the above): `ignoreValue` (
#### Static and regex engines (only what affects the contract)
- `detectHtml`: reads file, imports `htmlparser2`, `css-select`, `css-tree`, `domutils`; on import failure prints once to stderr `impeccable detect: DEGRADED - HTML parser modules unavailable (htmlparser2, css-select, css-tree, domutils).\nFalling back to regex matching. Custom properties, selector matching and computed contrast are NOT evaluated; findings are an undercount, not a clean bill of health.\n` and falls back to `detectText`. Inlines `<link rel=stylesheet href>` that are local (not `/^(https?:)?\/\//i`), query/hash stripped. Runs element rules, design-system rules (`checkSourceDesignSystem` + `collectStaticDesignSystemFindings`, merged), then page rules only when `isFullPage(html)` (`/<!doctype\s|<html[\s>]|<head[\s>]/i` after stripping comments), plus text-content analyzers; ends with inline-ignore filtering.
- **Sampled contrast (#560, engine only, no JS ancestor)**: when `resolveBackgroundInfo` ends at a `url()` layer (the case the JS skipped), the static engine reads that image and measures the text against its pixels. The url resolves to a local file (relative to the page, then to each linked stylesheet's directory; root-relative paths walk up to the project root the way linked stylesheets do) or a base64 `data:` URI; a remote URL, a missing or unreadable file, a file over 24 MiB, an image over 8192px on a side, and SVG keep the skip. Decoders: PNG, JPEG, GIF, WebP. The image is scaled to at most 640px on a side and read on a fixed 6x6 grid. Each sample composites a translucent pixel over the element's own color and its parent's resolved ground (white at the root; unknown when another layer sits beneath the image), then under every translucent surface and every uniform gradient wash between the text and the image; a gradient whose stops differ is a scrim placed on purpose and keeps the skip, as does an opaque gradient or an unparseable color anywhere in the chain. A layer whose `background-repeat` leaves an axis unrepeated and whose painted extent on that axis (`background-size` in px, else the intrinsic size) is under 160px is decoration and keeps the skip; `cover`, `contain`, and percentage sizes always paint. The cascade carries `backgroundRepeat` / `backgroundSize` for this from the longhands and from the `background` shorthand (per layer, defaults `repeat` / `auto`). A verdict needs 27 of the 36 samples; the finding fires when the 90th-percentile ratio is under the WCAG threshold (same large-text rule as `checkColors`, text alpha blended over the sample): `{id:'low-contrast', snippet:`sampled (coarse) ${p90}:1 (need ${threshold}:1) — text ${hex} on ${file name | data:<mime>}; p90 of ${n} samples, median ${median}:1`}` (ratio label to 2 decimals when its 1-decimal form equals the threshold, as in `checkColors`). It is emitted in the `color-rules` pass right after `checkColors`'s hits for that element, obeys the same `SAFE_TAGS` gate, `data-impeccable-ignore`, and inline ignores, and never produces `gray-on-color`. Fixture: `sampled-image-contrast.html` (+ `sampled-images/`).
- `detectText`: regex line matchers (ids: side-tab, border-accent-on-rounded, overused-font, gradient-text, ai-color-palette, gray-on-color, bounce-easing, layout-transition, broken-image), inset-stripe/pseudo-stripe CSS scans, `codex-grid-background`, `<style>` blocks (Astro/Vue/Svelte), CSS-in-JS templates, design-system source checks; dedupe (same antipattern+snippet within 2 lines); page analyzers only when `isFullPage` and ext ∈ `{'.html','.htm','.astro','.vue','.svelte'}` or no ext (`<stdin>`): flat-type-hierarchy, monotonous-spacing, em-dash-overuse, marketing-buzzword, aphoristic-cadence, dark-glow (+ radial-halo, marquee); inline ignores last.
#### Profiler (`cli/engine/profile/profiler.mjs`)
+112
View File
@@ -0,0 +1,112 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Fixture: pixel-sampled contrast on image-backed text (static engine)</title>
<link rel="stylesheet" href="sampled-images/site.css">
<style>
body { margin: 0; padding: 40px; background: #ffffff; font-family: Georgia, serif; color: #262421; }
/* Every panel is a url() ground the analytic walk cannot measure. The
static engine reads the image's pixels instead (#560): a fixed grid over
the whole image, and a finding only when nearly all of it fails. Each
case carries UNIQUE copy so a finding attributes to exactly one case.
Images live in sampled-images/: light.png and light.webp are a
near-white texture, dark.jpg a charcoal one, split.png half light and
half dark, cutout.png the light texture at 25% alpha, icon.png a 24px
glyph. */
h1 { font-size: 28px; margin: 0 0 24px; }
.panel { width: 420px; padding: 24px; margin: 0 0 24px; }
.panel p { margin: 0; font-size: 16px; line-height: 1.6; }
.panel h2 { margin: 0; font-size: 28px; line-height: 1.3; }
.light { background: url(sampled-images/light.png); }
.light-webp { background: url("sampled-images/light.webp") center / cover; }
.dark-jpg { background: url(sampled-images/dark.jpg) center / cover no-repeat; }
.split { background: url(sampled-images/split.png) repeat; }
.inline-data { background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAaUlEQVR42k2PSQrAMAwD/f9ndt+TNl2OLmMQ5CCELUtR7CuLP+cUuPPo19F73tqY09q4nXsXC0SYA8T3mgNxgMiypMGZccLA5Kij6+eMDhKIZEZQsmkBA47UKxLkFKsHjCE6IOBQSv2bH8D48hUetch/AAAAAElFTkSuQmCC); }
/* Uniform tints composite exactly; a wash that varies is a scrim placed
under the text on purpose, and without layout the engine cannot say
which stop the text sits on. */
.wash-weak { background: linear-gradient(rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0.3)), url(sampled-images/light.png); }
.wash-strong { background: linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0.6)), url(sampled-images/light.png); }
.fade-scrim { background: linear-gradient(rgba(0, 0, 0, 0.8), rgba(0, 0, 0, 0)), url(sampled-images/light.png); }
/* A translucent image composites over what is beneath it: here a dark
section, so the light texture at 25% reads as a dark ground. */
.dark-section { width: 468px; padding: 0; background: #17150f; }
.cutout { background: url(sampled-images/cutout.png); margin: 0; }
.missing { background: url(sampled-images/nope.png); }
.remote { background: url(https://example.com/hero.jpg) center / cover; }
.svg-data { background: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100'><rect width='100' height='100' fill='%23f5f2ea'/></svg>") center / cover; }
/* A no-repeat icon painted at 24px is decoration, not the ground. */
.icon-link { background: url(sampled-images/icon.png) no-repeat left center; padding: 8px 8px 8px 32px; margin: 0 0 24px; width: 380px; font-size: 16px; line-height: 1.6; }
.white { color: #fdfdfd; }
.ink { color: #262421; }
.smoke { color: #3a3632; }
</style>
</head>
<body>
<h1>Sampled contrast fixture</h1>
<!-- ── Should flag ──────────────────────────────────────────────── -->
<div class="panel light">
<p class="white">White copy on a near-white texture fails everywhere the grid lands.</p>
</div>
<div class="panel dark-jpg">
<p class="smoke">Smoke gray copy on a charcoal photo fails wherever it sits.</p>
</div>
<div class="panel light-webp">
<p class="white">The same texture as a WebP decodes through the same path.</p>
</div>
<div class="panel inline-data">
<p class="white">A base64 PNG data URI is read without touching the disk.</p>
</div>
<div class="panel wash-weak">
<p class="white">A thirty percent tint is too weak to carry white copy over the texture.</p>
</div>
<div class="dark-section">
<div class="panel cutout">
<p class="ink">Ink on the translucent texture composites over the dark section and fails.</p>
</div>
</div>
<div class="panel from-sheet">
<p class="white">The ground of this panel is declared in the linked stylesheet.</p>
</div>
<div class="panel light">
<h2 class="white">Large white heading on the texture</h2>
</div>
<!-- ── Should pass ──────────────────────────────────────────────── -->
<div class="panel light">
<p class="ink">Ink on the near-white texture reads fine and must not flag.</p>
</div>
<div class="panel dark-jpg">
<p class="white">White copy on the charcoal photo reads fine and must not flag.</p>
</div>
<div class="panel split">
<p class="white">Half of this image is dark, so the copy may well sit there.</p>
</div>
<div class="panel wash-strong">
<p class="white">A sixty percent tint carries white copy over the texture.</p>
</div>
<div class="panel fade-scrim">
<p class="white">A scrim that fades out is placed on purpose; the engine keeps the skip.</p>
</div>
<div class="dark-section">
<div class="panel cutout">
<p class="white">White copy on the translucent texture over the dark section passes.</p>
</div>
</div>
<div class="panel missing">
<p class="white">An image that is not on disk keeps the skip.</p>
</div>
<div class="panel remote">
<p class="white">A remote image is never fetched during a file scan.</p>
</div>
<div class="panel svg-data">
<p class="white">An SVG has no raster to sample, so the skip stands.</p>
</div>
<p class="icon-link">A twenty four pixel icon beside the copy is decoration, not the ground.</p>
<div class="panel light" data-impeccable-ignore="low-contrast">
<p class="white">A waived panel keeps its sampled finding out of the report.</p>
</div>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 174 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

+3
View File
@@ -0,0 +1,3 @@
/* Linked from ../sampled-image-contrast.html. A url() inside a stylesheet is
relative to the sheet, so light.png resolves here, not next to the page. */
.from-sheet { background: url(light.png); }
Binary file not shown.

After

Width:  |  Height:  |  Size: 202 B

+12
View File
@@ -164,3 +164,15 @@ installed. The binary's `CLI_VERSION` moves from `3.6.0` to `4.0.0` with the
CLI 4.0.0 release; it is what the binary prints when run directly.
- `cli-version`.
## Recorded 2026-09-10: sampled contrast on image-backed text (#560)
The static engine now reads the pixels of a local `url()` background behind
text instead of skipping the contrast check (docs/CLI-CONTRACT.md, "Sampled
contrast"). The JS never had this path, so the new fixture
`sampled-image-contrast.html` (with `sampled-images/`) has no JS golden; its
cases were recorded from the engine, and the directory sweeps below moved
only by that fixture's eight `low-contrast` findings, verified entry by entry.
- `detect-fixture-json-sampled-image-contrast-html`, `detect-fixture-text-sampled-image-contrast-html`, `detect-fixture-json-sampled-images`, `detect-fixture-text-sampled-images`: new cases.
- `detect-dir-json-all-fixtures`, `detect-dir-text-all-fixtures`, `detect-dir-quiet-all-fixtures`, `detect-no-advisory-json`, `detect-no-advisory-text`: the directory sweep picks up the new fixture.
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
{
"stdout": "",
"stderr": "415 anti-patterns found.\n17 advisory notes (not counted).\n",
"stderr": "423 anti-patterns found.\n17 advisory notes (not counted).\n",
"exit": 2,
"signal": null,
"files": {}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,7 @@
{
"stdout": "[\n {\n \"antipattern\": \"low-contrast\",\n \"name\": \"Low contrast text\",\n \"description\": \"Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/sampled-image-contrast.html\",\n \"line\": 0,\n \"snippet\": \"sampled (coarse) 1.2:1 (need 4.5:1) — text #fdfdfd on light.png; p90 of 36 samples, median 1.1:1\"\n },\n {\n \"antipattern\": \"low-contrast\",\n \"name\": \"Low contrast text\",\n \"description\": \"Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/sampled-image-contrast.html\",\n \"line\": 0,\n \"snippet\": \"sampled (coarse) 1.3:1 (need 4.5:1) — text #3a3632 on dark.jpg; p90 of 36 samples, median 1.2:1\"\n },\n {\n \"antipattern\": \"low-contrast\",\n \"name\": \"Low contrast text\",\n \"description\": \"Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/sampled-image-contrast.html\",\n \"line\": 0,\n \"snippet\": \"sampled (coarse) 1.2:1 (need 4.5:1) — text #fdfdfd on light.webp; p90 of 36 samples, median 1.2:1\"\n },\n {\n \"antipattern\": \"low-contrast\",\n \"name\": \"Low contrast text\",\n \"description\": \"Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/sampled-image-contrast.html\",\n \"line\": 0,\n \"snippet\": \"sampled (coarse) 1.1:1 (need 4.5:1) — text #fdfdfd on data:image/png; p90 of 36 samples, median 1.1:1\"\n },\n {\n \"antipattern\": \"low-contrast\",\n \"name\": \"Low contrast text\",\n \"description\": \"Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/sampled-image-contrast.html\",\n \"line\": 0,\n \"snippet\": \"sampled (coarse) 2.5:1 (need 4.5:1) — text #fdfdfd on light.png; p90 of 36 samples, median 2.4:1\"\n },\n {\n \"antipattern\": \"low-contrast\",\n \"name\": \"Low contrast text\",\n \"description\": \"Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/sampled-image-contrast.html\",\n \"line\": 0,\n \"snippet\": \"sampled (coarse) 1.8:1 (need 4.5:1) — text #262421 on cutout.png; p90 of 36 samples, median 1.8:1\"\n },\n {\n \"antipattern\": \"low-contrast\",\n \"name\": \"Low contrast text\",\n \"description\": \"Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/sampled-image-contrast.html\",\n \"line\": 0,\n \"snippet\": \"sampled (coarse) 1.2:1 (need 4.5:1) — text #fdfdfd on light.png; p90 of 36 samples, median 1.1:1\"\n },\n {\n \"antipattern\": \"low-contrast\",\n \"name\": \"Low contrast text\",\n \"description\": \"Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/sampled-image-contrast.html\",\n \"line\": 0,\n \"snippet\": \"sampled (coarse) 1.2:1 (need 3:1) — text #fdfdfd on light.png; p90 of 36 samples, median 1.1:1\"\n }\n]\n",
"stderr": "",
"exit": 2,
"signal": null,
"files": {}
}
@@ -0,0 +1,7 @@
{
"stdout": "[]\n",
"stderr": "",
"exit": 0,
"signal": null,
"files": {}
}
@@ -0,0 +1,7 @@
{
"stdout": "",
"stderr": "\n<REPO>/tests/fixtures/antipatterns/sampled-image-contrast.html\n [low-contrast] sampled (coarse) 1.2:1 (need 4.5:1) — text #fdfdfd on light.png; p90 of 36 samples, median 1.1:1\n → Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\n [low-contrast] sampled (coarse) 1.3:1 (need 4.5:1) — text #3a3632 on dark.jpg; p90 of 36 samples, median 1.2:1\n → Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\n [low-contrast] sampled (coarse) 1.2:1 (need 4.5:1) — text #fdfdfd on light.webp; p90 of 36 samples, median 1.2:1\n → Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\n [low-contrast] sampled (coarse) 1.1:1 (need 4.5:1) — text #fdfdfd on data:image/png; p90 of 36 samples, median 1.1:1\n → Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\n [low-contrast] sampled (coarse) 2.5:1 (need 4.5:1) — text #fdfdfd on light.png; p90 of 36 samples, median 2.4:1\n → Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\n [low-contrast] sampled (coarse) 1.8:1 (need 4.5:1) — text #262421 on cutout.png; p90 of 36 samples, median 1.8:1\n → Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\n [low-contrast] sampled (coarse) 1.2:1 (need 4.5:1) — text #fdfdfd on light.png; p90 of 36 samples, median 1.1:1\n → Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\n [low-contrast] sampled (coarse) 1.2:1 (need 3:1) — text #fdfdfd on light.png; p90 of 36 samples, median 1.1:1\n → Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\n\n8 anti-patterns found.\n",
"exit": 2,
"signal": null,
"files": {}
}
@@ -0,0 +1,7 @@
{
"stdout": "",
"stderr": "",
"exit": 0,
"signal": null,
"files": {}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long