paperkit 0.1.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.
paperkit/checks.py ADDED
@@ -0,0 +1,563 @@
1
+ # Copyright (C) 2026 Vrije Universiteit Brussel. All rights reserved.
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """Verification checks ported from build.sh."""
5
+
6
+ import contextlib
7
+ import re
8
+ import shutil
9
+ import subprocess
10
+ import tempfile
11
+ from collections import defaultdict
12
+ from dataclasses import dataclass, field
13
+ from enum import StrEnum
14
+ from pathlib import Path
15
+
16
+ from paperkit import pages, texlive
17
+ from paperkit.builder import BuildResult
18
+ from paperkit.config import PaperkitConfig
19
+
20
+
21
+ class CheckStatus(StrEnum):
22
+ CLEAN = "clean"
23
+ # Could not run (missing tool or input): not a pass, so it is shown in
24
+ # yellow and counts like a warning in the overall verdict.
25
+ SKIPPED = "skipped"
26
+ WARNING = "warning"
27
+ ERROR = "error"
28
+
29
+
30
+ @dataclass
31
+ class CheckResult:
32
+ name: str
33
+ status: CheckStatus = CheckStatus.CLEAN
34
+ messages: list[str] = field(default_factory=list)
35
+ # Advisory checks render with their own status color but are excluded from
36
+ # the overall build verdict; useful for informational metrics (page count,
37
+ # etc.) whose "failure" doesn't mean the build broke.
38
+ advisory: bool = False
39
+ # Print the messages in the report's details even when the status is clean.
40
+ show_details: bool = False
41
+
42
+
43
+ def _collect_tex_files(config: PaperkitConfig) -> list[Path]:
44
+ """Collect all .tex files from content_dirs + every main .tex."""
45
+ files: list[Path] = []
46
+ for d in config.content_dirs:
47
+ tex_dir = config.root / d
48
+ if tex_dir.is_dir():
49
+ files.extend(sorted(tex_dir.rglob("*.tex")))
50
+ for main in config.mains or [config.main]:
51
+ main_tex = config.root / f"{main}.tex"
52
+ if main_tex.is_file():
53
+ files.append(main_tex)
54
+ return files
55
+
56
+
57
+ def check_log_errors(log_path: Path, label: str) -> CheckResult:
58
+ """Parse a LaTeX or BibTeX log for errors and warnings."""
59
+ result = CheckResult(name=label)
60
+ if not log_path.is_file():
61
+ return result
62
+
63
+ text = log_path.read_text(errors="replace")
64
+ lines = text.splitlines()
65
+
66
+ errors = sum(1 for line in lines if line.startswith("!"))
67
+ # Biber uses "> ERROR -" instead of "!"
68
+ errors += sum(1 for line in lines if "> ERROR -" in line)
69
+ warnings = sum(1 for line in lines if "warning" in line.lower())
70
+ # Biber uses "> WARN -" instead of "warning"
71
+ warnings += sum(1 for line in lines if "> WARN -" in line)
72
+ # Subtract false positives
73
+ fp = sum(1 for line in lines if "Providing info/warning/error" in line)
74
+ fp += sum(1 for line in lines if "warning$ --" in line)
75
+ # Biber summary line "WARNINGS: N" already matched by "warning" above
76
+ fp += sum(1 for line in lines if re.match(r".*> INFO - WARNINGS: \d+", line))
77
+ warnings = max(0, warnings - fp)
78
+
79
+ if errors > 0:
80
+ result.status = CheckStatus.ERROR
81
+ result.messages.append(f"{errors} error(s), {warnings} warning(s)")
82
+ # Group errors with line numbers
83
+ err_info: dict[str, list[int]] = defaultdict(list)
84
+ prev_err = None
85
+ for line in lines:
86
+ if line.startswith("!"):
87
+ prev_err = line
88
+ elif prev_err and line.startswith("l."):
89
+ m = re.match(r"l\.(\d+)", line)
90
+ if m:
91
+ err_info[prev_err].append(int(m.group(1)))
92
+ prev_err = None
93
+ else:
94
+ prev_err = None
95
+ for err, lnums in sorted(err_info.items(), key=lambda x: -len(x[1])):
96
+ lo, hi = min(lnums), max(lnums)
97
+ loc = f"l.{lo}" if lo == hi else f"l.{lo}-{hi}"
98
+ result.messages.append(f" {len(lnums):4d} {err} ({loc})")
99
+ elif warnings > 0:
100
+ result.status = CheckStatus.WARNING
101
+ result.messages.append(f"{warnings} warning(s)")
102
+ else:
103
+ result.messages.append("clean")
104
+
105
+ return result
106
+
107
+
108
+ def check_overfull_boxes(log_path: Path | None) -> CheckResult:
109
+ """Count overfull and underfull boxes from the LaTeX log."""
110
+ result = CheckResult(name="Boxes")
111
+ if not log_path or not log_path.is_file():
112
+ return result
113
+
114
+ text = log_path.read_text(errors="replace")
115
+ lines = text.splitlines()
116
+
117
+ overfull = [line for line in lines if line.startswith("Overfull")]
118
+ underfull_count = sum(1 for line in lines if line.startswith("Underfull"))
119
+
120
+ if overfull or underfull_count:
121
+ result.status = CheckStatus.WARNING
122
+ result.messages.append(f"{len(overfull)} overfull, {underfull_count} underfull")
123
+ for line in overfull:
124
+ result.messages.append(f" {line}")
125
+ else:
126
+ result.messages.append("clean")
127
+
128
+ return result
129
+
130
+
131
+ def _pdffonts_columns(header: str, separator: str) -> dict[str, tuple[int, int]]:
132
+ """Map pdffonts column names to (start, end) spans.
133
+
134
+ Spans come from the dashed separator under the header, so they follow
135
+ whatever widths the installed poppler version prints.
136
+ """
137
+ columns: dict[str, tuple[int, int]] = {}
138
+ for m in re.finditer(r"-+", separator):
139
+ start, end = m.span()
140
+ columns[header[start:end].strip().lower()] = (start, end)
141
+ return columns
142
+
143
+
144
+ def _pdffonts_field(line: str, span: tuple[int, int], name_width: int) -> str:
145
+ """Extract one column of a pdffonts row.
146
+
147
+ A font name longer than its column pushes the rest of the row right;
148
+ shift the span by the overflow so later columns still line up.
149
+ """
150
+ tokens = line.split(maxsplit=1)
151
+ overflow = max(0, len(tokens[0]) - name_width) if tokens else 0
152
+ start, end = span
153
+ return line[start + overflow : end + overflow].strip()
154
+
155
+
156
+ def check_font_embedding(pdf_path: Path | None, no_type3: bool = True) -> CheckResult:
157
+ """Check all fonts are embedded and, optionally, none are Type 3 (bitmap)."""
158
+ result = CheckResult(name="Fonts")
159
+
160
+ if not pdf_path or not pdf_path.is_file():
161
+ result.status = CheckStatus.SKIPPED
162
+ result.messages.append("Skipped (PDF not found)")
163
+ return result
164
+
165
+ if not shutil.which("pdffonts"):
166
+ result.status = CheckStatus.SKIPPED
167
+ result.messages.append("Skipped (pdffonts not found; install poppler-utils)")
168
+ return result
169
+
170
+ proc = subprocess.run(
171
+ ["pdffonts", str(pdf_path)],
172
+ capture_output=True,
173
+ text=True,
174
+ )
175
+ lines = proc.stdout.strip().splitlines()
176
+ if len(lines) < 3:
177
+ result.messages.append("No fonts found")
178
+ return result
179
+
180
+ columns = _pdffonts_columns(lines[0], lines[1])
181
+ if "name" not in columns or "type" not in columns or "emb" not in columns:
182
+ result.messages.append("Unrecognized pdffonts output")
183
+ return result
184
+ name_width = columns["name"][1] - columns["name"][0]
185
+
186
+ font_lines = lines[2:] # skip header + separator
187
+ total = len(font_lines)
188
+ unembedded = [
189
+ line for line in font_lines if _pdffonts_field(line, columns["emb"], name_width) != "yes"
190
+ ]
191
+ type3 = (
192
+ [
193
+ line
194
+ for line in font_lines
195
+ if _pdffonts_field(line, columns["type"], name_width) == "Type 3"
196
+ ]
197
+ if no_type3
198
+ else []
199
+ )
200
+
201
+ # The first message is the one-line summary shown in the report table.
202
+ if unembedded:
203
+ summary = f"{len(unembedded)}/{total} NOT embedded"
204
+ else:
205
+ summary = f"{total}/{total} embedded"
206
+ if type3:
207
+ summary += f", {len(type3)} Type 3 (bitmap)"
208
+ elif no_type3:
209
+ summary += ", no Type 3"
210
+ result.messages.append(summary)
211
+
212
+ if unembedded:
213
+ result.status = CheckStatus.ERROR
214
+ result.messages.append("Not embedded:")
215
+ result.messages.extend(f" {line}" for line in unembedded)
216
+
217
+ type3_pages: dict[str, list[int]] = {}
218
+ if type3:
219
+ if result.status != CheckStatus.ERROR:
220
+ result.status = CheckStatus.WARNING
221
+ type3_pages = _type3_pages(pdf_path, columns, name_width)
222
+ result.messages.append("Type 3 (bitmap):")
223
+ for line in type3:
224
+ obj = _pdffonts_field(line, columns["object id"], name_width)
225
+ result.messages.append(f" {line}{_format_pages(type3_pages.get(obj, []))}")
226
+ result.messages.append(
227
+ " hint: usual culprits are matplotlib figures (set pdf.fonttype: 42)"
228
+ " and METAFONT-rendered glyphs (install cm-super or use a Type 1"
229
+ " alternative); run pdffonts on the figure PDFs included on those pages",
230
+ )
231
+
232
+ report = pdf_path.with_name("fonts.txt")
233
+ _write_font_report(report, proc.stdout, type3, type3_pages, columns, name_width)
234
+ result.messages.append(f"Full font report: {report}")
235
+
236
+ return result
237
+
238
+
239
+ def _type3_pages(
240
+ pdf_path: Path,
241
+ columns: dict[str, tuple[int, int]],
242
+ name_width: int,
243
+ ) -> dict[str, list[int]]:
244
+ """Map each Type 3 font's object ID to the pages that use it."""
245
+ n_pages = pages.get_total_pages(pdf_path) or 0
246
+ found: dict[str, list[int]] = defaultdict(list)
247
+ for page in range(1, n_pages + 1):
248
+ proc = subprocess.run(
249
+ ["pdffonts", "-f", str(page), "-l", str(page), str(pdf_path)],
250
+ capture_output=True,
251
+ text=True,
252
+ )
253
+ for line in proc.stdout.splitlines()[2:]:
254
+ if _pdffonts_field(line, columns["type"], name_width) == "Type 3":
255
+ found[_pdffonts_field(line, columns["object id"], name_width)].append(page)
256
+ return found
257
+
258
+
259
+ def _format_pages(page_list: list[int]) -> str:
260
+ if not page_list:
261
+ return ""
262
+ return f" (p. {', '.join(map(str, page_list))})"
263
+
264
+
265
+ def _write_font_report(
266
+ report: Path,
267
+ pdffonts_output: str,
268
+ type3: list[str],
269
+ type3_pages: dict[str, list[int]],
270
+ columns: dict[str, tuple[int, int]],
271
+ name_width: int,
272
+ ) -> None:
273
+ """Write the full pdffonts listing plus Type 3 page locations."""
274
+ parts = ["# pdffonts", pdffonts_output.rstrip(), ""]
275
+ if type3:
276
+ parts.append("# Type 3 (bitmap) fonts and the pages using them")
277
+ for line in type3:
278
+ obj = _pdffonts_field(line, columns["object id"], name_width)
279
+ parts.append(f"{line}{_format_pages(type3_pages.get(obj, []))}")
280
+ parts.append("")
281
+ with contextlib.suppress(OSError):
282
+ report.write_text("\n".join(parts))
283
+
284
+
285
+ def check_toolchain(config: PaperkitConfig, log_path: Path | None) -> CheckResult:
286
+ """Check the PDF was built with the TeX Live that [texlive] configures."""
287
+ result = CheckResult(name="Toolchain")
288
+ banner = texlive.read_log_banner(log_path)
289
+ if banner is None:
290
+ result.status = CheckStatus.SKIPPED
291
+ result.messages.append("Skipped (no engine banner in the build log)")
292
+ return result
293
+
294
+ mismatch = texlive.toolchain_mismatch(config.texlive, banner)
295
+ if mismatch:
296
+ result.status = CheckStatus.WARNING
297
+ result.messages.append(f"PDF not built with the configured TeX Live: {mismatch}")
298
+ result.messages.append(
299
+ " was it built outside the paperkit image (e.g. `paperkit build --no-docker`)?",
300
+ )
301
+ else:
302
+ result.messages.append(f"{banner.distribution} ({texlive.describe(config.texlive)})")
303
+ return result
304
+
305
+
306
+ def check_non_ascii(config: PaperkitConfig) -> CheckResult:
307
+ """Flag non-ASCII characters in source .tex files."""
308
+ result = CheckResult(name="Non-ASCII")
309
+ tex_files = _collect_tex_files(config)
310
+ bad_files: list[tuple[str, list[str]]] = []
311
+
312
+ for tf in tex_files:
313
+ content = tf.read_bytes()
314
+ hits = []
315
+ for i, line in enumerate(content.splitlines(), 1):
316
+ if any(b > 127 for b in line):
317
+ hits.append(f" {i}: {line.decode('utf-8', errors='replace')}")
318
+ if hits:
319
+ rel = tf.relative_to(config.root)
320
+ bad_files.append((str(rel), hits))
321
+
322
+ if not bad_files:
323
+ result.messages.append("All source .tex files are ASCII-clean")
324
+ else:
325
+ result.status = CheckStatus.WARNING
326
+ for relpath, hits in bad_files:
327
+ result.messages.append(f"{relpath}:")
328
+ result.messages.extend(hits)
329
+
330
+ return result
331
+
332
+
333
+ def check_trailing_whitespace(config: PaperkitConfig) -> CheckResult:
334
+ """Check for trailing whitespace in source .tex files."""
335
+ result = CheckResult(name="Trailing whitespace")
336
+ tex_files = _collect_tex_files(config)
337
+ total = 0
338
+ file_counts: list[tuple[str, int]] = []
339
+
340
+ for tf in tex_files:
341
+ text = tf.read_text(errors="replace")
342
+ count = sum(1 for line in text.splitlines() if line != line.rstrip(" "))
343
+ if count:
344
+ rel = tf.relative_to(config.root)
345
+ file_counts.append((str(rel), count))
346
+ total += count
347
+
348
+ if total == 0:
349
+ result.messages.append("No trailing whitespace found")
350
+ else:
351
+ result.status = CheckStatus.WARNING
352
+ result.messages.append(f"{total} line(s) with trailing whitespace:")
353
+ for relpath, count in file_counts:
354
+ result.messages.append(f" {relpath} ({count} lines)")
355
+
356
+ return result
357
+
358
+
359
+ def check_final_newline(config: PaperkitConfig) -> CheckResult:
360
+ """Check that each .tex file ends with exactly one newline."""
361
+ result = CheckResult(name="Final newline")
362
+ tex_files = _collect_tex_files(config)
363
+ issues: list[str] = []
364
+
365
+ for tf in tex_files:
366
+ data = tf.read_bytes()
367
+ rel = str(tf.relative_to(config.root))
368
+ if not data:
369
+ issues.append(f"{rel}: empty file")
370
+ elif data[-1:] != b"\n":
371
+ issues.append(f"{rel}: no final newline")
372
+ elif data[-2:] == b"\n\n":
373
+ issues.append(f"{rel}: multiple trailing newlines")
374
+
375
+ if not issues:
376
+ result.messages.append("All source .tex files end with a single newline")
377
+ else:
378
+ result.status = CheckStatus.WARNING
379
+ result.messages.extend(issues)
380
+
381
+ return result
382
+
383
+
384
+ def check_pages(config: PaperkitConfig, build_result: BuildResult) -> CheckResult:
385
+ """Report total pages + section start pages; flag if over the configured limits."""
386
+ result = CheckResult(name="Pages/Limit", advisory=True)
387
+ limits = config.page_limits
388
+
389
+ stats = pages.gather(
390
+ pdf_path=build_result.pdf,
391
+ aux_path=build_result.aux,
392
+ refs_pattern=limits.refs_pattern,
393
+ ack_pattern=limits.ack_pattern,
394
+ ack_counts_as_content=limits.ack_counts_as_content,
395
+ )
396
+
397
+ if stats.total is None:
398
+ result.status = CheckStatus.SKIPPED
399
+ result.messages.append("Skipped (pdfinfo not found or no PDF)")
400
+ return result
401
+
402
+ # Inline summary string + per-metric overage report.
403
+ parts: list[str] = []
404
+ overages: list[str] = []
405
+
406
+ def _metric(label: str, value: int | None, limit: int | None, suffix: str = "") -> None:
407
+ if value is None:
408
+ return
409
+ if limit is None:
410
+ parts.append(f"{label} {value}{suffix}")
411
+ else:
412
+ if value > limit:
413
+ parts.append(f"{label} {value}/{limit}{suffix} \u274c")
414
+ overages.append(f"{label}: {value} > {limit}")
415
+ else:
416
+ parts.append(f"{label} {value}/{limit}{suffix} \u2714")
417
+
418
+ _metric("total", stats.total, limits.total)
419
+
420
+ refs_note = f"refs start p.{stats.refs_page}" if stats.refs_page is not None else ""
421
+ if limits.ack_counts_as_content and stats.ack_page is not None:
422
+ # Say why the acknowledgments are inside the budget.
423
+ refs_note = f"{refs_note}; acks counted" if refs_note else "acks counted"
424
+ _metric(
425
+ "before refs",
426
+ stats.before_refs,
427
+ limits.before_refs,
428
+ f" ({refs_note})" if refs_note else "",
429
+ )
430
+
431
+ _metric(
432
+ "before ack",
433
+ stats.before_ack,
434
+ limits.before_ack,
435
+ f" (ack starts p.{stats.ack_page})" if stats.ack_page is not None else "",
436
+ )
437
+
438
+ # Note un-detected milestones only when the user has set a limit for them.
439
+ if limits.before_refs is not None and stats.before_refs is None:
440
+ parts.append("refs not detected")
441
+ if limits.before_ack is not None and stats.before_ack is None:
442
+ parts.append("ack not detected")
443
+
444
+ summary = " \u2502 ".join(parts) if parts else "no data"
445
+ if overages:
446
+ result.status = CheckStatus.ERROR
447
+ result.messages.append(summary)
448
+ for line in overages:
449
+ result.messages.append(f" {line}")
450
+ else:
451
+ result.messages.append(summary)
452
+
453
+ return result
454
+
455
+
456
+ def check_bibtex_tidy(config: PaperkitConfig) -> CheckResult:
457
+ """Run bibtex-tidy in dry-run mode and check for differences."""
458
+ result = CheckResult(name="BibTeX tidy")
459
+
460
+ if not config.checks.bibtex_tidy.enabled:
461
+ result.messages.append("Disabled")
462
+ return result
463
+
464
+ bib_path = config.root / config.bib if config.bib else None
465
+ if not bib_path or not bib_path.is_file():
466
+ result.messages.append("Skipped (bib file not found)")
467
+ return result
468
+
469
+ if not shutil.which("bibtex-tidy"):
470
+ result.status = CheckStatus.SKIPPED
471
+ result.messages.append("Skipped (bibtex-tidy not found)")
472
+ return result
473
+
474
+ # Report version
475
+ ver_proc = subprocess.run(["bibtex-tidy", "--version"], capture_output=True, text=True)
476
+ version = ver_proc.stdout.strip() or "unknown"
477
+ result.messages.append(f"bibtex-tidy: {version}")
478
+
479
+ from paperkit.profiles import get_profile
480
+
481
+ profile = get_profile(config)
482
+ tidy_args = profile.bibtex_tidy_args(sort=config.checks.bibtex_tidy.sort)
483
+
484
+ # Copy bib to temp, run bibtex-tidy, compare
485
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".bib", delete=False) as tmp:
486
+ tmp.write(bib_path.read_text())
487
+ tmp_path = Path(tmp.name)
488
+
489
+ try:
490
+ tidy_proc = subprocess.run(
491
+ ["bibtex-tidy", *tidy_args, str(tmp_path)],
492
+ capture_output=True,
493
+ text=True,
494
+ )
495
+
496
+ # Compare
497
+ original = bib_path.read_text()
498
+ tidied = tmp_path.read_text()
499
+
500
+ if original == tidied:
501
+ result.messages.append("references.bib is tidy")
502
+ else:
503
+ orig_lines = original.splitlines()
504
+ tidy_lines = tidied.splitlines()
505
+ diff_lines = sum(1 for a, b in zip(orig_lines, tidy_lines, strict=False) if a != b)
506
+ result.status = CheckStatus.WARNING
507
+ result.messages.append(f"references.bib is not tidy ({diff_lines} lines differ)")
508
+ cmd = " ".join(["bibtex-tidy", *tidy_args])
509
+ result.messages.append(f"To fix, run: {cmd} {config.bib}")
510
+
511
+ # Check for duplicates
512
+ for line in tidy_proc.stdout.splitlines():
513
+ if "DUPLICATE_ENTRY" in line:
514
+ result.status = CheckStatus.WARNING
515
+ result.messages.append(f" {line.replace('DUPLICATE_ENTRY: ', '')}")
516
+ finally:
517
+ tmp_path.unlink(missing_ok=True)
518
+
519
+ return result
520
+
521
+
522
+ def run_all_checks(
523
+ config: PaperkitConfig,
524
+ build_result: BuildResult,
525
+ bib_results: list[CheckResult] | None = None,
526
+ ) -> list[CheckResult]:
527
+ """Run all enabled per-target checks and return results.
528
+
529
+ Bibliography checks span targets sharing a bib file, so the caller
530
+ computes them (paperkit.bibliography) and passes them in *bib_results*.
531
+ """
532
+ results: list[CheckResult] = []
533
+
534
+ # Log checks (always run)
535
+ if build_result.log:
536
+ results.append(check_log_errors(build_result.log, "LaTeX"))
537
+ if build_result.blg:
538
+ bib_label = "Biber" if config.biber else "BibTeX"
539
+ results.append(check_log_errors(build_result.blg, bib_label))
540
+
541
+ if config.texlive.source == "upstream":
542
+ results.append(check_toolchain(config, build_result.log))
543
+
544
+ if config.checks.overfull_boxes:
545
+ results.append(check_overfull_boxes(build_result.log))
546
+
547
+ if config.checks.font_embedding:
548
+ results.append(check_font_embedding(build_result.pdf, config.checks.no_type3))
549
+
550
+ if config.checks.non_ascii:
551
+ results.append(check_non_ascii(config))
552
+
553
+ if config.checks.trailing_whitespace:
554
+ results.append(check_trailing_whitespace(config))
555
+
556
+ if config.checks.final_newline:
557
+ results.append(check_final_newline(config))
558
+
559
+ results += bib_results or []
560
+
561
+ results.append(check_pages(config, build_result))
562
+
563
+ return results