Release standalone PPT template workflow 0.5.0
This commit is contained in:
@@ -0,0 +1,619 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PPT Master - LaTeX Formula Renderer
|
||||
|
||||
Render project-declared LaTeX formulas to transparent PNG assets.
|
||||
The script reads an explicit manifest; it never scans spec_lock.md or source
|
||||
content for dollar-delimited math.
|
||||
|
||||
Usage:
|
||||
python3 scripts/latex_render.py <project_path>
|
||||
python3 scripts/latex_render.py <project_path> --manifest images/formula_manifest.json
|
||||
python3 scripts/latex_render.py <project_path> --dry-run
|
||||
|
||||
Examples:
|
||||
python3 scripts/latex_render.py projects/demo_ppt169_20260523
|
||||
python3 scripts/latex_render.py projects/demo_ppt169_20260523 --providers codecogs,quicklatex,mathpad,wikimedia
|
||||
|
||||
Dependencies:
|
||||
Pillow (for measuring generated PNG dimensions)
|
||||
Network access to at least one configured rendering provider
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from console_encoding import configure_utf8_stdio
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError:
|
||||
Image = None
|
||||
|
||||
|
||||
configure_utf8_stdio()
|
||||
|
||||
|
||||
DEFAULT_DPI = 300
|
||||
DEFAULT_TRANSPARENT_TOLERANCE = 12
|
||||
DEFAULT_MANIFEST = "images/formula_manifest.json"
|
||||
DEFAULT_PROVIDERS = ["codecogs", "quicklatex", "mathpad", "wikimedia"]
|
||||
CODECOGS_ENDPOINT = "https://latex.codecogs.com/png.image?"
|
||||
WIKIMEDIA_CHECK_ENDPOINT = "https://wikimedia.org/api/rest_v1/media/math/check"
|
||||
WIKIMEDIA_RENDER_ENDPOINT = "https://wikimedia.org/api/rest_v1/media/math/render/png"
|
||||
QUICKLATEX_ENDPOINT = "https://quicklatex.com/latex3.f"
|
||||
MATHPAD_ENDPOINT = "https://mathpad.ai/api/v1/latex2image"
|
||||
VALID_FILENAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*\.png$")
|
||||
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
||||
|
||||
|
||||
def _project_relative(path: Path, project_path: Path) -> str:
|
||||
"""Return a POSIX-style path relative to the project."""
|
||||
return path.relative_to(project_path).as_posix()
|
||||
|
||||
|
||||
def _load_manifest(path: Path) -> dict[str, Any]:
|
||||
"""Load a JSON formula manifest."""
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except OSError as exc:
|
||||
raise RuntimeError(f"Cannot read manifest: {path} ({exc})") from exc
|
||||
except json.JSONDecodeError as exc:
|
||||
raise RuntimeError(f"Manifest is not valid JSON: {path} ({exc})") from exc
|
||||
|
||||
if not isinstance(data, dict):
|
||||
raise RuntimeError("Manifest root must be a JSON object.")
|
||||
items = data.get("items")
|
||||
if not isinstance(items, list):
|
||||
raise RuntimeError("Manifest must contain an `items` array.")
|
||||
return data
|
||||
|
||||
|
||||
def _safe_filename(item: dict[str, Any], index: int) -> str:
|
||||
"""Resolve and validate the output PNG filename for one formula."""
|
||||
filename = item.get("filename")
|
||||
if filename is None:
|
||||
formula_id = str(item.get("id") or f"formula_{index:03d}")
|
||||
filename = f"{formula_id}.png"
|
||||
filename = str(filename)
|
||||
if "/" in filename or "\\" in filename or not VALID_FILENAME_RE.match(filename):
|
||||
raise RuntimeError(
|
||||
f"Invalid formula filename `{filename}`. Use a simple PNG filename "
|
||||
"such as `formula_001.png`."
|
||||
)
|
||||
return filename
|
||||
|
||||
|
||||
def _normalize_hex_color(color: str | None, field_name: str) -> str | None:
|
||||
"""Normalize an optional 6-digit HEX color."""
|
||||
if not color:
|
||||
return None
|
||||
value = color.strip()
|
||||
if value.startswith("#"):
|
||||
value = value[1:]
|
||||
if not re.fullmatch(r"[0-9A-Fa-f]{6}", value):
|
||||
raise RuntimeError(f"Formula {field_name} must be a 6-digit HEX value: {color}")
|
||||
return value.upper()
|
||||
|
||||
|
||||
def _hex_to_rgb(color: str) -> tuple[int, int, int]:
|
||||
"""Convert a normalized HEX color to an RGB tuple."""
|
||||
return (int(color[0:2], 16), int(color[2:4], 16), int(color[4:6], 16))
|
||||
|
||||
|
||||
def _parse_bool(value: Any, default: bool) -> bool:
|
||||
"""Parse a manifest boolean with a default."""
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip().lower()
|
||||
if normalized in {"1", "true", "yes", "on"}:
|
||||
return True
|
||||
if normalized in {"0", "false", "no", "off"}:
|
||||
return False
|
||||
raise RuntimeError(f"Expected boolean value, got: {value!r}")
|
||||
|
||||
|
||||
def _normalize_tolerance(value: Any) -> int:
|
||||
"""Normalize the background removal tolerance."""
|
||||
if value is None:
|
||||
return DEFAULT_TRANSPARENT_TOLERANCE
|
||||
tolerance = int(value)
|
||||
if tolerance < 0 or tolerance > 255:
|
||||
raise RuntimeError("transparent_tolerance must be between 0 and 255.")
|
||||
return tolerance
|
||||
|
||||
|
||||
def _parse_providers(value: str | list[str] | None) -> list[str]:
|
||||
"""Parse and validate a provider chain."""
|
||||
if value is None:
|
||||
providers = DEFAULT_PROVIDERS
|
||||
elif isinstance(value, list):
|
||||
providers = value
|
||||
else:
|
||||
providers = [part.strip() for part in value.split(",") if part.strip()]
|
||||
|
||||
valid = {"codecogs", "quicklatex", "mathpad", "wikimedia"}
|
||||
unknown = [provider for provider in providers if provider not in valid]
|
||||
if unknown:
|
||||
raise RuntimeError(
|
||||
f"Unknown formula provider(s): {', '.join(unknown)}. "
|
||||
f"Available: {', '.join(sorted(valid))}"
|
||||
)
|
||||
if not providers:
|
||||
raise RuntimeError("Provider chain must include at least one provider.")
|
||||
return providers
|
||||
|
||||
|
||||
def _request_bytes(req: urllib.request.Request, timeout: int = 30) -> tuple[bytes, str]:
|
||||
"""Fetch bytes and return the response content type."""
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return resp.read(), resp.headers.get("Content-Type", "")
|
||||
except (urllib.error.URLError, TimeoutError) as exc:
|
||||
raise RuntimeError(str(exc)) from exc
|
||||
|
||||
|
||||
def _assert_png(data: bytes, provider: str, content_type: str) -> bytes:
|
||||
"""Validate PNG response bytes."""
|
||||
if not data.startswith(PNG_SIGNATURE):
|
||||
raise RuntimeError(
|
||||
f"{provider} did not return PNG data (Content-Type: {content_type})"
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
def _build_codecogs_payload(latex: str, dpi: int, color: str | None) -> str:
|
||||
"""Build a CodeCogs LaTeX payload."""
|
||||
# CodeCogs documents PNG DPI in the 50-300 range.
|
||||
safe_dpi = min(max(dpi, 50), 300)
|
||||
parts = [rf"\dpi{{{safe_dpi}}}"]
|
||||
if color:
|
||||
parts.append(rf"\fg{{{color}}}")
|
||||
parts.append(latex)
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def _render_codecogs(
|
||||
latex: str,
|
||||
dpi: int,
|
||||
color: str | None,
|
||||
background: str | None,
|
||||
display: str,
|
||||
) -> bytes:
|
||||
"""Render one formula through CodeCogs."""
|
||||
payload = _build_codecogs_payload(latex, dpi, color)
|
||||
url = CODECOGS_ENDPOINT + urllib.parse.quote(payload)
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "PPT-Master/1.0"})
|
||||
data, content_type = _request_bytes(req)
|
||||
return _assert_png(data, "codecogs", content_type)
|
||||
|
||||
|
||||
def _render_wikimedia(
|
||||
latex: str,
|
||||
dpi: int,
|
||||
color: str | None,
|
||||
background: str | None,
|
||||
display: str,
|
||||
) -> bytes:
|
||||
"""Render one formula through Wikimedia Mathoid."""
|
||||
formula_type = "inline-tex" if display == "inline" else "tex"
|
||||
payload = urllib.parse.urlencode({"q": latex}).encode("utf-8")
|
||||
check_req = urllib.request.Request(
|
||||
f"{WIKIMEDIA_CHECK_ENDPOINT}/{formula_type}",
|
||||
data=payload,
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"User-Agent": "PPT-Master/1.0",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(check_req, timeout=30) as resp:
|
||||
resp.read()
|
||||
resource = resp.headers.get("x-resource-location")
|
||||
except (urllib.error.URLError, TimeoutError) as exc:
|
||||
raise RuntimeError(str(exc)) from exc
|
||||
|
||||
if not resource:
|
||||
raise RuntimeError("missing x-resource-location header")
|
||||
|
||||
render_req = urllib.request.Request(
|
||||
f"{WIKIMEDIA_RENDER_ENDPOINT}/{resource}",
|
||||
headers={"User-Agent": "PPT-Master/1.0"},
|
||||
)
|
||||
data, content_type = _request_bytes(render_req)
|
||||
return _assert_png(data, "wikimedia", content_type)
|
||||
|
||||
|
||||
def _render_quicklatex(
|
||||
latex: str,
|
||||
dpi: int,
|
||||
color: str | None,
|
||||
background: str | None,
|
||||
display: str,
|
||||
) -> bytes:
|
||||
"""Render one formula through QuickLaTeX."""
|
||||
wrapped = f"${latex}$" if display == "inline" else f"$${latex}$$"
|
||||
# QuickLaTeX uses CSS font size rather than DPI. Keep a conservative fixed
|
||||
# size; downstream placement uses measured dimensions from the PNG.
|
||||
params = {
|
||||
"formula": wrapped,
|
||||
"fsize": "24px",
|
||||
"fcolor": color or "000000",
|
||||
"mode": "0",
|
||||
"out": "1",
|
||||
"remhost": "quicklatex.com",
|
||||
}
|
||||
req = urllib.request.Request(
|
||||
QUICKLATEX_ENDPOINT,
|
||||
data=urllib.parse.urlencode(params).encode("utf-8"),
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"User-Agent": "PPT-Master/1.0",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
data, _ = _request_bytes(req)
|
||||
text = data.decode("utf-8", errors="replace").strip()
|
||||
lines = text.splitlines()
|
||||
if not lines or lines[0].strip() != "0":
|
||||
raise RuntimeError(text or "QuickLaTeX returned an empty response")
|
||||
if len(lines) < 2:
|
||||
raise RuntimeError(f"QuickLaTeX response missing image URL: {text}")
|
||||
image_url = lines[1].split()[0]
|
||||
image_req = urllib.request.Request(
|
||||
image_url,
|
||||
headers={"User-Agent": "PPT-Master/1.0"},
|
||||
)
|
||||
image_data, content_type = _request_bytes(image_req)
|
||||
return _assert_png(image_data, "quicklatex", content_type)
|
||||
|
||||
|
||||
def _render_mathpad(
|
||||
latex: str,
|
||||
dpi: int,
|
||||
color: str | None,
|
||||
background: str | None,
|
||||
display: str,
|
||||
) -> bytes:
|
||||
"""Render one formula through MathPad's public LaTeX image endpoint."""
|
||||
wrapped = f"${latex}$" if display == "inline" else f"$${latex}$$"
|
||||
scale = "4" if dpi >= 300 else "2"
|
||||
params = {
|
||||
"latex": wrapped,
|
||||
"format": "png",
|
||||
"scale": scale,
|
||||
"color": f"#{color or '000000'}",
|
||||
"bg": f"#{background or 'FFFFFF'}",
|
||||
}
|
||||
url = MATHPAD_ENDPOINT + "?" + urllib.parse.urlencode(params)
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "PPT-Master/1.0"})
|
||||
data, content_type = _request_bytes(req)
|
||||
return _assert_png(data, "mathpad", content_type)
|
||||
|
||||
|
||||
PROVIDER_RENDERERS = {
|
||||
"codecogs": _render_codecogs,
|
||||
"quicklatex": _render_quicklatex,
|
||||
"mathpad": _render_mathpad,
|
||||
"wikimedia": _render_wikimedia,
|
||||
}
|
||||
COLOR_AWARE_PROVIDERS = {"codecogs", "quicklatex", "mathpad"}
|
||||
|
||||
|
||||
def _render_with_providers(
|
||||
latex: str,
|
||||
output_path: Path,
|
||||
dpi: int,
|
||||
color: str | None,
|
||||
background: str | None,
|
||||
display: str,
|
||||
providers: list[str],
|
||||
) -> tuple[str, list[str]]:
|
||||
"""Try providers in order and write the first successful PNG."""
|
||||
errors: list[str] = []
|
||||
for provider in providers:
|
||||
try:
|
||||
data = PROVIDER_RENDERERS[provider](latex, dpi, color, background, display)
|
||||
output_path.write_bytes(data)
|
||||
return provider, errors
|
||||
except RuntimeError as exc:
|
||||
errors.append(f"{provider}: {exc}")
|
||||
raise RuntimeError("; ".join(errors))
|
||||
|
||||
|
||||
def _image_dimensions(path: Path) -> tuple[int, int]:
|
||||
"""Read PNG dimensions."""
|
||||
if Image is None:
|
||||
raise RuntimeError(
|
||||
"Pillow is required to measure formula PNGs. Run: pip install Pillow"
|
||||
)
|
||||
with Image.open(path) as img:
|
||||
return img.size
|
||||
|
||||
|
||||
def _make_png_background_transparent(
|
||||
path: Path,
|
||||
*,
|
||||
color: str | None,
|
||||
background: str | None,
|
||||
tolerance: int,
|
||||
) -> None:
|
||||
"""Convert the rendered formula's matte background to alpha."""
|
||||
if Image is None:
|
||||
raise RuntimeError(
|
||||
"Pillow is required to post-process transparent formula PNGs. "
|
||||
"Run: pip install Pillow"
|
||||
)
|
||||
|
||||
bg_rgb = _hex_to_rgb(background or "FFFFFF")
|
||||
fg_rgb = _hex_to_rgb(color or "000000")
|
||||
|
||||
with Image.open(path) as img:
|
||||
rgba = img.convert("RGBA")
|
||||
|
||||
alpha = rgba.getchannel("A")
|
||||
if alpha.getextrema()[0] < 255:
|
||||
if color:
|
||||
pixels = rgba.load()
|
||||
width, height = rgba.size
|
||||
for y in range(height):
|
||||
for x in range(width):
|
||||
_, _, _, a = pixels[x, y]
|
||||
if a:
|
||||
pixels[x, y] = (fg_rgb[0], fg_rgb[1], fg_rgb[2], a)
|
||||
rgba.save(path)
|
||||
return
|
||||
|
||||
fg_bg_distance = max(abs(fg_rgb[i] - bg_rgb[i]) for i in range(3))
|
||||
pixels = rgba.load()
|
||||
width, height = rgba.size
|
||||
for y in range(height):
|
||||
for x in range(width):
|
||||
r, g, b, a = pixels[x, y]
|
||||
if a == 0:
|
||||
continue
|
||||
|
||||
bg_distance = max(
|
||||
abs(r - bg_rgb[0]),
|
||||
abs(g - bg_rgb[1]),
|
||||
abs(b - bg_rgb[2]),
|
||||
)
|
||||
if bg_distance <= tolerance:
|
||||
pixels[x, y] = (fg_rgb[0], fg_rgb[1], fg_rgb[2], 0)
|
||||
continue
|
||||
|
||||
if fg_bg_distance > tolerance:
|
||||
coverage = min(1.0, bg_distance / fg_bg_distance)
|
||||
new_alpha = max(1, min(255, round(a * coverage)))
|
||||
else:
|
||||
new_alpha = a
|
||||
pixels[x, y] = (fg_rgb[0], fg_rgb[1], fg_rgb[2], new_alpha)
|
||||
|
||||
rgba.save(path)
|
||||
|
||||
|
||||
def _process_item(
|
||||
item: dict[str, Any],
|
||||
index: int,
|
||||
project_path: Path,
|
||||
output_dir: Path,
|
||||
default_dpi: int,
|
||||
providers: list[str],
|
||||
dry_run: bool,
|
||||
) -> dict[str, Any]:
|
||||
"""Render one manifest item and return its updated record."""
|
||||
latex = str(item.get("latex") or "").strip()
|
||||
if not latex:
|
||||
raise RuntimeError(f"Formula item #{index} is missing `latex`.")
|
||||
|
||||
filename = _safe_filename(item, index)
|
||||
output_path = output_dir / filename
|
||||
dpi = int(item.get("dpi") or default_dpi)
|
||||
color = _normalize_hex_color(item.get("color"), "color")
|
||||
background = _normalize_hex_color(item.get("background"), "background")
|
||||
transparent = _parse_bool(item.get("transparent"), True)
|
||||
transparent_tolerance = _normalize_tolerance(item.get("transparent_tolerance"))
|
||||
display = str(item.get("display") or "block").strip().lower()
|
||||
if display not in {"inline", "block"}:
|
||||
raise RuntimeError(f"Formula item #{index} has invalid `display`: {display}")
|
||||
item_providers = _parse_providers(
|
||||
item.get("providers") or item.get("provider_chain") or providers
|
||||
)
|
||||
|
||||
updated = dict(item)
|
||||
updated["filename"] = filename
|
||||
updated["file"] = _project_relative(output_path, project_path)
|
||||
updated["dpi"] = dpi
|
||||
updated["display"] = display
|
||||
updated["providers"] = item_providers
|
||||
updated["transparent"] = transparent
|
||||
if color:
|
||||
updated["color"] = f"#{color}"
|
||||
if background:
|
||||
updated["background"] = f"#{background}"
|
||||
if transparent:
|
||||
updated["transparent_tolerance"] = transparent_tolerance
|
||||
|
||||
if dry_run:
|
||||
updated["status"] = item.get("status") or "Pending"
|
||||
return updated
|
||||
|
||||
try:
|
||||
if not output_path.exists() or item.get("status") != "Rendered":
|
||||
provider_used, provider_errors = _render_with_providers(
|
||||
latex,
|
||||
output_path,
|
||||
dpi,
|
||||
color,
|
||||
background,
|
||||
display,
|
||||
item_providers,
|
||||
)
|
||||
updated["provider"] = provider_used
|
||||
if color and provider_used not in COLOR_AWARE_PROVIDERS:
|
||||
updated["color_warning"] = (
|
||||
f"Provider `{provider_used}` is an availability fallback and may "
|
||||
"not preserve the requested formula color."
|
||||
)
|
||||
if provider_errors:
|
||||
updated["provider_errors"] = provider_errors
|
||||
if transparent:
|
||||
_make_png_background_transparent(
|
||||
output_path,
|
||||
color=color,
|
||||
background=background,
|
||||
tolerance=transparent_tolerance,
|
||||
)
|
||||
width, height = _image_dimensions(output_path)
|
||||
updated["pixel_width"] = width
|
||||
updated["pixel_height"] = height
|
||||
updated["ratio"] = round(width / height, 4) if height else None
|
||||
updated["status"] = "Rendered"
|
||||
updated.pop("error", None)
|
||||
except RuntimeError as exc:
|
||||
updated["status"] = "Failed"
|
||||
updated["error"] = str(exc)
|
||||
|
||||
return updated
|
||||
|
||||
|
||||
def render_manifest(
|
||||
project_path: Path,
|
||||
manifest_path: Path,
|
||||
*,
|
||||
default_dpi: int = DEFAULT_DPI,
|
||||
providers: list[str] | None = None,
|
||||
dry_run: bool = False,
|
||||
) -> int:
|
||||
"""Render all formulas declared in a manifest."""
|
||||
manifest = _load_manifest(manifest_path)
|
||||
output_dir = project_path / "images"
|
||||
updated_items: list[dict[str, Any]] = []
|
||||
provider_chain = _parse_providers(providers or manifest.get("providers"))
|
||||
|
||||
if not dry_run:
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
failures = 0
|
||||
for index, raw_item in enumerate(manifest["items"], 1):
|
||||
if not isinstance(raw_item, dict):
|
||||
raise RuntimeError(f"Formula item #{index} must be an object.")
|
||||
updated = _process_item(
|
||||
raw_item,
|
||||
index,
|
||||
project_path,
|
||||
output_dir,
|
||||
default_dpi,
|
||||
provider_chain,
|
||||
dry_run,
|
||||
)
|
||||
updated_items.append(updated)
|
||||
status = updated.get("status")
|
||||
label = updated.get("id") or updated.get("filename")
|
||||
print(f"{status}: {label} -> {updated.get('file')}", file=sys.stderr)
|
||||
if status == "Failed":
|
||||
failures += 1
|
||||
print(f" {updated.get('error')}", file=sys.stderr)
|
||||
|
||||
if dry_run:
|
||||
print(f"Dry run: {len(updated_items)} formula item(s) parsed.", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
manifest["items"] = updated_items
|
||||
manifest["renderer"] = {
|
||||
"providers": provider_chain,
|
||||
"default_dpi": default_dpi,
|
||||
"output_dir": _project_relative(output_dir, project_path),
|
||||
}
|
||||
manifest_path.write_text(
|
||||
json.dumps(manifest, indent=2, ensure_ascii=False) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
if failures:
|
||||
print(f"Formula rendering completed with {failures} failure(s).", file=sys.stderr)
|
||||
return 2
|
||||
print(f"Formula rendering complete: {len(updated_items)} item(s).", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
"""Build the CLI parser."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Render project-declared LaTeX formulas to PNG assets.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument("project_path", type=Path, help="Project directory.")
|
||||
parser.add_argument(
|
||||
"--manifest",
|
||||
type=Path,
|
||||
default=None,
|
||||
help=f"Formula manifest path. Default: <project>/{DEFAULT_MANIFEST}",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dpi",
|
||||
type=int,
|
||||
default=DEFAULT_DPI,
|
||||
help=f"Default render DPI when an item omits `dpi` (default: {DEFAULT_DPI}).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--providers",
|
||||
default=None,
|
||||
help=(
|
||||
"Comma-separated provider fallback chain "
|
||||
f"(default: {','.join(DEFAULT_PROVIDERS)})."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Validate and list formula items without rendering or writing files.",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""Run the CLI entry point."""
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
project_path = args.project_path.resolve()
|
||||
if not project_path.is_dir():
|
||||
print(f"Error: project directory not found: {project_path}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
manifest_path = args.manifest
|
||||
if manifest_path is None:
|
||||
manifest_path = project_path / DEFAULT_MANIFEST
|
||||
elif not manifest_path.is_absolute():
|
||||
manifest_path = project_path / manifest_path
|
||||
manifest_path = manifest_path.resolve()
|
||||
|
||||
try:
|
||||
return render_manifest(
|
||||
project_path,
|
||||
manifest_path,
|
||||
default_dpi=args.dpi,
|
||||
providers=_parse_providers(args.providers) if args.providers else None,
|
||||
dry_run=args.dry_run,
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user