opencode-bioresearcher 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +103 -0
- package/agents/bioresearcher-dr-worker.md +54 -0
- package/connector-meta.json +23 -0
- package/index.js +77 -0
- package/loader.js +3 -0
- package/package.json +42 -0
- package/skill-bundle.json +12 -0
- package/skills/bioresearcher-deep-research/SKILL.md +330 -0
- package/skills/bioresearcher-deep-research/references/analysis-methods.md +90 -0
- package/skills/bioresearcher-deep-research/references/article-literature.md +89 -0
- package/skills/bioresearcher-deep-research/references/best-practices.md +102 -0
- package/skills/bioresearcher-deep-research/references/citations.md +146 -0
- package/skills/bioresearcher-deep-research/references/clinical-trials.md +87 -0
- package/skills/bioresearcher-deep-research/references/diseases.md +94 -0
- package/skills/bioresearcher-deep-research/references/drugs.md +88 -0
- package/skills/bioresearcher-deep-research/references/ensembl-pdb.md +134 -0
- package/skills/bioresearcher-deep-research/references/functional-genomics.md +118 -0
- package/skills/bioresearcher-deep-research/references/genes.md +93 -0
- package/skills/bioresearcher-deep-research/references/optional-analysis.md +108 -0
- package/skills/bioresearcher-deep-research/references/patents.md +92 -0
- package/skills/bioresearcher-deep-research/references/rate-limiting-auth.md +95 -0
- package/skills/bioresearcher-deep-research/references/report-template.md +117 -0
- package/skills/bioresearcher-deep-research/references/tool-selection.md +142 -0
- package/skills/bioresearcher-deep-research/references/utility-config.md +116 -0
- package/skills/bioresearcher-deep-research/references/variants.md +109 -0
- package/skills/bioresearcher-deep-research/references/worker-protocol.md +110 -0
- package/skills/bioresearcher-deep-research/scripts/markdown-to-html.py +86 -0
- package/skills/bioresearcher-plot-making/SKILL.md +97 -0
- package/skills/bioresearcher-plot-making/references/literature-search-method-summary.md +163 -0
- package/skills/bioresearcher-plot-making/references/qa-gates-and-gotchas.md +156 -0
- package/skills/bioresearcher-plot-making/references/structural-biology-binder-visualization.md +206 -0
- package/skills/bioresearcher-plot-making/scripts/audit_figure_collisions.py +742 -0
- package/skills/bioresearcher-plot-making/scripts/audit_panel_alignment.py +935 -0
- package/skills/bioresearcher-plot-making/scripts/audit_pdf_text.py +152 -0
- package/skills/bioresearcher-plot-making/scripts/plot_helpers.py +177 -0
- package/skills/bioresearcher-pubmed-weekly/SKILL.md +223 -0
- package/skills/bioresearcher-pubmed-weekly/scripts/parse_updatefiles.py +272 -0
- package/skills/bioresearcher-pubmed-weekly/scripts/pubmed_weekly.py +493 -0
- package/skills/bioresearcher-python-setup-uv/SKILL.md +184 -0
|
@@ -0,0 +1,935 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Audit multi-panel figure alignment from rendered layout geometry.
|
|
3
|
+
|
|
4
|
+
The core auditor consumes a small backend-neutral JSON manifest. Matplotlib
|
|
5
|
+
figures can call :func:`require_matplotlib_panel_alignment` directly after the
|
|
6
|
+
final layout draw. R/patchwork figures use ``panel_alignment.R`` to export the
|
|
7
|
+
same manifest and then invoke this CLI.
|
|
8
|
+
|
|
9
|
+
This gate checks the plot-area rectangles that readers perceive as panels. It
|
|
10
|
+
does not infer scientific equivalence: comparable row and column groups come
|
|
11
|
+
from Matplotlib SubplotSpec metadata, patchwork/gtable metadata, or explicit
|
|
12
|
+
groups supplied by the plotting script. Horizontal rows of three or four equal
|
|
13
|
+
grid spans must also have equal final physical widths. Asymmetric hero panels,
|
|
14
|
+
insets and colorbars must be excluded or exempted with a recorded reason rather
|
|
15
|
+
than silently weakening the tolerance.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import html
|
|
22
|
+
import json
|
|
23
|
+
import math
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
import tempfile
|
|
27
|
+
from collections import defaultdict
|
|
28
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
SCHEMA_VERSION = 1
|
|
34
|
+
DEFAULT_TOLERANCE_PT = 1.5
|
|
35
|
+
DEFAULT_GUTTER_TOLERANCE_PT = 1.5
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class PanelAlignmentError(RuntimeError):
|
|
39
|
+
"""Raised when a required in-memory alignment gate does not pass."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _atomic_write_text(path: Path, content: str) -> None:
|
|
43
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
descriptor, temporary_name = tempfile.mkstemp(
|
|
45
|
+
prefix=f".{path.name}.", suffix=".tmp", dir=path.parent
|
|
46
|
+
)
|
|
47
|
+
temporary = Path(temporary_name)
|
|
48
|
+
try:
|
|
49
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
50
|
+
handle.write(content)
|
|
51
|
+
temporary.replace(path)
|
|
52
|
+
except Exception:
|
|
53
|
+
temporary.unlink(missing_ok=True)
|
|
54
|
+
raise
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _bbox(value: Any) -> tuple[float, float, float, float]:
|
|
58
|
+
if isinstance(value, Mapping):
|
|
59
|
+
values = [value.get(key) for key in ("left", "bottom", "right", "top")]
|
|
60
|
+
else:
|
|
61
|
+
values = list(value) if isinstance(value, Sequence) else []
|
|
62
|
+
if len(values) != 4:
|
|
63
|
+
raise ValueError("panel bbox_pt must contain left, bottom, right and top")
|
|
64
|
+
bbox = tuple(float(item) for item in values)
|
|
65
|
+
if not all(math.isfinite(item) for item in bbox):
|
|
66
|
+
raise ValueError("panel bbox_pt values must be finite")
|
|
67
|
+
if bbox[2] <= bbox[0] or bbox[3] <= bbox[1]:
|
|
68
|
+
raise ValueError("panel bbox_pt must have positive width and height")
|
|
69
|
+
return bbox
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _group_rows(raw: Any, prefix: str) -> list[dict[str, Any]]:
|
|
73
|
+
groups: list[dict[str, Any]] = []
|
|
74
|
+
if raw is None:
|
|
75
|
+
return groups
|
|
76
|
+
if not isinstance(raw, Sequence) or isinstance(raw, (str, bytes)):
|
|
77
|
+
raise ValueError(f"{prefix}_groups must be a list")
|
|
78
|
+
for index, item in enumerate(raw, 1):
|
|
79
|
+
if isinstance(item, Mapping):
|
|
80
|
+
panels = item.get("panels", [])
|
|
81
|
+
group_id = str(item.get("id") or f"{prefix}-{index}")
|
|
82
|
+
else:
|
|
83
|
+
panels = item
|
|
84
|
+
group_id = f"{prefix}-{index}"
|
|
85
|
+
if not isinstance(panels, Sequence) or isinstance(panels, (str, bytes)):
|
|
86
|
+
raise ValueError(f"{prefix} group {group_id} must list panel ids")
|
|
87
|
+
panel_ids = [str(panel) for panel in panels]
|
|
88
|
+
if len(panel_ids) < 2:
|
|
89
|
+
raise ValueError(f"{prefix} group {group_id} needs at least two panels")
|
|
90
|
+
if len(panel_ids) != len(set(panel_ids)):
|
|
91
|
+
raise ValueError(f"{prefix} group {group_id} repeats a panel id")
|
|
92
|
+
groups.append({"id": group_id, "panels": panel_ids})
|
|
93
|
+
return groups
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _inferred_groups(panels: Sequence[dict[str, Any]], orientation: str) -> list[dict[str, Any]]:
|
|
97
|
+
grouped: dict[tuple[Any, ...], list[str]] = defaultdict(list)
|
|
98
|
+
for panel in panels:
|
|
99
|
+
if orientation == "row":
|
|
100
|
+
fields = (panel.get("grid_id"), panel.get("row_start"), panel.get("row_stop"))
|
|
101
|
+
else:
|
|
102
|
+
fields = (panel.get("grid_id"), panel.get("col_start"), panel.get("col_stop"))
|
|
103
|
+
if any(value is None for value in fields):
|
|
104
|
+
continue
|
|
105
|
+
grouped[fields].append(str(panel["id"]))
|
|
106
|
+
output: list[dict[str, Any]] = []
|
|
107
|
+
for index, (_key, panel_ids) in enumerate(grouped.items(), 1):
|
|
108
|
+
if len(panel_ids) >= 2:
|
|
109
|
+
output.append({"id": f"inferred-{orientation}-{index}", "panels": panel_ids})
|
|
110
|
+
return output
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _inferred_boundary_groups(panels: Sequence[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
114
|
+
"""Infer shared grid boundaries across panels with unequal spans.
|
|
115
|
+
|
|
116
|
+
This covers layouts such as two stacked panels beside one panel that spans
|
|
117
|
+
both rows. Equal-span panels are handled by the ordinary row/column groups,
|
|
118
|
+
so boundary groups are emitted only when at least two different spans meet
|
|
119
|
+
at the same grid boundary.
|
|
120
|
+
"""
|
|
121
|
+
specifications = (
|
|
122
|
+
("top", "row_start", "row_start", "row_stop"),
|
|
123
|
+
("bottom", "row_stop", "row_start", "row_stop"),
|
|
124
|
+
("left", "col_start", "col_start", "col_stop"),
|
|
125
|
+
("right", "col_stop", "col_start", "col_stop"),
|
|
126
|
+
)
|
|
127
|
+
output: list[dict[str, Any]] = []
|
|
128
|
+
for edge, boundary_field, span_start, span_stop in specifications:
|
|
129
|
+
grouped: dict[tuple[Any, Any], list[dict[str, Any]]] = defaultdict(list)
|
|
130
|
+
for panel in panels:
|
|
131
|
+
grid_id = panel.get("grid_id")
|
|
132
|
+
boundary = panel.get(boundary_field)
|
|
133
|
+
start = panel.get(span_start)
|
|
134
|
+
stop = panel.get(span_stop)
|
|
135
|
+
if any(value is None for value in (grid_id, boundary, start, stop)):
|
|
136
|
+
continue
|
|
137
|
+
grouped[(grid_id, boundary)].append(panel)
|
|
138
|
+
edge_index = 0
|
|
139
|
+
for (_grid_id, _boundary), members in grouped.items():
|
|
140
|
+
spans = {(panel.get(span_start), panel.get(span_stop)) for panel in members}
|
|
141
|
+
if len(members) < 2 or len(spans) < 2:
|
|
142
|
+
continue
|
|
143
|
+
edge_index += 1
|
|
144
|
+
output.append(
|
|
145
|
+
{
|
|
146
|
+
"id": f"inferred-shared-{edge}-{edge_index}",
|
|
147
|
+
"edge": edge,
|
|
148
|
+
"panels": [str(panel["id"]) for panel in members],
|
|
149
|
+
}
|
|
150
|
+
)
|
|
151
|
+
return output
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _validated_layout(manifest: Mapping[str, Any]) -> tuple[dict[str, Any] | None, list[str]]:
|
|
155
|
+
if isinstance(manifest.get("layout"), Mapping):
|
|
156
|
+
manifest = manifest["layout"]
|
|
157
|
+
errors: list[str] = []
|
|
158
|
+
if manifest.get("schema_version") != SCHEMA_VERSION:
|
|
159
|
+
errors.append(f"schema_version must be {SCHEMA_VERSION}")
|
|
160
|
+
|
|
161
|
+
figure = manifest.get("figure")
|
|
162
|
+
if not isinstance(figure, Mapping):
|
|
163
|
+
errors.append("figure must be an object with width_pt and height_pt")
|
|
164
|
+
figure = {}
|
|
165
|
+
try:
|
|
166
|
+
width_pt = float(figure.get("width_pt"))
|
|
167
|
+
height_pt = float(figure.get("height_pt"))
|
|
168
|
+
if not math.isfinite(width_pt) or not math.isfinite(height_pt) or width_pt <= 0 or height_pt <= 0:
|
|
169
|
+
raise ValueError
|
|
170
|
+
except (TypeError, ValueError):
|
|
171
|
+
errors.append("figure width_pt and height_pt must be positive finite numbers")
|
|
172
|
+
width_pt = height_pt = 0.0
|
|
173
|
+
|
|
174
|
+
raw_panels = manifest.get("panels")
|
|
175
|
+
panels: list[dict[str, Any]] = []
|
|
176
|
+
if not isinstance(raw_panels, Sequence) or isinstance(raw_panels, (str, bytes)):
|
|
177
|
+
errors.append("panels must be a list")
|
|
178
|
+
raw_panels = []
|
|
179
|
+
for index, raw_panel in enumerate(raw_panels, 1):
|
|
180
|
+
if not isinstance(raw_panel, Mapping):
|
|
181
|
+
errors.append(f"panel {index} must be an object")
|
|
182
|
+
continue
|
|
183
|
+
panel_id = str(raw_panel.get("id") or "").strip()
|
|
184
|
+
if not panel_id:
|
|
185
|
+
errors.append(f"panel {index} is missing id")
|
|
186
|
+
continue
|
|
187
|
+
try:
|
|
188
|
+
bbox = _bbox(raw_panel.get("bbox_pt"))
|
|
189
|
+
except (TypeError, ValueError) as exc:
|
|
190
|
+
errors.append(f"panel {panel_id}: {exc}")
|
|
191
|
+
continue
|
|
192
|
+
if bbox[0] < -0.01 or bbox[1] < -0.01 or bbox[2] > width_pt + 0.01 or bbox[3] > height_pt + 0.01:
|
|
193
|
+
errors.append(f"panel {panel_id} bbox_pt extends beyond the declared figure")
|
|
194
|
+
panel = dict(raw_panel)
|
|
195
|
+
panel["id"] = panel_id
|
|
196
|
+
panel["bbox_pt"] = list(bbox)
|
|
197
|
+
anchor = raw_panel.get("panel_label_anchor_pt")
|
|
198
|
+
if anchor is not None:
|
|
199
|
+
try:
|
|
200
|
+
anchor_values = [float(value) for value in anchor]
|
|
201
|
+
if len(anchor_values) != 2 or not all(math.isfinite(value) for value in anchor_values):
|
|
202
|
+
raise ValueError
|
|
203
|
+
panel["panel_label_anchor_pt"] = anchor_values
|
|
204
|
+
except (TypeError, ValueError):
|
|
205
|
+
errors.append(f"panel {panel_id} has invalid panel_label_anchor_pt")
|
|
206
|
+
panels.append(panel)
|
|
207
|
+
|
|
208
|
+
panel_ids = [panel["id"] for panel in panels]
|
|
209
|
+
if len(panel_ids) != len(set(panel_ids)):
|
|
210
|
+
errors.append("panel ids must be unique")
|
|
211
|
+
if not panels:
|
|
212
|
+
errors.append("at least one panel rectangle is required")
|
|
213
|
+
|
|
214
|
+
try:
|
|
215
|
+
row_groups = _group_rows(manifest.get("row_groups"), "row")
|
|
216
|
+
column_groups = _group_rows(manifest.get("column_groups"), "column")
|
|
217
|
+
except ValueError as exc:
|
|
218
|
+
errors.append(str(exc))
|
|
219
|
+
row_groups = column_groups = []
|
|
220
|
+
if not row_groups:
|
|
221
|
+
row_groups = _inferred_groups(panels, "row")
|
|
222
|
+
if not column_groups:
|
|
223
|
+
column_groups = _inferred_groups(panels, "column")
|
|
224
|
+
boundary_groups = _inferred_boundary_groups(panels)
|
|
225
|
+
|
|
226
|
+
known_ids = set(panel_ids)
|
|
227
|
+
for group in [*row_groups, *column_groups]:
|
|
228
|
+
missing = [panel for panel in group["panels"] if panel not in known_ids]
|
|
229
|
+
if missing:
|
|
230
|
+
errors.append(f"group {group['id']} references unknown panels: {', '.join(missing)}")
|
|
231
|
+
|
|
232
|
+
exemptions: list[dict[str, Any]] = []
|
|
233
|
+
raw_exemptions = manifest.get("exemptions", [])
|
|
234
|
+
if not isinstance(raw_exemptions, Sequence) or isinstance(raw_exemptions, (str, bytes)):
|
|
235
|
+
errors.append("exemptions must be a list")
|
|
236
|
+
raw_exemptions = []
|
|
237
|
+
for index, exemption in enumerate(raw_exemptions, 1):
|
|
238
|
+
if not isinstance(exemption, Mapping):
|
|
239
|
+
errors.append(f"exemption {index} must be an object")
|
|
240
|
+
continue
|
|
241
|
+
exemption_panels = exemption.get("panels", [])
|
|
242
|
+
checks = exemption.get("checks", [])
|
|
243
|
+
reason = str(exemption.get("reason") or "").strip()
|
|
244
|
+
if isinstance(exemption_panels, str):
|
|
245
|
+
exemption_panels = [exemption_panels]
|
|
246
|
+
if isinstance(checks, str):
|
|
247
|
+
checks = [checks]
|
|
248
|
+
if not exemption_panels or any(str(panel) not in known_ids for panel in exemption_panels):
|
|
249
|
+
errors.append(f"exemption {index} must reference known panels")
|
|
250
|
+
allowed_checks = {
|
|
251
|
+
"row",
|
|
252
|
+
"column",
|
|
253
|
+
"panel-width",
|
|
254
|
+
"horizontal-gutter",
|
|
255
|
+
"vertical-gutter",
|
|
256
|
+
"panel-label",
|
|
257
|
+
"all",
|
|
258
|
+
}
|
|
259
|
+
check_names = [str(check) for check in checks]
|
|
260
|
+
if not check_names or any(check not in allowed_checks for check in check_names):
|
|
261
|
+
errors.append(f"exemption {index} has invalid checks")
|
|
262
|
+
if not reason:
|
|
263
|
+
errors.append(f"exemption {index} needs a non-empty reason")
|
|
264
|
+
exemptions.append(
|
|
265
|
+
{"panels": [str(panel) for panel in exemption_panels], "checks": check_names, "reason": reason}
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
if errors:
|
|
269
|
+
return None, errors
|
|
270
|
+
return {
|
|
271
|
+
"schema_version": SCHEMA_VERSION,
|
|
272
|
+
"backend": str(manifest.get("backend") or "unknown"),
|
|
273
|
+
"figure": {"width_pt": width_pt, "height_pt": height_pt},
|
|
274
|
+
"panels": panels,
|
|
275
|
+
"row_groups": row_groups,
|
|
276
|
+
"column_groups": column_groups,
|
|
277
|
+
"boundary_groups": boundary_groups,
|
|
278
|
+
"exemptions": exemptions,
|
|
279
|
+
}, []
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _is_exempt(layout: Mapping[str, Any], panel_id: str, check: str) -> bool:
|
|
283
|
+
for exemption in layout.get("exemptions", []):
|
|
284
|
+
if panel_id in exemption["panels"] and (check in exemption["checks"] or "all" in exemption["checks"]):
|
|
285
|
+
return True
|
|
286
|
+
return False
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _metric_spread(values: Mapping[str, float]) -> float:
|
|
290
|
+
return max(values.values()) - min(values.values()) if values else 0.0
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _finding(
|
|
294
|
+
kind: str,
|
|
295
|
+
group: Mapping[str, Any],
|
|
296
|
+
panels: Sequence[str],
|
|
297
|
+
tolerance_pt: float,
|
|
298
|
+
metrics: Mapping[str, Mapping[str, float]],
|
|
299
|
+
message: str,
|
|
300
|
+
severity: str = "FAIL",
|
|
301
|
+
) -> dict[str, Any]:
|
|
302
|
+
return {
|
|
303
|
+
"severity": severity,
|
|
304
|
+
"kind": kind,
|
|
305
|
+
"group": group["id"],
|
|
306
|
+
"panels": list(panels),
|
|
307
|
+
"message": message,
|
|
308
|
+
"tolerance_pt": tolerance_pt,
|
|
309
|
+
"metric_spreads_pt": {
|
|
310
|
+
metric: round(_metric_spread(values), 6) for metric, values in metrics.items()
|
|
311
|
+
},
|
|
312
|
+
"values_pt": {
|
|
313
|
+
metric: {panel: round(value, 6) for panel, value in values.items()}
|
|
314
|
+
for metric, values in metrics.items()
|
|
315
|
+
},
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def audit_layout_manifest(
|
|
320
|
+
manifest: Mapping[str, Any],
|
|
321
|
+
*,
|
|
322
|
+
tolerance_pt: float = DEFAULT_TOLERANCE_PT,
|
|
323
|
+
gutter_tolerance_pt: float = DEFAULT_GUTTER_TOLERANCE_PT,
|
|
324
|
+
require_panel_labels: bool = False,
|
|
325
|
+
) -> dict[str, Any]:
|
|
326
|
+
"""Audit a backend-neutral panel-layout manifest."""
|
|
327
|
+
if tolerance_pt < 0 or gutter_tolerance_pt < 0:
|
|
328
|
+
raise ValueError("alignment tolerances must be non-negative")
|
|
329
|
+
layout, errors = _validated_layout(manifest)
|
|
330
|
+
if layout is None:
|
|
331
|
+
return {
|
|
332
|
+
"schema_version": SCHEMA_VERSION,
|
|
333
|
+
"auditable": False,
|
|
334
|
+
"verdict": "NOT AUDITABLE",
|
|
335
|
+
"summary": {"fail": 0, "warn": 0, "comparisons": 0, "exemptions": 0},
|
|
336
|
+
"errors": errors,
|
|
337
|
+
"findings": [],
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if len(layout["panels"]) == 1:
|
|
341
|
+
return {
|
|
342
|
+
"schema_version": SCHEMA_VERSION,
|
|
343
|
+
"applicable": False,
|
|
344
|
+
"auditable": False,
|
|
345
|
+
"verdict": "NOT APPLICABLE",
|
|
346
|
+
"backend": layout["backend"],
|
|
347
|
+
"summary": {"fail": 0, "warn": 0, "comparisons": 0, "exemptions": 0},
|
|
348
|
+
"tolerances": {
|
|
349
|
+
"alignment_pt": tolerance_pt,
|
|
350
|
+
"gutter_pt": gutter_tolerance_pt,
|
|
351
|
+
},
|
|
352
|
+
"errors": [],
|
|
353
|
+
"findings": [],
|
|
354
|
+
"layout": layout,
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
panel_map = {panel["id"]: panel for panel in layout["panels"]}
|
|
358
|
+
findings: list[dict[str, Any]] = []
|
|
359
|
+
comparisons = 0
|
|
360
|
+
|
|
361
|
+
for orientation, group_key, check_name in (
|
|
362
|
+
("row", "row_groups", "row"),
|
|
363
|
+
("column", "column_groups", "column"),
|
|
364
|
+
):
|
|
365
|
+
for group in layout[group_key]:
|
|
366
|
+
group_panel_ids = list(group["panels"])
|
|
367
|
+
panel_ids = [
|
|
368
|
+
panel_id for panel_id in group_panel_ids if not _is_exempt(layout, panel_id, check_name)
|
|
369
|
+
]
|
|
370
|
+
if len(panel_ids) < 2:
|
|
371
|
+
continue
|
|
372
|
+
comparisons += 1
|
|
373
|
+
boxes = {panel_id: _bbox(panel_map[panel_id]["bbox_pt"]) for panel_id in panel_ids}
|
|
374
|
+
if orientation == "row":
|
|
375
|
+
metrics = {
|
|
376
|
+
"top": {panel: box[3] for panel, box in boxes.items()},
|
|
377
|
+
"bottom": {panel: box[1] for panel, box in boxes.items()},
|
|
378
|
+
"height": {panel: box[3] - box[1] for panel, box in boxes.items()},
|
|
379
|
+
}
|
|
380
|
+
kind = "row-axes-misalignment"
|
|
381
|
+
message = "Panels intended for one row do not share top/bottom edges and height"
|
|
382
|
+
else:
|
|
383
|
+
metrics = {
|
|
384
|
+
"left": {panel: box[0] for panel, box in boxes.items()},
|
|
385
|
+
"right": {panel: box[2] for panel, box in boxes.items()},
|
|
386
|
+
"width": {panel: box[2] - box[0] for panel, box in boxes.items()},
|
|
387
|
+
}
|
|
388
|
+
kind = "column-axes-misalignment"
|
|
389
|
+
message = "Panels intended for one column do not share left/right edges and width"
|
|
390
|
+
if max(_metric_spread(values) for values in metrics.values()) > tolerance_pt:
|
|
391
|
+
findings.append(_finding(kind, group, panel_ids, tolerance_pt, metrics, message))
|
|
392
|
+
|
|
393
|
+
if orientation == "row" and len(group_panel_ids) >= 3:
|
|
394
|
+
width_candidates = [
|
|
395
|
+
panel_id
|
|
396
|
+
for panel_id in group_panel_ids
|
|
397
|
+
if not _is_exempt(layout, panel_id, "row")
|
|
398
|
+
and not _is_exempt(layout, panel_id, "panel-width")
|
|
399
|
+
]
|
|
400
|
+
by_column_span: dict[Any, list[str]] = defaultdict(list)
|
|
401
|
+
for panel_id in width_candidates:
|
|
402
|
+
panel = panel_map[panel_id]
|
|
403
|
+
try:
|
|
404
|
+
column_span = float(panel.get("col_stop")) - float(panel.get("col_start"))
|
|
405
|
+
if not math.isfinite(column_span) or column_span <= 0:
|
|
406
|
+
raise ValueError
|
|
407
|
+
span_key: Any = round(column_span, 9)
|
|
408
|
+
except (TypeError, ValueError):
|
|
409
|
+
span_key = "unspecified"
|
|
410
|
+
by_column_span[span_key].append(panel_id)
|
|
411
|
+
for width_index, width_ids in enumerate(by_column_span.values(), 1):
|
|
412
|
+
if len(width_ids) < 2:
|
|
413
|
+
continue
|
|
414
|
+
comparisons += 1
|
|
415
|
+
width_metrics = {
|
|
416
|
+
"width": {
|
|
417
|
+
panel_id: _bbox(panel_map[panel_id]["bbox_pt"])[2]
|
|
418
|
+
- _bbox(panel_map[panel_id]["bbox_pt"])[0]
|
|
419
|
+
for panel_id in width_ids
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
if _metric_spread(width_metrics["width"]) > tolerance_pt:
|
|
423
|
+
findings.append(
|
|
424
|
+
_finding(
|
|
425
|
+
"horizontal-panel-width-misalignment",
|
|
426
|
+
{"id": f"{group['id']}-equal-span-{width_index}"},
|
|
427
|
+
width_ids,
|
|
428
|
+
tolerance_pt,
|
|
429
|
+
width_metrics,
|
|
430
|
+
"Three-or-more-panel row contains unequal final plot-area widths for equal grid spans",
|
|
431
|
+
)
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
anchors = {
|
|
435
|
+
panel_id: panel_map[panel_id].get("panel_label_anchor_pt") for panel_id in panel_ids
|
|
436
|
+
}
|
|
437
|
+
present_anchors = {panel: anchor for panel, anchor in anchors.items() if anchor is not None}
|
|
438
|
+
missing_labels = [panel for panel, anchor in anchors.items() if anchor is None]
|
|
439
|
+
if require_panel_labels and missing_labels:
|
|
440
|
+
findings.append(
|
|
441
|
+
{
|
|
442
|
+
"severity": "WARN",
|
|
443
|
+
"kind": "panel-label-not-auditable",
|
|
444
|
+
"group": group["id"],
|
|
445
|
+
"panels": missing_labels,
|
|
446
|
+
"message": "Comparable panels are missing detectable top-left panel-label anchors",
|
|
447
|
+
}
|
|
448
|
+
)
|
|
449
|
+
if len(present_anchors) >= 2:
|
|
450
|
+
label_check = "panel-label"
|
|
451
|
+
label_ids = [
|
|
452
|
+
panel for panel in panel_ids if panel in present_anchors and not _is_exempt(layout, panel, label_check)
|
|
453
|
+
]
|
|
454
|
+
if len(label_ids) >= 2:
|
|
455
|
+
coordinate = 1 if orientation == "row" else 0
|
|
456
|
+
metric_name = "label-y" if orientation == "row" else "label-x"
|
|
457
|
+
metrics = {
|
|
458
|
+
metric_name: {panel: float(present_anchors[panel][coordinate]) for panel in label_ids}
|
|
459
|
+
}
|
|
460
|
+
if _metric_spread(metrics[metric_name]) > tolerance_pt:
|
|
461
|
+
findings.append(
|
|
462
|
+
_finding(
|
|
463
|
+
f"{orientation}-panel-label-misalignment",
|
|
464
|
+
group,
|
|
465
|
+
label_ids,
|
|
466
|
+
tolerance_pt,
|
|
467
|
+
metrics,
|
|
468
|
+
"Panel-label anchors are not aligned within their comparable group",
|
|
469
|
+
)
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
gutter_check = "horizontal-gutter" if orientation == "row" else "vertical-gutter"
|
|
473
|
+
gutter_ids = [panel for panel in panel_ids if not _is_exempt(layout, panel, gutter_check)]
|
|
474
|
+
if len(gutter_ids) >= 2:
|
|
475
|
+
if orientation == "row":
|
|
476
|
+
ordered = sorted(gutter_ids, key=lambda panel: boxes[panel][0])
|
|
477
|
+
gaps = {
|
|
478
|
+
f"{first}->{second}": boxes[second][0] - boxes[first][2]
|
|
479
|
+
for first, second in zip(ordered, ordered[1:])
|
|
480
|
+
}
|
|
481
|
+
else:
|
|
482
|
+
ordered = sorted(gutter_ids, key=lambda panel: boxes[panel][3], reverse=True)
|
|
483
|
+
gaps = {
|
|
484
|
+
f"{first}->{second}": boxes[first][1] - boxes[second][3]
|
|
485
|
+
for first, second in zip(ordered, ordered[1:])
|
|
486
|
+
}
|
|
487
|
+
if gaps and min(gaps.values()) < -tolerance_pt:
|
|
488
|
+
findings.append(
|
|
489
|
+
_finding(
|
|
490
|
+
"panel-axes-overlap",
|
|
491
|
+
group,
|
|
492
|
+
gutter_ids,
|
|
493
|
+
tolerance_pt,
|
|
494
|
+
{"gutter": gaps},
|
|
495
|
+
"Panel plot-area rectangles overlap",
|
|
496
|
+
)
|
|
497
|
+
)
|
|
498
|
+
elif len(gaps) >= 2 and _metric_spread(gaps) > gutter_tolerance_pt:
|
|
499
|
+
findings.append(
|
|
500
|
+
_finding(
|
|
501
|
+
f"{gutter_check}-misalignment",
|
|
502
|
+
group,
|
|
503
|
+
gutter_ids,
|
|
504
|
+
gutter_tolerance_pt,
|
|
505
|
+
{"gutter": gaps},
|
|
506
|
+
"Comparable inter-panel gutters are not uniform",
|
|
507
|
+
)
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
edge_coordinates = {"left": 0, "bottom": 1, "right": 2, "top": 3}
|
|
511
|
+
for group in layout["boundary_groups"]:
|
|
512
|
+
edge = str(group["edge"])
|
|
513
|
+
check_name = "row" if edge in {"top", "bottom"} else "column"
|
|
514
|
+
panel_ids = [
|
|
515
|
+
panel_id
|
|
516
|
+
for panel_id in group["panels"]
|
|
517
|
+
if not _is_exempt(layout, panel_id, check_name)
|
|
518
|
+
]
|
|
519
|
+
if len(panel_ids) < 2:
|
|
520
|
+
continue
|
|
521
|
+
comparisons += 1
|
|
522
|
+
coordinate = edge_coordinates[edge]
|
|
523
|
+
metrics = {
|
|
524
|
+
edge: {
|
|
525
|
+
panel_id: _bbox(panel_map[panel_id]["bbox_pt"])[coordinate]
|
|
526
|
+
for panel_id in panel_ids
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
if _metric_spread(metrics[edge]) > tolerance_pt:
|
|
530
|
+
findings.append(
|
|
531
|
+
_finding(
|
|
532
|
+
f"shared-{edge}-edge-misalignment",
|
|
533
|
+
group,
|
|
534
|
+
panel_ids,
|
|
535
|
+
tolerance_pt,
|
|
536
|
+
metrics,
|
|
537
|
+
f"Panels meeting the same grid {edge} boundary are not aligned",
|
|
538
|
+
)
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
if edge not in {"top", "left"}:
|
|
542
|
+
continue
|
|
543
|
+
anchors = {
|
|
544
|
+
panel_id: panel_map[panel_id].get("panel_label_anchor_pt")
|
|
545
|
+
for panel_id in panel_ids
|
|
546
|
+
}
|
|
547
|
+
missing_labels = [panel_id for panel_id, anchor in anchors.items() if anchor is None]
|
|
548
|
+
if require_panel_labels and missing_labels:
|
|
549
|
+
findings.append(
|
|
550
|
+
{
|
|
551
|
+
"severity": "WARN",
|
|
552
|
+
"kind": "panel-label-not-auditable",
|
|
553
|
+
"group": group["id"],
|
|
554
|
+
"panels": missing_labels,
|
|
555
|
+
"message": "Comparable panels are missing detectable top-left panel-label anchors",
|
|
556
|
+
}
|
|
557
|
+
)
|
|
558
|
+
label_ids = [
|
|
559
|
+
panel_id
|
|
560
|
+
for panel_id, anchor in anchors.items()
|
|
561
|
+
if anchor is not None and not _is_exempt(layout, panel_id, "panel-label")
|
|
562
|
+
]
|
|
563
|
+
if len(label_ids) < 2:
|
|
564
|
+
continue
|
|
565
|
+
label_coordinate = 1 if edge == "top" else 0
|
|
566
|
+
metric_name = "label-y" if edge == "top" else "label-x"
|
|
567
|
+
label_metrics = {
|
|
568
|
+
metric_name: {
|
|
569
|
+
panel_id: float(anchors[panel_id][label_coordinate])
|
|
570
|
+
for panel_id in label_ids
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
if _metric_spread(label_metrics[metric_name]) > tolerance_pt:
|
|
574
|
+
findings.append(
|
|
575
|
+
_finding(
|
|
576
|
+
f"shared-{edge}-panel-label-misalignment",
|
|
577
|
+
group,
|
|
578
|
+
label_ids,
|
|
579
|
+
tolerance_pt,
|
|
580
|
+
label_metrics,
|
|
581
|
+
"Panel-label anchors are not aligned at the shared grid boundary",
|
|
582
|
+
)
|
|
583
|
+
)
|
|
584
|
+
fail_count = sum(finding["severity"] == "FAIL" for finding in findings)
|
|
585
|
+
warn_count = sum(finding["severity"] == "WARN" for finding in findings)
|
|
586
|
+
auditable = comparisons > 0
|
|
587
|
+
verdict = "FIX BEFORE DELIVERY" if fail_count else "REVIEW REQUIRED" if warn_count else "PASS"
|
|
588
|
+
if not auditable:
|
|
589
|
+
verdict = "NOT AUDITABLE"
|
|
590
|
+
return {
|
|
591
|
+
"schema_version": SCHEMA_VERSION,
|
|
592
|
+
"applicable": True,
|
|
593
|
+
"auditable": auditable,
|
|
594
|
+
"verdict": verdict,
|
|
595
|
+
"backend": layout["backend"],
|
|
596
|
+
"summary": {
|
|
597
|
+
"fail": fail_count,
|
|
598
|
+
"warn": warn_count,
|
|
599
|
+
"comparisons": comparisons,
|
|
600
|
+
"exemptions": len(layout["exemptions"]),
|
|
601
|
+
},
|
|
602
|
+
"tolerances": {
|
|
603
|
+
"alignment_pt": tolerance_pt,
|
|
604
|
+
"gutter_pt": gutter_tolerance_pt,
|
|
605
|
+
},
|
|
606
|
+
"layout": layout,
|
|
607
|
+
"findings": findings,
|
|
608
|
+
**(
|
|
609
|
+
{"errors": ["No comparable row, column or shared-boundary groups were declared or inferred"]}
|
|
610
|
+
if not auditable
|
|
611
|
+
else {}
|
|
612
|
+
),
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def exit_code(report: Mapping[str, Any], strict: bool = False) -> int:
|
|
617
|
+
if report.get("verdict") == "NOT APPLICABLE":
|
|
618
|
+
return 0
|
|
619
|
+
if not report.get("auditable") or report.get("verdict") == "NOT AUDITABLE":
|
|
620
|
+
return 2
|
|
621
|
+
if int(report.get("summary", {}).get("fail", 0)):
|
|
622
|
+
return 1
|
|
623
|
+
if strict and int(report.get("summary", {}).get("warn", 0)):
|
|
624
|
+
return 1
|
|
625
|
+
return 0
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def render_text(report: Mapping[str, Any], strict: bool = False) -> str:
|
|
629
|
+
summary = report.get("summary", {})
|
|
630
|
+
lines = [
|
|
631
|
+
f"Panel alignment: {report.get('verdict', 'UNKNOWN')}",
|
|
632
|
+
f" comparisons: {summary.get('comparisons', 0)}",
|
|
633
|
+
f" fail: {summary.get('fail', 0)}",
|
|
634
|
+
f" warn: {summary.get('warn', 0)}",
|
|
635
|
+
f" exemptions: {summary.get('exemptions', 0)}",
|
|
636
|
+
]
|
|
637
|
+
for error in report.get("errors", []):
|
|
638
|
+
lines.append(f" ERROR: {error}")
|
|
639
|
+
for finding in report.get("findings", []):
|
|
640
|
+
lines.append(
|
|
641
|
+
f" {finding['severity']} {finding['kind']} [{finding.get('group', 'n/a')}]: "
|
|
642
|
+
f"{finding['message']} ({', '.join(finding.get('panels', []))})"
|
|
643
|
+
)
|
|
644
|
+
if strict and summary.get("warn", 0):
|
|
645
|
+
lines.append(" strict mode: WARN findings block delivery")
|
|
646
|
+
return "\n".join(lines)
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def write_json_report(report: Mapping[str, Any], path: str | Path) -> None:
|
|
650
|
+
_atomic_write_text(Path(path), json.dumps(report, indent=2, ensure_ascii=False) + "\n")
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def write_overlay_svg(report: Mapping[str, Any], path: str | Path) -> None:
|
|
654
|
+
layout = report.get("layout")
|
|
655
|
+
if not isinstance(layout, Mapping):
|
|
656
|
+
raise ValueError("an auditable layout is required for the diagnostic SVG")
|
|
657
|
+
width = float(layout["figure"]["width_pt"])
|
|
658
|
+
height = float(layout["figure"]["height_pt"])
|
|
659
|
+
failed_panels = {
|
|
660
|
+
panel for finding in report.get("findings", []) if finding.get("severity") == "FAIL"
|
|
661
|
+
for panel in finding.get("panels", [])
|
|
662
|
+
}
|
|
663
|
+
rows = [
|
|
664
|
+
f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}pt" height="{height}pt" viewBox="0 0 {width} {height}">',
|
|
665
|
+
'<rect width="100%" height="100%" fill="white"/>',
|
|
666
|
+
]
|
|
667
|
+
for panel in layout["panels"]:
|
|
668
|
+
left, bottom, right, top = _bbox(panel["bbox_pt"])
|
|
669
|
+
y = height - top
|
|
670
|
+
color = "#d7191c" if panel["id"] in failed_panels else "#2c7bb6"
|
|
671
|
+
rows.append(
|
|
672
|
+
f'<rect x="{left:.3f}" y="{y:.3f}" width="{right-left:.3f}" height="{top-bottom:.3f}" '
|
|
673
|
+
f'fill="none" stroke="{color}" stroke-width="1.2"/>'
|
|
674
|
+
)
|
|
675
|
+
rows.append(
|
|
676
|
+
f'<text x="{left+2:.3f}" y="{y+10:.3f}" font-family="Arial, sans-serif" font-size="8" '
|
|
677
|
+
f'fill="{color}">{html.escape(str(panel["id"]))}</text>'
|
|
678
|
+
)
|
|
679
|
+
rows.append(
|
|
680
|
+
f'<text x="4" y="{height-5:.3f}" font-family="Arial, sans-serif" font-size="7" fill="#333">'
|
|
681
|
+
f'{report.get("verdict", "UNKNOWN")}: {report.get("summary", {}).get("fail", 0)} fail, '
|
|
682
|
+
f'{report.get("summary", {}).get("warn", 0)} warn</text>'
|
|
683
|
+
)
|
|
684
|
+
rows.append("</svg>")
|
|
685
|
+
_atomic_write_text(Path(path), "\n".join(rows) + "\n")
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def _alphabetic_id(index: int) -> str:
|
|
689
|
+
output = ""
|
|
690
|
+
value = index + 1
|
|
691
|
+
while value:
|
|
692
|
+
value, remainder = divmod(value - 1, 26)
|
|
693
|
+
output = chr(ord("a") + remainder) + output
|
|
694
|
+
return output
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def _font_is_bold(weight: Any) -> bool:
|
|
698
|
+
if isinstance(weight, (int, float)):
|
|
699
|
+
return float(weight) >= 600
|
|
700
|
+
return str(weight).lower() in {"bold", "semibold", "demibold", "heavy", "black"}
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def _matplotlib_panel_label_anchor(ax: Any) -> tuple[str, list[float]] | None:
|
|
704
|
+
for artist in ax.texts:
|
|
705
|
+
label = artist.get_text().strip()
|
|
706
|
+
if not re.fullmatch(r"[a-z]", label) or not _font_is_bold(artist.get_fontweight()):
|
|
707
|
+
continue
|
|
708
|
+
display = artist.get_transform().transform(artist.get_position())
|
|
709
|
+
axes_xy = ax.transAxes.inverted().transform(display)
|
|
710
|
+
if not (-0.35 <= axes_xy[0] <= 0.2 and 0.8 <= axes_xy[1] <= 1.35):
|
|
711
|
+
continue
|
|
712
|
+
inches = ax.figure.dpi_scale_trans.inverted().transform(display)
|
|
713
|
+
return label, [float(inches[0] * 72), float(inches[1] * 72)]
|
|
714
|
+
return None
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def matplotlib_layout_manifest(
|
|
718
|
+
fig: Any,
|
|
719
|
+
*,
|
|
720
|
+
axes: Sequence[Any] | None = None,
|
|
721
|
+
panel_ids: Mapping[Any, str] | Sequence[str] | None = None,
|
|
722
|
+
row_groups: Sequence[Any] | None = None,
|
|
723
|
+
column_groups: Sequence[Any] | None = None,
|
|
724
|
+
exclude_axes: Iterable[Any] = (),
|
|
725
|
+
exemptions: Sequence[Mapping[str, Any]] = (),
|
|
726
|
+
) -> dict[str, Any]:
|
|
727
|
+
"""Measure final Matplotlib axes rectangles in physical points."""
|
|
728
|
+
fig.canvas.draw()
|
|
729
|
+
width_in, height_in = (float(value) for value in fig.get_size_inches())
|
|
730
|
+
width_pt, height_pt = width_in * 72, height_in * 72
|
|
731
|
+
excluded = {id(axis) for axis in exclude_axes}
|
|
732
|
+
candidates = list(axes) if axes is not None else list(fig.axes)
|
|
733
|
+
selected: list[Any] = []
|
|
734
|
+
seen_subplot_cells: set[tuple[int, int, int]] = set()
|
|
735
|
+
for axis in candidates:
|
|
736
|
+
if id(axis) in excluded or not axis.get_visible():
|
|
737
|
+
continue
|
|
738
|
+
axis_label = str(axis.get_label() or "")
|
|
739
|
+
if axis_label.startswith("<colorbar"):
|
|
740
|
+
continue
|
|
741
|
+
try:
|
|
742
|
+
subplot_spec = axis.get_subplotspec()
|
|
743
|
+
except AttributeError:
|
|
744
|
+
subplot_spec = None
|
|
745
|
+
if subplot_spec is not None and panel_ids is None:
|
|
746
|
+
subplot_key = (
|
|
747
|
+
id(subplot_spec.get_gridspec()),
|
|
748
|
+
int(subplot_spec.num1),
|
|
749
|
+
int(subplot_spec.num2),
|
|
750
|
+
)
|
|
751
|
+
if subplot_key in seen_subplot_cells:
|
|
752
|
+
continue
|
|
753
|
+
seen_subplot_cells.add(subplot_key)
|
|
754
|
+
selected.append(axis)
|
|
755
|
+
|
|
756
|
+
if isinstance(panel_ids, Mapping):
|
|
757
|
+
ids = [str(panel_ids.get(axis) or "") for axis in selected]
|
|
758
|
+
elif panel_ids is not None:
|
|
759
|
+
ids = [str(value) for value in panel_ids]
|
|
760
|
+
if len(ids) != len(selected):
|
|
761
|
+
raise ValueError("panel_ids length must match the selected Matplotlib axes")
|
|
762
|
+
else:
|
|
763
|
+
ids = []
|
|
764
|
+
for index, axis in enumerate(selected):
|
|
765
|
+
candidate = str(axis.get_gid() or axis.get_label() or "").strip()
|
|
766
|
+
ids.append(candidate if candidate and not candidate.startswith("<") else _alphabetic_id(index))
|
|
767
|
+
|
|
768
|
+
grid_ids: dict[int, str] = {}
|
|
769
|
+
panels: list[dict[str, Any]] = []
|
|
770
|
+
for axis, panel_id in zip(selected, ids):
|
|
771
|
+
position = axis.get_position(original=False)
|
|
772
|
+
panel: dict[str, Any] = {
|
|
773
|
+
"id": panel_id,
|
|
774
|
+
"bbox_pt": [
|
|
775
|
+
float(position.x0 * width_pt),
|
|
776
|
+
float(position.y0 * height_pt),
|
|
777
|
+
float(position.x1 * width_pt),
|
|
778
|
+
float(position.y1 * height_pt),
|
|
779
|
+
],
|
|
780
|
+
}
|
|
781
|
+
try:
|
|
782
|
+
subplot_spec = axis.get_subplotspec()
|
|
783
|
+
except AttributeError:
|
|
784
|
+
subplot_spec = None
|
|
785
|
+
if subplot_spec is not None:
|
|
786
|
+
grid_spec = subplot_spec.get_gridspec()
|
|
787
|
+
grid_key = id(grid_spec)
|
|
788
|
+
if grid_key not in grid_ids:
|
|
789
|
+
grid_ids[grid_key] = f"matplotlib-grid-{len(grid_ids) + 1}"
|
|
790
|
+
panel.update(
|
|
791
|
+
{
|
|
792
|
+
"grid_id": grid_ids[grid_key],
|
|
793
|
+
"row_start": int(subplot_spec.rowspan.start),
|
|
794
|
+
"row_stop": int(subplot_spec.rowspan.stop),
|
|
795
|
+
"col_start": int(subplot_spec.colspan.start),
|
|
796
|
+
"col_stop": int(subplot_spec.colspan.stop),
|
|
797
|
+
}
|
|
798
|
+
)
|
|
799
|
+
label_anchor = _matplotlib_panel_label_anchor(axis)
|
|
800
|
+
if label_anchor is not None:
|
|
801
|
+
panel["panel_label"] = label_anchor[0]
|
|
802
|
+
panel["panel_label_anchor_pt"] = label_anchor[1]
|
|
803
|
+
panels.append(panel)
|
|
804
|
+
|
|
805
|
+
manifest: dict[str, Any] = {
|
|
806
|
+
"schema_version": SCHEMA_VERSION,
|
|
807
|
+
"backend": "python-matplotlib",
|
|
808
|
+
"figure": {"width_pt": width_pt, "height_pt": height_pt},
|
|
809
|
+
"panels": panels,
|
|
810
|
+
"exemptions": [dict(exemption) for exemption in exemptions],
|
|
811
|
+
}
|
|
812
|
+
if row_groups is not None:
|
|
813
|
+
manifest["row_groups"] = list(row_groups)
|
|
814
|
+
if column_groups is not None:
|
|
815
|
+
manifest["column_groups"] = list(column_groups)
|
|
816
|
+
return manifest
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
def require_matplotlib_panel_alignment(
|
|
820
|
+
fig: Any,
|
|
821
|
+
*,
|
|
822
|
+
json_out: str | Path | None = None,
|
|
823
|
+
overlay_svg: str | Path | None = None,
|
|
824
|
+
tolerance_pt: float = DEFAULT_TOLERANCE_PT,
|
|
825
|
+
gutter_tolerance_pt: float = DEFAULT_GUTTER_TOLERANCE_PT,
|
|
826
|
+
require_panel_labels: bool = False,
|
|
827
|
+
strict: bool = False,
|
|
828
|
+
**manifest_options: Any,
|
|
829
|
+
) -> dict[str, Any]:
|
|
830
|
+
"""Measure and block delivery when a Matplotlib multi-panel layout is misaligned."""
|
|
831
|
+
manifest = matplotlib_layout_manifest(fig, **manifest_options)
|
|
832
|
+
report = audit_layout_manifest(
|
|
833
|
+
manifest,
|
|
834
|
+
tolerance_pt=tolerance_pt,
|
|
835
|
+
gutter_tolerance_pt=gutter_tolerance_pt,
|
|
836
|
+
require_panel_labels=require_panel_labels,
|
|
837
|
+
)
|
|
838
|
+
if json_out is not None:
|
|
839
|
+
write_json_report(report, json_out)
|
|
840
|
+
if overlay_svg is not None and report.get("layout"):
|
|
841
|
+
write_overlay_svg(report, overlay_svg)
|
|
842
|
+
code = exit_code(report, strict=strict)
|
|
843
|
+
if code:
|
|
844
|
+
raise PanelAlignmentError(render_text(report, strict=strict))
|
|
845
|
+
return report
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
def run_self_tests() -> None:
|
|
849
|
+
aligned = {
|
|
850
|
+
"schema_version": 1,
|
|
851
|
+
"backend": "self-test",
|
|
852
|
+
"figure": {"width_pt": 300, "height_pt": 200},
|
|
853
|
+
"panels": [
|
|
854
|
+
{"id": "a", "bbox_pt": [20, 110, 130, 180], "grid_id": "g", "row_start": 0, "row_stop": 1, "col_start": 0, "col_stop": 1},
|
|
855
|
+
{"id": "b", "bbox_pt": [170, 110, 280, 180], "grid_id": "g", "row_start": 0, "row_stop": 1, "col_start": 1, "col_stop": 2},
|
|
856
|
+
{"id": "c", "bbox_pt": [20, 20, 130, 90], "grid_id": "g", "row_start": 1, "row_stop": 2, "col_start": 0, "col_stop": 1},
|
|
857
|
+
{"id": "d", "bbox_pt": [170, 20, 280, 90], "grid_id": "g", "row_start": 1, "row_stop": 2, "col_start": 1, "col_stop": 2},
|
|
858
|
+
],
|
|
859
|
+
}
|
|
860
|
+
if exit_code(audit_layout_manifest(aligned)) != 0:
|
|
861
|
+
raise AssertionError("aligned self-test layout did not pass")
|
|
862
|
+
shifted = json.loads(json.dumps(aligned))
|
|
863
|
+
shifted["panels"][1]["bbox_pt"][1] -= 5
|
|
864
|
+
if exit_code(audit_layout_manifest(shifted)) != 1:
|
|
865
|
+
raise AssertionError("shifted self-test layout did not fail")
|
|
866
|
+
unequal_widths = {
|
|
867
|
+
"schema_version": 1,
|
|
868
|
+
"backend": "self-test",
|
|
869
|
+
"figure": {"width_pt": 320, "height_pt": 100},
|
|
870
|
+
"panels": [
|
|
871
|
+
{"id": "a", "bbox_pt": [10, 20, 70, 80], "grid_id": "g", "row_start": 0, "row_stop": 1, "col_start": 0, "col_stop": 1},
|
|
872
|
+
{"id": "b", "bbox_pt": [90, 20, 170, 80], "grid_id": "g", "row_start": 0, "row_stop": 1, "col_start": 1, "col_stop": 2},
|
|
873
|
+
{"id": "c", "bbox_pt": [190, 20, 290, 80], "grid_id": "g", "row_start": 0, "row_stop": 1, "col_start": 2, "col_stop": 3},
|
|
874
|
+
],
|
|
875
|
+
}
|
|
876
|
+
unequal_report = audit_layout_manifest(unequal_widths)
|
|
877
|
+
if exit_code(unequal_report) != 1 or not any(
|
|
878
|
+
finding.get("kind") == "horizontal-panel-width-misalignment"
|
|
879
|
+
for finding in unequal_report.get("findings", [])
|
|
880
|
+
):
|
|
881
|
+
raise AssertionError("unequal-width self-test row did not fail")
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
885
|
+
parser = argparse.ArgumentParser(
|
|
886
|
+
description="Audit rendered multi-panel axes alignment from a JSON layout manifest."
|
|
887
|
+
)
|
|
888
|
+
parser.add_argument("layout", nargs="?", help="backend-neutral panel-layout JSON")
|
|
889
|
+
parser.add_argument("--json", action="store_true", help="print the report as JSON")
|
|
890
|
+
parser.add_argument("--json-out", help="write the report atomically as JSON")
|
|
891
|
+
parser.add_argument("--overlay-svg", help="write a QA-only SVG of measured panel rectangles")
|
|
892
|
+
parser.add_argument("--tolerance-pt", type=float, default=DEFAULT_TOLERANCE_PT)
|
|
893
|
+
parser.add_argument("--gutter-tolerance-pt", type=float, default=DEFAULT_GUTTER_TOLERANCE_PT)
|
|
894
|
+
parser.add_argument("--require-panel-labels", action="store_true")
|
|
895
|
+
parser.add_argument("--strict", action="store_true", help="make WARN findings blocking")
|
|
896
|
+
parser.add_argument("--self-test", action="store_true", help="run dependency-free core tests")
|
|
897
|
+
return parser
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
901
|
+
parser = build_parser()
|
|
902
|
+
args = parser.parse_args(argv)
|
|
903
|
+
if args.self_test:
|
|
904
|
+
run_self_tests()
|
|
905
|
+
print("Panel alignment self-test: PASS")
|
|
906
|
+
return 0
|
|
907
|
+
if not args.layout:
|
|
908
|
+
parser.error("layout JSON is required unless --self-test is used")
|
|
909
|
+
try:
|
|
910
|
+
manifest = json.loads(Path(args.layout).read_text(encoding="utf-8"))
|
|
911
|
+
report = audit_layout_manifest(
|
|
912
|
+
manifest,
|
|
913
|
+
tolerance_pt=args.tolerance_pt,
|
|
914
|
+
gutter_tolerance_pt=args.gutter_tolerance_pt,
|
|
915
|
+
require_panel_labels=args.require_panel_labels,
|
|
916
|
+
)
|
|
917
|
+
except (OSError, json.JSONDecodeError, TypeError, ValueError) as exc:
|
|
918
|
+
report = {
|
|
919
|
+
"schema_version": SCHEMA_VERSION,
|
|
920
|
+
"auditable": False,
|
|
921
|
+
"verdict": "NOT AUDITABLE",
|
|
922
|
+
"summary": {"fail": 0, "warn": 0, "comparisons": 0, "exemptions": 0},
|
|
923
|
+
"errors": [str(exc)],
|
|
924
|
+
"findings": [],
|
|
925
|
+
}
|
|
926
|
+
if args.json_out:
|
|
927
|
+
write_json_report(report, args.json_out)
|
|
928
|
+
if args.overlay_svg and report.get("layout"):
|
|
929
|
+
write_overlay_svg(report, args.overlay_svg)
|
|
930
|
+
print(json.dumps(report, indent=2, ensure_ascii=False) if args.json else render_text(report, strict=args.strict))
|
|
931
|
+
return exit_code(report, strict=args.strict)
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
if __name__ == "__main__":
|
|
935
|
+
raise SystemExit(main())
|