Initial Slide Factory MVP scaffold

This commit is contained in:
Codex 2026-06-08 21:40:57 -07:00
commit eb0a17f75e
31 changed files with 4831 additions and 0 deletions

9
.env.example Normal file
View file

@ -0,0 +1,9 @@
SLIDE_FACTORY_PORT=3025
SLIDE_FACTORY_OUTPUT_DIR=outputs
# Future Model Service Gateway integration.
# Use a scoped runtime projection or env var name only; do not commit token values.
MODEL_SERVICE_GATEWAY_URL=
MODEL_SERVICE_APP_ID=slide-factory
MODEL_SERVICE_GATEWAY_TOKEN=

9
.gitignore vendored Normal file
View file

@ -0,0 +1,9 @@
node_modules/
dist/
outputs/
.env
.env.*
!.env.example
.DS_Store
*.log

58
README.md Normal file
View file

@ -0,0 +1,58 @@
# Slide Factory
Slide Factory turns messy business content into brand-consistent slide decks.
The first build is intentionally local-first: a CLI, an API, and a small web UI
share the same deck spec, style packs, and PPTX renderer. The same core contract
can later run behind `dev.slides.scottfelten.com` and `slides.scottfelten.com`.
## MVP Contract
```text
source content
-> content extraction
-> deck planning
-> validated DeckSpec
-> brand/style pack
-> PPTX renderer
-> saved artifacts
```
The LLM layer should produce structured thinking, not freehand slide design.
Renderers consume a validated `DeckSpec` and a `StylePack`.
## Quick Start
```bash
npm install
npm run sample
```
Outputs are written to:
```text
outputs/sample-incorta.pptx
outputs/sample-incorta.deck.json
```
Run the local API:
```bash
npm run dev:api
```
Run the web shell:
```bash
npm run dev:web
```
## AgentPlane Notes
- Implementation truth should live in Forgejo.
- Runtime secrets must come from scoped projections or Model Service Gateway.
- Do not commit raw source data, tokens, refresh tokens, or customer documents.
- Google Slides generation should reuse the existing AgentPlane guidance:
copy templates, inventory native containers, fill existing object IDs, and
render thumbnails/contact sheets before handoff.

25
apps/api/package.json Normal file
View file

@ -0,0 +1,25 @@
{
"name": "@slide-factory/api",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "tsc -p tsconfig.json",
"dev": "tsx src/server.ts"
},
"dependencies": {
"@slide-factory/core": "0.1.0",
"@slide-factory/render-pptx": "0.1.0",
"cors": "^2.8.5",
"express": "^4.19.2",
"nanoid": "^5.0.7",
"tsx": "^4.16.2",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"typescript": "^5.5.4"
}
}

96
apps/api/src/server.ts Normal file
View file

@ -0,0 +1,96 @@
import { access, mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import cors from "cors";
import express from "express";
import { nanoid } from "nanoid";
import { z } from "zod";
import { loadStylePack, planDeckFromSource } from "@slide-factory/core";
import { renderPptx } from "@slide-factory/render-pptx";
const CreateDeckRequestSchema = z.object({
style: z.string().default("incorta"),
instructions: z.string().optional(),
audience: z.string().default("executives"),
input: z.object({
type: z.enum(["text", "markdown"]).default("markdown"),
title: z.string().optional(),
content: z.string().min(1)
})
});
const app = express();
const port = Number(process.env.SLIDE_FACTORY_PORT || 3025);
const outputDir = path.resolve(process.env.SLIDE_FACTORY_OUTPUT_DIR || "outputs");
app.use(cors());
app.use(express.json({ limit: "5mb" }));
app.use("/outputs", express.static(outputDir));
app.get("/api/health", (_req, res) => {
res.json({ ok: true, service: "slide-factory", version: "0.1.0" });
});
app.post("/api/decks", async (req, res, next) => {
try {
const body = CreateDeckRequestSchema.parse(req.body);
const id = nanoid(10);
const stylePath = await resolveStylePath(body.style);
const style = await loadStylePack(stylePath);
const deck = planDeckFromSource({
source: body.input,
style,
instructions: body.instructions,
audience: body.audience
});
const jobDir = path.join(outputDir, id);
const specPath = path.join(jobDir, "deck.json");
const pptxPath = path.join(jobDir, "deck.pptx");
await mkdir(jobDir, { recursive: true });
await writeFile(specPath, `${JSON.stringify(deck, null, 2)}\n`);
await renderPptx({ deck, style, outputPath: pptxPath });
res.status(201).json({
id,
title: deck.title,
slides: deck.slides.length,
specUrl: `/outputs/${id}/deck.json`,
pptxUrl: `/outputs/${id}/deck.pptx`
});
} catch (error) {
next(error);
}
});
app.use((error: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
console.error(error);
res.status(400).json({
error: error instanceof Error ? error.message : "Unknown error"
});
});
app.listen(port, () => {
console.log(`Slide Factory API listening on http://127.0.0.1:${port}`);
});
async function resolveStylePath(styleId: string): Promise<string> {
const direct = path.resolve("styles", styleId);
if (await exists(path.join(direct, "theme.json"))) return direct;
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(moduleDir, "../../..");
const fromRepoRoot = path.join(repoRoot, "styles", styleId);
if (await exists(path.join(fromRepoRoot, "theme.json"))) return fromRepoRoot;
return direct;
}
async function exists(targetPath: string): Promise<boolean> {
try {
await access(targetPath);
return true;
} catch {
return false;
}
}

9
apps/api/tsconfig.json Normal file
View file

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*.ts"]
}

