| 1 | #!/usr/bin/env python3 |
| 2 | """ |
| 3 | PPT Master - LaTeX Formula Renderer |
| 4 | |
| 5 | Render project-declared LaTeX formulas to transparent PNG assets. |
| 6 | The script reads an explicit manifest; it never scans spec_lock.md or source |
| 7 | content for dollar-delimited math. |
| 8 | |
| 9 | Usage: |
| 10 | python3 scripts/latex_render.py <project_path> |
| 11 | python3 scripts/latex_render.py <project_path> --manifest images/formula_manifest.json |
| 12 | python3 scripts/latex_render.py <project_path> --dry-run |
| 13 | |
| 14 | Examples: |
| 15 | python3 scripts/latex_render.py projects/demo_ppt169_20260523 |
| 16 | python3 scripts/latex_render.py projects/demo_ppt169_20260523 --providers codecogs,quicklatex,mathpad,wikimedia |
| 17 | |
| 18 | Dependencies: |
| 19 | Pillow (for measuring generated PNG dimensions) |
| 20 | Network access to at least one configured rendering provider |
| 21 | """ |
| 22 | |
| 23 | from __future__ import annotations |
| 24 | |
| 25 | import argparse |
| 26 | import json |
| 27 | import re |
| 28 | import sys |
| 29 | import urllib.error |
| 30 | import urllib.parse |
| 31 | import urllib.request |
| 32 | from pathlib import Path |
| 33 | from typing import Any |
| 34 | |
| 35 | from console_encoding import configure_utf8_stdio |
| 36 | |
| 37 | try: |
| 38 | from PIL import Image |
| 39 | except ImportError: |
| 40 | Image = None |
| 41 | |
| 42 | |
| 43 | configure_utf8_stdio() |
| 44 | |
| 45 | |
| 46 | DEFAULT_DPI = 300 |
| 47 | DEFAULT_TRANSPARENT_TOLERANCE = 12 |
| 48 | DEFAULT_MANIFEST = "images/formula_manifest.json" |
| 49 | DEFAULT_PROVIDERS = ["codecogs", "quicklatex", "mathpad", "wikimedia"] |
| 50 | CODECOGS_ENDPOINT = "https://latex.codecogs.com/png.image?" |
| 51 | WIKIMEDIA_CHECK_ENDPOINT = "https://wikimedia.org/api/rest_v1/media/math/check" |
| 52 | WIKIMEDIA_RENDER_ENDPOINT = "https://wikimedia.org/api/rest_v1/media/math/render/png" |
| 53 | QUICKLATEX_ENDPOINT = "https://quicklatex.com/latex3.f" |
| 54 | MATHPAD_ENDPOINT = "https://mathpad.ai/api/v1/latex2image" |
| 55 | VALID_FILENAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*\.png$") |
| 56 | PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" |
| 57 | |
| 58 | |
| 59 | def _project_relative(path: Path, project_path: Path) -> str: |
| 60 | """Return a POSIX-style path relative to the project.""" |
| 61 | return path.relative_to(project_path).as_posix() |
| 62 | |
| 63 | |
| 64 | def _load_manifest(path: Path) -> dict[str, Any]: |
| 65 | """Load a JSON formula manifest.""" |
| 66 | try: |
| 67 | data = json.loads(path.read_text(encoding="utf-8")) |
| 68 | except OSError as exc: |
| 69 | raise RuntimeError(f"Cannot read manifest: {path} ({exc})") from exc |
| 70 | except json.JSONDecodeError as exc: |
| 71 | raise RuntimeError(f"Manifest is not valid JSON: {path} ({exc})") from exc |
| 72 | |
| 73 | if not isinstance(data, dict): |
| 74 | raise RuntimeError("Manifest root must be a JSON object.") |
| 75 | items = data.get("items") |
| 76 | if not isinstance(items, list): |
| 77 | raise RuntimeError("Manifest must contain an `items` array.") |
| 78 | return data |
| 79 | |
| 80 | |
| 81 | def _safe_filename(item: dict[str, Any], index: int) -> str: |
| 82 | """Resolve and validate the output PNG filename for one formula.""" |
| 83 | filename = item.get("filename") |
| 84 | if filename is None: |
| 85 | formula_id = str(item.get("id") or f"formula_{index:03d}") |
| 86 | filename = f"{formula_id}.png" |
| 87 | filename = str(filename) |
| 88 | if "/" in filename or "\\" in filename or not VALID_FILENAME_RE.match(filename): |
| 89 | raise RuntimeError( |
| 90 | f"Invalid formula filename `{filename}`. Use a simple PNG filename " |
| 91 | "such as `formula_001.png`." |
| 92 | ) |
| 93 | return filename |
| 94 | |
| 95 | |
| 96 | def _normalize_hex_color(color: str | None, field_name: str) -> str | None: |
| 97 | """Normalize an optional 6-digit HEX color.""" |
| 98 | if not color: |
| 99 | return None |
| 100 | value = color.strip() |
| 101 | if value.startswith("#"): |
| 102 | value = value[1:] |
| 103 | if not re.fullmatch(r"[0-9A-Fa-f]{6}", value): |
| 104 | raise RuntimeError(f"Formula {field_name} must be a 6-digit HEX value: {color}") |
| 105 | return value.upper() |
| 106 | |
| 107 | |
| 108 | def _hex_to_rgb(color: str) -> tuple[int, int, int]: |
| 109 | """Convert a normalized HEX color to an RGB tuple.""" |
| 110 | return (int(color[0:2], 16), int(color[2:4], 16), int(color[4:6], 16)) |
| 111 | |
| 112 | |
| 113 | def _parse_bool(value: Any, default: bool) -> bool: |
| 114 | """Parse a manifest boolean with a default.""" |
| 115 | if value is None: |
| 116 | return default |
| 117 | if isinstance(value, bool): |
| 118 | return value |
| 119 | if isinstance(value, str): |
| 120 | normalized = value.strip().lower() |
| 121 | if normalized in {"1", "true", "yes", "on"}: |
| 122 | return True |
| 123 | if normalized in {"0", "false", "no", "off"}: |
| 124 | return False |
| 125 | raise RuntimeError(f"Expected boolean value, got: {value!r}") |
| 126 | |
| 127 | |
| 128 | def _normalize_tolerance(value: Any) -> int: |
| 129 | """Normalize the background removal tolerance.""" |
| 130 | if value is None: |
| 131 | return DEFAULT_TRANSPARENT_TOLERANCE |
| 132 | tolerance = int(value) |
| 133 | if tolerance < 0 or tolerance > 255: |
| 134 | raise RuntimeError("transparent_tolerance must be between 0 and 255.") |
| 135 | return tolerance |
| 136 | |
| 137 | |
| 138 | def _parse_providers(value: str | list[str] | None) -> list[str]: |
| 139 | """Parse and validate a provider chain.""" |
| 140 | if value is None: |
| 141 | providers = DEFAULT_PROVIDERS |
| 142 | elif isinstance(value, list): |
| 143 | providers = value |
| 144 | else: |
| 145 | providers = [part.strip() for part in value.split(",") if part.strip()] |
| 146 | |
| 147 | valid = {"codecogs", "quicklatex", "mathpad", "wikimedia"} |
| 148 | unknown = [provider for provider in providers if provider not in valid] |
| 149 | if unknown: |
| 150 | raise RuntimeError( |
| 151 | f"Unknown formula provider(s): {', '.join(unknown)}. " |
| 152 | f"Available: {', '.join(sorted(valid))}" |
| 153 | ) |
| 154 | if not providers: |
| 155 | raise RuntimeError("Provider chain must include at least one provider.") |
| 156 | return providers |
| 157 | |
| 158 | |
| 159 | def _request_bytes(req: urllib.request.Request, timeout: int = 30) -> tuple[bytes, str]: |
| 160 | """Fetch bytes and return the response content type.""" |
| 161 | try: |
| 162 | with urllib.request.urlopen(req, timeout=timeout) as resp: |
| 163 | return resp.read(), resp.headers.get("Content-Type", "") |
| 164 | except (urllib.error.URLError, TimeoutError) as exc: |
| 165 | raise RuntimeError(str(exc)) from exc |
| 166 | |
| 167 | |
| 168 | def _assert_png(data: bytes, provider: str, content_type: str) -> bytes: |
| 169 | """Validate PNG response bytes.""" |
| 170 | if not data.startswith(PNG_SIGNATURE): |
| 171 | raise RuntimeError( |
| 172 | f"{provider} did not return PNG data (Content-Type: {content_type})" |
| 173 | ) |
| 174 | return data |
| 175 | |
| 176 | |
| 177 | def _build_codecogs_payload(latex: str, dpi: int, color: str | None) -> str: |
| 178 | """Build a CodeCogs LaTeX payload.""" |
| 179 | # CodeCogs documents PNG DPI in the 50-300 range. |
| 180 | safe_dpi = min(max(dpi, 50), 300) |
| 181 | parts = [rf"\dpi{{{safe_dpi}}}"] |
| 182 | if color: |
| 183 | parts.append(rf"\fg{{{color}}}") |
| 184 | parts.append(latex) |
| 185 | return " ".join(parts) |
| 186 | |
| 187 | |
| 188 | def _render_codecogs( |
| 189 | latex: str, |
| 190 | dpi: int, |
| 191 | color: str | None, |
| 192 | background: str | None, |
| 193 | display: str, |
| 194 | ) -> bytes: |
| 195 | """Render one formula through CodeCogs.""" |
| 196 | payload = _build_codecogs_payload(latex, dpi, color) |
| 197 | url = CODECOGS_ENDPOINT + urllib.parse.quote(payload) |
| 198 | req = urllib.request.Request(url, headers={"User-Agent": "PPT-Master/1.0"}) |
| 199 | data, content_type = _request_bytes(req) |
| 200 | return _assert_png(data, "codecogs", content_type) |
| 201 | |
| 202 | |
| 203 | def _render_wikimedia( |
| 204 | latex: str, |
| 205 | dpi: int, |
| 206 | color: str | None, |
| 207 | background: str | None, |
| 208 | display: str, |
| 209 | ) -> bytes: |
| 210 | """Render one formula through Wikimedia Mathoid.""" |
| 211 | formula_type = "inline-tex" if display == "inline" else "tex" |
| 212 | payload = urllib.parse.urlencode({"q": latex}).encode("utf-8") |
| 213 | check_req = urllib.request.Request( |
| 214 | f"{WIKIMEDIA_CHECK_ENDPOINT}/{formula_type}", |
| 215 | data=payload, |
| 216 | headers={ |
| 217 | "Content-Type": "application/x-www-form-urlencoded", |
| 218 | "User-Agent": "PPT-Master/1.0", |
| 219 | }, |
| 220 | method="POST", |
| 221 | ) |
| 222 | try: |
| 223 | with urllib.request.urlopen(check_req, timeout=30) as resp: |
| 224 | resp.read() |
| 225 | resource = resp.headers.get("x-resource-location") |
| 226 | except (urllib.error.URLError, TimeoutError) as exc: |
| 227 | raise RuntimeError(str(exc)) from exc |
| 228 | |
| 229 | if not resource: |
| 230 | raise RuntimeError("missing x-resource-location header") |
| 231 | |
| 232 | render_req = urllib.request.Request( |
| 233 | f"{WIKIMEDIA_RENDER_ENDPOINT}/{resource}", |
| 234 | headers={"User-Agent": "PPT-Master/1.0"}, |
| 235 | ) |
| 236 | data, content_type = _request_bytes(render_req) |
| 237 | return _assert_png(data, "wikimedia", content_type) |
| 238 | |
| 239 | |
| 240 | def _render_quicklatex( |
| 241 | latex: str, |
| 242 | dpi: int, |
| 243 | color: str | None, |
| 244 | background: str | None, |
| 245 | display: str, |
| 246 | ) -> bytes: |
| 247 | """Render one formula through QuickLaTeX.""" |
| 248 | wrapped = f"${latex}$" if display == "inline" else f"$${latex}$$" |
| 249 | # QuickLaTeX uses CSS font size rather than DPI. Keep a conservative fixed |
| 250 | # size; downstream placement uses measured dimensions from the PNG. |
| 251 | params = { |
| 252 | "formula": wrapped, |
| 253 | "fsize": "24px", |
| 254 | "fcolor": color or "000000", |
| 255 | "mode": "0", |
| 256 | "out": "1", |
| 257 | "remhost": "quicklatex.com", |
| 258 | } |
| 259 | req = urllib.request.Request( |
| 260 | QUICKLATEX_ENDPOINT, |
| 261 | data=urllib.parse.urlencode(params).encode("utf-8"), |
| 262 | headers={ |
| 263 | "Content-Type": "application/x-www-form-urlencoded", |
| 264 | "User-Agent": "PPT-Master/1.0", |
| 265 | }, |
| 266 | method="POST", |
| 267 | ) |
| 268 | data, _ = _request_bytes(req) |
| 269 | text = data.decode("utf-8", errors="replace").strip() |
| 270 | lines = text.splitlines() |
| 271 | if not lines or lines[0].strip() != "0": |
| 272 | raise RuntimeError(text or "QuickLaTeX returned an empty response") |
| 273 | if len(lines) < 2: |
| 274 | raise RuntimeError(f"QuickLaTeX response missing image URL: {text}") |
| 275 | image_url = lines[1].split()[0] |
| 276 | image_req = urllib.request.Request( |
| 277 | image_url, |
| 278 | headers={"User-Agent": "PPT-Master/1.0"}, |
| 279 | ) |
| 280 | image_data, content_type = _request_bytes(image_req) |
| 281 | return _assert_png(image_data, "quicklatex", content_type) |
| 282 | |
| 283 | |
| 284 | def _render_mathpad( |
| 285 | latex: str, |
| 286 | dpi: int, |
| 287 | color: str | None, |
| 288 | background: str | None, |
| 289 | display: str, |
| 290 | ) -> bytes: |
| 291 | """Render one formula through MathPad's public LaTeX image endpoint.""" |
| 292 | wrapped = f"${latex}$" if display == "inline" else f"$${latex}$$" |
| 293 | scale = "4" if dpi >= 300 else "2" |
| 294 | params = { |
| 295 | "latex": wrapped, |
| 296 | "format": "png", |
| 297 | "scale": scale, |
| 298 | "color": f"#{color or '000000'}", |
| 299 | "bg": f"#{background or 'FFFFFF'}", |
| 300 | } |
| 301 | url = MATHPAD_ENDPOINT + "?" + urllib.parse.urlencode(params) |
| 302 | req = urllib.request.Request(url, headers={"User-Agent": "PPT-Master/1.0"}) |
| 303 | data, content_type = _request_bytes(req) |
| 304 | return _assert_png(data, "mathpad", content_type) |
| 305 | |
| 306 | |
| 307 | PROVIDER_RENDERERS = { |
| 308 | "codecogs": _render_codecogs, |
| 309 | "quicklatex": _render_quicklatex, |
| 310 | "mathpad": _render_mathpad, |
| 311 | "wikimedia": _render_wikimedia, |
| 312 | } |
| 313 | COLOR_AWARE_PROVIDERS = {"codecogs", "quicklatex", "mathpad"} |
| 314 | |
| 315 | |
| 316 | def _render_with_providers( |
| 317 | latex: str, |
| 318 | output_path: Path, |
| 319 | dpi: int, |
| 320 | color: str | None, |
| 321 | background: str | None, |
| 322 | display: str, |
| 323 | providers: list[str], |
| 324 | ) -> tuple[str, list[str]]: |
| 325 | """Try providers in order and write the first successful PNG.""" |
| 326 | errors: list[str] = [] |
| 327 | for provider in providers: |
| 328 | try: |
| 329 | data = PROVIDER_RENDERERS[provider](latex, dpi, color, background, display) |
| 330 | output_path.write_bytes(data) |
| 331 | return provider, errors |
| 332 | except RuntimeError as exc: |
| 333 | errors.append(f"{provider}: {exc}") |
| 334 | raise RuntimeError("; ".join(errors)) |
| 335 | |
| 336 | |
| 337 | def _image_dimensions(path: Path) -> tuple[int, int]: |
| 338 | """Read PNG dimensions.""" |
| 339 | if Image is None: |
| 340 | raise RuntimeError( |
| 341 | "Pillow is required to measure formula PNGs. Run: pip install Pillow" |
| 342 | ) |
| 343 | with Image.open(path) as img: |
| 344 | return img.size |
| 345 | |
| 346 | |
| 347 | def _make_png_background_transparent( |
| 348 | path: Path, |
| 349 | *, |
| 350 | color: str | None, |
| 351 | background: str | None, |
| 352 | tolerance: int, |
| 353 | ) -> None: |
| 354 | """Convert the rendered formula's matte background to alpha.""" |
| 355 | if Image is None: |
| 356 | raise RuntimeError( |
| 357 | "Pillow is required to post-process transparent formula PNGs. " |
| 358 | "Run: pip install Pillow" |
| 359 | ) |
| 360 | |
| 361 | bg_rgb = _hex_to_rgb(background or "FFFFFF") |
| 362 | fg_rgb = _hex_to_rgb(color or "000000") |
| 363 | |
| 364 | with Image.open(path) as img: |
| 365 | rgba = img.convert("RGBA") |
| 366 | |
| 367 | alpha = rgba.getchannel("A") |
| 368 | if alpha.getextrema()[0] < 255: |
| 369 | if color: |
| 370 | pixels = rgba.load() |
| 371 | width, height = rgba.size |
| 372 | for y in range(height): |
| 373 | for x in range(width): |
| 374 | _, _, _, a = pixels[x, y] |
| 375 | if a: |
| 376 | pixels[x, y] = (fg_rgb[0], fg_rgb[1], fg_rgb[2], a) |
| 377 | rgba.save(path) |
| 378 | return |
| 379 | |
| 380 | fg_bg_distance = max(abs(fg_rgb[i] - bg_rgb[i]) for i in range(3)) |
| 381 | pixels = rgba.load() |
| 382 | width, height = rgba.size |
| 383 | for y in range(height): |
| 384 | for x in range(width): |
| 385 | r, g, b, a = pixels[x, y] |
| 386 | if a == 0: |
| 387 | continue |
| 388 | |
| 389 | bg_distance = max( |
| 390 | abs(r - bg_rgb[0]), |
| 391 | abs(g - bg_rgb[1]), |
| 392 | abs(b - bg_rgb[2]), |
| 393 | ) |
| 394 | if bg_distance <= tolerance: |
| 395 | pixels[x, y] = (fg_rgb[0], fg_rgb[1], fg_rgb[2], 0) |
| 396 | continue |
| 397 | |
| 398 | if fg_bg_distance > tolerance: |
| 399 | coverage = min(1.0, bg_distance / fg_bg_distance) |
| 400 | new_alpha = max(1, min(255, round(a * coverage))) |
| 401 | else: |
| 402 | new_alpha = a |
| 403 | pixels[x, y] = (fg_rgb[0], fg_rgb[1], fg_rgb[2], new_alpha) |
| 404 | |
| 405 | rgba.save(path) |
| 406 | |
| 407 | |
| 408 | def _process_item( |
| 409 | item: dict[str, Any], |
| 410 | index: int, |
| 411 | project_path: Path, |
| 412 | output_dir: Path, |
| 413 | default_dpi: int, |
| 414 | providers: list[str], |
| 415 | dry_run: bool, |
| 416 | ) -> dict[str, Any]: |
| 417 | """Render one manifest item and return its updated record.""" |
| 418 | latex = str(item.get("latex") or "").strip() |
| 419 | if not latex: |
| 420 | raise RuntimeError(f"Formula item #{index} is missing `latex`.") |
| 421 | |
| 422 | filename = _safe_filename(item, index) |
| 423 | output_path = output_dir / filename |
| 424 | dpi = int(item.get("dpi") or default_dpi) |
| 425 | color = _normalize_hex_color(item.get("color"), "color") |
| 426 | background = _normalize_hex_color(item.get("background"), "background") |
| 427 | transparent = _parse_bool(item.get("transparent"), True) |
| 428 | transparent_tolerance = _normalize_tolerance(item.get("transparent_tolerance")) |
| 429 | display = str(item.get("display") or "block").strip().lower() |
| 430 | if display not in {"inline", "block"}: |
| 431 | raise RuntimeError(f"Formula item #{index} has invalid `display`: {display}") |
| 432 | item_providers = _parse_providers( |
| 433 | item.get("providers") or item.get("provider_chain") or providers |
| 434 | ) |
| 435 | |
| 436 | updated = dict(item) |
| 437 | updated["filename"] = filename |
| 438 | updated["file"] = _project_relative(output_path, project_path) |
| 439 | updated["dpi"] = dpi |
| 440 | updated["display"] = display |
| 441 | updated["providers"] = item_providers |
| 442 | updated["transparent"] = transparent |
| 443 | if color: |
| 444 | updated["color"] = f"#{color}" |
| 445 | if background: |
| 446 | updated["background"] = f"#{background}" |
| 447 | if transparent: |
| 448 | updated["transparent_tolerance"] = transparent_tolerance |
| 449 | |
| 450 | if dry_run: |
| 451 | updated["status"] = item.get("status") or "Pending" |
| 452 | return updated |
| 453 | |
| 454 | try: |
| 455 | if not output_path.exists() or item.get("status") != "Rendered": |
| 456 | provider_used, provider_errors = _render_with_providers( |
| 457 | latex, |
| 458 | output_path, |
| 459 | dpi, |
| 460 | color, |
| 461 | background, |
| 462 | display, |
| 463 | item_providers, |
| 464 | ) |
| 465 | updated["provider"] = provider_used |
| 466 | if color and provider_used not in COLOR_AWARE_PROVIDERS: |
| 467 | updated["color_warning"] = ( |
| 468 | f"Provider `{provider_used}` is an availability fallback and may " |
| 469 | "not preserve the requested formula color." |
| 470 | ) |
| 471 | if provider_errors: |
| 472 | updated["provider_errors"] = provider_errors |
| 473 | if transparent: |
| 474 | _make_png_background_transparent( |
| 475 | output_path, |
| 476 | color=color, |
| 477 | background=background, |
| 478 | tolerance=transparent_tolerance, |
| 479 | ) |
| 480 | width, height = _image_dimensions(output_path) |
| 481 | updated["pixel_width"] = width |
| 482 | updated["pixel_height"] = height |
| 483 | updated["ratio"] = round(width / height, 4) if height else None |
| 484 | updated["status"] = "Rendered" |
| 485 | updated.pop("error", None) |
| 486 | except RuntimeError as exc: |
| 487 | updated["status"] = "Failed" |
| 488 | updated["error"] = str(exc) |
| 489 | |
| 490 | return updated |
| 491 | |
| 492 | |
| 493 | def render_manifest( |
| 494 | project_path: Path, |
| 495 | manifest_path: Path, |
| 496 | *, |
| 497 | default_dpi: int = DEFAULT_DPI, |
| 498 | providers: list[str] | None = None, |
| 499 | dry_run: bool = False, |
| 500 | ) -> int: |
| 501 | """Render all formulas declared in a manifest.""" |
| 502 | manifest = _load_manifest(manifest_path) |
| 503 | output_dir = project_path / "images" |
| 504 | updated_items: list[dict[str, Any]] = [] |
| 505 | provider_chain = _parse_providers(providers or manifest.get("providers")) |
| 506 | |
| 507 | if not dry_run: |
| 508 | output_dir.mkdir(parents=True, exist_ok=True) |
| 509 | |
| 510 | failures = 0 |
| 511 | for index, raw_item in enumerate(manifest["items"], 1): |
| 512 | if not isinstance(raw_item, dict): |
| 513 | raise RuntimeError(f"Formula item #{index} must be an object.") |
| 514 | updated = _process_item( |
| 515 | raw_item, |
| 516 | index, |
| 517 | project_path, |
| 518 | output_dir, |
| 519 | default_dpi, |
| 520 | provider_chain, |
| 521 | dry_run, |
| 522 | ) |
| 523 | updated_items.append(updated) |
| 524 | status = updated.get("status") |
| 525 | label = updated.get("id") or updated.get("filename") |
| 526 | print(f"{status}: {label} -> {updated.get('file')}", file=sys.stderr) |
| 527 | if status == "Failed": |
| 528 | failures += 1 |
| 529 | print(f" {updated.get('error')}", file=sys.stderr) |
| 530 | |
| 531 | if dry_run: |
| 532 | print(f"Dry run: {len(updated_items)} formula item(s) parsed.", file=sys.stderr) |
| 533 | return 0 |
| 534 | |
| 535 | manifest["items"] = updated_items |
| 536 | manifest["renderer"] = { |
| 537 | "providers": provider_chain, |
| 538 | "default_dpi": default_dpi, |
| 539 | "output_dir": _project_relative(output_dir, project_path), |
| 540 | } |
| 541 | manifest_path.write_text( |
| 542 | json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", |
| 543 | encoding="utf-8", |
| 544 | ) |
| 545 | |
| 546 | if failures: |
| 547 | print(f"Formula rendering completed with {failures} failure(s).", file=sys.stderr) |
| 548 | return 2 |
| 549 | print(f"Formula rendering complete: {len(updated_items)} item(s).", file=sys.stderr) |
| 550 | return 0 |
| 551 | |
| 552 | |
| 553 | def build_parser() -> argparse.ArgumentParser: |
| 554 | """Build the CLI parser.""" |
| 555 | parser = argparse.ArgumentParser( |
| 556 | description="Render project-declared LaTeX formulas to PNG assets.", |
| 557 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 558 | ) |
| 559 | parser.add_argument("project_path", type=Path, help="Project directory.") |
| 560 | parser.add_argument( |
| 561 | "--manifest", |
| 562 | type=Path, |
| 563 | default=None, |
| 564 | help=f"Formula manifest path. Default: <project>/{DEFAULT_MANIFEST}", |
| 565 | ) |
| 566 | parser.add_argument( |
| 567 | "--dpi", |
| 568 | type=int, |
| 569 | default=DEFAULT_DPI, |
| 570 | help=f"Default render DPI when an item omits `dpi` (default: {DEFAULT_DPI}).", |
| 571 | ) |
| 572 | parser.add_argument( |
| 573 | "--providers", |
| 574 | default=None, |
| 575 | help=( |
| 576 | "Comma-separated provider fallback chain " |
| 577 | f"(default: {','.join(DEFAULT_PROVIDERS)})." |
| 578 | ), |
| 579 | ) |
| 580 | parser.add_argument( |
| 581 | "--dry-run", |
| 582 | action="store_true", |
| 583 | help="Validate and list formula items without rendering or writing files.", |
| 584 | ) |
| 585 | return parser |
| 586 | |
| 587 | |
| 588 | def main(argv: list[str] | None = None) -> int: |
| 589 | """Run the CLI entry point.""" |
| 590 | parser = build_parser() |
| 591 | args = parser.parse_args(argv) |
| 592 | |
| 593 | project_path = args.project_path.resolve() |
| 594 | if not project_path.is_dir(): |
| 595 | print(f"Error: project directory not found: {project_path}", file=sys.stderr) |
| 596 | return 1 |
| 597 | |
| 598 | manifest_path = args.manifest |
| 599 | if manifest_path is None: |
| 600 | manifest_path = project_path / DEFAULT_MANIFEST |
| 601 | elif not manifest_path.is_absolute(): |
| 602 | manifest_path = project_path / manifest_path |
| 603 | manifest_path = manifest_path.resolve() |
| 604 | |
| 605 | try: |
| 606 | return render_manifest( |
| 607 | project_path, |
| 608 | manifest_path, |
| 609 | default_dpi=args.dpi, |
| 610 | providers=_parse_providers(args.providers) if args.providers else None, |
| 611 | dry_run=args.dry_run, |
| 612 | ) |
| 613 | except RuntimeError as exc: |
| 614 | print(f"Error: {exc}", file=sys.stderr) |
| 615 | return 1 |
| 616 | |
| 617 | |
| 618 | if __name__ == "__main__": |
| 619 | raise SystemExit(main()) |
| 620 |