Office Add-in Integration
The Office Add-in integration in pptcraft relies on a dynamic manifest generation system and a JavaScript bridge to communicate with the PowerPoint host. The manifest is rendered from a Jinja2 template with a persistent UUID to ensure PowerPoint pins the add-in state correctly without requiring re-sideloading on every restart 1. The JavaScript bridge, loaded into the taskpane, exposes methods to the backend and UI controller for slide manipulation, ensuring all interactions use opaque Slide.id strings rather than shifting indices 2.
Manifest Generation
Section titled “Manifest Generation”The manifest is generated by src/ppt_craft/manifest.py, which renders src/ppt_craft/templates/manifest.xml.j2 1. A critical design choice is the persistence of the add-in’s Id under ~/.local/share/ppt-craft/install.id. Because PowerPoint pins state to the manifest Id, rotating it would force re-sideloading. The get_or_create_install_id() function ensures the UUID is created once and reused.
JavaScript Bridge
Section titled “JavaScript Bridge”The bridge is implemented in src/ppt_craft/static/taskpane/officejs-bridge.js and loaded into the taskpane via src/ppt_craft/static/taskpane/index.html 3. The taskpane loads the hosted CDN version of office.js (https://appsforoffice.microsoft.com/lib/1/hosted/office.js). The bridge exposes a window.bridge object containing methods for slide operations 2.
The ready() function initializes the bridge, checking for Office.HostType.PowerPoint and PowerPointApi 1.8 support. The snapshotDeck() function retrieves the full presentation as a base64 string using getFileAsync with 4MB slices. Slide enumeration is handled by listSlides(), which returns an array of {id, title} objects using PowerPoint.run.
Slide insertion and replacement are managed by insertOrReplaceSlide(), which uses ctx.presentation.insertSlidesFromBase64(). If mode is “replace”, it deletes the target slide by ID after insertion. The openNewFromBase64() function creates a new presentation if the base64 string is under 71,680,000 characters; otherwise, it falls back to a client-side <a download> blob.
Taskpane Integration
Section titled “Taskpane Integration”The taskpane UI is defined in src/ppt_craft/static/taskpane/index.html and controlled by src/ppt_craft/static/taskpane/app.js 3. The app.js module connects to the backend via WebSocket and uses window.bridge for all PowerPoint interactions 4.
The bootstrap() function in app.js calls window.bridge.ready() to ensure the host is PowerPoint and the API is supported. It then establishes a WebSocket connection to /api/ws/{SESSION_ID}. The onWsMessage() handler processes server events: slide_ready triggers window.bridge.insertOrReplaceSlide(), and deck_complete updates the UI status.
Manual actions in the UI, such as “List slides” or “Insert stub slide,” are wired to app.js event listeners that call corresponding window.bridge methods. For example, btn-insert-stub fetches a slide from the backend API and uses window.bridge.insertOrReplaceSlide() to add it to the presentation. The chat input sends user intent to the server via WebSocket, which then processes the request and pushes updates back to the taskpane.
"""OfficeApp manifest emitter.
Renders templates/manifest.xml.j2 with one stable per-install UUID. The UUID
is persisted under ~/.local/share/ppt-craft/install.id so re-emitting the
manifest produces a byte-identical Id (PowerPoint pins state to the
manifest Id; rotating it would force re-sideloading).
"""
from __future__ import annotations
import uuid
from pathlib import Path
from jinja2 import Environment, FileSystemLoader, select_autoescape
from ppt_craft import __version__ as PPT_VERSION
INSTALL_DIR = Path.home() / ".local" / "share" / "ppt-craft"
INSTALL_ID_PATH = INSTALL_DIR / "install.id"
TEMPLATES_DIR = Path(__file__).parent / "templates"
def get_or_create_install_id() -> str:
"""Return the persisted UUID, creating one on first call."""
INSTALL_DIR.mkdir(parents=True, exist_ok=True)
INSTALL_DIR.chmod(0o700)
if INSTALL_ID_PATH.exists():
return INSTALL_ID_PATH.read_text().strip()
new_id = str(uuid.uuid4())
INSTALL_ID_PATH.write_text(new_id + "\n")
INSTALL_ID_PATH.chmod(0o600)
return new_id
def render_manifest(*, host: str, port: int, scheme: str = "https", install_id: str | None = None) -> str:
"""Render the OfficeApp XML for `https://host:port`."""
base_url = f"{scheme}://{host}:{port}"
env = Environment(
loader=FileSystemLoader(str(TEMPLATES_DIR)),
autoescape=select_autoescape(["xml"]),
// Office.js bridge - Slide.id-keyed surface for the chat layer.
// Every method takes opaque Slide.id strings (never indices) and base64
// PPTXes (never raw slide XML). Mirrors the contract in plan §Office.js
// ↔ OOXML division of labour.
//
// The 71,680,000-char ceiling on PowerPoint.createPresentation is the
// documented hard limit; on overflow we fall back to a client-side
// `<a download>` save in the user's own browser (per Codex iter-3 Q1).
const CREATE_PRES_MAX_CHARS = 71_680_000;
let _ready = false;
let _hostInfo = null;
function ready() {
if (_ready) return Promise.resolve(_hostInfo);
return new Promise((resolve, reject) => {
if (typeof Office === "undefined") {
reject(new Error("Office.js not loaded - open this page from inside PowerPoint."));
return;
}
Office.onReady((info) => {
_hostInfo = info;
if (info.host !== Office.HostType.PowerPoint) {
reject(new Error(`unsupported host: ${info.host} (this add-in is PowerPoint-only)`));
return;
}
const supported = Office.context.requirements.isSetSupported("PowerPointApi", "1.8");
if (!supported) {
reject(new Error("PowerPointApi 1.8 not supported in this host - please upgrade."));
return;
}
_ready = true;
resolve(info);
});
});
}
function hostInfo() {
return _hostInfo;
<!doctype html>
<html lang="en-GB">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Claude (local Qwen)</title>
<meta name="robots" content="noindex, nofollow">
<link rel="stylesheet" href="/taskpane/styles.css">
<!--
Bootstrap rule (per plan §P6): load ONLY the hosted CDN office.js.
Office.js auto-detects the host/platform at runtime; we never include
a host-specific shim like powerpoint-mac-16.00.js (would force a
cross-host inconsistency, per Codex iteration 3 P1 finding).
-->
<script src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
</head>
<body>
<header>
<div class="brand">Claude <span class="muted">· local Qwen</span></div>
<div class="spacer"></div>
<button id="btn-clear" class="ghost" type="button">New chat</button>
</header>
<main>
<section id="status" class="status">
<span id="status-dot" class="dot pending"></span>
<span id="status-text">initialising…</span>
</section>
<section id="actions" class="actions" hidden>
<button id="btn-list" type="button">List slides</button>
<button id="btn-insert-stub" type="button">Insert stub slide</button>
<button id="btn-replace-stub" type="button">Replace selected slide</button>
<button id="btn-snapshot-image" type="button">Get image of selected</button>
</section>
<section id="transcript" class="transcript" aria-live="polite"></section>
</main>
<footer>
// Taskpane chat + actions. Keeps Office.js out of the chat layer; all
// host I/O goes through window.bridge (officejs-bridge.js).
//
// Chat flow (P4 wired):
// user types → ws.send({kind:"draft", intent, theme, slide_count})
// server emits storyline_ready → app shows storyline preview
// server emits slide_ready (one per slide) → bridge.insertOrReplaceSlide
// server emits deck_complete → status "done"
const $ = (id) => document.getElementById(id);
const TOKEN_KEY = "sddc-ppt-token";
const SESSION_ID = `sess-${crypto.randomUUID().slice(0, 8)}`;
let ws = null;
let currentTheme = "corporate"; // P8 will surface a picker
let authToken = new URLSearchParams(location.search).get("token")
|| localStorage.getItem(TOKEN_KEY)
|| "";
if (authToken) localStorage.setItem(TOKEN_KEY, authToken);
function setStatus(text, kind /* "ok" | "err" | "pending" */) {
$("status-text").textContent = text;
$("status-dot").className = `dot ${kind}`;
}
function appendChat(role, body) {
const li = document.createElement("div");
li.className = `msg msg-${role}`;
li.textContent = body;
$("transcript").appendChild(li);
li.scrollIntoView({ block: "nearest" });
}
async function api(path, init = {}) {
const headers = { "content-type": "application/json", ...init.headers };
if (authToken) headers["authorization"] = `Bearer ${authToken}`;
const resp = await fetch(path, { ...init, headers });
if (!resp.ok) {
throw new Error(`${resp.status} ${resp.statusText}: ${await resp.text()}`);
}