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/pages.py ADDED
@@ -0,0 +1,254 @@
1
+ # Copyright (C) 2026 Vrije Universiteit Brussel. All rights reserved.
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """Page-count analysis from PDF + aux file.
5
+
6
+ Extracts:
7
+ * total pages - via pdfinfo
8
+ * refs start page - from .aux TOC entries ("References"/"Bibliography"),
9
+ falling back to a pdftotext heading scan
10
+ * ack start page - same, matching "Acknowledg..."
11
+ * before refs/ack - pages occupied by the content preceding each section
12
+ (a section starting mid-page counts that page)
13
+
14
+ Patterns are configurable for non-English papers. Page limits in
15
+ [page_limits] flip the check to ERROR when exceeded.
16
+
17
+ Page numbers are the same in single- and double-column layouts (a page is a
18
+ page), so this module needs no layout awareness.
19
+ """
20
+
21
+ import re
22
+ import shutil
23
+ import subprocess
24
+ from dataclasses import dataclass
25
+ from pathlib import Path
26
+
27
+
28
+ @dataclass
29
+ class PageStats:
30
+ total: int | None = None
31
+ refs_page: int | None = None
32
+ ack_page: int | None = None
33
+ # Number of pages occupied by the content preceding each section. By
34
+ # default refs and ack form one exempt tail, so both hold the pages before
35
+ # whichever comes first: content of one exempt section must not count
36
+ # against the other's limit. With ack_counts_as_content the tail is the
37
+ # references alone, and before_ack becomes the pages before the
38
+ # acknowledgments. The page is counted when the section starts mid-page.
39
+ before_refs: int | None = None
40
+ before_ack: int | None = None
41
+
42
+
43
+ # Matches \@writefile{toc}{\contentsline {section}{TITLE}{PAGE}{ANCHOR}...}
44
+ # where TITLE may contain balanced nested braces. We pull TITLE with a manual
45
+ # brace scanner since regex can't track arbitrary nesting.
46
+ _TOC_PREFIX = r"\@writefile{toc}{\contentsline {section}{"
47
+
48
+
49
+ def _parse_toc_line(line: str) -> tuple[str, int] | None:
50
+ """Return (raw_title, page) from one \\@writefile{toc} section line."""
51
+ line = line.lstrip()
52
+ if not line.startswith(_TOC_PREFIX):
53
+ return None
54
+ rest = line[len(_TOC_PREFIX) :]
55
+
56
+ # Scan balanced braces for the title.
57
+ depth = 1
58
+ i = 0
59
+ while i < len(rest) and depth > 0:
60
+ c = rest[i]
61
+ if c == "{":
62
+ depth += 1
63
+ elif c == "}":
64
+ depth -= 1
65
+ if depth == 0:
66
+ break
67
+ i += 1
68
+ if depth != 0 or i >= len(rest):
69
+ return None
70
+ title = rest[:i]
71
+ rest = rest[i + 1 :]
72
+
73
+ # Expect {PAGE}
74
+ if not rest.startswith("{"):
75
+ return None
76
+ page_end = rest.find("}", 1)
77
+ if page_end < 0:
78
+ return None
79
+ page_str = rest[1:page_end].strip()
80
+ try:
81
+ return title, int(page_str)
82
+ except ValueError:
83
+ return None # roman numerals or odd page anchors: skip
84
+
85
+
86
+ def _clean_title(title: str) -> str:
87
+ """Strip \\numberline{...}, \\mbox{...}, and other LaTeX command wrappers."""
88
+ # \numberline {X} or \numberline{X}
89
+ title = re.sub(r"\\numberline\s*\{[^{}]*\}", "", title)
90
+ # Drop \mbox{...} wrappers around section numbers
91
+ title = re.sub(r"\\mbox\s*\{([^{}]*)\}", r"\1", title)
92
+ # Drop single-arg \cmd{content} wrappers, keep content
93
+ for _ in range(3):
94
+ title = re.sub(r"\\[a-zA-Z]+\s*\{([^{}]*)\}", r"\1", title)
95
+ # Remove remaining lone commands
96
+ title = re.sub(r"\\[a-zA-Z@]+", "", title)
97
+ return title.replace("{", "").replace("}", "").strip()
98
+
99
+
100
+ def parse_toc(aux_path: Path) -> list[tuple[str, int]]:
101
+ """Read .aux and return [(cleaned_title, page)] in document order."""
102
+ if not aux_path.is_file():
103
+ return []
104
+ out: list[tuple[str, int]] = []
105
+ for line in aux_path.read_text(errors="replace").splitlines():
106
+ parsed = _parse_toc_line(line)
107
+ if parsed is None:
108
+ continue
109
+ title, page = parsed
110
+ out.append((_clean_title(title), page))
111
+ return out
112
+
113
+
114
+ def find_section_page(toc: list[tuple[str, int]], pattern: str) -> int | None:
115
+ """First TOC entry whose title matches *pattern* (used with re.search)."""
116
+ rx = re.compile(pattern)
117
+ for title, page in toc:
118
+ if rx.search(title):
119
+ return page
120
+ return None
121
+
122
+
123
+ def _pdf_text_pages(pdf_path: Path) -> list[str] | None:
124
+ """Extract per-page text via pdftotext (pages are form-feed separated)."""
125
+ if not pdf_path.is_file() or shutil.which("pdftotext") is None:
126
+ return None
127
+ proc = subprocess.run(
128
+ ["pdftotext", str(pdf_path), "-"],
129
+ capture_output=True,
130
+ text=True,
131
+ )
132
+ if proc.returncode != 0:
133
+ return None
134
+ return proc.stdout.split("\f")
135
+
136
+
137
+ def _is_heading_line(line: str, rx: re.Pattern[str]) -> bool:
138
+ """True if *line* looks like a standalone section heading matching *rx*.
139
+
140
+ Headings render as short lines; small-caps fonts may introduce spurious
141
+ spaces ("R EFERENCES"), so we also match with spaces stripped.
142
+ """
143
+ line = line.strip()
144
+ if not 0 < len(line) < 30:
145
+ return False
146
+ return bool(rx.search(line) or rx.search(line.replace(" ", "")))
147
+
148
+
149
+ def find_heading_pos(pages_text: list[str], pattern: str) -> tuple[int, int] | None:
150
+ """Locate *pattern* as a standalone heading line in the rendered PDF text.
151
+
152
+ Returns (page_num, line_index) of the first match, or None. Also serves
153
+ as fallback detection for sections that don't write to \\@writefile{toc},
154
+ such as IEEEtran's `\\section*{Acknowledgment}`.
155
+ """
156
+ rx = re.compile(pattern)
157
+ for page_num, page_text in enumerate(pages_text, start=1):
158
+ for line_idx, raw_line in enumerate(page_text.splitlines()):
159
+ if _is_heading_line(raw_line, rx):
160
+ return page_num, line_idx
161
+ return None
162
+
163
+
164
+ def body_end_page(pages_text: list[str], heading_pos: tuple[int, int]) -> int:
165
+ """Page on which the body content preceding *heading_pos* ends.
166
+
167
+ If the heading sits at the top of its page (nothing but a running header
168
+ or page number above it), the body ended on the previous page; otherwise
169
+ it shares the heading's page. In double-column layouts pdftotext emits
170
+ text in reading order, so a heading at the top of the second column is
171
+ correctly preceded by first-column body text.
172
+ """
173
+ page, line_idx = heading_pos
174
+ lines = pages_text[page - 1].splitlines()[:line_idx]
175
+ preceding = sum(1 for raw_line in lines if raw_line.strip())
176
+ # Tolerate a few short lines above the heading (running header, page number).
177
+ return page if preceding > 2 else page - 1
178
+
179
+
180
+ def get_total_pages(pdf_path: Path) -> int | None:
181
+ """Return total PDF pages via pdfinfo, or None if it can't be read."""
182
+ if not pdf_path.is_file() or shutil.which("pdfinfo") is None:
183
+ return None
184
+ proc = subprocess.run(["pdfinfo", str(pdf_path)], capture_output=True, text=True)
185
+ for line in proc.stdout.splitlines():
186
+ if line.startswith("Pages:"):
187
+ try:
188
+ return int(line.split(":", 1)[1].strip())
189
+ except ValueError:
190
+ return None
191
+ return None
192
+
193
+
194
+ def content_end_pages(
195
+ pages_text: list[str],
196
+ refs_pos: tuple[int, int] | None,
197
+ ack_pos: tuple[int, int] | None,
198
+ ack_counts_as_content: bool = False,
199
+ ) -> tuple[int | None, int | None]:
200
+ """Pages occupied by the content before the references and before the acks.
201
+
202
+ By default the two sections form one exempt tail: both values are the
203
+ pages before whichever heading comes first, so acknowledgments sitting
204
+ between the body and the references do not count against either limit.
205
+
206
+ With *ack_counts_as_content* only the references are exempt, as at venues
207
+ that count acknowledgments as text: the page budget then ends at the
208
+ references heading, and the acknowledgments fall inside it.
209
+ """
210
+ if ack_counts_as_content:
211
+ before_refs = body_end_page(pages_text, refs_pos) if refs_pos else None
212
+ before_ack = body_end_page(pages_text, ack_pos) if ack_pos else None
213
+ return before_refs, before_ack
214
+
215
+ positions = [pos for pos in (refs_pos, ack_pos) if pos is not None]
216
+ if not positions:
217
+ return None, None
218
+ end = body_end_page(pages_text, min(positions))
219
+ return (end if refs_pos else None), (end if ack_pos else None)
220
+
221
+
222
+ def gather(
223
+ pdf_path: Path | None,
224
+ aux_path: Path | None,
225
+ refs_pattern: str,
226
+ ack_pattern: str,
227
+ ack_counts_as_content: bool = False,
228
+ ) -> PageStats:
229
+ """Collect all page stats; missing inputs leave fields as None."""
230
+ stats = PageStats()
231
+ pages_text: list[str] | None = None
232
+ if pdf_path is not None:
233
+ stats.total = get_total_pages(pdf_path)
234
+ pages_text = _pdf_text_pages(pdf_path)
235
+ if aux_path is not None:
236
+ toc = parse_toc(aux_path)
237
+ stats.refs_page = find_section_page(toc, refs_pattern)
238
+ stats.ack_page = find_section_page(toc, ack_pattern)
239
+ if pages_text:
240
+ refs_pos = find_heading_pos(pages_text, refs_pattern)
241
+ ack_pos = find_heading_pos(pages_text, ack_pattern)
242
+ # Fallback for sections missing from the TOC (e.g. IEEEtran's
243
+ # `\section*{Acknowledgment}` doesn't emit `\@writefile{toc}`).
244
+ if stats.refs_page is None and refs_pos is not None:
245
+ stats.refs_page = refs_pos[0]
246
+ if stats.ack_page is None and ack_pos is not None:
247
+ stats.ack_page = ack_pos[0]
248
+ stats.before_refs, stats.before_ack = content_end_pages(
249
+ pages_text,
250
+ refs_pos,
251
+ ack_pos,
252
+ ack_counts_as_content,
253
+ )
254
+ return stats
@@ -0,0 +1,56 @@
1
+ # Copyright (C) 2026 Vrije Universiteit Brussel. All rights reserved.
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """Profile registry and base class."""
5
+
6
+ from paperkit.config import PaperkitConfig
7
+
8
+
9
+ class BaseProfile:
10
+ """Base profile with sensible defaults."""
11
+
12
+ def docker_packages(self) -> list[str]:
13
+ """Extra packages beyond the base set (see docker._BASE_PACKAGES)."""
14
+ return []
15
+
16
+ # Reordering arguments, dropped when sorting is off.
17
+ SORT_ARGS = ("--sort=key", "--sort-fields")
18
+
19
+ def bibtex_tidy_args(self, sort: bool = True) -> list[str]:
20
+ """Arguments for bibtex-tidy; *sort* also reorders entries and fields."""
21
+ args = [
22
+ "--curly",
23
+ "--blank-lines",
24
+ "--sort=key",
25
+ "--duplicates=key,doi,citation",
26
+ "--no-escape",
27
+ "--sort-fields",
28
+ "--trailing-commas",
29
+ "--remove-empty-fields",
30
+ "--enclosing-braces=title",
31
+ "--align=13",
32
+ ]
33
+ return args if sort else [a for a in args if a not in self.SORT_ARGS]
34
+
35
+ def bibtex_tidy_version(self) -> str:
36
+ return "1.14.0"
37
+
38
+
39
+ _PROFILES: dict[str, type[BaseProfile]] = {}
40
+
41
+
42
+ def register_profile(name: str, cls: type[BaseProfile]) -> None:
43
+ _PROFILES[name] = cls
44
+
45
+
46
+ def get_profile(config: PaperkitConfig) -> BaseProfile:
47
+ cls = _PROFILES.get(config.profile_name, BaseProfile)
48
+ return cls()
49
+
50
+
51
+ # Register built-in profiles
52
+ register_profile("base", BaseProfile)
53
+
54
+ from paperkit.profiles.acm import ACMProfile # noqa: E402
55
+
56
+ register_profile("acm", ACMProfile)
@@ -0,0 +1,10 @@
1
+ # Copyright (C) 2026 Vrije Universiteit Brussel. All rights reserved.
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """ACM sigconf profile."""
5
+
6
+ from paperkit.profiles import BaseProfile
7
+
8
+
9
+ class ACMProfile(BaseProfile):
10
+ """Profile for ACM sigconf papers (acmart class)."""
paperkit/py.typed ADDED
File without changes
paperkit/report.py ADDED
@@ -0,0 +1,246 @@
1
+ # Copyright (C) 2026 Vrije Universiteit Brussel. All rights reserved.
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """Build report formatting with colors and summary table."""
5
+
6
+ import re
7
+ import shutil
8
+ import subprocess
9
+
10
+ from paperkit import reproducible, texlive
11
+ from paperkit.builder import BuildResult
12
+ from paperkit.checks import CheckResult, CheckStatus
13
+ from paperkit.config import PaperkitConfig
14
+
15
+ # ANSI color codes
16
+ _RESET = "\033[0m"
17
+ _BOLD = "\033[1m"
18
+ _DIM = "\033[2m"
19
+ _GREEN = "\033[32m"
20
+ _YELLOW = "\033[33m"
21
+ _RED = "\033[31m"
22
+ _CYAN = "\033[36m"
23
+ _WHITE = "\033[37m"
24
+
25
+ # Status decorations
26
+ _STATUS_STYLE: dict[CheckStatus, tuple[str, str, str]] = {
27
+ # emoji color label
28
+ CheckStatus.CLEAN: ("\u2705", _GREEN, "pass"),
29
+ CheckStatus.SKIPPED: ("\u23ed\ufe0f ", _YELLOW, "skip"),
30
+ CheckStatus.WARNING: ("\u26a0\ufe0f ", _YELLOW, "warn"),
31
+ CheckStatus.ERROR: ("\u274c", _RED, "FAIL"),
32
+ }
33
+
34
+
35
+ def _styled(text: str, *codes: str) -> str:
36
+ return "".join(codes) + text + _RESET
37
+
38
+
39
+ def _status_icon(status: CheckStatus) -> str:
40
+ emoji, _, _ = _STATUS_STYLE[status]
41
+ return emoji
42
+
43
+
44
+ def _status_colored(text: str, status: CheckStatus) -> str:
45
+ _, color, _ = _STATUS_STYLE[status]
46
+ return _styled(text, color)
47
+
48
+
49
+ def get_environment_info(config: PaperkitConfig, build_result: BuildResult) -> dict[str, str]:
50
+ """Collect environment info as a dict."""
51
+ info: dict[str, str] = {}
52
+
53
+ # The log banner names the engine that produced the PDF, wherever this
54
+ # report runs; probing the engine on PATH is only a fallback.
55
+ banner = texlive.read_log_banner(build_result.log)
56
+ if banner:
57
+ info["TeX Live"] = str(banner)
58
+ elif shutil.which(config.engine):
59
+ proc = subprocess.run([config.engine, "--version"], capture_output=True, text=True)
60
+ if proc.stdout:
61
+ first = proc.stdout.splitlines()[0]
62
+ info["TeX Live"] = f"{first} (host {config.engine}, no build log)"
63
+ if config.texlive.source == "upstream":
64
+ info["TeX Live source"] = texlive.describe(config.texlive)
65
+ info["Source date"] = str(reproducible.resolve(config.source_date_epoch, config.root))
66
+
67
+ if config.profile_name == "acm" and build_result.log and build_result.log.is_file():
68
+ text = build_result.log.read_text(errors="replace")
69
+ m = re.search(r"acmart (\d{4}/\d{2}/\d{2} v[\d.]+)", text)
70
+ info["acmart"] = m.group(1) if m else "not found"
71
+
72
+ return info
73
+
74
+
75
+ def print_report(
76
+ build_result: BuildResult,
77
+ check_results: list[CheckResult],
78
+ env_info: dict[str, str] | None = None,
79
+ ) -> None:
80
+ """Print formatted build report with colors and summary table."""
81
+ print()
82
+
83
+ # -- Detail sections --
84
+ _print_details(check_results)
85
+
86
+ # -- Summary table --
87
+ print(_styled(" Summary", _BOLD))
88
+ print()
89
+
90
+ # Environment info
91
+ if env_info:
92
+ for key, val in env_info.items():
93
+ print(f" {_styled(key.rjust(24), _DIM)} {val}")
94
+ print()
95
+
96
+ _print_rows(check_results)
97
+ print()
98
+
99
+ # -- Overall verdict --
100
+ worst = _worst_status(check_results)
101
+ if worst == CheckStatus.ERROR:
102
+ verdict = _styled(" \U0001f4a5 Build has errors", _BOLD, _RED)
103
+ elif worst == CheckStatus.WARNING:
104
+ verdict = _styled(" \u26a0\ufe0f Build OK with warnings", _BOLD, _YELLOW)
105
+ else:
106
+ verdict = _styled(" \u2728 All checks passed", _BOLD, _GREEN)
107
+ print(verdict)
108
+
109
+ # Log location
110
+ print(f" {_styled(f'Logs: {build_result.output_dir}/*.log, *.blg', _DIM)}")
111
+ print()
112
+
113
+
114
+ def _print_details(check_results: list[CheckResult]) -> None:
115
+ """Detail sections for non-clean checks (and clean ones with extra info)."""
116
+ has_details = False
117
+ for cr in check_results:
118
+ if cr.status != CheckStatus.CLEAN or _has_extra_info(cr):
119
+ if not has_details:
120
+ print(_styled(" Details", _BOLD, _DIM))
121
+ print()
122
+ has_details = True
123
+ _print_check_detail(cr)
124
+
125
+
126
+ def _print_rows(check_results: list[CheckResult]) -> None:
127
+ """One summary row per check."""
128
+ for cr in check_results:
129
+ icon = _status_icon(cr.status)
130
+ colored_summary = _status_colored(_check_summary(cr), cr.status)
131
+ print(f" {icon} {_styled(cr.name.rjust(24), _BOLD)} {colored_summary}")
132
+
133
+
134
+ def _has_extra_info(cr: CheckResult) -> bool:
135
+ """Check if a result has info worth showing beyond the summary."""
136
+ if cr.show_details:
137
+ return True
138
+ # bibtex-tidy reports version in messages even when clean
139
+ return cr.name == "BibTeX tidy" and any("bibtex-tidy:" in m for m in cr.messages)
140
+
141
+
142
+ def _check_summary(cr: CheckResult) -> str:
143
+ """One-line summary for the table."""
144
+ if not cr.messages:
145
+ return cr.status.value
146
+ # Use first message as summary, strip leading whitespace
147
+ return cr.messages[0].strip()
148
+
149
+
150
+ def _print_check_detail(cr: CheckResult) -> None:
151
+ """Print detailed messages for a check (warnings/errors)."""
152
+ icon = _status_icon(cr.status)
153
+ _, color, _ = _STATUS_STYLE[cr.status]
154
+ print(f" {icon} {_styled(cr.name, _BOLD, color)}")
155
+ for msg in cr.messages:
156
+ print(f" {_status_colored(msg, cr.status)}")
157
+ print()
158
+
159
+
160
+ # Statuses that make the overall verdict a warning.
161
+ _WARNING_LEVEL = (CheckStatus.WARNING, CheckStatus.SKIPPED)
162
+
163
+
164
+ def _worst_status(results: list[CheckResult]) -> CheckStatus:
165
+ """Worst verdict across results (CLEAN, WARNING or ERROR), ignoring advisory ones."""
166
+ binding = [cr for cr in results if not cr.advisory]
167
+ if any(cr.status == CheckStatus.ERROR for cr in binding):
168
+ return CheckStatus.ERROR
169
+ if any(cr.status in _WARNING_LEVEL for cr in binding):
170
+ return CheckStatus.WARNING
171
+ return CheckStatus.CLEAN
172
+
173
+
174
+ def print_multi_summary(
175
+ runs: list[tuple[str, BuildResult, list[CheckResult]]],
176
+ shared: list[CheckResult] | None = None,
177
+ ) -> None:
178
+ """Print a one-line-per-main summary across all builds.
179
+
180
+ *shared* holds project-wide results (bibliography checks, one per bib
181
+ file), printed once in their own block and included in the verdict.
182
+ """
183
+ if shared:
184
+ print(_styled(" Bibliography", _BOLD))
185
+ print()
186
+ _print_details(shared)
187
+ _print_rows(shared)
188
+ print()
189
+
190
+ print(_styled(" Per-project summary", _BOLD))
191
+ print()
192
+
193
+ width = max(len(name) for name, _, _ in runs) if runs else 0
194
+ counts = {CheckStatus.CLEAN: 0, CheckStatus.WARNING: 0, CheckStatus.ERROR: 0}
195
+ failed = 0
196
+
197
+ for name, build_result, check_results in runs:
198
+ if not build_result.success:
199
+ failed += 1
200
+ counts[CheckStatus.ERROR] += 1
201
+ icon = _status_icon(CheckStatus.ERROR)
202
+ label = _status_colored("build failed", CheckStatus.ERROR)
203
+ else:
204
+ worst = _worst_status(check_results)
205
+ counts[worst] += 1
206
+ icon = _status_icon(worst)
207
+ if worst == CheckStatus.CLEAN:
208
+ label = _status_colored("all checks passed", worst)
209
+ else:
210
+ level = _WARNING_LEVEL if worst == CheckStatus.WARNING else (worst,)
211
+ bad = [cr.name for cr in check_results if cr.status in level]
212
+ summary = ", ".join(bad) if bad else worst.value
213
+ label = _status_colored(summary, worst)
214
+ print(f" {icon} {_styled(name.ljust(width), _BOLD)} {label}")
215
+
216
+ print()
217
+ n = len(runs)
218
+ parts = [
219
+ f"{counts[CheckStatus.CLEAN]} pass",
220
+ f"{counts[CheckStatus.WARNING]} warn",
221
+ f"{counts[CheckStatus.ERROR]} fail",
222
+ ]
223
+ breakdown = ", ".join(parts)
224
+ shared_worst = _worst_status(shared or [])
225
+ if shared_worst != CheckStatus.CLEAN:
226
+ breakdown += f"; bibliography: {_STATUS_STYLE[shared_worst][2]}"
227
+ if failed:
228
+ verdict = _styled(
229
+ f" \U0001f4a5 {failed}/{n} project(s) failed to build ({breakdown})",
230
+ _BOLD,
231
+ _RED,
232
+ )
233
+ elif counts[CheckStatus.WARNING] or shared_worst != CheckStatus.CLEAN:
234
+ verdict = _styled(
235
+ f" \u26a0\ufe0f {n} project(s) built, see warnings ({breakdown})",
236
+ _BOLD,
237
+ _YELLOW,
238
+ )
239
+ else:
240
+ verdict = _styled(
241
+ f" \u2728 All {n} project(s) clean ({breakdown})",
242
+ _BOLD,
243
+ _GREEN,
244
+ )
245
+ print(verdict)
246
+ print()