chore: 1.0.0 release
This commit is contained in:
Executable
+662
@@ -0,0 +1,662 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Export a PPTD project through Kimi's public browser-side PPTX writer.
|
||||
|
||||
The script uses a temporary localhost SDK host and agent-browser. It never uploads
|
||||
the PPTD project as a document. Referenced remote resources may still be fetched by
|
||||
the official editor. Local image files are exposed to the iframe as data URLs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
import zipfile
|
||||
import xml.etree.ElementTree as ET
|
||||
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError as exc: # pragma: no cover - environment diagnostic
|
||||
raise SystemExit(
|
||||
"PyYAML is required. Install it with: python3 -m pip install --user pyyaml"
|
||||
) from exc
|
||||
|
||||
|
||||
SKILL_DIR = Path(__file__).resolve().parent.parent
|
||||
HOST_TEMPLATE = Path(__file__).with_name("export_host.html")
|
||||
IMAGE_MIME = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".svg": "image/svg+xml",
|
||||
}
|
||||
MAX_IMAGE_BYTES = 20 * 1024 * 1024
|
||||
MAX_EMBEDDED_MEDIA_BYTES = 200 * 1024 * 1024
|
||||
PPTX_CONTENT_TYPE = (
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"
|
||||
)
|
||||
FADE_TRANSITION_XML = (
|
||||
'<p:transition spd="fast" advClick="1"><p:fade/></p:transition>'
|
||||
)
|
||||
MIN_AGENT_BROWSER_VERSION = (0, 33, 2)
|
||||
|
||||
|
||||
class ExportError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class QuietHandler(SimpleHTTPRequestHandler):
|
||||
def log_message(self, _format: str, *_args: Any) -> None:
|
||||
return
|
||||
|
||||
|
||||
def log(message: str) -> None:
|
||||
print(f"[open-kimi-ppt] {message}", file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
def parse_version(output: str) -> Tuple[int, int, int]:
|
||||
match = re.search(r"(\d+)\.(\d+)\.(\d+)\b", output)
|
||||
if not match:
|
||||
raise ExportError(f"could not parse agent-browser version from: {output.strip()}")
|
||||
return tuple(int(part) for part in match.groups())
|
||||
|
||||
|
||||
def read_agent_browser_version(executable: str) -> Tuple[int, int, int]:
|
||||
process = subprocess.run(
|
||||
[executable, "--version"],
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
timeout=30,
|
||||
)
|
||||
if process.returncode != 0:
|
||||
raise ExportError(f"agent-browser --version failed:\n{process.stdout[-2000:]}")
|
||||
return parse_version(process.stdout)
|
||||
|
||||
|
||||
def ensure_agent_browser() -> str:
|
||||
executable = shutil.which("agent-browser")
|
||||
version = read_agent_browser_version(executable) if executable else None
|
||||
if version is not None and version >= MIN_AGENT_BROWSER_VERSION:
|
||||
log(f"agent-browser version: {'.'.join(map(str, version))}")
|
||||
return executable
|
||||
|
||||
npm = shutil.which("npm")
|
||||
if not npm:
|
||||
reason = "not installed" if version is None else ".".join(map(str, version))
|
||||
raise ExportError(
|
||||
f"agent-browser {reason}; npm is required to install agent-browser@latest"
|
||||
)
|
||||
|
||||
current = "not installed" if version is None else ".".join(map(str, version))
|
||||
minimum = ".".join(map(str, MIN_AGENT_BROWSER_VERSION))
|
||||
log(f"agent-browser {current} is below {minimum}; installing agent-browser@latest")
|
||||
process = subprocess.run(
|
||||
[npm, "install", "-g", "agent-browser@latest"],
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
timeout=300,
|
||||
)
|
||||
if process.returncode != 0:
|
||||
raise ExportError(f"failed to install agent-browser@latest:\n{process.stdout[-4000:]}")
|
||||
|
||||
executable = shutil.which("agent-browser")
|
||||
if not executable:
|
||||
raise ExportError("agent-browser@latest installed, but executable is not on PATH")
|
||||
version = read_agent_browser_version(executable)
|
||||
if version < MIN_AGENT_BROWSER_VERSION:
|
||||
raise ExportError(
|
||||
"agent-browser@latest is still below the required version "
|
||||
f"{minimum}: {'.'.join(map(str, version))}"
|
||||
)
|
||||
log(f"agent-browser upgraded to {'.'.join(map(str, version))}")
|
||||
return executable
|
||||
|
||||
|
||||
def find_manifest(source: Path) -> Path:
|
||||
source = source.expanduser().resolve()
|
||||
if source.is_file():
|
||||
if source.suffix.lower() != ".pptd":
|
||||
raise ExportError(f"input must be a .pptd file or project directory: {source}")
|
||||
return source
|
||||
if not source.is_dir():
|
||||
raise ExportError(f"input does not exist: {source}")
|
||||
manifests = sorted(source.rglob("*.pptd"))
|
||||
if not manifests:
|
||||
raise ExportError(f"no .pptd manifest found under: {source}")
|
||||
if len(manifests) > 1:
|
||||
choices = "\n ".join(str(path) for path in manifests[:20])
|
||||
raise ExportError(
|
||||
"multiple .pptd manifests found; pass one manifest explicitly:\n " + choices
|
||||
)
|
||||
return manifests[0]
|
||||
|
||||
|
||||
def read_yaml_mapping(path: Path) -> Tuple[str, Dict[str, Any]]:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
try:
|
||||
value = yaml.safe_load(text)
|
||||
except yaml.YAMLError as exc:
|
||||
raise ExportError(f"invalid YAML in {path}: {exc}") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise ExportError(f"expected a YAML mapping in {path}")
|
||||
return text, value
|
||||
|
||||
|
||||
def safe_project_path(root: Path, relative: str) -> Path:
|
||||
if not isinstance(relative, str) or not relative.strip():
|
||||
raise ExportError("page path must be a non-empty string")
|
||||
candidate = (root / relative).resolve()
|
||||
try:
|
||||
candidate.relative_to(root)
|
||||
except ValueError as exc:
|
||||
raise ExportError(f"project path escapes the PPTD directory: {relative}") from exc
|
||||
return candidate
|
||||
|
||||
|
||||
def build_image_map(root: Path) -> Dict[str, str]:
|
||||
image_map: Dict[str, str] = {}
|
||||
total = 0
|
||||
for path in sorted(root.rglob("*")):
|
||||
if not path.is_file() or path.suffix.lower() not in IMAGE_MIME:
|
||||
continue
|
||||
size = path.stat().st_size
|
||||
if size > MAX_IMAGE_BYTES:
|
||||
log(f"skip local image over 20 MiB: {path.relative_to(root)}")
|
||||
continue
|
||||
if total + size > MAX_EMBEDDED_MEDIA_BYTES:
|
||||
raise ExportError(
|
||||
"local image payload exceeds 200 MiB; reduce media size or use remote URLs"
|
||||
)
|
||||
data = base64.b64encode(path.read_bytes()).decode("ascii")
|
||||
rel = path.relative_to(root).as_posix()
|
||||
image_map[rel] = f"data:{IMAGE_MIME[path.suffix.lower()]};base64,{data}"
|
||||
total += size
|
||||
if image_map:
|
||||
log(f"prepared {len(image_map)} local image resource(s), {total} bytes")
|
||||
return image_map
|
||||
|
||||
|
||||
def build_payload(manifest: Path) -> Dict[str, Any]:
|
||||
manifest_text, manifest_data = read_yaml_mapping(manifest)
|
||||
if manifest_data.get("version") != "v2":
|
||||
raise ExportError("local PPTX export currently requires PPTD version: v2")
|
||||
page_paths = manifest_data.get("pages")
|
||||
if not isinstance(page_paths, list) or not page_paths:
|
||||
raise ExportError("PPTD manifest must contain a non-empty pages list")
|
||||
|
||||
root = manifest.parent.resolve()
|
||||
pages: List[Dict[str, str]] = []
|
||||
for entry in page_paths:
|
||||
page_path = safe_project_path(root, entry)
|
||||
if not page_path.is_file():
|
||||
raise ExportError(f"missing page file: {entry}")
|
||||
page_text, page_data = read_yaml_mapping(page_path)
|
||||
if not isinstance(page_data.get("elements"), list):
|
||||
raise ExportError(f"page elements must be an array: {entry}")
|
||||
pages.append({"path": str(entry), "content": page_text})
|
||||
|
||||
title = str(manifest_data.get("title") or manifest.stem)
|
||||
return {
|
||||
"id": f"local-export-{uuid.uuid4().hex}",
|
||||
"title": title,
|
||||
"manifestPath": manifest.name,
|
||||
"manifestContent": manifest_text,
|
||||
"pages": pages,
|
||||
"imageMap": build_image_map(root),
|
||||
}
|
||||
|
||||
|
||||
def json_result(output: str) -> Dict[str, Any]:
|
||||
for line in reversed(output.splitlines()):
|
||||
line = line.strip()
|
||||
if not line.startswith("{"):
|
||||
continue
|
||||
try:
|
||||
value = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
raise ExportError(f"agent-browser returned no JSON object:\n{output[-2000:]}")
|
||||
|
||||
|
||||
class BrowserSession:
|
||||
def __init__(self, executable: str, session: str, cwd: Path, download_dir: Path):
|
||||
self.executable = executable
|
||||
self.session = session
|
||||
self.cwd = cwd
|
||||
self.download_dir = download_dir
|
||||
self.env = os.environ.copy()
|
||||
self.env.setdefault("AGENT_BROWSER_DEFAULT_TIMEOUT", "60000")
|
||||
self.env.setdefault("AGENT_BROWSER_IDLE_TIMEOUT_MS", "180000")
|
||||
|
||||
def run(
|
||||
self,
|
||||
args: Sequence[str],
|
||||
*,
|
||||
timeout: int = 90,
|
||||
check: bool = True,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
command = [self.executable, "--session", self.session, *args]
|
||||
process = subprocess.run(
|
||||
command,
|
||||
cwd=self.cwd,
|
||||
env=self.env,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
timeout=timeout,
|
||||
)
|
||||
if check and process.returncode != 0:
|
||||
raise ExportError(
|
||||
f"agent-browser command failed ({process.returncode}): "
|
||||
f"{' '.join(args)}\n{process.stdout[-4000:]}"
|
||||
)
|
||||
return process
|
||||
|
||||
def open(self, url: str) -> None:
|
||||
self.run(
|
||||
["--download-path", str(self.download_dir), "open", url], timeout=90
|
||||
)
|
||||
|
||||
def snapshot(self) -> Dict[str, Any]:
|
||||
process = self.run(["snapshot", "-i", "-C", "--json"])
|
||||
return json_result(process.stdout)
|
||||
|
||||
def close(self) -> None:
|
||||
self.run(["close"], timeout=20, check=False)
|
||||
|
||||
|
||||
def snapshot_data(snapshot: Dict[str, Any]) -> Dict[str, Any]:
|
||||
data = snapshot.get("data")
|
||||
if not isinstance(data, dict):
|
||||
raise ExportError(f"invalid agent-browser snapshot: {snapshot}")
|
||||
return data
|
||||
|
||||
|
||||
def ref_by_name(snapshot: Dict[str, Any], name: str, role: Optional[str] = None) -> str:
|
||||
refs = snapshot_data(snapshot).get("refs")
|
||||
if not isinstance(refs, dict):
|
||||
raise ExportError("snapshot contains no interactive refs")
|
||||
matches = []
|
||||
for ref, metadata in refs.items():
|
||||
if not isinstance(metadata, dict) or metadata.get("name") != name:
|
||||
continue
|
||||
if role is not None and str(metadata.get("role", "")).lower() != role.lower():
|
||||
continue
|
||||
matches.append(ref)
|
||||
if not matches:
|
||||
raise ExportError(f"could not find {role or 'element'} named {name!r}")
|
||||
return matches[-1]
|
||||
|
||||
|
||||
def switch_state(snapshot: Dict[str, Any]) -> Optional[Tuple[str, bool, bool]]:
|
||||
text = str(snapshot_data(snapshot).get("snapshot") or "")
|
||||
match = re.search(r"switch \[(?P<attrs>[^\]]*?)ref=(?P<ref>e\d+)\]", text)
|
||||
if not match:
|
||||
return None
|
||||
attrs = match.group("attrs")
|
||||
return match.group("ref"), "checked=true" in attrs, "disabled" in attrs
|
||||
|
||||
|
||||
def wait_for_export_dialog(browser: BrowserSession, timeout: float = 20.0) -> Dict[str, Any]:
|
||||
deadline = time.monotonic() + timeout
|
||||
last: Optional[Dict[str, Any]] = None
|
||||
while time.monotonic() < deadline:
|
||||
last = browser.snapshot()
|
||||
try:
|
||||
ref_by_name(last, "下载", "button")
|
||||
return last
|
||||
except ExportError:
|
||||
time.sleep(0.35)
|
||||
raise ExportError(f"export dialog did not become ready: {last}")
|
||||
|
||||
|
||||
def is_pptx(path: Path) -> bool:
|
||||
if not path.is_file() or path.name.endswith(".crdownload"):
|
||||
return False
|
||||
try:
|
||||
with zipfile.ZipFile(path) as archive:
|
||||
if "ppt/presentation.xml" not in archive.namelist():
|
||||
return False
|
||||
content_types = archive.read("[Content_Types].xml")
|
||||
return PPTX_CONTENT_TYPE.encode("utf-8") in content_types
|
||||
except (OSError, KeyError, zipfile.BadZipFile):
|
||||
return False
|
||||
|
||||
|
||||
def find_download(
|
||||
search_roots: Iterable[Path],
|
||||
timeout: float = 150.0,
|
||||
accept: Callable[[Path], bool] = is_pptx,
|
||||
) -> Path:
|
||||
deadline = time.monotonic() + timeout
|
||||
last_sizes: Dict[Path, int] = {}
|
||||
stable: Dict[Path, int] = {}
|
||||
while time.monotonic() < deadline:
|
||||
candidates: List[Path] = []
|
||||
for root in search_roots:
|
||||
if not root.exists():
|
||||
continue
|
||||
candidates.extend(path for path in root.rglob("*") if path.is_file())
|
||||
for path in sorted(candidates, key=lambda item: item.stat().st_mtime, reverse=True):
|
||||
try:
|
||||
size = path.stat().st_size
|
||||
except OSError:
|
||||
continue
|
||||
if size == last_sizes.get(path) and size > 0:
|
||||
stable[path] = stable.get(path, 0) + 1
|
||||
else:
|
||||
stable[path] = 0
|
||||
last_sizes[path] = size
|
||||
if stable[path] >= 1 and accept(path):
|
||||
return path
|
||||
time.sleep(0.5)
|
||||
visible = "\n ".join(str(path) for path in last_sizes) or "(none)"
|
||||
raise ExportError(f"timed out waiting for download; observed files:\n {visible}")
|
||||
|
||||
|
||||
def replace_transition(slide_xml: bytes, transition: str) -> bytes:
|
||||
text = slide_xml.decode("utf-8")
|
||||
pattern = re.compile(
|
||||
r"<p:transition\b[^>]*(?:/>|>.*?</p:transition>)", re.DOTALL
|
||||
)
|
||||
text = pattern.sub("", text)
|
||||
if transition == "none":
|
||||
return text.encode("utf-8")
|
||||
|
||||
# CT_Slide requires transition as a direct child after cSld/clrMapOvr and
|
||||
# before timing/extLst. Searching for the first p:extLst is incorrect:
|
||||
# shapes may contain their own nested extLst inside cSld, causing Office to
|
||||
# ignore a transition inserted there.
|
||||
color_map = re.search(
|
||||
r"<p:clrMapOvr\b[^>]*(?:/>|>.*?</p:clrMapOvr>)", text, re.DOTALL
|
||||
)
|
||||
common_slide = re.search(
|
||||
r"<p:cSld\b[^>]*(?:/>|>.*?</p:cSld>)", text, re.DOTALL
|
||||
)
|
||||
anchor = color_map or common_slide
|
||||
if anchor is None:
|
||||
raise ExportError("slide XML has no cSld/clrMapOvr insertion anchor")
|
||||
position = anchor.end()
|
||||
return (text[:position] + FADE_TRANSITION_XML + text[position:]).encode("utf-8")
|
||||
|
||||
|
||||
def root_child_names(slide_xml: bytes) -> List[str]:
|
||||
try:
|
||||
root = ET.fromstring(slide_xml)
|
||||
except ET.ParseError as exc:
|
||||
raise ExportError(f"invalid slide XML: {exc}") from exc
|
||||
return [child.tag.rsplit("}", 1)[-1] for child in root]
|
||||
|
||||
|
||||
def has_direct_fade_transition(slide_xml: bytes) -> bool:
|
||||
try:
|
||||
root = ET.fromstring(slide_xml)
|
||||
except ET.ParseError as exc:
|
||||
raise ExportError(f"invalid slide XML: {exc}") from exc
|
||||
transition = next(
|
||||
(child for child in root if child.tag.rsplit("}", 1)[-1] == "transition"),
|
||||
None,
|
||||
)
|
||||
if transition is None:
|
||||
return False
|
||||
return any(child.tag.rsplit("}", 1)[-1] == "fade" for child in transition)
|
||||
|
||||
|
||||
def validate_transition_order(slide_xml: bytes, transition: str) -> None:
|
||||
names = root_child_names(slide_xml)
|
||||
transition_indexes = [index for index, name in enumerate(names) if name == "transition"]
|
||||
if transition == "none":
|
||||
if transition_indexes:
|
||||
raise ExportError("transition=none left a root-level transition")
|
||||
return
|
||||
if len(transition_indexes) != 1 or not has_direct_fade_transition(slide_xml):
|
||||
raise ExportError("slide does not contain exactly one root-level fade transition")
|
||||
transition_index = transition_indexes[0]
|
||||
for required_before in ("cSld", "clrMapOvr"):
|
||||
if required_before in names and names.index(required_before) > transition_index:
|
||||
raise ExportError(f"{required_before} appears after transition")
|
||||
for required_after in ("timing", "extLst"):
|
||||
if required_after in names and names.index(required_after) < transition_index:
|
||||
raise ExportError(f"{required_after} appears before transition")
|
||||
|
||||
|
||||
def patch_transitions(pptx: Path, transition: str) -> int:
|
||||
temporary = pptx.with_name(f".{pptx.name}.{uuid.uuid4().hex}.tmp")
|
||||
slide_count = 0
|
||||
try:
|
||||
with zipfile.ZipFile(pptx, "r") as source, zipfile.ZipFile(temporary, "w") as target:
|
||||
target.comment = source.comment
|
||||
for info in source.infolist():
|
||||
data = source.read(info.filename)
|
||||
if re.fullmatch(r"ppt/slides/slide\d+\.xml", info.filename):
|
||||
data = replace_transition(data, transition)
|
||||
slide_count += 1
|
||||
target.writestr(info, data, compress_type=info.compress_type)
|
||||
if slide_count == 0:
|
||||
raise ExportError("exported PPTX contains no slide XML")
|
||||
temporary.replace(pptx)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
return slide_count
|
||||
|
||||
|
||||
def verify_output(pptx: Path, transition: str, expect_fonts: bool) -> Dict[str, Any]:
|
||||
if not is_pptx(pptx):
|
||||
raise ExportError(f"output is not a valid PPTX ZIP: {pptx}")
|
||||
with zipfile.ZipFile(pptx) as archive:
|
||||
broken = archive.testzip()
|
||||
if broken:
|
||||
raise ExportError(f"PPTX CRC check failed at: {broken}")
|
||||
slide_names = [
|
||||
name
|
||||
for name in archive.namelist()
|
||||
if re.fullmatch(r"ppt/slides/slide\d+\.xml", name)
|
||||
]
|
||||
slide_xml = {name: archive.read(name) for name in slide_names}
|
||||
for data in slide_xml.values():
|
||||
validate_transition_order(data, transition)
|
||||
transition_hits = sum(has_direct_fade_transition(data) for data in slide_xml.values())
|
||||
if transition == "fade" and transition_hits != len(slide_names):
|
||||
raise ExportError("fade transition was not written to every slide")
|
||||
fonts = [
|
||||
name
|
||||
for name in archive.namelist()
|
||||
if name.startswith("ppt/fonts/") and not name.endswith("/")
|
||||
]
|
||||
if expect_fonts and not fonts:
|
||||
log(
|
||||
"warning: embed-fonts was enabled, but the official writer produced no font part"
|
||||
)
|
||||
return {
|
||||
"slides": len(slide_names),
|
||||
"fadeTransitions": transition_hits,
|
||||
"fontParts": len(fonts),
|
||||
"bytes": pptx.stat().st_size,
|
||||
}
|
||||
|
||||
|
||||
def serve(directory: Path) -> Tuple[ThreadingHTTPServer, threading.Thread, str]:
|
||||
handler = lambda *args, **kwargs: QuietHandler( # noqa: E731
|
||||
*args, directory=str(directory), **kwargs
|
||||
)
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
host, port = server.server_address
|
||||
return server, thread, f"http://{host}:{port}/export_host.html"
|
||||
|
||||
|
||||
def export_pptx(
|
||||
source: Path,
|
||||
output: Path,
|
||||
transition: str,
|
||||
embed_fonts: bool,
|
||||
keep_download: bool = False,
|
||||
force: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
manifest = find_manifest(source)
|
||||
payload = build_payload(manifest)
|
||||
output = output.expanduser().resolve()
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
if output.exists() and not force:
|
||||
raise ExportError(f"output already exists (pass --force to replace it): {output}")
|
||||
agent_browser = ensure_agent_browser()
|
||||
|
||||
log(f"manifest: {manifest}")
|
||||
log(
|
||||
f"defaults: transition={transition}, embed_fonts={'on' if embed_fonts else 'off'}"
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="open-kimi-ppt-export-") as temp_name:
|
||||
temp_dir = Path(temp_name)
|
||||
download_dir = temp_dir / "downloads"
|
||||
download_dir.mkdir()
|
||||
shutil.copy2(HOST_TEMPLATE, temp_dir / HOST_TEMPLATE.name)
|
||||
(temp_dir / "payload.json").write_text(
|
||||
json.dumps(payload, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
server, thread, url = serve(temp_dir)
|
||||
session = f"open-kimi-ppt-export-{os.getpid()}-{uuid.uuid4().hex[:8]}"
|
||||
browser = BrowserSession(agent_browser, session, temp_dir, download_dir)
|
||||
try:
|
||||
log("opening the public Kimi slide editor")
|
||||
browser.open(url)
|
||||
browser.run(
|
||||
[
|
||||
"wait",
|
||||
"--fn",
|
||||
'document.documentElement.dataset.deckStatus === "ready"',
|
||||
],
|
||||
timeout=120,
|
||||
)
|
||||
browser.run(["set", "viewport", "1280", "720"])
|
||||
snapshot = browser.snapshot()
|
||||
export_ref = ref_by_name(snapshot, "导出", "button")
|
||||
browser.run(["click", f"@{export_ref}"])
|
||||
dialog = wait_for_export_dialog(browser)
|
||||
|
||||
state = switch_state(dialog)
|
||||
if state is not None:
|
||||
switch_ref, checked, disabled = state
|
||||
if disabled and checked != embed_fonts:
|
||||
log("warning: the official font switch is disabled for this deck")
|
||||
elif checked != embed_fonts:
|
||||
browser.run(["click", f"@{switch_ref}"])
|
||||
dialog = wait_for_export_dialog(browser)
|
||||
elif embed_fonts:
|
||||
log("warning: the official export dialog exposed no font switch")
|
||||
|
||||
download_ref = ref_by_name(dialog, "下载", "button")
|
||||
log("generating PPTX in the browser")
|
||||
result = browser.run(
|
||||
["download", f"@{download_ref}", str(temp_dir / "browser-output.pptx")],
|
||||
timeout=180,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
log("download capture reported a timeout; checking browser output files")
|
||||
downloaded = find_download((download_dir, temp_dir), timeout=90)
|
||||
shutil.copy2(downloaded, output)
|
||||
if keep_download:
|
||||
debug_copy = output.with_name(f"{output.stem}.browser-raw.pptx")
|
||||
if debug_copy.exists() and not force:
|
||||
raise ExportError(
|
||||
f"raw debug output already exists (pass --force): {debug_copy}"
|
||||
)
|
||||
shutil.copy2(downloaded, debug_copy)
|
||||
finally:
|
||||
browser.close()
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=2)
|
||||
|
||||
slide_count = patch_transitions(output, transition)
|
||||
summary = verify_output(output, transition, embed_fonts)
|
||||
summary["transitionPatchedSlides"] = slide_count
|
||||
summary["output"] = str(output)
|
||||
return summary
|
||||
|
||||
|
||||
def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
"Export a PPTD project to PPTX using Kimi's public browser-side writer. "
|
||||
"Defaults: fade transition and embedded fonts."
|
||||
)
|
||||
)
|
||||
parser.add_argument("input", type=Path, help=".pptd manifest or project directory")
|
||||
parser.add_argument("--output", "-o", type=Path, help="output .pptx path")
|
||||
parser.add_argument(
|
||||
"--transition",
|
||||
choices=("fade", "none"),
|
||||
default="fade",
|
||||
help="slide transition written to every slide (default: fade)",
|
||||
)
|
||||
font_group = parser.add_mutually_exclusive_group()
|
||||
font_group.add_argument(
|
||||
"--embed-fonts",
|
||||
dest="embed_fonts",
|
||||
action="store_true",
|
||||
default=True,
|
||||
help="embed fonts when available (default)",
|
||||
)
|
||||
font_group.add_argument(
|
||||
"--no-embed-fonts",
|
||||
dest="embed_fonts",
|
||||
action="store_false",
|
||||
help="disable font embedding",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--keep-browser-raw",
|
||||
action="store_true",
|
||||
help="also keep the unpatched browser download beside the output",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="replace an existing output file",
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: Optional[Sequence[str]] = None) -> int:
|
||||
args = parse_args(argv)
|
||||
try:
|
||||
manifest = find_manifest(args.input)
|
||||
output = args.output or manifest.with_suffix(".pptx")
|
||||
summary = export_pptx(
|
||||
args.input,
|
||||
output,
|
||||
args.transition,
|
||||
args.embed_fonts,
|
||||
args.keep_browser_raw,
|
||||
args.force,
|
||||
)
|
||||
except (ExportError, OSError, subprocess.SubprocessError) as exc:
|
||||
print(f"open-kimi-ppt export failed: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
print(json.dumps(summary, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user