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,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())
|