← Files PresentonARCHIVED FILE

skills/presenton/scripts/presenton_artifacts.py

21.1 KB · Oct 2, 2026 · 00:32 UTC

↓ Download file

#!/usr/bin/env python3
"""Prepare Presenton assets, export HTML, and return download and preview URLs."""

from __future__ import annotations

import argparse
import json
import re
import secrets
import shutil
import sys
import tempfile
import threading
import urllib.error
import urllib.request
from collections.abc import Iterator
from contextlib import contextmanager
from html import unescape
from html.parser import HTMLParser
from pathlib import Path
from typing import Any
from urllib.parse import parse_qs, unquote_plus, urlencode, urlparse, urlsplit

from validate_html import validate_html


DEFAULT_API_BASE = "https://api.presenton.ai"
SEARCH_TIMEOUT_SECONDS = 60.0
EXPORT_TIMEOUT_SECONDS = 300.0
PUBLIC_IMAGE_UPLOAD_MAX_BYTES = 10 * 1024 * 1024
TEMP_MARKER_NAME = ".presenton-temp"
TEMP_MARKER_CONTENT = "presenton-owned\n"
PUBLIC_IMAGE_CONTENT_TYPES = {
    ".jpeg": "image/jpeg",
    ".jpg": "image/jpeg",
    ".png": "image/png",
    ".webp": "image/webp",
}
ICON_TYPES = ("bold", "duotone", "fill", "light", "regular", "thin")

GENERIC_FONT_FAMILIES = {
    "cursive",
    "fantasy",
    "fangsong",
    "inherit",
    "initial",
    "math",
    "monospace",
    "revert",
    "sans-serif",
    "serif",
    "system-ui",
    "ui-monospace",
    "ui-rounded",
    "ui-sans-serif",
    "ui-serif",
    "unset",
}
TAILWIND_FONT_STACKS = {
    "font-sans": "ui-sans-serif, system-ui, sans-serif",
    "font-serif": "ui-serif, Georgia, Cambria, Times New Roman, Times, serif",
    "font-mono": (
        "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "
        "Liberation Mono, Courier New, monospace"
    ),
}


class PresentonError(RuntimeError):
    pass


class FontUsageParser(HTMLParser):
    """Collect font utilities, inline declarations, and web-font stylesheets."""

    def __init__(self) -> None:
        super().__init__(convert_charrefs=True)
        self.tailwind_fonts: list[tuple[str, str]] = []
        self.web_font_urls: list[str] = []

    def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
        attributes = dict(attrs)
        classes = (attributes.get("class") or "").split()
        for class_name in classes:
            if class_name in TAILWIND_FONT_STACKS:
                self.tailwind_fonts.append((class_name, TAILWIND_FONT_STACKS[class_name]))
            elif class_name.startswith("font-[") and class_name.endswith("]"):
                family = class_name[6:-1].strip("'\"").replace("_", " ")
                if family:
                    self.tailwind_fonts.append(("arbitrary font utility", family))

        href = attributes.get("href") or attributes.get("src")
        if href:
            parsed = urlsplit(unescape(href))
            if parsed.scheme == "https" and "family" in parse_qs(parsed.query):
                self.web_font_urls.append(unescape(href))


def split_font_stack(value: str) -> list[str]:
    """Split a CSS font-family value while preserving quoted family names."""

    parts = re.split(r",\s*(?![^()]*\))", value)
    return [part.strip().strip("'\"") for part in parts if part.strip()]


def is_installable_font_name(name: str) -> bool:
    normalized = re.sub(r"\s+", " ", name.strip()).lower()
    return bool(normalized) and normalized not in GENERIC_FONT_FAMILIES and not normalized.startswith(
        ("var(", "--")
    )


