Ray1ee01 commited on
Commit
dc30f9a
·
verified ·
1 Parent(s): 07f70f8

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
+ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
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()