21
apps/cli/package.json Normal file
View file

@ -0,0 +1,21 @@
{
"name": "@slide-factory/cli",
"version": "0.1.0",
"private": true,
"type": "module",
"bin": {
"slide-factory": "dist/index.js"
},
"scripts": {
"build": "tsc -p tsconfig.json"
},
"dependencies": {
"@slide-factory/core": "0.1.0",
"@slide-factory/render-pptx": "0.1.0",
"commander": "^12.1.0"
},
"devDependencies": {
"typescript": "^5.5.4"
}
}

70
apps/cli/src/index.ts Normal file
View file

@ -0,0 +1,70 @@
#!/usr/bin/env node
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { Command } from "commander";
import { loadStylePack, planDeckFromSource } from "@slide-factory/core";
import { renderPptx } from "@slide-factory/render-pptx";
const program = new Command();
program
.name("slide-factory")
.description("Create brand-consistent slide decks from source content.")
.version("0.1.0");
program
.command("create")
.requiredOption("-i, --input <path>", "Input markdown or text file")
.option("-s, --style <path>", "Style pack path", "styles/incorta")
.option("-o, --output <path>", "Output PPTX path", "outputs/deck.pptx")
.option("--spec <path>", "Output DeckSpec JSON path", "outputs/deck.deck.json")
.option("--instructions <text>", "Deck instructions")
.option("--audience <text>", "Target audience", "executives")
.action(async (options) => {
const inputPath = path.resolve(options.input);
const stylePath = await resolvePathFromRepo(options.style);
const outputPath = path.resolve(options.output);
const specPath = path.resolve(options.spec);
const content = await readFile(inputPath, "utf8");
const style = await loadStylePack(stylePath);
const deck = planDeckFromSource({
source: {
type: inputPath.endsWith(".md") ? "markdown" : "text",
title: undefined,
content,
sourceName: path.basename(inputPath)
},
style,
instructions: options.instructions,
audience: options.audience
});
await mkdir(path.dirname(specPath), { recursive: true });
await writeFile(specPath, `${JSON.stringify(deck, null, 2)}\n`);
await renderPptx({ deck, style, outputPath });
console.log(JSON.stringify({ deck: specPath, pptx: outputPath }, null, 2));
});
await program.parseAsync(process.argv);
async function resolvePathFromRepo(inputPath: string): Promise<string> {
const direct = path.resolve(inputPath);
if (await exists(direct)) return direct;
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(moduleDir, "../../..");
const fromRoot = path.resolve(repoRoot, inputPath);
if (await exists(fromRoot)) return fromRoot;
return direct;
}
async function exists(targetPath: string): Promise<boolean> {
try {
await access(targetPath);
return true;
} catch {
return false;
}
}

9
apps/cli/tsconfig.json Normal file
View file

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*.ts"]
}

13
apps/web/index.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Slide Factory</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

23
apps/web/package.json Normal file
View file

