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.
Files changed (40) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +103 -0
  3. package/agents/bioresearcher-dr-worker.md +54 -0
  4. package/connector-meta.json +23 -0
  5. package/index.js +77 -0
  6. package/loader.js +3 -0
  7. package/package.json +42 -0
  8. package/skill-bundle.json +12 -0
  9. package/skills/bioresearcher-deep-research/SKILL.md +330 -0
  10. package/skills/bioresearcher-deep-research/references/analysis-methods.md +90 -0
  11. package/skills/bioresearcher-deep-research/references/article-literature.md +89 -0
  12. package/skills/bioresearcher-deep-research/references/best-practices.md +102 -0
  13. package/skills/bioresearcher-deep-research/references/citations.md +146 -0
  14. package/skills/bioresearcher-deep-research/references/clinical-trials.md +87 -0
  15. package/skills/bioresearcher-deep-research/references/diseases.md +94 -0
  16. package/skills/bioresearcher-deep-research/references/drugs.md +88 -0
  17. package/skills/bioresearcher-deep-research/references/ensembl-pdb.md +134 -0
  18. package/skills/bioresearcher-deep-research/references/functional-genomics.md +118 -0
  19. package/skills/bioresearcher-deep-research/references/genes.md +93 -0
  20. package/skills/bioresearcher-deep-research/references/optional-analysis.md +108 -0
  21. package/skills/bioresearcher-deep-research/references/patents.md +92 -0
  22. package/skills/bioresearcher-deep-research/references/rate-limiting-auth.md +95 -0
  23. package/skills/bioresearcher-deep-research/references/report-template.md +117 -0
  24. package/skills/bioresearcher-deep-research/references/tool-selection.md +142 -0
  25. package/skills/bioresearcher-deep-research/references/utility-config.md +116 -0
  26. package/skills/bioresearcher-deep-research/references/variants.md +109 -0
  27. package/skills/bioresearcher-deep-research/references/worker-protocol.md +110 -0
  28. package/skills/bioresearcher-deep-research/scripts/markdown-to-html.py +86 -0
  29. package/skills/bioresearcher-plot-making/SKILL.md +97 -0
  30. package/skills/bioresearcher-plot-making/references/literature-search-method-summary.md +163 -0
  31. package/skills/bioresearcher-plot-making/references/qa-gates-and-gotchas.md +156 -0
  32. package/skills/bioresearcher-plot-making/references/structural-biology-binder-visualization.md +206 -0
  33. package/skills/bioresearcher-plot-making/scripts/audit_figure_collisions.py +742 -0
  34. package/skills/bioresearcher-plot-making/scripts/audit_panel_alignment.py +935 -0
  35. package/skills/bioresearcher-plot-making/scripts/audit_pdf_text.py +152 -0
  36. package/skills/bioresearcher-plot-making/scripts/plot_helpers.py +177 -0
  37. package/skills/bioresearcher-pubmed-weekly/SKILL.md +223 -0
  38. package/skills/bioresearcher-pubmed-weekly/scripts/parse_updatefiles.py +272 -0
  39. package/skills/bioresearcher-pubmed-weekly/scripts/pubmed_weekly.py +493 -0
  40. package/skills/bioresearcher-python-setup-uv/SKILL.md +184 -0
