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/__init__.py +6 -0
- paperkit/archive.py +183 -0
- paperkit/bibliography.py +321 -0
- paperkit/builder.py +186 -0
- paperkit/checks.py +563 -0
- paperkit/cli.py +595 -0
- paperkit/config.py +437 -0
- paperkit/docker.py +265 -0
- paperkit/pages.py +254 -0
- paperkit/profiles/__init__.py +56 -0
- paperkit/profiles/acm.py +10 -0
- paperkit/py.typed +0 -0
- paperkit/report.py +246 -0
- paperkit/reproducible.py +158 -0
- paperkit/texlive.py +310 -0
- paperkit/text_export.py +165 -0
- paperkit-0.1.0.dist-info/METADATA +381 -0
- paperkit-0.1.0.dist-info/RECORD +21 -0
- paperkit-0.1.0.dist-info/WHEEL +4 -0
- paperkit-0.1.0.dist-info/entry_points.txt +2 -0
- paperkit-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|