Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files
tests/infographic_regression/run.py
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Full-infographic regression for title width / clipping (Bug B).
|
| 3 |
+
|
| 4 |
+
This is intentionally NOT a title-only test. It runs the real
|
| 5 |
+
`infographics_generator.process()` path with a fixed input dataset and a fixed
|
| 6 |
+
chart template, then measures the final composed SVG in Chrome.
|
| 7 |
+
|
| 8 |
+
Comparison modes:
|
| 9 |
+
- after_A_legacy_font:
|
| 10 |
+
Current code with Bug A line-spacing fix, but font_metrics is monkey-
|
| 11 |
+
patched to the old Linux fallback behavior (missing Georgia/Times ->
|
| 12 |
+
LiberationSans). This simulates "After A, before Bug B".
|
| 13 |
+
- after_AB_current:
|
| 14 |
+
Current production behavior: font_metrics resolves via fontconfig and
|
| 15 |
+
SVG text alignment uses native text-anchor where possible.
|
| 16 |
+
|
| 17 |
+
Usage:
|
| 18 |
+
PYTHONPATH=. python tests/infographic_regression/run.py
|
| 19 |
+
|
| 20 |
+
Outputs:
|
| 21 |
+
tests/infographic_regression/results/<mode>/<case>/
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
import json
|
| 25 |
+
import os
|
| 26 |
+
import random
|
| 27 |
+
import shutil
|
| 28 |
+
import sys
|
| 29 |
+
import xml.etree.ElementTree as ET
|
| 30 |
+
from pathlib import Path
|
| 31 |
+
|
| 32 |
+
ROOT = Path(__file__).resolve().parents[2]
|
| 33 |
+
sys.path.insert(0, str(ROOT))
|
| 34 |
+
os.chdir(ROOT)
|
| 35 |
+
|
| 36 |
+
# Do not let the chart-type whitelist hide the fixed regression template.
|
| 37 |
+
os.environ["ALLOWED_CHART_TYPES_FILE"] = str(
|
| 38 |
+
ROOT / "tests" / "infographic_regression" / "_no_chart_whitelist.json"
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
import numpy as np
|
| 42 |
+
|
| 43 |
+
from modules.title_styler.infographic_title_generator import generate_title
|
| 44 |
+
from modules.infographics_generator.parse_utils import convert_svg_to_html
|
| 45 |
+
from modules.infographics_generator.screenshot_utils import get_driver
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
ET.register_namespace("", "http://www.w3.org/2000/svg")
|
| 49 |
+
RESULTS_ROOT = ROOT / "tests" / "infographic_regression" / "results"
|
| 50 |
+
RUN100_BAD_SVG = (
|
| 51 |
+
ROOT
|
| 52 |
+
/ "output"
|
| 53 |
+
/ "run100"
|
| 54 |
+
/ "1777364634_horizontal_group_bar_chart_03_Art_scenario_1_031622_28996"
|
| 55 |
+
/ "chart.svg"
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
CASES = [
|
| 59 |
+
{
|
| 60 |
+
"case_id": "techjobs_horizontal_group_bar_right_elegant_stack",
|
| 61 |
+
"input": ROOT / "test_input_100" / "Art_scenario_1_031622_28996.json",
|
| 62 |
+
"chart_name": "horizontal_group_bar_chart_03",
|
| 63 |
+
# This is the exact title template/segmentation shape observed in the
|
| 64 |
+
# bad run100 SVG:
|
| 65 |
+
# TECH JOB GROWTH IN (Arial 18, letter-spacing .15)
|
| 66 |
+
# Key U.S. Cities (Georgia 32 bold)
|
| 67 |
+
# long subtitle (Arial 17)
|
| 68 |
+
#
|
| 69 |
+
# It is intentionally full-infographic, because title-only rendering
|
| 70 |
+
# cannot prove whether the final outer SVG viewport is wide enough.
|
| 71 |
+
"title_template": "right_elegant_stack",
|
| 72 |
+
"title_override": "TECH JOB GROWTH IN\nKey U.S. Cities",
|
| 73 |
+
}
|
| 74 |
+
]
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def _legacy_sans_fallback_paths(self, font_family, font_weight, font_style):
|
| 78 |
+
is_bold = font_weight in ["bold", "bolder", "700", "800", "900"]
|
| 79 |
+
is_italic = font_style in ["italic", "oblique"]
|
| 80 |
+
if is_bold and is_italic:
|
| 81 |
+
lib = "LiberationSans-BoldItalic.ttf"
|
| 82 |
+
dejavu = "DejaVuSans-BoldOblique.ttf"
|
| 83 |
+
elif is_bold:
|
| 84 |
+
lib = "LiberationSans-Bold.ttf"
|
| 85 |
+
dejavu = "DejaVuSans-Bold.ttf"
|
| 86 |
+
elif is_italic:
|
| 87 |
+
lib = "LiberationSans-Italic.ttf"
|
| 88 |
+
dejavu = "DejaVuSans-Oblique.ttf"
|
| 89 |
+
else:
|
| 90 |
+
lib = "LiberationSans-Regular.ttf"
|
| 91 |
+
dejavu = "DejaVuSans.ttf"
|
| 92 |
+
return [
|
| 93 |
+
f"/usr/share/fonts/truetype/liberation2/{lib}",
|
| 94 |
+
f"/usr/share/fonts/truetype/liberation/{lib}",
|
| 95 |
+
f"/usr/share/fonts/truetype/dejavu/{dejavu}",
|
| 96 |
+
]
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def configure_font_mode(mode: str):
|
| 100 |
+
import modules.title_styler.font_metrics as fm
|
| 101 |
+
|
| 102 |
+
fm._metrics_instance = None
|
| 103 |
+
fm._FC_MATCH_CACHE.clear()
|
| 104 |
+
if mode == "after_A_legacy_font":
|
| 105 |
+
os.environ["TITLE_STYLER_NATIVE_TEXT_ANCHOR"] = "0"
|
| 106 |
+
fm.FontMetrics._fc_match = lambda self, font_family, font_weight, font_style: None
|
| 107 |
+
fm.FontMetrics._get_generic_fallback_paths = _legacy_sans_fallback_paths
|
| 108 |
+
else:
|
| 109 |
+
os.environ["TITLE_STYLER_NATIVE_TEXT_ANCHOR"] = "1"
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def make_deterministic_title_batch(case: dict):
|
| 113 |
+
def deterministic_title_styler_process_batch(
|
| 114 |
+
input=None,
|
| 115 |
+
input_data=None,
|
| 116 |
+
max_widths=None,
|
| 117 |
+
text_align="left",
|
| 118 |
+
background_color="#FFFFFF",
|
| 119 |
+
dark=False,
|
| 120 |
+
show_embellishment=True,
|
| 121 |
+
show_sub_title=True,
|
| 122 |
+
font_family=None,
|
| 123 |
+
return_meta=False,
|
| 124 |
+
):
|
| 125 |
+
if max_widths is None:
|
| 126 |
+
max_widths = [500]
|
| 127 |
+
|
| 128 |
+
data = input_data
|
| 129 |
+
title = data["titles"]["main_title"]
|
| 130 |
+
subtitle = data["titles"].get("sub_title") if show_sub_title else None
|
| 131 |
+
colors_key = "colors_dark" if dark else "colors"
|
| 132 |
+
colors = data[colors_key]["other"]
|
| 133 |
+
|
| 134 |
+
out = []
|
| 135 |
+
for max_width in max_widths:
|
| 136 |
+
results = generate_title(
|
| 137 |
+
title=case.get("title_override") or title,
|
| 138 |
+
description=subtitle,
|
| 139 |
+
primary_color=colors["primary"],
|
| 140 |
+
secondary_color=colors.get("secondary"),
|
| 141 |
+
background_color=background_color,
|
| 142 |
+
max_width=max_width,
|
| 143 |
+
alignment=text_align,
|
| 144 |
+
template_name=case["title_template"],
|
| 145 |
+
use_llm=False,
|
| 146 |
+
)
|
| 147 |
+
result = results[0]
|
| 148 |
+
meta = {
|
| 149 |
+
"template_name": result.get("template_name"),
|
| 150 |
+
"width": result.get("width"),
|
| 151 |
+
"height": result.get("height"),
|
| 152 |
+
"max_width_requested": max_width,
|
| 153 |
+
"text_align": text_align,
|
| 154 |
+
}
|
| 155 |
+
out.append((result["svg"], meta) if return_meta else result["svg"])
|
| 156 |
+
return out
|
| 157 |
+
|
| 158 |
+
return deterministic_title_styler_process_batch
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def locate_latest_subfolder(mode_dir: Path, case_id: str) -> Path:
|
| 162 |
+
candidates = [
|
| 163 |
+
p for p in mode_dir.iterdir()
|
| 164 |
+
if p.is_dir() and p.name.endswith(case_id)
|
| 165 |
+
]
|
| 166 |
+
return max(candidates, key=lambda p: p.stat().st_mtime)
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def measure_final_svg(driver, svg_path: Path) -> dict:
|
| 170 |
+
html_path = svg_path.with_suffix(".measure.html")
|
| 171 |
+
convert_svg_to_html(str(svg_path), str(html_path))
|
| 172 |
+
driver.get(f"file://{html_path.resolve()}")
|
| 173 |
+
return driver.execute_script(
|
| 174 |
+
"""
|
| 175 |
+
const svg = document.querySelector('svg');
|
| 176 |
+
const title = svg.querySelector('g[data-type="title"], g.text');
|
| 177 |
+
const root = svg.getBoundingClientRect();
|
| 178 |
+
const rel = (r) => ({
|
| 179 |
+
left: r.left - root.left,
|
| 180 |
+
right: r.right - root.left,
|
| 181 |
+
top: r.top - root.top,
|
| 182 |
+
bottom: r.bottom - root.top,
|
| 183 |
+
width: r.width,
|
| 184 |
+
height: r.height,
|
| 185 |
+
});
|
| 186 |
+
const titleRect = title ? rel(title.getBoundingClientRect()) : null;
|
| 187 |
+
const textRects = title ? [...title.querySelectorAll('text')].map(t => ({
|
| 188 |
+
text: t.textContent,
|
| 189 |
+
fontFamily: getComputedStyle(t).fontFamily,
|
| 190 |
+
fontSize: getComputedStyle(t).fontSize,
|
| 191 |
+
anchor: t.getAttribute('text-anchor'),
|
| 192 |
+
rect: rel(t.getBoundingClientRect()),
|
| 193 |
+
})) : [];
|
| 194 |
+
const maxRight = Math.max(...textRects.map(t => t.rect.right), titleRect ? titleRect.right : 0);
|
| 195 |
+
const minLeft = Math.min(...textRects.map(t => t.rect.left), titleRect ? titleRect.left : 0);
|
| 196 |
+
const maxBottom = Math.max(...textRects.map(t => t.rect.bottom), titleRect ? titleRect.bottom : 0);
|
| 197 |
+
const minTop = Math.min(...textRects.map(t => t.rect.top), titleRect ? titleRect.top : 0);
|
| 198 |
+
return {
|
| 199 |
+
svg: { width: root.width, height: root.height },
|
| 200 |
+
titleRect,
|
| 201 |
+
textRects,
|
| 202 |
+
titleBounds: { minLeft, maxRight, minTop, maxBottom },
|
| 203 |
+
overflow: {
|
| 204 |
+
left: Math.max(0, -minLeft),
|
| 205 |
+
right: Math.max(0, maxRight - root.width),
|
| 206 |
+
top: Math.max(0, -minTop),
|
| 207 |
+
bottom: Math.max(0, maxBottom - root.height),
|
| 208 |
+
},
|
| 209 |
+
};
|
| 210 |
+
"""
|
| 211 |
+
)
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def run_case(mode: str, case: dict, driver) -> dict:
|
| 215 |
+
random.seed(20260525)
|
| 216 |
+
np.random.seed(20260525)
|
| 217 |
+
configure_font_mode(mode)
|
| 218 |
+
|
| 219 |
+
import modules.infographics_generator.infographics_generator as ig
|
| 220 |
+
|
| 221 |
+
ig.title_styler_process_batch = make_deterministic_title_batch(case)
|
| 222 |
+
|
| 223 |
+
mode_dir = RESULTS_ROOT / mode
|
| 224 |
+
case_output = mode_dir / f"{case['case_id']}.svg"
|
| 225 |
+
case_output.parent.mkdir(parents=True, exist_ok=True)
|
| 226 |
+
ok = ig.process(
|
| 227 |
+
input=str(case["input"]),
|
| 228 |
+
output=str(case_output),
|
| 229 |
+
base_url="",
|
| 230 |
+
api_key="",
|
| 231 |
+
chart_name=case["chart_name"],
|
| 232 |
+
chart_only=False,
|
| 233 |
+
output_png=True,
|
| 234 |
+
)
|
| 235 |
+
subfolder = locate_latest_subfolder(mode_dir, case["case_id"])
|
| 236 |
+
final_svg = subfolder / "chart.svg"
|
| 237 |
+
final_png = subfolder / "chart.png"
|
| 238 |
+
final_info = subfolder / "info.json"
|
| 239 |
+
measure = measure_final_svg(driver, final_svg)
|
| 240 |
+
meta = {
|
| 241 |
+
"mode": mode,
|
| 242 |
+
"case_id": case["case_id"],
|
| 243 |
+
"ok": bool(ok),
|
| 244 |
+
"subfolder": str(subfolder.relative_to(ROOT)),
|
| 245 |
+
"svg": str(final_svg.relative_to(ROOT)),
|
| 246 |
+
"png": str(final_png.relative_to(ROOT)),
|
| 247 |
+
"info": json.loads(final_info.read_text()),
|
| 248 |
+
"measure": measure,
|
| 249 |
+
}
|
| 250 |
+
(subfolder / "regression_meta.json").write_text(
|
| 251 |
+
json.dumps(meta, indent=2, ensure_ascii=False),
|
| 252 |
+
encoding="utf-8",
|
| 253 |
+
)
|
| 254 |
+
return meta
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
def _fixed_run100_title_svg() -> str:
|
| 258 |
+
input_data = json.loads(CASES[0]["input"].read_text(encoding="utf-8"))
|
| 259 |
+
configure_font_mode("after_AB_current")
|
| 260 |
+
result = generate_title(
|
| 261 |
+
title=CASES[0]["title_override"],
|
| 262 |
+
description=input_data["titles"]["sub_title"],
|
| 263 |
+
primary_color="#ee5f51",
|
| 264 |
+
secondary_color=None,
|
| 265 |
+
background_color="#F2EDEE",
|
| 266 |
+
max_width=433,
|
| 267 |
+
alignment="right",
|
| 268 |
+
template_name=CASES[0]["title_template"],
|
| 269 |
+
use_llm=False,
|
| 270 |
+
)[0]
|
| 271 |
+
return result["svg"]
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
def run_run100_svg_fixture(driver) -> dict:
|
| 275 |
+
"""Full-SVG regression anchored on the real bad run100 artifact."""
|
| 276 |
+
fixture_dir = RESULTS_ROOT / "run100_final_svg_fixture"
|
| 277 |
+
fixture_dir.mkdir(parents=True, exist_ok=True)
|
| 278 |
+
|
| 279 |
+
before_svg = fixture_dir / "after_A_run100_bad.svg"
|
| 280 |
+
shutil.copyfile(RUN100_BAD_SVG, before_svg)
|
| 281 |
+
|
| 282 |
+
root = ET.parse(RUN100_BAD_SVG).getroot()
|
| 283 |
+
ns = {"svg": "http://www.w3.org/2000/svg"}
|
| 284 |
+
title_group = root.find(".//svg:g[@data-type='title']", ns)
|
| 285 |
+
assert title_group is not None, "run100 fixture must contain a title group"
|
| 286 |
+
for child in list(title_group):
|
| 287 |
+
title_group.remove(child)
|
| 288 |
+
|
| 289 |
+
fixed_title_root = ET.fromstring(_fixed_run100_title_svg())
|
| 290 |
+
for child in list(fixed_title_root):
|
| 291 |
+
title_group.append(child)
|
| 292 |
+
|
| 293 |
+
after_svg = fixture_dir / "after_AB_current_title_replaced.svg"
|
| 294 |
+
ET.ElementTree(root).write(after_svg, encoding="unicode")
|
| 295 |
+
|
| 296 |
+
before_meta = {
|
| 297 |
+
"mode": "after_A_run100_bad",
|
| 298 |
+
"svg": str(before_svg.relative_to(ROOT)),
|
| 299 |
+
"measure": measure_final_svg(driver, before_svg),
|
| 300 |
+
}
|
| 301 |
+
after_meta = {
|
| 302 |
+
"mode": "after_AB_current_title_replaced",
|
| 303 |
+
"svg": str(after_svg.relative_to(ROOT)),
|
| 304 |
+
"measure": measure_final_svg(driver, after_svg),
|
| 305 |
+
}
|
| 306 |
+
before_right = before_meta["measure"]["overflow"]["right"]
|
| 307 |
+
after_right = after_meta["measure"]["overflow"]["right"]
|
| 308 |
+
assert before_right > 100, f"run100 fixture should reproduce Bug B, got {before_right}px"
|
| 309 |
+
assert after_right <= 1, f"Bug B fix should remove right overflow, got {after_right}px"
|
| 310 |
+
meta = {
|
| 311 |
+
"case_id": "run100_real_bad_svg_title_replacement",
|
| 312 |
+
"modes": {
|
| 313 |
+
"after_A_run100_bad": before_meta,
|
| 314 |
+
"after_AB_current_title_replaced": after_meta,
|
| 315 |
+
},
|
| 316 |
+
}
|
| 317 |
+
(fixture_dir / "regression_meta.json").write_text(
|
| 318 |
+
json.dumps(meta, indent=2, ensure_ascii=False),
|
| 319 |
+
encoding="utf-8",
|
| 320 |
+
)
|
| 321 |
+
return meta
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
def main():
|
| 325 |
+
if RESULTS_ROOT.exists():
|
| 326 |
+
shutil.rmtree(RESULTS_ROOT)
|
| 327 |
+
RESULTS_ROOT.mkdir(parents=True)
|
| 328 |
+
|
| 329 |
+
driver = get_driver()
|
| 330 |
+
summary = {"cases": []}
|
| 331 |
+
for case in CASES:
|
| 332 |
+
case_summary = {"case_id": case["case_id"], "modes": {}}
|
| 333 |
+
for mode in ["after_A_legacy_font", "after_AB_current"]:
|
| 334 |
+
meta = run_case(mode, case, driver)
|
| 335 |
+
case_summary["modes"][mode] = meta
|
| 336 |
+
summary["cases"].append(case_summary)
|
| 337 |
+
summary["cases"].append(run_run100_svg_fixture(driver))
|
| 338 |
+
driver.quit()
|
| 339 |
+
|
| 340 |
+
(RESULTS_ROOT / "summary.json").write_text(
|
| 341 |
+
json.dumps(summary, indent=2, ensure_ascii=False),
|
| 342 |
+
encoding="utf-8",
|
| 343 |
+
)
|
| 344 |
+
|
| 345 |
+
for case_summary in summary["cases"]:
|
| 346 |
+
print(f"=== {case_summary['case_id']} ===")
|
| 347 |
+
for mode, meta in case_summary["modes"].items():
|
| 348 |
+
overflow = meta["measure"]["overflow"]
|
| 349 |
+
svg = meta["measure"]["svg"]
|
| 350 |
+
title_width = meta.get("info", {}).get("title_width", "-")
|
| 351 |
+
png = meta.get("png", "-")
|
| 352 |
+
print(
|
| 353 |
+
f"{mode:22s} svg={svg['width']:.0f}x{svg['height']:.0f} "
|
| 354 |
+
f"title_width={title_width} overflow={overflow} png={png}"
|
| 355 |
+
)
|
| 356 |
+
|
| 357 |
+
|
| 358 |
+
if __name__ == "__main__":
|
| 359 |
+
main()
|
tests/test_polisher_chart_internal_slots.py
ADDED
|
@@ -0,0 +1,415 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import sys
|
| 5 |
+
|
| 6 |
+
from lxml import etree
|
| 7 |
+
from PIL import Image
|
| 8 |
+
|
| 9 |
+
from modules.full_image_polisher.full_image_polisher import (
|
| 10 |
+
SLOT_GUIDED_PROMPT,
|
| 11 |
+
build_slot_guidance_artifacts,
|
| 12 |
+
collect_svg_slots,
|
| 13 |
+
polish_full_image,
|
| 14 |
+
)
|
| 15 |
+
from modules.slot_layout_planner.chart_sanitizer import is_chart_internal_slot, sanitize_chart_svg
|
| 16 |
+
from modules.slot_layout_planner.planner import ProtectedRegion, _collect_protected_regions, _slots_from_layout_payload
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
SYNTHETIC_SVG = """<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">
|
| 20 |
+
<g class="data-point" transform="translate(50,50)">
|
| 21 |
+
<circle class="point-mark" r="12" fill="#358"/>
|
| 22 |
+
<circle class="point-marker-interior asset-slot"
|
| 23 |
+
data-asset-slot="point-marker-interior"
|
| 24 |
+
data-slot-anchor="point-center"
|
| 25 |
+
data-collision-rule="clipped-within-marker-avoid-labels-axes"
|
| 26 |
+
r="7" fill="#fff"/>
|
| 27 |
+
</g>
|
| 28 |
+
<g class="asset-slot header-topic-art" data-asset-slot="header-topic-art" data-asset-bbox="6,6,20,20">
|
| 29 |
+
<rect x="6" y="6" width="20" height="20" fill="#ccc"/>
|
| 30 |
+
</g>
|
| 31 |
+
</svg>
|
| 32 |
+
"""
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def test_sanitizer_keeps_chart_internal_asset_slots(tmp_path):
|
| 36 |
+
input_svg = tmp_path / "input.svg"
|
| 37 |
+
output_svg = tmp_path / "sanitized.svg"
|
| 38 |
+
report_path = tmp_path / "report.json"
|
| 39 |
+
input_svg.write_text(SYNTHETIC_SVG, encoding="utf-8")
|
| 40 |
+
|
| 41 |
+
sanitize_chart_svg(input_svg, output_svg, report_path=report_path)
|
| 42 |
+
|
| 43 |
+
sanitized = output_svg.read_text(encoding="utf-8")
|
| 44 |
+
report = json.loads(report_path.read_text(encoding="utf-8"))
|
| 45 |
+
assert "point-marker-interior" in sanitized
|
| 46 |
+
assert "header-topic-art" not in sanitized
|
| 47 |
+
assert report["removed_by_reason"] == {"slot_metadata": 1}
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def test_sanitizer_keeps_descendants_inside_chart_internal_slot(tmp_path):
|
| 51 |
+
input_svg = tmp_path / "input.svg"
|
| 52 |
+
output_svg = tmp_path / "sanitized.svg"
|
| 53 |
+
input_svg.write_text(
|
| 54 |
+
"""<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
|
| 55 |
+
<g data-asset-slot="row-icon" data-bbox="10,10,20,20">
|
| 56 |
+
<path id="reserved-child-line" class="reserved-asset-slot-placeholder-line" d="M 10 10 L 20 20"/>
|
| 57 |
+
</g>
|
| 58 |
+
</svg>""",
|
| 59 |
+
encoding="utf-8",
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
sanitize_chart_svg(input_svg, output_svg)
|
| 63 |
+
|
| 64 |
+
sanitized = output_svg.read_text(encoding="utf-8")
|
| 65 |
+
assert "row-icon" in sanitized
|
| 66 |
+
assert "reserved-child-line" in sanitized
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def test_polisher_collects_chart_internal_slots(tmp_path):
|
| 70 |
+
input_svg = tmp_path / "input.svg"
|
| 71 |
+
input_png = tmp_path / "input.png"
|
| 72 |
+
input_svg.write_text(SYNTHETIC_SVG, encoding="utf-8")
|
| 73 |
+
Image.new("RGB", (100, 100), "white").save(input_png)
|
| 74 |
+
|
| 75 |
+
slots = collect_svg_slots(input_svg, input_png, slot_padding_px=0)
|
| 76 |
+
|
| 77 |
+
assert [slot.slot_name for slot in slots] == ["point-marker-interior", "header-topic-art"]
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def test_protected_regions_override_edit_mask(tmp_path):
|
| 81 |
+
input_png = tmp_path / "input.png"
|
| 82 |
+
planned_svg = tmp_path / "planned.svg"
|
| 83 |
+
reference_map = tmp_path / "reference_element_map.json"
|
| 84 |
+
mask_path = tmp_path / "slot_mask.png"
|
| 85 |
+
overlay_path = tmp_path / "slot_overlay.png"
|
| 86 |
+
prompt_path = tmp_path / "slot_prompt.json"
|
| 87 |
+
|
| 88 |
+
Image.new("RGB", (100, 100), "white").save(input_png)
|
| 89 |
+
planned_svg.write_text(
|
| 90 |
+
"""<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">
|
| 91 |
+
<g data-asset-slot="chart_overlay_callout" data-planned-element="callout" data-bbox="20,20,60,60">
|
| 92 |
+
<rect x="20" y="20" width="60" height="60"/>
|
| 93 |
+
</g>
|
| 94 |
+
</svg>""",
|
| 95 |
+
encoding="utf-8",
|
| 96 |
+
)
|
| 97 |
+
reference_map.write_text(
|
| 98 |
+
json.dumps(
|
| 99 |
+
{
|
| 100 |
+
"reserved_slots": [],
|
| 101 |
+
"protected_regions": [
|
| 102 |
+
{
|
| 103 |
+
"id": "PROTECT_01",
|
| 104 |
+
"reason": "preserve nearby chart label",
|
| 105 |
+
"bbox_px": [40, 40, 20, 20],
|
| 106 |
+
}
|
| 107 |
+
],
|
| 108 |
+
}
|
| 109 |
+
),
|
| 110 |
+
encoding="utf-8",
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
build_slot_guidance_artifacts(
|
| 114 |
+
input_png=input_png,
|
| 115 |
+
svg_path=planned_svg,
|
| 116 |
+
reference_map=reference_map,
|
| 117 |
+
mask_path=mask_path,
|
| 118 |
+
overlay_path=overlay_path,
|
| 119 |
+
prompt_path=prompt_path,
|
| 120 |
+
slot_padding_px=0,
|
| 121 |
+
prompt_template=SLOT_GUIDED_PROMPT,
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
with Image.open(mask_path) as mask:
|
| 125 |
+
rgba = mask.convert("RGBA")
|
| 126 |
+
assert rgba.getpixel((30, 30))[3] == 0
|
| 127 |
+
assert rgba.getpixel((50, 50))[3] == 255
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def test_chart_internal_reference_slots_are_added_to_edit_mask(tmp_path):
|
| 131 |
+
input_png = tmp_path / "input.png"
|
| 132 |
+
planned_svg = tmp_path / "planned.svg"
|
| 133 |
+
reference_map = tmp_path / "reference_element_map.json"
|
| 134 |
+
mask_path = tmp_path / "slot_mask.png"
|
| 135 |
+
overlay_path = tmp_path / "slot_overlay.png"
|
| 136 |
+
prompt_path = tmp_path / "slot_prompt.json"
|
| 137 |
+
|
| 138 |
+
Image.new("RGB", (100, 100), "white").save(input_png)
|
| 139 |
+
planned_svg.write_text(
|
| 140 |
+
"""<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">
|
| 141 |
+
<rect x="0" y="0" width="100" height="100"/>
|
| 142 |
+
</svg>""",
|
| 143 |
+
encoding="utf-8",
|
| 144 |
+
)
|
| 145 |
+
reference_map.write_text(
|
| 146 |
+
json.dumps(
|
| 147 |
+
{
|
| 148 |
+
"reserved_slots": [],
|
| 149 |
+
"chart_internal_slots": [
|
| 150 |
+
{
|
| 151 |
+
"id": "CHART_ICON_01",
|
| 152 |
+
"source_slot": "row-icon-1",
|
| 153 |
+
"bbox_px": [40, 40, 20, 20],
|
| 154 |
+
"semantic": "Generate a contained row icon.",
|
| 155 |
+
}
|
| 156 |
+
],
|
| 157 |
+
"protected_regions": [],
|
| 158 |
+
}
|
| 159 |
+
),
|
| 160 |
+
encoding="utf-8",
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
slots, _ = build_slot_guidance_artifacts(
|
| 164 |
+
input_png=input_png,
|
| 165 |
+
svg_path=planned_svg,
|
| 166 |
+
reference_map=reference_map,
|
| 167 |
+
mask_path=mask_path,
|
| 168 |
+
overlay_path=overlay_path,
|
| 169 |
+
prompt_path=prompt_path,
|
| 170 |
+
slot_padding_px=0,
|
| 171 |
+
prompt_template=SLOT_GUIDED_PROMPT,
|
| 172 |
+
)
|
| 173 |
+
|
| 174 |
+
assert [slot.slot_name for slot in slots] == ["row-icon-1"]
|
| 175 |
+
prompt = json.loads(prompt_path.read_text(encoding="utf-8"))
|
| 176 |
+
assert prompt["chart_internal_slot_count"] == 1
|
| 177 |
+
with Image.open(mask_path) as mask:
|
| 178 |
+
rgba = mask.convert("RGBA")
|
| 179 |
+
assert rgba.getpixel((50, 50))[3] == 0
|
| 180 |
+
assert rgba.getpixel((20, 20))[3] == 255
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def test_planner_collects_chart_internal_slot_regions(tmp_path):
|
| 184 |
+
input_svg = tmp_path / "input.svg"
|
| 185 |
+
input_png = tmp_path / "input.png"
|
| 186 |
+
input_svg.write_text(SYNTHETIC_SVG, encoding="utf-8")
|
| 187 |
+
Image.new("RGB", (100, 100), "white").save(input_png)
|
| 188 |
+
|
| 189 |
+
regions = _collect_protected_regions(input_svg, input_png, (100, 200, 500, 500), padding_px=0)
|
| 190 |
+
|
| 191 |
+
assert len(regions) == 1
|
| 192 |
+
assert regions[0].source_slot == "point-marker-interior"
|
| 193 |
+
assert regions[0].id == "CHART_ICON_01"
|
| 194 |
+
assert regions[0].bbox_px == (315, 415, 70, 70)
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def test_chart_internal_classifier_covers_common_template_icon_names():
|
| 198 |
+
protected_names = [
|
| 199 |
+
"x-category-logo",
|
| 200 |
+
"category_icon_slot",
|
| 201 |
+
"category-icon-badge",
|
| 202 |
+
"category_badge_symbol",
|
| 203 |
+
"measure-badge-icon",
|
| 204 |
+
"measure_pill_icon_slot",
|
| 205 |
+
"row-source-logo",
|
| 206 |
+
"row-icon",
|
| 207 |
+
"card-header-icon",
|
| 208 |
+
"item-icon",
|
| 209 |
+
"bar_icon_slot",
|
| 210 |
+
"row-badge",
|
| 211 |
+
"bubble_identity_icon_1",
|
| 212 |
+
"platform-icon",
|
| 213 |
+
"year-card-temporal-icon",
|
| 214 |
+
"rose-sector-texture",
|
| 215 |
+
"range-area-gap-annotation-icon",
|
| 216 |
+
"emissions-cue-endpoint",
|
| 217 |
+
]
|
| 218 |
+
editable_names = [
|
| 219 |
+
"header-topic-art",
|
| 220 |
+
"summary-card-icon",
|
| 221 |
+
"bottom-hero-image",
|
| 222 |
+
]
|
| 223 |
+
|
| 224 |
+
for name in protected_names:
|
| 225 |
+
elem = etree.Element("g", {"data-asset-slot": name})
|
| 226 |
+
assert is_chart_internal_slot(elem), name
|
| 227 |
+
|
| 228 |
+
for name in editable_names:
|
| 229 |
+
elem = etree.Element("g", {"data-asset-slot": name})
|
| 230 |
+
assert not is_chart_internal_slot(elem), name
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
def test_sanitizer_keeps_ambiguous_slot_metadata_by_default(tmp_path):
|
| 234 |
+
input_svg = tmp_path / "input.svg"
|
| 235 |
+
output_svg = tmp_path / "sanitized.svg"
|
| 236 |
+
report_path = tmp_path / "report.json"
|
| 237 |
+
input_svg.write_text(
|
| 238 |
+
"""<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
|
| 239 |
+
<g class="reserved-asset-slot" data-asset-slot="unknown-template-slot" data-bbox="10,10,20,20">
|
| 240 |
+
<rect x="10" y="10" width="20" height="20"/>
|
| 241 |
+
</g>
|
| 242 |
+
<g data-asset-slot="bottom-summary-icon" data-bbox="50,10,20,20">
|
| 243 |
+
<rect x="50" y="10" width="20" height="20"/>
|
| 244 |
+
</g>
|
| 245 |
+
</svg>""",
|
| 246 |
+
encoding="utf-8",
|
| 247 |
+
)
|
| 248 |
+
|
| 249 |
+
sanitize_chart_svg(input_svg, output_svg, report_path=report_path)
|
| 250 |
+
|
| 251 |
+
sanitized = output_svg.read_text(encoding="utf-8")
|
| 252 |
+
report = json.loads(report_path.read_text(encoding="utf-8"))
|
| 253 |
+
assert "unknown-template-slot" in sanitized
|
| 254 |
+
assert "bottom-summary-icon" not in sanitized
|
| 255 |
+
assert report["removed_by_reason"] == {"slot_metadata": 1}
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
def test_sanitizer_keeps_data_bearing_descendants_inside_editable_slot(tmp_path):
|
| 259 |
+
input_svg = tmp_path / "input.svg"
|
| 260 |
+
output_svg = tmp_path / "sanitized.svg"
|
| 261 |
+
report_path = tmp_path / "report.json"
|
| 262 |
+
input_svg.write_text(
|
| 263 |
+
"""<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
|
| 264 |
+
<g class="reserved-asset-slot bottom-summary-icon" data-asset-slot="bottom-summary-icon">
|
| 265 |
+
<rect class="value-node" x="10" y="10" width="20" height="20"
|
| 266 |
+
data-group="Retail" data-value="42"/>
|
| 267 |
+
</g>
|
| 268 |
+
<g class="reserved-asset-slot bottom-summary-icon" data-asset-slot="bottom-summary-icon">
|
| 269 |
+
<rect id="placeholder-only" x="50" y="10" width="20" height="20"/>
|
| 270 |
+
</g>
|
| 271 |
+
</svg>""",
|
| 272 |
+
encoding="utf-8",
|
| 273 |
+
)
|
| 274 |
+
|
| 275 |
+
sanitize_chart_svg(input_svg, output_svg, report_path=report_path)
|
| 276 |
+
|
| 277 |
+
sanitized = output_svg.read_text(encoding="utf-8")
|
| 278 |
+
report = json.loads(report_path.read_text(encoding="utf-8"))
|
| 279 |
+
assert "data-value=\"42\"" in sanitized
|
| 280 |
+
assert "placeholder-only" not in sanitized
|
| 281 |
+
assert report["removed_by_reason"] == {"slot_metadata": 1}
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
def test_gpt_layout_payload_validation_avoids_chart_internal_slots():
|
| 285 |
+
internal = ProtectedRegion(
|
| 286 |
+
id="CHART_ICON_01",
|
| 287 |
+
reason="chart-internal icon/asset slot",
|
| 288 |
+
bbox_px=(100, 100, 40, 40),
|
| 289 |
+
source_slot="row-icon-1",
|
| 290 |
+
)
|
| 291 |
+
|
| 292 |
+
slots, background, meta = _slots_from_layout_payload(
|
| 293 |
+
{
|
| 294 |
+
"background": "#FFFFFF",
|
| 295 |
+
"design_rationale": "test",
|
| 296 |
+
"slots": [
|
| 297 |
+
{
|
| 298 |
+
"id": "callout",
|
| 299 |
+
"kind": "callout",
|
| 300 |
+
"bbox_px": [95, 95, 60, 40],
|
| 301 |
+
"may_overlap_chart": True,
|
| 302 |
+
"element": "Generate a callout.",
|
| 303 |
+
"anchor": "near chart",
|
| 304 |
+
"collision_rule": "avoid row icon",
|
| 305 |
+
"asset_source_policy": "llm_generated_text_and_shape",
|
| 306 |
+
"slot_requirements": ["preserve chart values"],
|
| 307 |
+
}
|
| 308 |
+
],
|
| 309 |
+
},
|
| 310 |
+
canvas_w=300,
|
| 311 |
+
canvas_h=300,
|
| 312 |
+
chart_bbox=(40, 40, 220, 180),
|
| 313 |
+
allow_chart_overlap=True,
|
| 314 |
+
chart_internal_slots=[internal],
|
| 315 |
+
)
|
| 316 |
+
|
| 317 |
+
assert background == "#FFFFFF"
|
| 318 |
+
assert len(slots) == 1
|
| 319 |
+
assert slots[0].bbox_px != (95, 95, 60, 40)
|
| 320 |
+
assert meta["validation_notes"][0]["reason"] == "nudged clear of chart_internal_slots"
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
def test_pinco_backend_dry_run_builds_inpaint_artifacts(tmp_path):
|
| 324 |
+
input_png = tmp_path / "input.png"
|
| 325 |
+
planned_svg = tmp_path / "planned.svg"
|
| 326 |
+
output_png = tmp_path / "pinco_output.png"
|
| 327 |
+
|
| 328 |
+
Image.new("RGB", (100, 100), "white").save(input_png)
|
| 329 |
+
planned_svg.write_text(
|
| 330 |
+
"""<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">
|
| 331 |
+
<g data-asset-slot="planned-icon" data-bbox="20,20,30,30">
|
| 332 |
+
<rect x="20" y="20" width="30" height="30"/>
|
| 333 |
+
</g>
|
| 334 |
+
</svg>""",
|
| 335 |
+
encoding="utf-8",
|
| 336 |
+
)
|
| 337 |
+
|
| 338 |
+
manifest = polish_full_image(
|
| 339 |
+
input_png=input_png,
|
| 340 |
+
output_png=output_png,
|
| 341 |
+
api_key=None,
|
| 342 |
+
base_url=None,
|
| 343 |
+
svg_path=planned_svg,
|
| 344 |
+
slot_guided=True,
|
| 345 |
+
dry_run=True,
|
| 346 |
+
image_backend="pinco",
|
| 347 |
+
slot_padding_px=0,
|
| 348 |
+
)
|
| 349 |
+
|
| 350 |
+
assert manifest["image_backend"] == "pinco"
|
| 351 |
+
assert manifest["model"] == "pinco"
|
| 352 |
+
with Image.open(manifest["pinco_mask"]) as mask:
|
| 353 |
+
mask_l = mask.convert("L")
|
| 354 |
+
assert mask_l.getpixel((25, 25)) == 255
|
| 355 |
+
assert mask_l.getpixel((10, 10)) == 0
|
| 356 |
+
with Image.open(manifest["pinco_foreground"]) as foreground:
|
| 357 |
+
rgba = foreground.convert("RGBA")
|
| 358 |
+
assert rgba.getpixel((25, 25))[3] == 0
|
| 359 |
+
assert rgba.getpixel((10, 10))[3] == 255
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
def test_pinco_command_backend_runs_slot_polish_without_openai_key(tmp_path):
|
| 363 |
+
input_png = tmp_path / "input.png"
|
| 364 |
+
planned_svg = tmp_path / "planned.svg"
|
| 365 |
+
output_png = tmp_path / "pinco_output.png"
|
| 366 |
+
stub = tmp_path / "pinco_stub.py"
|
| 367 |
+
|
| 368 |
+
Image.new("RGB", (100, 100), "white").save(input_png)
|
| 369 |
+
planned_svg.write_text(
|
| 370 |
+
"""<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">
|
| 371 |
+
<g data-asset-slot="planned-icon" data-bbox="20,20,30,30">
|
| 372 |
+
<rect x="20" y="20" width="30" height="30"/>
|
| 373 |
+
</g>
|
| 374 |
+
</svg>""",
|
| 375 |
+
encoding="utf-8",
|
| 376 |
+
)
|
| 377 |
+
stub.write_text(
|
| 378 |
+
"""import argparse
|
| 379 |
+
from PIL import Image, ImageDraw
|
| 380 |
+
|
| 381 |
+
parser = argparse.ArgumentParser()
|
| 382 |
+
parser.add_argument("--input", required=True)
|
| 383 |
+
parser.add_argument("--mask", required=True)
|
| 384 |
+
parser.add_argument("--prompt-file", required=True)
|
| 385 |
+
parser.add_argument("--output", required=True)
|
| 386 |
+
args = parser.parse_args()
|
| 387 |
+
with Image.open(args.input) as image:
|
| 388 |
+
out = image.convert("RGBA")
|
| 389 |
+
draw = ImageDraw.Draw(out)
|
| 390 |
+
draw.rectangle((20, 20, 50, 50), fill=(12, 34, 56, 255))
|
| 391 |
+
out.save(args.output)
|
| 392 |
+
""",
|
| 393 |
+
encoding="utf-8",
|
| 394 |
+
)
|
| 395 |
+
|
| 396 |
+
manifest = polish_full_image(
|
| 397 |
+
input_png=input_png,
|
| 398 |
+
output_png=output_png,
|
| 399 |
+
api_key=None,
|
| 400 |
+
base_url=None,
|
| 401 |
+
svg_path=planned_svg,
|
| 402 |
+
slot_guided=True,
|
| 403 |
+
dry_run=False,
|
| 404 |
+
resize_to_input=True,
|
| 405 |
+
image_backend="pinco",
|
| 406 |
+
pinco_command=f"{sys.executable} {stub} --input {{input}} --mask {{mask}} --prompt-file {{prompt_file}} --output {{output}}",
|
| 407 |
+
slot_padding_px=0,
|
| 408 |
+
)
|
| 409 |
+
|
| 410 |
+
assert manifest["image_backend"] == "pinco"
|
| 411 |
+
assert manifest["pinco_call_mode"] == "command"
|
| 412 |
+
assert output_png.is_file()
|
| 413 |
+
with Image.open(output_png) as output:
|
| 414 |
+
assert output.size == (100, 100)
|
| 415 |
+
assert output.convert("RGBA").getpixel((25, 25)) == (12, 34, 56, 255)
|
tests/title_styler_regression/run.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
title_styler 回归测试集 driver。
|
| 3 |
+
|
| 4 |
+
特性:
|
| 5 |
+
- 不调 LLM (use_llm=False, template_name= 指定) → 完全 deterministic
|
| 6 |
+
- 一次跑遍 PROBLEMATIC_CASES + FULL_TEMPLATE_TEST_CASES 30+ 个 case
|
| 7 |
+
- 同时输出 SVG (styler 直接产物) 和 PNG (Selenium 渲染, overlap-fix 关闭)
|
| 8 |
+
- PNG 用同一渲染管线 (modules.infographics_generator.screenshot_utils) → 跟最终产物一致
|
| 9 |
+
- 输出按 run_name 分桶,方便 baseline / after_fix 对比
|
| 10 |
+
|
| 11 |
+
用法:
|
| 12 |
+
PYTHONPATH=. python tests/title_styler_regression/run.py --run baseline
|
| 13 |
+
# 改完代码再跑:
|
| 14 |
+
PYTHONPATH=. python tests/title_styler_regression/run.py --run after_fix
|
| 15 |
+
# 对比:
|
| 16 |
+
PYTHONPATH=. python tests/title_styler_regression/run.py --diff baseline after_fix
|
| 17 |
+
|
| 18 |
+
输出:
|
| 19 |
+
tests/title_styler_regression/results/<run>/<case_id>/{title.svg, title.png, meta.json}
|
| 20 |
+
tests/title_styler_regression/results/<run>/summary.json
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
import argparse
|
| 24 |
+
import json
|
| 25 |
+
import os
|
| 26 |
+
import re
|
| 27 |
+
import shutil
|
| 28 |
+
import sys
|
| 29 |
+
import time
|
| 30 |
+
|
| 31 |
+
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
| 32 |
+
sys.path.insert(0, ROOT)
|
| 33 |
+
os.chdir(ROOT)
|
| 34 |
+
|
| 35 |
+
from modules.title_styler.test_cases import PROBLEMATIC_CASES, FULL_TEMPLATE_TEST_CASES
|
| 36 |
+
from modules.title_styler.infographic_title_generator import generate_title
|
| 37 |
+
from modules.infographics_generator.screenshot_utils import get_driver, take_screenshot
|
| 38 |
+
from modules.infographics_generator.parse_utils import convert_svg_to_html
|
| 39 |
+
|
| 40 |
+
RESULTS_ROOT = os.path.join("tests", "title_styler_regression", "results")
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _safe_id(s: str) -> str:
|
| 44 |
+
return re.sub(r"[^a-zA-Z0-9._-]+", "_", s)[:80]
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _font_size_histogram(svg_text: str) -> dict:
|
| 48 |
+
hist = {}
|
| 49 |
+
for s in re.findall(r"font-size[=:]\s*[\"']?(\d+(?:\.\d+)?)", svg_text):
|
| 50 |
+
hist[s] = hist.get(s, 0) + 1
|
| 51 |
+
return hist
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def collect_cases():
|
| 55 |
+
"""合并去重;每个 case 用 (test_id + template_name + 前 30 char title) 作为 key。"""
|
| 56 |
+
seen = set()
|
| 57 |
+
out = []
|
| 58 |
+
for src_name, src in [
|
| 59 |
+
("problematic", PROBLEMATIC_CASES),
|
| 60 |
+
("full", FULL_TEMPLATE_TEST_CASES),
|
| 61 |
+
]:
|
| 62 |
+
for c in src:
|
| 63 |
+
key = (c["test_id"], c["template_name"], c["title_segments"][:30])
|
| 64 |
+
if key in seen:
|
| 65 |
+
continue
|
| 66 |
+
seen.add(key)
|
| 67 |
+
c2 = dict(c)
|
| 68 |
+
c2["__source__"] = src_name
|
| 69 |
+
out.append(c2)
|
| 70 |
+
return out
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def render_one(driver, case, run_dir):
|
| 74 |
+
case_id = _safe_id(f'{case["template_name"]}__{case["test_id"]}')
|
| 75 |
+
case_dir = os.path.join(run_dir, case_id)
|
| 76 |
+
os.makedirs(case_dir, exist_ok=True)
|
| 77 |
+
|
| 78 |
+
t0 = time.time()
|
| 79 |
+
results = generate_title(
|
| 80 |
+
title=case["title_segments"],
|
| 81 |
+
description=case["description"],
|
| 82 |
+
primary_color=case["primary_color"],
|
| 83 |
+
background_color=case["background_color"],
|
| 84 |
+
template_name=case["template_name"],
|
| 85 |
+
max_width=900,
|
| 86 |
+
use_llm=False,
|
| 87 |
+
)
|
| 88 |
+
styler_ms = (time.time() - t0) * 1000
|
| 89 |
+
|
| 90 |
+
if not results:
|
| 91 |
+
print(f" FAIL no-svg: {case_id}", flush=True)
|
| 92 |
+
return {
|
| 93 |
+
"case_id": case_id,
|
| 94 |
+
"ok": False,
|
| 95 |
+
"reason": "generate_title returned empty",
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
r = results[0]
|
| 99 |
+
svg_text = r["svg"]
|
| 100 |
+
svg_path = os.path.join(case_dir, "title.svg")
|
| 101 |
+
with open(svg_path, "w", encoding="utf-8") as f:
|
| 102 |
+
f.write(svg_text)
|
| 103 |
+
|
| 104 |
+
html_path = os.path.join(case_dir, "render.html")
|
| 105 |
+
convert_svg_to_html(svg_path, html_path)
|
| 106 |
+
t1 = time.time()
|
| 107 |
+
take_screenshot(driver, html_path, resolve_text_overlap=False)
|
| 108 |
+
render_ms = (time.time() - t1) * 1000
|
| 109 |
+
rendered_png = html_path.replace(".html", ".png")
|
| 110 |
+
final_png = os.path.join(case_dir, "title.png")
|
| 111 |
+
shutil.move(rendered_png, final_png)
|
| 112 |
+
|
| 113 |
+
meta = {
|
| 114 |
+
"case_id": case_id,
|
| 115 |
+
"ok": True,
|
| 116 |
+
"test_id": case["test_id"],
|
| 117 |
+
"template_name": case["template_name"],
|
| 118 |
+
"title_segments": case["title_segments"],
|
| 119 |
+
"description": case["description"],
|
| 120 |
+
"primary_color": case["primary_color"],
|
| 121 |
+
"background_color": case["background_color"],
|
| 122 |
+
"styler_ms": round(styler_ms, 1),
|
| 123 |
+
"render_ms": round(render_ms, 1),
|
| 124 |
+
"svg_width": r.get("width"),
|
| 125 |
+
"svg_height": r.get("height"),
|
| 126 |
+
"font_size_hist": _font_size_histogram(svg_text),
|
| 127 |
+
"source_suite": case["__source__"],
|
| 128 |
+
}
|
| 129 |
+
with open(os.path.join(case_dir, "meta.json"), "w", encoding="utf-8") as f:
|
| 130 |
+
json.dump(meta, f, indent=2, ensure_ascii=False)
|
| 131 |
+
print(
|
| 132 |
+
f" OK {case_id:60s} {r['width']}x{r['height']}px "
|
| 133 |
+
f"styler={styler_ms:.0f}ms render={render_ms:.0f}ms",
|
| 134 |
+
flush=True,
|
| 135 |
+
)
|
| 136 |
+
return meta
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def run(run_name: str, only: str = None):
|
| 140 |
+
cases = collect_cases()
|
| 141 |
+
if only:
|
| 142 |
+
cases = [c for c in cases if only in c["template_name"] or only in c["test_id"]]
|
| 143 |
+
if not cases:
|
| 144 |
+
print("no cases matched", flush=True)
|
| 145 |
+
return
|
| 146 |
+
|
| 147 |
+
run_dir = os.path.join(RESULTS_ROOT, run_name)
|
| 148 |
+
if os.path.exists(run_dir):
|
| 149 |
+
print(f"clearing {run_dir}", flush=True)
|
| 150 |
+
shutil.rmtree(run_dir)
|
| 151 |
+
os.makedirs(run_dir, exist_ok=True)
|
| 152 |
+
|
| 153 |
+
print(f"=== run '{run_name}' on {len(cases)} cases ===", flush=True)
|
| 154 |
+
driver = get_driver()
|
| 155 |
+
metas = []
|
| 156 |
+
for i, case in enumerate(cases, 1):
|
| 157 |
+
print(f"[{i}/{len(cases)}]", flush=True)
|
| 158 |
+
meta = render_one(driver, case, run_dir)
|
| 159 |
+
metas.append(meta)
|
| 160 |
+
driver.quit()
|
| 161 |
+
|
| 162 |
+
summary = {
|
| 163 |
+
"run_name": run_name,
|
| 164 |
+
"n_cases": len(metas),
|
| 165 |
+
"n_ok": sum(1 for m in metas if m.get("ok")),
|
| 166 |
+
"n_fail": sum(1 for m in metas if not m.get("ok")),
|
| 167 |
+
"cases": metas,
|
| 168 |
+
}
|
| 169 |
+
with open(os.path.join(run_dir, "summary.json"), "w", encoding="utf-8") as f:
|
| 170 |
+
json.dump(summary, f, indent=2, ensure_ascii=False)
|
| 171 |
+
print(
|
| 172 |
+
f"\n=== run '{run_name}' done. ok={summary['n_ok']} fail={summary['n_fail']} ===",
|
| 173 |
+
flush=True,
|
| 174 |
+
)
|
| 175 |
+
print(f"results in: {run_dir}/")
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def diff(run_a: str, run_b: str):
|
| 179 |
+
dir_a = os.path.join(RESULTS_ROOT, run_a)
|
| 180 |
+
dir_b = os.path.join(RESULTS_ROOT, run_b)
|
| 181 |
+
sa = json.load(open(os.path.join(dir_a, "summary.json")))
|
| 182 |
+
sb = json.load(open(os.path.join(dir_b, "summary.json")))
|
| 183 |
+
map_a = {c["case_id"]: c for c in sa["cases"]}
|
| 184 |
+
map_b = {c["case_id"]: c for c in sb["cases"]}
|
| 185 |
+
all_ids = sorted(set(map_a) | set(map_b))
|
| 186 |
+
print(f"=== diff {run_a} vs {run_b} ===")
|
| 187 |
+
print(
|
| 188 |
+
f"{'case':70s} {'svg WxH (a)':>18s} {'svg WxH (b)':>18s} {'dh':>5s} {'fonts a/b'}"
|
| 189 |
+
)
|
| 190 |
+
for cid in all_ids:
|
| 191 |
+
a, b = map_a.get(cid), map_b.get(cid)
|
| 192 |
+
if a and a.get("ok") and b and b.get("ok"):
|
| 193 |
+
wa, ha = a["svg_width"], a["svg_height"]
|
| 194 |
+
wb, hb = b["svg_width"], b["svg_height"]
|
| 195 |
+
dh = hb - ha
|
| 196 |
+
fa = sorted(a["font_size_hist"].keys(), key=float)
|
| 197 |
+
fb = sorted(b["font_size_hist"].keys(), key=float)
|
| 198 |
+
flag = "" if dh == 0 else (" *" if abs(dh) >= 4 else "")
|
| 199 |
+
print(
|
| 200 |
+
f"{cid:70s} {wa}x{ha:>4d} {wb}x{hb:>4d} {dh:+5d} "
|
| 201 |
+
f"{','.join(fa)} / {','.join(fb)}{flag}"
|
| 202 |
+
)
|
| 203 |
+
else:
|
| 204 |
+
print(f"{cid:70s} (missing or failed in one run)")
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def main():
|
| 208 |
+
ap = argparse.ArgumentParser()
|
| 209 |
+
g = ap.add_mutually_exclusive_group(required=True)
|
| 210 |
+
g.add_argument("--run", help="run a regression pass with this name (e.g. baseline, after_fix)")
|
| 211 |
+
g.add_argument("--diff", nargs=2, metavar=("RUN_A", "RUN_B"), help="diff two runs")
|
| 212 |
+
ap.add_argument("--only", help="filter cases whose template_name or test_id contains this substring")
|
| 213 |
+
args = ap.parse_args()
|
| 214 |
+
if args.run:
|
| 215 |
+
run(args.run, only=args.only)
|
| 216 |
+
else:
|
| 217 |
+
diff(args.diff[0], args.diff[1])
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
if __name__ == "__main__":
|
| 221 |
+
main()
|
tests/title_styler_regression/viewer.py
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
为 title_styler 回归测试结果生成一个零依赖前端 (viewer.html)。
|
| 3 |
+
|
| 4 |
+
工作流:
|
| 5 |
+
PYTHONPATH=. python tests/title_styler_regression/run.py --run baseline
|
| 6 |
+
PYTHONPATH=. python tests/title_styler_regression/run.py --run after_fix
|
| 7 |
+
PYTHONPATH=. python tests/title_styler_regression/viewer.py
|
| 8 |
+
# 然后浏览器打开 tests/title_styler_regression/viewer.html
|
| 9 |
+
|
| 10 |
+
特性:
|
| 11 |
+
- 扫描 results/<run>/summary.json 自动发现所有 run
|
| 12 |
+
- 顶部两个 dropdown 选 左/右 run, 一键切换
|
| 13 |
+
- 每个 case 一行: 左 PNG | 右 PNG | meta (尺寸、字号、dh)
|
| 14 |
+
- 支持按 |dh| 排序、按模板名 / test_id 过滤
|
| 15 |
+
- 仅生成一个 HTML 文件 (相对路径引用 PNG), file:// 协议可直接打开
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
import base64
|
| 19 |
+
import json
|
| 20 |
+
import os
|
| 21 |
+
import sys
|
| 22 |
+
|
| 23 |
+
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
| 24 |
+
HERE = os.path.abspath(os.path.dirname(__file__))
|
| 25 |
+
RESULTS_ROOT = os.path.join(HERE, "results")
|
| 26 |
+
OUT_HTML = os.path.join(HERE, "viewer.html")
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def discover_runs():
|
| 30 |
+
runs = {}
|
| 31 |
+
if not os.path.isdir(RESULTS_ROOT):
|
| 32 |
+
return runs
|
| 33 |
+
for entry in sorted(os.listdir(RESULTS_ROOT)):
|
| 34 |
+
run_dir = os.path.join(RESULTS_ROOT, entry)
|
| 35 |
+
summary_path = os.path.join(run_dir, "summary.json")
|
| 36 |
+
if not os.path.isfile(summary_path):
|
| 37 |
+
continue
|
| 38 |
+
with open(summary_path, encoding="utf-8") as f:
|
| 39 |
+
summary = json.load(f)
|
| 40 |
+
cases = {}
|
| 41 |
+
for c in summary.get("cases", []):
|
| 42 |
+
cid = c.get("case_id")
|
| 43 |
+
if not cid:
|
| 44 |
+
continue
|
| 45 |
+
png_rel = os.path.relpath(
|
| 46 |
+
os.path.join(run_dir, cid, "title.png"), start=HERE
|
| 47 |
+
)
|
| 48 |
+
svg_rel = os.path.relpath(
|
| 49 |
+
os.path.join(run_dir, cid, "title.svg"), start=HERE
|
| 50 |
+
)
|
| 51 |
+
png_exists = os.path.isfile(os.path.join(HERE, png_rel))
|
| 52 |
+
cases[cid] = {
|
| 53 |
+
"ok": c.get("ok", False),
|
| 54 |
+
"reason": c.get("reason"),
|
| 55 |
+
"png": png_rel if png_exists else None,
|
| 56 |
+
"svg": svg_rel,
|
| 57 |
+
"w": c.get("svg_width"),
|
| 58 |
+
"h": c.get("svg_height"),
|
| 59 |
+
"fonts": c.get("font_size_hist", {}),
|
| 60 |
+
"test_id": c.get("test_id"),
|
| 61 |
+
"template": c.get("template_name"),
|
| 62 |
+
"title": c.get("title_segments"),
|
| 63 |
+
"description": c.get("description"),
|
| 64 |
+
}
|
| 65 |
+
runs[entry] = {
|
| 66 |
+
"n_ok": summary.get("n_ok", 0),
|
| 67 |
+
"n_fail": summary.get("n_fail", 0),
|
| 68 |
+
"cases": cases,
|
| 69 |
+
}
|
| 70 |
+
return runs
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
HTML_TEMPLATE = """<!DOCTYPE html>
|
| 74 |
+
<html lang="zh">
|
| 75 |
+
<head>
|
| 76 |
+
<meta charset="UTF-8">
|
| 77 |
+
<title>title_styler regression viewer</title>
|
| 78 |
+
<style>
|
| 79 |
+
* { box-sizing: border-box; }
|
| 80 |
+
body { margin: 0; font: 13px/1.4 -apple-system, BlinkMacSystemFont, sans-serif; background: #f6f7f9; color: #222; }
|
| 81 |
+
header { position: sticky; top: 0; background: #1f2937; color: #fff; padding: 10px 16px; z-index: 10;
|
| 82 |
+
box-shadow: 0 1px 4px rgba(0,0,0,.15); }
|
| 83 |
+
header h1 { font-size: 14px; margin: 0 0 6px; font-weight: 600; letter-spacing: .3px; }
|
| 84 |
+
.controls { display: flex; gap: 14px; flex-wrap: wrap; align-items: center; font-size: 12px; }
|
| 85 |
+
.controls label { color: #cbd5e1; }
|
| 86 |
+
select, input[type=text] { background: #374151; color: #fff; border: 1px solid #4b5563;
|
| 87 |
+
border-radius: 4px; padding: 4px 8px; font: inherit; }
|
| 88 |
+
select:focus, input:focus { outline: 2px solid #60a5fa; }
|
| 89 |
+
.badge { background: #374151; padding: 2px 8px; border-radius: 10px; color: #e5e7eb; }
|
| 90 |
+
.badge.up { background: #14532d; color: #bbf7d0; }
|
| 91 |
+
.badge.down { background: #7f1d1d; color: #fecaca; }
|
| 92 |
+
.badge.zero { background: #374151; color: #9ca3af; }
|
| 93 |
+
main { padding: 14px; }
|
| 94 |
+
.summary { background: #fff; padding: 10px 14px; border-radius: 6px; margin-bottom: 12px;
|
| 95 |
+
box-shadow: 0 1px 2px rgba(0,0,0,.06); font-size: 12px; color: #4b5563;
|
| 96 |
+
display: flex; gap: 18px; flex-wrap: wrap; }
|
| 97 |
+
.row { background: #fff; border-radius: 6px; margin-bottom: 12px; padding: 10px;
|
| 98 |
+
box-shadow: 0 1px 2px rgba(0,0,0,.06); }
|
| 99 |
+
.row.changed { box-shadow: 0 0 0 2px #fbbf24, 0 1px 2px rgba(0,0,0,.06); }
|
| 100 |
+
.row-head { display: flex; gap: 10px; align-items: center; font-weight: 600; margin-bottom: 8px;
|
| 101 |
+
padding-bottom: 6px; border-bottom: 1px solid #e5e7eb; flex-wrap: wrap; }
|
| 102 |
+
.row-head .case-id { color: #1f2937; font-size: 13px; }
|
| 103 |
+
.row-head .pill { font-size: 11px; padding: 1px 6px; border-radius: 8px;
|
| 104 |
+
background: #eef2ff; color: #3730a3; font-weight: 500; }
|
| 105 |
+
.row-head .pill.tpl { background: #ecfeff; color: #155e75; }
|
| 106 |
+
.row-meta { font-size: 11px; color: #6b7280; font-weight: 400; }
|
| 107 |
+
.pair { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
| 108 |
+
.panel { background: #fafbfc; border: 1px solid #e5e7eb; border-radius: 4px; padding: 8px; }
|
| 109 |
+
.panel .head { font-size: 11px; color: #6b7280; margin-bottom: 6px; display: flex; justify-content: space-between; }
|
| 110 |
+
.panel img { display: block; max-width: 100%; height: auto;
|
| 111 |
+
background-image: repeating-conic-gradient(#f3f4f6 0 25%, #fff 0 50%);
|
| 112 |
+
background-size: 16px 16px; }
|
| 113 |
+
.panel.missing { background: #fef2f2; border-color: #fecaca; color: #991b1b; padding: 18px; text-align: center; }
|
| 114 |
+
.fonts { font-family: ui-monospace, SF Mono, Menlo, monospace; font-size: 11px; color: #1f2937; }
|
| 115 |
+
details { margin-top: 6px; }
|
| 116 |
+
details > summary { cursor: pointer; color: #4b5563; font-size: 11px; }
|
| 117 |
+
details pre { background: #f3f4f6; padding: 6px 8px; border-radius: 4px; overflow: auto;
|
| 118 |
+
font: 11px ui-monospace, monospace; margin: 6px 0 0; }
|
| 119 |
+
.empty { text-align: center; color: #6b7280; padding: 60px; }
|
| 120 |
+
</style>
|
| 121 |
+
</head>
|
| 122 |
+
<body>
|
| 123 |
+
<header>
|
| 124 |
+
<h1>title_styler regression viewer</h1>
|
| 125 |
+
<div class="controls">
|
| 126 |
+
<label>left run <select id="runA"></select></label>
|
| 127 |
+
<label>right run <select id="runB"></select></label>
|
| 128 |
+
<label>filter <input type="text" id="filter" placeholder="template / test_id"></label>
|
| 129 |
+
<label>sort <select id="sort">
|
| 130 |
+
<option value="abs_dh_desc">|dh| desc (changed first)</option>
|
| 131 |
+
<option value="dh_desc">dh desc</option>
|
| 132 |
+
<option value="dh_asc">dh asc</option>
|
| 133 |
+
<option value="case_id">case_id</option>
|
| 134 |
+
</select></label>
|
| 135 |
+
<label><input type="checkbox" id="onlyChanged"> only changed (|dh|≥1)</label>
|
| 136 |
+
<span class="badge" id="counts"></span>
|
| 137 |
+
</div>
|
| 138 |
+
</header>
|
| 139 |
+
<main>
|
| 140 |
+
<div class="summary" id="summary"></div>
|
| 141 |
+
<div id="grid"></div>
|
| 142 |
+
</main>
|
| 143 |
+
|
| 144 |
+
<script>
|
| 145 |
+
const RUNS = __RUNS_JSON__;
|
| 146 |
+
|
| 147 |
+
function caseIds(run) {
|
| 148 |
+
return run ? Object.keys(run.cases) : [];
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
function pickDefaults() {
|
| 152 |
+
const names = Object.keys(RUNS);
|
| 153 |
+
if (names.length === 0) return [null, null];
|
| 154 |
+
if (names.length === 1) return [names[0], names[0]];
|
| 155 |
+
const afterAIdx = names.indexOf('after_fix');
|
| 156 |
+
const preferredB = [
|
| 157 |
+
'after_fix_linegap_tune',
|
| 158 |
+
'after_fix_bg_width',
|
| 159 |
+
'after_fix_desc_gap',
|
| 160 |
+
'after_fix_AB_anchor',
|
| 161 |
+
'after_fix_AB',
|
| 162 |
+
].find(n => names.includes(n));
|
| 163 |
+
const preferredBIdx = preferredB ? names.indexOf(preferredB) : -1;
|
| 164 |
+
const a = afterAIdx >= 0 ? names[afterAIdx] : names[0];
|
| 165 |
+
const b = preferredBIdx >= 0 ? names[preferredBIdx] : names[1];
|
| 166 |
+
return [a, b];
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
function fontsHistToString(h) {
|
| 170 |
+
if (!h) return '';
|
| 171 |
+
return Object.keys(h).sort((a,b)=>parseFloat(a)-parseFloat(b)).map(k => `${k}×${h[k]}`).join(' ');
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
function classifyDh(dh) {
|
| 175 |
+
if (dh > 0) return 'up';
|
| 176 |
+
if (dh < 0) return 'down';
|
| 177 |
+
return 'zero';
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
function render() {
|
| 181 |
+
const aName = document.getElementById('runA').value;
|
| 182 |
+
const bName = document.getElementById('runB').value;
|
| 183 |
+
const runA = RUNS[aName] || { cases: {} };
|
| 184 |
+
const runB = RUNS[bName] || { cases: {} };
|
| 185 |
+
const filterStr = document.getElementById('filter').value.trim().toLowerCase();
|
| 186 |
+
const sortBy = document.getElementById('sort').value;
|
| 187 |
+
const onlyChanged = document.getElementById('onlyChanged').checked;
|
| 188 |
+
const allIds = Array.from(new Set([...caseIds(runA), ...caseIds(runB)]));
|
| 189 |
+
|
| 190 |
+
let rows = allIds.map(cid => {
|
| 191 |
+
const a = runA.cases[cid];
|
| 192 |
+
const b = runB.cases[cid];
|
| 193 |
+
const dh = (a && b && a.ok && b.ok) ? (b.h - a.h) : 0;
|
| 194 |
+
return { cid, a, b, dh };
|
| 195 |
+
});
|
| 196 |
+
|
| 197 |
+
if (filterStr) {
|
| 198 |
+
rows = rows.filter(r => r.cid.toLowerCase().includes(filterStr));
|
| 199 |
+
}
|
| 200 |
+
if (onlyChanged) {
|
| 201 |
+
rows = rows.filter(r => Math.abs(r.dh) >= 1);
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
switch (sortBy) {
|
| 205 |
+
case 'abs_dh_desc': rows.sort((x,y) => Math.abs(y.dh) - Math.abs(x.dh) || x.cid.localeCompare(y.cid)); break;
|
| 206 |
+
case 'dh_desc': rows.sort((x,y) => y.dh - x.dh); break;
|
| 207 |
+
case 'dh_asc': rows.sort((x,y) => x.dh - y.dh); break;
|
| 208 |
+
case 'case_id': rows.sort((x,y) => x.cid.localeCompare(y.cid)); break;
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
const summaryEl = document.getElementById('summary');
|
| 212 |
+
const ups = rows.filter(r => r.dh > 0).length;
|
| 213 |
+
const downs = rows.filter(r => r.dh < 0).length;
|
| 214 |
+
const zeros = rows.filter(r => r.dh === 0).length;
|
| 215 |
+
const max = Math.max(0, ...rows.map(r => Math.abs(r.dh)));
|
| 216 |
+
summaryEl.innerHTML =
|
| 217 |
+
`<span><b>${rows.length}</b> cases shown</span>` +
|
| 218 |
+
`<span class="badge up">↑ taller: ${ups}</span>` +
|
| 219 |
+
`<span class="badge down">↓ shorter: ${downs}</span>` +
|
| 220 |
+
`<span class="badge zero">– unchanged: ${zeros}</span>` +
|
| 221 |
+
`<span>max |Δh| = <b>${max}</b>px</span>` +
|
| 222 |
+
`<span>left = <b>${aName}</b> · right = <b>${bName}</b></span>`;
|
| 223 |
+
|
| 224 |
+
const grid = document.getElementById('grid');
|
| 225 |
+
if (rows.length === 0) {
|
| 226 |
+
grid.innerHTML = '<div class="empty">no cases match</div>';
|
| 227 |
+
return;
|
| 228 |
+
}
|
| 229 |
+
grid.innerHTML = rows.map(r => renderRow(r, aName, bName)).join('');
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
function renderRow({ cid, a, b, dh }, aName, bName) {
|
| 233 |
+
const klass = Math.abs(dh) >= 4 ? 'row changed' : 'row';
|
| 234 |
+
const dhKlass = classifyDh(dh);
|
| 235 |
+
const dhText = dh === 0 ? '±0' : (dh > 0 ? `+${dh}` : `${dh}`);
|
| 236 |
+
const aFonts = a && a.fonts ? fontsHistToString(a.fonts) : '—';
|
| 237 |
+
const bFonts = b && b.fonts ? fontsHistToString(b.fonts) : '—';
|
| 238 |
+
const tpl = (a && a.template) || (b && b.template) || '';
|
| 239 |
+
const tid = (a && a.test_id) || (b && b.test_id) || '';
|
| 240 |
+
return `
|
| 241 |
+
<div class="${klass}">
|
| 242 |
+
<div class="row-head">
|
| 243 |
+
<span class="case-id">${cid}</span>
|
| 244 |
+
<span class="pill tpl">${tpl}</span>
|
| 245 |
+
<span class="pill">${tid}</span>
|
| 246 |
+
<span class="badge ${dhKlass}">Δh ${dhText}px</span>
|
| 247 |
+
<span class="row-meta">${a ? `left ${a.w}×${a.h}` : 'left missing'} · ${b ? `right ${b.w}×${b.h}` : 'right missing'}</span>
|
| 248 |
+
</div>
|
| 249 |
+
<div class="pair">
|
| 250 |
+
${renderPanel(a, aName, aFonts)}
|
| 251 |
+
${renderPanel(b, bName, bFonts)}
|
| 252 |
+
</div>
|
| 253 |
+
${a && a.title ? `<details><summary>show title text / description</summary>
|
| 254 |
+
<pre>title: ${escapeHtml(a.title)}\\ndescription: ${escapeHtml(a.description||'')}</pre></details>` : ''}
|
| 255 |
+
</div>`;
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
function renderPanel(rec, runName, fontsStr) {
|
| 259 |
+
if (!rec) return `<div class="panel missing">missing in ${runName}</div>`;
|
| 260 |
+
if (!rec.ok) return `<div class="panel missing">failed: ${escapeHtml(rec.reason||'')}</div>`;
|
| 261 |
+
if (!rec.png) return `<div class="panel missing">no png for ${runName}</div>`;
|
| 262 |
+
return `<div class="panel">
|
| 263 |
+
<div class="head"><span>${runName}</span><span class="fonts">${fontsStr}</span></div>
|
| 264 |
+
<img src="${rec.png}" alt="${rec.png}">
|
| 265 |
+
</div>`;
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
function escapeHtml(s) {
|
| 269 |
+
return String(s).replace(/[&<>"']/g, ch => ({
|
| 270 |
+
'&':'&','<':'<','>':'>','"':'"',"'":'''
|
| 271 |
+
})[ch]);
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
function populateSelects() {
|
| 275 |
+
const names = Object.keys(RUNS);
|
| 276 |
+
const [defA, defB] = pickDefaults();
|
| 277 |
+
for (const id of ['runA', 'runB']) {
|
| 278 |
+
const sel = document.getElementById(id);
|
| 279 |
+
sel.innerHTML = names.map(n => `<option value="${n}">${n}</option>`).join('');
|
| 280 |
+
sel.value = id === 'runA' ? defA : defB;
|
| 281 |
+
sel.addEventListener('change', render);
|
| 282 |
+
}
|
| 283 |
+
document.getElementById('filter').addEventListener('input', render);
|
| 284 |
+
document.getElementById('sort').addEventListener('change', render);
|
| 285 |
+
document.getElementById('onlyChanged').addEventListener('change', render);
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
if (Object.keys(RUNS).length === 0) {
|
| 289 |
+
document.getElementById('grid').innerHTML =
|
| 290 |
+
'<div class="empty">no runs found. run <code>tests/title_styler_regression/run.py --run baseline</code> first.</div>';
|
| 291 |
+
} else {
|
| 292 |
+
populateSelects();
|
| 293 |
+
render();
|
| 294 |
+
}
|
| 295 |
+
</script>
|
| 296 |
+
</body>
|
| 297 |
+
</html>
|
| 298 |
+
"""
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
def main():
|
| 302 |
+
runs = discover_runs()
|
| 303 |
+
if not runs:
|
| 304 |
+
print("no run found under tests/title_styler_regression/results/", file=sys.stderr)
|
| 305 |
+
print("first run:", file=sys.stderr)
|
| 306 |
+
print(" PYTHONPATH=. python tests/title_styler_regression/run.py --run baseline",
|
| 307 |
+
file=sys.stderr)
|
| 308 |
+
payload = json.dumps(runs, ensure_ascii=False)
|
| 309 |
+
html = HTML_TEMPLATE.replace("__RUNS_JSON__", payload)
|
| 310 |
+
with open(OUT_HTML, "w", encoding="utf-8") as f:
|
| 311 |
+
f.write(html)
|
| 312 |
+
print(f"wrote {OUT_HTML}")
|
| 313 |
+
if runs:
|
| 314 |
+
print("available runs:")
|
| 315 |
+
for name, r in runs.items():
|
| 316 |
+
print(f" - {name} ok={r['n_ok']} fail={r['n_fail']}")
|
| 317 |
+
print(f"\nopen in browser: file://{OUT_HTML}")
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
if __name__ == "__main__":
|
| 321 |
+
main()
|