def collect_font_usage(html: str) -> list[tuple[str, list[str]]]:
    """Return ordered font names/stacks and the places that declare them."""

    records: dict[str, tuple[str, list[str]]] = {}

    def add(name: str, source: str) -> None:
        clean_name = re.sub(r"\s+", " ", unescape(name).strip())
        if not is_installable_font_name(clean_name):
            return
        key = clean_name.casefold()
        if key not in records:
            records[key] = (clean_name, [])
        if source not in records[key][1]:
            records[key][1].append(source)

    parser = FontUsageParser()
    parser.feed(html)
    parser.close()

    for match in re.finditer(r"font-family\s*:\s*([^;}\n]+)", html, re.IGNORECASE):
        stack = re.sub(r"\s+", " ", match.group(1).strip())
        for family in split_font_stack(stack):
            add(family, f"CSS font-family stack: {stack}")

    # Arbitrary Tailwind font-family utilities are explicit enough to be useful
    # even when Tailwind's generated CSS is not present in the source HTML.
    for utility, value in parser.tailwind_fonts:
        if utility == "arbitrary font utility":
            add(value, f"Tailwind {utility}: font-[{value}]")
        else:
            add(f"{utility} stack", f"Tailwind {utility}: {value}")

    web_font_urls = list(parser.web_font_urls)
    web_font_urls.extend(re.findall(r"https://[^\s\"')>]+", html, re.IGNORECASE))
    for source_url in dict.fromkeys(web_font_urls):
        parsed_url = urlsplit(source_url)
        query = parse_qs(parsed_url.query)
        for encoded_family in query.get("family", []):
            family_spec = unquote_plus(encoded_family)
            family_name = family_spec.split(":", 1)[0].replace("+", " ").strip()
            add(family_name, f"Web font stylesheet ({parsed_url.netloc}): {source_url}")

    return list(records.values())


def print_font_usage(html: str) -> None:
    records = collect_font_usage(html)
    print("Fonts used by the final HTML:")
    if not records:
        print("- No explicit installable font family was found; browser/exporter defaults may apply.")
        return
    for name, sources in records:
        print(f"- {name}")
        for source in sources:
            print(f"  Source: {source}")


def print_status(message: str) -> None:
    print(f"[presenton] {message}", file=sys.stderr, flush=True)


def print_response_message(response: Any) -> None:
    """Expose optional user-facing messages without changing stdout contracts."""

    message = response.get("message") if isinstance(response, dict) else None
    if isinstance(message, str) and message:
        print_status(f"API message: {message}")


def valid_web_url(value: Any) -> str | None:
    """Return a valid HTTP(S) URL string, or None."""

    if not isinstance(value, str):
        return None
    parsed_url = urlparse(value)
    if parsed_url.scheme not in {"http", "https"} or not parsed_url.netloc:
        return None
    return value


@contextmanager
def status_heartbeat(message: str, interval_seconds: float = 10.0) -> Iterator[None]:
    print_status(message)
    stopped = threading.Event()

    def emit_heartbeat() -> None:
        while not stopped.wait(interval_seconds):
            print_status(f"{message} (still working)")

    thread = threading.Thread(target=emit_heartbeat, daemon=True)
    thread.start()
    try:
        yield
    finally:
        stopped.set()
        thread.join()


def request_json(
    endpoint: str,
    payload: dict[str, Any],
    timeout: float = 300.0,
) -> Any:
    headers = {
        "Content-Type": "application/json",
        "Accept": "application/json",
        "User-Agent": "presenton-skill/1",
    }
    request = urllib.request.Request(
        f"{DEFAULT_API_BASE}{endpoint}",
        data=json.dumps(payload).encode("utf-8"),
        headers=headers,
        method="POST",
    )
    return send_json_request(request, endpoint, timeout)


def send_json_request(
    request: urllib.request.Request,
    endpoint: str,
    timeout: float,
) -> Any:
    """Send a prepared request and decode its JSON response consistently."""

    try:
        with status_heartbeat(f"Waiting for {endpoint}"):
            with urllib.request.urlopen(request, timeout=timeout) as response:
                result = json.loads(response.read().decode("utf-8"))
        print_status(f"Received response from {endpoint}")
        return result
    except urllib.error.HTTPError as exc:
        body = exc.read().decode("utf-8", errors="replace")
        try:
            error_payload = json.loads(body)
            detail = (
                error_payload.get("detail") or error_payload.get("error") or body
                if isinstance(error_payload, dict)
                else body
            )
        except json.JSONDecodeError:
            detail = body
        raise PresentonError(f"Presenton API returned HTTP {exc.code}: {detail}") from exc
    except urllib.error.URLError as exc:
        raise PresentonError(f"Could not reach Presenton API: {exc.reason}") from exc
    except json.JSONDecodeError as exc:
        raise PresentonError("Presenton API returned invalid JSON") from exc