@@ -0,0 +1,742 @@
1
+ #!/usr/bin/env python3
2
+ """Audit rendered PDF figures for probable text and graphic collisions.
3
+
4
+ The audit is backend-neutral: Python and R figures are both checked from their
5
+ final PDF geometry. Reliable collisions (text-text, text crossed by a stroked
6
+ path, and text clipped by the page) block delivery. Partial text overlap with a
7
+ filled shape or raster image is reported for review because in-bar labels,
8
+ heatmap values, microscopy annotations, and scale bars can be intentional.
9
+
10
+ PyMuPDF is imported lazily so ``--self-test`` can exercise the geometry core in
11
+ minimal CI environments. Install the runtime dependency with
12
+ ``VIRTUAL_ENV="$(pwd)/.venv" ./uv pip install pymupdf``.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import json
19
+ import math
20
+ import os
21
+ import sys
22
+ import tempfile
23
+ from dataclasses import asdict, dataclass, field
24
+ from itertools import combinations
25
+ from pathlib import Path
26
+ from typing import Any, Iterable, Sequence
27
+
28
+
29
+ Rect = tuple[float, float, float, float]
30
+ Point = tuple[float, float]
31
+ Segment = tuple[Point, Point]
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class TextBox:
36
+ index: int
37
+ text: str
38
+ bbox: Rect
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class StrokePath:
43
+ index: int
44
+ bbox: Rect
45
+ width: float
46
+ segments: tuple[Segment, ...]
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class FilledRegion:
51
+ index: int
52
+ bbox: Rect
53
+ source: str
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class TraceBox:
58
+ index: int
59
+ text: str
60
+ bbox: Rect
61
+
62
+
63
+ @dataclass
64
+ class PageGeometry:
65
+ page: int
66
+ bbox: Rect
67
+ texts: list[TextBox] = field(default_factory=list)
68
+ traces: list[TraceBox] = field(default_factory=list)
69
+ strokes: list[StrokePath] = field(default_factory=list)
70
+ fills: list[FilledRegion] = field(default_factory=list)
71
+ images: list[FilledRegion] = field(default_factory=list)
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class CollisionFinding:
76
+ severity: str
77
+ kind: str
78
+ page: int
79
+ message: str
80
+ text: str
81
+ text_bbox: Rect
82
+ other_text: str | None = None
83
+ other_bbox: Rect | None = None
84
+ object_count: int = 1
85
+ object_indexes: tuple[int, ...] = ()
86
+
87
+
88
+ def normalize_rect(rect: Sequence[float]) -> Rect:
89
+ x0, y0, x1, y1 = (float(value) for value in rect)
90
+ return min(x0, x1), min(y0, y1), max(x0, x1), max(y0, y1)
91
+
92
+
93
+ def rect_width(rect: Rect) -> float:
94
+ return max(0.0, rect[2] - rect[0])
95
+
96
+
97
+ def rect_height(rect: Rect) -> float:
98
+ return max(0.0, rect[3] - rect[1])
99
+
100
+
101
+ def rect_area(rect: Rect) -> float:
102
+ return rect_width(rect) * rect_height(rect)
103
+
104
+
105
+ def rect_intersection(first: Rect, second: Rect) -> Rect | None:
106
+ x0 = max(first[0], second[0])
107
+ y0 = max(first[1], second[1])
108
+ x1 = min(first[2], second[2])
109
+ y1 = min(first[3], second[3])
110
+ if x1 <= x0 or y1 <= y0:
111
+ return None
112
+ return x0, y0, x1, y1
113
+
114
+
115
+ def rect_overlap_ratio(first: Rect, second: Rect) -> float:
116
+ intersection = rect_intersection(first, second)
117
+ if intersection is None:
118
+ return 0.0
119
+ denominator = min(rect_area(first), rect_area(second))
120
+ return rect_area(intersection) / denominator if denominator > 0 else 0.0
121
+
122
+
123
+ def rect_contains(outer: Rect, inner: Rect, tolerance: float = 0.0) -> bool:
124
+ return (
125
+ inner[0] >= outer[0] - tolerance
126
+ and inner[1] >= outer[1] - tolerance
127
+ and inner[2] <= outer[2] + tolerance
128
+ and inner[3] <= outer[3] + tolerance
129
+ )
130
+
131
+
132
+ def rect_inset(rect: Rect, amount: float) -> Rect:
133
+ maximum = max(0.0, min(rect_width(rect), rect_height(rect)) * 0.2)
134
+ inset = min(max(0.0, amount), maximum)
135
+ return rect[0] + inset, rect[1] + inset, rect[2] - inset, rect[3] - inset
136
+
137
+
138
+ def rect_expand(rect: Rect, amount: float) -> Rect:
139
+ pad = max(0.0, amount)
140
+ return rect[0] - pad, rect[1] - pad, rect[2] + pad, rect[3] + pad
141
+
142
+
143
+ def union_rects(rectangles: Iterable[Rect]) -> Rect | None:
144
+ rows = list(rectangles)
145
+ if not rows:
146
+ return None
147
+ return (
148
+ min(row[0] for row in rows),
149
+ min(row[1] for row in rows),
150
+ max(row[2] for row in rows),
151
+ max(row[3] for row in rows),
152
+ )
153
+
154
+
155
+ def point_in_rect(point: Point, rect: Rect) -> bool:
156
+ return rect[0] <= point[0] <= rect[2] and rect[1] <= point[1] <= rect[3]
157
+
158
+
159
+ def segment_intersects_rect(segment: Segment, rect: Rect) -> bool:
160
+ """Return whether a line segment intersects a rectangle (Liang-Barsky)."""
161
+ (x0, y0), (x1, y1) = segment
162
+ if point_in_rect((x0, y0), rect) or point_in_rect((x1, y1), rect):
163
+ return True
164
+ dx = x1 - x0
165
+ dy = y1 - y0
166
+ p = (-dx, dx, -dy, dy)
167
+ q = (x0 - rect[0], rect[2] - x0, y0 - rect[1], rect[3] - y0)
168
+ lower, upper = 0.0, 1.0
169
+ for denominator, numerator in zip(p, q):
170
+ if abs(denominator) < 1e-12:
171
+ if numerator < 0:
172
+ return False
173
+ continue
174
+ ratio = numerator / denominator
175
+ if denominator < 0:
176
+ lower = max(lower, ratio)
177
+ else:
178
+ upper = min(upper, ratio)
179
+ if lower > upper:
180
+ return False
181
+ return True
182
+
183
+
184
+ def cubic_segments(points: Sequence[Point], steps: int = 16) -> list[Segment]:
185
+ if len(points) != 4:
186
+ return []
187
+ p0, p1, p2, p3 = points
188
+ samples: list[Point] = []
189
+ for index in range(steps + 1):
190
+ t = index / steps
191
+ u = 1.0 - t
192
+ samples.append(
193
+ (
194
+ u**3 * p0[0] + 3 * u**2 * t * p1[0] + 3 * u * t**2 * p2[0] + t**3 * p3[0],
195
+ u**3 * p0[1] + 3 * u**2 * t * p1[1] + 3 * u * t**2 * p2[1] + t**3 * p3[1],
196
+ )
197
+ )
198
+ return list(zip(samples, samples[1:]))
199
+
200
+
201
+ def rectangle_segments(rect: Rect) -> tuple[Segment, ...]:
202
+ x0, y0, x1, y1 = rect
203
+ return (
204
+ ((x0, y0), (x1, y0)),
205
+ ((x1, y0), (x1, y1)),
206
+ ((x1, y1), (x0, y1)),
207
+ ((x0, y1), (x0, y0)),
208
+ )
209
+
210
+
211
+ def _point(value: Any) -> Point:
212
+ return float(value.x), float(value.y)
213
+
214
+
215
+ def _segments_from_items(items: Sequence[Sequence[Any]]) -> tuple[Segment, ...]:
216
+ segments: list[Segment] = []
217
+ for item in items:
218
+ if not item:
219
+ continue
220
+ operator = item[0]
221
+ if operator == "l" and len(item) >= 3:
222
+ segments.append((_point(item[1]), _point(item[2])))
223
+ elif operator == "c" and len(item) >= 5:
224
+ segments.extend(cubic_segments([_point(value) for value in item[1:5]]))
225
+ elif operator == "re" and len(item) >= 2:
226
+ segments.extend(rectangle_segments(normalize_rect(tuple(item[1]))))
227
+ elif operator == "qu" and len(item) >= 2:
228
+ quad = item[1]
229
+ points = [_point(quad.ul), _point(quad.ur), _point(quad.lr), _point(quad.ll)]
230
+ segments.extend(zip(points, points[1:] + points[:1]))
231
+ return tuple(segments)
232
+
233
+
234
+ def _text_from_chars(chars: Sequence[Sequence[Any]]) -> str:
235
+ output: list[str] = []
236
+ for char in chars:
237
+ try:
238
+ codepoint = int(char[0])
239
+ output.append(chr(codepoint) if codepoint >= 0 else "�")
240
+ except (TypeError, ValueError, OverflowError):
241
+ output.append("�")
242
+ return "".join(output).strip()
243
+
244
+
245
+ def extract_pdf_geometry(path: Path) -> list[PageGeometry]:
246
+ try:
247
+ import pymupdf as fitz # type: ignore
248
+ except ImportError:
249
+ try:
250
+ import fitz # type: ignore
251
+ except ImportError as exc:
252
+ raise RuntimeError(
253
+ "PyMuPDF is required for rendered collision QA. Install it with: "
254
+ 'VIRTUAL_ENV="$(pwd)/.venv" ./uv pip install pymupdf'
255
+ ) from exc
256
+
257
+ document = fitz.open(path)
258
+ pages: list[PageGeometry] = []
259
+ try:
260
+ for page_index, page in enumerate(document, 1):
261
+ geometry = PageGeometry(page=page_index, bbox=normalize_rect(tuple(page.rect)))
262
+
263
+ trace_rows: list[TraceBox] = []
264
+ for trace_index, trace in enumerate(page.get_texttrace()):
265
+ text = _text_from_chars(trace.get("chars", ()))
266
+ if not text:
267
+ continue
268
+ trace_rows.append(
269
+ TraceBox(index=trace_index, text=text, bbox=normalize_rect(trace["bbox"]))
270
+ )
271
+ geometry.traces.extend(trace_rows)
272
+
273
+ text_dict = page.get_text("dict")
274
+ text_index = 0
275
+ used_trace_indexes: set[int] = set()
276
+ for block in text_dict.get("blocks", []):
277
+ if block.get("type") != 0:
278
+ continue
279
+ for line in block.get("lines", []):
280
+ spans = [span for span in line.get("spans", []) if span.get("text", "").strip()]
281
+ if not spans:
282
+ continue
283
+ loose_bbox = union_rects(normalize_rect(span["bbox"]) for span in spans)
284
+ if loose_bbox is None:
285
+ continue
286
+ matched_traces = [
287
+ trace
288
+ for trace in trace_rows
289
+ if trace.index not in used_trace_indexes
290
+ and rect_overlap_ratio(trace.bbox, loose_bbox) >= 0.3
291
+ ]
292
+ bbox = union_rects(trace.bbox for trace in matched_traces) or loose_bbox
293
+ used_trace_indexes.update(trace.index for trace in matched_traces)
294
+ text = "".join(span.get("text", "") for span in spans).strip()
295
+ geometry.texts.append(TextBox(index=text_index, text=text, bbox=bbox))
296
+ text_index += 1
297
+
298
+ for trace in trace_rows:
299
+ if trace.index in used_trace_indexes:
300
+ continue
301
+ if rect_intersection(trace.bbox, geometry.bbox) is None:
302
+ continue
303
+ geometry.texts.append(
304
+ TextBox(index=text_index, text=trace.text, bbox=trace.bbox)
305
+ )
306
+ text_index += 1
307
+
308
+ for drawing_index, drawing in enumerate(page.get_drawings()):
309
+ drawing_type = str(drawing.get("type", ""))
310
+ drawing_bbox = normalize_rect(tuple(drawing["rect"]))
311
+ if "s" in drawing_type and drawing.get("stroke_opacity", 1.0) not in (None, 0):
312
+ segments = _segments_from_items(drawing.get("items", ()))
313
+ if segments:
314
+ geometry.strokes.append(
315
+ StrokePath(
316
+ index=drawing_index,
317
+ bbox=drawing_bbox,
318
+ width=float(drawing.get("width") or 0.0),
319
+ segments=segments,
320
+ )
321
+ )
322
+ if "f" in drawing_type and drawing.get("fill_opacity", 1.0) not in (None, 0):
323
+ rectangle_items = [item for item in drawing.get("items", ()) if item and item[0] == "re"]
324
+ if rectangle_items:
325
+ for offset, item in enumerate(rectangle_items):
326
+ geometry.fills.append(
327
+ FilledRegion(
328
+ index=drawing_index * 1000 + offset,
329
+ bbox=normalize_rect(tuple(item[1])),
330
+ source="fill",
331
+ )
332
+ )
333
+ else:
334
+ geometry.fills.append(
335
+ FilledRegion(index=drawing_index, bbox=drawing_bbox, source="fill")
336
+ )
337
+
338
+ image_index = 0
339
+ seen_images: set[tuple[int, int, int, int]] = set()
340
+ for image in page.get_images(full=True):
341
+ xref = int(image[0])
342
+ for image_rect in page.get_image_rects(xref):
343
+ bbox = normalize_rect(tuple(image_rect))
344
+ key = tuple(round(value * 10) for value in bbox)
345
+ if key in seen_images:
346
+ continue
347
+ seen_images.add(key)
348
+ geometry.images.append(
349
+ FilledRegion(index=image_index, bbox=bbox, source="image")
350
+ )
351
+ image_index += 1
352
+
353
+ pages.append(geometry)
354
+ finally:
355
+ document.close()
356
+ return pages
357
+
358
+
359
+ def _outside_distance(inner: Rect, outer: Rect) -> float:
360
+ return max(
361
+ outer[0] - inner[0],
362
+ outer[1] - inner[1],
363
+ inner[2] - outer[2],
364
+ inner[3] - outer[3],
365
+ 0.0,
366
+ )
367
+
368
+
369
+ def _finding_sort_key(finding: CollisionFinding) -> tuple[Any, ...]:
370
+ severity_order = {"FAIL": 0, "WARN": 1}
371
+ return severity_order.get(finding.severity, 2), finding.page, finding.kind, finding.text_bbox
372
+
373
+
374
+ def audit_page_geometry(
375
+ page: PageGeometry,
376
+ *,
377
+ text_inset_pt: float = 0.6,
378
+ min_overlap_ratio: float = 0.05,
379
+ clipping_tolerance_pt: float = 0.5,
380
+ background_area_ratio: float = 0.2,
381
+ ) -> tuple[list[CollisionFinding], dict[str, int]]:
382
+ findings: list[CollisionFinding] = []
383
+ info = {"contained_fill_overlays": 0, "contained_image_overlays": 0}
384
+
385
+ for first, second in combinations(page.texts, 2):
386
+ ratio = rect_overlap_ratio(first.bbox, second.bbox)
387
+ if ratio < min_overlap_ratio:
388
+ continue
389
+ findings.append(
390
+ CollisionFinding(
391
+ severity="FAIL",
392
+ kind="text-text",
393
+ page=page.page,
394
+ message=f"Text boxes overlap by {ratio:.1%} of the smaller box",
395
+ text=first.text,
396
+ text_bbox=first.bbox,
397
+ other_text=second.text,
398
+ other_bbox=second.bbox,
399
+ )
400
+ )
401
+
402
+ for text in page.texts:
403
+ inner = rect_inset(text.bbox, text_inset_pt)
404
+ hit_indexes: list[int] = []
405
+ hit_rectangles: list[Rect] = []
406
+ for stroke in page.strokes:
407
+ expanded = rect_expand(inner, max(0.0, stroke.width) / 2)
408
+ if rect_intersection(expanded, rect_expand(stroke.bbox, stroke.width / 2)) is None:
409
+ continue
410
+ if any(segment_intersects_rect(segment, expanded) for segment in stroke.segments):
411
+ hit_indexes.append(stroke.index)
412
+ hit_rectangles.append(stroke.bbox)
413
+ if hit_indexes:
414
+ findings.append(
415
+ CollisionFinding(
416
+ severity="FAIL",
417
+ kind="text-stroke",
418
+ page=page.page,
419
+ message=f"Text is crossed by {len(hit_indexes)} stroked path(s)",
420
+ text=text.text,
421
+ text_bbox=text.bbox,
422
+ other_bbox=union_rects(hit_rectangles),
423
+ object_count=len(hit_indexes),
424
+ object_indexes=tuple(hit_indexes),
425
+ )
426
+ )
427
+
428
+ page_area = max(rect_area(page.bbox), 1.0)
429
+ for regions, kind, info_key in (
430
+ (page.fills, "text-fill-edge", "contained_fill_overlays"),
431
+ (page.images, "text-image-edge", "contained_image_overlays"),
432
+ ):
433
+ for text in page.texts:
434
+ partial_indexes: list[int] = []
435
+ partial_rectangles: list[Rect] = []
436
+ for region in regions:
437
+ if rect_area(region.bbox) / page_area >= background_area_ratio:
438
+ continue
439
+ ratio = rect_overlap_ratio(text.bbox, region.bbox)
440
+ if ratio < min_overlap_ratio:
441
+ continue
442
+ if rect_contains(region.bbox, text.bbox, tolerance=0.25):
443
+ info[info_key] += 1
444
+ continue
445
+ partial_indexes.append(region.index)
446
+ partial_rectangles.append(region.bbox)
447
+ if partial_indexes:
448
+ label = "filled region" if kind == "text-fill-edge" else "raster image"
449
+ findings.append(
450
+ CollisionFinding(
451
+ severity="WARN",
452
+ kind=kind,
453
+ page=page.page,
454
+ message=f"Text partially overlaps the edge of {len(partial_indexes)} {label}(s)",
455
+ text=text.text,
456
+ text_bbox=text.bbox,
457
+ other_bbox=union_rects(partial_rectangles),
458
+ object_count=len(partial_indexes),
459
+ object_indexes=tuple(partial_indexes),
460
+ )
461
+ )
462
+
463
+ clipped_seen: set[tuple[str, tuple[int, int, int, int]]] = set()
464
+ for trace in page.traces:
465
+ if _outside_distance(trace.bbox, page.bbox) <= clipping_tolerance_pt:
466
+ continue
467
+ key = (trace.text, tuple(round(value * 10) for value in trace.bbox))
468
+ if key in clipped_seen:
469
+ continue
470
+ clipped_seen.add(key)
471
+ findings.append(
472
+ CollisionFinding(
473
+ severity="FAIL",
474
+ kind="text-page-clipping",
475
+ page=page.page,
476
+ message="Text extends beyond the final PDF page boundary",
477
+ text=trace.text,
478
+ text_bbox=trace.bbox,
479
+ other_bbox=page.bbox,
480
+ object_indexes=(trace.index,),
481
+ )
482
+ )
483
+
484
+ return sorted(findings, key=_finding_sort_key), info
485
+
486
+
487
+ def audit_geometries(
488
+ pages: Sequence[PageGeometry],
489
+ *,
490
+ text_inset_pt: float = 0.6,
491
+ min_overlap_ratio: float = 0.05,
492
+ clipping_tolerance_pt: float = 0.5,
493
+ ) -> dict[str, Any]:
494
+ findings: list[CollisionFinding] = []
495
+ contained_fill_overlays = 0
496
+ contained_image_overlays = 0
497
+ for page in pages:
498
+ page_findings, info = audit_page_geometry(
499
+ page,
500
+ text_inset_pt=text_inset_pt,
501
+ min_overlap_ratio=min_overlap_ratio,
502
+ clipping_tolerance_pt=clipping_tolerance_pt,
503
+ )
504
+ findings.extend(page_findings)
505
+ contained_fill_overlays += info["contained_fill_overlays"]
506
+ contained_image_overlays += info["contained_image_overlays"]
507
+
508
+ fail_count = sum(finding.severity == "FAIL" for finding in findings)
509
+ warn_count = sum(finding.severity == "WARN" for finding in findings)
510
+ visible_text_count = sum(len(page.texts) for page in pages)
511
+ trace_count = sum(len(page.traces) for page in pages)
512
+ auditable = visible_text_count > 0 or trace_count > 0
513
+ verdict = (
514
+ "NOT AUDITABLE"
515
+ if not auditable
516
+ else "FIX BEFORE DELIVERY"
517
+ if fail_count
518
+ else "REVIEW REQUIRED"
519
+ if warn_count
520
+ else "PASS"
521
+ )
522
+ return {
523
+ "auditable": auditable,
524
+ "verdict": verdict,
525
+ "page_count": len(pages),
526
+ "visible_text_box_count": visible_text_count,
527
+ "text_trace_count": trace_count,
528
+ "stroke_path_count": sum(len(page.strokes) for page in pages),
529
+ "filled_region_count": sum(len(page.fills) for page in pages),
530
+ "image_region_count": sum(len(page.images) for page in pages),
531
+ "summary": {
532
+ "fail": fail_count,
533
+ "warn": warn_count,
534
+ "contained_fill_overlays": contained_fill_overlays,
535
+ "contained_image_overlays": contained_image_overlays,
536
+ },
537
+ "thresholds": {
538
+ "text_inset_pt": text_inset_pt,
539
+ "min_overlap_ratio": min_overlap_ratio,
540
+ "clipping_tolerance_pt": clipping_tolerance_pt,
541
+ },
542
+ "findings": [asdict(finding) for finding in sorted(findings, key=_finding_sort_key)],
543
+ }
544
+
545
+
546
+ def audit_pdf(
547
+ path: Path,
548
+ *,
549
+ text_inset_pt: float = 0.6,
550
+ min_overlap_ratio: float = 0.05,
551
+ clipping_tolerance_pt: float = 0.5,
552
+ ) -> dict[str, Any]:
553
+ pages = extract_pdf_geometry(path)
554
+ return {
555
+ "pdf": str(path),
556
+ **audit_geometries(
557
+ pages,
558
+ text_inset_pt=text_inset_pt,
559
+ min_overlap_ratio=min_overlap_ratio,
560
+ clipping_tolerance_pt=clipping_tolerance_pt,
561
+ ),
562
+ }
563
+
564
+
565
+ def write_overlay_pdf(source: Path, destination: Path, findings: Sequence[dict[str, Any]]) -> None:
566
+ try:
567
+ import pymupdf as fitz # type: ignore
568
+ except ImportError:
569
+ try:
570
+ import fitz # type: ignore
571
+ except ImportError as exc:
572
+ raise RuntimeError("PyMuPDF is required to write the diagnostic overlay PDF") from exc
573
+ if source.resolve() == destination.resolve():
574
+ raise ValueError("overlay PDF must not overwrite the source PDF")
575
+ destination.parent.mkdir(parents=True, exist_ok=True)
576
+ descriptor, temporary_name = tempfile.mkstemp(
577
+ prefix=f".{destination.stem}.",
578
+ suffix=".tmp.pdf",
579
+ dir=destination.parent,
580
+ )
581
+ os.close(descriptor)
582
+ temporary = Path(temporary_name)
583
+ temporary.unlink()
584
+ try:
585
+ document = fitz.open(source)
586
+ try:
587
+ for finding in findings:
588
+ page_number = int(finding["page"]) - 1
589
+ if not 0 <= page_number < len(document):
590
+ continue
591
+ page = document[page_number]
592
+ color = (0.9, 0.05, 0.05) if finding["severity"] == "FAIL" else (1.0, 0.55, 0.0)
593
+ page.draw_rect(fitz.Rect(finding["text_bbox"]), color=color, width=1.2, overlay=True)
594
+ other_bbox = finding.get("other_bbox")
595
+ if other_bbox:
596
+ page.draw_rect(
597
+ fitz.Rect(other_bbox),
598
+ color=color,
599
+ width=0.6,
600
+ dashes="[2 2] 0",
601
+ overlay=True,
602
+ )
603
+ document.save(temporary)
604
+ finally:
605
+ document.close()
606
+ temporary.replace(destination)
607
+ finally:
608
+ if temporary.exists():
609
+ temporary.unlink()
610
+
611
+
612
+ def render_text(result: dict[str, Any], strict: bool = False) -> str:
613
+ summary = result["summary"]
614
+ lines = [
615
+ "Bioresearcher Plot Rendered Collision Audit",
616
+ f"pdf: {result.get('pdf', '<geometry fixture>')}",
617
+ f"pages: {result['page_count']}",
618
+ f"visible text boxes: {result['visible_text_box_count']}",
619
+ f"stroke paths: {result['stroke_path_count']}",
620
+ "",
621
+ ]
622
+ for finding in result["findings"]:
623
+ lines.append(
624
+ f"[{finding['severity']}] page {finding['page']} {finding['kind']}: "
625
+ f"{finding['message']} — {finding['text']!r}"
626
+ )
627
+ if finding.get("other_text"):
628
+ lines.append(f" other text: {finding['other_text']!r}")
629
+ lines.append(f" text bbox: {finding['text_bbox']}")
630
+ lines.extend(
631
+ [
632
+ "",
633
+ f"summary: {summary['fail']} fail, {summary['warn']} warn, "
634
+ f"{summary['contained_fill_overlays']} contained fill overlays, "
635
+ f"{summary['contained_image_overlays']} contained image overlays",
636
+ f"verdict: {result['verdict']}",
637
+ "note: contained overlays can be intentional; WARN findings need final-size visual review",
638
+ ]
639
+ )
640
+ if strict and summary["warn"]:
641
+ lines.append("strict verdict: FIX BEFORE DELIVERY (WARN is blocking)")
642
+ return "\n".join(lines)
643
+
644
+
645
+ def exit_code(result: dict[str, Any], strict: bool = False) -> int:
646
+ if not result["auditable"]:
647
+ return 2
648
+ summary = result["summary"]
649
+ if summary["fail"] or (strict and summary["warn"]):
650
+ return 1
651
+ return 0
652
+
653
+
654
+ def run_self_tests() -> None:
655
+ clean = PageGeometry(
656
+ page=1,
657
+ bbox=(0, 0, 200, 120),
658
+ texts=[TextBox(0, "Clear", (50, 20, 90, 32))],
659
+ traces=[TraceBox(0, "Clear", (50, 20, 90, 32))],
660
+ strokes=[StrokePath(0, (20, 70, 180, 70), 1.0, (((20, 70), (180, 70)),))],
661
+ )
662
+ clean_result = audit_geometries([clean])
663
+ assert clean_result["verdict"] == "PASS", clean_result
664
+
665
+ crossed = PageGeometry(
666
+ page=1,
667
+ bbox=(0, 0, 200, 120),
668
+ texts=[TextBox(0, "Crossed", (50, 40, 100, 54))],
669
+ traces=[TraceBox(0, "Crossed", (50, 40, 100, 54))],
670
+ strokes=[StrokePath(0, (20, 47, 180, 47), 1.0, (((20, 47), (180, 47)),))],
671
+ )
672
+ crossed_result = audit_geometries([crossed])
673
+ assert crossed_result["verdict"] == "FIX BEFORE DELIVERY", crossed_result
674
+ assert crossed_result["findings"][0]["kind"] == "text-stroke", crossed_result
675
+
676
+ intentional_fill = PageGeometry(
677
+ page=1,
678
+ bbox=(0, 0, 200, 120),
679
+ texts=[TextBox(0, "Inside", (60, 40, 90, 52))],
680
+ traces=[TraceBox(0, "Inside", (60, 40, 90, 52))],
681
+ fills=[FilledRegion(0, (50, 30, 100, 60), "fill")],
682
+ )
683
+ fill_result = audit_geometries([intentional_fill])
684
+ assert fill_result["verdict"] == "PASS", fill_result
685
+ assert fill_result["summary"]["contained_fill_overlays"] == 1, fill_result
686
+
687
+
688
+ def build_parser() -> argparse.ArgumentParser:
689
+ parser = argparse.ArgumentParser(description=__doc__)
690
+ parser.add_argument("pdf", nargs="?", type=Path, help="final exported PDF figure")
691
+ parser.add_argument("--json", action="store_true", help="print the report as JSON")
692
+ parser.add_argument("--json-out", type=Path, help="write a machine-readable JSON report")
693
+ parser.add_argument("--overlay-pdf", type=Path, help="write a QA-only PDF with collision boxes")
694
+ parser.add_argument("--strict", action="store_true", help="treat WARN findings as blocking")
695
+ parser.add_argument("--text-inset-pt", type=float, default=0.6)
696
+ parser.add_argument("--min-overlap-ratio", type=float, default=0.05)
697
+ parser.add_argument("--clipping-tolerance-pt", type=float, default=0.5)
698
+ parser.add_argument("--self-test", action="store_true", help="run dependency-free geometry tests")
699
+ return parser
700
+
701
+
702
+ def main(argv: list[str] | None = None) -> int:
703
+ args = build_parser().parse_args(argv)
704
+ if args.self_test:
705
+ run_self_tests()
706
+ print("audit_figure_collisions.py self-test: PASS")
707
+ return 0
708
+ if args.pdf is None:
709
+ print("error: PDF path is required unless --self-test is used", file=sys.stderr)
710
+ return 2
711
+ for name, value in (
712
+ ("--text-inset-pt", args.text_inset_pt),
713
+ ("--min-overlap-ratio", args.min_overlap_ratio),
714
+ ("--clipping-tolerance-pt", args.clipping_tolerance_pt),
715
+ ):
716
+ if value < 0 or not math.isfinite(value):
717
+ print(f"error: {name} must be a finite non-negative number", file=sys.stderr)
718
+ return 2
719
+ if not 0 <= args.min_overlap_ratio <= 1:
720
+ print("error: --min-overlap-ratio must be between 0 and 1", file=sys.stderr)
721
+ return 2
722
+ try:
723
+ result = audit_pdf(
724
+ args.pdf,
725
+ text_inset_pt=args.text_inset_pt,
726
+ min_overlap_ratio=args.min_overlap_ratio,
727
+ clipping_tolerance_pt=args.clipping_tolerance_pt,
728
+ )
729
+ if args.json_out:
730
+ args.json_out.parent.mkdir(parents=True, exist_ok=True)
731
+ args.json_out.write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
732
+ if args.overlay_pdf and result["findings"]:
733
+ write_overlay_pdf(args.pdf, args.overlay_pdf, result["findings"])
734
+ except (OSError, RuntimeError, ValueError) as exc:
735
+ print(f"error: {exc}", file=sys.stderr)
736
+ return 2
737
+ print(json.dumps(result, indent=2, ensure_ascii=False) if args.json else render_text(result, args.strict))
738
+ return exit_code(result, args.strict)
739
+
740
+
741
+ if __name__ == "__main__":
742
+ raise SystemExit(main())