Files
wehub-resource-sync 917eedffcf
Main / Python 3.11 - Docs (push) Has been cancelled
Main / Python 3.11 - Build (push) Has been cancelled
Main / Python 3.11 - Lint (push) Has been cancelled
Main / Python 3.11 - Style (push) Has been cancelled
Main / Python 3.11 - Test (push) Has been cancelled
Main / GPU CI (push) Has been cancelled
Main / Release (push) Has been cancelled
Main / Build and Push Docker Images (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:27:09 +08:00

565 lines
22 KiB
Python

"""Detection of text content that is cut off or hidden in rendered HTML.
Detects two types of hidden content:
1. Overflow clipping: text inside ancestors with overflow:hidden gets clipped.
2. Occlusion: opaque elements positioned on top of text, hiding it.
Watermarks and semi-transparent overlays (alpha <= 0.5) are ignored.
"""
import argparse
import asyncio
import glob
import os
import re
from dataclasses import dataclass, field
from typing import List, Optional
from playwright.async_api import async_playwright
@dataclass
class CutoffElement:
"""Represents an element whose text content is partially or fully cut off."""
tag: str
text: str
visible_ratio: float # 0.0 = fully hidden, 1.0 = fully visible
horizontal_visible_ratio: float = 1.0 # fraction of width visible after clipping
is_occluded: bool = False # True if covered by an opaque non-ancestor element
bounding_rect: dict = field(default_factory=dict)
clipping_ancestor_tag: Optional[str] = None
@dataclass
class RenderResult:
"""Result of rendering HTML to PDF, including cutoff detection info."""
success: bool
scale_used: Optional[float] = None
cutoff_elements: List[CutoffElement] = field(default_factory=list)
has_cutoff: bool = False
_CUTOFF_JS = """
(threshold) => {
const results = [];
// Collect leaf text elements: elements that have at least one
// direct Text node child with non-whitespace content.
function getLeafTextElements(root) {
const leaves = [];
function walk(el) {
// Skip invisible elements
const style = getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden') {
return;
}
let hasDirectText = false;
for (const child of el.childNodes) {
if (child.nodeType === Node.TEXT_NODE && child.textContent.trim()) {
hasDirectText = true;
break;
}
}
if (hasDirectText) {
leaves.push(el);
}
for (const child of el.children) {
walk(child);
}
}
walk(root);
return leaves;
}
const elements = getLeafTextElements(document.body);
for (const el of elements) {
const rect = el.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) continue;
// Only consider the direct text content of this element
let directText = '';
for (const child of el.childNodes) {
if (child.nodeType === Node.TEXT_NODE) {
directText += child.textContent;
}
}
directText = directText.trim();
if (!directText) continue;
// Use scrollWidth/scrollHeight to account for content that
// overflows the element's CSS box (e.g. white-space: nowrap
// text inside a fixed-width block element).
const overflowX = Math.max(0, el.scrollWidth - el.clientWidth);
const overflowY = Math.max(0, el.scrollHeight - el.clientHeight);
const contentRight = rect.right + overflowX;
const contentBottom = rect.bottom + overflowY;
// Compute the visible rect by intersecting with all clipping ancestors
let visLeft = rect.left;
let visTop = rect.top;
let visRight = contentRight;
let visBottom = contentBottom;
let clippingAncestorTag = null;
let ancestor = el.parentElement;
while (ancestor) {
const aStyle = getComputedStyle(ancestor);
const ovf = aStyle.overflow;
const ovfX = aStyle.overflowX;
const ovfY = aStyle.overflowY;
const clipsX = ovf === 'hidden' || ovf === 'clip' ||
ovfX === 'hidden' || ovfX === 'clip';
const clipsY = ovf === 'hidden' || ovf === 'clip' ||
ovfY === 'hidden' || ovfY === 'clip';
if (clipsX || clipsY) {
const aRect = ancestor.getBoundingClientRect();
const prevLeft = visLeft;
const prevTop = visTop;
const prevRight = visRight;
const prevBottom = visBottom;
if (clipsX) {
visLeft = Math.max(visLeft, aRect.left);
visRight = Math.min(visRight, aRect.right);
}
if (clipsY) {
visTop = Math.max(visTop, aRect.top);
visBottom = Math.min(visBottom, aRect.bottom);
}
// Record the first ancestor that actually clips this element
if (!clippingAncestorTag &&
(visLeft !== prevLeft || visTop !== prevTop ||
visRight !== prevRight || visBottom !== prevBottom)) {
clippingAncestorTag = ancestor.tagName;
}
}
ancestor = ancestor.parentElement;
}
// Skip viewport clipping — PDF scaling handles viewport overflow.
// We only care about overflow:hidden container clipping.
// Calculate areas using content dimensions (including overflow)
const contentWidth = rect.width + overflowX;
const contentHeight = rect.height + overflowY;
const originalArea = contentWidth * contentHeight;
const visibleWidth = Math.max(0, visRight - visLeft);
const visibleHeight = Math.max(0, visBottom - visTop);
const visibleArea = visibleWidth * visibleHeight;
const visibleRatio = originalArea > 0 ? visibleArea / originalArea : 0;
const horizontalVisibleRatio = contentWidth > 0 ? visibleWidth / contentWidth : 1;
if (visibleRatio < threshold) {
results.push({
tag: el.tagName,
text: directText.substring(0, 500),
visibleRatio: visibleRatio,
horizontalVisibleRatio: horizontalVisibleRatio,
boundingRect: {
left: rect.left,
top: rect.top,
right: rect.right,
bottom: rect.bottom,
width: rect.width,
height: rect.height
},
clippingAncestorTag: clippingAncestorTag
});
}
}
return results;
}
"""
_OCCLUSION_JS = """
() => {
const results = [];
function getLeafTextElements(root) {
const leaves = [];
function walk(el) {
const style = getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden') return;
let hasDirectText = false;
for (const child of el.childNodes) {
if (child.nodeType === Node.TEXT_NODE && child.textContent.trim()) {
hasDirectText = true;
break;
}
}
if (hasDirectText) leaves.push(el);
for (const child of el.children) walk(child);
}
walk(root);
return leaves;
}
function isRelated(a, b) {
let cur = a.parentElement;
while (cur) { if (cur === b) return true; cur = cur.parentElement; }
cur = b.parentElement;
while (cur) { if (cur === a) return true; cur = cur.parentElement; }
return false;
}
function parseAlpha(bgColor) {
if (!bgColor || bgColor === 'rgba(0, 0, 0, 0)' || bgColor === 'transparent') return 0;
const m = bgColor.match(/rgba\\((\\d+),\\s*(\\d+),\\s*(\\d+),\\s*([\\d.]+)\\)/);
if (m) return parseFloat(m[4]);
if (bgColor.startsWith('rgb(')) return 1;
return 0;
}
const elements = getLeafTextElements(document.body);
for (const el of elements) {
const rect = el.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) continue;
let directText = '';
for (const child of el.childNodes) {
if (child.nodeType === Node.TEXT_NODE) directText += child.textContent;
}
directText = directText.trim();
if (!directText || directText.length < 3) continue;
// Sample a grid of points across the element
const inset = 2;
const cols = Math.max(2, Math.min(5, Math.ceil(rect.width / 20)));
const rows = Math.max(2, Math.min(4, Math.ceil(rect.height / 20)));
let totalPoints = 0;
let occludedCount = 0;
let blockerTag = null;
for (let r = 0; r < rows; r++) {
const py = rect.top + inset + (rect.height - 2 * inset) * r / (rows - 1);
for (let c = 0; c < cols; c++) {
const px = rect.left + inset + (rect.width - 2 * inset) * c / (cols - 1);
if (px < 0 || py < 0) continue;
totalPoints++;
const topEl = document.elementFromPoint(px, py);
if (!topEl) continue;
if (topEl === el || isRelated(topEl, el)) continue;
const alpha = parseAlpha(getComputedStyle(topEl).backgroundColor);
if (alpha > 0.5) {
occludedCount++;
if (!blockerTag) blockerTag = topEl.tagName;
}
}
}
// Flag if >= 20% of sample points are occluded
const occlusionRatio = totalPoints > 0 ? occludedCount / totalPoints : 0;
if (occlusionRatio >= 0.2) {
results.push({
tag: el.tagName,
text: directText.substring(0, 500),
occludedPoints: occludedCount,
totalPoints: totalPoints,
occlusionRatio: occlusionRatio,
blockerTag: blockerTag,
boundingRect: {
left: rect.left,
top: rect.top,
right: rect.right,
bottom: rect.bottom,
width: rect.width,
height: rect.height
}
});
}
}
return results;
}
"""
def _parse_cutoff_data(cutoff_data: list) -> List[CutoffElement]:
return [
CutoffElement(
tag=item["tag"],
text=item["text"],
visible_ratio=item["visibleRatio"],
horizontal_visible_ratio=item.get("horizontalVisibleRatio", 1.0),
bounding_rect=item["boundingRect"],
clipping_ancestor_tag=item.get("clippingAncestorTag"),
)
for item in cutoff_data
]
def _parse_occlusion_data(occlusion_data: list) -> List[CutoffElement]:
return [
CutoffElement(
tag=item["tag"],
text=item["text"],
visible_ratio=1.0 - item["occludedPoints"] / item["totalPoints"],
horizontal_visible_ratio=1.0,
is_occluded=True,
bounding_rect=item["boundingRect"],
clipping_ancestor_tag=item.get("blockerTag"),
)
for item in occlusion_data
]
async def _detect_cutoff_on_page(page, visibility_threshold: float) -> List[CutoffElement]:
"""Run overflow-clipping and occlusion detection on an already-loaded Playwright page."""
cutoff_data = await page.evaluate(_CUTOFF_JS, visibility_threshold)
occlusion_data = await page.evaluate(_OCCLUSION_JS)
return _parse_cutoff_data(cutoff_data) + _parse_occlusion_data(occlusion_data)
async def detect_cutoff_text(
html_content: str,
viewport_width: int,
viewport_height: int,
visibility_threshold: float = 0.9,
) -> List[CutoffElement]:
"""
Detect text elements that are cut off due to overflow:hidden or viewport clipping.
Renders the HTML in a headless browser and checks each text-containing element's
bounding box against all ancestor elements that have overflow:hidden. If the
element's visible area (after clipping) is less than the threshold fraction of
its full area, it is flagged.
Args:
html_content: The HTML string to analyze.
viewport_width: Width of the viewport in pixels.
viewport_height: Height of the viewport in pixels.
visibility_threshold: Elements with visible_ratio below this are flagged (0.0-1.0).
Returns:
List of CutoffElement instances for elements that are significantly cut off.
"""
async with async_playwright() as p:
browser = await p.chromium.launch()
page = await browser.new_page(viewport={"width": viewport_width, "height": viewport_height})
await page.set_content(html_content, wait_until="load")
results = await _detect_cutoff_on_page(page, visibility_threshold)
await browser.close()
return results
def has_significant_cutoff(
cutoff_elements: List[CutoffElement],
min_text_length: int = 3,
max_visible_ratio: float = 0.5,
) -> bool:
"""
Determine if the detected cutoff elements represent significant content loss.
Flags two types of issues:
1. Horizontal overflow clipping — text width cut by overflow:hidden containers.
2. Occlusion — text covered by opaque non-ancestor elements.
Vertical-only clipping is ignored (PDF scaling handles it).
Watermarks / transparent overlays are ignored by the detection JS.
Args:
cutoff_elements: List of CutoffElement from detect_cutoff_text.
min_text_length: Minimum text length to consider an element significant.
max_visible_ratio: Elements with horizontal_visible_ratio at or below this
are considered significant cutoff (default 0.5 means >50% of the
element's width is hidden).
Returns:
True if there is significant text cutoff or occlusion that warrants
skipping or re-rendering.
"""
for el in cutoff_elements:
if len(el.text.strip()) < min_text_length:
continue
# Horizontal overflow clipping
if el.horizontal_visible_ratio <= max_visible_ratio:
return True
# Occlusion by opaque element
if el.is_occluded:
return True
return False
def extract_viewport_from_html(html_content: str) -> tuple:
"""Extract viewport dimensions from the HTML meta viewport tag.
Parses ``<meta name="viewport" content="width=N, ...">``. Falls back to
1024 wide if not found. Height always defaults to 100000 (large value
avoids false viewport-clipping on pages that flow naturally).
"""
width = 1024
height = 100000
meta_match = re.search(
r'<meta\s[^>]*name=["\']viewport["\'][^>]*content=["\']([^"\']*)["\']' r'|<meta\s[^>]*content=["\']([^"\']*)["\'][^>]*name=["\']viewport["\']',
html_content,
re.IGNORECASE,
)
if meta_match:
content = meta_match.group(1) or meta_match.group(2)
w = re.search(r"width\s*=\s*(\d+)", content)
if w:
width = int(w.group(1))
h = re.search(r"height\s*=\s*(\d+)", content)
if h:
height = int(h.group(1))
return width, height
async def classify_html_files(
html_paths: List[str],
cutoff_dir: str,
no_cutoff_dir: str,
visibility_threshold: float = 0.9,
min_text_length: int = 3,
max_visible_ratio: float = 0.5,
):
"""Classify HTML files into cutoff / no-cutoff directories.
Files are symlinked (not copied) into the output directories.
For cutoff files, a PNG screenshot is saved alongside the symlink.
"""
os.makedirs(cutoff_dir, exist_ok=True)
os.makedirs(no_cutoff_dir, exist_ok=True)
n_cutoff = 0
n_ok = 0
async with async_playwright() as p:
browser = await p.chromium.launch()
for path in html_paths:
filename = os.path.basename(path)
try:
with open(path, "r") as f:
html_content = f.read()
vw, vh = extract_viewport_from_html(html_content)
page = await browser.new_page(viewport={"width": vw, "height": vh})
await page.set_content(html_content, wait_until="load")
elements = await _detect_cutoff_on_page(page, visibility_threshold)
is_cutoff = has_significant_cutoff(elements, min_text_length, max_visible_ratio)
if is_cutoff:
dest = os.path.join(cutoff_dir, filename)
n_cutoff += 1
# Save a screenshot next to the symlink
png_name = os.path.splitext(filename)[0] + ".png"
png_path = os.path.join(cutoff_dir, png_name)
await page.screenshot(path=png_path, full_page=False)
else:
dest = os.path.join(no_cutoff_dir, filename)
n_ok += 1
await page.close()
# Symlink to the original file
if os.path.exists(dest) or os.path.islink(dest):
os.remove(dest)
os.symlink(os.path.abspath(path), dest)
status = "CUTOFF" if is_cutoff else "ok"
detail = ""
if is_cutoff:
# Show the worst occluded element if any, otherwise worst clipped
occluded = [e for e in elements if e.is_occluded and len(e.text.strip()) >= min_text_length]
clipped = [
e for e in elements if not e.is_occluded and e.horizontal_visible_ratio <= max_visible_ratio and len(e.text.strip()) >= min_text_length
]
if occluded:
worst = occluded[0]
detail = f' occluded: {worst.tag} by={worst.clipping_ancestor_tag} "{worst.text[:60]}"'
elif clipped:
worst = min(clipped, key=lambda e: e.horizontal_visible_ratio)
detail = f' clipped: {worst.tag} hvis={worst.horizontal_visible_ratio:.2f} "{worst.text[:60]}"'
else:
detail = ""
print(f"[{status:6s}] {filename}{detail}")
except Exception as e:
print(f"[ERROR ] {filename}: {e}")
await browser.close()
print(f"\nDone. cutoff={n_cutoff} ok={n_ok} total={n_cutoff + n_ok}")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Sort HTML files into cutoff / no-cutoff directories based on overflow clipping detection.")
parser.add_argument(
"input_dir",
help="Directory containing HTML files to classify.",
)
parser.add_argument(
"--cutoff-dir",
default=None,
help="Output directory for files with cutoff (default: <input_dir>_cutoff).",
)
parser.add_argument(
"--no-cutoff-dir",
default=None,
help="Output directory for files without cutoff (default: <input_dir>_no_cutoff).",
)
parser.add_argument(
"--visibility-threshold",
type=float,
default=0.9,
help="Flag elements with visible ratio below this (default: 0.9).",
)
parser.add_argument(
"--min-text-length",
type=int,
default=3,
help="Minimum text length to consider significant (default: 3).",
)
parser.add_argument(
"--max-visible-ratio",
type=float,
default=0.5,
help="Elements at or below this visibility are 'significant' cutoff (default: 0.5).",
)
args = parser.parse_args()
input_dir = args.input_dir.rstrip("/")
cutoff_dir = args.cutoff_dir or f"{input_dir}_cutoff"
no_cutoff_dir = args.no_cutoff_dir or f"{input_dir}_no_cutoff"
html_paths = sorted(glob.glob(os.path.join(input_dir, "*.html")))
if not html_paths:
print(f"No .html files found in {input_dir}")
exit(1)
print(f"Found {len(html_paths)} HTML files in {input_dir}")
print(f" cutoff -> {cutoff_dir}")
print(f" no_cutoff -> {no_cutoff_dir}")
print()
asyncio.run(
classify_html_files(
html_paths,
cutoff_dir,
no_cutoff_dir,
visibility_threshold=args.visibility_threshold,
min_text_length=args.min_text_length,
max_visible_ratio=args.max_visible_ratio,
)
)