@ -0,0 +1,23 @@
{
"name": "@slide-factory/web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "tsc -p tsconfig.json && vite build",
"dev": "vite --host 127.0.0.1 --port 5185"
},
"dependencies": {
"@vitejs/plugin-react": "^4.3.1",
"vite": "^5.3.5",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"lucide-react": "^0.468.0"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"typescript": "^5.5.4"
}
}

100
apps/web/src/main.tsx Normal file
View file

@ -0,0 +1,100 @@
import React, { useState } from "react";
import { createRoot } from "react-dom/client";
import { Download, FileText, WandSparkles } from "lucide-react";
import "./styles.css";
type DeckResult = {
id: string;
title: string;
slides: number;
specUrl: string;
pptxUrl: string;
};
function App() {
const [content, setContent] = useState(`# Customer Discovery Notes\n\nPaste notes, transcript excerpts, or brief content here.\n\n- Confirm the top decisions.\n- Build an executive narrative.\n- Render a PPTX for review.`);
const [instructions, setInstructions] = useState("Create a concise executive deck.");
const [result, setResult] = useState<DeckResult | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function createDeck() {
setBusy(true);
setError(null);
setResult(null);
try {
const response = await fetch("/api/decks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
style: "incorta",
instructions,
input: { type: "markdown", content }
})
});
const json = await response.json();
if (!response.ok) throw new Error(json.error || "Deck generation failed");
setResult(json);
} catch (err) {
setError(err instanceof Error ? err.message : "Unknown error");
} finally {
setBusy(false);
}
}
return (
<main className="app-shell">
<section className="workspace">
<header className="topbar">
<div>
<p className="eyebrow">Slide Factory</p>
<h1>Deck builder</h1>
</div>
<button onClick={createDeck} disabled={busy}>
<WandSparkles size={18} />
{busy ? "Building" : "Build"}
</button>
</header>
<div className="grid">
<label className="field">
<span>Instructions</span>
<input value={instructions} onChange={(event) => setInstructions(event.target.value)} />
</label>
<label className="field content">
<span>Source Content</span>
<textarea value={content} onChange={(event) => setContent(event.target.value)} />
</label>
<aside className="result-panel">
<div className="result-title">
<FileText size={18} />
<span>Output</span>
</div>
{error && <p className="error">{error}</p>}
{!result && !error && <p className="muted">Build a deck to get download links.</p>}
{result && (
<div className="result">
<strong>{result.title}</strong>
<span>{result.slides} slides</span>
<a href={result.pptxUrl}>
<Download size={16} />
PPTX
</a>
<a href={result.specUrl}>DeckSpec JSON</a>
</div>
)}
</aside>
</div>
</section>
</main>
);
}
createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

156
apps/web/src/styles.css Normal file
View file

@ -0,0 +1,156 @@
:root {
color: #172033;
background: #f5f7fb;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
}
button,
input,
textarea {
font: inherit;
}
.app-shell {
min-height: 100vh;
padding: 24px;
}
.workspace {
max-width: 1180px;
margin: 0 auto;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 20px;
}
.eyebrow {
margin: 0 0 4px;
color: #f05a28;
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
}
h1 {
margin: 0;
font-size: 28px;
}
button {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 40px;
border: 0;
border-radius: 6px;
padding: 0 16px;
color: #fff;
background: #172033;
cursor: pointer;
}
button:disabled {
opacity: 0.65;
cursor: wait;
}
.grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: 16px;
}
.field {
display: flex;
flex-direction: column;
gap: 8px;
}
.field span,
.result-title {
font-size: 13px;
font-weight: 750;
}
input,
textarea,
.result-panel {
border: 1px solid #d8dee9;
border-radius: 8px;
background: #fff;
}
input {
height: 42px;
padding: 0 12px;
}
.content {
grid-column: 1;
}
textarea {
min-height: 520px;
padding: 14px;
resize: vertical;
line-height: 1.45;
}
.result-panel {
grid-column: 2;
grid-row: 1 / span 2;
padding: 16px;
}
.result-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
}
.muted {
color: #596579;
}
.error {
color: #b42318;
}
.result {
display: grid;
gap: 10px;
}
.result a {
display: inline-flex;
align-items: center;
gap: 8px;
color: #1e6bff;
font-weight: 700;
text-decoration: none;
}
@media (max-width: 860px) {
.grid {
grid-template-columns: 1fr;
}
.result-panel {
grid-column: 1;
grid-row: auto;
}
}

