pour-engine 1.10.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,6 +6,6 @@
6
6
  export default {
7
7
  engine: {
8
8
  name: 'pour engine',
9
- version: '1.10.0',
9
+ version: '1.11.0',
10
10
  },
11
11
  };
@@ -650,7 +650,24 @@ export function paintedBackdrop(element) {
650
650
  if ((style.mixBlendMode && style.mixBlendMode !== 'normal')
651
651
  || (style.filter && style.filter !== 'none')
652
652
  || (style.backdropFilter && style.backdropFilter !== 'none')) return 'unresolved';
653
- if (style.backgroundImage !== 'none') return { image: layer, scrim };
653
+ // An ancestor carrying a background-image is only this text's backdrop if
654
+ // that image actually paints where the text is. The element's own image is
655
+ // adjudicated upstream against its paint rect; ancestors were not, so a
656
+ // decorative 4px accent stripe down a callout's left edge counted as the
657
+ // backdrop for text sitting 44px to its right, and the stripe's colour
658
+ // then failed the whole paragraph. Adjudicate at the same point the hit
659
+ // test used: if the rect resolves and does not cover it, this image is not
660
+ // what is behind the glyphs, so fall through to the layer's own colour.
661
+ // An unresolvable rect keeps the old behaviour, which errs toward asking a
662
+ // human rather than asserting from geometry we could not compute.
663
+ if (style.backgroundImage !== 'none') {
664
+ const rects = backgroundImagePaintRects(layer);
665
+ // An unresolvable layer (null) counts as covering: not knowing where a
666
+ // layer paints is a reason to ask a human, never to rule it out.
667
+ const covers = (r) => !r
668
+ || (point.x >= r.left && point.x < r.right && point.y >= r.top && point.y < r.bottom);
669
+ if (!rects.length || rects.some(covers)) return { image: layer, scrim };
670
+ }
654
671
  let color = parseColor(style.backgroundColor);
655
672
  if (!color) return 'unresolved';
656
673
  // A layer's own opacity thins its paint. (Group opacity shared with the
@@ -918,6 +935,100 @@ export function imageLuminanceRange(url, overlays = []) {
918
935
  * clear text that never overlaps the image — e.g. external-link icons
919
936
  * painted in a link's right padding (Wikipedia-style).
920
937
  */
938
+ /**
939
+ * Paint rect for EVERY layer of a background-image list, one entry per layer,
940
+ * null where a layer's geometry cannot be resolved.
941
+ *
942
+ * background-size/position/repeat are comma-separated lists that pair up with
943
+ * the image list, so a four-layer background can be four separate 1px lines —
944
+ * which is how design systems draw a tile's border without a border. Judging
945
+ * such a background as one blob meant text in the middle of a tile was ruled
946
+ * unjudgeable because of four hairlines around its edge.
947
+ *
948
+ * A layer resolves to null (unknown, treat as covering) when it tiles, when
949
+ * cover/contain hands sizing to an intrinsic this function does not have, or
950
+ * when its position is not a plain length or percentage.
951
+ */
952
+ export function backgroundImagePaintRects(element) {
953
+ const style = getComputedStyle(element);
954
+ const images = splitLayers(style.backgroundImage);
955
+ if (!images.length) return [];
956
+ const box = element.getBoundingClientRect();
957
+ const sizes = splitLayers(style.backgroundSize);
958
+ const positions = splitLayers(style.backgroundPosition);
959
+ const repeats = splitLayers(style.backgroundRepeat);
960
+ const per = (list, i) => (list.length ? list[i % list.length] : undefined);
961
+
962
+ return images.map((image, i) => {
963
+ if (image === 'none') return { left: 0, top: 0, right: 0, bottom: 0 }; // paints nothing
964
+ const repeat = per(repeats, i) ?? 'repeat';
965
+ if (repeat !== 'no-repeat') return null; // may tile under the text
966
+ const size = per(sizes, i) ?? 'auto';
967
+ if (size === 'cover' || size === 'contain') return null;
968
+ const parts = splitParts(size);
969
+ const extent = (value, total) => {
970
+ if (value?.endsWith('px')) return parseFloat(value);
971
+ if (value?.endsWith('%')) return (parseFloat(value) / 100) * total;
972
+ return null; // 'auto' on a gradient means the full box, but only for a
973
+ // single-layer background; per-layer we decline rather than guess.
974
+ };
975
+ const width = extent(parts[0], box.width);
976
+ const height = extent(parts[1] ?? parts[0], box.height);
977
+ if (width === null || height === null
978
+ || Number.isNaN(width) || Number.isNaN(height)) return null;
979
+ const pos = splitParts(per(positions, i) ?? '0% 0%');
980
+ const offset = (value, total, span) => {
981
+ if (value?.endsWith('%')) return (parseFloat(value) / 100) * (total - span);
982
+ if (value?.endsWith('px')) return parseFloat(value);
983
+ return null;
984
+ };
985
+ const offsetX = offset(pos[0], box.width, width);
986
+ const offsetY = offset(pos[1] ?? '50%', box.height, height);
987
+ // NaN, not just null: `calc(100% - 1px)` is a real position a design
988
+ // system writes, and a NaN that escapes as a rect is worse than an
989
+ // unresolved one — it compares false against everything, so a genuine
990
+ // image backdrop would be silently ruled clear of the text.
991
+ if (offsetX === null || offsetY === null
992
+ || Number.isNaN(offsetX) || Number.isNaN(offsetY)) return null;
993
+ const left = box.left + offsetX;
994
+ const top = box.top + offsetY;
995
+ return { left, top, right: left + width, bottom: top + height };
996
+ });
997
+ }
998
+
999
+ /** Split a space-separated CSS value, keeping function calls whole. Splitting
1000
+ * `calc(100% - 1px)` on whitespace yields `calc(100%`, which parseFloat reads
1001
+ * as 100 and every guard downstream then trusts. */
1002
+ function splitParts(value) {
1003
+ const out = [];
1004
+ let depth = 0;
1005
+ let current = '';
1006
+ for (const ch of value) {
1007
+ if (ch === '(') depth += 1;
1008
+ if (ch === ')') depth -= 1;
1009
+ if (/\s/.test(ch) && depth === 0) { if (current) out.push(current); current = ''; continue; }
1010
+ current += ch;
1011
+ }
1012
+ if (current) out.push(current);
1013
+ return out;
1014
+ }
1015
+
1016
+ /** Split a comma-separated CSS list, ignoring commas inside parentheses. */
1017
+ function splitLayers(value) {
1018
+ if (!value || value === 'none') return value === 'none' ? ['none'] : [];
1019
+ const out = [];
1020
+ let depth = 0;
1021
+ let current = '';
1022
+ for (const ch of value) {
1023
+ if (ch === '(') depth += 1;
1024
+ if (ch === ')') depth -= 1;
1025
+ if (ch === ',' && depth === 0) { out.push(current.trim()); current = ''; continue; }
1026
+ current += ch;
1027
+ }
1028
+ if (current.trim()) out.push(current.trim());
1029
+ return out;
1030
+ }
1031
+
921
1032
  export function backgroundImagePaintRect(element, intrinsic) {
922
1033
  const style = getComputedStyle(element);
923
1034
  if ((style.backgroundImage.match(/url\(|gradient\(/g) ?? []).length !== 1) return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pour-engine",
3
- "version": "1.10.0",
3
+ "version": "1.11.0",
4
4
  "description": "Accessibility (WCAG 2.2) audit engine written from the W3C specifications — runs entirely in the browser, no dependencies.",
5
5
  "type": "module",
6
6
  "main": "engine/index.js",