返回 ppt-master
latex_render.py
根目录 / skills / ppt-master / scripts / latex_render.py
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
620 lines PYTHON