sciwrite-lint 0.2.0__py3-none-any.whl
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.
- sciwrite_lint/__init__.py +3 -0
- sciwrite_lint/__main__.py +527 -0
- sciwrite_lint/_network.py +195 -0
- sciwrite_lint/api.py +1484 -0
- sciwrite_lint/checks/__init__.py +1 -0
- sciwrite_lint/checks/_section_utils.py +111 -0
- sciwrite_lint/checks/cite_purpose.py +122 -0
- sciwrite_lint/checks/claim_support.py +96 -0
- sciwrite_lint/checks/cross_section_consistency.py +185 -0
- sciwrite_lint/checks/dangling_cite.py +93 -0
- sciwrite_lint/checks/dangling_ref.py +116 -0
- sciwrite_lint/checks/full_paper_consistency.py +604 -0
- sciwrite_lint/checks/ref_internal_checks.py +919 -0
- sciwrite_lint/checks/reference_accuracy.py +277 -0
- sciwrite_lint/checks/reference_exists.py +119 -0
- sciwrite_lint/checks/reference_unreliable.py +244 -0
- sciwrite_lint/checks/registry.py +136 -0
- sciwrite_lint/checks/retracted_cite.py +96 -0
- sciwrite_lint/checks/structure_promises.py +115 -0
- sciwrite_lint/checks/unreferenced_figure.py +70 -0
- sciwrite_lint/claims.py +330 -0
- sciwrite_lint/claude_backend.py +94 -0
- sciwrite_lint/claude_cli.py +405 -0
- sciwrite_lint/cli/__init__.py +1 -0
- sciwrite_lint/cli/check.py +480 -0
- sciwrite_lint/cli/config.py +229 -0
- sciwrite_lint/cli/fetch.py +250 -0
- sciwrite_lint/cli/misc.py +1202 -0
- sciwrite_lint/cli/rank.py +470 -0
- sciwrite_lint/cli/verify.py +437 -0
- sciwrite_lint/config.py +646 -0
- sciwrite_lint/cross_paper.py +174 -0
- sciwrite_lint/eval_claims.py +1196 -0
- sciwrite_lint/fulltext.py +851 -0
- sciwrite_lint/llm_utils.py +386 -0
- sciwrite_lint/local_pdfs.py +122 -0
- sciwrite_lint/manuscript_store.py +674 -0
- sciwrite_lint/models.py +139 -0
- sciwrite_lint/pdf/__init__.py +1 -0
- sciwrite_lint/pdf/grobid.py +785 -0
- sciwrite_lint/pdf/pdf_download.py +258 -0
- sciwrite_lint/pipeline.py +2694 -0
- sciwrite_lint/prompt_safety.py +30 -0
- sciwrite_lint/rate_limiter.py +227 -0
- sciwrite_lint/references/__init__.py +1 -0
- sciwrite_lint/references/citations.py +715 -0
- sciwrite_lint/references/crossref.py +282 -0
- sciwrite_lint/references/embedding_store.py +380 -0
- sciwrite_lint/references/matching.py +273 -0
- sciwrite_lint/references/metadata.py +273 -0
- sciwrite_lint/references/reference_store.py +823 -0
- sciwrite_lint/references/retraction_watch.py +178 -0
- sciwrite_lint/references/workspace_db.py +1390 -0
- sciwrite_lint/report.py +163 -0
- sciwrite_lint/schemas.py +260 -0
- sciwrite_lint/scoring/__init__.py +1 -0
- sciwrite_lint/scoring/chain.py +716 -0
- sciwrite_lint/scoring/contribution.py +322 -0
- sciwrite_lint/scoring/scilint_score.py +611 -0
- sciwrite_lint/tex_parser.py +248 -0
- sciwrite_lint/usage.py +594 -0
- sciwrite_lint/vision/__init__.py +1 -0
- sciwrite_lint/vision/cache.py +140 -0
- sciwrite_lint/vision/describe.py +311 -0
- sciwrite_lint/vision/image_extraction.py +491 -0
- sciwrite_lint/vision/pipeline.py +207 -0
- sciwrite_lint/vllm/__init__.py +1 -0
- sciwrite_lint/vllm/metrics.py +157 -0
- sciwrite_lint/vllm/vllm_server.py +445 -0
- sciwrite_lint/web.py +369 -0
- sciwrite_lint-0.2.0.dist-info/METADATA +268 -0
- sciwrite_lint-0.2.0.dist-info/RECORD +76 -0
- sciwrite_lint-0.2.0.dist-info/WHEEL +5 -0
- sciwrite_lint-0.2.0.dist-info/entry_points.txt +2 -0
- sciwrite_lint-0.2.0.dist-info/licenses/LICENSE +21 -0
- sciwrite_lint-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
"""Extract figure images from LaTeX source or PDF files.
|
|
2
|
+
|
|
3
|
+
LaTeX: parses \\includegraphics paths from .tex source, resolves relative to
|
|
4
|
+
the source directory. Gives clean, individual image files.
|
|
5
|
+
|
|
6
|
+
PDF: extracts embedded raster images via pypdfium2 (PDFium bindings). PDFium
|
|
7
|
+
ships with all image codecs (JBIG2, JPEG2000, CCITT, etc.) statically linked,
|
|
8
|
+
so no system binaries are required. Vector graphics (matplotlib/TikZ rendered
|
|
9
|
+
as PDF primitives) are not covered in V1.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from loguru import logger
|
|
18
|
+
from pydantic import BaseModel
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ExtractedImage(BaseModel):
|
|
22
|
+
"""An image extracted from a manuscript."""
|
|
23
|
+
|
|
24
|
+
path: Path # path to the image file (LaTeX) or temp file (PDF)
|
|
25
|
+
label: str # figure label if available (e.g., "fig:results")
|
|
26
|
+
caption: str # caption text if available
|
|
27
|
+
source: str # "latex" or "pdf"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# LaTeX: parse \includegraphics paths
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
# Match \includegraphics with optional args: \includegraphics[width=0.8\textwidth]{path}
|
|
35
|
+
_INCLUDEGRAPHICS_RE = re.compile(
|
|
36
|
+
r"\\includegraphics\s*(?:\[[^\]]*\])?\s*\{([^}]+)\}",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# Match figure environment to extract label and caption alongside graphics
|
|
40
|
+
_FIGURE_ENV_RE = re.compile(
|
|
41
|
+
r"\\begin\{figure\*?\}(.*?)\\end\{figure\*?\}",
|
|
42
|
+
re.DOTALL,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
_CAPTION_RE = re.compile(r"\\caption\{([^}]+)\}")
|
|
46
|
+
_LABEL_RE = re.compile(r"\\label\{([^}]+)\}")
|
|
47
|
+
|
|
48
|
+
# Match subfigure environments
|
|
49
|
+
_SUBFIGURE_ENV_RE = re.compile(
|
|
50
|
+
r"\\begin\{subfigure\}(?:\[[^\]]*\])?\{[^}]*\}(.*?)\\end\{subfigure\}",
|
|
51
|
+
re.DOTALL,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# Common image extensions to try if the path has no extension
|
|
55
|
+
_IMAGE_EXTENSIONS = [".png", ".jpg", ".jpeg", ".pdf", ".eps", ".svg"]
|
|
56
|
+
|
|
57
|
+
# \graphicspath{{dir1/}{dir2/}} — each {dir} is a search path
|
|
58
|
+
# The outer braces belong to \graphicspath, inner braces wrap each directory.
|
|
59
|
+
_GRAPHICSPATH_RE = re.compile(
|
|
60
|
+
r"\\graphicspath\s*\{\s*((?:\{[^}]*\}\s*)+)\}",
|
|
61
|
+
)
|
|
62
|
+
_GRAPHICSPATH_DIR_RE = re.compile(r"\{([^}]*)\}")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _parse_graphicspath(tex_text: str, source_dir: Path) -> list[Path]:
|
|
66
|
+
"""Extract search directories from \\graphicspath in LaTeX source.
|
|
67
|
+
|
|
68
|
+
Returns resolved absolute paths for each directory that exists.
|
|
69
|
+
The source_dir itself is NOT included (caller prepends it).
|
|
70
|
+
"""
|
|
71
|
+
m = _GRAPHICSPATH_RE.search(tex_text)
|
|
72
|
+
if not m:
|
|
73
|
+
return []
|
|
74
|
+
dirs: list[Path] = []
|
|
75
|
+
for dir_match in _GRAPHICSPATH_DIR_RE.finditer(m.group(1)):
|
|
76
|
+
raw = dir_match.group(1).strip()
|
|
77
|
+
if not raw:
|
|
78
|
+
continue
|
|
79
|
+
resolved = (source_dir / raw).resolve()
|
|
80
|
+
if resolved.is_dir():
|
|
81
|
+
dirs.append(resolved)
|
|
82
|
+
else:
|
|
83
|
+
logger.debug("graphicspath dir not found: {}", resolved)
|
|
84
|
+
return dirs
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _resolve_image_path(
|
|
88
|
+
raw_path: str,
|
|
89
|
+
search_dirs: list[Path],
|
|
90
|
+
) -> Path | None:
|
|
91
|
+
"""Resolve an \\includegraphics path to an actual file.
|
|
92
|
+
|
|
93
|
+
Searches each directory in ``search_dirs`` (source_dir first, then any
|
|
94
|
+
\\graphicspath dirs). LaTeX allows omitting extensions — tries common
|
|
95
|
+
ones if the exact path doesn't exist.
|
|
96
|
+
"""
|
|
97
|
+
clean = raw_path.strip()
|
|
98
|
+
for base in search_dirs:
|
|
99
|
+
candidate = base / clean
|
|
100
|
+
if candidate.is_file():
|
|
101
|
+
return candidate
|
|
102
|
+
# Try adding common extensions
|
|
103
|
+
if not candidate.suffix:
|
|
104
|
+
for ext in _IMAGE_EXTENSIONS:
|
|
105
|
+
with_ext = candidate.with_suffix(ext)
|
|
106
|
+
if with_ext.is_file():
|
|
107
|
+
return with_ext
|
|
108
|
+
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def extract_images_from_latex(tex_path: Path) -> list[ExtractedImage]:
|
|
113
|
+
"""Extract figure images from a LaTeX .tex file.
|
|
114
|
+
|
|
115
|
+
Parses figure environments for labels and captions, then resolves
|
|
116
|
+
\\includegraphics paths relative to the .tex file's directory.
|
|
117
|
+
"""
|
|
118
|
+
text = tex_path.read_text(encoding="utf-8")
|
|
119
|
+
source_dir = tex_path.parent
|
|
120
|
+
images: list[ExtractedImage] = []
|
|
121
|
+
|
|
122
|
+
# Build search path: source dir first, then \graphicspath dirs
|
|
123
|
+
search_dirs = [source_dir, *_parse_graphicspath(text, source_dir)]
|
|
124
|
+
|
|
125
|
+
# First pass: extract from figure environments (gets labels + captions)
|
|
126
|
+
seen_paths: set[str] = set()
|
|
127
|
+
for env_match in _FIGURE_ENV_RE.finditer(text):
|
|
128
|
+
env_body = env_match.group(1)
|
|
129
|
+
|
|
130
|
+
# Outer figure-level caption and label (used when no subfigure)
|
|
131
|
+
fig_caption_m = _CAPTION_RE.search(env_body)
|
|
132
|
+
fig_label_m = _LABEL_RE.search(env_body)
|
|
133
|
+
fig_caption = fig_caption_m.group(1) if fig_caption_m else ""
|
|
134
|
+
fig_label = fig_label_m.group(1) if fig_label_m else ""
|
|
135
|
+
|
|
136
|
+
# Build subfigure map: for each \includegraphics inside a subfigure,
|
|
137
|
+
# use that subfigure's own caption/label instead of the outer figure's.
|
|
138
|
+
subfig_meta: dict[str, tuple[str, str]] = {} # raw_path → (label, caption)
|
|
139
|
+
for sub_match in _SUBFIGURE_ENV_RE.finditer(env_body):
|
|
140
|
+
sub_body = sub_match.group(1)
|
|
141
|
+
sub_caption_m = _CAPTION_RE.search(sub_body)
|
|
142
|
+
sub_label_m = _LABEL_RE.search(sub_body)
|
|
143
|
+
sub_caption = sub_caption_m.group(1) if sub_caption_m else ""
|
|
144
|
+
sub_label = sub_label_m.group(1) if sub_label_m else ""
|
|
145
|
+
for gfx_m in _INCLUDEGRAPHICS_RE.finditer(sub_body):
|
|
146
|
+
subfig_meta[gfx_m.group(1)] = (sub_label, sub_caption)
|
|
147
|
+
|
|
148
|
+
for gfx_match in _INCLUDEGRAPHICS_RE.finditer(env_body):
|
|
149
|
+
raw_path = gfx_match.group(1)
|
|
150
|
+
resolved = _resolve_image_path(raw_path, search_dirs)
|
|
151
|
+
if resolved is None:
|
|
152
|
+
logger.debug("Image not found: {} (from {})", raw_path, tex_path.name)
|
|
153
|
+
continue
|
|
154
|
+
|
|
155
|
+
# Skip non-raster formats (EPS, SVG, PDF figures need rendering)
|
|
156
|
+
if resolved.suffix.lower() in {".eps", ".svg", ".pdf"}:
|
|
157
|
+
logger.debug("Skipping vector image: {}", resolved.name)
|
|
158
|
+
continue
|
|
159
|
+
|
|
160
|
+
# Use subfigure metadata if available, otherwise outer figure
|
|
161
|
+
label, caption = subfig_meta.get(raw_path, (fig_label, fig_caption))
|
|
162
|
+
|
|
163
|
+
seen_paths.add(raw_path)
|
|
164
|
+
images.append(
|
|
165
|
+
ExtractedImage(
|
|
166
|
+
path=resolved,
|
|
167
|
+
label=label,
|
|
168
|
+
caption=caption,
|
|
169
|
+
source="latex",
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
# Second pass: standalone \includegraphics not inside figure environments
|
|
174
|
+
for gfx_match in _INCLUDEGRAPHICS_RE.finditer(text):
|
|
175
|
+
raw_path = gfx_match.group(1)
|
|
176
|
+
if raw_path in seen_paths:
|
|
177
|
+
continue
|
|
178
|
+
resolved = _resolve_image_path(raw_path, search_dirs)
|
|
179
|
+
if resolved is None:
|
|
180
|
+
continue
|
|
181
|
+
if resolved.suffix.lower() in {".eps", ".svg", ".pdf"}:
|
|
182
|
+
continue
|
|
183
|
+
|
|
184
|
+
seen_paths.add(raw_path)
|
|
185
|
+
images.append(
|
|
186
|
+
ExtractedImage(
|
|
187
|
+
path=resolved,
|
|
188
|
+
label="",
|
|
189
|
+
caption="",
|
|
190
|
+
source="latex",
|
|
191
|
+
)
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
logger.info("Extracted {} images from LaTeX source", len(images))
|
|
195
|
+
return images
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def collect_image_paths(tex_path: Path) -> list[tuple[Path, Path]]:
|
|
199
|
+
"""Collect all image files referenced by a .tex file.
|
|
200
|
+
|
|
201
|
+
Returns a list of (absolute_path, relative_path) tuples where
|
|
202
|
+
relative_path is relative to the .tex file's parent directory.
|
|
203
|
+
This is used by ``save_source`` to copy images into the workspace
|
|
204
|
+
while preserving directory structure so \\includegraphics paths
|
|
205
|
+
still resolve.
|
|
206
|
+
|
|
207
|
+
Includes all formats (raster + vector) — the workspace should be
|
|
208
|
+
a complete snapshot of the source, not just what the vision pipeline
|
|
209
|
+
processes.
|
|
210
|
+
"""
|
|
211
|
+
text = tex_path.read_text(encoding="utf-8")
|
|
212
|
+
source_dir = tex_path.parent
|
|
213
|
+
search_dirs = [source_dir, *_parse_graphicspath(text, source_dir)]
|
|
214
|
+
|
|
215
|
+
result: list[tuple[Path, Path]] = []
|
|
216
|
+
seen: set[Path] = set()
|
|
217
|
+
|
|
218
|
+
for gfx_match in _INCLUDEGRAPHICS_RE.finditer(text):
|
|
219
|
+
raw_path = gfx_match.group(1)
|
|
220
|
+
resolved = _resolve_image_path(raw_path, search_dirs)
|
|
221
|
+
if resolved is None or resolved in seen:
|
|
222
|
+
continue
|
|
223
|
+
seen.add(resolved)
|
|
224
|
+
try:
|
|
225
|
+
rel = resolved.resolve().relative_to(source_dir.resolve())
|
|
226
|
+
except ValueError:
|
|
227
|
+
# Image outside source tree — use just the filename
|
|
228
|
+
rel = Path(resolved.name)
|
|
229
|
+
result.append((resolved, rel))
|
|
230
|
+
|
|
231
|
+
return result
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# ---------------------------------------------------------------------------
|
|
235
|
+
# PDF: extract embedded raster images via pypdfium2
|
|
236
|
+
# ---------------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def extract_images_from_pdf(pdf_path: Path, output_dir: Path) -> list[ExtractedImage]:
|
|
240
|
+
"""Extract embedded raster images from a PDF file.
|
|
241
|
+
|
|
242
|
+
Uses pypdfium2 (PDFium bindings) to iterate over image objects on each
|
|
243
|
+
page, render them as bitmaps, and write PNGs to ``output_dir``. PDFium
|
|
244
|
+
bundles all image codecs statically (JBIG2, JPEG2000, CCITT, etc.), so
|
|
245
|
+
no system binaries are required.
|
|
246
|
+
|
|
247
|
+
One broken PDF must not abort a multi-paper batch: PDFium is a native
|
|
248
|
+
binding and can raise unexpected exception types on malformed data. We
|
|
249
|
+
catch at the function boundary, log a clear warning with the failing
|
|
250
|
+
PDF's name, and return an empty list. This is isolation of external
|
|
251
|
+
input corruption, not silent degradation — the user sees which PDF
|
|
252
|
+
failed and why.
|
|
253
|
+
|
|
254
|
+
V1 limitation: only raster images. Vector graphics rendered as PDF
|
|
255
|
+
drawing commands are not extracted.
|
|
256
|
+
"""
|
|
257
|
+
import pypdfium2 as pdfium
|
|
258
|
+
import pypdfium2.raw as pdfium_raw
|
|
259
|
+
|
|
260
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
261
|
+
images: list[ExtractedImage] = []
|
|
262
|
+
|
|
263
|
+
try:
|
|
264
|
+
pdf = pdfium.PdfDocument(str(pdf_path))
|
|
265
|
+
try:
|
|
266
|
+
for page_idx in range(len(pdf)):
|
|
267
|
+
page = pdf[page_idx]
|
|
268
|
+
try:
|
|
269
|
+
img_idx = 0
|
|
270
|
+
for obj in page.get_objects(
|
|
271
|
+
filter=(pdfium_raw.FPDF_PAGEOBJ_IMAGE,)
|
|
272
|
+
):
|
|
273
|
+
img_idx += 1
|
|
274
|
+
# Cheap dimension check before rendering.
|
|
275
|
+
# Scientific figures are typically 300+ px on each side.
|
|
276
|
+
try:
|
|
277
|
+
w, h = obj.get_px_size()
|
|
278
|
+
except Exception as e:
|
|
279
|
+
logger.debug(
|
|
280
|
+
"image dimension probe skipped ({}: {})",
|
|
281
|
+
type(e).__name__,
|
|
282
|
+
e,
|
|
283
|
+
)
|
|
284
|
+
continue
|
|
285
|
+
if w < 200 or h < 200:
|
|
286
|
+
continue
|
|
287
|
+
|
|
288
|
+
# Encoded-size filter: skip mostly-blank or trivial
|
|
289
|
+
# images that pass the pixel filter but are decorations.
|
|
290
|
+
# Scientific figures are typically ≥5 KB once PNG-encoded.
|
|
291
|
+
# Encode in memory first so we don't write small images
|
|
292
|
+
# to disk and immediately delete them.
|
|
293
|
+
bitmap = obj.get_bitmap(render=True)
|
|
294
|
+
pil_img = bitmap.to_pil()
|
|
295
|
+
from io import BytesIO
|
|
296
|
+
|
|
297
|
+
buf = BytesIO()
|
|
298
|
+
pil_img.save(buf, format="PNG")
|
|
299
|
+
png_bytes = buf.getvalue()
|
|
300
|
+
if len(png_bytes) < 5120:
|
|
301
|
+
continue
|
|
302
|
+
|
|
303
|
+
out_path = output_dir / f"p{page_idx + 1}_img{img_idx}.png"
|
|
304
|
+
out_path.write_bytes(png_bytes)
|
|
305
|
+
|
|
306
|
+
images.append(
|
|
307
|
+
ExtractedImage(
|
|
308
|
+
path=out_path,
|
|
309
|
+
label=f"page{page_idx + 1}_img{img_idx}",
|
|
310
|
+
caption="",
|
|
311
|
+
source="pdf",
|
|
312
|
+
)
|
|
313
|
+
)
|
|
314
|
+
finally:
|
|
315
|
+
page.close()
|
|
316
|
+
finally:
|
|
317
|
+
pdf.close()
|
|
318
|
+
except Exception as e:
|
|
319
|
+
logger.warning(
|
|
320
|
+
"Skipping {}: PDF image extraction failed ({}: {})",
|
|
321
|
+
pdf_path.name,
|
|
322
|
+
type(e).__name__,
|
|
323
|
+
e,
|
|
324
|
+
)
|
|
325
|
+
return []
|
|
326
|
+
|
|
327
|
+
logger.info("Extracted {} raster images from PDF", len(images))
|
|
328
|
+
return images
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
# ---------------------------------------------------------------------------
|
|
332
|
+
# Rendered figures: TikZ/pgfplots via compiled PDF page rendering
|
|
333
|
+
# ---------------------------------------------------------------------------
|
|
334
|
+
|
|
335
|
+
# "Figure 1:" or "Figure 1." in PDF text — indicates a figure caption on that page
|
|
336
|
+
_FIGURE_CAPTION_PDF_RE = re.compile(r"Figure\s+(\d+)\s*[.:]")
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _find_compiled_pdf(tex_path: Path) -> Path | None:
|
|
340
|
+
"""Find a compiled PDF alongside the .tex file.
|
|
341
|
+
|
|
342
|
+
Looks for a PDF with the same stem, then any PDF in the same directory.
|
|
343
|
+
Returns None if no PDF found.
|
|
344
|
+
"""
|
|
345
|
+
same_stem = tex_path.with_suffix(".pdf")
|
|
346
|
+
if same_stem.is_file():
|
|
347
|
+
return same_stem
|
|
348
|
+
|
|
349
|
+
pdfs = sorted(tex_path.parent.glob("*.pdf"), key=lambda p: p.stat().st_mtime)
|
|
350
|
+
if pdfs:
|
|
351
|
+
return pdfs[-1] # most recently modified
|
|
352
|
+
|
|
353
|
+
return None
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _find_tikz_figures(tex_path: Path) -> list[tuple[str, str]]:
|
|
357
|
+
"""Find figure environments that have NO \\includegraphics (pure TikZ/pgfplots).
|
|
358
|
+
|
|
359
|
+
Returns list of (label, caption) for each vector-only figure.
|
|
360
|
+
"""
|
|
361
|
+
text = tex_path.read_text(encoding="utf-8")
|
|
362
|
+
results: list[tuple[str, str]] = []
|
|
363
|
+
|
|
364
|
+
for env_match in _FIGURE_ENV_RE.finditer(text):
|
|
365
|
+
env_body = env_match.group(1)
|
|
366
|
+
# Skip figures that have \includegraphics — already handled
|
|
367
|
+
if _INCLUDEGRAPHICS_RE.search(env_body):
|
|
368
|
+
continue
|
|
369
|
+
|
|
370
|
+
caption_m = _CAPTION_RE.search(env_body)
|
|
371
|
+
label_m = _LABEL_RE.search(env_body)
|
|
372
|
+
caption = caption_m.group(1) if caption_m else ""
|
|
373
|
+
label = label_m.group(1) if label_m else ""
|
|
374
|
+
results.append((label, caption))
|
|
375
|
+
|
|
376
|
+
return results
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _figure_pages_from_pdf(pdf_path: Path) -> dict[int, tuple[int, str]]:
|
|
380
|
+
"""Identify pages containing figure captions in a compiled PDF.
|
|
381
|
+
|
|
382
|
+
Returns {figure_number: (page_index_0based, caption_snippet)}.
|
|
383
|
+
Uses pypdf text extraction — fast, no rendering.
|
|
384
|
+
"""
|
|
385
|
+
from pypdf import PdfReader
|
|
386
|
+
|
|
387
|
+
reader = PdfReader(str(pdf_path))
|
|
388
|
+
result: dict[int, tuple[int, str]] = {}
|
|
389
|
+
|
|
390
|
+
for page_idx, page in enumerate(reader.pages):
|
|
391
|
+
text = page.extract_text() or ""
|
|
392
|
+
for m in _FIGURE_CAPTION_PDF_RE.finditer(text):
|
|
393
|
+
fig_num = int(m.group(1))
|
|
394
|
+
if fig_num not in result:
|
|
395
|
+
# Grab ~80 chars after "Figure N:" as caption snippet
|
|
396
|
+
start = m.end()
|
|
397
|
+
snippet = text[start : start + 80].strip().split("\n")[0]
|
|
398
|
+
result[fig_num] = (page_idx, snippet)
|
|
399
|
+
|
|
400
|
+
return result
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def render_tikz_figures(
|
|
404
|
+
tex_path: Path,
|
|
405
|
+
output_dir: Path,
|
|
406
|
+
pdf_path: Path | None = None,
|
|
407
|
+
dpi: int = 150,
|
|
408
|
+
) -> list[ExtractedImage]:
|
|
409
|
+
"""Render TikZ/pgfplots figures by finding them in the compiled PDF.
|
|
410
|
+
|
|
411
|
+
1. Finds figure environments in .tex that have no \\includegraphics
|
|
412
|
+
2. Locates the compiled PDF (explicit ``pdf_path``, or alongside .tex)
|
|
413
|
+
3. Identifies which PDF pages contain those figures (via caption text)
|
|
414
|
+
4. Renders those pages at ``dpi`` resolution via pdf2image (poppler)
|
|
415
|
+
|
|
416
|
+
Returns ExtractedImage entries for each rendered figure page.
|
|
417
|
+
"""
|
|
418
|
+
from pdf2image import convert_from_path
|
|
419
|
+
|
|
420
|
+
tikz_figs = _find_tikz_figures(tex_path)
|
|
421
|
+
if not tikz_figs:
|
|
422
|
+
return []
|
|
423
|
+
|
|
424
|
+
if pdf_path is None:
|
|
425
|
+
pdf_path = _find_compiled_pdf(tex_path)
|
|
426
|
+
if pdf_path is None:
|
|
427
|
+
logger.debug("No compiled PDF found for {}", tex_path.name)
|
|
428
|
+
return []
|
|
429
|
+
|
|
430
|
+
# Map figure numbers to pages
|
|
431
|
+
fig_pages = _figure_pages_from_pdf(pdf_path)
|
|
432
|
+
if not fig_pages:
|
|
433
|
+
logger.debug("No figure captions found in PDF text")
|
|
434
|
+
return []
|
|
435
|
+
|
|
436
|
+
# Match TikZ figures to PDF pages by order
|
|
437
|
+
# TikZ figures appear in .tex order → Figure 1, 2, 3...
|
|
438
|
+
# fig_pages maps figure number → page
|
|
439
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
440
|
+
images: list[ExtractedImage] = []
|
|
441
|
+
|
|
442
|
+
for fig_idx, (label, caption) in enumerate(tikz_figs):
|
|
443
|
+
fig_num = fig_idx + 1
|
|
444
|
+
page_info = fig_pages.get(fig_num)
|
|
445
|
+
if page_info is None:
|
|
446
|
+
# Numbering may not start at 1, or mixed with raster figures.
|
|
447
|
+
# Try matching by caption text.
|
|
448
|
+
for num, (pidx, snippet) in fig_pages.items():
|
|
449
|
+
if caption and snippet and caption[:30].lower() in snippet.lower():
|
|
450
|
+
page_info = (pidx, snippet)
|
|
451
|
+
break
|
|
452
|
+
if page_info is None:
|
|
453
|
+
logger.debug(
|
|
454
|
+
"Could not find PDF page for TikZ figure {} (label={})",
|
|
455
|
+
fig_num,
|
|
456
|
+
label,
|
|
457
|
+
)
|
|
458
|
+
continue
|
|
459
|
+
|
|
460
|
+
page_idx, _snippet = page_info
|
|
461
|
+
# Render single page
|
|
462
|
+
page_images = convert_from_path(
|
|
463
|
+
str(pdf_path),
|
|
464
|
+
dpi=dpi,
|
|
465
|
+
first_page=page_idx + 1, # 1-based
|
|
466
|
+
last_page=page_idx + 1,
|
|
467
|
+
fmt="png",
|
|
468
|
+
)
|
|
469
|
+
if not page_images:
|
|
470
|
+
continue
|
|
471
|
+
|
|
472
|
+
out_path = output_dir / f"tikz_fig{fig_num}_p{page_idx + 1}.png"
|
|
473
|
+
page_images[0].save(str(out_path), "PNG")
|
|
474
|
+
|
|
475
|
+
images.append(
|
|
476
|
+
ExtractedImage(
|
|
477
|
+
path=out_path,
|
|
478
|
+
label=label,
|
|
479
|
+
caption=caption,
|
|
480
|
+
source="rendered",
|
|
481
|
+
)
|
|
482
|
+
)
|
|
483
|
+
logger.debug(
|
|
484
|
+
"Rendered TikZ figure {} from page {} → {}",
|
|
485
|
+
fig_num,
|
|
486
|
+
page_idx + 1,
|
|
487
|
+
out_path.name,
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
logger.info("Rendered {} TikZ/vector figure(s) from compiled PDF", len(images))
|
|
491
|
+
return images
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Vision pipeline: extract images → describe via VL model → cache results.
|
|
2
|
+
|
|
3
|
+
Orchestrates the full vision flow for a single paper. Runs automatically
|
|
4
|
+
as part of ``sciwrite-lint check`` (Stage 0.5, before LLM checks).
|
|
5
|
+
|
|
6
|
+
On WSL2, CUDA memory overcommit lets the VL model (~4 GB float16) share
|
|
7
|
+
VRAM with vLLM transparently — no container restart needed. On native
|
|
8
|
+
Linux without overcommit, auto-resolves to CPU.
|
|
9
|
+
|
|
10
|
+
Can also run standalone: ``sciwrite-lint vision --paper paper_a``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import time
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from loguru import logger
|
|
19
|
+
|
|
20
|
+
from sciwrite_lint.config import LintConfig
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def run_vision_pipeline(
|
|
24
|
+
tex_path: Path,
|
|
25
|
+
config: LintConfig,
|
|
26
|
+
paper_name: str = "",
|
|
27
|
+
device: str = "auto",
|
|
28
|
+
fresh: bool = False,
|
|
29
|
+
) -> str:
|
|
30
|
+
"""Run the full vision pipeline for a manuscript.
|
|
31
|
+
|
|
32
|
+
1. Extract images from the manuscript (LaTeX or PDF)
|
|
33
|
+
2. Check workspace.db cache — skip images whose content hash hasn't changed
|
|
34
|
+
3. Run VL inference on new/changed images
|
|
35
|
+
4. Cache results in workspace.db (vision_cache table)
|
|
36
|
+
5. Return formatted figure descriptions
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
tex_path: Path to the .tex or .pdf manuscript file.
|
|
40
|
+
config: Lint configuration.
|
|
41
|
+
paper_name: Paper name for workspace resolution. Uses
|
|
42
|
+
``config.current_paper`` if empty.
|
|
43
|
+
device: ``"auto"``, ``"cpu"``, or ``"cuda"``.
|
|
44
|
+
fresh: Ignore cache and re-describe all images.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
Formatted figure descriptions string (empty if no images found).
|
|
48
|
+
"""
|
|
49
|
+
from sciwrite_lint.vision.describe import describe_figures
|
|
50
|
+
from sciwrite_lint.vision.image_extraction import (
|
|
51
|
+
ExtractedImage,
|
|
52
|
+
extract_images_from_latex,
|
|
53
|
+
extract_images_from_pdf,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
t0 = time.monotonic()
|
|
57
|
+
paper = paper_name or config.current_paper
|
|
58
|
+
|
|
59
|
+
# Determine workspace for DB caching
|
|
60
|
+
references_dir: Path | None = None
|
|
61
|
+
ws = None
|
|
62
|
+
if paper:
|
|
63
|
+
ws = config.paper_workspace(paper)
|
|
64
|
+
ws.ensure_dirs()
|
|
65
|
+
references_dir = ws.root
|
|
66
|
+
|
|
67
|
+
# Extract images based on source type.
|
|
68
|
+
# For LaTeX, prefer the workspace source copy (images are snapshotted
|
|
69
|
+
# there by save_source) so the pipeline is independent of the live
|
|
70
|
+
# source tree.
|
|
71
|
+
images: list[ExtractedImage]
|
|
72
|
+
if tex_path.suffix.lower() == ".pdf":
|
|
73
|
+
if references_dir is None:
|
|
74
|
+
raise RuntimeError(
|
|
75
|
+
"PDF input requires a paper workspace for image extraction. "
|
|
76
|
+
"Use --paper to specify the paper name."
|
|
77
|
+
)
|
|
78
|
+
output_dir = ws.parsed / "extracted_images" # type: ignore[union-attr]
|
|
79
|
+
images = extract_images_from_pdf(tex_path, output_dir)
|
|
80
|
+
else:
|
|
81
|
+
ws_tex = ws.source / tex_path.name if ws else None
|
|
82
|
+
effective_tex = ws_tex if ws_tex and ws_tex.is_file() else tex_path
|
|
83
|
+
images = extract_images_from_latex(effective_tex)
|
|
84
|
+
|
|
85
|
+
# Also render TikZ/pgfplots figures from the compiled PDF.
|
|
86
|
+
# Look for PDF alongside the original .tex (not the workspace copy).
|
|
87
|
+
if ws:
|
|
88
|
+
from sciwrite_lint.vision.image_extraction import (
|
|
89
|
+
render_tikz_figures,
|
|
90
|
+
_find_compiled_pdf,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
pdf_path = _find_compiled_pdf(tex_path)
|
|
94
|
+
if pdf_path:
|
|
95
|
+
rendered_dir = ws.parsed / "rendered_figures"
|
|
96
|
+
rendered = render_tikz_figures(
|
|
97
|
+
effective_tex, rendered_dir, pdf_path=pdf_path
|
|
98
|
+
)
|
|
99
|
+
images.extend(rendered)
|
|
100
|
+
|
|
101
|
+
if not images:
|
|
102
|
+
logger.info("No figures found in manuscript")
|
|
103
|
+
return ""
|
|
104
|
+
|
|
105
|
+
# Describe figures (with DB caching via get_db)
|
|
106
|
+
result = describe_figures(
|
|
107
|
+
images,
|
|
108
|
+
references_dir=references_dir,
|
|
109
|
+
device=device,
|
|
110
|
+
fresh=fresh,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
elapsed = time.monotonic() - t0
|
|
114
|
+
logger.info("Vision pipeline: {} figures in {:.1f}s", len(images), elapsed)
|
|
115
|
+
return result
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
# CLI entry point for subprocess isolation
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _cli_main() -> None:
|
|
124
|
+
"""Entry point for ``python -m sciwrite_lint.vision.pipeline``.
|
|
125
|
+
|
|
126
|
+
Called by _stage_vision() in a subprocess so the CUDA context
|
|
127
|
+
is fully released when this process exits.
|
|
128
|
+
|
|
129
|
+
Modes:
|
|
130
|
+
Single paper: ``python -m sciwrite_lint.vision.pipeline <tex> --paper <name>``
|
|
131
|
+
Batch: ``python -m sciwrite_lint.vision.pipeline --batch <manifest.json>``
|
|
132
|
+
|
|
133
|
+
Batch mode loads the VL model once and processes all papers in the
|
|
134
|
+
manifest sequentially. Used by ``run_papers_staged()`` to avoid
|
|
135
|
+
loading the model N times.
|
|
136
|
+
"""
|
|
137
|
+
import argparse
|
|
138
|
+
import sys
|
|
139
|
+
|
|
140
|
+
from sciwrite_lint.config import load_config
|
|
141
|
+
|
|
142
|
+
parser = argparse.ArgumentParser(description="Vision pipeline (subprocess)")
|
|
143
|
+
parser.add_argument("tex_path", type=Path, nargs="?", default=None)
|
|
144
|
+
parser.add_argument("--paper", default=None)
|
|
145
|
+
parser.add_argument("--fresh", action="store_true")
|
|
146
|
+
parser.add_argument("--config", type=Path, default=None)
|
|
147
|
+
parser.add_argument(
|
|
148
|
+
"--batch", type=Path, default=None, help="JSON manifest for batch mode"
|
|
149
|
+
)
|
|
150
|
+
args = parser.parse_args()
|
|
151
|
+
|
|
152
|
+
if args.batch:
|
|
153
|
+
_run_batch(args.batch)
|
|
154
|
+
elif args.tex_path and args.paper:
|
|
155
|
+
config = load_config(args.config)
|
|
156
|
+
try:
|
|
157
|
+
result = run_vision_pipeline(
|
|
158
|
+
args.tex_path, config, paper_name=args.paper, fresh=args.fresh
|
|
159
|
+
)
|
|
160
|
+
if not result:
|
|
161
|
+
logger.info("No figures found")
|
|
162
|
+
except Exception as e:
|
|
163
|
+
logger.error("Vision pipeline failed: {}", e)
|
|
164
|
+
sys.exit(1)
|
|
165
|
+
else:
|
|
166
|
+
parser.error("Provide tex_path + --paper, or --batch <manifest.json>")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _run_batch(manifest_path: Path) -> None:
|
|
170
|
+
"""Batch mode: load VL model once, process all papers in manifest.
|
|
171
|
+
|
|
172
|
+
Manifest JSON: list of objects with keys:
|
|
173
|
+
paper_name, tex_path, config_path (optional), fresh (optional)
|
|
174
|
+
|
|
175
|
+
Results are written to each paper's workspace.db (vision_cache table).
|
|
176
|
+
"""
|
|
177
|
+
import json
|
|
178
|
+
import sys
|
|
179
|
+
|
|
180
|
+
from sciwrite_lint.config import load_config
|
|
181
|
+
|
|
182
|
+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
183
|
+
if not manifest:
|
|
184
|
+
return
|
|
185
|
+
|
|
186
|
+
failed = 0
|
|
187
|
+
for i, entry in enumerate(manifest):
|
|
188
|
+
paper_name = entry["paper_name"]
|
|
189
|
+
tex_path = Path(entry["tex_path"])
|
|
190
|
+
config_path = entry.get("config_path")
|
|
191
|
+
fresh = entry.get("fresh", False)
|
|
192
|
+
|
|
193
|
+
config = load_config(Path(config_path) if config_path else None)
|
|
194
|
+
logger.info("[{}/{}] Vision: {}", i + 1, len(manifest), paper_name)
|
|
195
|
+
try:
|
|
196
|
+
run_vision_pipeline(tex_path, config, paper_name=paper_name, fresh=fresh)
|
|
197
|
+
except Exception as e:
|
|
198
|
+
logger.error("[{}] Vision failed: {}", paper_name, e)
|
|
199
|
+
failed += 1
|
|
200
|
+
|
|
201
|
+
if failed:
|
|
202
|
+
logger.warning("Vision batch: {}/{} papers failed", failed, len(manifest))
|
|
203
|
+
sys.exit(1)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
if __name__ == "__main__":
|
|
207
|
+
_cli_main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""vLLM server management: container lifecycle, health checks, model config."""
|