diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index 0d71599f7b..4e3feb4750 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -730,13 +730,117 @@ const RASTER_TAGS = new Set(["IMG", "VIDEO", "CANVAS"]); const FRAME_MEDIA_TAGS = new Set([...RASTER_TAGS, "SVG"]); + const imageAlphaCanvases = new WeakMap(); + + function objectPositionOffset(value, freeSpace) { + const token = String(value || "50%") + .trim() + .split(/\s+/)[0]; + if (token === "left" || token === "top") return 0; + if (token === "right" || token === "bottom") return freeSpace; + if (token === "center") return freeSpace / 2; + if (token.endsWith("%")) return (freeSpace * parseFloat(token)) / 100; + const pixels = parseFloat(token); + return Number.isFinite(pixels) ? pixels : freeSpace / 2; + } + + function objectPositionOffsets(value, freeX, freeY) { + const tokens = String(value || "50% 50%") + .trim() + .split(/\s+/) + .slice(0, 2); + let x = "50%"; + let y = "50%"; + if (tokens.length === 1) { + if (tokens[0] === "top" || tokens[0] === "bottom") y = tokens[0]; + else x = tokens[0]; + } else { + for (const token of tokens) { + if (token === "top" || token === "bottom") y = token; + else if (token === "left" || token === "right") x = token; + else if (x === "50%") x = token; + else y = token; + } + } + return { x: objectPositionOffset(x, freeX), y: objectPositionOffset(y, freeY) }; + } + + // Return the alpha painted by an at a viewport point. `null` means the + // browser would not let us inspect the image (not loaded or cross-origin), in + // which case callers preserve the conservative opaque fallback. + function imageAlphaAt(element, x, y) { + const sourceWidth = element.naturalWidth; + const sourceHeight = element.naturalHeight; + const rect = element.getBoundingClientRect(); + if (!sourceWidth || !sourceHeight || !rect.width || !rect.height) return null; + + const style = getComputedStyle(element); + const fit = style.objectFit || "fill"; + let scaleX = rect.width / sourceWidth; + let scaleY = rect.height / sourceHeight; + if (fit !== "fill") { + const contain = Math.min(scaleX, scaleY); + const cover = Math.max(scaleX, scaleY); + const scale = + fit === "cover" + ? cover + : fit === "none" + ? 1 + : fit === "scale-down" + ? Math.min(1, contain) + : contain; + scaleX = scale; + scaleY = scale; + } + + const paintedWidth = sourceWidth * scaleX; + const paintedHeight = sourceHeight * scaleY; + const offsets = objectPositionOffsets( + style.objectPosition, + rect.width - paintedWidth, + rect.height - paintedHeight, + ); + const localX = x - rect.left - offsets.x; + const localY = y - rect.top - offsets.y; + if (localX < 0 || localY < 0 || localX >= paintedWidth || localY >= paintedHeight) return 0; + + try { + let cached = imageAlphaCanvases.get(element); + const source = element.currentSrc || element.src; + if ( + !cached || + cached.width !== sourceWidth || + cached.height !== sourceHeight || + cached.source !== source + ) { + const canvas = document.createElement("canvas"); + canvas.width = sourceWidth; + canvas.height = sourceHeight; + const context = canvas.getContext("2d", { willReadFrequently: true }); + if (!context) return null; + context.drawImage(element, 0, 0, sourceWidth, sourceHeight); + cached = { context, width: sourceWidth, height: sourceHeight, source }; + imageAlphaCanvases.set(element, cached); + } + const sourceX = Math.min(sourceWidth - 1, Math.max(0, Math.floor(localX / scaleX))); + const sourceY = Math.min(sourceHeight - 1, Math.max(0, Math.floor(localY / scaleY))); + return cached.context.getImageData(sourceX, sourceY, 1, 1).data[3] / 255; + } catch { + return null; + } + } // An element hides text beneath it when it paints opaque pixels at near-full // opacity: raster content (img/video/canvas), a background image, or a solid // background colour. Low-opacity overlays (grain, scrims) do not occlude. - function isOpaqueOccluder(element) { - if (opacityChain(element) < 0.6) return false; + function isOpaqueOccluder(element, x, y) { + const opacity = opacityChain(element); + if (opacity < 0.6) return false; if (IGNORE_TAGS.has(element.tagName)) return false; + if (element.tagName === "IMG") { + const alpha = imageAlphaAt(element, x, y); + if (alpha !== null) return alpha * opacity >= 0.6; + } if (RASTER_TAGS.has(element.tagName)) return true; return hasOpaqueBackground(getComputedStyle(element)); } @@ -798,7 +902,7 @@ // Pair-specific exemptions excuse this hit only; keep walking for deeper occluders. if (sharedPreserve3d(element, hit)) continue; if (isCrossSceneTransitionOverlap(element, hit)) continue; - if (isOpaqueOccluder(hit)) return hit; + if (isOpaqueOccluder(hit, x, y)) return hit; } return null; } diff --git a/packages/cli/src/commands/layout-audit.browser.test.ts b/packages/cli/src/commands/layout-audit.browser.test.ts index 891a228ddf..1da9202d1d 100644 --- a/packages/cli/src/commands/layout-audit.browser.test.ts +++ b/packages/cli/src/commands/layout-audit.browser.test.ts @@ -924,6 +924,24 @@ describe("layout-audit.browser occlusion", () => { expect(issues.some((issue) => issue.code === "text_occluded")).toBe(false); }); + it("does not treat transparent pixels in an image as text occlusion", () => { + const issues = auditImageOcclusionScene(0); + expect(issues.some((issue) => issue.code === "text_occluded")).toBe(false); + }); + + it("still treats opaque pixels in an image as text occlusion", () => { + const occluded = auditImageOcclusionScene(255).find((issue) => issue.code === "text_occluded"); + expect(occluded).toMatchObject({ selector: "#headline", containerSelector: "#overlay" }); + }); + + it("does not treat object-fit letterboxing as image occlusion", () => { + const issues = auditImageOcclusionScene(255, { + objectFit: "contain", + headlineTextRect: rect({ left: 50, top: 500, width: 200, height: 80 }), + }); + expect(issues.some((issue) => issue.code === "text_occluded")).toBe(false); + }); + it("respects the data-layout-allow-occlusion opt-out", () => { const issues = auditOcclusionScene({ headlineAttrs: "data-layout-allow-occlusion", @@ -1194,6 +1212,39 @@ function auditOcclusionScene(options: { return runAudit(); } +function auditImageOcclusionScene( + alpha: number, + options: { objectFit?: string; headlineTextRect?: DOMRect } = {}, +): ReturnType { + document.body.innerHTML = ` +
+
Headline copy
+ +
+ `; + const overlay = document.getElementById("overlay") as HTMLImageElement; + Object.defineProperties(overlay, { + naturalWidth: { configurable: true, value: 100 }, + naturalHeight: { configurable: true, value: 100 }, + }); + const getContextSpy = vi.spyOn(HTMLCanvasElement.prototype, "getContext") as unknown as { + mockReturnValue(value: CanvasRenderingContext2D): void; + }; + getContextSpy.mockReturnValue({ + drawImage: vi.fn(), + getImageData: vi.fn(() => ({ data: new Uint8ClampedArray([0, 0, 0, alpha]) })), + } as unknown as CanvasRenderingContext2D); + installOcclusionGeometry({ + styleOverrides: { overlay: { objectFit: options.objectFit ?? "fill" } }, + headlineTextRect: + options.headlineTextRect ?? rect({ left: 200, top: 500, width: 600, height: 80 }), + topmostId: "overlay", + }); + overlay.getBoundingClientRect = () => rect({ left: 0, top: 0, width: 1920, height: 1080 }); + installAuditScript(); + return runAudit(); +} + function installOcclusionGeometry(options: { styleOverrides: Record>>; headlineTextRect: DOMRect;