rxiv2pdf 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.
rxiv2pdf/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """rxiv2pdf: bioRxiv/medRxiv preprint -> journal-style PDF, built from JATS XML."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("rxiv2pdf") # single source: pyproject.toml
7
+ except PackageNotFoundError: # running from a source tree that was never installed
8
+ __version__ = "0.0.0+unknown"
rxiv2pdf/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """`python -m rxiv2pdf` runs the CLI."""
2
+
3
+ from .cli import main
4
+
5
+ main()
rxiv2pdf/cli.py ADDED
@@ -0,0 +1,348 @@
1
+ """Command-line entry point: resolve -> fetch -> parse -> render -> compile."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import shutil
7
+ from collections import Counter
8
+ from dataclasses import dataclass
9
+ from enum import Enum
10
+ from pathlib import Path
11
+ from typing import Annotated
12
+
13
+ import typer
14
+ from rich.console import Console
15
+ from rich.logging import RichHandler
16
+ from rich.table import Table
17
+
18
+ from . import __version__
19
+ from .compile import CompileError, build_pdf
20
+ from .fetch import FetchResult, JatsUnavailable, fetch, fetch_original_pdf
21
+ from .jats import parse_jats
22
+ from .net import Client, FetchError
23
+ from .render_typst import RenderOptions
24
+ from .resolve import ResolveError, resolve
25
+
26
+ console = Console(stderr=True)
27
+ out = Console()
28
+
29
+ app = typer.Typer(add_completion=False, pretty_exceptions_enable=False)
30
+
31
+
32
+ class ServerChoice(str, Enum):
33
+ biorxiv = "biorxiv"
34
+ medrxiv = "medrxiv"
35
+
36
+
37
+ class PaperChoice(str, Enum):
38
+ letter = "letter"
39
+ a4 = "a4"
40
+
41
+
42
+ def make_client() -> Client:
43
+ """Factory so tests can swap in a mocked transport."""
44
+ return Client()
45
+
46
+
47
+ def _setup_logging(verbose: int) -> None:
48
+ level = logging.DEBUG if verbose else logging.INFO
49
+ handler = RichHandler(console=console, show_time=False, show_path=verbose > 1, markup=False)
50
+ logging.basicConfig(level=logging.WARNING, handlers=[handler], format="%(message)s", force=True)
51
+ logging.getLogger("rxiv2pdf").setLevel(level)
52
+ if verbose > 1:
53
+ logging.getLogger("httpx").setLevel(logging.INFO)
54
+
55
+
56
+ class ThemeChoice(str, Enum):
57
+ default = "default"
58
+ compact = "compact"
59
+ sans = "sans"
60
+
61
+
62
+ @dataclass
63
+ class _Options:
64
+ outdir: Path
65
+ version: int | None
66
+ server: str | None
67
+ refresh: bool
68
+ supplements: bool
69
+ paper: str
70
+ theme: str
71
+ verbose: int
72
+ fallback_pdf: bool = False
73
+ open_pdf: bool = False
74
+ append_supplements: bool = False
75
+
76
+
77
+ class _ItemFailed(Exception):
78
+ def __init__(self, message: str, code: int = 1):
79
+ super().__init__(message)
80
+ self.code = code
81
+
82
+
83
+ def _show_version(value: bool) -> None:
84
+ if value:
85
+ typer.echo(f"rxiv2pdf {__version__}")
86
+ raise typer.Exit()
87
+
88
+
89
+ @app.command(no_args_is_help=True) # on the command: a one-command app ignores it on Typer()
90
+ def run(
91
+ source: Annotated[str | None, typer.Argument(help="bioRxiv/medRxiv URL or DOI (10.1101/... or 10.64898/...)")] = None,
92
+ list_file: Annotated[Path | None, typer.Option("--list", exists=True, dir_okay=False, help="Batch mode: a file with one URL/DOI per line (# comments allowed).")] = None,
93
+ outdir: Annotated[Path, typer.Option("-o", "--outdir", help="Output directory.")] = Path("."),
94
+ preprint_version: Annotated[int | None, typer.Option("--preprint-version", min=1, help="Preprint version (default: from URL, else latest).")] = None,
95
+ server: Annotated[ServerChoice | None, typer.Option("--server", help="Override server detection.")] = None,
96
+ theme: Annotated[ThemeChoice, typer.Option("--theme", help="Look of the PDF.")] = ThemeChoice.default,
97
+ paper: Annotated[PaperChoice, typer.Option("--paper", help="Paper size.")] = PaperChoice.letter,
98
+ refresh: Annotated[bool, typer.Option("--refresh", help="Ignore the cache and re-download.")] = False,
99
+ supplements: Annotated[bool, typer.Option("--supplements", help="Also download the supplementary files, into <name>-supplements/ next to the PDF (listed and linked in the PDF either way).")] = False,
100
+ append_supplements: Annotated[bool, typer.Option("--append-supplements", help="Append the supplementary PDFs and images to the end of the PDF (downloads them).")] = False,
101
+ fallback_pdf: Annotated[bool, typer.Option("--fallback-pdf", help="If no JATS XML exists yet, save the server's original PDF instead.")] = False,
102
+ open_pdf: Annotated[bool, typer.Option("--open", help="Open the PDF when done.")] = False,
103
+ verbose: Annotated[int, typer.Option("-v", "--verbose", count=True, metavar="", show_default=False, help="-v: per-figure detail and tracebacks; -vv: also every HTTP request.")] = 0,
104
+ show_version: Annotated[bool, typer.Option("--version", callback=_show_version, is_eager=True, help="Show the rxiv2pdf version and exit.")] = False,
105
+ ) -> None:
106
+ """Build a journal-style PDF of a bioRxiv/medRxiv preprint from its JATS XML."""
107
+ _setup_logging(verbose)
108
+ if (source is None) == (list_file is None):
109
+ console.print("[bold red]error:[/] give either a URL/DOI or --list FILE")
110
+ raise typer.Exit(2)
111
+ if list_file is not None and preprint_version is not None:
112
+ console.print("[bold red]error:[/] --preprint-version applies to a single preprint; put versions in the list (e.g. ...575123v2)")
113
+ raise typer.Exit(2)
114
+ opts = _Options(
115
+ outdir, preprint_version, server.value if server else None, refresh, supplements,
116
+ paper.value, theme.value, verbose, fallback_pdf, open_pdf, append_supplements,
117
+ )
118
+ try:
119
+ outdir.mkdir(parents=True, exist_ok=True)
120
+ except OSError as exc:
121
+ console.print(f"[bold red]error:[/] cannot create the output directory {outdir}: {exc.strerror or exc}")
122
+ raise typer.Exit(2) from exc
123
+
124
+ try:
125
+ with make_client() as client:
126
+ if source is not None:
127
+ try:
128
+ _process(source, client, opts)
129
+ except _ItemFailed as exc:
130
+ console.print(f"[bold red]error:[/] {exc}")
131
+ raise typer.Exit(exc.code) from exc
132
+ return
133
+ assert list_file is not None
134
+ _batch(list_file, client, opts)
135
+ except KeyboardInterrupt:
136
+ console.print("\n[yellow]Interrupted.[/] Everything downloaded so far is cached; re-run to resume.")
137
+ raise typer.Exit(130) from None
138
+
139
+
140
+ def _batch(list_file: Path, client: Client, opts: _Options) -> None:
141
+ items = [line.strip() for line in list_file.read_text(encoding="utf-8").splitlines()]
142
+ items = [i for i in items if i and not i.startswith("#")]
143
+ if not items:
144
+ console.print(f"[bold red]error:[/] no URLs/DOIs in {list_file}")
145
+ raise typer.Exit(2)
146
+ results: list[tuple[str, bool, str]] = []
147
+ for n, item in enumerate(items, 1):
148
+ out.rule(f"[{n}/{len(items)}] {item}")
149
+ try:
150
+ pdf = _process(item, client, opts)
151
+ results.append((item, True, str(pdf)))
152
+ except _ItemFailed as exc:
153
+ console.print(f"[bold red]error:[/] {exc}")
154
+ results.append((item, False, str(exc)))
155
+ table = Table(title=f"Batch: {sum(ok for _, ok, _ in results)}/{len(results)} succeeded", show_lines=False)
156
+ table.add_column("Preprint", overflow="fold")
157
+ table.add_column("Result", overflow="fold")
158
+ for item, ok, detail in results:
159
+ table.add_row(item, f"[green]{detail}[/]" if ok else f"[red]failed:[/] {detail}")
160
+ out.print(table)
161
+ if not all(ok for _, ok, _ in results):
162
+ raise typer.Exit(1)
163
+
164
+
165
+ def _process(source: str, client: Client, o: _Options) -> Path:
166
+ """Run the whole pipeline for one preprint. Returns the PDF path."""
167
+ try:
168
+ resolved = resolve(source, server=o.server, version=o.version)
169
+ except ResolveError as exc:
170
+ raise _ItemFailed(str(exc), 2) from exc
171
+ try:
172
+ result = fetch(resolved, client=client, refresh=o.refresh, supplements=o.supplements or o.append_supplements)
173
+ except JatsUnavailable as exc:
174
+ if not o.fallback_pdf:
175
+ raise _ItemFailed(str(exc)) from exc
176
+ return _fallback(resolved, client, o)
177
+ except FetchError as exc:
178
+ raise _ItemFailed(str(exc)) from exc
179
+
180
+ _print_summary(result, verbose=o.verbose)
181
+
182
+ try:
183
+ article = parse_jats(result.xml_path.read_bytes(), meta=result.manifest)
184
+ build = build_pdf(
185
+ article, result.manifest, result.version_dir, o.outdir,
186
+ options=RenderOptions(paper=o.paper, theme=o.theme),
187
+ append_supplements=o.append_supplements,
188
+ )
189
+ except CompileError as exc:
190
+ raise _ItemFailed(str(exc)) from exc
191
+ except OSError as exc:
192
+ raise _ItemFailed(f"could not write to {o.outdir}: {exc.strerror or exc}") from exc
193
+ except Exception as exc: # a parser/renderer bug should name the preprint, not just a traceback
194
+ if o.verbose:
195
+ console.print_exception()
196
+ raise _ItemFailed(
197
+ f"rxiv2pdf could not build a PDF for {result.manifest.doi} v{result.manifest.version} "
198
+ f"({type(exc).__name__}: {exc}). This is a bug in rxiv2pdf, not in your input; please report it"
199
+ + ("" if o.verbose else " (re-run with -v for a traceback).")
200
+ ) from exc
201
+
202
+ for correction in article.corrections:
203
+ console.print(f"[cyan]fixed:[/] {correction}")
204
+ for warning in build.warnings:
205
+ console.print(f"[yellow]warning:[/] {warning}")
206
+ notes = []
207
+ if article.warnings:
208
+ notes.append(f"{len(article.warnings)} parse warning(s)")
209
+ if build.warnings:
210
+ notes.append(f"{len(build.warnings)} render/compile warning(s)")
211
+ # soft_wrap: never hard-wrap paths, so they stay copy-pasteable
212
+ out.print(f"[bold green]PDF[/] {build.pdf}" + (f" ({', '.join(notes)})" if notes else ""), soft_wrap=True)
213
+ if o.append_supplements:
214
+ if build.appended:
215
+ pages = sum(n for _, n in build.appended)
216
+ files = ", ".join(f"{name} ({n} p.)" for name, n in build.appended)
217
+ out.print(f"[bold green]Appended[/] {len(build.appended)} supplementary file(s), {pages} page(s): {files}", soft_wrap=True)
218
+ elif not result.manifest.supplements:
219
+ out.print("[dim]No supplementary files listed for this preprint.[/]")
220
+ if build.not_appended:
221
+ console.print(
222
+ f"[yellow]note:[/] not PDFs or images, so not appended (linked in the PDF): {', '.join(build.not_appended)}"
223
+ + ("" if o.supplements else "; --supplements saves them next to the PDF"),
224
+ soft_wrap=True,
225
+ )
226
+ if o.supplements:
227
+ _deliver_supplements(result, o.outdir)
228
+ if o.open_pdf:
229
+ _open(build.pdf)
230
+ return build.pdf
231
+
232
+
233
+ def _deliver_supplements(result: FetchResult, outdir: Path) -> None:
234
+ """Copy the downloaded supplementary files from the cache to <outdir>/<slug>-supplements/."""
235
+ m = result.manifest
236
+ if not m.supplements:
237
+ out.print("[dim]No supplementary files listed for this preprint.[/]")
238
+ return
239
+ dest_dir = outdir / f"{m.slug}-supplements"
240
+ for s in m.supplements:
241
+ src = result.version_dir / s.path if s.status == "ok" and s.path else None
242
+ if src is None or not src.exists():
243
+ console.print(f"[yellow]warning:[/] supplementary file {s.label or s.href} could not be downloaded")
244
+ continue
245
+ try:
246
+ dest_dir.mkdir(parents=True, exist_ok=True)
247
+ shutil.copyfile(src, dest_dir / src.name)
248
+ except OSError as exc:
249
+ raise _ItemFailed(f"could not write to {dest_dir}: {exc.strerror or exc}") from exc
250
+ out.print(f"[bold green]SUPP[/] {dest_dir / src.name}" + (f" ({s.label})" if s.label else ""), soft_wrap=True)
251
+
252
+
253
+ def _fallback(resolved: object, client: Client, o: _Options) -> Path:
254
+ """--fallback-pdf: no JATS yet, so hand over the server's own PDF."""
255
+ try:
256
+ cached, slug = fetch_original_pdf(resolved, client=client) # type: ignore[arg-type]
257
+ except FetchError as exc:
258
+ raise _ItemFailed(f"No JATS XML yet, and the original PDF could not be fetched either: {exc}") from exc
259
+ dest = o.outdir / f"{slug}.pdf"
260
+ shutil.copyfile(cached, dest)
261
+ console.print("[yellow]warning:[/] no JATS XML is available yet, so this is the server's original PDF, not a rebuilt one")
262
+ out.print(f"[bold green]PDF[/] {dest} (original, --fallback-pdf)", soft_wrap=True)
263
+ if o.open_pdf:
264
+ _open(dest)
265
+ return dest
266
+
267
+
268
+ def _open(path: Path) -> None:
269
+ import os
270
+ import subprocess
271
+ import sys
272
+
273
+ try:
274
+ if sys.platform == "darwin":
275
+ subprocess.run(["open", str(path)], check=False)
276
+ elif os.name == "nt":
277
+ os.startfile(str(path)) # type: ignore[attr-defined]
278
+ else:
279
+ subprocess.run(["xdg-open", str(path)], check=False)
280
+ except OSError as exc:
281
+ console.print(f"[yellow]warning:[/] could not open {path}: {exc}")
282
+
283
+
284
+ def _print_summary(result: FetchResult, *, verbose: int) -> None:
285
+ m = result.manifest
286
+ figs = [f for f in m.figures if f.kind == "figure"]
287
+ tables = [f for f in m.figures if f.kind == "table"]
288
+ other = [f for f in m.figures if f.kind not in ("figure", "table")]
289
+ missing = [f for f in m.figures if f.status != "ok"]
290
+ strategies = Counter(f.strategy for f in m.figures if f.status == "ok")
291
+
292
+ def ok(items: list) -> str:
293
+ return f"{sum(1 for f in items if f.status == 'ok')}/{len(items)}"
294
+
295
+ t = Table.grid(padding=(0, 2))
296
+ t.add_column(style="bold")
297
+ t.add_column(overflow="fold")
298
+ t.add_row("Preprint", f"{m.server} {m.doi} v{m.version} (versions: {', '.join(f'v{v}' for v in m.versions_available)})")
299
+ t.add_row("Title", m.title)
300
+ t.add_row("Posted", f"{m.date} | {m.category} | {m.type} | license {m.license}")
301
+ if m.withdrawn:
302
+ t.add_row("[red]Withdrawn[/]", f"[red]on {m.withdrawn.get('date')} (v{m.withdrawn.get('version')}); showing v{m.version} with a banner[/]")
303
+ if m.published and m.published != "NA":
304
+ t.add_row("Published", m.published)
305
+ t.add_row("JATS", f"{result.xml_path.stat().st_size // 1024} KB via {m.jats_strategy}")
306
+ t.add_row(
307
+ "Graphics",
308
+ f"figures {ok(figs)}, table images {ok(tables)}"
309
+ + (f", other {ok(other)}" if other else "")
310
+ + (f" [red]({len(missing)} missing)[/]" if missing else ""),
311
+ )
312
+ if strategies:
313
+ t.add_row("Strategies", ", ".join(f"{name}: {n}" for name, n in strategies.most_common()))
314
+ downloaded = sum(1 for s in m.supplements if s.status == "ok")
315
+ t.add_row("Supplements", f"{len(m.supplements)} listed, {downloaded} downloaded" + ("" if downloaded else " (--supplements to download)"))
316
+ n = result.requests_made # a cache hit can still look up the version list (API)
317
+ net = f"{n} network request{'' if n == 1 else 's'}"
318
+ if result.from_cache:
319
+ net = f"cache hit, {net}"
320
+ t.add_row("Cache", f"{result.version_dir} ({net})")
321
+ out.print(t)
322
+
323
+ if verbose:
324
+ detail = Table(show_header=True, header_style="bold", box=None, padding=(0, 1))
325
+ for col in ("label", "kind", "hwp ids", "status", "strategy", "size"):
326
+ detail.add_column(col, no_wrap=True)
327
+ detail.add_column("file", overflow="fold")
328
+ for f in m.figures:
329
+ detail.add_row(
330
+ f.label or "",
331
+ f.kind,
332
+ f"{f.parent_id}/{f.graphic_id}",
333
+ f.status,
334
+ f.strategy or "",
335
+ f"{f.width}x{f.height}" if f.width else "",
336
+ f.path or f.href,
337
+ )
338
+ out.print(detail)
339
+
340
+
341
+
342
+
343
+ def main() -> None:
344
+ app()
345
+
346
+
347
+ if __name__ == "__main__":
348
+ main()
rxiv2pdf/compile.py ADDED
@@ -0,0 +1,285 @@
1
+ """Stage 5 - Compile: write the build directory and run the Typst compiler (``typst`` package)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import shutil
8
+ import tempfile
9
+ from dataclasses import dataclass, field
10
+ from importlib import resources
11
+ from pathlib import Path
12
+
13
+ import typst
14
+ from PIL import Image
15
+
16
+ from .fetch import Manifest
17
+ from .images import lettering_height, text_line_height
18
+ from .model import Article
19
+ from .render_typst import TEMPLATE_NAME, AppendixItem, ImageRef, RenderOptions, render
20
+
21
+ log = logging.getLogger(__name__)
22
+
23
+
24
+ class CompileError(Exception):
25
+ """Typst rejected the generated source. ``str(err)`` is meant for the user."""
26
+
27
+
28
+ @dataclass
29
+ class BuildResult:
30
+ pdf: Path
31
+ warnings: list[str] # renderer + Typst warnings
32
+ appended: list[tuple[str, int]] = field(default_factory=list) # (file, pages) put after the article
33
+ not_appended: list[str] = field(default_factory=list) # supplementary files that are not PDFs/images
34
+
35
+
36
+ def template_dir() -> Path:
37
+ return Path(str(resources.files("rxiv2pdf") / "templates"))
38
+
39
+
40
+ def font_dirs() -> list[str]:
41
+ """Bundled fonts. Typst's embedded fonts are always available."""
42
+ fonts = Path(str(resources.files("rxiv2pdf"))) / "fonts"
43
+ return [str(fonts)] if fonts.is_dir() else []
44
+
45
+
46
+ def install_template(build: Path) -> None:
47
+ """Copy journal.typ and themes/ into the build directory."""
48
+ src = template_dir()
49
+ shutil.copyfile(src / TEMPLATE_NAME, build / TEMPLATE_NAME)
50
+ shutil.copytree(src / "themes", build / "themes", dirs_exist_ok=True)
51
+
52
+
53
+ PROMOTE_LABEL = "rxiv-promote"
54
+ _PROMOTE_ROUNDS = 4
55
+ BALANCE_INPUT = "rxiv-balance"
56
+ # Extra height, in body lines, given to the balanced columns: the left column can end up to a
57
+ # line short (a line or a heading does not fit), and the right one must still take the rest.
58
+ _BALANCE_SLACK = (0.5, 1.5)
59
+ REF_SCALE_INPUT = "rxiv-ref-scale"
60
+ _REF_SCALES = (0.97, 0.94, 0.9) # 0.9: the compact theme's reference size
61
+ _SHORT_TAIL = 0.2 # a last page whose left column is filled at most this much (and right empty)
62
+
63
+
64
+ def _end_of_body(compiler: typst.Compiler) -> dict | None:
65
+ """Where the body ends in the last compile (journal.typ's ``<rxiv-end>``), in pt."""
66
+ ends = json.loads(compiler.query("<rxiv-end>", field="value"))
67
+ return ends[0] if len(ends) == 1 else None
68
+
69
+
70
+ def _pull_back_short_tail(compiler: typst.Compiler, pdf: Path, inputs: dict[str, str]) -> dict[str, str]:
71
+ """If the last page holds only a few lines, try to fit them on the page before by setting
72
+ the reference list a little smaller (``rxiv-ref-scale``). Returns the inputs now in effect
73
+ (the compiler is left compiled with them)."""
74
+ end = _end_of_body(compiler)
75
+ if end is None or end["page"] == 1 or end["col"] != 0 or end["floats"] or end["used"] > _SHORT_TAIL * end["avail"]:
76
+ return inputs
77
+ trial = pdf.with_name(pdf.stem + ".tightening.pdf")
78
+ try:
79
+ for scale in _REF_SCALES:
80
+ tight = {**inputs, REF_SCALE_INPUT: str(scale)}
81
+ _, warnings = compiler.compile_with_warnings(output=str(trial), sys_inputs=tight)
82
+ after = _end_of_body(compiler)
83
+ if not warnings and after is not None and after["page"] == end["page"] - 1:
84
+ trial.replace(pdf)
85
+ return tight
86
+ compiler.compile_with_warnings(output=str(trial), sys_inputs=inputs) # back to the kept layout
87
+ return inputs
88
+ finally:
89
+ trial.unlink(missing_ok=True)
90
+
91
+
92
+ def compile_typst(typ: Path, pdf: Path, *, root: Path) -> list[str]:
93
+ """Compile ``typ`` to ``pdf`` with system fonts disabled (deterministic output).
94
+
95
+ journal.typ promotes a column float that ran off the page to a page-wide one, marking it
96
+ with ``<rxiv-promote>`` metadata (see ``_float``). A promotion moves things around (the next
97
+ float can run off the page, the page count changes), and Typst stops after five layout
98
+ passes. If the layout had not converged, the floats marked so far are passed back in
99
+ (``sys.inputs``), promoted from the first pass on, and the document is laid out again.
100
+ Then the columns of the last page are balanced (``_balance_last_page``).
101
+ """
102
+ try:
103
+ compiler = typst.Compiler(str(typ), root=str(root), font_paths=font_dirs(), ignore_system_fonts=True)
104
+ promoted: set[int] = set()
105
+ for _ in range(_PROMOTE_ROUNDS):
106
+ inputs = {PROMOTE_LABEL: ",".join(str(k) for k in sorted(promoted))}
107
+ _, warnings = compiler.compile_with_warnings(output=str(pdf), sys_inputs=inputs)
108
+ if not any("converge" in _format_warning(w) for w in warnings):
109
+ break
110
+ marked = {int(k) for k in json.loads(compiler.query(f"<{PROMOTE_LABEL}>", field="value"))}
111
+ if marked <= promoted:
112
+ break
113
+ promoted |= marked
114
+ if not warnings: # only a layout that converged
115
+ inputs = _pull_back_short_tail(compiler, pdf, inputs)
116
+ _balance_last_page(compiler, pdf, inputs)
117
+ except typst.TypstError as exc:
118
+ detail = getattr(exc, "diagnostic", None) or str(exc)
119
+ raise CompileError(f"Typst failed to compile {typ.name}: {detail}") from exc
120
+ return [_format_warning(w) for w in warnings]
121
+
122
+
123
+ def _balance_last_page(compiler: typst.Compiler, pdf: Path, inputs: dict[str, str]) -> bool:
124
+ """Even out the two columns on the last page of the body (Typst fills the left one first).
125
+
126
+ journal.typ reports where the body ends (``<rxiv-end>``: page, column, height used, column
127
+ height, line pitch, floats on that page, in pt). The text there is split in half by an
128
+ empty page-wide float at the bottom of that page (``rxiv-balance``: its height) that
129
+ shortens both columns. The balanced PDF replaces ``pdf`` only if the body still ends on
130
+ that page, and with no layout warnings. Pages with floats are left alone (their columns
131
+ do not start at the top). Returns whether the PDF was balanced.
132
+ """
133
+ end = _end_of_body(compiler)
134
+ if end is None or end["page"] == 1 or end["floats"]:
135
+ return False
136
+ total = end["used"] if end["col"] == 0 else end["avail"] + end["used"]
137
+ trial = pdf.with_name(pdf.stem + ".balancing.pdf")
138
+ try:
139
+ for slack in _BALANCE_SLACK:
140
+ spacer = end["avail"] - (total / 2 + slack * end["pitch"])
141
+ if spacer < 2 * end["pitch"]:
142
+ break # already (nearly) even
143
+ _, warnings = compiler.compile_with_warnings(output=str(trial), sys_inputs={**inputs, BALANCE_INPUT: f"{spacer:.3f}"})
144
+ after = _end_of_body(compiler)
145
+ spacer_pages = json.loads(compiler.query("<rxiv-spacer>", field="value"))
146
+ if not warnings and after is not None and after["page"] == end["page"] and spacer_pages == [end["page"]]:
147
+ trial.replace(pdf)
148
+ return True
149
+ return False
150
+ finally:
151
+ trial.unlink(missing_ok=True)
152
+
153
+
154
+ def _format_warning(w: object) -> str:
155
+ message = getattr(w, "message", str(w))
156
+ hints = getattr(w, "hints", None) or []
157
+ return message + (f" (hint: {'; '.join(hints)})" if hints else "")
158
+
159
+
160
+ _APPEND_IMAGES = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"}
161
+ _CONVERT_IMAGES = {".tif", ".tiff", ".bmp"}
162
+ _MAX_PROBE_PAGES = 5000
163
+
164
+
165
+ def pdf_page_count(path: Path) -> int | None:
166
+ """Pages Typst can place from ``path`` (None if it cannot read the PDF at all).
167
+
168
+ Typst has no page-count query, but placing a page that does not exist is an error, so
169
+ the count is found by probing (exponential, then binary search: ~2 log2(n) tiny compiles).
170
+ """
171
+
172
+ def has(n: int) -> bool:
173
+ try:
174
+ typst.compile(f'#image("{path.name}", page: {n})'.encode(), root=str(path.parent), format="svg")
175
+ except typst.TypstError:
176
+ return False
177
+ return True
178
+
179
+ if not has(1):
180
+ return None
181
+ lo, hi = 1, 2
182
+ while hi <= _MAX_PROBE_PAGES and has(hi):
183
+ lo, hi = hi, hi * 2
184
+ hi = min(hi, _MAX_PROBE_PAGES + 1)
185
+ while hi - lo > 1: # has(lo), not has(hi)
186
+ mid = (lo + hi) // 2
187
+ lo, hi = (mid, hi) if has(mid) else (lo, mid)
188
+ return lo
189
+
190
+
191
+ def _prepare_appendix(manifest: Manifest, version_dir: Path, build: Path) -> tuple[list[AppendixItem], list[str], list[str]]:
192
+ """Copy downloaded supplementary PDFs/images into ``build`` for appending.
193
+
194
+ Returns (items, files that are not PDFs/images, warnings)."""
195
+ items: list[AppendixItem] = []
196
+ skipped: list[str] = []
197
+ warnings: list[str] = []
198
+ for s in manifest.supplements:
199
+ if s.status != "ok" or not s.path or not (version_dir / s.path).exists():
200
+ continue
201
+ src = version_dir / s.path
202
+ ext = src.suffix.lower()
203
+ label = s.label or "Supplementary file"
204
+ dest = build / "supplements" / src.name
205
+ dest.parent.mkdir(parents=True, exist_ok=True)
206
+ if ext == ".pdf":
207
+ shutil.copyfile(src, dest)
208
+ pages = pdf_page_count(dest)
209
+ if pages is None:
210
+ warnings.append(f"Supplementary file {src.name} could not be read as a PDF, so it was not appended")
211
+ continue
212
+ elif ext in _APPEND_IMAGES:
213
+ shutil.copyfile(src, dest)
214
+ pages = 1
215
+ elif ext in _CONVERT_IMAGES:
216
+ dest = dest.with_suffix(".png")
217
+ try:
218
+ with Image.open(src) as im:
219
+ im.convert("RGBA" if "A" in im.mode else "RGB").save(dest)
220
+ except OSError:
221
+ warnings.append(f"Supplementary image {src.name} could not be read, so it was not appended")
222
+ continue
223
+ pages = 1
224
+ else:
225
+ skipped.append(src.name)
226
+ continue
227
+ items.append(AppendixItem(label, src.name, dest.relative_to(build).as_posix(), pages, s.href))
228
+ return items, skipped, warnings
229
+
230
+
231
+ def _text_px(kind: str | None, path: Path) -> float | None:
232
+ """Size of the text in an image, in pixels, that the template sizes it by (see ImageRef)."""
233
+ if kind == "table":
234
+ return text_line_height(path)
235
+ if kind == "figure":
236
+ return lettering_height(path)
237
+ return None
238
+
239
+
240
+ def build_pdf(
241
+ article: Article,
242
+ manifest: Manifest,
243
+ version_dir: Path,
244
+ outdir: Path,
245
+ *,
246
+ options: RenderOptions | None = None,
247
+ append_supplements: bool = False,
248
+ ) -> BuildResult:
249
+ """Render ``article`` and compile ``outdir/<slug>.pdf``.
250
+
251
+ The .typ, the template and ``figures/`` live in a temporary build directory; if Typst
252
+ fails, they are copied to ``outdir/<slug>-typst-debug/`` for the bug report. With
253
+ ``append_supplements``, downloaded supplementary PDFs and images follow the article.
254
+ """
255
+ outdir.mkdir(parents=True, exist_ok=True)
256
+ assets = {f.href: ImageRef(f.path, f.width, f.height, _text_px(f.kind, version_dir / f.path)) for f in manifest.figures if f.status == "ok" and f.path}
257
+ supplement_urls = {s.href: s.url for s in manifest.supplements if s.url}
258
+
259
+ with tempfile.TemporaryDirectory(prefix="rxiv2pdf-") as tmp:
260
+ build = Path(tmp)
261
+ appendix, not_appended, warnings = (
262
+ _prepare_appendix(manifest, version_dir, build) if append_supplements else ([], [], [])
263
+ )
264
+ source, render_warnings = render(
265
+ article, assets=assets, options=options, supplement_urls=supplement_urls, appendix=appendix
266
+ )
267
+ warnings = render_warnings + warnings
268
+ install_template(build)
269
+ for rel in {ref.path for ref in assets.values()}:
270
+ dest = build / rel
271
+ dest.parent.mkdir(parents=True, exist_ok=True)
272
+ shutil.copyfile(version_dir / rel, dest)
273
+ typ = build / f"{manifest.slug}.typ"
274
+ typ.write_text(source, encoding="utf-8")
275
+ pdf = outdir / f"{manifest.slug}.pdf"
276
+ try:
277
+ warnings += compile_typst(typ, pdf, root=build)
278
+ except CompileError as exc:
279
+ debug = outdir / f"{manifest.slug}-typst-debug"
280
+ shutil.copytree(build, debug, dirs_exist_ok=True)
281
+ raise CompileError(f"{exc}\nThis is a bug in rxiv2pdf; the generated source was kept in {debug} - please report it with that folder.") from exc
282
+ return BuildResult(pdf, warnings, [(x.file, x.pages) for x in appendix], not_appended)
283
+
284
+
285
+ __all__ = ["TEMPLATE_NAME", "BuildResult", "CompileError", "build_pdf", "compile_typst"]