#!/usr/bin/env python3 """Non-interactive replacement for the content repo's src/build_drawings.py. Rebuilds contents/drawings/.svg from contents/drawings/.tex (LuaLaTeX -> PDF -> pdftocairo -> SVG), run from the content repo root. build_drawings.py 502 503 build specific drawings build_drawings.py --stale build drawings whose .tex is newer than the .svg build_drawings.py --all build every drawing --preview DIR also write PNG previews into DIR (inside the container) -j N run N builds in parallel Width: \\DARCimageOnly autoscales to a width in cm (default 9). To keep the size of the existing drawing, the width is derived from the current SVG (pt -> cm); override with --width. """ import argparse import os import pathlib import re import subprocess import sys import tempfile from concurrent.futures import ThreadPoolExecutor, as_completed DRAWINGS = pathlib.Path("contents/drawings") PHOTOS = pathlib.Path("contents/photos").resolve() def width_from_svg(svg: pathlib.Path) -> float: """Width in cm of an existing SVG; 9 cm (the repo default) if unknown or ~9 cm.""" try: head = svg.read_text(encoding="utf-8", errors="ignore")[:600] # poppler <= 22 writes "42.872pt", newer versions a bare number (same unit: pt) pt = float(re.search(r'width="([\d.]+)(?:pt)?"', head).group(1)) except (OSError, AttributeError, ValueError): return 9.0 cm = pt * 2.54 / 72 if 8.85 <= cm <= 9.15: return 9.0 return round(cm, 1) # round (not ceil): stable when a drawing is rebuilt repeatedly def build_drawing(drawing_id: str, width: float | None, preview: pathlib.Path | None = None) -> tuple[str, bool, str]: tex = DRAWINGS / f"{drawing_id}.tex" svg = DRAWINGS / f"{drawing_id}.svg" if not tex.is_file(): return drawing_id, False, f"{tex} not found" latex = tex.read_text(encoding="utf-8") if "\\begin{document}" in latex or "\\begin{table}" in latex: return drawing_id, False, "contains a document environment" if width is None: width = width_from_svg(svg) with tempfile.TemporaryDirectory(prefix=f"build_{drawing_id}_", dir=".") as tmp: build = pathlib.Path(tmp) (build / "img").mkdir() (build / "img" / f"{drawing_id}include.tex").write_text(latex, encoding="utf-8") (build / f"{drawing_id}.tex").write_text( f"\\documentclass{{FiftyOhm}}\\DARCimageOnly{{{width}cm}}{{{drawing_id}include}}", encoding="utf-8" ) (build / "foto").symlink_to(PHOTOS) (build / "img" / "foto").symlink_to(PHOTOS) # Run from the repo root so latexmk picks up ./latexmkrc (TEXINPUTS for FiftyOhm.cls); # its `$do_cd = 1` then runs LaTeX inside the build directory. run = subprocess.run( ["latexmk", "-lualatex", "-interaction=nonstopmode", "-halt-on-error", str(build / f"{drawing_id}.tex")], stdin=subprocess.DEVNULL, capture_output=True, text=True, ) pdf = build / f"{drawing_id}.pdf" if run.returncode != 0 or not pdf.is_file(): log = (build / f"{drawing_id}.log") tail = log.read_text(errors="ignore")[-1500:] if log.exists() else run.stdout[-1500:] return drawing_id, False, "latexmk failed:\n" + tail tmp_svg = build / f"{drawing_id}.svg" conv = subprocess.run(["pdftocairo", "-svg", str(pdf), str(tmp_svg)], capture_output=True, text=True) if conv.returncode != 0 or not tmp_svg.is_file(): return drawing_id, False, "pdftocairo failed: " + conv.stderr if preview is not None: preview.mkdir(parents=True, exist_ok=True) subprocess.run(["pdftocairo", "-png", "-r", "130", "-singlefile", str(pdf), str(preview / drawing_id)], check=False) os.replace(tmp_svg, svg) # only replace the SVG once a new one exists return drawing_id, True, f"{width} cm" def stale_ids() -> list[str]: ids = [] for tex in sorted(DRAWINGS.glob("*.tex")): svg = tex.with_suffix(".svg") if not svg.exists() or tex.stat().st_mtime > svg.stat().st_mtime: ids.append(tex.stem) return ids def main() -> int: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("ids", nargs="*") ap.add_argument("--stale", action="store_true") ap.add_argument("--all", action="store_true") ap.add_argument("--width", type=float, help="override width in cm") ap.add_argument("--preview", type=pathlib.Path, help="also write a PNG preview of each drawing into this directory") ap.add_argument("-j", "--jobs", type=int, default=1) args = ap.parse_args() if not DRAWINGS.is_dir(): print("Run from the content repo root (contents/drawings not found).", file=sys.stderr) return 2 ids = list(args.ids) if args.all: ids = sorted(p.stem for p in DRAWINGS.glob("*.tex")) elif args.stale: ids = stale_ids() if not ids: print("Nothing to build.") return 0 print(f"Building {len(ids)} drawing(s) with {args.jobs} job(s)") failed = [] with ThreadPoolExecutor(max_workers=max(1, args.jobs)) as pool: futures = [pool.submit(build_drawing, i, args.width, args.preview) for i in ids] for n, fut in enumerate(as_completed(futures), 1): drawing_id, ok, info = fut.result() print(f"[{n}/{len(ids)}] {drawing_id}: {'ok ' + info if ok else 'FAILED ' + info}", flush=True) if not ok: failed.append(drawing_id) if failed: print(f"\n{len(failed)} failed: {' '.join(sorted(failed))}", file=sys.stderr) return 1 return 0 if __name__ == "__main__": sys.exit(main())