mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(cli): avoid multiline overlap false positives (#2468)
* fix(cli): avoid multiline overlap false positives * fix(cli): sample multiline occlusion fragments
This commit is contained in:
@@ -580,8 +580,9 @@
|
||||
const blocks = [];
|
||||
for (const element of Array.from(root.querySelectorAll("*"))) {
|
||||
if (!isSolidTextBlock(element)) continue;
|
||||
const rects = textClientRects(element, true);
|
||||
const rect = textRectFor(element, true);
|
||||
if (rect) blocks.push({ element, rect });
|
||||
if (rect) blocks.push({ element, rect, rects });
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
@@ -596,6 +597,18 @@
|
||||
return overlapX > 0 && overlapY > 0 ? overlapX * overlapY : 0;
|
||||
}
|
||||
|
||||
function rectsArea(rects) {
|
||||
return rects.reduce((total, rect) => total + rectArea(rect), 0);
|
||||
}
|
||||
|
||||
function fragmentIntersectionArea(a, b) {
|
||||
let total = 0;
|
||||
for (const aRect of a) {
|
||||
for (const bRect of b) total += intersectionArea(aRect, bRect);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
function isNested(a, b) {
|
||||
return a.contains(b) || b.contains(a);
|
||||
}
|
||||
@@ -631,8 +644,8 @@
|
||||
function overlapIssue(a, b, time) {
|
||||
if (isNested(a.element, b.element)) return null;
|
||||
if (isManagedFlowOverlap(a.element, b.element)) return null;
|
||||
const area = intersectionArea(a.rect, b.rect);
|
||||
if (area <= Math.min(rectArea(a.rect), rectArea(b.rect)) * 0.2) return null;
|
||||
const area = fragmentIntersectionArea(a.rects, b.rects);
|
||||
if (area <= Math.min(rectsArea(a.rects), rectsArea(b.rects)) * 0.2) return null;
|
||||
return {
|
||||
// Warning at the per-sample level: a single-sample overlap is usually an
|
||||
// entrance/exit transient (two blocks crossing mid-animation), not a real
|
||||
@@ -928,25 +941,32 @@
|
||||
return text.length > 0 && text.length <= ATOMIC_LABEL_MAX_CHARS && !/\s/.test(text);
|
||||
}
|
||||
|
||||
// Sweep a grid across the text box (three rows, not just the mid-line, so
|
||||
// overlays covering only part of a multi-line block are caught). Unlike a
|
||||
// Sweep a grid across each painted text fragment (three rows, not just the
|
||||
// mid-line, so overlays covering only part of a multi-line block are caught).
|
||||
// Sampling fragments instead of their union avoids probing empty line gaps.
|
||||
// Unlike a
|
||||
// first-hit scan, this keeps sampling every point so it can report what
|
||||
// fraction of the box is actually covered — a corner nibble on a paragraph
|
||||
// reads very differently from a label buried under an overlay. Still
|
||||
// returns the first opaque element found, for `containerSelector`.
|
||||
function occlusionCoverage(element, textRect) {
|
||||
function occlusionCoverage(element, textRects) {
|
||||
let occluder = null;
|
||||
let hits = 0;
|
||||
for (const yFraction of OCCLUSION_PROBE_Y_FRACTIONS) {
|
||||
const y = textRect.top + textRect.height * yFraction;
|
||||
for (const xFraction of OCCLUSION_PROBE_X_FRACTIONS) {
|
||||
const hit = occluderAt(element, textRect.left + textRect.width * xFraction, y);
|
||||
if (!hit) continue;
|
||||
hits += 1;
|
||||
if (!occluder) occluder = hit;
|
||||
for (const textRect of textRects) {
|
||||
for (const yFraction of OCCLUSION_PROBE_Y_FRACTIONS) {
|
||||
const y = textRect.top + textRect.height * yFraction;
|
||||
for (const xFraction of OCCLUSION_PROBE_X_FRACTIONS) {
|
||||
const hit = occluderAt(element, textRect.left + textRect.width * xFraction, y);
|
||||
if (!hit) continue;
|
||||
hits += 1;
|
||||
if (!occluder) occluder = hit;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { occluder, coveredFraction: round(hits / OCCLUSION_GRID_POINTS) };
|
||||
return {
|
||||
occluder,
|
||||
coveredFraction: round(hits / (OCCLUSION_GRID_POINTS * textRects.length)),
|
||||
};
|
||||
}
|
||||
|
||||
// pointer-events:none hides elements from elementFromPoint — both probed text AND occluders.
|
||||
@@ -985,8 +1005,12 @@
|
||||
if (!hasVisibleTextInk(element)) return null;
|
||||
const textRect = textRectFor(element, true);
|
||||
if (!textRect) return null;
|
||||
const textRects = textClientRects(element, true);
|
||||
const text = textContentFor(element, true);
|
||||
const { occluder, coveredFraction } = occlusionCoverage(element, textRect);
|
||||
const { occluder, coveredFraction } = occlusionCoverage(
|
||||
element,
|
||||
textRects.length > 0 ? textRects : [textRect],
|
||||
);
|
||||
if (!occluder) return null;
|
||||
if (!isAtomicLabel(text) && coveredFraction < PROSE_COVERAGE_FLOOR) return null;
|
||||
return {
|
||||
|
||||
@@ -861,6 +861,20 @@ describe("layout-audit.browser content overlap", () => {
|
||||
expect(issues.some((issue) => issue.code === "content_overlap")).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores another block placed only in a multiline text block's empty line gap", () => {
|
||||
const issues = auditOverlapScene({
|
||||
a: {
|
||||
textRect: [
|
||||
rect({ left: 100, top: 100, width: 400, height: 60 }),
|
||||
rect({ left: 100, top: 260, width: 400, height: 60 }),
|
||||
],
|
||||
},
|
||||
b: { textRect: rect({ left: 180, top: 180, width: 240, height: 50 }) },
|
||||
});
|
||||
|
||||
expect(issues.some((issue) => issue.code === "content_overlap")).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores watermark-style text with low colour alpha", () => {
|
||||
expectExemptFromOverlap({ color: "rgba(0, 0, 0, 0.2)" });
|
||||
});
|
||||
@@ -1283,8 +1297,8 @@ function expectExemptFromOverlap(aOverrides: { color?: string; attrs?: string })
|
||||
}
|
||||
|
||||
function auditOverlapScene(options: {
|
||||
a: { textRect: DOMRect; color?: string; attrs?: string; clipPath?: string };
|
||||
b: { textRect: DOMRect; color?: string; attrs?: string; clipPath?: string };
|
||||
a: { textRect: DOMRect | DOMRect[]; color?: string; attrs?: string; clipPath?: string };
|
||||
b: { textRect: DOMRect | DOMRect[]; color?: string; attrs?: string; clipPath?: string };
|
||||
}): ReturnType<typeof runAudit> {
|
||||
document.body.innerHTML = `
|
||||
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
|
||||
@@ -1300,7 +1314,10 @@ function auditOverlapScene(options: {
|
||||
a: options.a.clipPath ?? "none",
|
||||
b: options.b.clipPath ?? "none",
|
||||
};
|
||||
const textRects: Record<string, DOMRect> = { a: options.a.textRect, b: options.b.textRect };
|
||||
const textRects: Record<string, DOMRect[]> = {
|
||||
a: normalizeTextRects(options.a.textRect),
|
||||
b: normalizeTextRects(options.b.textRect),
|
||||
};
|
||||
|
||||
vi.spyOn(window, "getComputedStyle").mockImplementation((element) => {
|
||||
const id = (element as Element).id;
|
||||
@@ -1323,7 +1340,8 @@ function auditOverlapScene(options: {
|
||||
|
||||
for (const element of Array.from(document.querySelectorAll("*"))) {
|
||||
vi.spyOn(element, "getBoundingClientRect").mockReturnValue(
|
||||
textRects[element.id] ?? rect({ left: 0, top: 0, width: 1920, height: 1080 }),
|
||||
boundingTextRect(textRects[element.id]) ??
|
||||
rect({ left: 0, top: 0, width: 1920, height: 1080 }),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1339,9 +1357,7 @@ function auditOverlapScene(options: {
|
||||
? selected.parentElement
|
||||
: (selected as Element | null);
|
||||
const id = element?.id ?? "";
|
||||
return textRects[id]
|
||||
? ([textRects[id]] as unknown as DOMRectList)
|
||||
: ([] as unknown as DOMRectList);
|
||||
return (textRects[id] ?? []) as unknown as DOMRectList;
|
||||
},
|
||||
detach() {},
|
||||
} as unknown as Range;
|
||||
@@ -1351,6 +1367,19 @@ function auditOverlapScene(options: {
|
||||
return runAudit();
|
||||
}
|
||||
|
||||
function normalizeTextRects(value: DOMRect | DOMRect[]): DOMRect[] {
|
||||
return Array.isArray(value) ? value : [value];
|
||||
}
|
||||
|
||||
function boundingTextRect(rects: DOMRect[] | undefined): DOMRect | undefined {
|
||||
if (!rects?.length) return undefined;
|
||||
const left = Math.min(...rects.map((item) => item.left));
|
||||
const top = Math.min(...rects.map((item) => item.top));
|
||||
const right = Math.max(...rects.map((item) => item.right));
|
||||
const bottom = Math.max(...rects.map((item) => item.bottom));
|
||||
return rect({ left, top, width: right - left, height: bottom - top });
|
||||
}
|
||||
|
||||
function isFullyClipped(clipPath: string): boolean {
|
||||
return /inset\([^)]*100%|circle\(0px/i.test(clipPath);
|
||||
}
|
||||
@@ -1475,6 +1504,31 @@ describe("layout-audit.browser occlusion", () => {
|
||||
expect(issues.some((issue) => issue.code === "text_occluded")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not sample opaque content in the gap between multiline text fragments", () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
|
||||
<div id="headline">First line of prose<br />Second line of prose</div>
|
||||
<div id="overlay"></div>
|
||||
</div>
|
||||
`;
|
||||
const lineRects = [
|
||||
rect({ left: 200, top: 500, width: 600, height: 40 }),
|
||||
rect({ left: 200, top: 580, width: 600, height: 40 }),
|
||||
];
|
||||
installOcclusionGeometry({
|
||||
styleOverrides: { overlay: { backgroundColor: "rgb(10, 10, 10)" } },
|
||||
headlineTextRect: lineRects,
|
||||
topmostId: "headline",
|
||||
});
|
||||
(
|
||||
document as unknown as { elementFromPoint: (x: number, y: number) => Element | null }
|
||||
).elementFromPoint = (_x, y) =>
|
||||
document.getElementById(y > 540 && y < 580 ? "overlay" : "headline");
|
||||
|
||||
installAuditScript();
|
||||
expect(runAudit().some((issue) => issue.code === "text_occluded")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not flag visible text carrying pointer-events:none (probe restores hit-testing)", () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
|
||||
@@ -1778,7 +1832,7 @@ function auditImageOcclusionScene(
|
||||
|
||||
function installOcclusionGeometry(options: {
|
||||
styleOverrides: Record<string, Partial<Record<string, string>>>;
|
||||
headlineTextRect: DOMRect;
|
||||
headlineTextRect: DOMRect | DOMRect[];
|
||||
topmostId: string;
|
||||
textRectElementId?: string;
|
||||
}): void {
|
||||
@@ -1832,7 +1886,7 @@ function installOcclusionGeometry(options: {
|
||||
? (selected.parentElement as Element | null)
|
||||
: (selected as Element | null);
|
||||
return selectedElement?.id === (options.textRectElementId ?? "headline")
|
||||
? ([options.headlineTextRect] as unknown as DOMRectList)
|
||||
? (normalizeTextRects(options.headlineTextRect) as unknown as DOMRectList)
|
||||
: ([] as unknown as DOMRectList);
|
||||
},
|
||||
detach() {},
|
||||
|
||||
Reference in New Issue
Block a user