def request_get_json(
    endpoint: str,
    params: dict[str, Any],
    timeout: float = 60.0,
) -> Any:
    query = urlencode({key: value for key, value in params.items() if value is not None})
    request = urllib.request.Request(
        f"{DEFAULT_API_BASE}{endpoint}?{query}",
        headers={
            "Accept": "application/json",
            "User-Agent": "presenton-skill/1",
        },
        method="GET",
    )
    return send_json_request(request, endpoint, timeout)


def request_public_image_upload(image_path: Path, timeout: float = 60.0) -> Any:
    try:
        resolved_path = image_path.expanduser().resolve(strict=True)
        if not resolved_path.is_file():
            raise PresentonError("Image path is not a file")
        content_type = PUBLIC_IMAGE_CONTENT_TYPES.get(resolved_path.suffix.lower())
        if content_type is None:
            raise PresentonError("Image must be a PNG, JPEG, or WebP file")
        if resolved_path.stat().st_size > PUBLIC_IMAGE_UPLOAD_MAX_BYTES:
            raise PresentonError("Image exceeds the 10 MB public upload limit")
        image_bytes = resolved_path.read_bytes()
    except OSError as exc:
        raise PresentonError(f"Could not read image file: {exc}") from exc

    boundary = f"presenton-{secrets.token_hex(16)}"
    filename = resolved_path.name.replace('"', "_").replace("\\", "_")
    body = (
        f"--{boundary}\r\n"
        f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n'
        f"Content-Type: {content_type}\r\n\r\n"
    ).encode("utf-8") + image_bytes + f"\r\n--{boundary}--\r\n".encode("ascii")
    endpoint = "/api/v3/images/upload/public"
    request = urllib.request.Request(
        f"{DEFAULT_API_BASE}{endpoint}",
        data=body,
        headers={
            "Content-Type": f"multipart/form-data; boundary={boundary}",
            "Accept": "application/json",
            "User-Agent": "presenton-skill/1",
        },
        method="POST",
    )
    return send_json_request(request, endpoint, timeout)


def get_temp_root() -> Path:
    return Path(tempfile.gettempdir()).resolve(strict=True)


def resolve_owned_temp_dir(path: Path) -> Path:
    if path.expanduser().is_symlink():
        raise PresentonError("Refusing to clean up a symbolic link")
    try:
        resolved = path.expanduser().resolve(strict=True)
        temp_root = get_temp_root()
        resolved.relative_to(temp_root)
    except (OSError, ValueError) as exc:
        raise PresentonError("Temporary directory is outside the OS temporary root") from exc
    if resolved == temp_root:
        raise PresentonError("Refusing to clean up the OS temporary root")
    if resolved.parent != temp_root or not resolved.name.startswith("presenton-"):
        raise PresentonError("Temporary directory was not created by Presenton")
    if not resolved.is_dir():
        raise PresentonError("Presenton temporary path is not a directory")
    marker = resolved / TEMP_MARKER_NAME
    if marker.is_symlink() or not marker.is_file():
        raise PresentonError("Presenton temporary ownership marker is missing")
    try:
        marker_content = marker.read_text(encoding="utf-8")
    except OSError as exc:
        raise PresentonError("Could not read Presenton temporary ownership marker") from exc
    if marker_content != TEMP_MARKER_CONTENT:
        raise PresentonError("Presenton temporary ownership marker is invalid")
    return resolved


def command_create_temp(_: argparse.Namespace) -> int:
    temp_dir = Path(tempfile.mkdtemp(prefix="presenton-")).resolve(strict=True)
    (temp_dir / TEMP_MARKER_NAME).write_text(TEMP_MARKER_CONTENT, encoding="utf-8")
    print_status(f"Created temporary workspace {temp_dir}")
    print(temp_dir)
    return 0


def command_cleanup_temp(args: argparse.Namespace) -> int:
    temp_dir = resolve_owned_temp_dir(args.path)
    shutil.rmtree(temp_dir)
    print_status(f"Cleaned temporary workspace {temp_dir}")
    return 0


