Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 107 additions & 3 deletions packages/cli/src/commands/layout-audit.browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <img> 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));
}
Expand Down Expand Up @@ -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;
}
Expand Down
51 changes: 51 additions & 0 deletions packages/cli/src/commands/layout-audit.browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -1194,6 +1212,39 @@ function auditOcclusionScene(options: {
return runAudit();
}

function auditImageOcclusionScene(
alpha: number,
options: { objectFit?: string; headlineTextRect?: DOMRect } = {},
): ReturnType<typeof runAudit> {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
<div id="headline">Headline copy</div>
<img id="overlay" src="paper.png" alt="" />
</div>
`;
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<string, Partial<Record<string, string>>>;
headlineTextRect: DOMRect;
Expand Down
Loading