9
apps/web/tsconfig.json Normal file
View file

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"jsx": "react-jsx",
"outDir": "dist-types",
"noEmit": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "vite.config.ts"]
}

13
apps/web/vite.config.ts Normal file
View file

@ -0,0 +1,13 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
proxy: {
"/api": "http://127.0.0.1:3025",
"/outputs": "http://127.0.0.1:3025"
}
}
});

32
docs/architecture.md Normal file
View file

@ -0,0 +1,32 @@
# Architecture
## Components
- `packages/core`: deck schemas, style-pack loading, source ingestion, and deck
planning.
- `packages/render-pptx`: deterministic PPTX rendering from `DeckSpec`.
- `apps/cli`: local creation and regeneration commands.
- `apps/api`: HTTP API for deck jobs.
- `apps/web`: first human-friendly frontend for paste/upload/download.
- `styles/*`: reusable brand/style packs.
## Durable Data Model
The durable interface is the `DeckSpec`. Future LLM prompts, API clients,
Google Slides renderers, and web UI revisions should preserve this contract.
## Future Hosted Shape
```text
Forgejo repo
-> /var/www/slide-factory-dev
-> slide-factory-dev.service
-> nginx dev.slides.scottfelten.com
-> /var/www/slide-factory-prod
-> slide-factory-prod.service
-> nginx slides.scottfelten.com
```
No production deploy, DNS, nginx, OAuth, secret projection, or systemd mutation
is part of the initial local MVP.

View file

@ -0,0 +1,18 @@
# Customer Discovery Notes
Customer leaders described a reporting workflow that depends on manual exports,
spreadsheet cleanup, and delayed operational data. Finance wants faster variance
explanations. Supply chain teams need fresher inventory and order signals.
Revenue leaders want consistent definitions before asking AI for account-level
recommendations.
The strongest pain point is time-to-insight. Teams have useful data, but the
last mile is manual and trust erodes when teams compare different numbers.
Recommended next steps:
- Confirm the top three operational decisions that need faster signal.
- Map the current export and reconciliation workflow.
- Prototype an executive dashboard narrative using governed metrics.
- Define how AI-generated explanations cite source metrics and definitions.

3409
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

30
package.json Normal file
View file

@ -0,0 +1,30 @@
{
"name": "slide-factory",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Brand-safe slide generation factory for PPTX and Google Slides workflows.",
"workspaces": [
"packages/core",
"packages/render-pptx",
"apps/cli",
"apps/api",
"apps/web"
],
"scripts": {
"build": "npm run build --workspaces --if-present",
"dev:api": "npm --workspace @slide-factory/api run dev",
"dev:web": "npm --workspace @slide-factory/web run dev",
"cli": "node apps/cli/dist/index.js",
"sample": "npm run build && node apps/cli/dist/index.js create --input examples/customer-discovery.md --style styles/incorta --output outputs/sample-incorta.pptx --spec outputs/sample-incorta.deck.json --instructions \"Create a concise executive deck for a customer discovery summary.\"",
"check": "npm run build"
},
"engines": {
"node": ">=20"
},
"devDependencies": {
"@types/node": "^20.14.12",
"typescript": "^5.5.4"
}
}

View file

@ -0,0 +1,20 @@
{
"name": "@slide-factory/core",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": "./dist/index.js"
},
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsc -p tsconfig.json"
},
"dependencies": {
"zod": "^3.23.8"
},
"devDependencies": {
"typescript": "^5.5.4"
}
}

View file

@ -0,0 +1,4 @@
export * from "./planner.js";
export * from "./schemas.js";
export * from "./style-pack.js";

View file