def command_search(args: argparse.Namespace) -> int:
    print_status("Searching for matching presentation designs")
    response = request_json(
        "/api/v3/designs/search",
        {"query": args.query, "n": 4},
        timeout=SEARCH_TIMEOUT_SECONDS,
    )
    print_response_message(response)
    results = response.get("designs") if isinstance(response, dict) else None
    if not isinstance(results, list):
        raise PresentonError("Design search returned an unexpected response")
    print_status(f"Found {len(results)} design option(s)")
    for index, result in enumerate(results, start=1):
        print(f"{index}. {result.get('title', 'Untitled')} (id={result.get('id', '?')})")
        print(f"   {result.get('description', '')}")
    return 0


def command_upload_image(args: argparse.Namespace) -> int:
    print_status(f"Uploading presentation image {args.file}")
    response = request_public_image_upload(args.file, timeout=SEARCH_TIMEOUT_SECONDS)
    print_response_message(response)
    image_url = valid_web_url(response.get("url")) if isinstance(response, dict) else None
    if image_url is None:
        raise PresentonError(
            "Image upload response did not contain a valid HTTP or HTTPS URL"
        )
    print_status("Uploaded presentation image URL is ready")
    print(image_url)
    return 0


def command_search_icons(args: argparse.Namespace) -> int:
    query = args.query.strip()
    if not query:
        raise PresentonError("Icon search query must not be empty")
    if args.limit <= 0:
        raise PresentonError("Icon search limit must be a positive integer")
    print_status(f"Searching presentation icons for {query!r}")
    response = request_get_json(
        "/api/v3/icons/search",
        {
            "query": query,
            "limit": args.limit,
            "icon_type": args.icon_type,
        },
        timeout=SEARCH_TIMEOUT_SECONDS,
    )
    if not isinstance(response, list):
        raise PresentonError("Icon search returned an unexpected response")
    icon_urls = [valid_web_url(value) for value in response]
    if not icon_urls or any(value is None for value in icon_urls):
        raise PresentonError("Icon search did not return valid HTTP or HTTPS URLs")
    print_status(f"Found {len(icon_urls)} icon option(s)")
    for icon_url in icon_urls:
        print(icon_url)
    return 0


def command_export(args: argparse.Namespace) -> int:
    try:
        html_path = args.html.expanduser().resolve(strict=True)
        temp_root = get_temp_root()
        relative_path = html_path.relative_to(temp_root)
        if len(relative_path.parts) < 2 or not relative_path.parts[0].startswith(
            "presenton-"
        ):
            raise ValueError
        owned_temp_dir = temp_root / relative_path.parts[0]
        resolve_owned_temp_dir(owned_temp_dir)
    except (OSError, ValueError) as exc:
        raise PresentonError(
            "Presentation HTML must be inside a directory created by create-temp"
        ) from exc

    print_status(f"Reading temporary presentation HTML from {html_path}")
    try:
        html = html_path.read_text(encoding="utf-8")
    except OSError as exc:
        raise PresentonError(f"Could not read HTML file: {exc}") from exc
    print_status("Validating HTML, Tailwind, and Chart.js requirements")
    validation_errors = validate_html(html)
    if validation_errors:
        formatted = "\n".join(f"- {error}" for error in validation_errors)
        raise PresentonError(f"HTML preflight failed:\n{formatted}")

    print_status("HTML validation passed")
    payload: dict[str, Any] = {"html": html, "format": args.format}
    if args.title:
        payload["title"] = args.title
    if args.design_id is not None:
        payload["design_id"] = args.design_id
    print_status(f"Submitting {args.format.upper()} export")
    response = request_json(
        "/api/v3/export/html-to-any",
        payload,
        timeout=EXPORT_TIMEOUT_SECONDS,
    )
    print_response_message(response)
    url = valid_web_url(response.get("url")) if isinstance(response, dict) else None
    if url is None:
        raise PresentonError(
            "Export response did not contain a valid HTTP or HTTPS URL"
        )
    creation_id = response.get("id") if isinstance(response, dict) else None
    if type(creation_id) is not int or creation_id <= 0:
        raise PresentonError("Export response did not contain a valid creation id")
    print_status(f"{args.format.upper()} export URL is ready")
    if getattr(args, "json", False):
        print(json.dumps({"id": creation_id, "url": url}))
    else:
        print(url)
    return 0


