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.
Files changed (76) hide show
  1. sciwrite_lint/__init__.py +3 -0
  2. sciwrite_lint/__main__.py +527 -0
  3. sciwrite_lint/_network.py +195 -0
  4. sciwrite_lint/api.py +1484 -0
  5. sciwrite_lint/checks/__init__.py +1 -0
  6. sciwrite_lint/checks/_section_utils.py +111 -0
  7. sciwrite_lint/checks/cite_purpose.py +122 -0
  8. sciwrite_lint/checks/claim_support.py +96 -0
  9. sciwrite_lint/checks/cross_section_consistency.py +185 -0
  10. sciwrite_lint/checks/dangling_cite.py +93 -0
  11. sciwrite_lint/checks/dangling_ref.py +116 -0
  12. sciwrite_lint/checks/full_paper_consistency.py +604 -0
  13. sciwrite_lint/checks/ref_internal_checks.py +919 -0
  14. sciwrite_lint/checks/reference_accuracy.py +277 -0
  15. sciwrite_lint/checks/reference_exists.py +119 -0
  16. sciwrite_lint/checks/reference_unreliable.py +244 -0
  17. sciwrite_lint/checks/registry.py +136 -0
  18. sciwrite_lint/checks/retracted_cite.py +96 -0
  19. sciwrite_lint/checks/structure_promises.py +115 -0
  20. sciwrite_lint/checks/unreferenced_figure.py +70 -0
  21. sciwrite_lint/claims.py +330 -0
  22. sciwrite_lint/claude_backend.py +94 -0
  23. sciwrite_lint/claude_cli.py +405 -0
  24. sciwrite_lint/cli/__init__.py +1 -0
  25. sciwrite_lint/cli/check.py +480 -0
  26. sciwrite_lint/cli/config.py +229 -0
  27. sciwrite_lint/cli/fetch.py +250 -0
  28. sciwrite_lint/cli/misc.py +1202 -0
  29. sciwrite_lint/cli/rank.py +470 -0
  30. sciwrite_lint/cli/verify.py +437 -0
  31. sciwrite_lint/config.py +646 -0
  32. sciwrite_lint/cross_paper.py +174 -0
  33. sciwrite_lint/eval_claims.py +1196 -0
  34. sciwrite_lint/fulltext.py +851 -0
  35. sciwrite_lint/llm_utils.py +386 -0
  36. sciwrite_lint/local_pdfs.py +122 -0
  37. sciwrite_lint/manuscript_store.py +674 -0
  38. sciwrite_lint/models.py +139 -0
  39. sciwrite_lint/pdf/__init__.py +1 -0
  40. sciwrite_lint/pdf/grobid.py +785 -0
  41. sciwrite_lint/pdf/pdf_download.py +258 -0
  42. sciwrite_lint/pipeline.py +2694 -0
  43. sciwrite_lint/prompt_safety.py +30 -0
  44. sciwrite_lint/rate_limiter.py +227 -0
  45. sciwrite_lint/references/__init__.py +1 -0
  46. sciwrite_lint/references/citations.py +715 -0
  47. sciwrite_lint/references/crossref.py +282 -0
  48. sciwrite_lint/references/embedding_store.py +380 -0
  49. sciwrite_lint/references/matching.py +273 -0
  50. sciwrite_lint/references/metadata.py +273 -0
  51. sciwrite_lint/references/reference_store.py +823 -0
  52. sciwrite_lint/references/retraction_watch.py +178 -0
  53. sciwrite_lint/references/workspace_db.py +1390 -0
  54. sciwrite_lint/report.py +163 -0
  55. sciwrite_lint/schemas.py +260 -0
  56. sciwrite_lint/scoring/__init__.py +1 -0
  57. sciwrite_lint/scoring/chain.py +716 -0
  58. sciwrite_lint/scoring/contribution.py +322 -0
  59. sciwrite_lint/scoring/scilint_score.py +611 -0
  60. sciwrite_lint/tex_parser.py +248 -0
  61. sciwrite_lint/usage.py +594 -0
  62. sciwrite_lint/vision/__init__.py +1 -0
  63. sciwrite_lint/vision/cache.py +140 -0
  64. sciwrite_lint/vision/describe.py +311 -0
  65. sciwrite_lint/vision/image_extraction.py +491 -0
  66. sciwrite_lint/vision/pipeline.py +207 -0
  67. sciwrite_lint/vllm/__init__.py +1 -0
  68. sciwrite_lint/vllm/metrics.py +157 -0
  69. sciwrite_lint/vllm/vllm_server.py +445 -0
  70. sciwrite_lint/web.py +369 -0
  71. sciwrite_lint-0.2.0.dist-info/METADATA +268 -0
  72. sciwrite_lint-0.2.0.dist-info/RECORD +76 -0
  73. sciwrite_lint-0.2.0.dist-info/WHEEL +5 -0
  74. sciwrite_lint-0.2.0.dist-info/entry_points.txt +2 -0
  75. sciwrite_lint-0.2.0.dist-info/licenses/LICENSE +21 -0
  76. 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."""