@ -0,0 +1,159 @@
import {
DeckSpec,
DeckSpecSchema,
SourceDocument,
SourceDocumentSchema,
StylePack
} from "./schemas.js";
export type PlanDeckOptions = {
source: SourceDocument;
style: StylePack;
instructions?: string;
audience?: string;
};
export function planDeckFromSource(options: PlanDeckOptions): DeckSpec {
const source = SourceDocumentSchema.parse(options.source);
const title = source.title || deriveTitle(source.content) || "Generated Brief";
const bullets = extractBullets(source.content, options.style.density.maxBullets);
const themes = extractSentences(source.content, 8);
const nextSteps = extractNextSteps(source.content);
const spec: DeckSpec = {
schemaVersion: "0.1",
title,
style: options.style.id,
audience: options.audience || "executives",
instructions: options.instructions,
sourceSummary: themes.slice(0, 3).join(" "),
slides: [
{
id: "slide-1",
layout: "title",
title,
subtitle: "Generated by Slide Factory",
bullets: [],
leftBullets: [],
rightBullets: [],
timeline: []
},
{
id: "slide-2",
layout: "executive_summary",
title: "Executive signal",
bullets: bullets.length ? bullets : themes.slice(0, 4),
leftBullets: [],
rightBullets: [],
timeline: []
},
{
id: "slide-3",
layout: "two_column",
title: "What matters now",
leftTitle: "Observed friction",
rightTitle: "Opportunity",
bullets: [],
leftBullets: themes.slice(0, 3),
rightBullets: themes.slice(3, 6),
timeline: []
},
{
id: "slide-4",
layout: "metric_callout",
title: "Primary value lever",
metric: {
label: "Focus",
value: detectValueLever(source.content),
context: "The deck should quantify this with customer or operating data when available."
},
bullets: themes.slice(0, 3),
leftBullets: [],
rightBullets: [],
timeline: []
},
{
id: "slide-5",
layout: "roadmap",
title: "Recommended path",
bullets: [],
leftBullets: [],
rightBullets: [],
timeline: buildTimeline(nextSteps)
},
{
id: "slide-6",
layout: "recommendation",
title: "Recommended next steps",
bullets: nextSteps.length ? nextSteps : bullets.slice(0, 4),
leftBullets: [],
rightBullets: [],
timeline: []
},
{
id: "slide-7",
layout: "closing",
title: "Decision request",
subtitle: "Confirm scope, source evidence, and target audience for the next revision.",
bullets: [],
leftBullets: [],
rightBullets: [],
timeline: []
}
]
};
return DeckSpecSchema.parse(spec);
}
function deriveTitle(content: string): string | undefined {
const heading = content
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.startsWith("# "));
return heading?.replace(/^#\s+/, "").trim();
}
function extractBullets(content: string, limit: number): string[] {
return content
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => /^[-*]\s+/.test(line))
.map((line) => line.replace(/^[-*]\s+/, "").trim())
.filter(Boolean)
.slice(0, limit);
}
function extractSentences(content: string, limit: number): string[] {
return content
.replace(/^#+\s+/gm, "")
.split(/(?<=[.!?])\s+/)
.map((sentence) => sentence.replace(/\s+/g, " ").trim())
.filter((sentence) => sentence.length > 24)
.slice(0, limit);
}
function extractNextSteps(content: string): string[] {
const marker = /recommended next steps?:/i.exec(content);
if (!marker) {
return extractBullets(content, 4);
}
return extractBullets(content.slice(marker.index), 4);
}
function detectValueLever(content: string): string {
if (/time[- ]to[- ]insight|faster|speed/i.test(content)) return "Time-to-insight";
if (/revenue|pipeline|account/i.test(content)) return "Revenue signal";
if (/cost|margin|variance|finance/i.test(content)) return "Financial clarity";
return "Decision quality";
}
function buildTimeline(nextSteps: string[]) {
const steps = nextSteps.length
? nextSteps
: ["Confirm audience", "Draft deck spec", "Render first PPTX", "Review and revise"];
return steps.slice(0, 4).map((text, index) => ({
label: `Step ${index + 1}`,
text
}));
}

View file

@ -0,0 +1,92 @@
import { z } from "zod";
export const SourceDocumentSchema = z.object({
type: z.enum(["text", "markdown", "html", "pdf", "pptx", "docx"]).default("markdown"),
title: z.string().optional(),
content: z.string().min(1),
sourceName: z.string().optional()
});
export const SlideLayoutSchema = z.enum([
"title",
"executive_summary",
"section_divider",
"two_column",
"metric_callout",
"roadmap",
"recommendation",
"closing"
]);
export const SlideSpecSchema = z.object({
id: z.string(),
layout: SlideLayoutSchema,
title: z.string(),
subtitle: z.string().optional(),
eyebrow: z.string().optional(),
bullets: z.array(z.string()).default([]),
leftTitle: z.string().optional(),
rightTitle: z.string().optional(),
leftBullets: z.array(z.string()).default([]),
rightBullets: z.array(z.string()).default([]),
metric: z
.object({
label: z.string(),
value: z.string(),
context: z.string().optional()
})
.optional(),
timeline: z
.array(
z.object({
label: z.string(),
text: z.string()
})
)
.default([]),
speakerNotes: z.string().optional()
});
export const DeckSpecSchema = z.object({
schemaVersion: z.literal("0.1"),
title: z.string(),
style: z.string(),
audience: z.string().default("business"),
instructions: z.string().optional(),
sourceSummary: z.string().optional(),
slides: z.array(SlideSpecSchema).min(1)
});
export const StylePackSchema = z.object({
id: z.string(),
name: z.string(),
format: z.literal("16:9").default("16:9"),
colors: z.object({
background: z.string(),
surface: z.string(),
text: z.string(),
mutedText: z.string(),
primary: z.string(),
secondary: z.string(),
accent: z.string(),
line: z.string()
}),
fonts: z.object({
heading: z.string(),
body: z.string(),
mono: z.string().optional()
}),
layouts: z.array(SlideLayoutSchema),
density: z.object({
maxTitleWords: z.number().int().positive(),
maxBullets: z.number().int().positive(),
maxBulletWords: z.number().int().positive()
})
});
export type SourceDocument = z.infer<typeof SourceDocumentSchema>;
export type SlideLayout = z.infer<typeof SlideLayoutSchema>;
export type SlideSpec = z.infer<typeof SlideSpecSchema>;
export type DeckSpec = z.infer<typeof DeckSpecSchema>;
export type StylePack = z.infer<typeof StylePackSchema>;

View file

@ -0,0 +1,10 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { StylePack, StylePackSchema } from "./schemas.js";
export async function loadStylePack(stylePath: string): Promise<StylePack> {
const themePath = path.join(stylePath, "theme.json");
const raw = await readFile(themePath, "utf8");
return StylePackSchema.parse(JSON.parse(raw));
}

View file

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*.ts"]
}