def command_create_preview(args: argparse.Namespace) -> int:
    if type(args.id) is not int or args.id <= 0:
        raise PresentonError("Preview creation id must be a positive integer")
    print_status("Creating a shareable presentation preview")
    response = request_json(
        "/api/v3/export/html-to-any/create-preview",
        {"id": args.id},
        timeout=SEARCH_TIMEOUT_SECONDS,
    )
    print_response_message(response)
    preview_url = (
        valid_web_url(response.get("url"))
        if isinstance(response, dict)
        else None
    )
    if preview_url is None:
        raise PresentonError(
            "Preview response did not contain a valid HTTP or HTTPS URL"
        )
    print_status("Shareable presentation preview URL is ready")
    print(preview_url)
    return 0


def command_list_fonts(args: argparse.Namespace) -> int:
    try:
        html_path = args.html.expanduser().resolve(strict=True)
        temp_root = get_temp_root()
        relative_path = html_path.relative_to(temp_root)
        if len(relative_path.parts) < 2 or not relative_path.parts[0].startswith("presenton-"):
            raise ValueError
        owned_temp_dir = temp_root / relative_path.parts[0]
        resolve_owned_temp_dir(owned_temp_dir)
        html = html_path.read_text(encoding="utf-8")
    except (OSError, ValueError) as exc:
        raise PresentonError(
            "Presentation HTML must be inside a directory created by create-temp"
        ) from exc
    print_font_usage(html)
    return 0


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description=__doc__)
    subparsers = parser.add_subparsers(dest="command", required=True)

    create_temp = subparsers.add_parser(
        "create-temp", help="Create a private Presenton temporary workspace"
    )
    create_temp.set_defaults(handler=command_create_temp)

    cleanup_temp = subparsers.add_parser(
        "cleanup-temp", help="Remove a Presenton temporary workspace"
    )
    cleanup_temp.add_argument("--path", type=Path, required=True)
    cleanup_temp.set_defaults(handler=command_cleanup_temp)

    search = subparsers.add_parser("search-designs", help="Search Presenton visual designs")
    search.add_argument("--query", required=True)
    search.set_defaults(handler=command_search)

    upload_image = subparsers.add_parser(
        "upload-image",
        help="Upload a public image for use in presentation HTML",
    )
    upload_image.add_argument("--file", type=Path, required=True)
    upload_image.set_defaults(handler=command_upload_image)

    search_icons = subparsers.add_parser(
        "search-icons", help="Search public icon URLs for presentation HTML"
    )
    search_icons.add_argument("--query", required=True)
    search_icons.add_argument("--limit", type=int, default=5)
    search_icons.add_argument("--icon-type", choices=ICON_TYPES, default="bold")
    search_icons.set_defaults(handler=command_search_icons)

    export = subparsers.add_parser("export", help="Export compatible HTML")
    export.add_argument("--html", type=Path, required=True)
    export.add_argument("--format", choices=("pptx", "pdf", "png"), required=True)
    export.add_argument("--title")
    export.add_argument(
        "--json",
        action="store_true",
        help="Print a JSON object containing the export creation id and URL",
    )
    export.add_argument(
        "--design-id",
        type=int,
        help="ID of the selected design returned by search-designs",
    )
    export.set_defaults(handler=command_export)

    create_preview = subparsers.add_parser(
        "create-preview",
        help="Create a shareable preview URL from an export creation id",
    )
    create_preview.add_argument("--id", type=int, required=True)
    create_preview.set_defaults(handler=command_create_preview)

    list_fonts = subparsers.add_parser(
        "list-fonts", help="List fonts declared by a presentation HTML file"
    )
    list_fonts.add_argument("--html", type=Path, required=True)
    list_fonts.set_defaults(handler=command_list_fonts)

    return parser


def main() -> int:
    args = build_parser().parse_args()
    try:
        return args.handler(args)
    except (PresentonError, FileNotFoundError, PermissionError) as exc:
        print(f"ERROR: {exc}", file=sys.stderr)
        return 1


if __name__ == "__main__":
    raise SystemExit(main())

SHA-256: fe97d0763ef2e419d4127607a85308c1f82154af6a70ae7f2026c93797153b89