Initial Slide Factory MVP scaffold
This commit is contained in:
commit
eb0a17f75e
31 changed files with 4831 additions and 0 deletions
9
.env.example
Normal file
9
.env.example
Normal 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
9
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
node_modules/
|
||||
dist/
|
||||
outputs/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.DS_Store
|
||||
*.log
|
||||
|
||||
58
README.md
Normal file
58
README.md
Normal 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
25
apps/api/package.json
Normal 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
96
apps/api/src/server.ts
Normal 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
9
apps/api/tsconfig.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
|
||||
21
apps/cli/package.json
Normal file
21
apps/cli/package.json
Normal 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
70
apps/cli/src/index.ts
Normal 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
9
apps/cli/tsconfig.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
|
||||
13
apps/web/index.html
Normal file
13
apps/web/index.html
Normal 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
23
apps/web/package.json
Normal 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
100
apps/web/src/main.tsx
Normal 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
156
apps/web/src/styles.css
Normal 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
9
apps/web/tsconfig.json
Normal 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
13
apps/web/vite.config.ts
Normal 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
32
docs/architecture.md
Normal 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.
|
||||
|
||||
18
examples/customer-discovery.md
Normal file
18
examples/customer-discovery.md
Normal 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
3409
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
30
package.json
Normal file
30
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
|
||||
20
packages/core/package.json
Normal file
20
packages/core/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
|
||||
4
packages/core/src/index.ts
Normal file
4
packages/core/src/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export * from "./planner.js";
|
||||
export * from "./schemas.js";
|
||||
export * from "./style-pack.js";
|
||||
|
||||
159
packages/core/src/planner.ts
Normal file
159
packages/core/src/planner.ts
Normal 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
|
||||
}));
|
||||
}
|
||||
92
packages/core/src/schemas.ts
Normal file
92
packages/core/src/schemas.ts
Normal 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>;
|
||||
|
||||
10
packages/core/src/style-pack.ts
Normal file
10
packages/core/src/style-pack.ts
Normal 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));
|
||||
}
|
||||
|
||||
9
packages/core/tsconfig.json
Normal file
9
packages/core/tsconfig.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
|
||||
21
packages/render-pptx/package.json
Normal file
21
packages/render-pptx/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
|
||||
331
packages/render-pptx/src/index.ts
Normal file
331
packages/render-pptx/src/index.ts
Normal 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
|
||||
});
|
||||
}
|
||||
9
packages/render-pptx/tsconfig.json
Normal file
9
packages/render-pptx/tsconfig.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
|
||||
16
styles/incorta/style.md
Normal file
16
styles/incorta/style.md
Normal 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
36
styles/incorta/theme.json
Normal 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
15
tsconfig.base.json
Normal 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
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Add table
Reference in a new issue