View file

@ -0,0 +1,21 @@
{
"name": "@slide-factory/render-pptx",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": "./dist/index.js"
},
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsc -p tsconfig.json"
},
"dependencies": {
"@slide-factory/core": "0.1.0",
"pptxgenjs": "^3.12.0"
},
"devDependencies": {
"typescript": "^5.5.4"
}
}

View file

@ -0,0 +1,331 @@
import { mkdir } from "node:fs/promises";
import { createRequire } from "node:module";
import path from "node:path";
import { DeckSpec, SlideSpec, StylePack } from "@slide-factory/core";
const require = createRequire(import.meta.url);
const PptxGenJS = require("pptxgenjs");
type SlideLike = {
background?: { color: string };
addShape: (shapeName: string, options?: Record<string, unknown>) => unknown;
addText: (text: string, options?: Record<string, unknown>) => unknown;
};
export type RenderPptxOptions = {
deck: DeckSpec;
style: StylePack;
outputPath: string;
};
export async function renderPptx(options: RenderPptxOptions): Promise<string> {
await mkdir(path.dirname(options.outputPath), { recursive: true });
const pptx = new PptxGenJS();
pptx.layout = "LAYOUT_WIDE";
pptx.author = "Slide Factory";
pptx.company = "AgentPlane";
pptx.subject = options.deck.title;
pptx.title = options.deck.title;
pptx.lang = "en-US";
pptx.theme = {
headFontFace: options.style.fonts.heading,
bodyFontFace: options.style.fonts.body,
lang: "en-US"
};
for (const slideSpec of options.deck.slides) {
const slide = pptx.addSlide();
paintBackground(slide, options.style);
renderSlide(slide, slideSpec, options.style);
renderFooter(slide, options.style);
}
await pptx.writeFile({ fileName: options.outputPath });
return options.outputPath;
}
function renderSlide(slide: SlideLike, spec: SlideSpec, style: StylePack) {
switch (spec.layout) {
case "title":
titleSlide(slide, spec, style);
break;
case "executive_summary":
titleAndBullets(slide, spec, style);
break;
case "two_column":
twoColumn(slide, spec, style);
break;
case "metric_callout":
metricCallout(slide, spec, style);
break;
case "roadmap":
roadmap(slide, spec, style);
break;
case "section_divider":
sectionDivider(slide, spec, style);
break;
case "recommendation":
titleAndBullets(slide, spec, style);
break;
case "closing":
closing(slide, spec, style);
break;
}
}
function paintBackground(slide: SlideLike, style: StylePack) {
slide.background = { color: style.colors.background };
slide.addShape("rect", {
x: 0,
y: 0,
w: 13.333,
h: 0.12,
fill: { color: style.colors.primary },
line: { color: style.colors.primary }
});
}
function titleSlide(slide: SlideLike, spec: SlideSpec, style: StylePack) {
slide.addText(spec.eyebrow || "Slide Factory", {
x: 0.75,
y: 1.0,
w: 8.8,
h: 0.28,
fontFace: style.fonts.body,
fontSize: 11,
bold: true,
color: style.colors.primary,
margin: 0
});
slide.addText(spec.title, {
x: 0.72,
y: 1.48,
w: 9.5,
h: 1.0,
fontFace: style.fonts.heading,
fontSize: 34,
bold: true,
color: style.colors.text,
fit: "shrink",
margin: 0
});
if (spec.subtitle) {
slide.addText(spec.subtitle, {
x: 0.75,
y: 2.62,
w: 7.8,
h: 0.44,
fontFace: style.fonts.body,
fontSize: 15,
color: style.colors.mutedText,
fit: "shrink",
margin: 0
});
}
slide.addShape("rect", {
x: 0.75,
y: 4.62,
w: 3.0,
h: 0.08,
fill: { color: style.colors.primary },
line: { color: style.colors.primary }
});
}
function titleAndBullets(slide: SlideLike, spec: SlideSpec, style: StylePack) {
addTitle(slide, spec.title, style);
addBulletList(slide, spec.bullets, 0.9, 1.75, 11.4, 3.8, style);
}
function twoColumn(slide: SlideLike, spec: SlideSpec, style: StylePack) {
addTitle(slide, spec.title, style);
addPanel(slide, 0.8, 1.55, 5.7, 4.7, style);
addPanel(slide, 6.82, 1.55, 5.7, 4.7, style);
addSmallHeading(slide, spec.leftTitle || "Current state", 1.1, 1.9, style);
addSmallHeading(slide, spec.rightTitle || "Target state", 7.12, 1.9, style);
addBulletList(slide, spec.leftBullets, 1.12, 2.38, 4.95, 3.1, style);
addBulletList(slide, spec.rightBullets, 7.14, 2.38, 4.95, 3.1, style);
}
function metricCallout(slide: SlideLike, spec: SlideSpec, style: StylePack) {
addTitle(slide, spec.title, style);
slide.addText(spec.metric?.label || "Metric", {
x: 0.9,
y: 1.75,
w: 4.0,
h: 0.3,
fontFace: style.fonts.body,
fontSize: 12,
bold: true,
color: style.colors.primary,
margin: 0
});
slide.addText(spec.metric?.value || "Key signal", {
x: 0.86,
y: 2.1,
w: 5.2,
h: 0.7,
fontFace: style.fonts.heading,
fontSize: 32,
bold: true,
color: style.colors.text,
fit: "shrink",
margin: 0
});
slide.addText(spec.metric?.context || "", {
x: 0.9,
y: 3.0,
w: 5.0,
h: 0.8,
fontFace: style.fonts.body,
fontSize: 14,
color: style.colors.mutedText,
fit: "shrink",
margin: 0
});
addPanel(slide, 6.7, 1.65, 5.6, 4.35, style);
addBulletList(slide, spec.bullets, 7.05, 2.05, 4.85, 3.4, style);
}
function roadmap(slide: SlideLike, spec: SlideSpec, style: StylePack) {
addTitle(slide, spec.title, style);
const startX = 0.85;
const gap = 0.25;
const cardW = 2.9;
spec.timeline.slice(0, 4).forEach((item, index) => {
const x = startX + index * (cardW + gap);
slide.addShape("roundRect", {
x,
y: 2.0,
w: cardW,
h: 2.55,
rectRadius: 0.08,
fill: { color: style.colors.surface },
line: { color: style.colors.line }
});
slide.addText(item.label, {
x: x + 0.25,
y: 2.25,
w: cardW - 0.5,
h: 0.25,
fontFace: style.fonts.body,
fontSize: 11,
bold: true,
color: style.colors.primary,
margin: 0
});
slide.addText(item.text, {
x: x + 0.25,
y: 2.75,
w: cardW - 0.5,
h: 1.25,
fontFace: style.fonts.body,
fontSize: 14,
color: style.colors.text,
fit: "shrink",
margin: 0
});
});
}
function sectionDivider(slide: SlideLike, spec: SlideSpec, style: StylePack) {
slide.addText(spec.title, {
x: 0.85,
y: 2.45,
w: 9.5,
h: 0.85,
fontFace: style.fonts.heading,
fontSize: 34,
bold: true,
color: style.colors.text,
fit: "shrink",
margin: 0
});
}
function closing(slide: SlideLike, spec: SlideSpec, style: StylePack) {
titleSlide(slide, spec, style);
}
function addTitle(slide: SlideLike, title: string, style: StylePack) {
slide.addText(title, {
x: 0.75,
y: 0.62,
w: 11.4,
h: 0.58,
fontFace: style.fonts.heading,
fontSize: 24,
bold: true,
color: style.colors.text,
fit: "shrink",
margin: 0
});
}
function addSmallHeading(slide: SlideLike, text: string, x: number, y: number, style: StylePack) {
slide.addText(text, {
x,
y,
w: 4.9,
h: 0.3,
fontFace: style.fonts.heading,
fontSize: 15,
bold: true,
color: style.colors.text,
margin: 0
});
}
function addPanel(slide: SlideLike, x: number, y: number, w: number, h: number, style: StylePack) {
slide.addShape("roundRect", {
x,
y,
w,
h,
rectRadius: 0.08,
fill: { color: style.colors.surface },
line: { color: style.colors.line }
});
}
function addBulletList(
slide: SlideLike,
bullets: string[],
x: number,
y: number,
w: number,
h: number,
style: StylePack
) {
const text = bullets.length ? bullets.map((bullet) => `${bullet}`).join("\n") : "• Add supporting evidence";
slide.addText(text, {
x,
y,
w,
h,
fontFace: style.fonts.body,
fontSize: 14,
color: style.colors.text,
breakLine: false,
fit: "shrink",
valign: "top",
margin: 0.08,
paraSpaceAfterPt: 8,
breakLineOnHyphen: false
});
}
function renderFooter(slide: SlideLike, style: StylePack) {
slide.addText("Slide Factory", {
x: 10.85,
y: 7.1,
w: 1.65,
h: 0.18,
fontFace: style.fonts.body,
fontSize: 8,
color: style.colors.mutedText,
align: "right",
margin: 0
});
}

