mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(cli): flag overlapping text blocks in inspect (#1436)
The layout audit compares each element against its container, so two text blocks that collide with each other — neither overflowing its own box — render unreadable yet pass clean. Add a content_overlap check that pairs up the solid text blocks and reports any two whose boxes intersect by more than a fifth of the smaller box. Watermark-style text (low colour alpha) is decorative and exempt; opt out of intentional stacking with data-layout-allow-overlap. Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
This commit is contained in:
co-authored by
Miguel Ángel
parent
2002aa2c07
commit
abaf67176c
@@ -398,6 +398,91 @@
|
||||
return issues;
|
||||
}
|
||||
|
||||
function hasAllowOverlapFlag(element) {
|
||||
return !!element.closest("[data-layout-allow-overlap]");
|
||||
}
|
||||
|
||||
function alphaFromParts(parts, index) {
|
||||
return parts.length > index ? parsePx(parts[index]) : 1;
|
||||
}
|
||||
|
||||
// Alpha of a CSS colour; 1 when no alpha component is present. Handles both
|
||||
// legacy `rgba(r, g, b, a)` and modern `rgb(r g b / a)` syntaxes.
|
||||
function colorAlpha(color) {
|
||||
const match = (color || "").match(/rgba?\(([^)]+)\)/);
|
||||
if (!match) return 1;
|
||||
const body = match[1];
|
||||
return body.includes(",")
|
||||
? alphaFromParts(body.split(","), 3)
|
||||
: alphaFromParts(body.split("/"), 1);
|
||||
}
|
||||
|
||||
// A text block competes for space only when it is solid: watermark-style text
|
||||
// (low colour alpha) is decorative and exempt, as are elements opted out with
|
||||
// data-layout-allow-overlap.
|
||||
function isSolidTextBlock(element) {
|
||||
if (!isVisibleElement(element) || !hasOwnTextCandidate(element)) return false;
|
||||
if (hasAllowOverlapFlag(element)) return false;
|
||||
return colorAlpha(getComputedStyle(element).color) >= 0.35;
|
||||
}
|
||||
|
||||
function collectSolidTextBlocks(root) {
|
||||
const blocks = [];
|
||||
for (const element of Array.from(root.querySelectorAll("*"))) {
|
||||
if (!isSolidTextBlock(element)) continue;
|
||||
const rect = textRectFor(element);
|
||||
if (rect) blocks.push({ element, rect });
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function rectArea(rect) {
|
||||
return rect.width * rect.height;
|
||||
}
|
||||
|
||||
function intersectionArea(a, b) {
|
||||
const overlapX = Math.min(a.right, b.right) - Math.max(a.left, b.left);
|
||||
const overlapY = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
|
||||
return overlapX > 0 && overlapY > 0 ? overlapX * overlapY : 0;
|
||||
}
|
||||
|
||||
function isNested(a, b) {
|
||||
return a.contains(b) || b.contains(a);
|
||||
}
|
||||
|
||||
// Two solid text blocks whose boxes overlap by more than a fifth of the
|
||||
// smaller block read as a collision — unreadable, and invisible to the
|
||||
// overflow checks, which only compare an element against its container.
|
||||
function overlapIssue(a, b, time) {
|
||||
if (isNested(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;
|
||||
return {
|
||||
code: "content_overlap",
|
||||
severity: "error",
|
||||
time,
|
||||
selector: selectorFor(a.element),
|
||||
containerSelector: selectorFor(b.element),
|
||||
text: textContentFor(a.element),
|
||||
message: "Two text blocks overlap and render unreadable.",
|
||||
rect: a.rect,
|
||||
fixHint:
|
||||
"Give each block its own zone, or mark intentional layering with data-layout-allow-overlap.",
|
||||
};
|
||||
}
|
||||
|
||||
function contentOverlapIssues(root, time) {
|
||||
const blocks = collectSolidTextBlocks(root);
|
||||
const issues = [];
|
||||
for (let i = 0; i < blocks.length; i++) {
|
||||
for (let j = i + 1; j < blocks.length; j++) {
|
||||
const issue = overlapIssue(blocks[i], blocks[j], time);
|
||||
if (issue) issues.push(issue);
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
window.__hyperframesLayoutAudit = function auditLayout(options) {
|
||||
const time = options && typeof options.time === "number" ? options.time : 0;
|
||||
const tolerance =
|
||||
@@ -418,6 +503,7 @@
|
||||
}
|
||||
|
||||
issues.push(...containerOverflowIssues(root, time, tolerance));
|
||||
issues.push(...contentOverlapIssues(root, time));
|
||||
return issues;
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -100,6 +100,100 @@ describe("layout-audit.browser", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("layout-audit.browser content overlap", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
document.body.innerHTML = "";
|
||||
delete (window as unknown as { __hyperframesLayoutAudit?: unknown }).__hyperframesLayoutAudit;
|
||||
});
|
||||
|
||||
it("flags two solid text blocks that overlap", () => {
|
||||
const overlap = auditOverlapScene({
|
||||
a: { textRect: rect({ left: 100, top: 100, width: 400, height: 100 }) },
|
||||
b: { textRect: rect({ left: 300, top: 120, width: 400, height: 100 }) },
|
||||
}).find((issue) => issue.code === "content_overlap");
|
||||
expect(overlap).toMatchObject({ selector: "#a", containerSelector: "#b" });
|
||||
});
|
||||
|
||||
it("ignores blocks that overlap by less than a fifth of the smaller box", () => {
|
||||
const issues = auditOverlapScene({
|
||||
a: { textRect: rect({ left: 100, top: 100, width: 400, height: 100 }) },
|
||||
b: { textRect: rect({ left: 490, top: 100, width: 400, height: 100 }) },
|
||||
});
|
||||
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)" });
|
||||
});
|
||||
|
||||
it("respects the data-layout-allow-overlap opt-out", () => {
|
||||
expectExemptFromOverlap({ attrs: "data-layout-allow-overlap" });
|
||||
});
|
||||
});
|
||||
|
||||
// Both blocks overlap heavily; only the exemption on block A should suppress
|
||||
// the finding, so a missing exemption would surface as a failure here.
|
||||
function expectExemptFromOverlap(aOverrides: { color?: string; attrs?: string }): void {
|
||||
const issues = auditOverlapScene({
|
||||
a: { textRect: rect({ left: 100, top: 100, width: 400, height: 100 }), ...aOverrides },
|
||||
b: { textRect: rect({ left: 300, top: 120, width: 400, height: 100 }) },
|
||||
});
|
||||
expect(issues.some((issue) => issue.code === "content_overlap")).toBe(false);
|
||||
}
|
||||
|
||||
function auditOverlapScene(options: {
|
||||
a: { textRect: DOMRect; color?: string; attrs?: string };
|
||||
b: { textRect: DOMRect; color?: string; attrs?: string };
|
||||
}): ReturnType<typeof runAudit> {
|
||||
document.body.innerHTML = `
|
||||
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
|
||||
<div id="a" ${options.a.attrs ?? ""}>Block A copy</div>
|
||||
<div id="b" ${options.b.attrs ?? ""}>Block B copy</div>
|
||||
</div>
|
||||
`;
|
||||
const colors: Record<string, string> = {
|
||||
a: options.a.color ?? "rgb(0, 0, 0)",
|
||||
b: options.b.color ?? "rgb(0, 0, 0)",
|
||||
};
|
||||
const textRects: Record<string, DOMRect> = { a: options.a.textRect, b: options.b.textRect };
|
||||
|
||||
vi.spyOn(window, "getComputedStyle").mockImplementation((element) => {
|
||||
const id = (element as Element).id;
|
||||
return {
|
||||
display: "block",
|
||||
visibility: "visible",
|
||||
opacity: "1",
|
||||
color: colors[id] ?? "rgb(0, 0, 0)",
|
||||
} as unknown as CSSStyleDeclaration;
|
||||
});
|
||||
|
||||
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 }),
|
||||
);
|
||||
}
|
||||
|
||||
vi.spyOn(document, "createRange").mockImplementation(() => {
|
||||
let selected: Node | null = null;
|
||||
return {
|
||||
selectNodeContents(node: Node) {
|
||||
selected = node;
|
||||
},
|
||||
getClientRects() {
|
||||
const id = (selected as Element | null)?.id ?? "";
|
||||
return textRects[id]
|
||||
? ([textRects[id]] as unknown as DOMRectList)
|
||||
: ([] as unknown as DOMRectList);
|
||||
},
|
||||
detach() {},
|
||||
} as unknown as Range;
|
||||
});
|
||||
|
||||
installAuditScript();
|
||||
return runAudit();
|
||||
}
|
||||
|
||||
function installAuditScript(): void {
|
||||
window.eval(script);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,8 @@ export type LayoutIssueCode =
|
||||
| "text_box_overflow"
|
||||
| "clipped_text"
|
||||
| "canvas_overflow"
|
||||
| "container_overflow";
|
||||
| "container_overflow"
|
||||
| "content_overlap";
|
||||
|
||||
export type LayoutIssueSeverity = "error" | "warning" | "info";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user