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 ADDED
@@ -0,0 +1,6 @@
1
+ # Copyright (C) 2026 Vrije Universiteit Brussel. All rights reserved.
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """paperkit: reproducible builds and pre-submission checks for research papers."""
5
+
6
+ __version__ = "0.1.0"
paperkit/archive.py ADDED
@@ -0,0 +1,183 @@
1
+ # Copyright (C) 2026 Vrije Universiteit Brussel. All rights reserved.
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """Submission zip creation."""
5
+
6
+ import datetime as dt
7
+ import fnmatch
8
+ import zipfile
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+
12
+ from paperkit import reproducible
13
+ from paperkit.config import PaperkitConfig
14
+
15
+ # Zip stores DOS timestamps, which cannot predate 1980.
16
+ _DOS_EPOCH = dt.datetime(1980, 1, 1, tzinfo=dt.UTC)
17
+
18
+
19
+ def _expand_pattern(root: Path, pattern: str) -> list[Path]:
20
+ """Expand a glob-style include pattern relative to root.
21
+
22
+ Supports {main} placeholder for the main .tex filename.
23
+ """
24
+ # Trailing / means include directory recursively
25
+ if pattern.endswith("/"):
26
+ d = root / pattern.rstrip("/")
27
+ if d.is_dir():
28
+ return sorted(d.rglob("*"))
29
+ return []
30
+ return sorted(root.glob(pattern))
31
+
32
+
33
+ def _is_excluded(path: Path, excludes: list[str]) -> bool:
34
+ """Check if a path matches any exclude pattern."""
35
+ name = path.name
36
+ return any(fnmatch.fnmatch(name, pat) for pat in excludes)
37
+
38
+
39
+ @dataclass
40
+ class ZipResult:
41
+ """The archive that was written, and anything the caller should be told."""
42
+
43
+ path: Path
44
+ warnings: list[str] = field(default_factory=list)
45
+
46
+
47
+ class MissingBblError(Exception):
48
+ """A requested compiled bibliography is not in the build directory."""
49
+
50
+
51
+ def _bbl_entries(config: PaperkitConfig, files: set[Path]) -> tuple[dict[str, Path], list[str]]:
52
+ """Compiled bibliographies to add, keyed by their path inside the archive.
53
+
54
+ One per main whose .tex the archive already carries, taken from the build
55
+ directory and stored next to that .tex, since a publisher compiling the
56
+ archive alone looks for <main>.bbl there.
57
+ """
58
+ root = config.root
59
+ entries: dict[str, Path] = {}
60
+ warnings: list[str] = []
61
+ missing: list[str] = []
62
+
63
+ for main in config.mains or [config.main]:
64
+ tex = root / f"{main}.tex"
65
+ if tex not in files:
66
+ continue # this main is not part of the archive
67
+ bbl = root / config.output_dir / main / f"{main}.bbl"
68
+ if not bbl.is_file():
69
+ missing.append(f"{bbl.relative_to(root)}")
70
+ continue
71
+ entries[f"{main}.bbl"] = bbl
72
+ warnings += _staleness_warnings(config, main, bbl, tex)
73
+
74
+ if missing:
75
+ raise MissingBblError(
76
+ "compiled bibliography not found: "
77
+ + ", ".join(missing)
78
+ + "; run `paperkit build` first",
79
+ )
80
+ return entries, warnings
81
+
82
+
83
+ def _staleness_warnings(config: PaperkitConfig, main: str, bbl: Path, tex: Path) -> list[str]:
84
+ """Warn when the .bbl predates the sources it was compiled from."""
85
+ warnings = []
86
+ bbl_mtime = bbl.stat().st_mtime
87
+ sources = [tex]
88
+ if config.bib:
89
+ sources.append(config.root / config.bib)
90
+ for source in sources:
91
+ if source.is_file() and source.stat().st_mtime > bbl_mtime:
92
+ name = source.relative_to(config.root)
93
+ warnings.append(
94
+ f"{main}.bbl is older than {name}: the archive may not match the "
95
+ f"sources, rebuild with `paperkit build`",
96
+ )
97
+ return warnings
98
+
99
+
100
+ def create_zip(config: PaperkitConfig, include_bbl: bool | None = None) -> ZipResult:
101
+ """Create the submission zip archive.
102
+
103
+ *include_bbl* overrides `[zip] include_bbl` for this run.
104
+ """
105
+ root = config.root
106
+ zip_name = config.zip.name or config.main
107
+ zip_path = root / f"{zip_name}-sources.zip"
108
+
109
+ # Resolve {main} in patterns: expands once per main, so a single
110
+ # "{main}.tex" entry in the zip include list pulls in every project's .tex.
111
+ mains = config.mains or [config.main]
112
+ includes: list[str] = []
113
+ for include in config.zip.include:
114
+ if "{main}" in include:
115
+ includes.extend(include.replace("{main}", m) for m in mains)
116
+ else:
117
+ includes.append(include)
118
+
119
+ # Collect files
120
+ files: set[Path] = set()
121
+ for pattern in includes:
122
+ for p in _expand_pattern(root, pattern):
123
+ if p.is_file() and not _is_excluded(p, config.zip.exclude):
124
+ files.add(p)
125
+
126
+ # Resolved before the old archive is removed, so a missing .bbl leaves it
127
+ # in place. The exclude patterns do not apply: these are asked for by name.
128
+ wanted = config.zip.include_bbl if include_bbl is None else include_bbl
129
+ bbl_entries, warnings = _bbl_entries(config, files) if wanted else ({}, [])
130
+
131
+ # Entry metadata is normalized to the build timestamp, so the same sources
132
+ # archive to the same bytes on another machine or a fresh clone, where the
133
+ # files' own mtimes differ.
134
+ source_date = reproducible.resolve(config.source_date_epoch, root)
135
+ entries = {str(f.relative_to(root)): f for f in files}
136
+ entries.update(bbl_entries)
137
+
138
+ zip_path.unlink(missing_ok=True)
139
+ with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
140
+ for arcname in sorted(entries):
141
+ zf.writestr(
142
+ _entry_info(arcname, entries[arcname], source_date), entries[arcname].read_bytes()
143
+ )
144
+
145
+ return ZipResult(zip_path, warnings)
146
+
147
+
148
+ def _entry_info(
149
+ arcname: str, source: Path, source_date: reproducible.SourceDate
150
+ ) -> zipfile.ZipInfo:
151
+ """Zip entry whose metadata depends on the sources, not on this machine.
152
+
153
+ The timestamp is the build timestamp rather than the file's mtime (git
154
+ does not restore mtimes, so a fresh clone would archive differently), and
155
+ the mode keeps only the executable bit. The zip format stores DOS times,
156
+ which start in 1980 and have two-second resolution.
157
+ """
158
+ stamp = max(dt.datetime.fromtimestamp(source_date.epoch, dt.UTC), _DOS_EPOCH)
159
+ info = zipfile.ZipInfo(arcname, date_time=stamp.timetuple()[:6])
160
+ info.compress_type = zipfile.ZIP_DEFLATED
161
+ info.create_system = 3 # Unix, whatever the host
162
+ executable = source.stat().st_mode & 0o111
163
+ info.external_attr = (0o755 if executable else 0o644) << 16
164
+ return info
165
+
166
+
167
+ def print_zip_summary(zip_path: Path) -> None:
168
+ """Print a summary of the created zip."""
169
+ size_mb = zip_path.stat().st_size / (1024 * 1024)
170
+ print(f"==> Created: {zip_path}")
171
+ print(f" {size_mb:.1f} MiB")
172
+
173
+ with zipfile.ZipFile(zip_path, "r") as zf:
174
+ names = zf.namelist()
175
+
176
+ print("")
177
+ print("Contents:")
178
+ for name in names[:40]:
179
+ print(f" {name}")
180
+ if len(names) > 40:
181
+ print(f" ... ({len(names)} files total)")
182
+ else:
183
+ print(f" ({len(names)} files total)")
@@ -0,0 +1,321 @@
1
+ # Copyright (C) 2026 Vrije Universiteit Brussel. All rights reserved.
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """Project-wide bibliography checks: unused entries per bib file.
5
+
6
+ Several build targets may share a bib file, so an entry is unused only if no
7
+ target that uses the file cites it. Which bib files a target uses and which
8
+ keys it cites come from its LaTeX output, never from scanning sources:
9
+
10
+ * bibtex: `\\bibdata{a,b}` and `\\citation{k1,k2}` in the .aux (and the
11
+ .aux files it pulls in with `\\@input`); `\\citation{*}` is
12
+ `\\nocite{*}`, which uses every entry.
13
+ * biber: the .bcf `<bcf:datasource>` and `<bcf:citekey>` elements.
14
+
15
+ Targets not built or checked in this run are resolved from their existing
16
+ .aux if it is up to date, else from a draft LaTeX pass into a scratch
17
+ directory. If neither works, the target is "not checked" and no entry it
18
+ might cite is reported as unused.
19
+ """
20
+
21
+ import re
22
+ import tempfile
23
+ from dataclasses import dataclass, field
24
+ from pathlib import Path
25
+
26
+ from paperkit.builder import BuildResult, run_latex
27
+ from paperkit.checks import CheckResult, CheckStatus, check_bibtex_tidy
28
+ from paperkit.config import PaperkitConfig
29
+
30
+ _DIM = "\033[2m"
31
+ _RESET = "\033[0m"
32
+
33
+ _CITATION_RE = re.compile(r"\\citation\{([^}]*)\}")
34
+ _ABX_CITE_RE = re.compile(r"\\abx@aux@cite\{[^}]*\}\{([^}]+)\}")
35
+ _BIBDATA_RE = re.compile(r"\\bibdata\{([^}]*)\}")
36
+ _INPUT_RE = re.compile(r"\\@input\{([^}]*)\}")
37
+ _BCF_DATASOURCE_RE = re.compile(r"<bcf:datasource\b[^>]*>([^<]+)</bcf:datasource>")
38
+ _BCF_CITEKEY_RE = re.compile(r"<bcf:citekey\b[^>]*>([^<]+)</bcf:citekey>")
39
+ _BIB_ENTRY_RE = re.compile(r"^\s*@(\w+)\s*\{\s*([^,\s{}]+)", re.MULTILINE)
40
+ # Bib "entries" that are not citable references.
41
+ _NON_ENTRY_TYPES = {"string", "preamble", "comment"}
42
+
43
+ # Only these files can change what a target cites, so they decide whether an
44
+ # existing .aux is still up to date.
45
+ _SOURCE_SUFFIXES = {".tex", ".sty", ".cls", ".bib", ".bst", ".bbx", ".cbx", ".def", ".cfg"}
46
+
47
+
48
+ @dataclass
49
+ class AuxData:
50
+ """What a target's LaTeX run recorded about its bibliography."""
51
+
52
+ bibdata: list[str] = field(default_factory=list) # bib names as written
53
+ citations: set[str] = field(default_factory=set)
54
+ cite_all: bool = False # \nocite{*}
55
+
56
+
57
+ @dataclass
58
+ class TargetUsage:
59
+ """Bib files used and keys cited by one build target."""
60
+
61
+ name: str
62
+ bib_files: set[Path]
63
+ citations: set[str]
64
+ cite_all: bool
65
+ origin: str # "this run", "existing .aux", "draft pass"
66
+
67
+
68
+ def parse_aux(aux_path: Path) -> AuxData:
69
+ """Parse an .aux file and the .aux files it includes with \\@input."""
70
+ return _parse_aux(aux_path.resolve(), aux_path.resolve().parent, set())
71
+
72
+
73
+ def _parse_aux(aux_path: Path, base: Path, seen: set[Path]) -> AuxData:
74
+ data = AuxData()
75
+ if aux_path in seen or not aux_path.is_file():
76
+ return data
77
+ seen.add(aux_path)
78
+
79
+ text = aux_path.read_text(errors="replace")
80
+ for m in _BIBDATA_RE.finditer(text):
81
+ data.bibdata += [b.strip() for b in m.group(1).split(",") if b.strip()]
82
+ keys = [k for m in _CITATION_RE.finditer(text) for k in m.group(1).split(",")]
83
+ keys += [m.group(1) for m in _ABX_CITE_RE.finditer(text)]
84
+ _add_keys(data, keys)
85
+
86
+ for m in _INPUT_RE.finditer(text):
87
+ # Every \@input path, however deeply nested, is relative to the output
88
+ # directory holding the main .aux.
89
+ nested = _parse_aux((base / m.group(1).strip()).resolve(), base, seen)
90
+ data.bibdata += [b for b in nested.bibdata if b not in data.bibdata]
91
+ data.citations |= nested.citations
92
+ data.cite_all = data.cite_all or nested.cite_all
93
+ return data
94
+
95
+
96
+ def parse_bcf(bcf_path: Path) -> AuxData:
97
+ """Parse biblatex's control file (.bcf), which biber reads."""
98
+ data = AuxData()
99
+ if not bcf_path.is_file():
100
+ return data
101
+ text = bcf_path.read_text(errors="replace")
102
+ data.bibdata = [m.group(1).strip() for m in _BCF_DATASOURCE_RE.finditer(text)]
103
+ _add_keys(data, [m.group(1) for m in _BCF_CITEKEY_RE.finditer(text)])
104
+ return data
105
+
106
+
107
+ def _add_keys(data: AuxData, keys: list[str]) -> None:
108
+ for key in (k.strip() for k in keys):
109
+ if key == "*":
110
+ data.cite_all = True
111
+ elif key:
112
+ data.citations.add(key)
113
+
114
+
115
+ def resolve_bib_file(root: Path, name: str) -> Path | None:
116
+ """Project bib file for a \\bibdata / datasource name, or None.
117
+
118
+ Names resolve against the project root (where paperkit points bibtex and
119
+ biber). Files found only in the TeX tree (e.g. IEEEabrv) are not the
120
+ project's and are left out.
121
+ """
122
+ path = root / name
123
+ if path.suffix != ".bib":
124
+ path = path.with_name(path.name + ".bib")
125
+ return path.resolve() if path.is_file() else None
126
+
127
+
128
+ def usage_from_dir(
129
+ config: PaperkitConfig,
130
+ name: str,
131
+ directory: Path,
132
+ origin: str,
133
+ ) -> TargetUsage | None:
134
+ """Usage recorded in *directory*'s <name>.aux (and .bcf with biber)."""
135
+ aux = directory / f"{name}.aux"
136
+ if not aux.is_file():
137
+ return None
138
+ data = parse_aux(aux)
139
+ if config.biber:
140
+ bcf = parse_bcf(directory / f"{name}.bcf")
141
+ data.bibdata += bcf.bibdata
142
+ data.citations |= bcf.citations
143
+ data.cite_all = data.cite_all or bcf.cite_all
144
+ bib_files = {p for b in data.bibdata if (p := resolve_bib_file(config.root, b))}
145
+ return TargetUsage(name, bib_files, data.citations, data.cite_all, origin)
146
+
147
+
148
+ def _newest_source_mtime(config: PaperkitConfig, name: str, usage: TargetUsage) -> float:
149
+ """Newest modification time of anything that can change *name*'s citations."""
150
+ out_dir = (config.root / config.output_dir).resolve()
151
+ paths = [config.root / f"{name}.tex", *usage.bib_files]
152
+ if config.bib:
153
+ paths.append(config.root / config.bib)
154
+ for d in config.content_dirs:
155
+ for p in (config.root / d).rglob("*"):
156
+ if p.suffix in _SOURCE_SUFFIXES and out_dir not in p.resolve().parents:
157
+ paths.append(p)
158
+ return max((p.stat().st_mtime for p in paths if p.is_file()), default=0.0)
159
+
160
+
161
+ def is_up_to_date(config: PaperkitConfig, usage: TargetUsage, aux: Path) -> bool:
162
+ return aux.stat().st_mtime > _newest_source_mtime(config, usage.name, usage)
163
+
164
+
165
+ def draft_usage(config: PaperkitConfig, name: str) -> TargetUsage | None:
166
+ """Citations of *name* from one draft LaTeX pass into a scratch directory.
167
+
168
+ No PDF is written (shell-escape tools such as minted still run), and the
169
+ real output directory is not touched. None if the pass fails.
170
+ """
171
+ flag = "-no-pdf" if config.engine == "xelatex" else "-draftmode"
172
+ with tempfile.TemporaryDirectory(prefix=f"paperkit-draft-{name}-") as tmp:
173
+ rc = run_latex(
174
+ config.engine,
175
+ config.root / f"{name}.tex",
176
+ Path(tmp),
177
+ shell_escape=config.shell_escape,
178
+ extra_args=[flag],
179
+ )
180
+ if rc != 0:
181
+ return None
182
+ return usage_from_dir(config, name, Path(tmp), "draft pass")
183
+
184
+
185
+ def resolve_other_targets(
186
+ config: PaperkitConfig,
187
+ known: dict[str, TargetUsage],
188
+ skip: set[str],
189
+ ) -> tuple[dict[str, TargetUsage], list[str]]:
190
+ """Usage of every main not in *known*: (resolved usages, names not checked).
191
+
192
+ Mains in *skip* (e.g. a failed build in this run) are not checked.
193
+ """
194
+ usages = dict(known)
195
+ unchecked: list[str] = []
196
+ for name in config.mains:
197
+ if name in usages:
198
+ continue
199
+ if name in skip:
200
+ unchecked.append(name)
201
+ continue
202
+ out_dir = config.root / config.output_dir / name
203
+ usage = usage_from_dir(config, name, out_dir, "existing .aux")
204
+ if usage and is_up_to_date(config, usage, out_dir / f"{name}.aux"):
205
+ usages[name] = usage
206
+ continue
207
+ if config.checks.unused_bib.draft_pass:
208
+ print(f" {_DIM}citations of {name}: draft {config.engine} pass ...{_RESET}")
209
+ usage = draft_usage(config, name)
210
+ if usage:
211
+ usages[name] = usage
212
+ continue
213
+ unchecked.append(name)
214
+ return usages, unchecked
215
+
216
+
217
+ def bib_entry_keys(bib_path: Path) -> set[str]:
218
+ text = bib_path.read_text(errors="replace")
219
+ return {
220
+ m.group(2).strip()
221
+ for m in _BIB_ENTRY_RE.finditer(text)
222
+ if m.group(1).lower() not in _NON_ENTRY_TYPES
223
+ }
224
+
225
+
226
+ def unused_bib_checks(
227
+ config: PaperkitConfig,
228
+ usages: dict[str, TargetUsage],
229
+ unchecked: list[str],
230
+ focus: list[str],
231
+ ) -> list[CheckResult]:
232
+ """One "Unused bib entries" result per bib file used by the *focus* targets."""
233
+ focus_usages = [usages[n] for n in focus if n in usages]
234
+ configured = resolve_bib_file(config.root, config.bib) if config.bib else None
235
+ bib_files = {p for u in focus_usages for p in u.bib_files}
236
+ # A configured bib file no target declares (e.g. bibliography commented
237
+ # out) is still checked against the focus targets' citations.
238
+ legacy = configured is not None and not any(configured in u.bib_files for u in usages.values())
239
+ if legacy and configured is not None:
240
+ bib_files.add(configured)
241
+
242
+ if not focus_usages or not bib_files:
243
+ skipped = CheckResult(name="Unused bib entries")
244
+ skipped.messages.append("Skipped (aux or bib file not found)")
245
+ return [skipped]
246
+
247
+ ignore = set(config.checks.unused_bib.ignore)
248
+ root = config.root.resolve()
249
+ multi = len(config.mains) > 1
250
+ results = []
251
+ for bib in sorted(bib_files):
252
+ users = [u for u in usages.values() if bib in u.bib_files]
253
+ if legacy and bib == configured:
254
+ users = focus_usages
255
+ rel = bib.relative_to(root) if bib.is_relative_to(root) else bib
256
+ name = "Unused bib entries" if len(bib_files) == 1 else f"Unused bib entries ({rel})"
257
+ results.append(_unused_result(name, bib, users, unchecked, ignore, multi))
258
+ return results
259
+
260
+
261
+ def _unused_result(
262
+ name: str,
263
+ bib: Path,
264
+ users: list[TargetUsage],
265
+ unchecked: list[str],
266
+ ignore: set[str],
267
+ multi: bool,
268
+ ) -> CheckResult:
269
+ result = CheckResult(name=name)
270
+ if any(u.cite_all for u in users):
271
+ result.messages.append("All bib entries are cited (\\nocite{*})")
272
+ return result
273
+ cited = set().union(*(u.citations for u in users))
274
+ not_cited = sorted(bib_entry_keys(bib) - cited - ignore)
275
+ user_names = ", ".join(u.name for u in users)
276
+
277
+ if not not_cited:
278
+ result.messages.append("All bib entries are cited")
279
+ elif unchecked:
280
+ # Another target may cite these: say so, but never call them unused.
281
+ result.show_details = True
282
+ result.messages.append(
283
+ f"{len(not_cited)} not cited by {user_names or 'the checked targets'}; "
284
+ f"other targets not checked: {', '.join(unchecked)}",
285
+ )
286
+ result.messages.extend(f" {key}" for key in not_cited)
287
+ else:
288
+ result.status = CheckStatus.WARNING
289
+ if multi:
290
+ result.messages.append(
291
+ f"{len(not_cited)} unused bib entry/entries (cited by no target: {user_names}):",
292
+ )
293
+ else:
294
+ result.messages.append(f"{len(not_cited)} unused bib entry/entries:")
295
+ result.messages.extend(f" {key}" for key in not_cited)
296
+ return result
297
+
298
+
299
+ def project_bib_checks(
300
+ config: PaperkitConfig,
301
+ outputs: dict[str, BuildResult],
302
+ focus: list[str],
303
+ failed: set[str] | None = None,
304
+ ) -> list[CheckResult]:
305
+ """Bibliography checks for the bib files the *focus* targets use.
306
+
307
+ *outputs* holds this run's build (or checked) output per target; other
308
+ mains are resolved with resolve_other_targets.
309
+ """
310
+ results: list[CheckResult] = []
311
+ if config.checks.unused_bib.enabled:
312
+ known = {
313
+ name: usage
314
+ for name, out in outputs.items()
315
+ if (usage := usage_from_dir(config, name, out.output_dir, "this run"))
316
+ }
317
+ usages, unchecked = resolve_other_targets(config, known, skip=failed or set())
318
+ results += unused_bib_checks(config, usages, unchecked, focus)
319
+ if config.checks.bibtex_tidy.enabled:
320
+ results.append(check_bibtex_tidy(config))
321
+ return results
paperkit/builder.py ADDED
@@ -0,0 +1,186 @@
1
+ # Copyright (C) 2026 Vrije Universiteit Brussel. All rights reserved.
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """LaTeX build pipeline (pdflatex, lualatex, xelatex + bibtex/biber)."""
5
+
6
+ import os
7
+ import shutil
8
+ import subprocess
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+
12
+ from paperkit import reproducible
13
+ from paperkit.config import PaperkitConfig
14
+
15
+ # ANSI
16
+ _BOLD = "\033[1m"
17
+ _DIM = "\033[2m"
18
+ _GREEN = "\033[32m"
19
+ _RED = "\033[31m"
20
+ _RESET = "\033[0m"
21
+
22
+
23
+ @dataclass
24
+ class BuildResult:
25
+ """Result of a paper build."""
26
+
27
+ pdf: Path | None = None
28
+ log: Path | None = None
29
+ blg: Path | None = None
30
+ aux: Path | None = None
31
+ output_dir: Path = field(default_factory=lambda: Path("out"))
32
+ success: bool = False
33
+
34
+
35
+ def run_latex(
36
+ engine: str,
37
+ tex_file: Path,
38
+ output_dir: Path,
39
+ shell_escape: bool = True,
40
+ extra_args: list[str] | None = None,
41
+ env: dict[str, str] | None = None,
42
+ prelude: str = "",
43
+ ) -> int:
44
+ """Run a single LaTeX pass (pdflatex, lualatex, or xelatex). Returns exit code.
45
+
46
+ *prelude* is TeX code executed before the main file is read; the job name
47
+ is then set explicitly, since the engine would otherwise name the job
48
+ after that code instead of the file.
49
+ """
50
+ cmd = [
51
+ engine,
52
+ "-interaction=nonstopmode",
53
+ f"-output-directory={output_dir}",
54
+ ]
55
+ if shell_escape:
56
+ cmd.append("-shell-escape")
57
+ cmd += extra_args or []
58
+ if prelude:
59
+ cmd.append(f"-jobname={tex_file.stem}")
60
+ cmd.append(f"{prelude}\\input{{{tex_file}}}")
61
+ else:
62
+ cmd.append(str(tex_file))
63
+
64
+ result = subprocess.run(cmd, capture_output=True, env=_environment(env))
65
+ return result.returncode
66
+
67
+
68
+ def _environment(extra: dict[str, str] | None) -> dict[str, str]:
69
+ """The caller's environment plus *extra*."""
70
+ env = os.environ.copy()
71
+ env.update(extra or {})
72
+ return env
73
+
74
+
75
+ def run_bibtex(
76
+ aux_file: Path, bib_search_dir: Path, extra_env: dict[str, str] | None = None
77
+ ) -> int:
78
+ """Run bibtex with BIBINPUTS pointing to *bib_search_dir*. Returns exit code."""
79
+ env = _environment(extra_env)
80
+ env["BIBINPUTS"] = f"{bib_search_dir}:"
81
+
82
+ result = subprocess.run(
83
+ ["bibtex", aux_file.stem],
84
+ cwd=aux_file.parent,
85
+ env=env,
86
+ capture_output=True,
87
+ )
88
+ return result.returncode
89
+
90
+
91
+ def run_biber(
92
+ jobname: str,
93
+ output_dir: Path,
94
+ project_root: Path,
95
+ extra_env: dict[str, str] | None = None,
96
+ ) -> int:
97
+ """Run biber from the project root with output in *output_dir*. Returns exit code."""
98
+ result = subprocess.run(
99
+ ["biber", f"--output-directory={output_dir}", jobname],
100
+ cwd=project_root,
101
+ capture_output=True,
102
+ env=_environment(extra_env),
103
+ )
104
+ return result.returncode
105
+
106
+
107
+ def build_paper(config: PaperkitConfig, main: str | None = None) -> BuildResult:
108
+ """Orchestrate the full build for one main: N LaTeX passes with bibtex/biber after pass 1."""
109
+ if main is None:
110
+ main = config.main
111
+ root = config.root
112
+ output_dir = root / config.output_dir / main
113
+ output_dir.mkdir(parents=True, exist_ok=True)
114
+
115
+ tex_file = root / f"{main}.tex"
116
+ result = BuildResult(output_dir=output_dir)
117
+
118
+ # One timestamp for the whole pipeline, so rebuilds are byte-identical.
119
+ source_date = reproducible.resolve(config.source_date_epoch, root)
120
+ if source_date.warning:
121
+ print(f" {_DIM}warning: {source_date.warning}{_RESET}")
122
+ env = reproducible.build_env(source_date)
123
+ prelude = (
124
+ reproducible.trailer_id_code(config.engine, source_date, main)
125
+ if reproducible.needs_trailer_id(config.engine)
126
+ else ""
127
+ )
128
+
129
+ use_bib = config.bibtex or config.biber
130
+ bib_tool = "biber" if config.biber else "bibtex"
131
+ total_steps = config.passes + (1 if use_bib else 0)
132
+ step = 0
133
+
134
+ for i in range(1, config.passes + 1):
135
+ # Run bibliography tool after pass 1
136
+ if use_bib and i == 2:
137
+ step += 1
138
+ print(f" {_DIM}[{step}/{total_steps}]{_RESET} {bib_tool} ...")
139
+ if config.biber:
140
+ run_biber(main, output_dir, root, env)
141
+ else:
142
+ aux = output_dir / f"{main}.aux"
143
+ run_bibtex(aux, root, env)
144
+
145
+ step += 1
146
+ label = f"pass {i}"
147
+ if i == config.passes and config.passes >= 4:
148
+ label += " \u2013 pbalance"
149
+ print(f" {_DIM}[{step}/{total_steps}]{_RESET} {config.engine} ({label}) ...")
150
+ run_latex(
151
+ config.engine,
152
+ tex_file,
153
+ output_dir,
154
+ shell_escape=config.shell_escape,
155
+ env=env,
156
+ prelude=prelude,
157
+ )
158
+
159
+ # Check outputs
160
+ pdf_path = output_dir / f"{main}.pdf"
161
+ log_path = output_dir / f"{main}.log"
162
+ blg_path = output_dir / f"{main}.blg"
163
+ aux_path = output_dir / f"{main}.aux"
164
+
165
+ result.log = log_path if log_path.is_file() else None
166
+ result.blg = blg_path if blg_path.is_file() else None
167
+ result.aux = aux_path if aux_path.is_file() else None
168
+
169
+ if pdf_path.is_file():
170
+ result.pdf = pdf_path
171
+ result.success = True
172
+ dest = root / f"{main}.pdf"
173
+ shutil.copy2(pdf_path, dest)
174
+ print(f" {_GREEN}\u2714{_RESET} {_BOLD}{main}.pdf{_RESET}")
175
+
176
+ # Best-effort: dump LaTeX-free title and abstract next to the PDF for
177
+ # easy paste into submission systems (HotCRP, EasyChair, ...).
178
+ from paperkit.text_export import print_text_exports, write_text_exports
179
+
180
+ print_text_exports(write_text_exports(config, main, output_dir))
181
+ else:
182
+ print(f" {_RED}\u2718 ERROR: {main}.pdf was not produced{_RESET}")
183
+ if log_path.is_file():
184
+ print(f" See {log_path} for details")
185
+
186
+ return result