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 +8 -0
- rxiv2pdf/__main__.py +5 -0
- rxiv2pdf/cli.py +348 -0
- rxiv2pdf/compile.py +285 -0
- rxiv2pdf/fetch.py +808 -0
- rxiv2pdf/fonts/OFL-SourceSans3.md +93 -0
- rxiv2pdf/fonts/OFL-SourceSerif4.md +93 -0
- rxiv2pdf/fonts/README.md +6 -0
- rxiv2pdf/fonts/SourceSans3-Bold.ttf +0 -0
- rxiv2pdf/fonts/SourceSans3-BoldIt.ttf +0 -0
- rxiv2pdf/fonts/SourceSans3-It.ttf +0 -0
- rxiv2pdf/fonts/SourceSans3-Regular.ttf +0 -0
- rxiv2pdf/fonts/SourceSans3-Semibold.ttf +0 -0
- rxiv2pdf/fonts/SourceSerif4SmText-Bold.ttf +0 -0
- rxiv2pdf/fonts/SourceSerif4SmText-BoldIt.ttf +0 -0
- rxiv2pdf/fonts/SourceSerif4SmText-It.ttf +0 -0
- rxiv2pdf/fonts/SourceSerif4SmText-Regular.ttf +0 -0
- rxiv2pdf/images.py +240 -0
- rxiv2pdf/jats.py +1113 -0
- rxiv2pdf/mathml.py +275 -0
- rxiv2pdf/model.py +301 -0
- rxiv2pdf/net.py +290 -0
- rxiv2pdf/render_typst.py +764 -0
- rxiv2pdf/resolve.py +125 -0
- rxiv2pdf/templates/journal.typ +785 -0
- rxiv2pdf/templates/themes/compact.typ +28 -0
- rxiv2pdf/templates/themes/default.typ +3 -0
- rxiv2pdf/templates/themes/sans.typ +27 -0
- rxiv2pdf-0.1.0.dist-info/METADATA +191 -0
- rxiv2pdf-0.1.0.dist-info/RECORD +35 -0
- rxiv2pdf-0.1.0.dist-info/WHEEL +4 -0
- rxiv2pdf-0.1.0.dist-info/entry_points.txt +3 -0
- rxiv2pdf-0.1.0.dist-info/licenses/LICENSE +21 -0
- rxiv2pdf-0.1.0.dist-info/licenses/src/rxiv2pdf/fonts/OFL-SourceSans3.md +93 -0
- rxiv2pdf-0.1.0.dist-info/licenses/src/rxiv2pdf/fonts/OFL-SourceSerif4.md +93 -0
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
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"]
|