towishy_Owen-Graphite/dev/scripts/audit_visual_quality_fixture.py
2026-05-25 16:10:34 +09:00

282 lines
9.9 KiB
Python

#!/usr/bin/env python3
"""Render PDF visual quality fixtures when a local browser exists."""
from __future__ import annotations
import argparse
import os
import shutil
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
THEME = ROOT / "theme.css"
OUT_DIR = ROOT / "dev" / "temp" / "visual-quality"
FIXTURES = (
{
"id": "live-preview-pdf-parity",
"path": ROOT / "dev" / "WIKI" / "DOCS" / "v3" / "research" / "live-preview-pdf-parity-fixture.html",
"screenshot": OUT_DIR / "live-preview-pdf-parity.png",
"pdf": OUT_DIR / "live-preview-pdf-parity.pdf",
"min_png": 20_000,
"min_pdf": 10_000,
"markers": (
'data-ogd-fixture="live-preview-pdf-parity"',
'data-check="lp-callout-summary"',
'data-check="reading-callout-summary"',
'data-check="pdf-callout-summary"',
'class="ogd-html-table wrap-table print-fit-table"',
'class="ogd-token-wrap"',
'ogd-pdf-visibility',
'ogd-pdf-font-comfortable',
),
},
{
"id": "pdf-image-body-quality",
"path": ROOT / "dev" / "WIKI" / "DOCS" / "v3" / "research" / "pdf-image-body-quality-fixture.html",
"screenshot": OUT_DIR / "pdf-image-body-quality.png",
"pdf": OUT_DIR / "pdf-image-body-quality.pdf",
"min_png": 20_000,
"min_pdf": 10_000,
"markers": (
'data-ogd-fixture="pdf-image-body-quality"',
'data-check="pdf-figure-wide"',
'data-check="pdf-figure-compact"',
'data-check="pdf-body-callout"',
'ogd-figure-wide',
'ogd-figure-compact',
'ogd-pdf-visibility',
'ogd-pdf-font-comfortable',
),
},
{
"id": "code-font-clarity",
"path": ROOT / "dev" / "WIKI" / "DOCS" / "v3" / "research" / "code-font-clarity-fixture.html",
"screenshot": OUT_DIR / "code-font-clarity.png",
"pdf": OUT_DIR / "code-font-clarity.pdf",
"min_png": 20_000,
"min_pdf": 10_000,
"markers": (
'data-ogd-fixture="code-font-clarity"',
'data-check="lp-code-clarity"',
'data-check="lp-widget-code-clarity"',
'data-check="reading-code-clarity"',
'data-check="pdf-code-clarity"',
'data-check="pdf-cm-code-color"',
'cm-keyword',
'cm-preview-code-block',
'token keyword',
'ogd-pdf-visibility',
'ogd-pdf-font-comfortable',
),
},
{
"id": "table-callout-parity",
"path": ROOT / "dev" / "WIKI" / "DOCS" / "v3" / "research" / "table-callout-parity-fixture.html",
"screenshot": OUT_DIR / "table-callout-parity.png",
"pdf": OUT_DIR / "table-callout-parity.pdf",
"min_png": 20_000,
"min_pdf": 10_000,
"markers": (
'data-ogd-fixture="table-callout-parity"',
'data-check="lp-table-widget"',
'data-check="lp-html-table"',
'data-check="lp-callout-widget"',
'data-check="reading-table-callout"',
'data-check="pdf-table-callout"',
'data-check="pdf-body-text"',
'data-check="pdf-table-font-baseline"',
'class="ogd-html-table wrap-table print-fit-table comparison-table"',
'class="ogd-token-wrap"',
'cm-table-widget',
'cm-html-embed',
'ogd-pdf-visibility',
'ogd-pdf-font-comfortable',
'ogd-pdf-label-segmented-dual',
'ogd-pdf-header2-key-slate',
'ogd-pdf-header2-value-mint',
),
},
{
"id": "chrome-ui-state",
"path": ROOT / "dev" / "WIKI" / "DOCS" / "v3" / "research" / "chrome-ui-state-fixture.html",
"screenshot": OUT_DIR / "chrome-ui-state.png",
"pdf": OUT_DIR / "chrome-ui-state.pdf",
"min_png": 20_000,
"min_pdf": 10_000,
"markers": (
'data-ogd-fixture="chrome-ui-state"',
'data-check="chrome-state-shell"',
'data-check="tab-active"',
'data-check="nav-active"',
'data-check="settings-control-states"',
'data-check="settings-focus-within"',
'data-check="settings-search-focus-rim"',
'data-check="modal-close-state"',
'data-check="style-settings-button-states"',
'data-check="style-settings-export-modal"',
'data-check="style-settings-export-rim"',
'data-check="metadata-focus-state"',
'data-check="overlay-selection-states"',
'data-check="menu-selected-state"',
'data-check="status-bar-chip-state"',
'data-check="nav-parent-has-active"',
'data-check="mobile-narrow-overflow"',
'class="setting-item setting-item-heading"',
'class="checkbox-container is-enabled" role="switch" aria-checked="true" title="Glass intensity enabled"',
'class="suggestion-item is-selected"',
'class="modal-close-button" data-check="modal-close-state" aria-label="Close settings"',
'class="tree-item-self nav-file-title is-clickable is-active"',
),
},
)
BROWSER_NAMES = (
"msedge",
"msedge.exe",
"microsoft-edge",
"microsoft-edge-stable",
"google-chrome",
"google-chrome-stable",
"chrome",
"chrome.exe",
"chromium",
"chromium-browser",
)
def windows_browser_candidates() -> list[Path]:
candidates: list[Path] = []
for env_name in ("ProgramFiles", "ProgramFiles(x86)", "LocalAppData"):
base = os.environ.get(env_name)
if not base:
continue
root = Path(base)
candidates.extend(
(
root / "Microsoft" / "Edge" / "Application" / "msedge.exe",
root / "Google" / "Chrome" / "Application" / "chrome.exe",
)
)
return candidates
def find_browser() -> str | None:
for name in BROWSER_NAMES:
found = shutil.which(name)
if found:
return found
for candidate in windows_browser_candidates():
if candidate.exists():
return str(candidate)
return None
def validate_fixture_contract(fixture: dict[str, object]) -> None:
path = fixture["path"]
markers = fixture["markers"]
assert isinstance(path, Path)
assert isinstance(markers, tuple)
if not path.exists():
raise FileNotFoundError(f"missing fixture: {path}")
if not THEME.exists():
raise FileNotFoundError(f"missing theme: {THEME}")
html = path.read_text(encoding="utf-8")
missing = [marker for marker in markers if marker not in html]
if missing:
raise AssertionError(f"{fixture['id']} is missing required markers: " + ", ".join(missing))
def run_browser(browser: str, args: list[str]) -> subprocess.CompletedProcess[str]:
command = [browser, "--headless=new", "--disable-gpu", "--no-first-run", *args]
result = subprocess.run(command, cwd=ROOT, text=True, capture_output=True, check=False)
if result.returncode == 0:
return result
legacy_command = [browser, "--headless", "--disable-gpu", "--no-first-run", *args]
return subprocess.run(legacy_command, cwd=ROOT, text=True, capture_output=True, check=False)
def assert_output(path: Path, min_size: int) -> None:
if not path.exists():
raise AssertionError(f"browser did not create {path}")
size = path.stat().st_size
if size < min_size:
raise AssertionError(f"{path} is unexpectedly small: {size} bytes")
def render(browser: str, fixture: dict[str, object]) -> None:
OUT_DIR.mkdir(parents=True, exist_ok=True)
path = fixture["path"]
screenshot = fixture["screenshot"]
pdf = fixture["pdf"]
assert isinstance(path, Path)
assert isinstance(screenshot, Path)
assert isinstance(pdf, Path)
fixture_uri = path.as_uri()
screenshot_result = run_browser(
browser,
[
"--window-size=1440,1100",
f"--screenshot={screenshot}",
fixture_uri,
],
)
if screenshot_result.returncode != 0:
raise RuntimeError(screenshot_result.stderr.strip() or "screenshot render failed")
assert_output(screenshot, int(fixture["min_png"]))
pdf_result = run_browser(
browser,
[
"--run-all-compositor-stages-before-draw",
f"--print-to-pdf={pdf}",
"--print-to-pdf-no-header",
fixture_uri,
],
)
if pdf_result.returncode != 0:
raise RuntimeError(pdf_result.stderr.strip() or "PDF render failed")
assert_output(pdf, int(fixture["min_pdf"]))
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--static-only", action="store_true", help="validate fixture markers without launching a browser")
parser.add_argument("--require-browser", action="store_true", help="fail when Chrome/Edge/Chromium is not available")
args = parser.parse_args()
try:
for fixture in FIXTURES:
validate_fixture_contract(fixture)
if args.static_only:
print(f"OK: visual quality fixture contracts ({len(FIXTURES)} fixtures)")
return 0
browser = find_browser()
if not browser:
message = "SKIP: Chrome/Edge/Chromium not found; fixture contract passed"
print(message)
return 2 if args.require_browser else 0
for fixture in FIXTURES:
render(browser, fixture)
screenshot = fixture["screenshot"]
pdf = fixture["pdf"]
assert isinstance(screenshot, Path)
assert isinstance(pdf, Path)
print(f"OK: rendered {screenshot.relative_to(ROOT)}")
print(f"OK: rendered {pdf.relative_to(ROOT)}")
return 0
except Exception as exc:
print(f"FAIL: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())