Files
wehub-resource-sync 8ef9db2513
Deploy to GitHub Pages / deploy (push) Has been cancelled
Deploy to GitHub Pages / build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:56:49 +08:00

620 lines
20 KiB
Python

#!/usr/bin/env python3
"""
PPT Master - LaTeX Formula Renderer
Render Strategist-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 Strategist-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())