#!/usr/bin/env python3 """Lint diagram examples as *rendered* — catches breakage the source can't show. ``lint-skin.py`` reads the HTML. This one renders it in headless Chromium and asks the browser what actually got painted, so it catches content cut off by the SVG viewport, collapsed SVGs, sideways page overflow and runtime errors. How the clipping check works --------------------------- Chromium's ``getBoundingClientRect()`` on an SVG child reports *geometry*, not paint: it excludes stroke width, markers and filter bleed (so a 40px stroke spilling past the viewport measures as inside), and it ignores ``clip-path``, ``opacity: 0`` ancestors and ``overflow: visible`` (so safe content measures as outside). Both directions were reproduced in Chromium, so no geometry model is used here. Instead the browser is the oracle: screenshot the viewport as authored, screenshot it again with ``overflow`` released, and diff the two. New ink means paint was being cut off — ink is ink, so strokes, markers and filter bleed all count, while clip-path, invisible and already-visible content produce no new ink. Releases are **staged**, because a diagram can be clipped at more than one level and each release repaints its own box: - stage 0 releases the SVG alone, and looks for ink outside the SVG's box; - stage *k* also releases the *k* nearest clipping ancestors, and looks for ink outside the outermost released box. Without staging, releasing an ancestor masks spill just outside the SVG (its box becomes part of the ignored area), and an SVG authored ``overflow: visible`` inside a clipping wrapper looks clean while the wrapper cuts it off. What the diff ignores, and why: - The outermost released box for that stage. A released element repaints itself — an ``overflow: hidden`` box with a ``border-radius`` loses its rounded corners — which is not spill. - An ``EDGE_GUARD``-px band around it, and anything under ``MIN_DIFF_PIXELS`` pixels past ``CHANNEL_THRESHOLD``. Releasing ``overflow`` re-antialiases boundary pixels by a channel step or two, which is not a clipped diagram. Every stage runs at each of ``DIFF_SCALES``: 1x resolves spill of a few px, 0.25x pulls spill up to four viewports wide back into frame. All geometry stays in one coordinate system — viewport pixels, straight from ``getBoundingClientRect()``, matching ``page.screenshot()`` — so an SVG below the fold measures the same as one above it. Known ceilings: spill of ~2px or less, spill past 0.25x framing, and SVGs inside a *scrolling* ancestor, which report ``unmeasurable`` rather than passing quietly. Every property this script mutates is snapshotted and restored verbatim in a ``finally``, and ``--self-test`` asserts the DOM is byte-identical afterwards. Trust boundary -------------- This renders contributor HTML in a real browser with JavaScript enabled, so treat it like opening the file yourself. Network is cut at the resolver (``--host-resolver-rules``), which also stops WebSockets and anything else that does not go through Playwright's request routing, with request routing as a second layer. ``--fonts`` excludes exactly the two Google Fonts hostnames from the resolver block and allows them only over HTTPS on an exact hostname match. ``--self-test`` proves the isolation against a local listener. Because the oracle is pixels, CI must pin Playwright and its bundled Chromium rather than taking whatever is newest; see ``.github/workflows/ci.yml``. python3 scripts/lint-render.py --all python3 scripts/lint-render.py skills/diagram-design/assets/example-venn.html python3 scripts/lint-render.py --self-test # proves every check still fires Requires Playwright (same dev dependency the PNG export uses): pip install playwright && playwright install chromium """ import argparse import base64 import os import socket import sys import tempfile import threading from pathlib import Path from urllib.parse import urlparse ROOT = Path(__file__).resolve().parent.parent ASSET_DIR = ROOT / "skills/diagram-design/assets" VIEWPORT = {"width": 1600, "height": 1000} TOLERANCE = 1.0 # px of slop before page overflow counts, absorbs subpixel layout CHANNEL_THRESHOLD = 16 # per-channel delta that counts as new ink, not antialiasing MIN_DIFF_PIXELS = 8 # new-ink pixels needed before a clip is reported # ponytail: 2px guard band around a released box absorbs boundary re-antialiasing; # the cost is that a spill of 2px or less goes unreported. EDGE_GUARD = 2 # Two passes: 1x sees spills of a few px, 0.25x pulls a spill four viewports wide # back into frame. ponytail: anything past that stays unseen, and says so above. DIFF_SCALES = (1, 0.25) # Deepest ancestor chain to stage. Diagram wrappers are shallow; this only bounds # pathological nesting. ponytail: raise it if a real example needs more. MAX_ANCESTOR_STAGES = 4 FONT_HOSTS = ("fonts.googleapis.com", "fonts.gstatic.com") # Kill name resolution outright. Unlike request routing this also covers # WebSockets, EventSource and anything else that opens its own connection. RESOLVER_RULE = "MAP * ~NOTFOUND" # Findings in these categories are reported but do not fail the run: they say # "not checked", which is worth printing and wrong to treat as a defect. NOTE_CATEGORIES = {"unmeasurable"} # Per-svg facts that don't need paint: its box, whether the author already set # overflow visible, and the chain of ancestors that clip it. A clipping ancestor # that is currently scrolling cannot be released (its scrollbars would move and # change the pixels), so the chain stops there and the rest is reported unchecked. SURVEY_JS = """ () => { const label = (el) => { const id = el.id ? '#' + el.id : ''; const cls = el.getAttribute('class') ? '.' + el.getAttribute('class').split(/\\s+/)[0] : ''; return el.tagName.toLowerCase() + id + cls; }; return [...document.querySelectorAll('svg')].map((svg, index) => { const box = svg.getBoundingClientRect(); const chain = []; let blockedBy = null; for (let el = svg.parentElement; el; el = el.parentElement) { const overflow = getComputedStyle(el).overflow; if (overflow === 'visible') continue; const scrolls = el.scrollWidth > el.clientWidth + 1 || el.scrollHeight > el.clientHeight + 1; if (scrolls) { blockedBy = `${label(el)} (overflow: ${overflow})`; break; } chain.push(`${label(el)} (overflow: ${overflow})`); } return { index, label: label(svg), width: box.width, height: box.height, ownOverflowVisible: getComputedStyle(svg).overflow === 'visible', chain, blockedBy, }; }); } """ # Mutations record the exact `style` attribute they replace, so RESTORE_JS can put # it back verbatim — including an authored inline transform, which an earlier # version clobbered by resetting the property to ''. REMEMBER_JS = """ window.__lintSnapshots = window.__lintSnapshots || []; const remember = (el) => window.__lintSnapshots.push([el, el.getAttribute('style')]); """ RESTORE_JS = """ () => { const snapshots = window.__lintSnapshots || []; // Reverse order, so an element mutated twice ends on its earliest snapshot. for (const [el, style] of snapshots.slice().reverse()) { if (style === null) el.removeAttribute('style'); else el.setAttribute('style', style); } delete window.__lintSnapshots; } """ # A paint-only scale on the svg. Transforms don't reflow, so the rest of the page # stays put and both screenshots of a pass are directly comparable. Shrinking the # svg pulls spill that would land off-screen back into the viewport. SET_SCALE_JS = ( """ ([index, scale]) => { """ + REMEMBER_JS + """ const svg = document.querySelectorAll('svg')[index]; remember(svg); svg.style.setProperty('transform-origin', 'top left', 'important'); svg.style.setProperty('transform', `scale(${scale})`, 'important'); } """ ) # Release overflow on the svg plus the `depth` nearest clipping ancestors, and # return every released box in viewport coordinates, innermost first. `important` # so a class rule can't win; the exact prior style attribute is snapshotted. RELEASE_JS = ( """ ([index, depth]) => { """ + REMEMBER_JS + """ const svg = document.querySelectorAll('svg')[index]; const targets = [svg]; let taken = 0; for (let el = svg.parentElement; el && taken < depth; el = el.parentElement) { if (getComputedStyle(el).overflow === 'visible') continue; targets.push(el); taken++; } const rects = []; for (const el of targets) { remember(el); const r = el.getBoundingClientRect(); rects.push([r.left, r.top, r.right, r.bottom]); el.style.setProperty('overflow', 'visible', 'important'); } return rects; } """ ) DOM_STATE_JS = "() => document.documentElement.outerHTML" PAGE_OVERFLOW_JS = """ () => { const doc = document.documentElement; return [doc.scrollWidth - doc.clientWidth, doc.clientWidth]; } """ # Diff two screenshots of the same viewport inside the browser: no image library # on the Python side. Only ink OUTSIDE the released box counts, so an element # repainting its own area can't masquerade as clipping. DIFF_JS = """ async ({a, b, interior, threshold, minPixels, guard}) => { const load = async (src) => { const bitmap = await createImageBitmap(await (await fetch(src)).blob()); const canvas = new OffscreenCanvas(bitmap.width, bitmap.height); canvas.getContext('2d').drawImage(bitmap, 0, 0); return canvas.getContext('2d').getImageData(0, 0, bitmap.width, bitmap.height); }; const [before, after] = await Promise.all([load(a), load(b)]); if (before.width !== after.width || before.height !== after.height) { return {count: 0, resized: true, sides: {}}; } const sides = {left: 0, top: 0, right: 0, bottom: 0}; let count = 0; const g = guard; for (let y = 0; y < before.height; y++) { for (let x = 0; x < before.width; x++) { if (x >= interior.left - g && x < interior.right + g && y >= interior.top - g && y < interior.bottom + g) continue; const i = (y * before.width + x) * 4; const delta = Math.max( Math.abs(before.data[i] - after.data[i]), Math.abs(before.data[i + 1] - after.data[i + 1]), Math.abs(before.data[i + 2] - after.data[i + 2]), Math.abs(before.data[i + 3] - after.data[i + 3]), ); if (delta <= threshold) continue; count++; if (x < interior.left) sides.left = Math.max(sides.left, interior.left - x); if (x >= interior.right) sides.right = Math.max(sides.right, x - interior.right + 1); if (y < interior.top) sides.top = Math.max(sides.top, interior.top - y); if (y >= interior.bottom) sides.bottom = Math.max(sides.bottom, y - interior.bottom + 1); } } return {count, resized: false, sides, reported: count >= minPixels}; } """ def parse_args(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("files", nargs="*", type=Path, help="HTML files to render") parser.add_argument("--all", action="store_true", help="render every example") parser.add_argument("--quiet", action="store_true", help="summary only") parser.add_argument( "--fonts", action="store_true", help="allow exactly the Google Fonts hosts, over HTTPS, so text is measured " "with the real typefaces", ) parser.add_argument( "--self-test", action="store_true", dest="self_test", help="check the checks: detection, false flags, network isolation, DOM restore", ) return parser.parse_args() def shipped_assets(): """Every asset that ships and renders: examples *and* the templates they are scaffolded from. A broken template ships broken diagrams, so leaving templates out of --all was a hole (thanks @greptile). index.html and icons.html are galleries of the others, so they add no coverage. """ patterns = ("example-*.html", "template*.html") return sorted({path for pattern in patterns for path in ASSET_DIR.glob(pattern)}) def display_path(path): try: return str(path.resolve().relative_to(ROOT)) except ValueError: return str(path) def resolver_rule(allow_fonts): """Block every hostname, minus the font hosts when they are opted in.""" if not allow_fonts: return RESOLVER_RULE return ",".join([RESOLVER_RULE] + [f"EXCLUDE {host}" for host in FONT_HOSTS]) def launch(playwright, allow_fonts): """Bundled Chromium, or the channel named by DIAGRAM_LINT_BROWSER_CHANNEL. No silent fallback: in CI a missing bundled Chromium is a broken environment, and quietly using whatever browser is lying around hides that. """ args = [f"--host-resolver-rules={resolver_rule(allow_fonts)}"] channel = os.environ.get("DIAGRAM_LINT_BROWSER_CHANNEL") if channel: print(f"Using browser channel {channel!r} (DIAGRAM_LINT_BROWSER_CHANNEL).", file=sys.stderr) return playwright.chromium.launch(channel=channel, args=args) return playwright.chromium.launch(args=args) def block_network(context, allow_fonts): """Second layer behind the resolver block: local documents only. The font exception requires HTTPS and an exact hostname, so a URL merely containing "fonts.googleapis.com" (a path, a query, a lookalike host) is not enough to get through. """ def handler(route): url = route.request.url if url.startswith(("file://", "data:", "blob:")): route.continue_() return parsed = urlparse(url) if allow_fonts and parsed.scheme == "https" and parsed.hostname in FONT_HOSTS: route.continue_() return route.abort() context.route("**/*", handler) def watch(page, findings): """Runtime failures. Local assets are reported precisely, via requestfailed.""" page.on("pageerror", lambda error: findings.append(("page-error", str(error)))) page.on( "console", lambda message: message.type == "error" # Resource failures arrive on requestfailed with a URL, which is where they # get judged. This drops the duplicate, not the signal. and not message.text.startswith("Failed to load resource") and findings.append(("console-error", message.text)), ) def on_request_failed(request): if request.url.startswith("file://"): findings.append(("missing-asset", f"{request.url} failed to load")) # Remote requests are blocked by policy (resolver + routing); that is this # linter's doing, not the diagram's. page.on("requestfailed", on_request_failed) def shoot(page): """The viewport, not the page: fixed dimensions, so releasing overflow can't resize the image, and the whole visible area is available to spill into. Viewport pixels are also the coordinate system every rect here uses.""" return page.screenshot(animations="disabled") def data_url(png_bytes): return "data:image/png;base64," + base64.b64encode(png_bytes).decode("ascii") def compare_release(page, index, depth, scale): """One authored-vs-released paint comparison. Returns (diff, released_label_depth). Restores every mutated property before returning, whatever happens. """ try: if scale != 1: page.evaluate(SET_SCALE_JS, [index, scale]) as_authored = shoot(page) rects = page.evaluate(RELEASE_JS, [index, depth]) released = shoot(page) finally: page.evaluate(RESTORE_JS) if not rects: return None # Outermost released box: the only area whose own repaint is expected. left, top, right, bottom = rects[-1] interior = { "left": round(left), "top": round(top), "right": round(right), "bottom": round(bottom), } return page.evaluate( DIFF_JS, { "a": data_url(as_authored), "b": data_url(released), "interior": interior, "threshold": CHANNEL_THRESHOLD, "minPixels": MIN_DIFF_PIXELS, "guard": EDGE_GUARD, }, ) def clipping_findings(page, survey): """Staged paint comparisons per svg: the svg alone, then each clipping ancestor.""" findings = [] handles = page.locator("svg") for entry in survey: if not entry["width"] or not entry["height"]: findings.append( ("svg-collapsed", f"{entry['label']} renders {entry['width']}x{entry['height']}") ) continue if entry["blockedBy"]: findings.append( ( "unmeasurable", f"{entry['label']} sits inside scrolling {entry['blockedBy']}; " "clipping cannot be measured through it", ) ) chain = entry["chain"][:MAX_ANCESTOR_STAGES] # Stage 0 is the svg's own overflow — skipped only when the author already # set it visible. Ancestor stages run regardless, since a wrapper can clip # an svg that does not clip itself. stages = [] if entry["ownOverflowVisible"] else [0] stages += list(range(1, len(chain) + 1)) if not stages: continue handles.nth(entry["index"]).scroll_into_view_if_needed() for depth in stages: where = entry["label"] if depth == 0 else f"{entry['label']} inside {chain[depth - 1]}" worst = None unmeasurable = False for scale in DIFF_SCALES: diff = compare_release(page, entry["index"], depth, scale) if diff is None: continue if diff.get("resized"): findings.append( ("unmeasurable", f"{where} changed size when overflow was released") ) unmeasurable = True break if diff.get("reported") and (worst is None or diff["count"] > worst[0]["count"]): worst = (diff, scale) if unmeasurable or not worst: continue diff, scale = worst spills = ", ".join( f"{side} by {int(distance / scale)}px" for side, distance in sorted(diff["sides"].items()) if distance ) findings.append( ( "clipped", f"{where} paints outside its box: {spills} " f"({diff['count']} px of ink cut off at {scale:g}x)", ) ) # One report per svg is enough; deeper stages describe the same spill. break return findings def measure(page): findings = [] # Playwright awaits a returned promise; no timeout kwarg exists on evaluate. page.evaluate("() => document.fonts.ready") survey = page.evaluate(SURVEY_JS) findings.extend(clipping_findings(page, survey)) spill, client_width = page.evaluate(PAGE_OVERFLOW_JS) if spill > TOLERANCE: findings.append( ("page-overflow", f"page scrolls {spill:.0f}px horizontally at {client_width}px wide") ) return findings def check(page, path): findings = [] watch(page, findings) page.goto(path.resolve().as_uri(), wait_until="load") return measure(page) + findings # --- self-test --------------------------------------------------------------- SVG_CASES = [ # (name, svg markup, svg inline style, wrapper html or None, should_be_flagged) ("plain-overflow", '', "", None, True), ( "thick-stroke-spill", '', "", None, True, ), ( "marker-spill", '' '' '', "", None, True, ), ( "filter-bleed", '' '', "", None, True, ), ( "clip-path-keeps-it-safe", '' '', "", None, False, ), ( "opacity-0-ancestor", '', "", None, False, ), ( "display-none-ancestor", '', "", None, False, ), ("all-inside", '', "", None, False), # The author set overflow visible: the svg itself clips nothing. ( "overflow-visible-svg", '', "overflow:visible", None, False, ), # ... but a wrapper still clips it, and staging must catch that. ( "overflow-visible-svg-in-clipping-wrapper", '', "overflow:visible", '
', True, ), # Wrapper clipping declared inline, and via a class — both must be released. ( "inline-wrapper", '', "", '
', True, ), ( "class-wrapper", '', "", '
', True, ), # A rounded clipping wrapper repaints its corners when released; not spill. ( "rounded-wrapper", '', "background:#eee", '
', False, ), # An authored inline transform must survive the scale pass untouched. ( "authored-transform", '', "transform:rotate(2deg)", None, True, ), ] FIXTURE_PAGE = """ %(filler)s%(open)s %(markup)s %(close)s """ # Enough filler to push the svg below a 1000px-tall viewport, so the paired # above/below-fold cases exercise scrolled measurement. BELOW_FOLD_FILLER = '
' def fixture_html(markup, svg_style="", wrapper=None, filler=""): return FIXTURE_PAGE % { "filler": filler, "open": wrapper or "", "close": "
" if wrapper else "", "svg_style": svg_style, "markup": markup, } def network_isolation_failures(context): """Point a page at a local listener four different ways; nothing may connect. Request routing alone does not stop a WebSocket — verified — which is why the resolver block exists. This asserts the boundary rather than documenting it. """ connections = [] server = socket.socket() server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind(("127.0.0.1", 0)) port = server.getsockname()[1] server.listen(8) def accept_loop(): while True: try: connection, address = server.accept() except OSError: return connections.append(address) connection.close() thread = threading.Thread(target=accept_loop, daemon=True) thread.start() page = context.new_page() try: with tempfile.TemporaryDirectory() as directory: fixture = Path(directory) / "isolation.html" fixture.write_text( "", encoding="utf-8", ) page.goto(fixture.as_uri(), wait_until="load") page.wait_for_timeout(1200) finally: page.close() server.close() if connections: return [f"network-isolation: {len(connections)} connection(s) reached a local listener"] return [] def self_test(context): page = context.new_page() failures = [] checks = 0 def flagged(html): page.set_content(html) return any(category == "clipped" for category, _ in measure(page)) for name, markup, svg_style, wrapper, expected in SVG_CASES: checks += 1 if flagged(fixture_html(markup, svg_style, wrapper)) != expected: failures.append(f"{name}: flagged={not expected}, expected={expected}") # Paired above/below-fold: identical spill, so a scrolled measurement that # mixed document and viewport coordinates would disagree with its twin. for label, filler in (("above-fold", ""), ("below-fold", BELOW_FOLD_FILLER)): checks += 1 if not flagged(fixture_html(SVG_CASES[0][1], filler=filler)): failures.append(f"{label}-spill: not caught") checks += 1 if flagged(fixture_html(SVG_CASES[7][1], filler=BELOW_FOLD_FILLER)): failures.append("below-fold-clean: false clipped finding") # The DOM must come back exactly as authored, including inline transforms. checks += 1 page.set_content(fixture_html(SVG_CASES[0][1], svg_style="transform:rotate(2deg)")) before = page.evaluate(DOM_STATE_JS) measure(page) if page.evaluate(DOM_STATE_JS) != before: failures.append("dom-restore: markup changed after measuring") # A *scrolling* ancestor must be reported unmeasurable, not clean. checks += 1 page.set_content( fixture_html(SVG_CASES[0][1], wrapper='
') ) if not any(category == "unmeasurable" for category, _ in measure(page)): failures.append("scrolling-ancestor: no unmeasurable finding") # Collapsed svg, and a page that scrolls sideways. checks += 2 page.set_content( '
wide
' '' ) categories = {category for category, _ in measure(page)} for expected_category in ("svg-collapsed", "page-overflow"): if expected_category not in categories: failures.append(f"{expected_category}: check did not fire") page.close() # A missing local asset must surface, not be swallowed with the remote noise. checks += 1 asset_page = context.new_page() asset_findings = [] watch(asset_page, asset_findings) with tempfile.TemporaryDirectory() as directory: fixture = Path(directory) / "missing-asset.html" fixture.write_text('', encoding="utf-8") asset_page.goto(fixture.as_uri(), wait_until="load") asset_page.wait_for_timeout(300) asset_page.close() if not any(category == "missing-asset" for category, _ in asset_findings): failures.append("missing-asset: check did not fire") checks += 1 failures += network_isolation_failures(context) if failures: print("self-test FAILED:") for failure in failures: print(f" {failure}") return 1 print(f"self-test OK: {checks} cases, no false flags.") return 0 def main(): args = parse_args() try: from playwright.sync_api import sync_playwright except ImportError: print( "Playwright is not installed. Install it with:\n" " pip install playwright && playwright install chromium", file=sys.stderr, ) return 2 paths = shipped_assets() if args.all else args.files if not paths and not args.self_test: print("Nothing to check. Pass files, --all or --self-test.", file=sys.stderr) return 2 total_findings = 0 total_notes = 0 with sync_playwright() as playwright: try: browser = launch(playwright, args.fonts) except Exception as error: print( f"Could not launch a browser: {error}\n" " playwright install chromium\n" " (or set DIAGRAM_LINT_BROWSER_CHANNEL=chrome to use a system Chrome)", file=sys.stderr, ) return 2 # The oracle is pixels, so record which browser produced them. print(f"Chromium {browser.version}, network: {resolver_rule(args.fonts)}", file=sys.stderr) context = browser.new_context(viewport=VIEWPORT) block_network(context, args.fonts) if args.self_test: status = self_test(context) if status or not paths: browser.close() return status for path in paths: page = context.new_page() try: findings = check(page, path) except Exception as error: findings = [("render-error", str(error).splitlines()[0])] finally: page.close() findings.sort() total_findings += sum(1 for category, _ in findings if category not in NOTE_CATEGORIES) total_notes += sum(1 for category, _ in findings if category in NOTE_CATEGORIES) if not args.quiet: shown_path = display_path(path) for category, message in findings: print(f"{shown_path}: {category}: {message}") browser.close() print( f"Summary: {len(paths)} file(s) rendered, {total_findings} finding(s), " f"{total_notes} note(s) (not checked, not failed)." ) return 1 if total_findings else 0 if __name__ == "__main__": sys.exit(main())