mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dacf7d4664 | ||
|
|
634b579b63 | ||
|
|
4c5f3974b3 | ||
|
|
2422ec058f | ||
|
|
0f115e5d37 | ||
|
|
66945a79da | ||
|
|
a82233736c |
Generated
+3
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
//! 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));
|
||||
// An `auto` (or absent) axis follows the other at the image's aspect
|
||||
// ratio, as in CSS: `40px`, `40px auto`, and `auto 40px` all scale.
|
||||
let (w, h) = match (px(tokens.first()), px(tokens.get(1))) {
|
||||
(Some(w), Some(h)) => (w, h),
|
||||
(Some(w), None) if intrinsic_w > 0.0 => (w, intrinsic_h * (w / intrinsic_w)),
|
||||
(None, Some(h)) if intrinsic_h > 0.0 => (intrinsic_w * (h / intrinsic_h), h),
|
||||
(Some(w), None) => (w, intrinsic_h),
|
||||
(None, Some(h)) => (intrinsic_w, h),
|
||||
(None, None) => (intrinsic_w, 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("no-repeat", "40px auto", 1200.0, 800.0));
|
||||
assert!(is_decorative_layer("no-repeat", "auto 40px", 1200.0, 800.0));
|
||||
assert!(!is_decorative_layer(
|
||||
"no-repeat",
|
||||
"auto 800px",
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,151 @@ 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. A shorthand
|
||||
/// layer may carry other tokens around the call (`#000 url(x)center/cover`),
|
||||
/// so only the call itself is decoded.
|
||||
fn layer_url(layer: &str) -> String {
|
||||
let Some(call) = URL_CALL_RE.find(layer) else {
|
||||
return String::new();
|
||||
};
|
||||
let open = call.end();
|
||||
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(&format!("url({}", &layer[open..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);
|
||||
// A shorthand layer may put color, position, size, or repeat
|
||||
// tokens before the image call; the call is what matters.
|
||||
let is_gradient = GRADIENT_CALL_RE.is_match(layer);
|
||||
if !is_gradient && URL_CALL_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 is_gradient {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -117,7 +118,15 @@ 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) => {
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
let sheet_dir = jsp::dirname(&css_path);
|
||||
style_texts.push(if sheet_dir == file_dir_str {
|
||||
text.into_owned()
|
||||
} else {
|
||||
rewrite_sheet_urls(&text, &sheet_dir, &file_dir_str)
|
||||
});
|
||||
}
|
||||
Err(_) => {
|
||||
if warned_missing_stylesheets.insert(css_path.clone()) {
|
||||
if let Some(warn) = warn {
|
||||
@@ -132,6 +141,162 @@ pub fn collect_static_css_text(
|
||||
style_texts.join("\n")
|
||||
}
|
||||
|
||||
// A quoted url cannot span a line and an unquoted one cannot hold
|
||||
// whitespace, quotes, parens, braces, or semicolons (CSS syntax), so a
|
||||
// stray `url(` can never pair with a `)` in a later rule.
|
||||
static CSS_URL_RE: Lazy<Regex> = Lazy::new(|| {
|
||||
// The leading group keeps `url(` from matching inside a longer
|
||||
// identifier such as `myurl(`; the closure emits it unchanged.
|
||||
Regex::new(
|
||||
r#"(?i)(^|[^A-Za-z0-9_-])url\(\s*(?:"((?:[^"\\\n\r]|\\.)*)"|'((?:[^'\\\n\r]|\\.)*)'|([^)"'(\s{};]*))\s*\)"#,
|
||||
)
|
||||
.expect("CSS_URL_RE")
|
||||
});
|
||||
static URL_SCHEME_RE: Lazy<Regex> =
|
||||
Lazy::new(|| Regex::new(r"^[A-Za-z][A-Za-z0-9+.-]*:").expect("URL_SCHEME_RE"));
|
||||
|
||||
/// A relative `url()` in a stylesheet is relative to the sheet, and the
|
||||
/// cascade sees one concatenated text, so a sheet inlined from another
|
||||
/// directory has its relative urls rewritten to page-relative form here.
|
||||
/// This is what lets the sampled-contrast path (#560) resolve the image the
|
||||
/// winning declaration named. Root-relative, remote, `data:`, fragment, and
|
||||
/// escaped urls are left as they are, and comments are copied through
|
||||
/// untouched, so nothing inside one can reach the rules after it.
|
||||
pub fn rewrite_sheet_urls(css: &str, sheet_dir: &str, page_dir: &str) -> String {
|
||||
let mut out = String::with_capacity(css.len());
|
||||
let mut cursor = 0usize;
|
||||
for (start, end) in comment_spans(css) {
|
||||
out.push_str(&rewrite_code_urls(&css[cursor..start], sheet_dir, page_dir));
|
||||
out.push_str(&css[start..end]);
|
||||
cursor = end;
|
||||
}
|
||||
out.push_str(&rewrite_code_urls(&css[cursor..], sheet_dir, page_dir));
|
||||
out
|
||||
}
|
||||
|
||||
/// The byte spans of every `/* */` comment in `css`, found the way the CSS
|
||||
/// tokenizer finds them: a `/*` inside a quoted string or an unquoted
|
||||
/// `url()` is content, not a comment opener, and an unclosed comment runs to
|
||||
/// the end of the sheet. Every span boundary sits on an ASCII byte, so the
|
||||
/// spans are valid `str` indices.
|
||||
/// A byte that can continue a CSS identifier, so `myurl(` is a custom
|
||||
/// function and not the `url(` token.
|
||||
fn is_ident_byte(b: u8) -> bool {
|
||||
b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b >= 0x80
|
||||
}
|
||||
|
||||
fn comment_spans(css: &str) -> Vec<(usize, usize)> {
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum State {
|
||||
Code,
|
||||
Str(u8),
|
||||
Url,
|
||||
}
|
||||
let bytes = css.as_bytes();
|
||||
let mut spans = Vec::new();
|
||||
let mut state = State::Code;
|
||||
let mut i = 0usize;
|
||||
while i < bytes.len() {
|
||||
let b = bytes[i];
|
||||
match state {
|
||||
State::Code => {
|
||||
if b == b'/' && bytes.get(i + 1) == Some(&b'*') {
|
||||
let end = css[i + 2..]
|
||||
.find("*/")
|
||||
.map(|at| i + 2 + at + 2)
|
||||
.unwrap_or(bytes.len());
|
||||
spans.push((i, end));
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
if b == b'"' || b == b'\'' {
|
||||
state = State::Str(b);
|
||||
} else if b.eq_ignore_ascii_case(&b'u')
|
||||
&& css[i..]
|
||||
.get(..4)
|
||||
.is_some_and(|s| s.eq_ignore_ascii_case("url("))
|
||||
&& !(i > 0 && is_ident_byte(bytes[i - 1]))
|
||||
{
|
||||
i += 4;
|
||||
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
|
||||
i += 1;
|
||||
}
|
||||
// A quoted argument is a string like any other; an
|
||||
// unquoted one runs to the closing paren.
|
||||
if i < bytes.len() && bytes[i] != b'"' && bytes[i] != b'\'' {
|
||||
state = State::Url;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
State::Str(quote) => {
|
||||
if b == b'\\' {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
// A raw newline ends a string in CSS (a bad-string token).
|
||||
if b == quote || b == b'\n' || b == b'\r' {
|
||||
state = State::Code;
|
||||
}
|
||||
}
|
||||
State::Url => {
|
||||
if b == b'\\' {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if b == b')' {
|
||||
state = State::Code;
|
||||
}
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
spans
|
||||
}
|
||||
|
||||
fn rewrite_code_urls(css: &str, sheet_dir: &str, page_dir: &str) -> String {
|
||||
CSS_URL_RE
|
||||
.replace_all(css, |caps: ®ex::Captures| {
|
||||
let whole = caps.get(0).map(|m| m.as_str()).unwrap_or("");
|
||||
let before = caps.get(1).map(|m| m.as_str()).unwrap_or("");
|
||||
let (target, quote) = match (caps.get(2), caps.get(3), caps.get(4)) {
|
||||
(Some(m), _, _) => (m.as_str(), "\""),
|
||||
(_, Some(m), _) => (m.as_str(), "'"),
|
||||
(_, _, Some(m)) => (js::trim(m.as_str()), ""),
|
||||
_ => return whole.to_string(),
|
||||
};
|
||||
let lower = js::to_lower_case(target);
|
||||
if target.is_empty()
|
||||
|| target.contains('\\')
|
||||
|| target.starts_with('#')
|
||||
|| target.starts_with('/')
|
||||
|| lower.starts_with("data:")
|
||||
|| URL_SCHEME_RE.is_match(target)
|
||||
{
|
||||
return whole.to_string();
|
||||
}
|
||||
let cut = target.find(['?', '#']).unwrap_or(target.len());
|
||||
let (path, suffix) = target.split_at(cut);
|
||||
// Pure POSIX string math on both directories: a CSS url is
|
||||
// POSIX on every OS, and the win32 helpers would render a drive
|
||||
// path (`D:\a\...`) differently from the url beside it.
|
||||
let sheet = jsp::to_posix(sheet_dir);
|
||||
let page = jsp::to_posix(page_dir);
|
||||
let absolute = jsp::posix::resolve("/", &[&sheet, path]);
|
||||
let relative = jsp::posix::relative("/", &page, &absolute);
|
||||
if relative.is_empty() {
|
||||
return whole.to_string();
|
||||
}
|
||||
let needs_quotes = quote.is_empty()
|
||||
&& relative
|
||||
.chars()
|
||||
.any(|c| c.is_whitespace() || matches!(c, '(' | ')' | '"' | '\''));
|
||||
let quote = if needs_quotes { "\"" } else { quote };
|
||||
format!("{before}url({quote}{relative}{suffix}{quote})")
|
||||
})
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
static PSEUDO_RULE_RE: Lazy<Regex> = Lazy::new(|| {
|
||||
Regex::new(&format!(
|
||||
r"(?i)^(.+?){ws}*::?(?:before|after)$",
|
||||
@@ -308,6 +473,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 +499,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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -193,6 +193,101 @@ 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. Every `background` shorthand resets both to what it
|
||||
/// names, the defaults when it names nothing, as in CSS; a CSS-wide keyword
|
||||
/// passes through, and a bare `var()` value is left alone the way the
|
||||
/// expansion leaves it.
|
||||
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 VAR_ANYWHERE_RE.is_match(v) && !BG_IMAGE_RE.is_match(v) => Vec::new(),
|
||||
"background" if CSS_WIDE_KEYWORD_RE.is_match(v) => {
|
||||
let keyword = js::to_lower_case(v);
|
||||
vec![
|
||||
("backgroundRepeat".into(), keyword.clone()),
|
||||
("backgroundSize".into(), keyword),
|
||||
]
|
||||
}
|
||||
"background" => {
|
||||
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 CSS_WIDE_KEYWORD_RE: Lazy<Regex> = Lazy::new(|| {
|
||||
Regex::new(r"(?i)^(?:inherit|initial|unset|revert|revert-layer)$").expect("CSS_WIDE_KEYWORD_RE")
|
||||
});
|
||||
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(|| {
|
||||
|
||||
@@ -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);
|
||||
@@ -210,6 +216,7 @@ pub fn detect_html_source(
|
||||
let css_text = 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());
|
||||
|
||||
let mut findings: Vec<Finding> = Vec::new();
|
||||
let mk = |id: &str, snippet: &str| try_finding(id, fp, snippet, 0.0);
|
||||
@@ -222,7 +229,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) {
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
//! The pixels behind image-backed text for the static engine (#560). A
|
||||
//! `url()` resolves to bytes from a local file relative to the page (linked
|
||||
//! stylesheets have their urls rewritten to page-relative form when they are
|
||||
//! inlined, see `rewrite_sheet_urls`) 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. The same byte budget bounds a file on disk and a data
|
||||
//! URI's payload, checked before anything is copied or decoded.
|
||||
|
||||
use crate::cascade::resolve_linked_css_path;
|
||||
use base64::Engine;
|
||||
use impeccable_common::jsp;
|
||||
use impeccable_core::color::Rgba;
|
||||
use std::borrow::Cow;
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::hash::{Hash, Hasher};
|
||||
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;
|
||||
/// The base64 payload length that decodes to [`MAX_FILE_BYTES`], so a data
|
||||
/// URI is refused before its payload is copied or decoded.
|
||||
const MAX_DATA_URI_CHARS: usize = (MAX_FILE_BYTES as usize / 3) * 4 + 4;
|
||||
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, relative to
|
||||
/// the document's directory. A url from a linked stylesheet reaches the
|
||||
/// cascade already rewritten to page-relative form.
|
||||
pub struct ImageSampler {
|
||||
base: String,
|
||||
}
|
||||
|
||||
impl ImageSampler {
|
||||
pub fn new(html_dir: &str) -> Self {
|
||||
ImageSampler {
|
||||
base: html_dir.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
// Refused before anything is allocated: a project file can carry
|
||||
// any size of data URI, and the hook scans on every edit.
|
||||
if url.len() > MAX_DATA_URI_CHARS + 256 {
|
||||
return None;
|
||||
}
|
||||
data_uri_key(url)
|
||||
} 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;
|
||||
}
|
||||
let path = resolve_linked_css_path(&self.base, url);
|
||||
std::fs::metadata(&path)
|
||||
.map(|m| m.is_file())
|
||||
.unwrap_or(false)
|
||||
.then_some(path)
|
||||
}
|
||||
}
|
||||
|
||||
fn is_data_uri(url: &str) -> bool {
|
||||
// `get`, not a byte slice: a url may open with a multibyte character.
|
||||
url.get(..5)
|
||||
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("data:"))
|
||||
}
|
||||
|
||||
/// The cache key of a data URI: its length and a hash, so the cache never
|
||||
/// holds a copy of the URI itself.
|
||||
fn data_uri_key(url: &str) -> String {
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
url.hash(&mut hasher);
|
||||
format!("data:{}:{:016x}", url.len(), hasher.finish())
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
if payload.len() > MAX_DATA_URI_CHARS {
|
||||
return None;
|
||||
}
|
||||
let compact: Cow<str> = if payload.chars().any(char::is_whitespace) {
|
||||
Cow::Owned(payload.chars().filter(|c| !c.is_whitespace()).collect())
|
||||
} else {
|
||||
Cow::Borrowed(payload)
|
||||
};
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(compact.as_ref())
|
||||
.or_else(|_| base64::engine::general_purpose::STANDARD_NO_PAD.decode(compact.as_ref()))
|
||||
.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");
|
||||
// A url that opens with a multibyte character is a local path, not
|
||||
// a panic.
|
||||
assert!(!is_data_uri("dat\u{20ac}"));
|
||||
assert!(sampler.load("abcd\u{20ac}.png").is_none());
|
||||
assert_eq!(ground_label("abcd\u{20ac}.png"), "abcd\u{20ac}.png");
|
||||
// A payload past the byte budget is refused before it is decoded.
|
||||
let huge = format!(
|
||||
"data:image/png;base64,{}",
|
||||
"A".repeat(MAX_DATA_URI_CHARS + 1)
|
||||
);
|
||||
assert!(sampler.load(&huge).is_none());
|
||||
assert_ne!(data_uri_key(&uri), data_uri_key(&huge));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_resolve_against_the_page_dir_and_downscale() {
|
||||
let dir = std::env::temp_dir().join(format!("impeccable-sampler-{}", std::process::id()));
|
||||
std::fs::create_dir_all(dir.join("img")).unwrap();
|
||||
std::fs::write(
|
||||
dir.join("img").join("wide.png"),
|
||||
png_bytes(1280, 320, [20, 20, 20, 255]),
|
||||
)
|
||||
.unwrap();
|
||||
let sampler = ImageSampler::new(&dir.to_string_lossy());
|
||||
assert!(sampler.load("wide.png").is_none());
|
||||
let raster = sampler
|
||||
.load("img/wide.png")
|
||||
.expect("resolves against the page 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("img/wide.png").unwrap()));
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -209,3 +209,209 @@ 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())]
|
||||
);
|
||||
// A color-only shorthand still resets both longhands, as in CSS.
|
||||
assert_eq!(
|
||||
background_longhands("background", "#fff"),
|
||||
vec![
|
||||
("backgroundRepeat".to_string(), "repeat".to_string()),
|
||||
("backgroundSize".to_string(), "auto".to_string()),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
background_longhands("background", "Inherit"),
|
||||
vec![
|
||||
("backgroundRepeat".to_string(), "inherit".to_string()),
|
||||
("backgroundSize".to_string(), "inherit".to_string()),
|
||||
]
|
||||
);
|
||||
assert!(background_longhands("background", "var(--surface)").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 apply = |specified: &mut SpecifiedStore<&str>, prop: &str, value: &str, m: DeclMeta| {
|
||||
apply_static_declaration(specified, node, prop, value, &m);
|
||||
for (p, v) in background_longhands(prop, value) {
|
||||
apply_static_longhand(specified, node, &p, &v, &m);
|
||||
}
|
||||
};
|
||||
apply(
|
||||
&mut specified,
|
||||
"background-repeat",
|
||||
"no-repeat",
|
||||
meta(false, [0, 1, 0], 0, false),
|
||||
);
|
||||
apply(
|
||||
&mut specified,
|
||||
"background",
|
||||
"url(hero.jpg) center / cover",
|
||||
meta(false, [0, 1, 0], 1, false),
|
||||
);
|
||||
apply(
|
||||
&mut specified,
|
||||
"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")
|
||||
);
|
||||
// `background: #fff` after a longhand resets it, so a later
|
||||
// `background-image` is classified with the defaults, not a stale value.
|
||||
apply(
|
||||
&mut specified,
|
||||
"background-repeat",
|
||||
"no-repeat",
|
||||
meta(false, [0, 1, 0], 3, false),
|
||||
);
|
||||
apply(
|
||||
&mut specified,
|
||||
"background",
|
||||
"#fff",
|
||||
meta(false, [0, 1, 0], 4, 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("auto")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linked_sheet_urls_are_rewritten_page_relative() {
|
||||
use impeccable_html::cascade::build::rewrite_sheet_urls;
|
||||
|
||||
let css = concat!(
|
||||
".a { background: url(light.png) }\n",
|
||||
".b { background: url(\"../img/hero.jpg?v=3\") no-repeat }\n",
|
||||
".c { background-image: url('./x.webp'), url(/root.png), url(data:image/png;base64,AAAA) }\n",
|
||||
".d { background: url(https://cdn.example.com/a.png) }\n",
|
||||
".e { mask: url(#clip) }\n",
|
||||
".f { background: URL( a b.png ) }\n",
|
||||
);
|
||||
let out = rewrite_sheet_urls(css, "/site/css", "/site");
|
||||
assert!(
|
||||
out.contains(".a { background: url(css/light.png) }"),
|
||||
"{out}"
|
||||
);
|
||||
assert!(
|
||||
out.contains(".b { background: url(\"img/hero.jpg?v=3\") no-repeat }"),
|
||||
"{out}"
|
||||
);
|
||||
assert!(
|
||||
out.contains("url('css/x.webp'), url(/root.png), url(data:image/png;base64,AAAA)"),
|
||||
"{out}"
|
||||
);
|
||||
assert!(out.contains("url(https://cdn.example.com/a.png)"), "{out}");
|
||||
assert!(out.contains("url(#clip)"), "{out}");
|
||||
// An unquoted url with a space is not a url token in CSS; it stays put.
|
||||
assert!(out.contains("URL( a b.png )"), "{out}");
|
||||
// A sheet beside the page keeps every path where it was (the engine
|
||||
// does not even call the rewrite for that case).
|
||||
let same = rewrite_sheet_urls(css, "/site", "/site");
|
||||
assert!(same.contains(".a { background: url(light.png) }"), "{same}");
|
||||
assert!(same.contains("url(\"../img/hero.jpg?v=3\")"), "{same}");
|
||||
// A sheet above the page walks back up.
|
||||
let up = rewrite_sheet_urls(".a { background: url(light.png) }", "/site", "/site/pages");
|
||||
assert_eq!(up, ".a { background: url(../light.png) }");
|
||||
// A stray `url(` in a comment, or an unclosed quote, never swallows the
|
||||
// rules after it: comments pass through untouched and a url form ends
|
||||
// where CSS says it ends.
|
||||
let hazards = concat!(
|
||||
"/* see url( for details */ .g { color: red }\n",
|
||||
".h { background: url(a.png) }\n",
|
||||
".i { background: url(\"oops }\n",
|
||||
".j { background: url(b.png) }\n",
|
||||
"/* url(unterminated.png",
|
||||
);
|
||||
let out = rewrite_sheet_urls(hazards, "/site/css", "/site");
|
||||
assert!(
|
||||
out.contains("/* see url( for details */ .g { color: red }"),
|
||||
"{out}"
|
||||
);
|
||||
assert!(out.contains(".h { background: url(css/a.png) }"), "{out}");
|
||||
assert!(out.contains(".i { background: url(\"oops }"), "{out}");
|
||||
assert!(out.contains(".j { background: url(css/b.png) }"), "{out}");
|
||||
assert!(out.ends_with("/* url(unterminated.png"), "{out}");
|
||||
// A `/*` inside a string or an unquoted url is content, not a comment,
|
||||
// so the rules after it are still rewritten.
|
||||
let strings = concat!(
|
||||
".k { content: \"a/*b\"; background: url(k.png) }\n",
|
||||
".l { background: url(l/*.png) }\n",
|
||||
".m { content: 'c/*d'; } /* real url( */ .n { background: url(n.png) }\n",
|
||||
);
|
||||
let out = rewrite_sheet_urls(strings, "/site/css", "/site");
|
||||
assert!(
|
||||
out.contains(".k { content: \"a/*b\"; background: url(css/k.png) }"),
|
||||
"{out}"
|
||||
);
|
||||
assert!(out.contains(".l { background: url(css/l/*.png) }"), "{out}");
|
||||
assert!(
|
||||
out.contains("/* real url( */ .n { background: url(css/n.png) }"),
|
||||
"{out}"
|
||||
);
|
||||
// `myurl(` is a custom function, not the url token: its comment stays a
|
||||
// comment and its argument is not rewritten.
|
||||
let custom = ".q { mask: myurl(a /* url(x.png) */); background: url(q.png) }";
|
||||
let out = rewrite_sheet_urls(custom, "/site/css", "/site");
|
||||
assert_eq!(
|
||||
out,
|
||||
".q { mask: myurl(a /* url(x.png) */); background: url(css/q.png) }"
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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 (a linked stylesheet from another directory has its relative urls rewritten to page-relative form when it is inlined, so a url resolves against the sheet that declared it; 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, a data URI whose payload decodes to more than that, 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`)
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
<!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; }
|
||||
.tokens-first { background: center / cover no-repeat url(sampled-images/light.png); }
|
||||
.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>
|
||||
<div class="panel tokens-first">
|
||||
<p class="white">Shorthand tokens before the image still name the ground.</p>
|
||||
</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 |
@@ -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 |
@@ -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 nine `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": "424 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 \"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",
|
||||
"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 [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\n9 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
Reference in New Issue
Block a user