View file

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*.ts"]
}

16
styles/incorta/style.md Normal file
View file

@ -0,0 +1,16 @@
# Incorta Starter Style Pack
Status: starter approximation until the official template inventory is imported.
Use this style for concise executive and customer-facing decks. The voice should
be direct, evidence-backed, and practical.
Rules:
- Prefer short slide titles with a clear point of view.
- Keep body copy to three to five bullets when possible.
- Use metric callouts for business outcomes, not decorative numbers.
- Avoid off-brand colors and arbitrary layouts.
- For Google Slides, use brand-safe mode: copy the template, inventory
containers, fill existing object IDs, and visually QA with thumbnails.

36
styles/incorta/theme.json Normal file
View file

@ -0,0 +1,36 @@
{
"id": "incorta",
"name": "Incorta Starter",
"format": "16:9",
"colors": {
"background": "FFFFFF",
"surface": "F6F8FB",
"text": "172033",
"mutedText": "596579",
"primary": "F05A28",
"secondary": "1E6BFF",
"accent": "19A974",
"line": "D8DEE9"
},
"fonts": {
"heading": "Aptos Display",
"body": "Aptos",
"mono": "Aptos Mono"
},
"layouts": [
"title",
"executive_summary",
"section_divider",
"two_column",
"metric_callout",
"roadmap",
"recommendation",
"closing"
],
"density": {
"maxTitleWords": 12,
"maxBullets": 5,
"maxBulletWords": 16
}
}

15
tsconfig.base.json Normal file
View file

@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"declaration": true,
"sourceMap": true
}
}