Merge branch 'main' into feat/sentry-setup

This commit is contained in:
Sudip Parajuli 2026-04-11 14:54:13 +05:45 committed by GitHub
commit 48db43e86b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
231 changed files with 19752 additions and 5942 deletions

0
.codex Normal file
View file

4
.gitignore vendored
View file

@ -19,7 +19,5 @@ chroma
container.db
.next-build
.cursor
# Agent skill artifacts
.agents/
.agents
skills-lock.json

1
electron/.gitignore vendored
View file

@ -12,6 +12,7 @@ app_data
tmp
debug
.fastembed_cache
.codex
generated_models
nltk

View file

@ -71,7 +71,8 @@ const createWindow = () => {
win = new BrowserWindow({
width: 1280,
height: 720,
show: false, // Shown after LibreOffice check so "Skip" doesn't quit the app
show: false, // Reveal once the launch screen has painted to avoid a blank flash.
backgroundColor: "#f3f5ff",
icon: path.join(baseDir, "resources/ui/assets/images/presenton_short_filled.png"),
webPreferences: {
webSecurity: false,
@ -103,10 +104,19 @@ const createWindow = () => {
}
return { action: "allow" };
});
win.once("ready-to-show", () => {
if (!win || win.isDestroyed()) {
return;
}
win.show();
win.focus();
});
};
async function startServers(fastApiPort: number, nextjsPort: number) {
try {
const sofficePath = getSofficePath();
const fastApi = await startFastApiServer(
fastapiDir,
fastApiPort,
@ -140,12 +150,15 @@ async function startServers(fastApiPort: number, nextjsPort: number) {
DALL_E_3_QUALITY: process.env.DALL_E_3_QUALITY,
GPT_IMAGE_1_5_QUALITY: process.env.GPT_IMAGE_1_5_QUALITY,
APP_DATA_DIRECTORY: appDataDir,
FASTAPI_PUBLIC_URL: process.env.NEXT_PUBLIC_FAST_API,
TEMP_DIRECTORY: tempDir,
USER_CONFIG_PATH: userConfigPath,
MIGRATE_DATABASE_ON_STARTUP: "True",
// Resolved by libreoffice-check.ts at startup; lets Python invoke the
// exact binary path instead of relying on the system PATH.
SOFFICE_PATH: getSofficePath(),
// Resolved by libreoffice-check.ts at startup when available; lets
// Python invoke the exact binary path instead of relying on PATH.
...(sofficePath && {
SOFFICE_PATH: sofficePath,
}),
IMAGEMAGICK_BINARY: getImageMagickBinaryPath(),
LITEPARSE_RUNNER_PATH: getLiteParseRunnerPath(),
// Use Electron's embedded runtime for LiteParse so parsing does not
@ -184,25 +197,38 @@ async function startServers(fastApiPort: number, nextjsPort: number) {
async function stopServers() {
if (fastApiProcess?.pid) {
console.log("Closing FastAPI...");
console.log("Force killing FastAPI...");
try {
await killProcess(fastApiProcess.pid);
} catch {
await killProcess(fastApiProcess.pid, "SIGKILL");
} catch (error) {
console.error("Failed to force kill FastAPI:", error);
}
fastApiProcess = undefined;
}
if (nextjsProcess) {
if (isDev) {
console.log("Closing NextJS...");
if ("pid" in nextjsProcess && nextjsProcess.pid) {
console.log("Force killing NextJS...");
try {
await killProcess(nextjsProcess.pid);
} catch {
await killProcess(nextjsProcess.pid, "SIGKILL");
} catch (error) {
console.error("Failed to force kill NextJS:", error);
}
} else {
} else if (typeof nextjsProcess.close === "function") {
console.log("Closing NextJS...");
nextjsProcess.close();
}
nextjsProcess = undefined;
}
}
async function forceQuitApp(exitCode = 0) {
if (isStopping) return;
isStopping = true;
stopUpdateChecker();
try {
await stopServers();
} finally {
app.exit(exitCode);
}
}
@ -238,7 +264,7 @@ app.whenReady().then(async () => {
startupStatus.puppeteer = chromeOk ? "installed" : "missing";
startupStatus.imagemagick = imageMagickOk ? "installed" : "missing";
// Show and focus main window
// Ensure the launch screen stays visible and focused during the server boot.
win?.show();
win?.focus();
@ -301,29 +327,17 @@ app.whenReady().then(async () => {
});
app.on("window-all-closed", async () => {
stopUpdateChecker();
await stopServers();
app.quit();
await forceQuitApp(0);
});
app.on("before-quit", async (event) => {
if (isStopping) return;
isStopping = true;
event.preventDefault();
try {
await stopServers();
} finally {
app.quit();
}
await forceQuitApp(0);
});
app.on("will-quit", async (event) => {
if (isStopping) return;
isStopping = true;
event.preventDefault();
try {
await stopServers();
} finally {
app.quit();
}
await forceQuitApp(0);
});

View file

@ -28,10 +28,11 @@ interface FastApiEnv {
DALL_E_3_QUALITY?: string,
GPT_IMAGE_1_5_QUALITY?: string,
APP_DATA_DIRECTORY?: string,
FASTAPI_PUBLIC_URL?: string,
TEMP_DIRECTORY?: string,
USER_CONFIG_PATH?: string,
MIGRATE_DATABASE_ON_STARTUP?: string,
/** Absolute path to the soffice binary resolved at startup by libreoffice-check.ts. */
/** Absolute path to the resolved LibreOffice executable discovered at startup. */
SOFFICE_PATH?: string,
/** Absolute path to the ImageMagick binary resolved at startup by imagemagick-check.ts. */
IMAGEMAGICK_BINARY?: string,
@ -90,4 +91,4 @@ interface UserConfig {
interface IPCStatus {
success: boolean,
message?: string,
}
}

View file

@ -36,6 +36,7 @@ export function setupEnv(fastApiPort: number, nextjsPort: number) {
process.env.SENTRY_RELEASE = process.env.SENTRY_RELEASE || `presenton-electron@${process.env.APP_VERSION}`;
process.env.SENTRY_ENVIRONMENT = process.env.SENTRY_ENVIRONMENT || (app.isPackaged ? 'production' : 'development');
process.env.NEXT_PUBLIC_FAST_API = `${localhost}:${fastApiPort}`;
process.env.FASTAPI_PUBLIC_URL = process.env.NEXT_PUBLIC_FAST_API;
process.env.TEMP_DIRECTORY = tempDir;
process.env.NEXT_PUBLIC_USER_CONFIG_PATH = userConfigPath;
process.env.NEXT_PUBLIC_URL = `${localhost}:${nextjsPort}`;

View file

@ -239,24 +239,81 @@ export function getLinuxInstallCommand(): { cmd: string; args: string[] } | null
}
// ---------------------------------------------------------------------------
// Resolved path set once by checkLibreOfficeBeforeWindow()
// Resolved path populated by successful detection
// ---------------------------------------------------------------------------
/**
* The resolved soffice binary path discovered at startup.
* Defaults to the bare command name so callers always get a usable string
* even if the check has not run yet (e.g. in non-Electron environments).
* The resolved LibreOffice executable path discovered at startup.
* Empty until a successful detection populates it.
*/
let resolvedSofficePath: string = "soffice";
let resolvedSofficePath = "";
/**
* Returns the resolved soffice binary path found during startup detection.
* Returns the resolved LibreOffice executable path found during startup detection.
*
* Pass as the `SOFFICE_PATH` env var to the FastAPI subprocess so Python
* code can invoke the exact binary rather than relying on `PATH`.
*/
export function getSofficePath(): string {
return resolvedSofficePath;
export function getSofficePath(): string | undefined {
return resolvedSofficePath || undefined;
}
function setResolvedSofficePath(candidate?: string): void {
if (!candidate) {
return;
}
resolvedSofficePath = candidate;
}
function firstNonEmptyLine(value: string): string | undefined {
return value
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.length > 0);
}
async function getVersionForBinary(binaryPath: string): Promise<string | undefined> {
try {
const quoted = `"${binaryPath}"`;
const { stdout } = await execAsync(`${quoted} --version`, {
timeout: 8_000,
windowsHide: (process.platform as string) === "win32",
});
return stdout.trim() || undefined;
} catch {
return undefined;
}
}
async function resolveLibreOfficeFromPath(): Promise<string | undefined> {
if (process.platform === "win32") {
try {
const { stdout } = await execAsync("where soffice.exe", {
timeout: 8_000,
windowsHide: true,
});
return firstNonEmptyLine(stdout);
} catch {
return undefined;
}
}
try {
const { stdout } = await execAsync("command -v soffice || command -v libreoffice", {
timeout: 8_000,
windowsHide: false,
});
const resolved = firstNonEmptyLine(stdout);
if (!resolved) {
return undefined;
}
if (path.isAbsolute(resolved)) {
return resolved;
}
return undefined;
} catch {
return undefined;
}
}
// ---------------------------------------------------------------------------
@ -266,7 +323,7 @@ export function getSofficePath(): string {
/**
* Attempts to detect LibreOffice by:
* 1. Checking well-known installation paths for the binary (fast, no shell).
* 2. Falling back to `soffice --version` via the shell (catches PATH installs).
* 2. Falling back to resolving the binary from PATH.
*
* Returns an object indicating whether LibreOffice was found and, when it
* was, the version string reported by the binary.
@ -275,6 +332,7 @@ export async function isLibreOfficeInstalled(): Promise<LibreOfficeCheckResult>
// --- Step 1: check well-known paths synchronously (no exec overhead) ---
for (const candidate of getCandidatePaths()) {
if (fs.existsSync(candidate)) {
setResolvedSofficePath(candidate);
// On Windows, avoid probing with "--version" because some LibreOffice
// builds open a transient console window for this command.
if (process.platform === "win32") {
@ -282,53 +340,24 @@ export async function isLibreOfficeInstalled(): Promise<LibreOfficeCheckResult>
}
// Binary found at a known location try to get the version string.
try {
const quoted = `"${candidate}"`;
const { stdout } = await execAsync(`${quoted} --version`, {
timeout: 8_000,
windowsHide: (process.platform as string) === "win32",
});
return { installed: true, version: stdout.trim(), path: candidate };
} catch {
// Binary exists but failed to execute still treat as installed.
return { installed: true, path: candidate };
const version = await getVersionForBinary(candidate);
if (version) {
return { installed: true, version, path: candidate };
}
// Binary exists but failed to execute still treat as installed.
return { installed: true, path: candidate };
}
}
// --- Step 2: try the PATH-based command ---
if (process.platform === "win32") {
try {
// Use "where" for PATH detection without launching LibreOffice itself.
const { stdout } = await execAsync("where soffice.exe", {
timeout: 8_000,
windowsHide: true,
});
const firstPath = stdout
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.length > 0);
if (firstPath) {
return { installed: true, path: firstPath };
}
} catch {
// Keep behavior: if PATH lookup fails, report not installed.
}
const pathBinary = await resolveLibreOfficeFromPath();
if (!pathBinary) {
return { installed: false };
}
try {
const { stdout } = await execAsync("soffice --version", {
timeout: 8_000,
windowsHide: (process.platform as string) === "win32",
});
// Found via PATH record the bare command name as the path so callers
// can pass it directly to subprocess invocations.
return { installed: true, version: stdout.trim(), path: "soffice" };
} catch {
// Command not found or timed out LibreOffice is not available.
return { installed: false };
}
setResolvedSofficePath(pathBinary);
const version = await getVersionForBinary(pathBinary);
return { installed: true, version, path: pathBinary };
}
// ---------------------------------------------------------------------------
@ -426,4 +455,4 @@ export async function checkLibreOfficeBeforeWindow(
// Always proceed never block the app
return true;
}
}

View file

@ -11,6 +11,7 @@ const VERSION_JSON_URL =
"https://raw.githubusercontent.com/presenton/presenton/refs/heads/main/electron/version.json";
const CURRENT_VERSION = app.getVersion();
const WEBSITE_DOWNLOAD_URL = "https://presenton.ai/download";
/** Maximum number of fetch attempts (polls). */
const MAX_ATTEMPTS = 3;
@ -33,20 +34,13 @@ function log(msg: string): void {
interface VersionResponse {
version: string;
message?: string;
downloads: {
downloads?: {
linux: string;
mac: string;
windows: string;
};
}
function getDownloadUrlForPlatform(downloads: VersionResponse["downloads"]): string {
const platform = process.platform;
if (platform === "darwin") return downloads.mac;
if (platform === "win32") return downloads.windows;
return downloads.linux;
}
/**
* Simple semver comparison that strips pre-release labels for numeric comparison.
* Returns true if `remote` is strictly newer than `current`.
@ -235,7 +229,7 @@ async function checkForUpdatesWithRetry(win: BrowserWindow): Promise<void> {
const newer = isNewerVersion(CURRENT_VERSION, data.version);
log(`Remote ${data.version} vs current ${CURRENT_VERSION} -> newer? ${newer}`);
if (newer) {
const downloadUrl = getDownloadUrlForPlatform(data.downloads);
const downloadUrl = WEBSITE_DOWNLOAD_URL;
log(`Injecting banner for ${data.version} (after ${INJECT_DELAY_MS}ms delay)`);
scheduleBannerInjection(win, data.version, downloadUrl, data.message);
} else {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 453 B

After

Width:  |  Height:  |  Size: 830 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 815 B

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

BIN
electron/build/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

View file

@ -1,8 +1,8 @@
{
"name": "presenton",
"productName": "Presenton Open Source",
"version": "0.7.0-beta",
"exportVersion": "v0.1.1",
"version": "0.7.1-beta",
"exportVersion": "v0.2.0",
"main": "app_dist/main.js",
"description": "Open-Source AI Presentation Generator",
"homepage": "https://presenton.ai",

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 43 KiB

View file

@ -1,182 +1,212 @@
<!DOCTYPE html>
<html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Presenton</title>
<link rel="stylesheet" href="../assets/css/tailwind.css">
<script src="./script.js"></script>
<style>
.loading-circle {
width: 40px;
height: 40px;
border: 4px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top-color: white;
animation: spin 1s ease-in-out infinite;
@font-face {
font-family: "Unbounded";
src: url("../assets/fonts/Unbounded-Medium.ttf") format("truetype");
font-weight: 500;
font-style: normal;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
@font-face {
font-family: "Syne";
src: url("../assets/fonts/Syne-Regular.ttf") format("truetype");
font-weight: 400;
font-style: normal;
}
.status-panel {
@font-face {
font-family: "Syne";
src: url("../assets/fonts/Syne-Medium.ttf") format("truetype");
font-weight: 500;
font-style: normal;
}
:root {
color-scheme: light;
--bg-top: #ffffff;
--bg-bottom: #f2f4ff;
--title: #2d3347;
--subtitle: #9298ae;
--hint: #7e86a0;
--track: rgba(255, 255, 255, 0.76);
--track-border: rgba(128, 120, 212, 0.12);
--fill-start: #735cf7;
--fill-mid: #6e67f7;
--fill-end: #6b8eff;
--progress: 0.24;
}
* {
box-sizing: border-box;
}
html,
body {
width: 100%;
height: 100%;
min-height: 100%;
margin: 0;
}
body {
display: grid;
place-items: center;
width: 100vw;
min-height: 100vh;
overflow: hidden;
font-family: "Syne", "Segoe UI", system-ui, sans-serif;
color: var(--title);
background:
radial-gradient(circle at 20% 18%, rgba(127, 104, 255, 0.16), transparent 28%),
radial-gradient(circle at 82% 84%, rgba(108, 144, 255, 0.12), transparent 30%),
linear-gradient(135deg, var(--bg-top) 0%, #f8f9ff 42%, var(--bg-bottom) 100%);
}
body::before {
content: "";
position: fixed;
top: 16px;
right: 16px;
background: rgba(17, 24, 39, 0.9);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
padding: 10px 12px;
font-size: 12px;
line-height: 1.4;
display: inline-flex;
width: fit-content;
max-width: 80vw;
}
.status-row {
display: flex;
align-items: center;
gap: 8px;
margin: 4px 0;
}
.status-name {
flex: 1;
color: #e5e7eb;
font-weight: 600;
}
.status-text {
color: #d1d5db;
}
.status-icon {
width: 10px;
height: 10px;
display: inline-block;
border-radius: 9999px;
}
.status-icon.loading {
background: #3b82f6;
animation: pulse 1s ease-in-out infinite;
}
.status-icon.ok {
background: #10b981;
}
.status-icon.error {
background: #ef4444;
}
@keyframes pulse {
0% {
opacity: 0.35;
}
50% {
opacity: 1;
}
100% {
opacity: 0.35;
}
}
.status-tooltip {
position: absolute;
top: 100%;
right: 0;
margin-top: 8px;
background: rgba(17, 24, 39, 0.95);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 8px;
padding: 8px 10px;
font-size: 11px;
color: #e5e7eb;
white-space: normal;
display: flex;
flex-direction: column;
gap: 4px;
min-width: 200px;
opacity: 0;
transform: translateY(-4px);
inset: -20%;
pointer-events: none;
transition: opacity 120ms ease, transform 120ms ease;
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.28);
background:
radial-gradient(circle at center, rgba(255, 255, 255, 0.55), transparent 45%),
radial-gradient(circle at 30% 20%, rgba(145, 124, 255, 0.14), transparent 24%);
filter: blur(90px);
opacity: 0.9;
}
.status-panel:hover .status-tooltip {
opacity: 1;
transform: translateY(0);
.launch-screen {
position: relative;
z-index: 1;
width: min(100%, 540px);
padding: 24px 28px 32px;
text-align: center;
}
.status-tooltip-line {
display: flex;
justify-content: space-between;
gap: 12px;
.logo {
width: min(54vw, 300px);
margin: 0 auto 36px;
}
.status-tooltip-line span {
.title {
margin: 0;
font-size: clamp(1.3rem, 2.05vw, 1.72rem);
line-height: 1.12;
letter-spacing: -0.045em;
font-family: "Unbounded", "Syne", system-ui, sans-serif;
font-weight: 500;
white-space: nowrap;
}
.status-tooltip-label {
display: inline-flex;
align-items: center;
gap: 6px;
.subtitle {
margin: 14px 0 0;
font-size: clamp(0.98rem, 1.8vw, 1.04rem);
line-height: 1.5;
color: var(--subtitle);
font-weight: 500;
font-family: "Syne", "Segoe UI", system-ui, sans-serif;
}
.status-tooltip-label::before {
.progress-track {
position: relative;
width: min(100%, 360px);
height: 12px;
margin: 40px auto 0;
overflow: hidden;
border-radius: 999px;
background: var(--track);
box-shadow:
inset 0 0 0 1px var(--track-border),
0 14px 32px rgba(112, 105, 186, 0.12);
}
.progress-fill {
position: absolute;
inset: 0 auto 0 0;
width: 100%;
overflow: hidden;
border-radius: inherit;
background: linear-gradient(90deg, var(--fill-start) 0%, var(--fill-mid) 56%, var(--fill-end) 100%);
box-shadow: 0 6px 18px rgba(112, 97, 242, 0.28);
transform: scaleX(var(--progress));
transform-origin: left center;
transition: transform 380ms ease;
}
.progress-fill::after {
content: "";
width: 6px;
height: 6px;
border-radius: 9999px;
background: #6b7280;
box-shadow: 0 0 0 2px rgba(107, 114, 128, 0.2);
position: absolute;
inset: 0;
background: linear-gradient(90deg,
transparent 0%,
rgba(255, 255, 255, 0.12) 36%,
rgba(255, 255, 255, 0.58) 50%,
rgba(255, 255, 255, 0.12) 64%,
transparent 100%);
animation: shimmer 1.85s linear infinite;
}
.status-tooltip-label.loading::before {
background: #3b82f6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.25);
animation: pulse 1s ease-in-out infinite;
.hint {
min-height: 1.5rem;
margin: 16px 0 0;
font-size: 0.88rem;
line-height: 1.5;
color: var(--hint);
letter-spacing: 0.01em;
font-family: "Syne", "Segoe UI", system-ui, sans-serif;
}
.status-tooltip-label.ok::before {
background: #10b981;
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.25);
@keyframes shimmer {
0% {
transform: translateX(-125%);
}
100% {
transform: translateX(220%);
}
}
.status-tooltip-label.error::before {
background: #ef4444;
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.25);
@media (max-width: 640px) {
.launch-screen {
width: min(100%, 440px);
padding: 16px 24px 24px;
}
.logo {
width: min(74vw, 260px);
margin-bottom: 30px;
}
.title {
font-size: clamp(1.35rem, 6.7vw, 1.8rem);
white-space: normal;
}
.progress-track {
width: min(100%, 320px);
margin-top: 34px;
}
}
</style>
</head>
<body class="bg-gray-900 text-white flex flex-col items-center justify-center h-screen">
<img src="../assets/images/presenton_logo.png" alt="Presenton Logo" class="h-20">
<p class="mt-16 text-lg">Just a moment...</p>
<div class="loading-circle mt-8"></div>
<div class="status-panel" id="dependencies-panel">
<div class="status-row">
<span class="status-icon loading" id="icon-dependencies"></span>
<span class="status-name" id="status-dependencies">Dependencies</span>
<body>
<main class="launch-screen" aria-live="polite">
<img src="../assets/images/presenton_logo.png" alt="Presenton" class="logo">
<h1 class="title">Launching Presenton...</h1>
<p class="subtitle" data-startup-subtitle>Please wait a moment</p>
<div class="progress-track" role="progressbar" aria-label="Launching Presenton" aria-valuemin="0"
aria-valuemax="100" aria-valuenow="24" data-startup-meter>
<div class="progress-fill" data-startup-progress></div>
</div>
<div class="status-tooltip" id="dependencies-tooltip">
<div class="status-tooltip-line">
<span class="status-tooltip-label loading" id="tooltip-label-libreoffice">LibreOffice</span>
<span id="tooltip-libreoffice">Checking...</span>
</div>
<div class="status-tooltip-line">
<span class="status-tooltip-label loading" id="tooltip-label-puppeteer">Chromium</span>
<span id="tooltip-puppeteer">Checking...</span>
</div>
</div>
</div>
<p class="hint" data-startup-hint>Preparing your workspace</p>
</main>
<script src="./script.js"></script>
</body>
</html>
</html>

View file

@ -1,91 +1,121 @@
window.addEventListener("DOMContentLoaded", () => {
const statusMap = {
checking: "Checking...",
installed: "Installed",
missing: "Missing",
installing: "Installing...",
downloading: "Downloading...",
downloaded: "Downloaded",
skipped: "Skipped",
failed: "Failed",
};
const labelMap = {
libreoffice: "LibreOffice",
puppeteer: "Chromium",
imagemagick: "ImageMagick",
};
const subtitleEl = document.querySelector("[data-startup-subtitle]");
const hintEl = document.querySelector("[data-startup-hint]");
const progressEl = document.querySelector("[data-startup-progress]");
const meterEl = document.querySelector("[data-startup-meter]");
const dependenciesEl = document.getElementById("status-dependencies");
const dependenciesIcon = document.getElementById("icon-dependencies");
const dependenciesTooltip = document.getElementById("dependencies-tooltip");
const libreofficeTooltip = document.getElementById("tooltip-libreoffice");
const puppeteerTooltip = document.getElementById("tooltip-puppeteer");
const libreofficeLabel = document.getElementById("tooltip-label-libreoffice");
const puppeteerLabel = document.getElementById("tooltip-label-puppeteer");
const currentStatus = {
libreoffice: "checking",
puppeteer: "checking",
imagemagick: "checking",
};
function setStatus(name, status) {
if (currentStatus[name] !== undefined) {
currentStatus[name] = status;
const completeStates = new Set(["installed", "downloaded", "skipped"]);
const errorStates = new Set(["missing", "failed"]);
let visualProgress = 0.24;
let targetProgress = 0.58;
function applyProgress(value) {
const clampedValue = Math.max(0.18, Math.min(value, 1));
if (progressEl) {
progressEl.style.setProperty("--progress", String(clampedValue));
progressEl.style.transform = `scaleX(${clampedValue})`;
}
if (dependenciesEl) dependenciesEl.textContent = "Dependencies";
const statuses = Object.values(currentStatus);
const hasError = statuses.some((s) => s === "missing" || s === "failed");
const isBusy = statuses.some((s) => s === "checking" || s === "installing" || s === "downloading");
const isDone = statuses.every((s) => s === "installed" || s === "downloaded" || s === "skipped");
let iconClass = "loading";
let iconText = "";
if (hasError) {
iconClass = "error";
iconText = "×";
} else if (isDone && !isBusy) {
iconClass = "ok";
iconText = "✓";
} else {
iconClass = "loading";
iconText = "";
if (meterEl) {
meterEl.setAttribute("aria-valuenow", String(Math.round(clampedValue * 100)));
}
if (dependenciesIcon) {
dependenciesIcon.className = `status-icon ${iconClass}`;
}
const libreofficeStatus = currentStatus.libreoffice;
const puppeteerStatus = currentStatus.puppeteer;
const libreofficeText = statusMap[libreofficeStatus] || libreofficeStatus;
const puppeteerText = statusMap[puppeteerStatus] || puppeteerStatus;
const toDotClass = (value) => {
if (value === "missing" || value === "failed") return "error";
if (value === "installed" || value === "downloaded" || value === "skipped") return "ok";
return "loading";
};
if (libreofficeTooltip) libreofficeTooltip.textContent = libreofficeText;
if (puppeteerTooltip) puppeteerTooltip.textContent = puppeteerText;
if (libreofficeLabel) libreofficeLabel.className = `status-tooltip-label ${toDotClass(libreofficeStatus)}`;
if (puppeteerLabel) puppeteerLabel.className = `status-tooltip-label ${toDotClass(puppeteerStatus)}`;
if (dependenciesTooltip) dependenciesTooltip.setAttribute("aria-live", "polite");
}
function updateStateCopy() {
const statuses = Object.values(currentStatus);
const hasError = statuses.some((status) => errorStates.has(status));
const isInstalling = statuses.some(
(status) => status === "installing" || status === "downloading"
);
const isChecking = statuses.some((status) => status === "checking");
const isReady =
statuses.length > 0 &&
statuses.every((status) => completeStates.has(status));
if (hasError) {
targetProgress = Math.max(targetProgress, 0.54);
if (subtitleEl) subtitleEl.textContent = "Please wait a moment";
if (hintEl) hintEl.textContent = "Setup required before launch";
return;
}
if (isInstalling) {
targetProgress = Math.max(targetProgress, 0.72);
if (subtitleEl) subtitleEl.textContent = "Please wait a moment";
if (hintEl) hintEl.textContent = "Installing required components";
return;
}
if (isChecking) {
targetProgress = Math.max(targetProgress, 0.58);
if (subtitleEl) subtitleEl.textContent = "Please wait a moment";
if (hintEl) hintEl.textContent = "Checking required components";
return;
}
if (isReady) {
targetProgress = 0.88;
if (subtitleEl) subtitleEl.textContent = "Please wait a moment";
if (hintEl) hintEl.textContent = "Opening your workspace";
return;
}
targetProgress = Math.max(targetProgress, 0.68);
if (subtitleEl) subtitleEl.textContent = "Please wait a moment";
if (hintEl) hintEl.textContent = "Preparing your workspace";
}
function setStatus(name, status) {
if (!(name in currentStatus)) {
return;
}
currentStatus[name] = status;
updateStateCopy();
}
function animateProgress() {
visualProgress += (targetProgress - visualProgress) * 0.08;
applyProgress(visualProgress);
window.requestAnimationFrame(animateProgress);
}
updateStateCopy();
applyProgress(visualProgress);
animateProgress();
window.setInterval(() => {
if (targetProgress < 0.82) {
targetProgress = Math.min(0.82, targetProgress + 0.03);
}
}, 1200);
if (window.electron?.onStartupStatus) {
window.electron.onStartupStatus((payload) => {
if (!payload) return;
setStatus(payload.name, payload.status);
});
}
if (window.electron?.getStartupStatus) {
window.electron.getStartupStatus().then((statusMap) => {
if (!statusMap) return;
if (statusMap.libreoffice) setStatus("libreoffice", statusMap.libreoffice);
if (statusMap.puppeteer) setStatus("puppeteer", statusMap.puppeteer);
if (statusMap.imagemagick) setStatus("imagemagick", statusMap.imagemagick);
if (statusMap.imagemagick) {
setStatus("imagemagick", statusMap.imagemagick);
}
});
}
});
window.addEventListener("beforeunload", () => {
targetProgress = 1;
applyProgress(1);
});
});

View file

@ -70,10 +70,6 @@ function syncMigrations() {
throw new Error(`No migration files found in source directory: ${sourceDir}`);
}
for (const filename of listPythonMigrations(targetDir)) {
fs.unlinkSync(path.join(targetDir, filename));
}
for (const filename of sourceFiles) {
fs.copyFileSync(path.join(sourceDir, filename), path.join(targetDir, filename));
}
@ -82,7 +78,7 @@ function syncMigrations() {
validateSingleHead(targetDir, targetFiles);
console.log(
`Synced ${targetFiles.length} migration files and verified a single Alembic head.`
`Synced ${sourceFiles.length} migration files into ${targetFiles.length} target migrations and verified a single Alembic head.`
);
}

View file

@ -24,6 +24,7 @@ from models.sql.presentation_layout_code import ( # noqa: F401, E402
)
from models.sql.slide import SlideModel # noqa: F401, E402
from models.sql.template import TemplateModel # noqa: F401, E402
from models.sql.template_create_info import TemplateCreateInfoModel # noqa: F401, E402
from models.sql.webhook_subscription import WebhookSubscription # noqa: F401, E402
alembic_config = context.config

View file

@ -0,0 +1,39 @@
"""template create info
Revision ID: 95b5127e93cd
Revises: 82abdbc476a7
Create Date: 2026-04-08 13:44:21.132802
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision: str = '95b5127e93cd'
down_revision: Union[str, None] = '82abdbc476a7'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('template_create_infos',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('fonts', sa.JSON(), nullable=True),
sa.Column('pptx_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('slide_htmls', sa.JSON(), nullable=False),
sa.Column('slide_image_urls', sa.JSON(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('template_create_infos')
# ### end Alembic commands ###

View file

@ -16,25 +16,32 @@ from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from utils.oauth.openai_codex import (
CodexAccountProfile,
OAuthCallbackServer,
TokenSuccess,
create_authorization_flow,
exchange_authorization_code,
get_account_id,
get_account_profile,
parse_authorization_input,
refresh_access_token,
)
from utils.get_env import (
get_codex_access_token_env,
get_codex_email_env,
get_codex_is_pro_env,
get_codex_refresh_token_env,
get_codex_token_expires_env,
get_codex_username_env,
)
from utils.set_env import (
set_codex_access_token_env,
set_codex_account_id_env,
set_codex_email_env,
set_codex_is_pro_env,
set_codex_refresh_token_env,
set_codex_token_expires_env,
set_codex_model_env,
set_codex_username_env,
)
from utils.user_config import save_codex_tokens_to_user_config
@ -60,6 +67,9 @@ class InitiateResponse(BaseModel):
class StatusResponse(BaseModel):
status: str # "pending" | "success" | "failed"
account_id: Optional[str] = None
username: Optional[str] = None
email: Optional[str] = None
is_pro: Optional[bool] = None
detail: Optional[str] = None
@ -69,11 +79,17 @@ class ExchangeRequest(BaseModel):
class ExchangeResponse(BaseModel):
account_id: str
account_id: Optional[str] = None
username: Optional[str] = None
email: Optional[str] = None
is_pro: Optional[bool] = None
class RefreshResponse(BaseModel):
account_id: Optional[str]
username: Optional[str] = None
email: Optional[str] = None
is_pro: Optional[bool] = None
detail: str
@ -81,16 +97,31 @@ class RefreshResponse(BaseModel):
# Helper
# ---------------------------------------------------------------------------
def _store_token(result: TokenSuccess) -> Optional[str]:
"""Persist token fields in env vars and userConfig.json. Returns account_id or None."""
def _parse_optional_bool(value: Optional[str]) -> Optional[bool]:
if value is None:
return None
normalized = value.strip().lower()
if normalized in {"true", "1", "yes", "y"}:
return True
if normalized in {"false", "0", "no", "n"}:
return False
return None
def _store_token(result: TokenSuccess) -> CodexAccountProfile:
"""Persist token fields in env vars and userConfig.json. Returns parsed profile."""
set_codex_access_token_env(result.access)
set_codex_refresh_token_env(result.refresh)
set_codex_token_expires_env(str(result.expires))
account_id = get_account_id(result.access)
if account_id:
set_codex_account_id_env(account_id)
profile = get_account_profile(result.access, result.id_token)
set_codex_account_id_env(profile.account_id or "")
set_codex_username_env(profile.username or "")
set_codex_email_env(profile.email or "")
set_codex_is_pro_env("" if profile.is_pro is None else str(profile.is_pro))
save_codex_tokens_to_user_config()
return account_id
return profile
# ---------------------------------------------------------------------------
@ -166,8 +197,14 @@ async def poll_codex_auth_status(session_id: str):
if not isinstance(result, TokenSuccess):
return StatusResponse(status="failed", detail=result.reason)
account_id = _store_token(result)
return StatusResponse(status="success", account_id=account_id)
profile = _store_token(result)
return StatusResponse(
status="success",
account_id=profile.account_id,
username=profile.username,
email=profile.email,
is_pro=profile.is_pro,
)
@CODEX_AUTH_ROUTER.post("/exchange", response_model=ExchangeResponse)
@ -207,11 +244,16 @@ async def exchange_codex_code(body: ExchangeRequest):
if not isinstance(result, TokenSuccess):
raise HTTPException(status_code=502, detail=f"Token exchange failed: {result.reason}")
account_id = _store_token(result)
if not account_id:
profile = _store_token(result)
if not profile.account_id:
raise HTTPException(status_code=502, detail="Token exchanged but could not extract account ID")
return ExchangeResponse(account_id=account_id)
return ExchangeResponse(
account_id=profile.account_id,
username=profile.username,
email=profile.email,
is_pro=profile.is_pro,
)
@CODEX_AUTH_ROUTER.post("/refresh", response_model=RefreshResponse)
@ -232,9 +274,12 @@ async def refresh_codex_token():
if not isinstance(result, TokenSuccess):
raise HTTPException(status_code=502, detail=f"Token refresh failed: {result.reason}")
account_id = _store_token(result)
profile = _store_token(result)
return RefreshResponse(
account_id=account_id,
account_id=profile.account_id,
username=profile.username,
email=profile.email,
is_pro=profile.is_pro,
detail="Token refreshed successfully",
)
@ -260,8 +305,18 @@ async def get_codex_auth_status():
except (ValueError, TypeError):
pass
account_id = get_account_id(access_token)
return StatusResponse(status="authenticated", account_id=account_id)
profile = get_account_profile(access_token)
return StatusResponse(
status="authenticated",
account_id=profile.account_id,
username=profile.username or get_codex_username_env(),
email=profile.email or get_codex_email_env(),
is_pro=(
profile.is_pro
if profile.is_pro is not None
else _parse_optional_bool(get_codex_is_pro_env())
),
)
@CODEX_AUTH_ROUTER.post("/logout")
@ -273,6 +328,9 @@ async def logout_codex():
set_codex_refresh_token_env("")
set_codex_token_expires_env("")
set_codex_account_id_env("")
set_codex_username_env("")
set_codex_email_env("")
set_codex_is_pro_env("")
set_codex_model_env("")
save_codex_tokens_to_user_config()
return {"detail": "Logged out successfully"}

View file

@ -1,12 +1,11 @@
import os
import uuid
import shutil
from pathlib import Path
from typing import List, Dict, Any, Optional
from fastapi import APIRouter, HTTPException, File, UploadFile
from pydantic import BaseModel
from templates.preview import FontCheckResponse, check_fonts_in_pptx_handler
from utils.asset_directory_utils import get_app_data_directory_env
import uuid
try:
from fontTools.ttLib import TTFont
@ -287,6 +286,9 @@ async def get_uploaded_fonts():
)
FONTS_ROUTER.post("/check", response_model=FontCheckResponse)(check_fonts_in_pptx_handler)
@FONTS_ROUTER.delete("/delete/{filename}")
async def delete_font(filename: str):
"""
@ -330,4 +332,4 @@ async def delete_font(filename: str):
raise HTTPException(
status_code=500,
detail=f"Error deleting font: {str(e)}"
)
)

View file

@ -1,5 +1,5 @@
from typing import List
from fastapi import APIRouter, Depends, File, UploadFile, HTTPException
from fastapi import APIRouter, Depends, File, UploadFile, HTTPException, Query, Header
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import select
@ -8,6 +8,9 @@ from models.sql.image_asset import ImageAsset
from services.database import get_async_session
from services.image_generation_service import ImageGenerationService
from utils.asset_directory_utils import get_images_directory
from utils.get_env import get_pexels_api_key_env, get_pixabay_api_key_env
from utils.image_provider import get_selected_image_provider
from enums.image_provider import ImageProvider
import os
import uuid
from utils.file_utils import get_file_name_with_random_uuid
@ -15,6 +18,75 @@ from utils.file_utils import get_file_name_with_random_uuid
IMAGES_ROUTER = APIRouter(prefix="/images", tags=["Images"])
def _normalize_stock_provider(provider: str | None) -> str:
normalized_provider = (provider or "").strip().lower()
if normalized_provider in {"pixels", "pixel", "pexel"}:
normalized_provider = "pexels"
if normalized_provider:
if normalized_provider in {"pexels", "pixabay"}:
return normalized_provider
raise HTTPException(
status_code=400,
detail="provider must be either 'pexels' or 'pixabay'",
)
selected_provider = get_selected_image_provider()
if selected_provider == ImageProvider.PIXABAY:
return "pixabay"
return "pexels"
@IMAGES_ROUTER.get("/search", response_model=List[str])
async def search_stock_images(
query: str,
limit: int = Query(default=12, ge=1, le=30),
provider: str | None = Query(default=None),
strict_api_key: bool = Query(default=False),
x_provider_api_key: str | None = Header(default=None, alias="X-Provider-Api-Key"),
):
normalized_provider = _normalize_stock_provider(provider)
image_generation_service = ImageGenerationService(get_images_directory())
if normalized_provider == "pexels":
api_key = (x_provider_api_key or get_pexels_api_key_env() or "").strip()
if strict_api_key and not api_key:
raise HTTPException(status_code=401, detail="Pexels API key is required")
# Pexels can return cached public responses for common queries.
# Use a nonce query in strict mode to force a real auth check.
if strict_api_key:
validation_query = f"__presenton_auth_check_{uuid.uuid4().hex}"
await image_generation_service.get_image_from_pexels(
validation_query,
api_key=api_key,
limit=1,
)
images = await image_generation_service.get_image_from_pexels(
query,
api_key=api_key,
limit=limit,
)
if isinstance(images, str):
return [images] if images else []
return images
api_key = (x_provider_api_key or get_pixabay_api_key_env() or "").strip()
if strict_api_key and not api_key:
raise HTTPException(status_code=401, detail="Pixabay API key is required")
images = await image_generation_service.get_image_from_pixabay(
query,
api_key=api_key,
limit=limit,
)
if isinstance(images, str):
return [images] if images else []
return images
@IMAGES_ROUTER.get("/generate")
async def generate_image(
prompt: str, sql_session: AsyncSession = Depends(get_async_session)

View file

@ -1,8 +1,7 @@
from fastapi import APIRouter, HTTPException
import aiohttp
from typing import List, Any
from utils.get_layout_by_name import get_layout_by_name
from models.presentation_layout import PresentationLayoutModel
from templates.get_layout_by_name import get_layout_by_name
from templates.presentation_layout import PresentationLayoutModel
LAYOUTS_ROUTER = APIRouter(prefix="/layouts", tags=["Layouts"])

View file

@ -1,6 +1,5 @@
import asyncio
import json
import math
import traceback
import uuid
import dirtyjson
@ -19,8 +18,11 @@ from models.sse_response import (
from services.temp_file_service import TEMP_FILE_SERVICE
from services.database import get_async_session
from services.documents_loader import DocumentsLoader
from utils.outline_utils import (
get_no_of_outlines_to_generate_for_n_slides,
get_presentation_title_from_presentation_outline,
)
from utils.llm_calls.generate_presentation_outlines import generate_ppt_outline
from utils.ppt_utils import get_presentation_title_from_outlines
OUTLINES_ROUTER = APIRouter(prefix="/outlines", tags=["Outlines"])
@ -54,12 +56,14 @@ async def stream_outlines(
presentation_outlines_text = ""
n_slides_to_generate = presentation.n_slides
if presentation.include_table_of_contents:
needed_toc_count = math.ceil((presentation.n_slides - 1) / 10)
n_slides_to_generate -= math.ceil(
(presentation.n_slides - needed_toc_count) / 10
if presentation.n_slides > 0:
n_slides_to_generate = get_no_of_outlines_to_generate_for_n_slides(
n_slides=presentation.n_slides,
toc=presentation.include_table_of_contents,
title_slide=presentation.include_title_slide,
)
else:
n_slides_to_generate = None
async for chunk in generate_ppt_outline(
presentation.content,
@ -71,6 +75,7 @@ async def stream_outlines(
presentation.instructions,
presentation.include_title_slide,
presentation.web_search,
presentation.include_table_of_contents,
):
# Give control to the event loop
await asyncio.sleep(0)
@ -99,12 +104,30 @@ async def stream_outlines(
presentation_outlines = PresentationOutlineModel(**presentation_outlines_json)
presentation_outlines.slides = presentation_outlines.slides[
:n_slides_to_generate
]
if (
n_slides_to_generate is not None
and len(presentation_outlines.slides) != n_slides_to_generate
):
yield SSEErrorResponse(
detail=(
"Failed to generate presentation outlines with requested "
"number of slides. Please try again."
)
).to_string()
return
if n_slides_to_generate is not None:
presentation_outlines.slides = presentation_outlines.slides[
:n_slides_to_generate
]
if presentation.n_slides <= 0:
presentation.n_slides = len(presentation_outlines.slides)
presentation.outlines = presentation_outlines.model_dump()
presentation.title = get_presentation_title_from_outlines(presentation_outlines)
presentation.title = get_presentation_title_from_presentation_outline(
presentation_outlines
)
sql_session.add(presentation)
await sql_session.commit()

View file

@ -1,116 +0,0 @@
import os
import shutil
import tempfile
import subprocess
from typing import List, Optional
from fastapi import APIRouter, UploadFile, File, HTTPException
from pydantic import BaseModel
from services.documents_loader import DocumentsLoader
from utils.asset_directory_utils import get_images_directory
import uuid
from constants.documents import PDF_MIME_TYPES
PDF_SLIDES_ROUTER = APIRouter(prefix="/pdf-slides", tags=["PDF Slides"])
class PdfSlideData(BaseModel):
slide_number: int
screenshot_url: str
class PdfSlidesResponse(BaseModel):
success: bool
slides: List[PdfSlideData]
total_slides: int
@PDF_SLIDES_ROUTER.post("/process", response_model=PdfSlidesResponse)
async def process_pdf_slides(
pdf_file: UploadFile = File(..., description="PDF file to process")
):
"""
Process a PDF file to extract slide screenshots.
This endpoint:
1. Validates the uploaded PDF file
2. Uses ImageMagick to convert PDF pages to PNG images
3. Returns screenshot URLs for each slide/page
Note: Font installation is not needed since PDFs already have fonts embedded.
"""
# Validate PDF file
if pdf_file.content_type not in PDF_MIME_TYPES:
raise HTTPException(
status_code=400,
detail=f"Invalid file type. Expected PDF file, got {pdf_file.content_type}",
)
# Enforce 100MB size limit
if (
hasattr(pdf_file, "size")
and pdf_file.size
and pdf_file.size > (100 * 1024 * 1024)
):
raise HTTPException(
status_code=400,
detail="PDF file exceeded max upload size of 100 MB",
)
# Create temporary directory for processing
with tempfile.TemporaryDirectory() as temp_dir:
try:
# Save uploaded PDF file
pdf_path = os.path.join(temp_dir, "presentation.pdf")
with open(pdf_path, "wb") as f:
pdf_content = await pdf_file.read()
f.write(pdf_content)
# Generate screenshots from PDF using ImageMagick
screenshot_paths = await DocumentsLoader.get_page_images_from_pdf_async(
pdf_path, temp_dir
)
print(f"Generated {len(screenshot_paths)} PDF screenshots")
# Move screenshots to images directory and generate URLs
images_dir = get_images_directory()
presentation_id = uuid.uuid4()
presentation_images_dir = os.path.join(images_dir, str(presentation_id))
os.makedirs(presentation_images_dir, exist_ok=True)
slides_data = []
for i, screenshot_path in enumerate(screenshot_paths, 1):
# Move screenshot to permanent location
screenshot_filename = f"slide_{i}.png"
permanent_screenshot_path = os.path.join(
presentation_images_dir, screenshot_filename
)
if (
os.path.exists(screenshot_path)
and os.path.getsize(screenshot_path) > 0
):
# Use shutil.copy2 instead of os.rename to handle cross-device moves
shutil.copy2(screenshot_path, permanent_screenshot_path)
screenshot_url = (
f"/app_data/images/{presentation_id}/{screenshot_filename}"
)
else:
# Fallback if screenshot generation failed or file is empty placeholder
screenshot_url = "/static/images/placeholder.jpg"
slides_data.append(
PdfSlideData(slide_number=i, screenshot_url=screenshot_url)
)
return PdfSlidesResponse(
success=True, slides=slides_data, total_slides=len(slides_data)
)
except Exception as e:
print(f"Error processing PDF slides: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Failed to process PDF: {str(e)}"
)

View file

@ -1,641 +0,0 @@
import os
import shutil
import zipfile
import tempfile
import subprocess
import uuid
from typing import List, Optional, Dict
from fastapi import APIRouter, UploadFile, File, HTTPException
from pydantic import BaseModel
import aiohttp
import asyncio
import xml.etree.ElementTree as ET
import re
from services.documents_loader import DocumentsLoader
from utils.asset_directory_utils import get_images_directory
import uuid
from constants.documents import PPTX_MIME_TYPES
def _get_soffice_binary() -> str:
"""Return the soffice binary to use for LibreOffice subprocess calls.
When running inside the Electron desktop app, the main process resolves the
exact soffice binary path at startup and forwards it via the ``SOFFICE_PATH``
environment variable. Falling back to the bare ``"soffice"`` command keeps
Docker / server deployments working unchanged.
"""
configured = os.environ.get("SOFFICE_PATH")
if configured:
return configured
return "soffice.exe" if os.name == "nt" else "soffice"
def _windows_hidden_subprocess_kwargs() -> Dict[str, object]:
"""Return subprocess kwargs that suppress Windows console windows."""
if os.name != "nt":
return {}
startupinfo = subprocess.STARTUPINFO()
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
return {
"creationflags": getattr(subprocess, "CREATE_NO_WINDOW", 0),
"startupinfo": startupinfo,
}
PPTX_SLIDES_ROUTER = APIRouter(prefix="/pptx-slides", tags=["PPTX Slides"])
class SlideData(BaseModel):
slide_number: int
screenshot_url: str
xml_content: str
normalized_fonts: List[str]
class FontAnalysisResult(BaseModel):
internally_supported_fonts: List[
Dict[str, str]
] # [{"name": "Open Sans", "google_fonts_url": "..."}]
not_supported_fonts: List[str] # ["Custom Font Name"]
class PptxSlidesResponse(BaseModel):
success: bool
slides: List[SlideData]
total_slides: int
fonts: Optional[FontAnalysisResult] = None
# NEW: Fonts-only router and response for PPTX
class PptxFontsResponse(BaseModel):
success: bool
fonts: FontAnalysisResult
PPTX_FONTS_ROUTER = APIRouter(prefix="/pptx-fonts", tags=["PPTX Fonts"])
# NEW: Normalize font family names by removing style/weight/stretch descriptors and splitting camel case
_STYLE_TOKENS = {
# styles
"italic",
"italics",
"ital",
"oblique",
"roman",
# combined style shortcuts
"bolditalic",
"bolditalics",
# weights
"thin",
"hairline",
"extralight",
"ultralight",
"light",
"demilight",
"semilight",
"book",
"regular",
"normal",
"medium",
"semibold",
"demibold",
"bold",
"extrabold",
"ultrabold",
"black",
"extrablack",
"ultrablack",
"heavy",
# width/stretch
"narrow",
"condensed",
"semicondensed",
"extracondensed",
"ultracondensed",
"expanded",
"semiexpanded",
"extraexpanded",
"ultraexpanded",
}
# Modifiers commonly used with style tokens
_STYLE_MODIFIERS = {"semi", "demi", "extra", "ultra"}
def _insert_spaces_in_camel_case(value: str) -> str:
# Insert space before capital letters preceded by lowercase or digits (e.g., MontserratBold -> Montserrat Bold)
value = re.sub(r"(?<=[a-z0-9])([A-Z])", r" \1", value)
# Handle sequences like BoldItalic -> Bold Italic
value = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1 \2", value)
return value
def normalize_font_family_name(raw_name: str) -> str:
if not raw_name:
return raw_name
# Replace separators with spaces
name = raw_name.replace("_", " ").replace("-", " ")
# Insert spaces in camel case
name = _insert_spaces_in_camel_case(name)
# Collapse multiple spaces
name = re.sub(r"\s+", " ", name).strip()
# Lowercase helper for matching but keep original casing for output
lower_name = name.lower()
# Quick cut: if the full string ends with a pure style suffix, trim it
for style in sorted(_STYLE_TOKENS, key=len, reverse=True):
if lower_name.endswith(" " + style):
name = name[: -(len(style) + 1)]
lower_name = lower_name[: -(len(style) + 1)]
break
# Tokenize
tokens_original = name.split(" ")
tokens_filtered: List[str] = []
for index, tok in enumerate(tokens_original):
lower_tok = tok.lower()
# Always keep the first token to avoid stripping families like "Black Ops One"
if index == 0:
tokens_filtered.append(tok)
continue
# Drop style tokens and standalone modifiers
if lower_tok in _STYLE_TOKENS or lower_tok in _STYLE_MODIFIERS:
continue
tokens_filtered.append(tok)
# If everything except first token was dropped and first token is a style token (unlikely), fallback to original
if not tokens_filtered:
tokens_filtered = tokens_original
normalized = " ".join(tokens_filtered).strip()
# Final cleanup of leftover multiple spaces
normalized = re.sub(r"\s+", " ", normalized)
return normalized
def extract_fonts_from_oxml(xml_content: str) -> List[str]:
"""
Extract font names from OXML content.
Args:
xml_content: OXML content as string
Returns:
List of unique font names found in the OXML
"""
fonts = set()
try:
# Parse the XML content
root = ET.fromstring(xml_content)
# Define namespaces commonly used in OXML
namespaces = {
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
"p": "http://schemas.openxmlformats.org/presentationml/2006/main",
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
}
# Search for font references in various OXML elements
# Look for latin fonts
for font_elem in root.findall(".//a:latin", namespaces):
if "typeface" in font_elem.attrib:
fonts.add(font_elem.attrib["typeface"])
# Look for east asian fonts
for font_elem in root.findall(".//a:ea", namespaces):
if "typeface" in font_elem.attrib:
fonts.add(font_elem.attrib["typeface"])
# Look for complex script fonts
for font_elem in root.findall(".//a:cs", namespaces):
if "typeface" in font_elem.attrib:
fonts.add(font_elem.attrib["typeface"])
# Look for font references in theme elements
for font_elem in root.findall(".//a:font", namespaces):
if "typeface" in font_elem.attrib:
fonts.add(font_elem.attrib["typeface"])
# Look for rPr (run properties) font references
for rpr_elem in root.findall(".//a:rPr", namespaces):
for font_elem in rpr_elem.findall(".//a:latin", namespaces):
if "typeface" in font_elem.attrib:
fonts.add(font_elem.attrib["typeface"])
# Also search without namespace prefix for compatibility
for font_elem in root.findall(".//latin"):
if "typeface" in font_elem.attrib:
fonts.add(font_elem.attrib["typeface"])
# Regex fallback for fonts that might be missed
font_pattern = r'typeface="([^"]+)"'
regex_fonts = re.findall(font_pattern, xml_content)
fonts.update(regex_fonts)
# Filter out system fonts and empty values
system_fonts = {"+mn-lt", "+mj-lt", "+mn-ea", "+mj-ea", "+mn-cs", "+mj-cs", ""}
fonts = {font for font in fonts if font not in system_fonts and font.strip()}
return list(fonts)
except Exception as e:
print(f"Error extracting fonts from OXML: {e}")
return []
async def check_google_font_availability(font_name: str) -> bool:
"""
Check if a font is available in Google Fonts.
Args:
font_name: Name of the font to check
Returns:
True if font is available in Google Fonts, False otherwise
"""
try:
formatted_name = font_name.replace(" ", "+")
url = f"https://fonts.googleapis.com/css2?family={formatted_name}&display=swap"
async with aiohttp.ClientSession() as session:
async with session.head(
url, timeout=aiohttp.ClientTimeout(total=10)
) as response:
return response.status == 200
except Exception as e:
print(f"Error checking Google Font availability for {font_name}: {e}")
return False
async def analyze_fonts_in_all_slides(slide_xmls: List[str]) -> FontAnalysisResult:
"""
Analyze fonts across all slides and determine Google Fonts availability.
Args:
slide_xmls: List of OXML content strings from all slides
Returns:
FontAnalysisResult with supported and unsupported fonts
"""
# Extract fonts from all slides
raw_fonts = set()
for xml_content in slide_xmls:
slide_fonts = extract_fonts_from_oxml(xml_content)
raw_fonts.update(slide_fonts)
# Normalize to root families (e.g., "Montserrat Italic" -> "Montserrat")
normalized_fonts = {normalize_font_family_name(f) for f in raw_fonts}
# Remove empties if any
normalized_fonts = {f for f in normalized_fonts if f}
if not normalized_fonts:
return FontAnalysisResult(internally_supported_fonts=[], not_supported_fonts=[])
# Check each normalized font's availability in Google Fonts concurrently
tasks = [check_google_font_availability(font) for font in normalized_fonts]
results = await asyncio.gather(*tasks)
internally_supported_fonts = []
not_supported_fonts = []
for font, is_available in zip(normalized_fonts, results):
if is_available:
formatted_name = font.replace(" ", "+")
google_fonts_url = f"https://fonts.googleapis.com/css2?family={formatted_name}&display=swap"
internally_supported_fonts.append(
{"name": font, "google_fonts_url": google_fonts_url}
)
else:
not_supported_fonts.append(font)
return FontAnalysisResult(
internally_supported_fonts=internally_supported_fonts, not_supported_fonts=[]
)
@PPTX_SLIDES_ROUTER.post("/process", response_model=PptxSlidesResponse)
async def process_pptx_slides(
pptx_file: UploadFile = File(..., description="PPTX file to process"),
fonts: Optional[List[UploadFile]] = File(None, description="Optional font files"),
):
"""
Process a PPTX file to extract slide screenshots and XML content.
This endpoint:
1. Validates the uploaded PPTX file
2. Installs any provided font files
3. Unzips the PPTX to extract slide XMLs
4. Uses LibreOffice to generate slide screenshots
5. Returns both screenshot URLs and XML content for each slide
"""
# Validate PPTX file
if pptx_file.content_type not in PPTX_MIME_TYPES:
raise HTTPException(
status_code=400,
detail=f"Invalid file type. Expected PPTX file, got {pptx_file.content_type}",
)
# Enforce 100MB size limit
if (
hasattr(pptx_file, "size")
and pptx_file.size
and pptx_file.size > (100 * 1024 * 1024)
):
raise HTTPException(
status_code=400,
detail="PPTX file exceeded max upload size of 100 MB",
)
# Create temporary directory for processing
with tempfile.TemporaryDirectory() as temp_dir:
if True:
# Save uploaded PPTX file
pptx_path = os.path.join(temp_dir, "presentation.pptx")
with open(pptx_path, "wb") as f:
pptx_content = await pptx_file.read()
f.write(pptx_content)
# Install fonts if provided
if fonts:
await _install_fonts(fonts, temp_dir)
# Extract slide XMLs from PPTX
slide_xmls = _extract_slide_xmls(pptx_path, temp_dir)
# Convert PPTX to PDF
pdf_path = await _convert_pptx_to_pdf(pptx_path, temp_dir)
# Generate screenshots using LibreOffice
screenshot_paths = await DocumentsLoader.get_page_images_from_pdf_async(
pdf_path, temp_dir
)
print(f"Screenshot paths: {screenshot_paths}")
# Analyze fonts across all slides
font_analysis = await analyze_fonts_in_all_slides(slide_xmls)
print(
f"Font analysis completed: {len(font_analysis.internally_supported_fonts)} supported, {len(font_analysis.not_supported_fonts)} not supported"
)
# Move screenshots to images directory and generate URLs
images_dir = get_images_directory()
presentation_id = uuid.uuid4()
presentation_images_dir = os.path.join(images_dir, str(presentation_id))
os.makedirs(presentation_images_dir, exist_ok=True)
slides_data = []
for i, (xml_content, screenshot_path) in enumerate(
zip(slide_xmls, screenshot_paths), 1
):
# Move screenshot to permanent location
screenshot_filename = f"slide_{i}.png"
permanent_screenshot_path = os.path.join(
presentation_images_dir, screenshot_filename
)
if (
os.path.exists(screenshot_path)
and os.path.getsize(screenshot_path) > 0
):
# Use shutil.copy2 instead of os.rename to handle cross-device moves
shutil.copy2(screenshot_path, permanent_screenshot_path)
screenshot_url = (
f"/app_data/images/{presentation_id}/{screenshot_filename}"
)
else:
# Fallback if screenshot generation failed or file is empty placeholder
screenshot_url = "/static/images/placeholder.jpg"
# Compute normalized fonts for this slide
raw_slide_fonts = extract_fonts_from_oxml(xml_content)
normalized_fonts = sorted(
{normalize_font_family_name(f) for f in raw_slide_fonts if f}
)
slides_data.append(
SlideData(
slide_number=i,
screenshot_url=screenshot_url,
xml_content=xml_content,
normalized_fonts=normalized_fonts,
)
)
return PptxSlidesResponse(
success=True,
slides=slides_data,
total_slides=len(slides_data),
fonts=font_analysis,
)
# NEW: Fonts-only endpoint leveraging the same font extraction/analysis
@PPTX_FONTS_ROUTER.post("/process", response_model=PptxFontsResponse)
async def process_pptx_fonts(
pptx_file: UploadFile = File(..., description="PPTX file to analyze fonts from")
):
"""
Analyze a PPTX file and return only the fonts used in the document.
Uses the exact same font extraction and analysis utilities as the /pptx-slides endpoint.
"""
# Validate PPTX file
if pptx_file.content_type not in PPTX_MIME_TYPES:
raise HTTPException(
status_code=400,
detail=f"Invalid file type. Expected PPTX file, got {pptx_file.content_type}",
)
# Create temporary directory for processing
with tempfile.TemporaryDirectory() as temp_dir:
# Save uploaded PPTX file
pptx_path = os.path.join(temp_dir, "presentation.pptx")
with open(pptx_path, "wb") as f:
pptx_content = await pptx_file.read()
f.write(pptx_content)
# Extract slide XMLs from PPTX
slide_xmls = _extract_slide_xmls(pptx_path, temp_dir)
# Analyze fonts across all slides (same logic as in /pptx-slides)
font_analysis = await analyze_fonts_in_all_slides(slide_xmls)
return PptxFontsResponse(
success=True,
fonts=font_analysis,
)
def _create_font_alias_config(raw_fonts: List[str]) -> str:
"""Create a temporary fontconfig configuration that aliases variant family names to normalized root families.
Returns the path to the config file.
"""
# Build mapping from raw -> normalized where different
mappings: Dict[str, str] = {}
for f in raw_fonts:
normalized = normalize_font_family_name(f)
if normalized and normalized != f:
mappings[f] = normalized
# Create config only if we have mappings
fd, fonts_conf_path = tempfile.mkstemp(prefix="fonts_alias_", suffix=".conf")
os.close(fd)
with open(fonts_conf_path, "w", encoding="utf-8") as cfg:
cfg.write(
"""<?xml version='1.0'?>
<!DOCTYPE fontconfig SYSTEM "urn:fontconfig:fonts.dtd">
<fontconfig>
<include>/etc/fonts/fonts.conf</include>
"""
)
for src, dst in mappings.items():
cfg.write(
f"""
<match target="pattern">
<test name="family" compare="eq">
<string>{src}</string>
</test>
<edit name="family" mode="assign" binding="strong">
<string>{dst}</string>
</edit>
</match>
"""
)
cfg.write("\n</fontconfig>\n")
return fonts_conf_path
async def _install_fonts(fonts: List[UploadFile], temp_dir: str) -> None:
"""Install provided font files to the system."""
fonts_dir = os.path.join(temp_dir, "fonts")
os.makedirs(fonts_dir, exist_ok=True)
for font_file in fonts:
# Save font file
font_path = os.path.join(fonts_dir, font_file.filename)
with open(font_path, "wb") as f:
font_content = await font_file.read()
f.write(font_content)
# Install font (copy to system fonts directory)
try:
subprocess.run(
["cp", font_path, "/usr/share/fonts/truetype/"],
check=True,
capture_output=True,
)
except subprocess.CalledProcessError as e:
print(f"Warning: Failed to install font {font_file.filename}: {e}")
# Refresh font cache
try:
subprocess.run(["fc-cache", "-f", "-v"], check=True, capture_output=True)
except subprocess.CalledProcessError as e:
print(f"Warning: Failed to refresh font cache: {e}")
def _extract_slide_xmls(pptx_path: str, temp_dir: str) -> List[str]:
"""Extract slide XML content from PPTX file."""
slide_xmls = []
extract_dir = os.path.join(temp_dir, "pptx_extract")
try:
# Unzip PPTX file
with zipfile.ZipFile(pptx_path, "r") as zip_ref:
zip_ref.extractall(extract_dir)
# Look for slides in ppt/slides/ directory
slides_dir = os.path.join(extract_dir, "ppt", "slides")
if not os.path.exists(slides_dir):
raise Exception("No slides directory found in PPTX file")
# Get all slide XML files and sort them numerically
slide_files = [
f
for f in os.listdir(slides_dir)
if f.startswith("slide") and f.endswith(".xml")
]
slide_files.sort(key=lambda x: int(x.replace("slide", "").replace(".xml", "")))
# Read XML content from each slide
for slide_file in slide_files:
slide_path = os.path.join(slides_dir, slide_file)
with open(slide_path, "r", encoding="utf-8") as f:
slide_xmls.append(f.read())
return slide_xmls
except Exception as e:
raise Exception(f"Failed to extract slide XMLs: {str(e)}")
async def _convert_pptx_to_pdf(pptx_path: str, temp_dir: str) -> str:
"""Generate PNG screenshots of PPTX slides using LibreOffice + ImageMagick."""
screenshots_dir = os.path.join(temp_dir, "screenshots")
os.makedirs(screenshots_dir, exist_ok=True)
try:
# First, get the number of slides by extracting XMLs
slide_xmls = _extract_slide_xmls(pptx_path, temp_dir)
slide_count = len(slide_xmls)
# Build font alias config to force variant families to resolve to normalized root families
raw_fonts: List[str] = []
for xml in slide_xmls:
raw_fonts.extend(extract_fonts_from_oxml(xml))
raw_fonts = list({f for f in raw_fonts if f})
fonts_conf_path = _create_font_alias_config(raw_fonts)
env = os.environ.copy()
env["FONTCONFIG_FILE"] = fonts_conf_path
print(f"Found {slide_count} slides in presentation")
# Step 1: Convert PPTX to PDF using LibreOffice
print("Starting LibreOffice PDF conversion...")
pdf_filename = "temp_presentation.pdf"
pdf_path = os.path.join(screenshots_dir, pdf_filename)
try:
result = subprocess.run(
[
_get_soffice_binary(),
"--headless",
"--convert-to",
"pdf",
"--outdir",
screenshots_dir,
pptx_path,
],
check=True,
capture_output=True,
text=True,
timeout=500,
env=env,
**_windows_hidden_subprocess_kwargs(),
)
print(f"LibreOffice PDF conversion output: {result.stdout}")
if result.stderr:
print(f"LibreOffice PDF conversion warnings: {result.stderr}")
except subprocess.TimeoutExpired:
raise Exception("LibreOffice PDF conversion timed out after 120 seconds")
except subprocess.CalledProcessError as e:
error_msg = e.stderr if e.stderr else str(e)
raise Exception(f"LibreOffice PDF conversion failed: {error_msg}")
# Find the generated PDF file (LibreOffice uses original filename)
pdf_files = [f for f in os.listdir(screenshots_dir) if f.endswith(".pdf")]
if not pdf_files:
raise Exception("LibreOffice failed to generate PDF file")
actual_pdf_path = os.path.join(screenshots_dir, pdf_files[0])
print(f"Generated PDF: {actual_pdf_path}")
return actual_pdf_path
except Exception as e:
# Re-raise the specific exceptions we've already handled
if "timed out" in str(e) or "failed:" in str(e):
raise
# Handle any other unexpected exceptions
raise Exception(f"Screenshot generation failed: {str(e)}")

View file

@ -1,7 +1,6 @@
import asyncio
from datetime import datetime
import json
import math
import os
import random
import traceback
@ -12,7 +11,7 @@ from fastapi.responses import StreamingResponse
from sqlalchemy import delete
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import select
from constants.presentation import DEFAULT_TEMPLATES
from constants.presentation import DEFAULT_TEMPLATES, MAX_NUMBER_OF_SLIDES
from enums.webhook_event import WebhookEvent
from models.api_error_model import APIErrorModel
from models.generate_presentation_request import GeneratePresentationRequest
@ -25,16 +24,13 @@ from models.presentation_outline_model import (
from enums.tone import Tone
from enums.verbosity import Verbosity
from models.pptx_models import PptxPresentationModel
from models.presentation_layout import PresentationLayoutModel
from models.presentation_structure_model import PresentationStructureModel
from models.presentation_with_slides import (
PresentationWithSlides,
)
from models.sql.template import TemplateModel
from services.documents_loader import DocumentsLoader
from services.webhook_service import WebhookService
from utils.get_layout_by_name import get_layout_by_name
from services.image_generation_service import ImageGenerationService
from utils.dict_utils import deep_update
from utils.export_utils import export_presentation
@ -58,19 +54,41 @@ from utils.llm_calls.generate_slide_content import (
get_slide_content_from_type_and_outline,
)
from utils.ppt_utils import (
get_presentation_title_from_outlines,
select_toc_or_list_slide_layout_index,
)
from utils.outline_utils import (
get_images_for_slides_from_outline,
get_no_of_outlines_to_generate_for_n_slides,
get_no_of_toc_required_for_n_outlines,
get_presentation_outline_model_with_toc,
get_presentation_title_from_presentation_outline,
)
from utils.process_slides import (
process_slide_add_placeholder_assets,
process_slide_and_fetch_assets,
)
from templates.get_layout_by_name import get_layout_by_name
from templates.presentation_layout import PresentationLayoutModel
import uuid
PRESENTATION_ROUTER = APIRouter(prefix="/presentation", tags=["Presentation"])
def _insert_toc_layouts(
structure: PresentationStructureModel,
n_toc_slides: int,
include_title_slide: bool,
toc_slide_layout_index: int,
):
if n_toc_slides <= 0 or toc_slide_layout_index == -1:
return
insertion_index = 1 if include_title_slide else 0
for i in range(n_toc_slides):
structure.slides.insert(insertion_index + i, toc_slide_layout_index)
@PRESENTATION_ROUTER.get("/all", response_model=List[PresentationWithSlides])
async def get_all_presentations(sql_session: AsyncSession = Depends(get_async_session)):
presentations_with_slides = []
@ -129,8 +147,8 @@ async def delete_presentation(
@PRESENTATION_ROUTER.post("/create", response_model=PresentationModel)
async def create_presentation(
content: Annotated[str, Body()],
n_slides: Annotated[int, Body()],
language: Annotated[str, Body()],
n_slides: Annotated[Optional[int], Body()] = None,
language: Annotated[Optional[str], Body()] = None,
file_paths: Annotated[Optional[List[str]], Body()] = None,
tone: Annotated[Tone, Body()] = Tone.DEFAULT,
verbosity: Annotated[Verbosity, Body()] = Verbosity.STANDARD,
@ -141,19 +159,34 @@ async def create_presentation(
sql_session: AsyncSession = Depends(get_async_session),
):
if include_table_of_contents and n_slides < 3:
if n_slides is not None and n_slides < 1:
raise HTTPException(
status_code=400,
detail="Number of slides must be greater than 0",
)
if n_slides is not None and n_slides > MAX_NUMBER_OF_SLIDES:
raise HTTPException(
status_code=400,
detail=f"Number of slides cannot be greater than {MAX_NUMBER_OF_SLIDES}",
)
if include_table_of_contents and n_slides is not None and n_slides < 3:
raise HTTPException(
status_code=400,
detail="Number of slides cannot be less than 3 if table of contents is included",
)
presentation_id = uuid.uuid4()
language_to_store = (language or "").strip()
# DB schema stores an int; 0 is used as internal marker for auto slide count.
n_slides_to_store = n_slides if n_slides is not None else 0
presentation = PresentationModel(
id=presentation_id,
content=content,
n_slides=n_slides,
language=language,
n_slides=n_slides_to_store,
language=language_to_store,
file_paths=file_paths,
tone=tone.value,
verbosity=verbosity.value,
@ -210,40 +243,24 @@ async def prepare_presentation(
presentation_structure.slides[index] = random_slide_index
if presentation.include_table_of_contents:
n_toc_slides = presentation.n_slides - total_outlines
n_toc_slides = get_no_of_toc_required_for_n_outlines(
n_outlines=total_outlines,
title_slide=presentation.include_title_slide,
target_total_slides=(presentation.n_slides if presentation.n_slides > 0 else None),
)
toc_slide_layout_index = select_toc_or_list_slide_layout_index(layout)
if toc_slide_layout_index != -1:
outline_index = 1 if presentation.include_title_slide else 0
for i in range(n_toc_slides):
outlines_to = outline_index + 10
if total_outlines == outlines_to:
outlines_to -= 1
presentation_structure.slides.insert(
i + 1 if presentation.include_title_slide else i,
toc_slide_layout_index,
)
toc_outline = "Table of Contents\n\n"
for outline in presentation_outline_model.slides[
outline_index:outlines_to
]:
page_number = (
outline_index - i + n_toc_slides + 1
if presentation.include_title_slide
else outline_index - i + n_toc_slides
)
toc_outline += f"Slide page number: {page_number}\n Slide Content: {outline.content[:100]}\n\n"
outline_index += 1
outline_index += 1
presentation_outline_model.slides.insert(
i + 1 if presentation.include_title_slide else i,
SlideOutlineModel(
content=toc_outline,
),
)
_insert_toc_layouts(
presentation_structure,
n_toc_slides,
presentation.include_title_slide,
toc_slide_layout_index,
)
if toc_slide_layout_index != -1 and n_toc_slides > 0:
presentation_outline_model = get_presentation_outline_model_with_toc(
outline=presentation_outline_model,
n_toc_slides=n_toc_slides,
title_slide=presentation.include_title_slide,
)
sql_session.add(presentation)
presentation.outlines = presentation_outline_model.model_dump(mode="json")
@ -279,6 +296,7 @@ async def stream_presentation(
structure = presentation.get_structure()
layout = presentation.get_layout()
outline = presentation.get_presentation_outline()
image_urls_for_slides = get_images_for_slides_from_outline(outline.slides)
# These tasks will be gathered and awaited after all slides are generated
async_assets_generation_tasks = []
@ -319,7 +337,17 @@ async def stream_presentation(
# This will mutate slide - start task immediately so it runs in parallel with next slide LLM generation
async_assets_generation_tasks.append(
asyncio.create_task(process_slide_and_fetch_assets(image_generation_service, slide))
asyncio.create_task(
process_slide_and_fetch_assets(
image_generation_service,
slide,
outline_image_urls=(
image_urls_for_slides[i]
if i < len(image_urls_for_slides)
else None
),
)
)
)
yield SSEResponse(
@ -375,11 +403,11 @@ async def update_presentation(
raise HTTPException(status_code=404, detail="Presentation not found")
presentation_update_dict = {}
if n_slides:
if n_slides is not None:
presentation_update_dict["n_slides"] = n_slides
if title:
presentation_update_dict["title"] = title
if theme:
if theme or theme is None:
presentation_update_dict["theme"] = theme
if presentation_update_dict:
@ -465,13 +493,28 @@ async def check_if_api_request_is_valid(
detail="Either content or slides markdown or files is required to generate presentation",
)
# Making sure number of slides is greater than 0
if request.n_slides <= 0:
if request.n_slides is not None and request.n_slides <= 0:
raise HTTPException(
status_code=400,
detail="Number of slides must be greater than 0",
)
if request.n_slides is not None and request.n_slides > MAX_NUMBER_OF_SLIDES:
raise HTTPException(
status_code=400,
detail=f"Number of slides cannot be greater than {MAX_NUMBER_OF_SLIDES}",
)
if (
request.include_table_of_contents
and request.n_slides is not None
and request.n_slides < 3
):
raise HTTPException(
status_code=400,
detail="Number of slides cannot be less than 3 if table of contents is included",
)
# Checking if template is valid
if request.template not in DEFAULT_TEMPLATES:
request.template = request.template.lower()
@ -502,6 +545,7 @@ async def generate_presentation_handler(
):
try:
using_slides_markdown = False
language_to_use = (request.language or "").strip() or None
if request.slides_markdown:
using_slides_markdown = True
@ -529,30 +573,27 @@ async def generate_presentation_handler(
# Finding number of slides to generate by considering table of contents
n_slides_to_generate = request.n_slides
if request.include_table_of_contents:
needed_toc_count = math.ceil(
(
(request.n_slides - 1)
if request.include_title_slide
else request.n_slides
if request.include_table_of_contents and request.n_slides is not None:
n_slides_to_generate = (
get_no_of_outlines_to_generate_for_n_slides(
n_slides=request.n_slides,
toc=True,
title_slide=request.include_title_slide,
)
/ 10
)
n_slides_to_generate -= math.ceil(
(request.n_slides - needed_toc_count) / 10
)
presentation_outlines_text = ""
async for chunk in generate_ppt_outline(
request.content,
n_slides_to_generate,
request.language,
language_to_use,
additional_context,
request.tone.value,
request.verbosity.value,
request.instructions,
request.include_title_slide,
request.web_search,
request.include_table_of_contents,
):
if isinstance(chunk, HTTPException):
@ -573,7 +614,20 @@ async def generate_presentation_handler(
presentation_outlines = PresentationOutlineModel(
**presentation_outlines_json
)
total_outlines = n_slides_to_generate
if (
n_slides_to_generate is not None
and len(presentation_outlines.slides) != n_slides_to_generate
):
raise HTTPException(
status_code=400,
detail=(
"Failed to generate presentation outlines with requested "
"number of slides. Please try again."
),
)
total_outlines = len(presentation_outlines.slides)
else:
# Setting outlines to slides markdown
@ -621,50 +675,42 @@ async def generate_presentation_handler(
if presentation_structure.slides[index] >= total_slide_layouts:
presentation_structure.slides[index] = random_slide_index
# Injecting table of contents to the presentation structure and outlines
if request.include_table_of_contents and not using_slides_markdown:
n_toc_slides = request.n_slides - total_outlines
should_include_toc = (
request.include_table_of_contents and not using_slides_markdown
)
if should_include_toc:
n_toc_slides = get_no_of_toc_required_for_n_outlines(
n_outlines=total_outlines,
title_slide=request.include_title_slide,
target_total_slides=request.n_slides,
)
toc_slide_layout_index = select_toc_or_list_slide_layout_index(layout_model)
if toc_slide_layout_index != -1:
outline_index = 1 if request.include_title_slide else 0
for i in range(n_toc_slides):
outlines_to = outline_index + 10
if total_outlines == outlines_to:
outlines_to -= 1
_insert_toc_layouts(
presentation_structure,
n_toc_slides,
request.include_title_slide,
toc_slide_layout_index,
)
if toc_slide_layout_index != -1 and n_toc_slides > 0:
presentation_outlines = get_presentation_outline_model_with_toc(
outline=presentation_outlines,
n_toc_slides=n_toc_slides,
title_slide=request.include_title_slide,
)
presentation_structure.slides.insert(
i + 1 if request.include_title_slide else i,
toc_slide_layout_index,
)
toc_outline = "Table of Contents\n\n"
for outline in presentation_outlines.slides[
outline_index:outlines_to
]:
page_number = (
outline_index - i + n_toc_slides + 1
if request.include_title_slide
else outline_index - i + n_toc_slides
)
toc_outline += f"Slide page number: {page_number}\n Slide Content: {outline.content[:100]}\n\n"
outline_index += 1
outline_index += 1
presentation_outlines.slides.insert(
i + 1 if request.include_title_slide else i,
SlideOutlineModel(
content=toc_outline,
),
)
final_n_slides = request.n_slides
if final_n_slides is None:
final_n_slides = len(presentation_outlines.slides)
# Create PresentationModel
presentation = PresentationModel(
id=presentation_id,
content=request.content,
n_slides=request.n_slides,
language=request.language,
title=get_presentation_title_from_outlines(presentation_outlines),
n_slides=final_n_slides,
language=language_to_use or "",
title=get_presentation_title_from_presentation_outline(
presentation_outlines
),
outlines=presentation_outlines.model_dump(),
layout=layout_model.model_dump(),
structure=presentation_structure.model_dump(),
@ -701,7 +747,7 @@ async def generate_presentation_handler(
get_slide_content_from_type_and_outline(
slide_layouts[i],
presentation_outlines.slides[i],
request.language,
language_to_use,
request.tone.value,
request.verbosity.value,
request.instructions,
@ -726,10 +772,23 @@ async def generate_presentation_handler(
slides.append(slide)
batch_slides.append(slide)
if using_slides_markdown:
image_urls_for_batch = get_images_for_slides_from_outline(
presentation_outlines.slides[start:end]
)
else:
image_urls_for_batch = [[] for _ in batch_slides]
# Start asset fetch tasks immediately so they run in parallel with next batch's LLM calls
asset_tasks = [
asyncio.create_task(process_slide_and_fetch_assets(image_generation_service, slide))
for slide in batch_slides
asyncio.create_task(
process_slide_and_fetch_assets(
image_generation_service,
slide,
outline_image_urls=image_urls_for_batch[offset],
)
)
for offset, slide in enumerate(batch_slides)
]
async_assets_generation_tasks.extend(asset_tasks)
@ -821,6 +880,8 @@ async def generate_presentation_sync(
return await generate_presentation_handler(
request, presentation_id, None, sql_session
)
except HTTPException:
raise
except Exception:
traceback.print_exc()
raise HTTPException(status_code=500, detail="Presentation generation failed")

File diff suppressed because it is too large Load diff

View file

@ -1,13 +1,10 @@
from fastapi import APIRouter
from api.v1.ppt.endpoints.slide_to_html import SLIDE_TO_HTML_ROUTER, HTML_TO_REACT_ROUTER, HTML_EDIT_ROUTER, LAYOUT_MANAGEMENT_ROUTER
from api.v1.ppt.endpoints.presentation import PRESENTATION_ROUTER
from api.v1.ppt.endpoints.anthropic import ANTHROPIC_ROUTER
from api.v1.ppt.endpoints.google import GOOGLE_ROUTER
from api.v1.ppt.endpoints.openai import OPENAI_ROUTER
from api.v1.ppt.endpoints.files import FILES_ROUTER
from api.v1.ppt.endpoints.pptx_slides import PPTX_SLIDES_ROUTER
from api.v1.ppt.endpoints.pdf_slides import PDF_SLIDES_ROUTER
from api.v1.ppt.endpoints.fonts import FONTS_ROUTER
from api.v1.ppt.endpoints.icons import ICONS_ROUTER
from api.v1.ppt.endpoints.images import IMAGES_ROUTER
@ -15,9 +12,9 @@ from api.v1.ppt.endpoints.ollama import OLLAMA_ROUTER
from api.v1.ppt.endpoints.outlines import OUTLINES_ROUTER
from api.v1.ppt.endpoints.slide import SLIDE_ROUTER
from api.v1.ppt.endpoints.codex_auth import CODEX_AUTH_ROUTER
from api.v1.ppt.endpoints.pptx_slides import PPTX_FONTS_ROUTER
from api.v1.ppt.endpoints.theme import THEMES_ROUTER
from api.v1.ppt.endpoints.theme_generate import THEME_ROUTER
from templates.router import TEMPLATE_ROUTER
API_V1_PPT_ROUTER = APIRouter(prefix="/api/v1/ppt")
@ -26,20 +23,14 @@ API_V1_PPT_ROUTER.include_router(FILES_ROUTER)
API_V1_PPT_ROUTER.include_router(FONTS_ROUTER)
API_V1_PPT_ROUTER.include_router(OUTLINES_ROUTER)
API_V1_PPT_ROUTER.include_router(PRESENTATION_ROUTER)
API_V1_PPT_ROUTER.include_router(PPTX_SLIDES_ROUTER)
API_V1_PPT_ROUTER.include_router(SLIDE_ROUTER)
API_V1_PPT_ROUTER.include_router(SLIDE_TO_HTML_ROUTER)
API_V1_PPT_ROUTER.include_router(HTML_TO_REACT_ROUTER)
API_V1_PPT_ROUTER.include_router(HTML_EDIT_ROUTER)
API_V1_PPT_ROUTER.include_router(LAYOUT_MANAGEMENT_ROUTER)
API_V1_PPT_ROUTER.include_router(TEMPLATE_ROUTER)
API_V1_PPT_ROUTER.include_router(IMAGES_ROUTER)
API_V1_PPT_ROUTER.include_router(ICONS_ROUTER)
API_V1_PPT_ROUTER.include_router(THEMES_ROUTER)
API_V1_PPT_ROUTER.include_router(THEME_ROUTER)
API_V1_PPT_ROUTER.include_router(OLLAMA_ROUTER)
API_V1_PPT_ROUTER.include_router(PDF_SLIDES_ROUTER)
API_V1_PPT_ROUTER.include_router(OPENAI_ROUTER)
API_V1_PPT_ROUTER.include_router(ANTHROPIC_ROUTER)
API_V1_PPT_ROUTER.include_router(GOOGLE_ROUTER)
API_V1_PPT_ROUTER.include_router(CODEX_AUTH_ROUTER)
API_V1_PPT_ROUTER.include_router(PPTX_FONTS_ROUTER)

View file

@ -4,4 +4,4 @@ OPENAI_URL = "https://api.openai.com/v1"
DEFAULT_OPENAI_MODEL = "gpt-4.1"
DEFAULT_GOOGLE_MODEL = "models/gemini-2.5-flash"
DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4-20250514"
DEFAULT_CODEX_MODEL = "gpt-5.4-mini"
DEFAULT_CODEX_MODEL = "gpt-5.2-codex"

View file

@ -1 +1,2 @@
DEFAULT_TEMPLATES = ["general", "modern", "standard", "swift"]
MAX_NUMBER_OF_SLIDES = 50

View file

@ -18,9 +18,13 @@ class GeneratePresentationRequest(BaseModel):
default=Verbosity.STANDARD, description="How verbose the presentation should be"
)
web_search: bool = Field(default=False, description="Whether to enable web search")
n_slides: int = Field(default=8, description="Number of slides to generate")
language: str = Field(
default="English", description="Language for the presentation"
n_slides: Optional[int] = Field(
default=None,
description="Number of slides to generate. If omitted, model auto-detects slide count.",
)
language: Optional[str] = Field(
default=None,
description="Language for the presentation. If omitted, model auto-detects language.",
)
template: str = Field(
default="general", description="Template to use for the presentation"

View file

@ -4,9 +4,9 @@ import uuid
from sqlalchemy import JSON, Column, DateTime, String
from sqlmodel import Boolean, Field, SQLModel
from models.presentation_layout import PresentationLayoutModel
from models.presentation_outline_model import PresentationOutlineModel
from models.presentation_structure_model import PresentationStructureModel
from templates.presentation_layout import PresentationLayoutModel
from utils.datetime_utils import get_current_utc_datetime

View file

@ -1,15 +1,14 @@
from datetime import datetime
from typing import Optional, List
from typing import Optional
import uuid
from sqlalchemy import Column, DateTime, Text, JSON
from sqlmodel import SQLModel, Field
from sqlalchemy import JSON, Column, DateTime, Text
from sqlmodel import Field, SQLModel
from utils.datetime_utils import get_current_utc_datetime
class PresentationLayoutCodeModel(SQLModel, table=True):
"""Model for storing presentation layout codes"""
__tablename__ = "presentation_layout_codes"
id: Optional[int] = Field(default=None, primary_key=True)
@ -19,8 +18,10 @@ class PresentationLayoutCodeModel(SQLModel, table=True):
layout_code: str = Field(
sa_column=Column(Text), description="TSX/React component code for the layout"
)
fonts: Optional[List[str]] = Field(
sa_column=Column(JSON), default=None, description="Optional list of font links"
fonts: Optional[dict[str, str] | list[str]] = Field(
default=None,
sa_column=Column(JSON, nullable=True),
description="Optional font metadata associated with the layout",
)
created_at: datetime = Field(
sa_column=Column(

View file

@ -1,8 +1,9 @@
from datetime import datetime
from typing import Optional
import uuid
from sqlalchemy import Column, DateTime
from sqlmodel import SQLModel, Field
from sqlmodel import Field, SQLModel
from utils.datetime_utils import get_current_utc_datetime

View file

@ -0,0 +1,25 @@
from datetime import datetime
import uuid
from sqlalchemy import JSON, Column, DateTime
from sqlmodel import Field, SQLModel
from utils.datetime_utils import get_current_utc_datetime
class TemplateCreateInfoModel(SQLModel, table=True):
__tablename__ = "template_create_infos"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
fonts: dict[str, str] | None = Field(
default=None,
sa_column=Column(JSON, nullable=True),
)
pptx_url: str | None = Field(default=None)
slide_htmls: list[str] = Field(sa_column=Column(JSON, nullable=False))
slide_image_urls: list[str] = Field(sa_column=Column(JSON, nullable=False))
created_at: datetime = Field(
sa_column=Column(
DateTime(timezone=True), nullable=False, default=get_current_utc_datetime
)
)

View file

@ -55,3 +55,6 @@ class UserConfig(BaseModel):
CODEX_REFRESH_TOKEN: Optional[str] = None
CODEX_TOKEN_EXPIRES: Optional[str] = None
CODEX_ACCOUNT_ID: Optional[str] = None
CODEX_USERNAME: Optional[str] = None
CODEX_EMAIL: Optional[str] = None
CODEX_IS_PRO: Optional[bool] = None

View file

@ -43,4 +43,4 @@ dev = [
[tool.setuptools.packages.find]
where = ["."]
include = ["api*", "enums*", "models*", "services*", "constants*", "utils*"]
include = ["api*", "enums*", "models*", "services*", "constants*", "utils*", "templates*"]

View file

@ -15,8 +15,8 @@ if __name__ == "__main__":
reload = args.reload == "true"
host = "127.0.0.1"
# Provide a predictable public URL for services that need absolute asset links.
os.environ.setdefault("FASTAPI_PUBLIC_URL", f"http://{host}:{args.port}")
# Always bind absolute asset generation to the active runtime port.
os.environ["FASTAPI_PUBLIC_URL"] = f"http://{host}:{args.port}"
uvicorn.run(
"api.main:app",

View file

@ -14,10 +14,11 @@ from models.sql.async_presentation_generation_status import (
from models.sql.image_asset import ImageAsset
from models.sql.key_value import KeyValueSqlModel
from models.sql.ollama_pull_status import OllamaPullStatus
from models.sql.presentation import PresentationModel
from models.sql.slide import SlideModel
from models.sql.presentation_layout_code import PresentationLayoutCodeModel
from models.sql.presentation import PresentationModel
from models.sql.template import TemplateModel
from models.sql.template_create_info import TemplateCreateInfoModel
from models.sql.slide import SlideModel
from models.sql.webhook_subscription import WebhookSubscription
from utils.db_utils import get_database_url_and_connect_args
from utils.get_env import get_app_data_directory_env
@ -65,6 +66,7 @@ async def create_db_and_tables():
KeyValueSqlModel.__table__,
ImageAsset.__table__,
PresentationLayoutCodeModel.__table__,
TemplateCreateInfoModel.__table__,
TemplateModel.__table__,
WebhookSubscription.__table__,
AsyncPresentationGenerationTaskModel.__table__,

View file

@ -31,6 +31,7 @@ LOGGER = logging.getLogger(__name__)
class DocumentsLoader:
DECOMPOSE_TIMEOUT_SECONDS = 600
def __init__(
self,
@ -39,7 +40,9 @@ class DocumentsLoader:
):
self._file_paths = file_paths
self._ocr_language = presentation_language_to_ocr_code(presentation_language)
self.liteparse_service = LiteParseService()
self.liteparse_service = LiteParseService(
timeout_seconds=self.DECOMPOSE_TIMEOUT_SECONDS
)
self.document_conversion_service = DocumentConversionService()
self.document_service: Any = (
DocumentServiceCls() if DocumentServiceCls is not None else None
@ -142,6 +145,7 @@ class DocumentsLoader:
converted_path = self.document_conversion_service.convert_office_to_pdf(
file_path,
temp_dir,
timeout_seconds=self.DECOMPOSE_TIMEOUT_SECONDS,
)
return self._parse_with_liteparse(converted_path)
@ -149,6 +153,7 @@ class DocumentsLoader:
converted_path = self.document_conversion_service.convert_office_to_pdf(
file_path,
conversion_dir,
timeout_seconds=self.DECOMPOSE_TIMEOUT_SECONDS,
)
return self._parse_with_liteparse(converted_path)
@ -157,6 +162,7 @@ class DocumentsLoader:
converted_path = self.document_conversion_service.convert_image_to_png(
file_path,
temp_dir,
timeout_seconds=self.DECOMPOSE_TIMEOUT_SECONDS,
)
return self._parse_with_liteparse(converted_path)
@ -164,6 +170,7 @@ class DocumentsLoader:
converted_path = self.document_conversion_service.convert_image_to_png(
file_path,
conversion_dir,
timeout_seconds=self.DECOMPOSE_TIMEOUT_SECONDS,
)
return self._parse_with_liteparse(converted_path)

View file

@ -0,0 +1,246 @@
import asyncio
import json
import os
import shutil
import subprocess
import tempfile
import uuid
from typing import Mapping
from fastapi import HTTPException
from pydantic import BaseModel
from services.liteparse_service import _snippet, _subprocess_text_kwargs
from utils.asset_directory_utils import resolve_app_path_to_filesystem
from utils.get_env import (
get_app_data_directory_env,
get_next_public_fast_api_env,
get_temp_directory_env,
)
class PptxToHtmlDocument(BaseModel):
slides: list[str]
font_css: str = ""
width: float
height: float
images_dir: str
fonts_dir: str
class ExportTaskService:
def __init__(self, timeout_seconds: int = 300):
self.timeout_seconds = timeout_seconds
self.node_binary = os.getenv("LITEPARSE_NODE_BINARY", "node")
self.export_dir = self._resolve_export_dir()
self.entrypoint_path = os.path.join(self.export_dir, "index.js")
self.converter_path = self._resolve_converter_path(self.export_dir)
@staticmethod
def _resolve_export_dir() -> str:
configured = (os.getenv("EXPORT_RUNTIME_DIR") or "").strip()
if configured:
return configured
cwd = os.path.abspath(".")
service_dir = os.path.dirname(__file__)
candidates = [
os.path.abspath(os.path.join(cwd, "..", "..", "resources", "export")),
os.path.abspath(os.path.join(cwd, "..", "export")),
os.path.abspath(
os.path.join(service_dir, "..", "..", "..", "resources", "export")
),
os.path.abspath(os.path.join(service_dir, "..", "..", "export")),
os.path.abspath(
os.path.join(cwd, "..", "..", "electron", "resources", "export")
),
os.path.abspath(
os.path.join(
service_dir, "..", "..", "..", "..", "electron", "resources", "export"
)
),
]
for candidate in candidates:
if os.path.isfile(os.path.join(candidate, "index.js")):
return candidate
return candidates[0]
@staticmethod
def _resolve_converter_path(export_dir: str) -> str:
py_dir = os.path.join(export_dir, "py")
extension = ".exe" if os.name == "nt" else ""
platform_name = sys_platform()
arch_name = sys_arch()
candidates = [
os.path.join(py_dir, f"convert-{platform_name}-{arch_name}{extension}"),
os.path.join(py_dir, f"convert-{platform_name}{extension}"),
os.path.join(py_dir, f"convert{extension}"),
os.path.join(py_dir, "convert"),
]
for candidate in candidates:
if candidate and os.path.isfile(candidate):
return candidate
return candidates[1]
def _build_node_env(self) -> Mapping[str, str]:
env = os.environ.copy()
binary_name = os.path.basename(self.node_binary).lower()
if binary_name not in {"node", "node.exe"}:
env.setdefault("ELECTRON_RUN_AS_NODE", "1")
app_data_directory = get_app_data_directory_env()
if not app_data_directory:
raise HTTPException(
status_code=500,
detail="APP_DATA_DIRECTORY must be set for PPTX-to-HTML export",
)
env["APP_DATA_DIRECTORY"] = app_data_directory
temp_directory = get_temp_directory_env() or os.path.join(
tempfile.gettempdir(), "presenton"
)
os.makedirs(temp_directory, exist_ok=True)
env["TEMP_DIRECTORY"] = temp_directory
fastapi_public_url = get_next_public_fast_api_env()
if not fastapi_public_url:
raise HTTPException(
status_code=500,
detail="FASTAPI_PUBLIC_URL must be set for PPTX-to-HTML export",
)
env["ASSETS_BASE_URL"] = f"{fastapi_public_url.rstrip('/')}/app_data"
env["BUILT_PYTHON_MODULE_PATH"] = self.converter_path
return env
def _ensure_runtime_ready(self) -> None:
if not os.path.isfile(self.entrypoint_path):
raise HTTPException(
status_code=500,
detail=f"Export runtime not found at {self.entrypoint_path}",
)
if not os.path.isfile(self.converter_path):
raise HTTPException(
status_code=500,
detail=f"Export converter binary not found at {self.converter_path}",
)
@staticmethod
def _resolve_output_path(response_data: dict) -> str:
path_value = response_data.get("path")
if isinstance(path_value, str):
resolved = resolve_app_path_to_filesystem(path_value) or path_value
if os.path.isfile(resolved):
return resolved
url_value = response_data.get("url")
if isinstance(url_value, str):
resolved = resolve_app_path_to_filesystem(url_value)
if resolved and os.path.isfile(resolved):
return resolved
raise HTTPException(
status_code=500,
detail="PPTX-to-HTML task completed without a valid output path",
)
async def convert_pptx_to_html(
self, pptx_path: str, get_fonts: bool = False
) -> PptxToHtmlDocument:
self._ensure_runtime_ready()
if not os.path.isfile(pptx_path):
raise HTTPException(status_code=400, detail=f"PPTX not found: {pptx_path}")
temp_root = get_temp_directory_env() or os.path.join(tempfile.gettempdir(), "presenton")
os.makedirs(temp_root, exist_ok=True)
temp_dir = tempfile.mkdtemp(prefix="export-task-", dir=temp_root)
task_path = os.path.join(temp_dir, "export_task.json")
response_path = os.path.join(temp_dir, "export_task.response.json")
try:
with open(task_path, "w", encoding="utf-8") as task_file:
json.dump(
{
"type": "pptx-to-html",
"pptx_path": pptx_path,
"get_fonts": get_fonts,
},
task_file,
)
result = await asyncio.to_thread(
subprocess.run,
[self.node_binary, self.entrypoint_path, task_path],
cwd=self.export_dir,
capture_output=True,
timeout=self.timeout_seconds,
env=dict(self._build_node_env()),
**_subprocess_text_kwargs(),
)
if result.returncode != 0:
raise HTTPException(
status_code=500,
detail=(
"PPTX-to-HTML export task failed. "
f"stderr={_snippet(result.stderr)} stdout={_snippet(result.stdout)}"
),
)
if not os.path.isfile(response_path):
raise HTTPException(
status_code=500,
detail="PPTX-to-HTML export task did not produce a response file",
)
with open(response_path, "r", encoding="utf-8") as response_file:
response_data = json.load(response_file)
output_path = self._resolve_output_path(response_data)
with open(output_path, "r", encoding="utf-8") as output_file:
output_data = json.load(output_file)
return PptxToHtmlDocument(**output_data)
except subprocess.TimeoutExpired as exc:
raise HTTPException(
status_code=500,
detail=f"PPTX-to-HTML export timed out after {self.timeout_seconds} seconds",
) from exc
except json.JSONDecodeError as exc:
raise HTTPException(
status_code=500,
detail="PPTX-to-HTML export produced invalid JSON output",
) from exc
except OSError as exc:
raise HTTPException(
status_code=500,
detail=f"Failed to run PPTX-to-HTML export task: {exc}",
) from exc
finally:
shutil.rmtree(temp_dir, ignore_errors=True)
def sys_platform() -> str:
if os.name == "nt":
return "win32"
return os.sys.platform
def sys_arch() -> str:
machine = (os.environ.get("PROCESSOR_ARCHITECTURE") or "").lower()
if not machine and hasattr(os, "uname"):
machine = os.uname().machine.lower()
arch_map = {
"x86_64": "x64",
"amd64": "x64",
"x64": "x64",
"aarch64": "arm64",
"arm64": "arm64",
}
return arch_map.get(machine, machine or "x64")
EXPORT_TASK_SERVICE = ExportTaskService()

View file

@ -221,24 +221,92 @@ class ImageGenerationService:
prompt, output_directory, "gemini-3-pro-image-preview"
)
async def get_image_from_pexels(self, prompt: str) -> str:
async with aiohttp.ClientSession(trust_env=True) as session:
response = await session.get(
f"https://api.pexels.com/v1/search?query={prompt}&per_page=1",
headers={"Authorization": f"{get_pexels_api_key_env()}"},
)
data = await response.json()
image_url = data["photos"][0]["src"]["large"]
return image_url
async def get_image_from_pexels(
self, prompt: str, api_key: str | None = None, limit: int = 1
) -> str | list[str]:
per_page = max(1, min(limit, 80))
resolved_api_key = (api_key or get_pexels_api_key_env() or "").strip()
async def get_image_from_pixabay(self, prompt: str) -> str:
async with aiohttp.ClientSession(trust_env=True) as session:
response = await session.get(
f"https://pixabay.com/api/?key={get_pixabay_api_key_env()}&q={prompt}&image_type=photo&per_page=3"
"https://api.pexels.com/v1/search",
params={"query": prompt, "per_page": per_page},
headers={"Authorization": resolved_api_key} if resolved_api_key else {},
timeout=aiohttp.ClientTimeout(total=20),
)
if response.status in {401, 403}:
raise HTTPException(status_code=401, detail="Invalid Pexels API key")
if response.status != 200:
error_text = await response.text()
raise HTTPException(
status_code=502,
detail=f"Pexels request failed: {error_text}",
)
data = await response.json()
image_url = data["hits"][0]["largeImageURL"]
return image_url
photos = data.get("photos", [])
image_urls = [
photo.get("src", {}).get("large")
for photo in photos
if photo.get("src", {}).get("large")
]
if limit <= 1:
return image_urls[0] if image_urls else ""
return image_urls[:limit]
async def get_image_from_pixabay(
self, prompt: str, api_key: str | None = None, limit: int = 1
) -> str | list[str]:
per_page = max(3, min(limit, 200))
resolved_api_key = (api_key or get_pixabay_api_key_env() or "").strip()
async with aiohttp.ClientSession(trust_env=True) as session:
response = await session.get(
"https://pixabay.com/api/",
params={
"key": resolved_api_key,
"q": prompt[:99],
"image_type": "photo",
"per_page": per_page,
},
timeout=aiohttp.ClientTimeout(total=20),
)
if response.status in {401, 403}:
error_text = await response.text()
raise HTTPException(
status_code=401,
detail=f"Invalid Pixabay API key: {error_text}",
)
if response.status == 400:
error_text = await response.text()
if "api key" in error_text.lower():
raise HTTPException(
status_code=401,
detail=f"Invalid Pixabay API key: {error_text}",
)
raise HTTPException(
status_code=400,
detail=f"Pixabay request invalid: {error_text}",
)
if response.status != 200:
error_text = await response.text()
raise HTTPException(
status_code=502,
detail=f"Pixabay request failed: {error_text}",
)
data = await response.json()
hits = data.get("hits", [])
image_urls = [
hit.get("largeImageURL") for hit in hits if hit.get("largeImageURL")
]
if limit <= 1:
return image_urls[0] if image_urls else ""
return image_urls[:limit]
async def generate_image_comfyui(self, prompt: str, output_directory: str) -> str:
"""

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View file

@ -0,0 +1 @@
__all__ = []

View file

@ -0,0 +1,98 @@
from typing import Any
from templates.presentation_layout import PresentationLayoutModel
PLACEHOLDER_IMAGE_URL = "/static/images/replaceable_template_image.png"
PLACEHOLDER_ICON_URL = "/static/icons/placeholder.svg"
def build_schema_example(schema: dict) -> Any:
if not isinstance(schema, dict):
return None
if "default" in schema:
return schema["default"]
for key in ("anyOf", "oneOf", "allOf"):
options = schema.get(key)
if isinstance(options, list):
for option in options:
example = build_schema_example(option)
if example is not None:
return example
enum_values = schema.get("enum")
if enum_values:
return enum_values[0]
schema_type = schema.get("type")
if schema_type == "object":
properties = schema.get("properties", {})
result = {}
for field_name, field_schema in properties.items():
result[field_name] = build_schema_example(field_schema)
return result
if schema_type == "array":
items_schema = schema.get("items", {})
if "default" in schema:
return schema["default"]
item_example = build_schema_example(items_schema)
return [] if item_example is None else [item_example]
if schema_type == "string":
schema_description = (schema.get("description") or "").lower()
if "icon" in schema_description:
return PLACEHOLDER_ICON_URL
if "image" in schema_description or "url" in schema_description:
return PLACEHOLDER_IMAGE_URL
return "Sample text"
if schema_type == "integer":
return schema.get("minimum", 1)
if schema_type == "number":
return schema.get("minimum", 1)
if schema_type == "boolean":
return False
return None
def replace_special_placeholders(value: Any) -> Any:
if isinstance(value, dict):
result = {}
for key, child in value.items():
if key == "__image_url__":
result[key] = PLACEHOLDER_IMAGE_URL
elif key == "__icon_url__":
result[key] = PLACEHOLDER_ICON_URL
else:
result[key] = replace_special_placeholders(child)
return result
if isinstance(value, list):
return [replace_special_placeholders(item) for item in value]
if value == "__image_url__":
return PLACEHOLDER_IMAGE_URL
if value == "__icon_url__":
return PLACEHOLDER_ICON_URL
return value
def build_template_example(
template_id: str, layout: PresentationLayoutModel
) -> dict[str, Any]:
slides = []
for slide in layout.slides:
example_content = replace_special_placeholders(
build_schema_example(slide.json_schema)
)
slides.append({"layout": slide.id, "content": example_content})
return {
"template": template_id,
"slides": slides,
}

View file

@ -0,0 +1,167 @@
import asyncio
import re
import xml.etree.ElementTree as ET
from typing import Iterable
import aiohttp
_STYLE_TOKENS = {
"italic",
"italics",
"ital",
"oblique",
"roman",
"bolditalic",
"bolditalics",
"thin",
"hairline",
"extralight",
"ultralight",
"light",
"demilight",
"semilight",
"book",
"regular",
"normal",
"medium",
"semibold",
"demibold",
"bold",
"extrabold",
"ultrabold",
"black",
"extrablack",
"ultrablack",
"heavy",
"narrow",
"condensed",
"semicondensed",
"extracondensed",
"ultracondensed",
"expanded",
"semiexpanded",
"extraexpanded",
"ultraexpanded",
}
_STYLE_MODIFIERS = {"semi", "demi", "extra", "ultra"}
def _insert_spaces_in_camel_case(value: str) -> str:
value = re.sub(r"(?<=[a-z0-9])([A-Z])", r" \1", value)
value = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1 \2", value)
return value
def normalize_font_family_name(raw_name: str) -> str:
if not raw_name:
return raw_name
name = raw_name.replace("_", " ").replace("-", " ")
name = _insert_spaces_in_camel_case(name)
name = re.sub(r"\s+", " ", name).strip()
lower_name = name.lower()
for style in sorted(_STYLE_TOKENS, key=len, reverse=True):
suffix = " " + style
if lower_name.endswith(suffix):
name = name[: -len(suffix)]
lower_name = lower_name[: -len(suffix)]
break
tokens_original = name.split(" ")
tokens_filtered: list[str] = []
for index, token in enumerate(tokens_original):
lower_token = token.lower()
if index == 0:
tokens_filtered.append(token)
continue
if lower_token in _STYLE_TOKENS or lower_token in _STYLE_MODIFIERS:
continue
tokens_filtered.append(token)
if not tokens_filtered:
tokens_filtered = tokens_original
return re.sub(r"\s+", " ", " ".join(tokens_filtered).strip())
def extract_fonts_from_oxml(xml_content: str) -> list[str]:
fonts = set()
try:
root = ET.fromstring(xml_content)
namespaces = {
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
"p": "http://schemas.openxmlformats.org/presentationml/2006/main",
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
}
for xpath in (".//a:latin", ".//a:ea", ".//a:cs", ".//a:font"):
for font_elem in root.findall(xpath, namespaces):
typeface = font_elem.attrib.get("typeface")
if typeface:
fonts.add(typeface)
for rpr_elem in root.findall(".//a:rPr", namespaces):
for font_elem in rpr_elem.findall(".//a:latin", namespaces):
typeface = font_elem.attrib.get("typeface")
if typeface:
fonts.add(typeface)
for font_elem in root.findall(".//latin"):
typeface = font_elem.attrib.get("typeface")
if typeface:
fonts.add(typeface)
fonts.update(re.findall(r'typeface="([^"]+)"', xml_content))
system_fonts = {"+mn-lt", "+mj-lt", "+mn-ea", "+mj-ea", "+mn-cs", "+mj-cs", ""}
return sorted(font for font in fonts if font not in system_fonts and font.strip())
except Exception:
return []
def get_google_font_css_url(font_name: str) -> str:
return f"https://fonts.googleapis.com/css2?family={font_name.replace(' ', '+')}&display=swap"
async def check_google_font_availability(font_name: str) -> bool:
try:
async with aiohttp.ClientSession() as session:
async with session.head(
get_google_font_css_url(font_name),
timeout=aiohttp.ClientTimeout(total=10),
) as response:
return response.status == 200
except Exception:
return False
def collect_normalized_fonts_from_xmls(slide_xmls: Iterable[str]) -> list[str]:
raw_fonts = set()
for xml_content in slide_xmls:
raw_fonts.update(extract_fonts_from_oxml(xml_content))
normalized_fonts = {normalize_font_family_name(font) for font in raw_fonts}
return sorted(font for font in normalized_fonts if font)
async def get_available_and_unavailable_fonts(
font_names: Iterable[str],
) -> tuple[list[tuple[str, str]], list[tuple[str, None]]]:
normalized_fonts = sorted({font for font in font_names if font})
if not normalized_fonts:
return [], []
results = await asyncio.gather(
*[check_google_font_availability(font) for font in normalized_fonts]
)
available_fonts: list[tuple[str, str]] = []
unavailable_fonts: list[tuple[str, None]] = []
for font_name, is_available in zip(normalized_fonts, results):
if is_available:
available_fonts.append((font_name, get_google_font_css_url(font_name)))
else:
unavailable_fonts.append((font_name, None))
return available_fonts, unavailable_fonts

View file

@ -1,7 +1,8 @@
import aiohttp
from fastapi import HTTPException
from models.presentation_layout import PresentationLayoutModel
from typing import List
from templates.presentation_layout import PresentationLayoutModel
async def get_layout_by_name(layout_name: str) -> PresentationLayoutModel:
url = f"http://localhost/api/template?group={layout_name}"
@ -11,8 +12,7 @@ async def get_layout_by_name(layout_name: str) -> PresentationLayoutModel:
error_text = await response.text()
raise HTTPException(
status_code=404,
detail=f"Template '{layout_name}' not found: {error_text}"
detail=f"Template '{layout_name}' not found: {error_text}",
)
layout_json = await response.json()
# Parse the JSON into your Pydantic model
return PresentationLayoutModel(**layout_json)

View file

@ -0,0 +1,707 @@
import os
import random
import re
import uuid
from datetime import datetime
from typing import Any, List, Optional
import aiohttp
from fastapi import Body, Depends, File, Form, HTTPException, Path, Query, UploadFile
from pydantic import BaseModel
from sqlalchemy import func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import delete, select
from constants.presentation import DEFAULT_TEMPLATES
from models.sql.presentation_layout_code import PresentationLayoutCodeModel
from models.sql.template import TemplateModel
from models.sql.template_create_info import TemplateCreateInfoModel
from services.database import get_async_session
from services.export_task_service import EXPORT_TASK_SERVICE
from templates.example import build_template_example
from templates.get_layout_by_name import get_layout_by_name
from templates.presentation_layout import PresentationLayoutModel
from templates.preview import (
FontsUploadAndSlidesPreviewResponse,
upload_fonts_and_slides_preview_handler,
)
from templates.prompts import (
SLIDE_LAYOUT_CREATION_SYSTEM_PROMPT,
SLIDE_LAYOUT_EDIT_SECTION_SYSTEM_PROMPT,
SLIDE_LAYOUT_EDIT_SYSTEM_PROMPT,
)
from templates.providers import edit_slide_layout_code, generate_slide_layout_code
from utils.asset_directory_utils import (
resolve_app_path_to_filesystem,
resolve_image_path_to_filesystem,
)
class TemplateDetail(BaseModel):
id: str
name: str
total_layouts: Optional[int] = None
class TemplateLayoutData(BaseModel):
template: uuid.UUID
layout_id: str
layout_name: str
layout_code: str
fonts: Optional[Any] = None
class TemplateData(BaseModel):
id: uuid.UUID
init_id: Optional[uuid.UUID] = None
name: str
description: Optional[str] = None
created_at: datetime
class GetTemplateLayoutsResponse(BaseModel):
layouts: list[TemplateLayoutData]
template: Optional[TemplateData] = None
fonts: Optional[Any] = None
class TemplateExample(BaseModel):
template: str
slides: List[dict]
class CreateTemplateInitRequest(BaseModel):
pptx_url: str
slide_image_urls: List[str]
fonts: dict = {}
class CreateSlideLayoutRequest(BaseModel):
id: uuid.UUID
index: int
class CreateSlideLayoutResponse(BaseModel):
react_component: str
class EditSlideLayoutRequest(BaseModel):
react_component: str
prompt: str
class EditSlideLayoutResponse(CreateSlideLayoutResponse):
pass
class EditSlideLayoutSectionRequest(BaseModel):
react_component: str
section: str
prompt: str
class EditSlideLayoutSectionResponse(CreateSlideLayoutResponse):
pass
class SaveTemplateLayoutData(BaseModel):
layout_id: str
layout_name: str
layout_code: str
class SaveTemplateRequest(BaseModel):
template_info_id: uuid.UUID
name: str
description: Optional[str] = None
layouts: List[SaveTemplateLayoutData]
class SaveTemplateResponse(BaseModel):
id: uuid.UUID
name: str
description: Optional[str] = None
created_at: datetime
class CloneTemplateRequest(BaseModel):
id: str
name: str
description: Optional[str] = None
class UpdateTemplateRequest(BaseModel):
id: uuid.UUID
layouts: List[SaveTemplateLayoutData]
class SaveSlideLayoutRequest(BaseModel):
template_id: uuid.UUID
layout_id: str
layout_code: str
class CloneSlideLayoutRequest(BaseModel):
template_id: str
layout_id: str
layout_name: Optional[str] = None
def _strip_code_fences(value: str) -> str:
return (
value.replace("```tsx", "")
.replace("```typescript", "")
.replace("```ts", "")
.replace("```", "")
.strip()
)
def _normalize_layout_code_for_create(code: str) -> str:
normalized = _strip_code_fences(code)
normalized = (
normalized.replace("image_url", "__image_url__")
.replace("icon_url", "__icon_url__")
.replace("image_prompt", "__image_prompt__")
.replace("icon_query", "__icon_query__")
)
first_import_match = re.search(r"(?m)^\s*import\b", normalized)
if first_import_match:
normalized = normalized[first_import_match.start() :]
first_export_match = re.search(r"(?m)^\s*export\b", normalized)
if first_export_match:
normalized = normalized[: first_export_match.start()]
normalized = re.sub(
r"(?ms)^\s*(?:import|export)\b.*?;(?:\r?\n|$)",
"",
normalized,
)
normalized = re.sub(
r"(?m)^\s*(?:import|export)\b.*(?:\r?\n|$)",
"",
normalized,
)
normalized = normalized.strip()
normalized = re.sub(
r'(layoutId\s*=\s*["\'])([^"\']+)(["\'])',
lambda match: (
match.group(0)
if re.search(r"-\d{4}$", match.group(2))
else f"{match.group(1)}{match.group(2)}-{random.randint(1000, 9999)}{match.group(3)}"
),
normalized,
)
return normalized
def _update_layout_id_in_code(code: str) -> tuple[str, str]:
match = re.search(r'(layoutId\s*=\s*["\'])([^"\']+)(["\'])', code)
if not match:
raise HTTPException(status_code=400, detail="layoutId not found in layout code")
current_id = match.group(2)
suffix = f"{random.randint(1000, 9999)}"
new_id = re.sub(r"-\d{4}$", f"-{suffix}", current_id)
if new_id == current_id:
new_id = f"{current_id}-{suffix}"
new_code = re.sub(
r'(layoutId\s*=\s*["\'])([^"\']+)(["\'])',
f"\\1{new_id}\\3",
code,
count=1,
)
return new_code, new_id
async def _download_image_bytes(image_url: str) -> bytes:
async with aiohttp.ClientSession() as session:
async with session.get(image_url) as response:
if response.status != 200:
raise HTTPException(
status_code=400,
detail=f"Failed to download slide image: {image_url}",
)
return await response.read()
async def _read_image_bytes_and_media_type(image_url: str) -> tuple[bytes, str]:
actual_image_path = resolve_image_path_to_filesystem(image_url)
if actual_image_path and os.path.isfile(actual_image_path):
with open(actual_image_path, "rb") as image_file:
image_bytes = image_file.read()
file_extension = os.path.splitext(actual_image_path)[1].lower()
else:
image_bytes = await _download_image_bytes(image_url)
file_extension = os.path.splitext(image_url)[1].lower()
media_type_map = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
}
return image_bytes, media_type_map.get(file_extension, "image/png")
async def get_all_templates(
include_defaults: bool = Query(
default=True, description="Whether to include default templates"
),
sql_session: AsyncSession = Depends(get_async_session),
):
result = await sql_session.execute(
select(
TemplateModel.id,
TemplateModel.name,
func.count(PresentationLayoutCodeModel.id).label("total_layouts"),
)
.join(
PresentationLayoutCodeModel,
PresentationLayoutCodeModel.presentation == TemplateModel.id,
)
.group_by(TemplateModel.id, TemplateModel.name)
)
rows = result.all()
templates: list[TemplateDetail] = []
if include_defaults:
templates.extend(
TemplateDetail(id=template, name=template) for template in DEFAULT_TEMPLATES
)
templates.extend(
TemplateDetail(
id=f"custom-{template_id}",
name=template_name,
total_layouts=total_layouts,
)
for template_id, template_name, total_layouts in rows
)
return templates
async def get_layouts(
template_id: str = Path(..., description="The id of the template"),
session: AsyncSession = Depends(get_async_session),
):
if not template_id or not template_id.strip():
raise HTTPException(status_code=400, detail="Template ID cannot be empty")
try:
cleaned_template_id = template_id.replace("custom-", "")
template_id_uuid = uuid.UUID(cleaned_template_id)
except Exception as exc:
raise HTTPException(status_code=400, detail="Invalid custom template ID") from exc
result = await session.execute(
select(PresentationLayoutCodeModel).where(
PresentationLayoutCodeModel.presentation == template_id_uuid
)
)
layouts_db = result.scalars().all()
if not layouts_db:
raise HTTPException(
status_code=404,
detail=f"No layouts found for template ID: {template_id}",
)
template_meta = await session.get(TemplateModel, template_id_uuid)
template = None
if template_meta:
template = TemplateData(
id=template_id_uuid,
init_id=None,
name=template_meta.name,
description=template_meta.description,
created_at=template_meta.created_at,
)
layouts = [
TemplateLayoutData(
template=template_id_uuid,
layout_id=layout.layout_id,
layout_name=layout.layout_name,
layout_code=layout.layout_code,
fonts=layout.fonts,
)
for layout in layouts_db
]
return GetTemplateLayoutsResponse(
layouts=layouts,
template=template,
fonts=layouts[0].fonts if layouts else None,
)
async def get_template_by_id(
id: str = Path(
...,
description=f"The id of the template, must be one of {', '.join(DEFAULT_TEMPLATES)} or your custom template",
),
sql_session: AsyncSession = Depends(get_async_session),
):
if id.startswith("custom-"):
try:
template_id = uuid.UUID(id.replace("custom-", ""))
except Exception as exc:
raise HTTPException(
status_code=400,
detail="Template not found. Please use a valid template.",
) from exc
template = await sql_session.get(TemplateModel, template_id)
if not template:
raise HTTPException(
status_code=400,
detail="Template not found. Please use a valid template.",
)
return await get_layout_by_name(id)
async def get_template_example(
id: str = Path(
...,
description=f"The id of the template, must be one of {', '.join(DEFAULT_TEMPLATES)} or your custom template",
),
sql_session: AsyncSession = Depends(get_async_session),
):
template = await get_template_by_id(id=id, sql_session=sql_session)
return TemplateExample(**build_template_example(id, template))
async def upload_fonts_and_slides_preview(
pptx_file: UploadFile = File(..., description="PPTX file to preview"),
font_files: Optional[List[UploadFile]] = File(
default=None, description="Font files to upload"
),
original_font_names: Optional[List[str]] = Form(default=None),
):
return await upload_fonts_and_slides_preview_handler(
pptx_file=pptx_file,
font_files=font_files,
original_font_names=original_font_names,
max_slides=25,
)
async def init_create_template(
request: CreateTemplateInitRequest,
sql_session: AsyncSession = Depends(get_async_session),
):
if not request.slide_image_urls:
raise HTTPException(
status_code=400, detail="At least one slide image is required"
)
pptx_path = resolve_app_path_to_filesystem(request.pptx_url)
if not pptx_path or not os.path.isfile(pptx_path):
raise HTTPException(status_code=400, detail="PPTX file not found")
pptx_document = await EXPORT_TASK_SERVICE.convert_pptx_to_html(
pptx_path, get_fonts=False
)
if not pptx_document.slides:
raise HTTPException(
status_code=500,
detail="PPTX-to-HTML export returned no slides",
)
if len(pptx_document.slides) < len(request.slide_image_urls):
raise HTTPException(
status_code=400,
detail=(
"PPTX-to-HTML export returned fewer slides than the preview images. "
f"Expected at least {len(request.slide_image_urls)}, got {len(pptx_document.slides)}."
),
)
slide_htmls = pptx_document.slides[: len(request.slide_image_urls)]
template_create_info = TemplateCreateInfoModel(
fonts=request.fonts or {},
pptx_url=request.pptx_url,
slide_image_urls=request.slide_image_urls,
slide_htmls=slide_htmls,
)
sql_session.add(template_create_info)
await sql_session.commit()
await sql_session.refresh(template_create_info)
return template_create_info.id
async def create_slide_layout(
request: CreateSlideLayoutRequest = Body(...),
sql_session: AsyncSession = Depends(get_async_session),
):
template_info = await sql_session.get(TemplateCreateInfoModel, request.id)
if not template_info:
raise HTTPException(status_code=400, detail="Template not found")
total_slides = len(template_info.slide_htmls)
if request.index < 0 or request.index >= total_slides:
raise HTTPException(status_code=400, detail="Invalid slide index")
slide_html = template_info.slide_htmls[request.index]
slide_image_url = template_info.slide_image_urls[request.index]
image_bytes, media_type = await _read_image_bytes_and_media_type(slide_image_url)
fonts_text = ""
if template_info.fonts:
font_names = [font.replace(" ", "_") for font in template_info.fonts.keys()]
fonts_text = "#PROVIDED FONTS\n- " + "\n- ".join(font_names)
user_text = f"{fonts_text}\n\n#SLIDE HTML REFERENCE\n{slide_html}"
react_component = await generate_slide_layout_code(
system_prompt=SLIDE_LAYOUT_CREATION_SYSTEM_PROMPT,
user_text=user_text,
image_bytes=image_bytes,
media_type=media_type,
)
normalized_react_component = _normalize_layout_code_for_create(react_component)
return CreateSlideLayoutResponse(react_component=normalized_react_component)
async def edit_slide_layout(
request: EditSlideLayoutRequest,
):
user_text = f"#Prompt\n{request.prompt}\n\n#TSX code\n{request.react_component}"
react_component = await edit_slide_layout_code(
system_prompt=SLIDE_LAYOUT_EDIT_SYSTEM_PROMPT,
user_text=user_text,
)
return EditSlideLayoutResponse(react_component=_strip_code_fences(react_component))
async def edit_slide_layout_section(
request: EditSlideLayoutSectionRequest,
):
user_text = (
f"#Prompt\n{request.prompt}\n\n"
f"#Section to make changes around\n{request.section}\n\n"
f"#TSX code\n{request.react_component}"
)
react_component = await edit_slide_layout_code(
system_prompt=SLIDE_LAYOUT_EDIT_SECTION_SYSTEM_PROMPT,
user_text=user_text,
)
return EditSlideLayoutSectionResponse(
react_component=_strip_code_fences(react_component)
)
async def save_template(
request: SaveTemplateRequest,
sql_session: AsyncSession = Depends(get_async_session),
):
if not request.layouts:
raise HTTPException(status_code=400, detail="Layouts are required")
template_info = await sql_session.get(TemplateCreateInfoModel, request.template_info_id)
if not template_info:
raise HTTPException(status_code=400, detail="Template info not found")
template = TemplateModel(
id=uuid.uuid4(),
name=request.name,
description=request.description,
)
sql_session.add(template)
sql_session.add_all(
[
PresentationLayoutCodeModel(
presentation=template.id,
layout_id=layout.layout_id,
layout_name=layout.layout_name,
layout_code=layout.layout_code,
fonts=template_info.fonts,
)
for layout in request.layouts
]
)
await sql_session.commit()
await sql_session.refresh(template)
return SaveTemplateResponse(
id=template.id,
name=template.name,
description=template.description,
created_at=template.created_at,
)
async def clone_template(
request: CloneTemplateRequest = Body(...),
sql_session: AsyncSession = Depends(get_async_session),
):
if not request.id or not request.id.strip():
raise HTTPException(status_code=400, detail="Template ID cannot be empty")
try:
template_id_uuid = uuid.UUID(request.id.replace("custom-", ""))
except Exception as exc:
raise HTTPException(status_code=400, detail="Invalid custom template ID") from exc
template = await sql_session.get(TemplateModel, template_id_uuid)
if not template:
raise HTTPException(
status_code=400,
detail="Template not found. Please use a valid template.",
)
result = await sql_session.execute(
select(PresentationLayoutCodeModel).where(
PresentationLayoutCodeModel.presentation == template_id_uuid
)
)
layouts_db = result.scalars().all()
if not layouts_db:
raise HTTPException(status_code=400, detail="No layouts found for template")
new_template = TemplateModel(
id=uuid.uuid4(),
name=request.name,
description=template.description
if request.description is None
else request.description,
)
sql_session.add(new_template)
sql_session.add_all(
[
PresentationLayoutCodeModel(
presentation=new_template.id,
layout_id=layout.layout_id,
layout_name=layout.layout_name,
layout_code=layout.layout_code,
fonts=layout.fonts,
)
for layout in layouts_db
]
)
await sql_session.commit()
await sql_session.refresh(new_template)
return SaveTemplateResponse(
id=new_template.id,
name=new_template.name,
description=new_template.description,
created_at=new_template.created_at,
)
async def update_template(
request: UpdateTemplateRequest,
sql_session: AsyncSession = Depends(get_async_session),
):
if not request.layouts:
raise HTTPException(status_code=400, detail="Layouts are required")
template = await sql_session.get(TemplateModel, request.id)
if not template:
raise HTTPException(status_code=400, detail="Template not found")
existing_layout = await sql_session.scalar(
select(PresentationLayoutCodeModel).where(
PresentationLayoutCodeModel.presentation == request.id
)
)
fonts = existing_layout.fonts if existing_layout else None
await sql_session.execute(
delete(PresentationLayoutCodeModel).where(
PresentationLayoutCodeModel.presentation == request.id
)
)
sql_session.add_all(
[
PresentationLayoutCodeModel(
presentation=template.id,
layout_id=layout.layout_id,
layout_name=layout.layout_name,
layout_code=layout.layout_code,
fonts=fonts,
)
for layout in request.layouts
]
)
await sql_session.commit()
return SaveTemplateResponse(
id=template.id,
name=template.name,
description=template.description,
created_at=template.created_at,
)
async def save_slide_layout(
request: SaveSlideLayoutRequest,
sql_session: AsyncSession = Depends(get_async_session),
):
template = await sql_session.get(TemplateModel, request.template_id)
if not template:
raise HTTPException(status_code=400, detail="Template not found")
layout = await sql_session.scalar(
select(PresentationLayoutCodeModel).where(
PresentationLayoutCodeModel.presentation == request.template_id,
PresentationLayoutCodeModel.layout_id == request.layout_id,
)
)
if not layout:
raise HTTPException(status_code=400, detail="Layout not found")
layout.layout_code = request.layout_code
sql_session.add(layout)
await sql_session.commit()
async def clone_slide_layout(
request: CloneSlideLayoutRequest = Body(...),
sql_session: AsyncSession = Depends(get_async_session),
):
if not request.template_id or not request.template_id.strip():
raise HTTPException(status_code=400, detail="Template ID cannot be empty")
try:
template_id_uuid = uuid.UUID(request.template_id.replace("custom-", ""))
except Exception as exc:
raise HTTPException(status_code=400, detail="Invalid custom template ID") from exc
template = await sql_session.get(TemplateModel, template_id_uuid)
if not template:
raise HTTPException(status_code=400, detail="Template not found")
layout = await sql_session.scalar(
select(PresentationLayoutCodeModel).where(
PresentationLayoutCodeModel.presentation == template_id_uuid,
PresentationLayoutCodeModel.layout_id == request.layout_id,
)
)
if not layout:
raise HTTPException(status_code=400, detail="Layout not found")
new_layout_code, new_layout_id = _update_layout_id_in_code(layout.layout_code)
new_layout = PresentationLayoutCodeModel(
presentation=template_id_uuid,
layout_id=new_layout_id,
layout_name=request.layout_name or layout.layout_name,
layout_code=new_layout_code,
fonts=layout.fonts,
)
sql_session.add(new_layout)
await sql_session.commit()
await sql_session.refresh(new_layout)
return SaveTemplateLayoutData(
layout_id=new_layout.layout_id,
layout_name=new_layout.layout_name,
layout_code=new_layout.layout_code,
)

View file

@ -1,4 +1,5 @@
from typing import List, Optional
from fastapi import HTTPException
from pydantic import BaseModel, Field
@ -25,15 +26,15 @@ class PresentationLayoutModel(BaseModel):
status_code=404, detail=f"Slide layout {slide_layout_id} not found"
)
def to_presentation_structure(self):
def to_presentation_structure(self) -> PresentationStructureModel:
return PresentationStructureModel(
slides=[index for index in range(len(self.slides))]
)
def to_string(self):
message = f"## Presentation Layout\n\n"
def to_string(self) -> str:
message = "## Presentation Layout\n\n"
for index, slide in enumerate(self.slides):
message += f"### Slide Layout: {index}: \n"
message += f"- Name: {slide.name or slide.json_schema.get('title')} \n"
message += f"- Description: {slide.description} \n\n"
message += f"### Slide Layout: {index}\n"
message += f"- Name: {slide.name or slide.json_schema.get('title')}\n"
message += f"- Description: {slide.description}\n\n"
return message

View file

@ -0,0 +1,477 @@
import asyncio
from dataclasses import dataclass
import os
import re
import shutil
import subprocess
import tempfile
import uuid
import zipfile
from pathlib import Path
from typing import Dict, List, Optional
from fastapi import File, HTTPException, UploadFile
from pydantic import BaseModel
from constants.documents import PPTX_MIME_TYPES
from services.documents_loader import DocumentsLoader
from templates.font_utils import (
collect_normalized_fonts_from_xmls,
get_available_and_unavailable_fonts,
)
from utils.get_env import get_app_data_directory_env
try:
from fontTools.ttLib import TTFont
FONTTOOLS_AVAILABLE = True
except ImportError:
FONTTOOLS_AVAILABLE = False
SUPPORTED_FONT_EXTENSIONS = {
".ttf": "font/ttf",
".otf": "font/otf",
".woff": "font/woff",
".woff2": "font/woff2",
".eot": "application/vnd.ms-fontobject",
}
class FontInfo(BaseModel):
name: str
url: str | None = None
class FontCheckResponse(BaseModel):
available_fonts: List[FontInfo]
unavailable_fonts: List[FontInfo]
class FontsUploadAndSlidesPreviewResponse(BaseModel):
slide_image_urls: List[str]
pptx_url: str
modified_pptx_url: str
fonts: dict
@dataclass
class StoredFont:
display_name: str
url: str
temp_path: str
def _get_soffice_binary() -> str:
configured = os.environ.get("SOFFICE_PATH")
if configured:
return configured
return "soffice.exe" if os.name == "nt" else "soffice"
def _windows_hidden_subprocess_kwargs() -> Dict[str, object]:
if os.name != "nt":
return {}
startupinfo = subprocess.STARTUPINFO()
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
return {
"creationflags": getattr(subprocess, "CREATE_NO_WINDOW", 0),
"startupinfo": startupinfo,
}
def _app_data_directory() -> str:
app_data_dir = get_app_data_directory_env() or "/tmp/presenton"
os.makedirs(app_data_dir, exist_ok=True)
return app_data_dir
def _get_fonts_directory() -> str:
fonts_dir = os.path.join(_app_data_directory(), "fonts")
os.makedirs(fonts_dir, exist_ok=True)
return fonts_dir
def _get_images_directory() -> str:
images_dir = os.path.join(_app_data_directory(), "images")
os.makedirs(images_dir, exist_ok=True)
return images_dir
def _get_template_uploads_directory() -> str:
uploads_dir = os.path.join(_app_data_directory(), "uploads", "template-previews")
os.makedirs(uploads_dir, exist_ok=True)
return uploads_dir
def _write_bytes_to_path(path: str, data: bytes) -> None:
with open(path, "wb") as file:
file.write(data)
def _copy_file(source_path: str, destination_path: str) -> None:
shutil.copy2(source_path, destination_path)
def _extract_font_name_from_file(file_path: str) -> str:
filename = os.path.basename(file_path)
base_name = os.path.splitext(filename)[0]
if not FONTTOOLS_AVAILABLE:
return base_name
try:
font = TTFont(file_path)
if "name" in font:
name_table = font["name"]
for name_id in (1, 4, 6):
for record in name_table.names:
if record.nameID != name_id:
continue
if record.langID in (0x409, 0):
font_name = record.toUnicode().strip()
if font_name:
font.close()
return font_name
for record in name_table.names:
if record.nameID != 1:
continue
font_name = record.toUnicode().strip()
if font_name:
font.close()
return font_name
font.close()
except Exception:
pass
return base_name
def _validate_pptx_file(pptx_file: UploadFile) -> None:
filename = getattr(pptx_file, "filename", "") or ""
if not filename.lower().endswith(".pptx"):
raise HTTPException(
status_code=400,
detail="Invalid file type. Expected PPTX file",
)
if pptx_file.content_type and pptx_file.content_type not in PPTX_MIME_TYPES:
raise HTTPException(
status_code=400,
detail=f"Invalid file type. Expected PPTX file, got {pptx_file.content_type}",
)
def _ensure_valid_font_file(font_file: UploadFile) -> None:
filename = font_file.filename or ""
extension = os.path.splitext(filename)[1].lower()
if extension not in SUPPORTED_FONT_EXTENSIONS:
raise HTTPException(
status_code=400,
detail=f"Invalid font file. Supported formats: {', '.join(SUPPORTED_FONT_EXTENSIONS.keys())}",
)
async def _persist_custom_fonts(
font_files: Optional[List[UploadFile]],
original_font_names: Optional[List[str]],
temp_dir: str,
) -> list[StoredFont]:
if not font_files:
return []
stored_fonts: list[StoredFont] = []
fonts_dir = _get_fonts_directory()
for index, font_file in enumerate(font_files):
_ensure_valid_font_file(font_file)
original_name = (
original_font_names[index]
if original_font_names and index < len(original_font_names)
else None
)
extension = os.path.splitext(font_file.filename or "")[1].lower()
unique_name = f"{Path(font_file.filename or f'font_{index}').stem}_{uuid.uuid4().hex[:8]}{extension}"
temp_font_path = os.path.join(temp_dir, unique_name)
permanent_font_path = os.path.join(fonts_dir, unique_name)
font_bytes = await font_file.read()
await asyncio.to_thread(_write_bytes_to_path, temp_font_path, font_bytes)
await asyncio.to_thread(_write_bytes_to_path, permanent_font_path, font_bytes)
actual_font_name = await asyncio.to_thread(
_extract_font_name_from_file, permanent_font_path
)
display_name = original_name or actual_font_name
stored_fonts.append(
StoredFont(
display_name=display_name,
url=f"/app_data/fonts/{unique_name}",
temp_path=temp_font_path,
)
)
return stored_fonts
def _create_font_alias_config(raw_fonts: List[str]) -> str:
mappings: Dict[str, str] = {}
for font_name in raw_fonts:
normalized = font_name
if not normalized:
continue
mappings[font_name] = normalized
fd, fonts_conf_path = tempfile.mkstemp(prefix="fonts_alias_", suffix=".conf")
os.close(fd)
with open(fonts_conf_path, "w", encoding="utf-8") as cfg:
cfg.write(
"""<?xml version='1.0'?>
<!DOCTYPE fontconfig SYSTEM "urn:fontconfig:fonts.dtd">
<fontconfig>
<include>/etc/fonts/fonts.conf</include>
"""
)
for source_family, destination_family in mappings.items():
if source_family == destination_family:
continue
cfg.write(
f"""
<match target="pattern">
<test name="family" compare="eq">
<string>{source_family}</string>
</test>
<edit name="family" mode="assign" binding="strong">
<string>{destination_family}</string>
</edit>
</match>
"""
)
cfg.write("\n</fontconfig>\n")
return fonts_conf_path
async def _install_fonts(font_paths: List[str]) -> None:
if not font_paths:
return
for font_path in font_paths:
try:
subprocess.run(
["cp", font_path, "/usr/share/fonts/truetype/"],
check=True,
capture_output=True,
)
except subprocess.CalledProcessError:
continue
try:
subprocess.run(["fc-cache", "-f", "-v"], check=True, capture_output=True)
except subprocess.CalledProcessError:
pass
def extract_slide_xmls(pptx_path: str, temp_dir: str) -> List[str]:
slide_xmls: list[str] = []
extract_dir = os.path.join(temp_dir, "pptx_extract")
with zipfile.ZipFile(pptx_path, "r") as zip_ref:
zip_ref.extractall(extract_dir)
slides_dir = os.path.join(extract_dir, "ppt", "slides")
if not os.path.exists(slides_dir):
raise HTTPException(status_code=400, detail="No slides directory found in PPTX")
slide_files = [
file_name
for file_name in os.listdir(slides_dir)
if file_name.startswith("slide") and file_name.endswith(".xml")
]
slide_files.sort(key=lambda value: int(re.sub(r"[^0-9]", "", value) or "0"))
for slide_file in slide_files:
slide_path = os.path.join(slides_dir, slide_file)
with open(slide_path, "r", encoding="utf-8") as slide_handle:
slide_xmls.append(slide_handle.read())
return slide_xmls
async def convert_pptx_to_pdf(
pptx_path: str,
temp_dir: str,
slide_xmls: Optional[List[str]] = None,
) -> str:
screenshots_dir = os.path.join(temp_dir, "screenshots")
os.makedirs(screenshots_dir, exist_ok=True)
slide_xmls = slide_xmls or extract_slide_xmls(pptx_path, temp_dir)
raw_fonts = collect_normalized_fonts_from_xmls(slide_xmls)
fonts_conf_path = _create_font_alias_config(raw_fonts)
env = os.environ.copy()
env["FONTCONFIG_FILE"] = fonts_conf_path
try:
subprocess.run(
[
_get_soffice_binary(),
"--headless",
"--convert-to",
"pdf",
"--outdir",
screenshots_dir,
pptx_path,
],
check=True,
capture_output=True,
text=True,
timeout=500,
env=env,
**_windows_hidden_subprocess_kwargs(),
)
except subprocess.TimeoutExpired as exc:
raise HTTPException(
status_code=500,
detail="LibreOffice PDF conversion timed out after 500 seconds",
) from exc
except subprocess.CalledProcessError as exc:
error_message = exc.stderr if exc.stderr else str(exc)
raise HTTPException(
status_code=500,
detail=f"LibreOffice PDF conversion failed: {error_message}",
) from exc
pdf_files = [file_name for file_name in os.listdir(screenshots_dir) if file_name.endswith(".pdf")]
if not pdf_files:
raise HTTPException(
status_code=500, detail="LibreOffice failed to generate a PDF file"
)
return os.path.join(screenshots_dir, pdf_files[0])
async def store_slide_images(
screenshot_paths: List[str],
session_id: uuid.UUID,
) -> List[str]:
images_dir = _get_images_directory()
target_dir = os.path.join(images_dir, str(session_id))
os.makedirs(target_dir, exist_ok=True)
slide_image_urls: list[str] = []
for index, screenshot_path in enumerate(screenshot_paths, start=1):
file_name = f"slide_{index}.png"
destination_path = os.path.join(target_dir, file_name)
if os.path.exists(screenshot_path) and os.path.getsize(screenshot_path) > 0:
await asyncio.to_thread(_copy_file, screenshot_path, destination_path)
slide_image_urls.append(f"/app_data/images/{session_id}/{file_name}")
else:
slide_image_urls.append("/static/images/placeholder.jpg")
return slide_image_urls
async def store_uploaded_pptx(
pptx_path: str,
session_id: uuid.UUID,
) -> str:
uploads_dir = _get_template_uploads_directory()
target_dir = os.path.join(uploads_dir, str(session_id))
os.makedirs(target_dir, exist_ok=True)
destination_path = os.path.join(target_dir, "presentation.pptx")
await asyncio.to_thread(_copy_file, pptx_path, destination_path)
return f"/app_data/uploads/template-previews/{session_id}/presentation.pptx"
async def get_available_and_unavailable_fonts_for_pptx(
pptx_path: str, temp_dir: str
) -> tuple[list[tuple[str, str]], list[tuple[str, None]]]:
slide_xmls = extract_slide_xmls(pptx_path, temp_dir)
normalized_fonts = collect_normalized_fonts_from_xmls(slide_xmls)
return await get_available_and_unavailable_fonts(normalized_fonts)
async def check_fonts_in_pptx_handler(
pptx_file: UploadFile = File(..., description="PPTX file to analyze fonts from")
) -> FontCheckResponse:
_validate_pptx_file(pptx_file)
with tempfile.TemporaryDirectory() as temp_dir:
pptx_path = os.path.join(temp_dir, "presentation.pptx")
pptx_content = await pptx_file.read()
await asyncio.to_thread(_write_bytes_to_path, pptx_path, pptx_content)
available_fonts_data, unavailable_fonts_data = (
await get_available_and_unavailable_fonts_for_pptx(pptx_path, temp_dir)
)
return FontCheckResponse(
available_fonts=[
FontInfo(name=name, url=url) for name, url in available_fonts_data
],
unavailable_fonts=[
FontInfo(name=name, url=url) for name, url in unavailable_fonts_data
],
)
async def upload_fonts_and_slides_preview_handler(
pptx_file: UploadFile,
font_files: Optional[List[UploadFile]] = None,
original_font_names: Optional[List[str]] = None,
max_slides: Optional[int] = None,
) -> FontsUploadAndSlidesPreviewResponse:
if (font_files and not original_font_names) or (
original_font_names and not font_files
):
raise HTTPException(
status_code=400,
detail="Both font_files and original_font_names must be provided together",
)
if font_files and original_font_names and len(font_files) != len(original_font_names):
raise HTTPException(
status_code=400,
detail="Number of font files must match number of original font names",
)
_validate_pptx_file(pptx_file)
with tempfile.TemporaryDirectory() as temp_dir:
pptx_path = os.path.join(temp_dir, "presentation.pptx")
pptx_content = await pptx_file.read()
await asyncio.to_thread(_write_bytes_to_path, pptx_path, pptx_content)
stored_fonts = await _persist_custom_fonts(
font_files=font_files,
original_font_names=original_font_names,
temp_dir=temp_dir,
)
await _install_fonts([font.temp_path for font in stored_fonts])
slide_xmls = extract_slide_xmls(pptx_path, temp_dir)
pdf_path = await convert_pptx_to_pdf(pptx_path, temp_dir, slide_xmls=slide_xmls)
screenshot_paths = await DocumentsLoader.get_page_images_from_pdf_async(
pdf_path, temp_dir
)
if max_slides and len(screenshot_paths) > max_slides:
screenshot_paths = screenshot_paths[:max_slides]
session_id = uuid.uuid4()
slide_image_urls = await store_slide_images(screenshot_paths, session_id)
pptx_url = await store_uploaded_pptx(pptx_path, session_id)
available_fonts, _ = await get_available_and_unavailable_fonts(
collect_normalized_fonts_from_xmls(slide_xmls)
)
fonts: dict[str, str] = {name: url for name, url in available_fonts}
fonts.update({font.display_name: font.url for font in stored_fonts})
return FontsUploadAndSlidesPreviewResponse(
slide_image_urls=slide_image_urls,
pptx_url=pptx_url,
modified_pptx_url=pptx_url,
fonts=fonts,
)

View file

@ -0,0 +1,220 @@
SLIDE_LAYOUT_CREATION_SYSTEM_PROMPT = """
You need to generate a Zod schema and a TSX React component and provide it as output.
Provide reusable TSX code which can be used as template to generate new slides with different content.
# Steps:
1. Analyze the slide image to understand the visual hierarchy.
3. Classify elements into decorative and content elements.
4. Group content elements into logical sections like Header, Body, BulletPoints, etc.
5. Generate a Zod schema for the content elements.
6. Generate id, name and description for the layout.
6. Generate a TSX React component using the Zod schema and the HTML reference.
# Decorative Elements:
- Arrows, Lines, Shapes, etc.
- Images with Grid patterns, background patterns, gradients, solid colors, etc.
- Background of infographics like funnel, timeline, etc.
- Company name, logos, etc.
- Images covering the entire slide.
- Images containing company name, logos, etc.
# Decorative Elements Rules:
- Use them exactly as they are in the HTML reference.
- Do not change decorative images and icons urls.
- Images containing company name, logos, etc should be identified as decorative elements.
# Content Elements:
- Title, Description, BulletPoints, etc.
- Graphs, Charts, etc.
- Images and Icons representing textual content like title, description, bullet points, etc.
- Meaningful Images and Icons.
- Icons in infographics that represent the data.
# Content Elements Rules:
- Properly identify between images and icons elements.
- Image content:
- Image field should be 'z.object({"image_url": z.string(), "image_prompt": z.string().max(100)})'
- Replace actual image url with '/static/images/replaceable_template_image.png'
- Icon content:
- Icon field should be 'z.object({"icon_url": z.string(), "icon_query": z.string().max(30)})'
- Replace actual icon url with '/static/icons/placeholder.svg'
- Add color styling to the icon to match the color in the image.
- Make sure the urls are correct.
# Layout Rules:
- The layout should be fixed 1280px width and 720px height.
- Adjust the positions and sizes of elements to fit the layout.
- Try to keep the positions and sizes of elements as close to HTML reference as possible.
# Flexible Positioning and Sizes Rules:
- Must not use 'absolute' positioning for elements.
- Must use 'flex', 'grid', 'margin', 'padding', 'gap', 'basis', 'justify', 'align', etc for positioning of elements.
- For variable length lists, wrap list into a container and center it.
- Don't use specific sizes (height, width) for elements if not necessary.
# Schema Field Name and Description Rules:
- Must not use content specific words.
- Only use words based on what content types are present in the slide image.
- Use words like 'title', description', 'heading', 'image', 'graph', 'table', 'bullet points', etc.
- Must not use words like 'budget', 'market', 'revenue', 'sales', 'growth', 'workflow', 'channel', 'plannedValue', 'actualValue', etc.
# Layout ID, Name and Description Rules:
- Must only use slide structure to derive layout id, name and description.
- Informations like: Type of content, position of content, etc. should be used.
- layoutId example: title-description-right-image.
- layoutName example: Title Description Image.
- layoutDescription example: A slide with a title, description, and an image on right.
# Zod Schema Rules:
- "describe" must be added for every fields.
- Add `.default(...)` to every top-level field directly inside the initial `z.object({ ... })` shape.
- Must not put a single `default` on the whole object like `const Schema = z.object({ ... }).default({ ... })`.
- Top level fields are those not nested inside other fields.
- Don't mention string type in schema like "url()", "email()", etc.
- Table must be object with "columns" and "rows" fields.
- "columns" must be an array of strings.
- "rows" must be an array of arrays of strings.
- Graph must be object with "categories" and "series" fields.
- "categories" must be an array of strings.
- "series" must be an array of objects with {"name": string, "data": array of numbers}.
- Must not use z.record() anywhere in the schema.
# String and Array Field Rules:
- Every string field must include `.max(...)`; every array field must include `.max(...)`.
- For strings, set `max` to the exact character count of the text content it represents.
- For arrays, set `max` to the exact item count of the array content it represents.
- Choose a `max` that keeps the longest allowed content from overflowing its container.
# Table Rules:
- Construct "tr -> th" by iterating over the "columns" field.
- Construct "tr -> td" by iterating over the "rows" field.
- Make sure table height and width adjusts to fit the content.
# Grahps, Charts, etc Rules:
- Identify if graphs, charts, etc are present in the slide image.
- Identify the type of graph, chart, etc.
- If present, generate a zod schema for the graph, chart, etc.
- Generate TSX code for the graph, chart, etc. even if it is not present in the HTML reference.
- Use graph schema and image to generate the TSX code.
- Use Recharts library for graphs.
# Fonts Rules:
- Check for "PROVIDED FONTS".
- Must use fonts only from "PROVIDED FONTS".
- Add "font-[\"font-name\"]" to every text element in the slide.
# Page Number Rules:
- Identify if the slide contains page number from provided HTML reference and image.
- If page number is present, add a "page: z.number().min(1).meta({ description: "Page number" })" field in the schema.
# React Component Rules:
- React component must be named dynamicSlideLayout.
- dynamicSlideLayout must take "{ data }: { data: Partial<z.infer<typeof Schema>> }" as props.
- Wrap the code inside these classes: "relative w-full rounded-sm max-w-[1280px] shadow-lg max-h-[720px] aspect-video bg-white z-20 mx-auto overflow-hidden".
- Make sure camelCase is used for all styles. For e.g. "letter-spacing" should be "letterSpacing".
- Schema.parse must not be used in the code.
- Use 'const {field1, field2, ...} = data;' to access the data.
- field1 or field2 or ... can be undefined, so use optional chaining to access them.
- Don't use "min-height" on cards and instead make its height grow/shrink to fit the content.
- Make sure cards/items are centered vertically and horizontally in the available space.
- Make sure no element is scrollable.
- Don't add any animations, transitions, or effects.
- Make sure no content elements are overflowing the slide boundaries.
# Import and Export Rules:
- All import statements must be defined at the top.
- Export using 'export {Schema, layoutId, layoutName, layoutDescription, dynamicSlideLayout}' statement at the bottom.
- There must be only one 'export' statement in the whole TSX code.
# Output Code Rules:
- Code should be in following order:
- Zod Schema (Schema)
- Layout ID, Name and Description (layoutId, layoutName, layoutDescription)
- React Component (dynamicSlideLayout)
- Give just one valid TSX code as output.
- Don't add comments in the code.
- Make sure the generated code is valid TSX code.
- Give only code as output and nothing else. (no json, no markdown, no text, no explanation)
- Go through generated code and make sure all rules are followed.
- Think as long as you can and iterate as many times as necessary to make sure all rules are followed.
"""
SLIDE_LAYOUT_EDIT_SYSTEM_PROMPT = """
You need to edit the given TSX code of the slide layout code according to the prompt and provide it as output.
# Steps
1. Analyze the TSX code to understand the slide layout.
2. Analyze the prompt to understand the changes to be made.
3. Edit the TSX code according to the prompt.
4. Provide the updated TSX code as output.
# Rules
- Make sure the changes does not break the existing code.
- Make sure to follow the pattern of the existing code.
- Make sure there are no unused schema fields after the changes are made.
# Icons and Images Rules
Follow these rules if new icons/images are asked:
- Image field should be 'z.object({"image_url": z.string(), "image_prompt": z.string().max(100)})'
- Use this as default image url: '/static/images/replaceable_template_image.png'
- Icon field should be 'z.object({"icon_url": z.string(), "icon_query": z.string().max(30)})'
- Use this as default icon url: '/static/icons/placeholder.svg'
# Schema Rules
- "describe" must be added for every fields.
- "default" must be added in top level fields of schema.
- Top level fields are those not nested inside other fields.
- Must set max for every string and array fields.
- Must set max to a number that will not cause overflow on max content.
# Graphs And Table Rules
Follow these rules if new graphs/tables are asked:
1. Schema Rules
- Table must be object with "columns" and "rows" fields.
- "columns" must be an array of strings.
- "rows" must be an array of arrays of strings.
- Graph must be object with "categories" and "series" fields.
- "categories" must be an array of strings.
- "series" must be an array of objects with {"name": string, "data": array of numbers}.
2. React Component Rules
- Use recharts library for graphs.
# Common Prompts
1. Fix the slide
- Check if text/cards/items is overflowing the slide boundaries or text/cards/items are overlapping.
- If yes, fix by moving the element to a better position or resizing the element.
# Output Rules
- Make sure the schema and react component are valid.
- No matter what prompt is given, don't break the code.
- Provide only the updated TSX code as output and nothing else. (no json, no markdown, no text, no explanation)
"""
SLIDE_LAYOUT_EDIT_SECTION_SYSTEM_PROMPT = """
You need to edit the given TSX code of the slide layout code according to the prompt and provide it as output.
# Steps
1. Analyze the TSX code to understand the slide layout.
2. Analyze the prompt to understand the changes to be made.
3. Edit the TSX code according to the prompt.
4. Provide the updated TSX code as output.
# Rules
- Changes should be made only around the mentioned "section to make changes around".
- Make sure the changes does not break the existing code.
- Make sure to follow the pattern of the existing code.
- Make sure there are no unused schema fields after the changes are made.
# Icons and Images Rules
Follow these rules if new icons/images are asked:
- Image field should be 'z.object({"image_url": z.string(), "image_prompt": z.string().max(100)})'
- Use this as default image url: '/static/images/replaceable_template_image.png'
- Icon field should be 'z.object({"icon_url": z.string(), "icon_query": z.string().max(30)})'
- Use this as default icon url: '/static/icons/placeholder.svg'
# Output Rules
- Make sure the schema and react component are valid.
- No matter what prompt is given, don't break the code.
- Provide only the updated TSX code as output and nothing else. (no json, no markdown, no text, no explanation)
"""

View file

@ -0,0 +1,425 @@
import asyncio
import base64
from dataclasses import dataclass
import time
from typing import Any, Awaitable, Callable, Optional
from anthropic import AsyncAnthropic
from fastapi import HTTPException
from google import genai
from google.genai import types as google_types
from openai import AsyncOpenAI
from enums.llm_provider import LLMProvider
from utils.get_env import (
get_anthropic_api_key_env,
get_codex_access_token_env,
get_codex_account_id_env,
get_codex_refresh_token_env,
get_codex_token_expires_env,
get_google_api_key_env,
get_openai_api_key_env,
)
from utils.llm_provider import get_llm_provider, get_model
from utils.set_env import (
set_codex_access_token_env,
set_codex_account_id_env,
set_codex_refresh_token_env,
set_codex_token_expires_env,
)
MAX_ATTEMPTS_PER_PROVIDER = 4
@dataclass(frozen=True)
class TemplateProviderSpec:
provider: LLMProvider
model: str
@dataclass(frozen=True)
class PlainLLMProvider:
name: str
call: Callable[[], Awaitable[str]]
def get_template_provider_spec() -> TemplateProviderSpec:
provider = get_llm_provider()
if provider == LLMProvider.OPENAI:
return TemplateProviderSpec(provider=provider, model=get_model())
if provider == LLMProvider.CODEX:
return TemplateProviderSpec(provider=provider, model=get_model())
if provider == LLMProvider.GOOGLE:
return TemplateProviderSpec(provider=provider, model=get_model())
if provider == LLMProvider.ANTHROPIC:
return TemplateProviderSpec(provider=provider, model=get_model())
raise HTTPException(
status_code=400,
detail="Template generation only supports OpenAI, Codex, Google, or Anthropic.",
)
async def run_plain_provider_buckets(*, providers: list[PlainLLMProvider]) -> str:
last_exception: Optional[Exception] = None
for provider in providers:
for attempt in range(1, MAX_ATTEMPTS_PER_PROVIDER + 1):
try:
response_text = await provider.call()
if response_text:
return response_text
raise ValueError("No output from template generation provider")
except Exception as exc:
last_exception = exc
if isinstance(last_exception, HTTPException):
raise last_exception
raise HTTPException(status_code=500, detail="Failed to generate template output")
def _read_openai_response_text(response) -> str:
output_text = getattr(response, "output_text", None)
if output_text:
return output_text
text = getattr(response, "text", None)
if text:
return text
return ""
def _get_openai_client() -> AsyncOpenAI:
api_key = get_openai_api_key_env()
if not api_key:
raise HTTPException(status_code=400, detail="OPENAI_API_KEY is not set")
return AsyncOpenAI(api_key=api_key, timeout=120.0)
def _get_codex_headers() -> dict:
access_token = get_codex_access_token_env()
if not access_token:
raise HTTPException(
status_code=400,
detail="Codex OAuth access token is not set. Please authenticate via /api/v1/ppt/codex/auth/initiate",
)
expires_str = get_codex_token_expires_env()
if expires_str:
try:
expires_ms = int(expires_str)
now_ms = int(time.time() * 1000)
if now_ms >= expires_ms - 60_000:
refresh_token = get_codex_refresh_token_env()
if refresh_token:
from utils.oauth.openai_codex import (
TokenSuccess,
get_account_id,
refresh_access_token,
)
result = refresh_access_token(refresh_token)
if isinstance(result, TokenSuccess):
set_codex_access_token_env(result.access)
set_codex_refresh_token_env(result.refresh)
set_codex_token_expires_env(str(result.expires))
account_id = get_account_id(result.access)
if account_id:
set_codex_account_id_env(account_id)
access_token = result.access
except (TypeError, ValueError):
pass
account_id = get_codex_account_id_env() or ""
return {
"Authorization": f"Bearer {access_token}",
"chatgpt-account-id": account_id,
"OpenAI-Beta": "responses=experimental",
"originator": "pi",
}
def _get_codex_client() -> AsyncOpenAI:
headers = _get_codex_headers()
access_token = (headers.get("Authorization") or "").replace("Bearer ", "").strip()
default_headers = {
key: value
for key, value in headers.items()
if key.lower() not in {"authorization", "content-type", "accept"}
}
return AsyncOpenAI(
base_url="https://chatgpt.com/backend-api/codex",
api_key=access_token or "codex",
default_headers=default_headers,
timeout=120.0,
)
def _get_google_client() -> genai.Client:
api_key = get_google_api_key_env()
if not api_key:
raise HTTPException(status_code=400, detail="GOOGLE_API_KEY is not set")
return genai.Client(api_key=api_key)
def _get_anthropic_client() -> AsyncAnthropic:
api_key = get_anthropic_api_key_env()
if not api_key:
raise HTTPException(status_code=400, detail="ANTHROPIC_API_KEY is not set")
return AsyncAnthropic(api_key=api_key)
async def _call_openai_like(
*,
client: AsyncOpenAI,
model: str,
system_prompt: str,
user_text: str,
image_bytes: Optional[bytes] = None,
media_type: str = "image/png",
) -> str:
content = [{"type": "input_text", "text": user_text}]
if image_bytes:
content.insert(
0,
{
"type": "input_image",
"image_url": f"data:{media_type};base64,{base64.b64encode(image_bytes).decode('utf-8')}",
},
)
response = await client.responses.create(
model=model,
instructions=system_prompt,
input=[{"role": "user", "content": content}],
text={"verbosity": "medium"},
store=False,
)
output_text = _read_openai_response_text(response)
if not output_text:
raise HTTPException(status_code=500, detail="No output from template provider")
return output_text
def _response_event_to_dict(event: Any) -> dict:
if isinstance(event, dict):
return event
if hasattr(event, "model_dump"):
return event.model_dump()
return {
"type": getattr(event, "type", None),
"delta": getattr(event, "delta", None),
"text": getattr(event, "text", None),
"item": getattr(event, "item", None),
"response": getattr(event, "response", None),
"error": getattr(event, "error", None),
"message": getattr(event, "message", None),
}
async def _call_codex(
*,
model: str,
system_prompt: str,
user_text: str,
image_bytes: Optional[bytes] = None,
media_type: str = "image/png",
) -> str:
client = _get_codex_client()
content = [{"type": "input_text", "text": user_text}]
if image_bytes:
content.insert(
0,
{
"type": "input_image",
"image_url": f"data:{media_type};base64,{base64.b64encode(image_bytes).decode('utf-8')}",
},
)
stream = await client.responses.create(
model=model,
instructions=system_prompt,
input=[{"role": "user", "content": content}],
text={"verbosity": "medium"},
store=False,
stream=True,
)
text_parts: list[str] = []
async for event in stream:
payload = _response_event_to_dict(event)
event_type = payload.get("type") or ""
if event_type == "response.output_text.delta":
delta = payload.get("delta") or ""
if delta:
text_parts.append(delta)
continue
if event_type == "response.output_text.done":
text = payload.get("text") or ""
if text and not text_parts:
text_parts.append(text)
continue
if event_type in ("response.error", "response.failed", "error"):
error_detail = payload.get("message") or payload.get("error") or str(payload)
raise HTTPException(status_code=502, detail=f"Codex error: {error_detail}"[:400])
output_text = "".join(text_parts).strip()
if not output_text:
raise HTTPException(status_code=500, detail="No output from template provider")
return output_text
async def _call_google(
*,
model: str,
system_prompt: str,
user_text: str,
image_bytes: Optional[bytes] = None,
media_type: str = "image/png",
) -> str:
client = _get_google_client()
parts = [google_types.Part.from_text(text=user_text)]
if image_bytes:
parts.append(google_types.Part.from_bytes(data=image_bytes, mime_type=media_type))
response = await asyncio.to_thread(
client.models.generate_content,
model=model,
contents=[google_types.Content(role="user", parts=parts)],
config=google_types.GenerateContentConfig(
system_instruction=system_prompt,
response_mime_type="text/plain",
),
)
output_text = getattr(response, "text", None) or ""
if not output_text:
raise HTTPException(status_code=500, detail="No output from template provider")
return output_text
async def _call_anthropic(
*,
model: str,
system_prompt: str,
user_text: str,
image_bytes: Optional[bytes] = None,
media_type: str = "image/png",
) -> str:
client = _get_anthropic_client()
content = [{"type": "text", "text": user_text}]
if image_bytes:
content.append(
{
"type": "image",
"source": {
"type": "base64",
"media_type": media_type,
"data": base64.b64encode(image_bytes).decode("utf-8"),
},
}
)
response = await client.messages.create(
model=model,
max_tokens=8192,
system=system_prompt,
messages=[{"role": "user", "content": content}],
)
output_text = "".join(
block.text for block in response.content if getattr(block, "type", None) == "text"
)
if not output_text:
raise HTTPException(status_code=500, detail="No output from template provider")
return output_text
def _build_provider_call(
*,
spec: Optional[TemplateProviderSpec] = None,
system_prompt: str,
user_text: str,
image_bytes: Optional[bytes] = None,
media_type: str = "image/png",
) -> PlainLLMProvider:
spec = spec or get_template_provider_spec()
if spec.provider == LLMProvider.OPENAI:
return PlainLLMProvider(
name="OpenAI",
call=lambda: _call_openai_like(
client=_get_openai_client(),
model=spec.model,
system_prompt=system_prompt,
user_text=user_text,
image_bytes=image_bytes,
media_type=media_type,
),
)
if spec.provider == LLMProvider.CODEX:
return PlainLLMProvider(
name="Codex",
call=lambda: _call_codex(
model=spec.model,
system_prompt=system_prompt,
user_text=user_text,
image_bytes=image_bytes,
media_type=media_type,
),
)
if spec.provider == LLMProvider.GOOGLE:
return PlainLLMProvider(
name="Google",
call=lambda: _call_google(
model=spec.model,
system_prompt=system_prompt,
user_text=user_text,
image_bytes=image_bytes,
media_type=media_type,
),
)
if spec.provider == LLMProvider.ANTHROPIC:
return PlainLLMProvider(
name="Anthropic",
call=lambda: _call_anthropic(
model=spec.model,
system_prompt=system_prompt,
user_text=user_text,
image_bytes=image_bytes,
media_type=media_type,
),
)
raise HTTPException(
status_code=400,
detail="Template generation only supports OpenAI, Codex, Google, or Anthropic.",
)
async def generate_slide_layout_code(
*,
system_prompt: str,
user_text: str,
image_bytes: bytes,
media_type: str = "image/png",
) -> str:
provider = _build_provider_call(
system_prompt=system_prompt,
user_text=user_text,
image_bytes=image_bytes,
media_type=media_type,
)
return await run_plain_provider_buckets(providers=[provider])
async def edit_slide_layout_code(
*,
system_prompt: str,
user_text: str,
) -> str:
provider = _build_provider_call(
system_prompt=system_prompt,
user_text=user_text,
)
return await run_plain_provider_buckets(providers=[provider])

View file

@ -0,0 +1,65 @@
import uuid
from fastapi import APIRouter
from templates.handler import (
CreateSlideLayoutResponse,
EditSlideLayoutResponse,
EditSlideLayoutSectionResponse,
FontsUploadAndSlidesPreviewResponse,
GetTemplateLayoutsResponse,
PresentationLayoutModel,
SaveTemplateLayoutData,
SaveTemplateResponse,
TemplateDetail,
TemplateExample,
clone_slide_layout,
clone_template,
create_slide_layout,
edit_slide_layout,
edit_slide_layout_section,
get_all_templates,
get_layouts,
get_template_by_id,
get_template_example,
init_create_template,
save_slide_layout,
save_template,
update_template,
upload_fonts_and_slides_preview,
)
TEMPLATE_ROUTER = APIRouter(prefix="/template", tags=["Template"])
TEMPLATE_ROUTER.get("/all", response_model=list[TemplateDetail])(get_all_templates)
TEMPLATE_ROUTER.get(
"/{template_id}/layouts", response_model=GetTemplateLayoutsResponse
)(get_layouts)
TEMPLATE_ROUTER.get("/{id}", response_model=PresentationLayoutModel)(get_template_by_id)
TEMPLATE_ROUTER.get("/{id}/example", response_model=TemplateExample)(
get_template_example
)
TEMPLATE_ROUTER.post(
"/fonts-upload-and-slides-preview",
response_model=FontsUploadAndSlidesPreviewResponse,
)(upload_fonts_and_slides_preview)
TEMPLATE_ROUTER.post("/create/init", response_model=uuid.UUID)(init_create_template)
TEMPLATE_ROUTER.post("/slide-layout/create", response_model=CreateSlideLayoutResponse)(
create_slide_layout
)
TEMPLATE_ROUTER.post("/create/slide-layout", response_model=CreateSlideLayoutResponse)(
create_slide_layout
)
TEMPLATE_ROUTER.post("/slide-layout/edit", response_model=EditSlideLayoutResponse)(
edit_slide_layout
)
TEMPLATE_ROUTER.post(
"/slide-layout/edit-section", response_model=EditSlideLayoutSectionResponse
)(edit_slide_layout_section)
TEMPLATE_ROUTER.post("/save", response_model=SaveTemplateResponse)(save_template)
TEMPLATE_ROUTER.post("/clone", response_model=SaveTemplateResponse)(clone_template)
TEMPLATE_ROUTER.put("/update", response_model=SaveTemplateResponse)(update_template)
TEMPLATE_ROUTER.post("/slide-layout/save", status_code=200)(save_slide_layout)
TEMPLATE_ROUTER.post("/slide-layout/clone", response_model=SaveTemplateLayoutData)(
clone_slide_layout
)

View file

@ -398,3 +398,38 @@ class TestImageGenerationEndpoint:
asyncio.run(run_test())
def test_search_stock_images_defaults_to_selected_pixabay(self, client, mock_images_directory):
"""
Test stock image search defaults to IMAGE_PROVIDER when provider query param is omitted
- Sets IMAGE_PROVIDER to pixabay
- Ensures /images/search uses Pixabay instead of returning provider validation error
"""
with patch.dict(os.environ, {"IMAGE_PROVIDER": "pixabay"}):
with patch('api.v1.ppt.endpoints.images.get_images_directory', return_value=mock_images_directory):
with patch('api.v1.ppt.endpoints.images.ImageGenerationService') as mock_service_class:
mock_service_instance = Mock()
mock_service_instance.get_image_from_pixabay = AsyncMock(
return_value=["https://example.com/pixabay_image.jpg"]
)
mock_service_instance.get_image_from_pexels = AsyncMock(
return_value=["https://example.com/pexels_image.jpg"]
)
mock_service_class.return_value = mock_service_instance
response = client.get("/images/search?query=business&limit=1")
assert response.status_code == 200
assert response.json() == ["https://example.com/pixabay_image.jpg"]
mock_service_instance.get_image_from_pixabay.assert_awaited_once()
mock_service_instance.get_image_from_pexels.assert_not_called()
def test_search_stock_images_invalid_provider_returns_400(self, client):
"""
Test stock image search validates invalid provider values
- Ensures unsupported providers return HTTP 400 with clear guidance
"""
response = client.get("/images/search?query=business&provider=invalid-provider")
assert response.status_code == 400
assert response.json()["detail"] == "provider must be either 'pexels' or 'pixabay'"

View file

@ -1,140 +0,0 @@
import os
import tempfile
import zipfile
from fastapi.testclient import TestClient
from fastapi import UploadFile
import pytest
from api.main import app
client = TestClient(app)
def create_sample_pptx():
"""Create a minimal PPTX file for testing."""
# This creates a very basic PPTX structure for testing
pptx_content = {
'[Content_Types].xml': '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="xml" ContentType="application/xml"/>
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Override PartName="/ppt/presentation.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/>
<Override PartName="/ppt/slides/slide1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>
</Types>''',
'_rels/.rels': '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="ppt/presentation.xml"/>
</Relationships>''',
'ppt/presentation.xml': '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
<p:sldMasterIdLst/>
<p:sldIdLst>
<p:sldId id="256" r:id="rId2"/>
</p:sldIdLst>
<p:sldSz cx="9144000" cy="6858000"/>
</p:presentation>''',
'ppt/_rels/presentation.xml.rels': '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide1.xml"/>
</Relationships>''',
'ppt/slides/slide1.xml': '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:sld xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
<p:cSld>
<p:spTree>
<p:nvGrpSpPr>
<p:cNvPr id="1" name=""/>
<p:cNvGrpSpPr/>
<p:nvPr/>
</p:nvGrpSpPr>
<p:grpSpPr>
<a:xfrm xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
<a:off x="0" y="0"/>
<a:ext cx="0" cy="0"/>
<a:chOff x="0" y="0"/>
<a:chExt cx="0" cy="0"/>
</a:xfrm>
</p:grpSpPr>
</p:spTree>
</p:cSld>
<p:clrMapOvr>
<a:masterClrMapping xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"/>
</p:clrMapOvr>
</p:sld>'''
}
with tempfile.NamedTemporaryFile(suffix='.pptx', delete=False) as temp_file:
with zipfile.ZipFile(temp_file.name, 'w') as zip_file:
for path, content in pptx_content.items():
zip_file.writestr(path, content)
return temp_file.name
def test_pptx_slides_processing():
"""Test the PPTX slides processing endpoint."""
# Create a sample PPTX file
pptx_path = create_sample_pptx()
try:
with open(pptx_path, 'rb') as pptx_file:
files = {'pptx_file': ('test.pptx', pptx_file, 'application/vnd.openxmlformats-officedocument.presentationml.presentation')}
response = client.post("/api/v1/ppt/pptx-slides/process", files=files)
# Check response
assert response.status_code == 200
data = response.json()
assert data['success'] == True
assert 'slides' in data
assert 'total_slides' in data
assert data['total_slides'] > 0
# Check slide data structure
if data['slides']:
slide = data['slides'][0]
assert 'slide_number' in slide
assert 'screenshot_url' in slide
assert 'xml_content' in slide
assert slide['slide_number'] == 1
assert slide['xml_content'] != ''
print(f"✅ Test passed! Processed {data['total_slides']} slides successfully")
finally:
# Clean up
if os.path.exists(pptx_path):
os.unlink(pptx_path)
def test_invalid_file_type():
"""Test that non-PPTX files are rejected."""
# Create a text file and try to upload it
with tempfile.NamedTemporaryFile(suffix='.txt', delete=False) as temp_file:
temp_file.write(b"This is not a PPTX file")
temp_file.flush()
try:
with open(temp_file.name, 'rb') as txt_file:
files = {'pptx_file': ('test.txt', txt_file, 'text/plain')}
response = client.post("/api/v1/ppt/pptx-slides/process", files=files)
# Should return 400 for invalid file type
assert response.status_code == 400
data = response.json()
assert 'Invalid file type' in data['detail']
print("✅ Invalid file type test passed!")
finally:
os.unlink(temp_file.name)
if __name__ == "__main__":
print("Running PPTX slides processing tests...")
test_pptx_slides_processing()
test_invalid_file_type()
print("🎉 All tests completed!")

View file

@ -1,189 +1,117 @@
from unittest.mock import patch, AsyncMock, MagicMock
import asyncio
import uuid
from unittest.mock import AsyncMock, patch
import pytest
from fastapi.testclient import TestClient
from fastapi import FastAPI
from models.presentation_layout import PresentationLayoutModel
from models.presentation_structure_model import PresentationStructureModel
from api.v1.ppt.endpoints.presentation import PRESENTATION_ROUTER
from fastapi import HTTPException
from pydantic import ValidationError
class MockAiohttpResponse:
def __init__(self, status=200, json_data=None):
self.status = status
self._json_data = json_data or {"path": "/tmp/exports/test.pdf"}
from api.v1.ppt.endpoints.presentation import generate_presentation_sync
from models.generate_presentation_request import GeneratePresentationRequest
from models.presentation_and_path import PresentationPathAndEditPath
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
pass
class FakeAsyncSession:
async def get(self, *_args, **_kwargs):
return None
async def json(self):
return self._json_data
def add(self, *_args, **_kwargs):
return None
async def text(self):
return str(self._json_data)
def add_all(self, *_args, **_kwargs):
return None
class MockAiohttpSession:
def __init__(self, *args, **kwargs):
pass
async def commit(self):
return None
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
pass
def post(self, *args, **kwargs):
return MockAiohttpResponse()
def get(self, *args, **kwargs):
pptx_model_data = {
"slides": [],
"title": "Test",
"notes": [],
"layout": {},
"structure": {},
}
return MockAiohttpResponse(json_data=pptx_model_data)
@pytest.fixture
def app():
app = FastAPI()
app.include_router(PRESENTATION_ROUTER, prefix="/api/v1/ppt")
return app
@pytest.fixture
def client(app):
return TestClient(app)
@pytest.fixture
def mock_get_layout():
async def _mock_get_layout_by_name(layout_name: str):
mock_slide = MagicMock()
mock_slide.name = "Mock Slide"
mock_slide.json_schema = {"title": "Mock Slide Title"}
mock_slide.description = "Mock slide description"
mock_layout = MagicMock(spec=PresentationLayoutModel)
mock_layout.name = layout_name
mock_layout.ordered = True
mock_layout.slides = [mock_slide]
mock_layout.model_dump = lambda: {}
mock_layout.to_presentation_structure = lambda: PresentationStructureModel(
slides=[index for index in range(len(mock_layout.slides))]
)
def to_string():
message = f"## Presentation Layout\n\n"
for index, slide in enumerate(mock_layout.slides):
message += f"### Slide Layout: {index}: \n"
message += f"- Name: {slide.name or slide.json_schema.get('title')} \n"
message += f"- Description: {slide.description} \n\n"
return message
mock_layout.to_string = to_string
return mock_layout
return _mock_get_layout_by_name
async def mock_generate_ppt_outline(*args, **kwargs):
yield '{"title": "Test", "slides": [{"title": "Slide 1", "body": "Body 1"}], "notes": []}'
@pytest.fixture(autouse=True)
def patch_presentation_api(monkeypatch, mock_get_layout):
# Patch all dependencies used in the API
patches = [
patch('api.v1.ppt.endpoints.presentation.get_layout_by_name', new=AsyncMock(side_effect=mock_get_layout)),
patch('api.v1.ppt.endpoints.presentation.TEMP_FILE_SERVICE.create_temp_dir', return_value='/tmp/mockdir'),
patch('api.v1.ppt.endpoints.presentation.DocumentsLoader'),
patch('api.v1.ppt.endpoints.presentation.generate_document_summary', new_callable=AsyncMock, return_value="mock_summary"),
patch('api.v1.ppt.endpoints.presentation.generate_ppt_outline', side_effect=mock_generate_ppt_outline),
patch('api.v1.ppt.endpoints.presentation.get_sql_session'),
patch('api.v1.ppt.endpoints.presentation.get_slide_content_from_type_and_outline', new_callable=AsyncMock, return_value={"mock": "slide_content"}),
patch('api.v1.ppt.endpoints.presentation.process_slide_and_fetch_assets', new_callable=AsyncMock),
patch('api.v1.ppt.endpoints.presentation.get_exports_directory', return_value='/tmp/exports'),
patch('api.v1.ppt.endpoints.presentation.PptxPresentationCreator'),
patch('api.v1.ppt.endpoints.presentation.aiohttp.ClientSession', return_value=MockAiohttpSession()),
]
mocks = [p.start() for p in patches]
# Setup DocumentsLoader mock
docs_loader = mocks[2]
docs_loader.return_value.load_documents = AsyncMock()
docs_loader.return_value.documents = []
# Setup PptxPresentationCreator mock for pptx test
pptx_creator = mocks[9]
pptx_creator.return_value.create_ppt = AsyncMock()
pptx_creator.return_value.save = MagicMock()
yield
for p in patches:
p.stop()
class TestPresentationGenerationAPI:
def test_generate_presentation_export_as_pdf(self, client):
response = client.post(
"/api/v1/ppt/presentation/generate",
json={
"content": "Create a presentation about artificial intelligence and machine learning",
"n_slides": 5,
"language": "English",
"export_as": "pdf",
"layout": "general"
}
def test_generate_presentation_export_as_pdf(self):
request = GeneratePresentationRequest(
content="Create a presentation about artificial intelligence and machine learning",
n_slides=5,
language="English",
export_as="pdf",
template="general",
)
assert response.status_code == 200
assert "presentation_id" in response.json()
assert "pdf" in response.json()["path"]
def test_generate_presentation_export_as_pptx(self, client):
response = client.post(
"/api/v1/ppt/presentation/generate",
json={
"content": "Create a presentation about artificial intelligence and machine learning",
"n_slides": 5,
"language": "English",
"export_as": "pptx",
"layout": "general"
}
response_payload = PresentationPathAndEditPath(
presentation_id=uuid.uuid4(),
path="/tmp/exports/test.pdf",
edit_path="/presentation?id=test",
)
assert response.status_code == 200
assert "presentation_id" in response.json()
assert "pptx" in response.json()["path"]
def test_generate_presentation_with_no_content(self, client):
response = client.post(
"/api/v1/ppt/presentation/generate",
json={
"n_slides": 5,
"language": "English",
"export_as": "pdf",
"layout": "general"
}
with patch(
"api.v1.ppt.endpoints.presentation.generate_presentation_handler",
new=AsyncMock(return_value=response_payload),
) as mock_handler:
response = asyncio.run(
generate_presentation_sync(request, sql_session=FakeAsyncSession())
)
assert response == response_payload
mock_handler.assert_awaited_once()
def test_generate_presentation_export_as_pptx(self):
request = GeneratePresentationRequest(
content="Create a presentation about artificial intelligence and machine learning",
n_slides=5,
language="English",
export_as="pptx",
template="general",
)
assert response.status_code == 422
def test_generate_presentation_with_n_slides_less_than_one(self, client):
response = client.post(
"/api/v1/ppt/presentation/generate",
json={
"content": "Create a presentation about artificial intelligence and machine learning",
"n_slides": 0,
"language": "English",
"export_as": "pdf",
"layout": "general"
}
response_payload = PresentationPathAndEditPath(
presentation_id=uuid.uuid4(),
path="/tmp/exports/test.pptx",
edit_path="/presentation?id=test",
)
assert response.status_code == 422
def test_generate_presentation_with_invalid_export_type(self, client):
response = client.post(
"/api/v1/ppt/presentation/generate",
json={
"content": "Create a presentation about artificial intelligence and machine learning",
"n_slides": 5,
"language": "English",
"export_as": "invalid_type",
"layout": "general"
}
with patch(
"api.v1.ppt.endpoints.presentation.generate_presentation_handler",
new=AsyncMock(return_value=response_payload),
) as mock_handler:
response = asyncio.run(
generate_presentation_sync(request, sql_session=FakeAsyncSession())
)
assert response == response_payload
mock_handler.assert_awaited_once()
def test_generate_presentation_with_no_content(self):
with pytest.raises(ValidationError):
GeneratePresentationRequest.model_validate(
{
"n_slides": 5,
"language": "English",
"export_as": "pdf",
"template": "general",
}
)
def test_generate_presentation_with_n_slides_less_than_one(self):
request = GeneratePresentationRequest(
content="Create a presentation about artificial intelligence and machine learning",
n_slides=0,
language="English",
export_as="pdf",
template="general",
)
assert response.status_code == 422
with pytest.raises(HTTPException) as exc:
asyncio.run(
generate_presentation_sync(request, sql_session=FakeAsyncSession())
)
assert exc.value.status_code == 400
assert exc.value.detail == "Number of slides must be greater than 0"
def test_generate_presentation_with_invalid_export_type(self):
with pytest.raises(ValidationError):
GeneratePresentationRequest.model_validate(
{
"content": "Create a presentation about artificial intelligence and machine learning",
"n_slides": 5,
"language": "English",
"export_as": "invalid_type",
"template": "general",
}
)

View file

@ -1,115 +0,0 @@
import pytest
import os
from fastapi.testclient import TestClient
# Import the main app
from server import app
client = TestClient(app)
def test_slide_to_html_endpoint():
"""Test the slide-to-html endpoint with streaming API support."""
# Sample XML data (simplified version of OXML)
test_xml = '''<?xml version="1.0" encoding="UTF-8"?>
<p:sld xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
<p:cSld>
<p:bg>
<p:bgPr>
<a:solidFill xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
<a:srgbClr val="FFFFFF"/>
</a:solidFill>
</p:bgPr>
</p:bg>
<p:spTree>
<p:sp>
<p:txBody>
<a:p xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
<a:r>
<a:t>Test Slide</a:t>
</a:r>
</a:p>
</p:txBody>
</p:sp>
</p:spTree>
</p:cSld>
</p:sld>'''
# Skip this test if ANTHROPIC_API_KEY is not set
if not os.getenv('ANTHROPIC_API_KEY'):
pytest.skip("ANTHROPIC_API_KEY not set - skipping API test")
# Use a placeholder image path (since we can't easily test with real files)
test_data = {
"image": "/static/images/placeholder.jpg",
"xml": test_xml
}
# Make the request with JSON
response = client.post(
"/api/v1/ppt/slide-to-html/",
json=test_data
)
# Check response (may take several minutes due to streaming)
print("Note: This test may take several minutes due to Claude's streaming processing...")
if response.status_code == 200:
data = response.json()
assert data["success"] is True
assert "html" in data
assert len(data["html"]) > 0
print(f"Generated HTML preview: {data['html'][:200]}...")
print("✅ Streaming API test completed successfully")
else:
print(f"Request failed with status {response.status_code}: {response.text}")
# Don't fail the test if API key is missing or invalid
if "ANTHROPIC_API_KEY" in response.text:
pytest.skip("Invalid API key - skipping test")
elif "Streaming is required" in response.text:
print("✅ Streaming error handled correctly by endpoint")
def test_slide_to_html_invalid_path():
"""Test the endpoint with an invalid image path."""
test_data = {
"image": "/app_data/images/nonexistent/image.png",
"xml": "<simple>xml</simple>"
}
response = client.post(
"/api/v1/ppt/slide-to-html/",
json=test_data
)
assert response.status_code == 404
assert "Image file not found" in response.json()["detail"]
def test_slide_to_html_missing_xml():
"""Test the endpoint with missing XML data."""
test_data = {
"image": "/static/images/placeholder.jpg"
# No XML data provided
}
response = client.post(
"/api/v1/ppt/slide-to-html/",
json=test_data
)
assert response.status_code == 422 # Validation error
if __name__ == "__main__":
# Run a simple test
test_slide_to_html_invalid_path()
print("✅ Invalid path test passed")
test_slide_to_html_missing_xml()
print("✅ Missing XML test passed")
print("🧪 Run full tests with: pytest test_slide_to_html.py")

View file

@ -0,0 +1,632 @@
import asyncio
import base64
import re
from datetime import datetime, timezone
import pytest
from fastapi import HTTPException
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy.sql import Delete, Select
from api.v1.ppt.router import API_V1_PPT_ROUTER
from enums.llm_provider import LLMProvider
from models.sql.presentation_layout_code import PresentationLayoutCodeModel
from models.sql.template import TemplateModel
from models.sql.template_create_info import TemplateCreateInfoModel
from services.export_task_service import PptxToHtmlDocument
from templates.handler import (
CloneSlideLayoutRequest,
CreateSlideLayoutRequest,
EditSlideLayoutRequest,
EditSlideLayoutSectionRequest,
SaveSlideLayoutRequest,
SaveTemplateLayoutData,
SaveTemplateRequest,
UpdateTemplateRequest,
clone_slide_layout,
create_slide_layout,
edit_slide_layout,
edit_slide_layout_section,
init_create_template,
save_slide_layout,
save_template,
update_template,
upload_fonts_and_slides_preview,
)
from templates.preview import (
FontCheckResponse,
FontsUploadAndSlidesPreviewResponse,
check_fonts_in_pptx_handler,
)
from templates.providers import (
generate_slide_layout_code,
get_template_provider_spec,
)
PNG_BYTES = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9s5WzxQAAAAASUVORK5CYII="
)
@pytest.fixture
def api_client(tmp_path, monkeypatch):
monkeypatch.setenv("APP_DATA_DIRECTORY", str(tmp_path / "app_data"))
app = FastAPI()
app.include_router(API_V1_PPT_ROUTER)
client = TestClient(app)
yield client, tmp_path
client.close()
class FakeScalarResult:
def __init__(self, items):
self._items = list(items)
def all(self):
return list(self._items)
class FakeExecuteResult:
def __init__(self, items):
self._items = list(items)
def scalars(self):
return FakeScalarResult(self._items)
def all(self):
return list(self._items)
class FakeAsyncSession:
def __init__(self):
self.template_infos: dict = {}
self.templates: dict = {}
self.layouts: list[PresentationLayoutCodeModel] = []
self._next_layout_row_id = 1
async def get(self, model, key):
if model is TemplateCreateInfoModel:
return self.template_infos.get(key)
if model is TemplateModel:
return self.templates.get(key)
return None
def add(self, obj):
if isinstance(obj, TemplateCreateInfoModel):
if getattr(obj, "created_at", None) is None:
obj.created_at = datetime.now(timezone.utc)
self.template_infos[obj.id] = obj
elif isinstance(obj, TemplateModel):
if getattr(obj, "created_at", None) is None:
obj.created_at = datetime.now(timezone.utc)
self.templates[obj.id] = obj
elif isinstance(obj, PresentationLayoutCodeModel):
if obj.id is None:
obj.id = self._next_layout_row_id
self._next_layout_row_id += 1
if getattr(obj, "created_at", None) is None:
obj.created_at = datetime.now(timezone.utc)
if getattr(obj, "updated_at", None) is None:
obj.updated_at = datetime.now(timezone.utc)
self.layouts.append(obj)
def add_all(self, objects):
for obj in objects:
self.add(obj)
async def commit(self):
return None
async def refresh(self, _obj):
return None
async def scalar(self, statement):
items = self._execute_select(statement)
return items[0] if items else None
async def execute(self, statement):
if isinstance(statement, Delete):
self._execute_delete(statement)
return FakeExecuteResult([])
return FakeExecuteResult(self._execute_select(statement))
def _execute_select(self, statement):
if not isinstance(statement, Select):
return []
entity = statement.column_descriptions[0].get("entity")
if entity is not PresentationLayoutCodeModel:
return []
return [
layout
for layout in self.layouts
if all(self._matches_clause(layout, clause) for clause in statement._where_criteria)
]
def _execute_delete(self, statement):
if statement.table.name != PresentationLayoutCodeModel.__tablename__:
return
self.layouts = [
layout
for layout in self.layouts
if not all(self._matches_clause(layout, clause) for clause in statement._where_criteria)
]
def _matches_clause(self, obj, clause):
if hasattr(clause, "clauses"):
return all(self._matches_clause(obj, child) for child in clause.clauses)
left = getattr(clause.left, "key", None) or getattr(clause.left, "name", None)
right = getattr(clause.right, "value", None)
return getattr(obj, left) == right
class SimpleUploadFile:
def __init__(self, filename: str, content_type: str, data: bytes):
self.filename = filename
self.content_type = content_type
self._data = data
async def read(self):
return self._data
def test_router_registration_replaces_old_routes(api_client):
client, _ = api_client
paths = {
route.path
for route in client.app.routes
if hasattr(route, "path") and route.path.startswith("/api/v1/ppt")
}
assert "/api/v1/ppt/template/all" in paths
assert "/api/v1/ppt/template/create/init" in paths
assert "/api/v1/ppt/template/slide-layout/create" in paths
assert "/api/v1/ppt/template/fonts-upload-and-slides-preview" in paths
assert "/api/v1/ppt/fonts/check" in paths
assert "/api/v1/ppt/fonts/upload" in paths
assert "/api/v1/ppt/fonts/list" in paths
assert "/api/v1/ppt/fonts/uploaded" in paths
assert "/api/v1/ppt/slide-to-html/" not in paths
assert "/api/v1/ppt/html-to-react/" not in paths
assert "/api/v1/ppt/html-edit/" not in paths
assert "/api/v1/ppt/pptx-slides/process" not in paths
assert "/api/v1/ppt/pdf-slides/process" not in paths
assert "/api/v1/ppt/pptx-fonts/process" not in paths
def test_template_create_init_stores_exported_htmls(tmp_path, monkeypatch):
session = FakeAsyncSession()
pptx_path = tmp_path / "presentation.pptx"
pptx_path.write_bytes(b"pptx")
async def fake_convert(pptx_path_value: str, get_fonts: bool = False):
assert pptx_path_value == str(pptx_path)
assert get_fonts is False
return PptxToHtmlDocument(
slides=["<div>slide 1</div>", "<div>slide 2</div>"],
width=1280,
height=720,
images_dir="images",
fonts_dir="fonts",
)
monkeypatch.setattr(
"templates.handler.resolve_app_path_to_filesystem",
lambda _value: str(pptx_path),
)
monkeypatch.setattr(
"templates.handler.EXPORT_TASK_SERVICE.convert_pptx_to_html",
fake_convert,
)
template_info_id = asyncio.run(
init_create_template(
request=type(
"Request",
(),
{
"pptx_url": "/app_data/uploads/template-previews/test/presentation.pptx",
"slide_image_urls": [
"/app_data/images/a/slide_1.png",
"/app_data/images/a/slide_2.png",
],
"fonts": {
"Inter": "https://fonts.googleapis.com/css2?family=Inter&display=swap"
},
},
)(),
sql_session=session,
)
)
template_info = session.template_infos[template_info_id]
assert template_info.slide_htmls == ["<div>slide 1</div>", "<div>slide 2</div>"]
assert template_info.slide_image_urls == [
"/app_data/images/a/slide_1.png",
"/app_data/images/a/slide_2.png",
]
def test_template_create_init_truncates_exported_htmls_to_preview_count(tmp_path, monkeypatch):
session = FakeAsyncSession()
pptx_path = tmp_path / "presentation.pptx"
pptx_path.write_bytes(b"pptx")
async def fake_convert(_pptx_path_value: str, get_fonts: bool = False):
assert get_fonts is False
return PptxToHtmlDocument(
slides=["<div>slide 1</div>", "<div>slide 2</div>", "<div>slide 3</div>"],
width=1280,
height=720,
images_dir="images",
fonts_dir="fonts",
)
monkeypatch.setattr(
"templates.handler.resolve_app_path_to_filesystem",
lambda _value: str(pptx_path),
)
monkeypatch.setattr(
"templates.handler.EXPORT_TASK_SERVICE.convert_pptx_to_html",
fake_convert,
)
template_info_id = asyncio.run(
init_create_template(
request=type(
"Request",
(),
{
"pptx_url": "/app_data/uploads/template-previews/test/presentation.pptx",
"slide_image_urls": ["/app_data/images/a/slide_1.png"],
"fonts": {},
},
)(),
sql_session=session,
)
)
assert session.template_infos[template_info_id].slide_htmls == ["<div>slide 1</div>"]
def test_template_create_init_fails_when_export_returns_too_few_slides(tmp_path, monkeypatch):
session = FakeAsyncSession()
pptx_path = tmp_path / "presentation.pptx"
pptx_path.write_bytes(b"pptx")
async def fake_convert(_pptx_path_value: str, get_fonts: bool = False):
assert get_fonts is False
return PptxToHtmlDocument(
slides=["<div>slide 1</div>"],
width=1280,
height=720,
images_dir="images",
fonts_dir="fonts",
)
monkeypatch.setattr(
"templates.handler.resolve_app_path_to_filesystem",
lambda _value: str(pptx_path),
)
monkeypatch.setattr(
"templates.handler.EXPORT_TASK_SERVICE.convert_pptx_to_html",
fake_convert,
)
with pytest.raises(HTTPException) as exc:
asyncio.run(
init_create_template(
request=type(
"Request",
(),
{
"pptx_url": "/app_data/uploads/template-previews/test/presentation.pptx",
"slide_image_urls": [
"/app_data/images/a/slide_1.png",
"/app_data/images/a/slide_2.png",
],
"fonts": {},
},
)(),
sql_session=session,
)
)
assert exc.value.status_code == 400
assert "returned fewer slides than the preview images" in exc.value.detail
def test_fonts_check_endpoint(monkeypatch):
async def fake_font_check(_pptx_path: str, _temp_dir: str):
return [("Inter", "https://fonts.googleapis.com/css2?family=Inter&display=swap")], [("Custom Font", None)]
async def fake_to_thread(func, *args, **kwargs):
return func(*args, **kwargs)
monkeypatch.setattr(
"templates.preview.get_available_and_unavailable_fonts_for_pptx",
fake_font_check,
)
monkeypatch.setattr("templates.preview.asyncio.to_thread", fake_to_thread)
upload = SimpleUploadFile(
filename="deck.pptx",
content_type="application/vnd.openxmlformats-officedocument.presentationml.presentation",
data=b"fake-pptx",
)
response = asyncio.run(check_fonts_in_pptx_handler(pptx_file=upload))
assert response == FontCheckResponse(
available_fonts=[
{
"name": "Inter",
"url": "https://fonts.googleapis.com/css2?family=Inter&display=swap",
}
],
unavailable_fonts=[{"name": "Custom Font", "url": None}],
)
def test_fonts_upload_and_preview_route_uses_new_handler(monkeypatch):
async def fake_preview_handler(**kwargs):
assert kwargs["pptx_file"].filename == "deck.pptx"
return FontsUploadAndSlidesPreviewResponse(
slide_image_urls=["/app_data/images/1/slide_1.png"],
pptx_url="/app_data/uploads/template-previews/1/presentation.pptx",
modified_pptx_url="/app_data/uploads/template-previews/1/presentation.pptx",
fonts={"Inter": "https://fonts.googleapis.com/css2?family=Inter&display=swap"},
)
monkeypatch.setattr(
"templates.handler.upload_fonts_and_slides_preview_handler",
fake_preview_handler,
)
upload = SimpleUploadFile(
filename="deck.pptx",
content_type="application/vnd.openxmlformats-officedocument.presentationml.presentation",
data=b"fake-pptx",
)
response = asyncio.run(upload_fonts_and_slides_preview(pptx_file=upload))
assert response == FontsUploadAndSlidesPreviewResponse(
slide_image_urls=["/app_data/images/1/slide_1.png"],
pptx_url="/app_data/uploads/template-previews/1/presentation.pptx",
modified_pptx_url="/app_data/uploads/template-previews/1/presentation.pptx",
fonts={"Inter": "https://fonts.googleapis.com/css2?family=Inter&display=swap"},
)
def test_provider_spec_mapping_and_restrictions(monkeypatch):
monkeypatch.setattr("templates.providers.get_model", lambda: "user-selected-model")
monkeypatch.setattr("templates.providers.get_llm_provider", lambda: LLMProvider.OPENAI)
spec = get_template_provider_spec()
assert spec.provider == LLMProvider.OPENAI
assert spec.model == "user-selected-model"
monkeypatch.setattr("templates.providers.get_llm_provider", lambda: LLMProvider.GOOGLE)
spec = get_template_provider_spec()
assert spec.provider == LLMProvider.GOOGLE
assert spec.model == "user-selected-model"
monkeypatch.setattr("templates.providers.get_llm_provider", lambda: LLMProvider.ANTHROPIC)
spec = get_template_provider_spec()
assert spec.provider == LLMProvider.ANTHROPIC
assert spec.model == "user-selected-model"
monkeypatch.setattr("templates.providers.get_llm_provider", lambda: LLMProvider.CODEX)
spec = get_template_provider_spec()
assert spec.provider == LLMProvider.CODEX
assert spec.model == "user-selected-model"
monkeypatch.setattr("templates.providers.get_llm_provider", lambda: LLMProvider.OLLAMA)
with pytest.raises(Exception) as exc:
get_template_provider_spec()
assert "Template generation only supports OpenAI, Codex, Google, or Anthropic." in str(exc.value)
def test_generate_slide_layout_code_uses_streaming_for_codex(monkeypatch):
create_kwargs = {}
class FakeResponses:
async def create(self, **kwargs):
create_kwargs.update(kwargs)
async def _stream():
yield {"type": "response.output_text.delta", "delta": "const layoutId = "}
yield {"type": "response.output_text.delta", "delta": '"title-image";'}
return _stream()
class FakeClient:
def __init__(self):
self.responses = FakeResponses()
monkeypatch.setattr("templates.providers.get_llm_provider", lambda: LLMProvider.CODEX)
monkeypatch.setattr("templates.providers.get_model", lambda: "user-selected-model")
monkeypatch.setattr("templates.providers._get_codex_client", lambda: FakeClient())
response = asyncio.run(
generate_slide_layout_code(
system_prompt="system prompt",
user_text="user text",
image_bytes=PNG_BYTES,
media_type="image/png",
)
)
assert response == 'const layoutId = "title-image";'
assert create_kwargs["model"] == "user-selected-model"
assert create_kwargs["stream"] is True
assert create_kwargs["store"] is False
assert create_kwargs["text"] == {"verbosity": "medium"}
assert create_kwargs["input"][0]["content"][0]["type"] == "input_image"
assert create_kwargs["input"][0]["content"][1] == {
"type": "input_text",
"text": "user text",
}
def test_create_and_edit_slide_layout_routes_use_provider_layer(tmp_path, monkeypatch):
session = FakeAsyncSession()
image_path = tmp_path / "slide.png"
image_path.write_bytes(PNG_BYTES)
template_info = TemplateCreateInfoModel(
fonts={"Inter": "https://fonts.googleapis.com/css2?family=Inter&display=swap"},
pptx_url="/app_data/uploads/template-previews/seed/presentation.pptx",
slide_htmls=["<div>seed html</div>"],
slide_image_urls=[str(image_path)],
)
session.add(template_info)
create_calls = []
edit_calls = []
async def fake_generate_layout(**kwargs):
create_calls.append(kwargs)
return """```tsx
import { z } from "zod";
const layoutId = "title-image";
const layoutName = "Title Image";
const layoutDescription = "desc";
function dynamicSlideLayout() { return <div>{image_url}{icon_url}{image_prompt}{icon_query}</div>; }
export { layoutId };
```"""
async def fake_edit_layout(**kwargs):
edit_calls.append(kwargs)
return "```tsx\nconst updatedLayout = true;\n```"
monkeypatch.setattr("templates.handler.generate_slide_layout_code", fake_generate_layout)
monkeypatch.setattr("templates.handler.edit_slide_layout_code", fake_edit_layout)
create_response = asyncio.run(
create_slide_layout(
request=CreateSlideLayoutRequest(id=template_info.id, index=0),
sql_session=session,
)
)
assert "__image_url__" in create_response.react_component
assert "__icon_url__" in create_response.react_component
assert "__image_prompt__" in create_response.react_component
assert "__icon_query__" in create_response.react_component
assert "import " not in create_response.react_component
assert "export " not in create_response.react_component
assert re.search(r'layoutId\s*=\s*"title-image-\d{4}"', create_response.react_component)
assert create_calls
assert create_calls[0]["system_prompt"]
assert "#SLIDE HTML REFERENCE" in create_calls[0]["user_text"]
assert create_calls[0]["media_type"] == "image/png"
assert create_calls[0]["image_bytes"] == PNG_BYTES
edit_response = asyncio.run(
edit_slide_layout(
request=EditSlideLayoutRequest(
react_component="const x = 1;",
prompt="Move title up",
)
)
)
assert edit_response.react_component == "const updatedLayout = true;"
section_response = asyncio.run(
edit_slide_layout_section(
request=EditSlideLayoutSectionRequest(
react_component="const x = 1;",
section="header",
prompt="Change spacing",
)
)
)
assert section_response.react_component == "const updatedLayout = true;"
assert len(edit_calls) == 2
assert "#Prompt\nMove title up" in edit_calls[0]["user_text"]
assert "#Section to make changes around\nheader" in edit_calls[1]["user_text"]
def test_save_update_and_clone_template_flow():
session = FakeAsyncSession()
template_info = TemplateCreateInfoModel(
fonts={"Inter": "https://fonts.googleapis.com/css2?family=Inter&display=swap"},
pptx_url="/app_data/uploads/template-previews/seed/presentation.pptx",
slide_htmls=["<div>seed html</div>"],
slide_image_urls=["/app_data/images/seed/slide_1.png"],
)
session.add(template_info)
save_response = asyncio.run(
save_template(
request=SaveTemplateRequest(
template_info_id=template_info.id,
name="My Template",
description="Saved from test",
layouts=[
SaveTemplateLayoutData(
layout_id="title-image-1000",
layout_name="Title Image",
layout_code='const layoutId = "title-image-1000";',
)
],
),
sql_session=session,
)
)
template_id = save_response.id
clone_layout_response = asyncio.run(
clone_slide_layout(
request=CloneSlideLayoutRequest(
template_id=f"custom-{template_id}",
layout_id="title-image-1000",
layout_name="Cloned Layout",
),
sql_session=session,
)
)
assert clone_layout_response.layout_name == "Cloned Layout"
assert clone_layout_response.layout_id != "title-image-1000"
asyncio.run(
save_slide_layout(
request=SaveSlideLayoutRequest(
template_id=template_id,
layout_id="title-image-1000",
layout_code='const layoutId = "title-image-1000"; const edited = true;',
),
sql_session=session,
)
)
assert any("edited = true" in layout.layout_code for layout in session.layouts)
update_response = asyncio.run(
update_template(
request=UpdateTemplateRequest(
id=template_id,
layouts=[
SaveTemplateLayoutData(
layout_id="updated-layout-2000",
layout_name="Updated Layout",
layout_code='const layoutId = "updated-layout-2000";',
)
],
),
sql_session=session,
)
)
assert update_response.id == template_id
layouts = [layout for layout in session.layouts if layout.presentation == template_id]
assert session.templates[template_id] is not None
assert len(layouts) == 1
assert layouts[0].layout_id == "updated-layout-2000"
assert layouts[0].fonts == {
"Inter": "https://fonts.googleapis.com/css2?family=Inter&display=swap"
}

View file

@ -5,12 +5,13 @@ from urllib.parse import urlparse, unquote
from utils.get_env import get_app_data_directory_env
def resolve_image_path_to_filesystem(path_or_url: str) -> Optional[str]:
def resolve_app_path_to_filesystem(path_or_url: str) -> Optional[str]:
"""
Resolve an image path or URL to an actual filesystem path.
Resolve an app-served path or URL to an actual filesystem path.
Handles:
- Path strings: /app_data/images/..., /static/..., absolute paths, relative
- file:// URLs returned by export runtimes
- HTTP URLs whose path component is an absolute filesystem path (Mac/Electron):
When img src is /Users/.../images/xxx.png, browser resolves to
http://origin/Users/.../images/xxx.png. Next.js returns 404 for these.
@ -21,10 +22,12 @@ def resolve_image_path_to_filesystem(path_or_url: str) -> Optional[str]:
return None
# Extract path from HTTP URL if needed
path = path_or_url
if path_or_url.startswith("http"):
if path_or_url.startswith("http") or path_or_url.startswith("file:"):
try:
parsed = urlparse(path_or_url)
path = unquote(parsed.path)
if parsed.scheme == "file" and os.name == "nt" and path.startswith("/"):
path = path[1:]
except Exception:
return None
# Handle /app_data/images/
@ -63,6 +66,10 @@ def resolve_image_path_to_filesystem(path_or_url: str) -> Optional[str]:
return actual if os.path.isfile(actual) else None
def resolve_image_path_to_filesystem(path_or_url: str) -> Optional[str]:
return resolve_app_path_to_filesystem(path_or_url)
def get_images_directory():
images_directory = os.path.join(get_app_data_directory_env(), "images")
os.makedirs(images_directory, exist_ok=True)

View file

@ -136,6 +136,18 @@ def get_codex_account_id_env():
return os.getenv("CODEX_ACCOUNT_ID")
def get_codex_username_env():
return os.getenv("CODEX_USERNAME")
def get_codex_email_env():
return os.getenv("CODEX_EMAIL")
def get_codex_is_pro_env():
return os.getenv("CODEX_IS_PRO")
def get_codex_model_env():
return os.getenv("CODEX_MODEL")

View file

@ -1,7 +1,7 @@
from datetime import datetime
from typing import Optional
from models.llm_message import LLMSystemMessage, LLMUserMessage
from models.presentation_layout import SlideLayoutModel
from templates.presentation_layout import SlideLayoutModel
from models.sql.slide import SlideModel
from services.llm_client import LLMClient
from utils.llm_client_error_handler import handle_llm_client_exceptions
@ -9,6 +9,17 @@ from utils.llm_provider import get_model
from utils.schema_utils import add_field_in_schema, remove_fields_from_schema
def _resolve_prompt_language(language: Optional[str]) -> str:
if language is None:
return "auto-detect"
s = str(language).strip()
if not s:
return "auto-detect"
if s.lower() in {"auto", "auto-detect"}:
return "auto-detect"
return s
def get_system_prompt(
tone: Optional[str] = None,
verbosity: Optional[str] = None,
@ -40,6 +51,7 @@ def get_system_prompt(
def get_user_prompt(prompt: str, slide_data: dict, language: str):
display_language = _resolve_prompt_language(language)
return f"""
## Icon Query And Image Prompt Language
English
@ -48,7 +60,7 @@ def get_user_prompt(prompt: str, slide_data: dict, language: str):
{datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
## Slide Content Language
{language}
{display_language}
## Prompt
{prompt}
@ -61,7 +73,7 @@ def get_user_prompt(prompt: str, slide_data: dict, language: str):
def get_messages(
prompt: str,
slide_data: dict,
language: str,
language: Optional[str],
tone: Optional[str] = None,
verbosity: Optional[str] = None,
instructions: Optional[str] = None,
@ -79,7 +91,7 @@ def get_messages(
async def get_edited_slide_content(
prompt: str,
slide: SlideModel,
language: str,
language: Optional[str],
slide_layout: SlideLayoutModel,
tone: Optional[str] = None,
verbosity: Optional[str] = None,

View file

@ -2,6 +2,7 @@ from datetime import datetime
from typing import Optional
from models.llm_message import LLMSystemMessage, LLMUserMessage
from models.presentation_outline_model import PresentationOutlineModel
from models.llm_tools import SearchWebTool
from services.llm_client import LLMClient
from utils.get_dynamic_models import get_presentation_outline_model_with_n_slides
@ -14,77 +15,144 @@ def get_system_prompt(
verbosity: Optional[str] = None,
instructions: Optional[str] = None,
include_title_slide: bool = True,
include_table_of_contents: bool = False,
):
return f"""
You are an expert presentation creator. Generate structured presentations based on user requirements and format them according to the specified JSON schema with markdown content.
verbosity_instruction = (
"Slide content should be abound 20 words but detailed enough to generate a good slide."
if verbosity == "concise"
else (
"Slide content should be abound 60 words but detailed enough to generate a good slide."
if verbosity == "text-heavy"
else "Slide content should be abound 40 words but detailed enough to generate a good slide."
)
)
Try to use available tools for better results.
title_slide_instruction = (
"Include presenter name in first slide."
if include_title_slide
else "Do not include presenter name in any slides."
)
{"# User Instruction:" if instructions else ""}
{instructions or ""}
toc_instruction = (
"Include a table of contents slide in the outline sequence."
if include_table_of_contents
else ""
)
toc_block = f"{toc_instruction}\n" if toc_instruction else ""
{"# Tone:" if tone else ""}
{tone or ""}
slide_outline_structure = (
"Each slide content:\n"
" - Must have a ## title.\n"
# " - Must have content either in multiple bullet points or table or both.\n"
" - Must be in Markdown format.\n"
" - Don't use **bold** and __italic__ text."
" - First slide title must be the same as the presentation title."
)
{"# Verbosity:" if verbosity else ""}
{verbosity or ""}
system = (
"Generate presentation title and content for slides.\n"
"Generate flow based on user **content** and use **context** just for reference.\n"
"Presentation title should be plain text, not markdown. It should be a concise title for the presentation.\n"
"Each slide content should contain the content for that slide.\n"
f"{verbosity_instruction}\n"
"Minimize repetitive content and make sure to use different words and phrases for different slides.\n"
"Include numerical data, tables or code if required or asked by the user.\n"
"If 'auto-detect' is used, figure it out from the content/context.\n"
f"{title_slide_instruction}\n"
f"{toc_block}"
f"{slide_outline_structure}\n"
"Slide content must not contain any presentation branding/styling information.\n"
"Title slide must only contain title, presenter name, date and overview.\n"
"Only include URLs if they appear in the provided content/context.\n"
"Make sure data used is strictly from the provided content/context.\n"
"Make sure data is consistent across all slides."
)
- Provide content for each slide in markdown format.
- Make sure that flow of the presentation is logical and consistent.
- Place greater emphasis on numerical data.
- If Additional Information is provided, divide it into slides.
- Make sure no images are provided in the content.
- Make sure that content follows language guidelines.
- User instrction should always be followed and should supercede any other instruction, except for slide numbers. **Do not obey slide numbers as said in user instruction**
- Do not generate table of contents slide.
- Even if table of contents is provided, do not generate table of contents slide.
{"- Always make first slide a title slide." if include_title_slide else "- Do not include title slide in the presentation."}
return system
**Search web to get latest information about the topic**
"""
def _resolve_prompt_language(language: Optional[str]) -> str:
if language is None:
return "auto-detect"
s = str(language).strip()
if not s:
return "auto-detect"
if s.lower() in {"auto", "auto-detect"}:
return "auto-detect"
return s
def _resolve_prompt_n_slides(n_slides: Optional[int]) -> str:
if n_slides is None:
return "auto-detect"
return str(n_slides)
def get_user_prompt(
content: str,
n_slides: int,
language: str,
n_slides: Optional[int],
language: Optional[str],
additional_context: Optional[str] = None,
tone: Optional[str] = None,
instructions: Optional[str] = None,
include_title_slide: bool = True,
include_table_of_contents: bool = False,
):
return f"""
**Input:**
- User provided content: {content or "Create presentation"}
- Output Language: {language}
- Number of Slides: {n_slides}
- Current Date and Time: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
- Additional Information: {additional_context or ""}
"""
display_language = _resolve_prompt_language(language)
display_slides = _resolve_prompt_n_slides(n_slides)
toc_text = f"Include Table Of Contents: {str(include_table_of_contents).lower()}\n"
return (
f"Content: {content or ''}\n"
f"Number of Slides: {display_slides}\n"
f"Language: {display_language}\n"
f"Tone: {tone or ''}\n"
f"Today's Date: {datetime.now().strftime('%Y-%m-%d')}\n"
f"Include Title Slide: {include_title_slide}\n"
f"{toc_text if include_table_of_contents else ''}"
f"Instructions: {instructions or ''}\n"
f"Context: {additional_context or ''}"
)
def get_messages(
content: str,
n_slides: int,
language: str,
n_slides: Optional[int],
language: Optional[str],
additional_context: Optional[str] = None,
tone: Optional[str] = None,
verbosity: Optional[str] = None,
instructions: Optional[str] = None,
include_title_slide: bool = True,
include_table_of_contents: bool = False,
):
return [
LLMSystemMessage(
content=get_system_prompt(
tone, verbosity, instructions, include_title_slide
tone,
verbosity,
instructions,
include_title_slide,
include_table_of_contents,
),
),
LLMUserMessage(
content=get_user_prompt(content, n_slides, language, additional_context),
content=get_user_prompt(
content,
n_slides,
language,
additional_context,
tone,
instructions,
include_title_slide,
include_table_of_contents,
),
),
]
async def generate_ppt_outline(
content: str,
n_slides: int,
n_slides: Optional[int],
language: Optional[str] = None,
additional_context: Optional[str] = None,
tone: Optional[str] = None,
@ -92,9 +160,14 @@ async def generate_ppt_outline(
instructions: Optional[str] = None,
include_title_slide: bool = True,
web_search: bool = False,
include_table_of_contents: bool = False,
):
model = get_model()
response_model = get_presentation_outline_model_with_n_slides(n_slides)
response_model = (
get_presentation_outline_model_with_n_slides(n_slides)
if n_slides is not None
else PresentationOutlineModel
)
client = LLMClient()
@ -110,6 +183,7 @@ async def generate_ppt_outline(
verbosity,
instructions,
include_title_slide,
include_table_of_contents,
),
response_model.model_json_schema(),
strict=True,

View file

@ -1,6 +1,7 @@
from typing import Optional
from typing import Optional, Dict
from models.llm_message import LLMSystemMessage, LLMUserMessage
from models.presentation_layout import PresentationLayoutModel
from templates.presentation_layout import PresentationLayoutModel
from models.presentation_outline_model import PresentationOutlineModel
from services.llm_client import LLMClient
from utils.llm_client_error_handler import handle_llm_client_exceptions
@ -9,58 +10,106 @@ from utils.get_dynamic_models import get_presentation_structure_model_with_n_sli
from models.presentation_structure_model import PresentationStructureModel
STRUCTURE_FROM_SLIDES_MARKDOWN_SYSTEM_PROMPT = """
You will be given available slide layouts and content for each slide.
You need to select a layout for each slide based on the mentioned guidelines.
# Steps
1. Analyze all available slide layouts.
2. Analyze content for each slide.
3. Select a layout for each slide one by one by following the selection rules.
# Analyzing Slide Layouts
- Identify what each layout contains based on provided schema markdown.
# Analyzing Content
- Identify how the content is structured.
- Identify if the content contains tables.
# Selection Rules
- If content contains table, then select either table layout or graph layout.
- Don't select layout with image unless content contains image.
- Don't select table layout if content does not contain table.
- You are allowed to select same layout for multiple slides.
# Table Layout Selection Rules
- Must select table layout if the content contains table with text data.
- Must only select a layout with table if the table only contains text data.
# Graph Layout Selection Rules
- Must only select a layout with chart if the content contains table with numeric data.
- Identify how many columns are present in the table.
- Must select a layout that supports n-1 charts for n columns.
- Must prioritize layouts that support multiple charts.
- Don't select metrics layout for content containing table with numeric data.
- For example, if content contains table with 3 columns, then select a layout that supports 2 charts.
{user_instructions}
# Output Rules:
- One layout index for each slide.
- Example: [0, 1, 2, 3, 4]
{presentation_layout}
"""
GET_MESSAGES_SYSTEM_PROMPT = """
You're a professional presentation designer with creative freedom to design engaging presentations.
# DESIGN PHILOSOPHY
- Create visually compelling and varied presentations
- Match layout to content purpose and audience needs
# Layout Selection Guidelines
1. **Content-driven choices**: Let the slide's purpose guide layout selection
- Opening/closing Title layouts
- Processes/workflows Visual process layouts
- Comparisons/contrasts Side-by-side layouts
- Data/metrics Chart/graph layouts
- Concepts/ideas Image + text layouts
- Key insights Emphasis layouts
2. **Visual variety**: Aim for diverse slide layouts across the presentation.
- Don't use same layout for multiple slides unless necessary.
- Mix text-heavy and visual-heavy slides naturally
- Use your judgment on when repetition serves the content
- Balance information density across slides
- Adjacent slide layouts should be different unless instructed/necessary otherwise.
3. **Audience experience**: Consider how slides work together
- Create natural transitions between topics
4. **Table of contents**:
- Must only use table of contents layout if slide content contains table of contents.
{user_instruction_header}
User instruction should be taken into account while creating the presentation structure, except for number of slides.
Select layout index for each of the {n_slides} slides based on what will best serve the presentation's goals.
"""
def get_messages(
presentation_layout: PresentationLayoutModel,
n_slides: int,
data: str,
instructions: Optional[str] = None,
):
system_prompt = GET_MESSAGES_SYSTEM_PROMPT.format(
user_instruction_header="# User Instruction:" if instructions else "",
n_slides=n_slides,
)
return [
LLMSystemMessage(
content=f"""
You're a professional presentation designer with creative freedom to design engaging presentations.
{presentation_layout.to_string()}
# DESIGN PHILOSOPHY
- Create visually compelling and varied presentations
- Match layout to content purpose and audience needs
- Prioritize engagement over rigid formatting rules
# Layout Selection Guidelines
1. **Content-driven choices**: Let the slide's purpose guide layout selection
- Opening/closing Title layouts
- Processes/workflows Visual process layouts
- Comparisons/contrasts Side-by-side layouts
- Data/metrics Chart/graph layouts
- Concepts/ideas Image + text layouts
- Key insights Emphasis layouts
2. **Visual variety**: Aim for diverse, engaging presentation flow
- Mix text-heavy and visual-heavy slides naturally
- Use your judgment on when repetition serves the content
- Balance information density across slides
3. **Audience experience**: Consider how slides work together
- Create natural transitions between topics
- Use layouts that enhance comprehension
- Design for maximum impact and retention
**Trust your design instincts. Focus on creating the most effective presentation for the content and audience.**
{"# User Instruction:" if instructions else ""}
{instructions or ""}
User intruction should be taken into account while creating the presentation structure, except for number of slides.
Select layout index for each of the {n_slides} slides based on what will best serve the presentation's goals.
""",
),
LLMUserMessage(
content=f"""
{data}
""",
),
LLMSystemMessage(content=system_prompt),
LLMUserMessage(content=(
f"{presentation_layout.to_string()}\n\n"
"--------------------------------------\n\n"
f"{data}"
)),
]
@ -70,28 +119,18 @@ def get_messages_for_slides_markdown(
data: str,
instructions: Optional[str] = None,
):
system_prompt = STRUCTURE_FROM_SLIDES_MARKDOWN_SYSTEM_PROMPT.format(
user_instructions=instructions or "",
presentation_layout=presentation_layout.to_string(with_schema=True),
)
return [
LLMSystemMessage(
content=f"""
You're a professional presentation designer with creative freedom to design engaging presentations.
{"# User Instruction:" if instructions else ""}
{instructions or ""}
{presentation_layout.to_string()}
Select layout that best matches the content of the slides.
User intruction should be taken into account while creating the presentation structure, except for number of slides.
Select layout index for each of the {n_slides} slides based on what will best serve the presentation's goals.
""",
content=system_prompt
),
LLMUserMessage(
content=f"""
{data}
""",
),
content=data
)
]

View file

@ -1,7 +1,8 @@
from datetime import datetime
import json
from typing import Optional
from models.llm_message import LLMSystemMessage, LLMUserMessage
from models.presentation_layout import SlideLayoutModel
from templates.presentation_layout import SlideLayoutModel
from models.presentation_outline_model import SlideOutlineModel
from services.llm_client import LLMClient
from utils.llm_client_error_handler import handle_llm_client_exceptions
@ -9,88 +10,136 @@ from utils.llm_provider import get_model
from utils.schema_utils import add_field_in_schema, remove_fields_from_schema
SLIDE_CONTENT_SYSTEM_PROMPT = """
You will be given slide content and response schema.
You need to generate structured content json based on the schema.
# Steps
1. Analyze the content.
2. Analyze the response schema.
3. Generate structured content json based on the schema.
4. Generate speaker note if required.
5. Provide structured content json as output.
# General Rules
- Make sure to follow language guidelines.
- Speaker note should be normal text, not markdown.
- Never ever go over the max character limit.
- Do not add emoji in the content.
- Don't provide $schema field in content json.
{markdown_emphasis_rules}
{user_instructions}
{tone_instructions}
{verbosity_instructions}
{output_fields_instructions}
"""
SLIDE_CONTENT_USER_PROMPT = """
# Current Date and Time:
{current_date_time}
# Icon Query And Image Prompt Language:
English
# Slide Language:
{language}
# SLIDE CONTENT: START
{content}
# SLIDE CONTENT: END
"""
def _resolve_prompt_language(language: Optional[str]) -> str:
if language is None:
return "auto-detect"
s = str(language).strip()
if not s:
return "auto-detect"
if s.lower() in {"auto", "auto-detect"}:
return "auto-detect"
return s
def _get_schema_markdown(response_schema: Optional[dict]) -> str:
if not response_schema:
return "- Follow the provided response schema strictly."
try:
schema_text = json.dumps(response_schema, ensure_ascii=False)
except Exception:
return "- Follow the provided response schema strictly."
return f"- Follow this response schema exactly: {schema_text}"
def get_system_prompt(
tone: Optional[str] = None,
verbosity: Optional[str] = None,
instructions: Optional[str] = None,
response_schema: Optional[dict] = None,
):
return f"""
Generate structured slide based on provided outline, follow mentioned steps and notes and provide structured output.
markdown_emphasis_rules = (
"- Strictly use markdown to emphasize important points, by bolding or "
"italicizing the part of text."
)
{"# User Instructions:" if instructions else ""}
{instructions or ""}
user_instructions = f"# User Instructions:\n{instructions}" if instructions else ""
tone_instructions = (
f"# Tone Instructions:\nMake slide as {tone} as possible." if tone else ""
)
{"# Tone:" if tone else ""}
{tone or ""}
verbosity_instructions = ""
if verbosity:
verbosity_instructions = "# Verbosity Instructions:\n"
if verbosity == "concise":
verbosity_instructions += "Make slide as concise as possible."
elif verbosity == "standard":
verbosity_instructions += "Make slide as standard as possible."
elif verbosity == "text-heavy":
verbosity_instructions += "Make slide as text-heavy as possible."
{"# Verbosity:" if verbosity else ""}
{verbosity or ""}
output_fields_instructions = "# Output Fields:\n" + _get_schema_markdown(
response_schema
)
# Steps
1. Analyze the outline.
2. Generate structured slide based on the outline.
3. Generate speaker note that is simple, clear, concise and to the point.
# Notes
- Slide body should not use words like "This slide", "This presentation".
- Rephrase the slide body to make it flow naturally.
- Only use markdown to highlight important points.
- Make sure to follow language guidelines.
- Speaker note should be normal text, not markdown.
- Strictly follow the max and min character limit for every property in the slide.
- Never ever go over the max character limit. Limit your narration to make sure you never go over the max character limit.
- Number of items should not be more than max number of items specified in slide schema. If you have to put multiple points then merge them to obey max numebr of items.
- Generate content as per the given tone.
- Be very careful with number of words to generate for given field. As generating more than max characters will overflow in the design. So, analyze early and never generate more characters than allowed.
- Do not add emoji in the content.
- Metrics should be in abbreviated form with least possible characters. Do not add long sequence of words for metrics.
- For verbosity:
- If verbosity is 'concise', then generate description as 1/3 or lower of the max character limit. Don't worry if you miss content or context.
- If verbosity is 'standard', then generate description as 2/3 of the max character limit.
- If verbosity is 'text-heavy', then generate description as 3/4 or higher of the max character limit. Make sure it does not exceed the max character limit.
User instructions, tone and verbosity should always be followed and should supercede any other instruction, except for max and min character limit, slide schema and number of items.
- Provide output in json format and **don't include <parameters> tags**.
# Image and Icon Output Format
image: {{
__image_prompt__: string,
}}
icon: {{
__icon_query__: string,
}}
"""
return SLIDE_CONTENT_SYSTEM_PROMPT.format(
markdown_emphasis_rules=markdown_emphasis_rules,
user_instructions=user_instructions,
tone_instructions=tone_instructions,
verbosity_instructions=verbosity_instructions,
output_fields_instructions=output_fields_instructions,
)
def get_user_prompt(outline: str, language: str):
return f"""
## Current Date and Time
{datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
## Icon Query And Image Prompt Language
English
## Slide Content Language
{language}
## Slide Outline
{outline}
"""
def get_user_prompt(outline: str, language: Optional[str]):
return SLIDE_CONTENT_USER_PROMPT.format(
current_date_time=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
language=_resolve_prompt_language(language),
content=outline,
)
def get_messages(
outline: str,
language: str,
language: Optional[str],
tone: Optional[str] = None,
verbosity: Optional[str] = None,
instructions: Optional[str] = None,
response_schema: Optional[dict] = None,
):
return [
LLMSystemMessage(
content=get_system_prompt(tone, verbosity, instructions),
content=get_system_prompt(
tone,
verbosity,
instructions,
response_schema,
),
),
LLMUserMessage(
content=get_user_prompt(outline, language),
@ -101,7 +150,7 @@ def get_messages(
async def get_slide_content_from_type_and_outline(
slide_layout: SlideLayoutModel,
outline: SlideOutlineModel,
language: str,
language: Optional[str],
tone: Optional[str] = None,
verbosity: Optional[str] = None,
instructions: Optional[str] = None,
@ -134,6 +183,7 @@ async def get_slide_content_from_type_and_outline(
tone,
verbosity,
instructions,
response_schema,
),
response_format=response_schema,
strict=False,

View file

@ -1,5 +1,5 @@
from models.llm_message import LLMSystemMessage, LLMUserMessage
from models.presentation_layout import PresentationLayoutModel, SlideLayoutModel
from templates.presentation_layout import PresentationLayoutModel, SlideLayoutModel
from models.slide_layout_index import SlideLayoutIndex
from models.sql.slide import SlideModel
from services.llm_client import LLMClient

View file

@ -244,6 +244,7 @@ class TokenSuccess:
access: str
refresh: str
expires: int # Unix ms timestamp when the token expires
id_token: Optional[str] = None
@dataclass
@ -261,6 +262,14 @@ class AuthorizationFlow:
url: str
@dataclass
class CodexAccountProfile:
account_id: Optional[str] = None
username: Optional[str] = None
email: Optional[str] = None
is_pro: Optional[bool] = None
# ---------------------------------------------------------------------------
# JWT helpers
# ---------------------------------------------------------------------------
@ -296,6 +305,51 @@ def get_account_id(access_token: str) -> Optional[str]:
return None
def _as_non_empty_str(value) -> Optional[str]:
if isinstance(value, str):
stripped = value.strip()
return stripped or None
return None
def get_account_profile(access_token: str, id_token: Optional[str] = None) -> CodexAccountProfile:
"""Extract profile from exact observed JWT paths in access/id tokens."""
access_payload = _decode_jwt_payload(access_token) or {}
access_auth = access_payload.get(JWT_CLAIM_PATH)
access_auth = access_auth if isinstance(access_auth, dict) else {}
access_profile = access_payload.get("https://api.openai.com/profile")
access_profile = access_profile if isinstance(access_profile, dict) else {}
id_payload = _decode_jwt_payload(id_token) if id_token else None
id_payload = id_payload if isinstance(id_payload, dict) else {}
id_auth = id_payload.get(JWT_CLAIM_PATH)
id_auth = id_auth if isinstance(id_auth, dict) else {}
account_id = _as_non_empty_str(access_auth.get("chatgpt_account_id")) or _as_non_empty_str(
id_auth.get("chatgpt_account_id")
)
username = _as_non_empty_str(id_payload.get("name"))
email = _as_non_empty_str(access_profile.get("email")) or _as_non_empty_str(
id_payload.get("email")
)
plan_type = _as_non_empty_str(access_auth.get("chatgpt_plan_type")) or _as_non_empty_str(
id_auth.get("chatgpt_plan_type")
)
if plan_type:
is_pro = plan_type.strip().lower() != "free"
else:
is_pro = None
return CodexAccountProfile(
account_id=account_id,
username=username,
email=email,
is_pro=is_pro,
)
# ---------------------------------------------------------------------------
# Authorization URL + PKCE
# ---------------------------------------------------------------------------
@ -474,6 +528,7 @@ def exchange_authorization_code(
return TokenFailure(reason=f"HTTP {response.status_code}: {response.text[:200]}")
body = response.json()
access = body.get("access_token")
refresh = body.get("refresh_token")
expires_in = body.get("expires_in")
@ -482,7 +537,9 @@ def exchange_authorization_code(
return TokenFailure(reason=f"Token response missing fields: {list(body.keys())}")
expires_ms = int(time.time() * 1000) + int(expires_in) * 1000
return TokenSuccess(access=access, refresh=refresh, expires=expires_ms)
id_token = body.get("id_token")
id_token = id_token if isinstance(id_token, str) else None
return TokenSuccess(access=access, refresh=refresh, expires=expires_ms, id_token=id_token)
except Exception as exc:
return TokenFailure(reason=str(exc))

View file

@ -0,0 +1,205 @@
import math
import re
from typing import Iterable, List, Optional
from models.presentation_outline_model import (
PresentationOutlineModel,
SlideOutlineModel,
)
HEADING_PATTERN = re.compile(r"^\s{0,3}#+\s*(.+)$", re.MULTILINE)
FIRST_SENTENCE_PATTERN = re.compile(r"^\s*([^.?!]+?[.?!])", re.DOTALL)
IMAGE_URL_PATTERN = re.compile(
r"https?://[-\w./%~:!$&'()*+,;=]+?\.(?:jpe?g|png|webp)(?:\?[^\s\"\'\\]*)?",
re.IGNORECASE | re.UNICODE,
)
def get_presentation_title_from_presentation_outline(
presentation_outline: PresentationOutlineModel,
) -> str:
if not presentation_outline.slides:
return "Untitled Presentation"
first_content = presentation_outline.slides[0].content or ""
if re.match(r"^\s*#{1,6}\s*Page\s+\d+\b", first_content):
first_content = re.sub(
r"^\s*#{1,6}\s*Page\s+\d+\b[\s,:\-]*",
"",
first_content,
count=1,
)
return (
first_content[:100]
.replace("#", "")
.replace("/", "")
.replace("\\", "")
.replace("\n", " ")
)
def _get_toc_count_for_total_slides(total_slides: int, title_slide: bool) -> int:
if total_slides <= 0:
return 0
first_pass = math.ceil(((total_slides - 1) if title_slide else total_slides) / 10)
return math.ceil((total_slides - first_pass) / 10)
def get_no_of_toc_required_for_n_outlines(
*,
n_outlines: int,
title_slide: bool,
target_total_slides: Optional[int] = None,
) -> int:
if target_total_slides is not None:
adjusted_total = max(target_total_slides, n_outlines)
return _get_toc_count_for_total_slides(adjusted_total, title_slide)
if n_outlines <= 0:
return 0
return math.ceil(((n_outlines - 1) if title_slide else n_outlines) / 10)
def get_no_of_outlines_to_generate_for_n_slides(
*,
n_slides: int,
toc: bool,
title_slide: bool,
) -> int:
if toc:
n_toc_1 = math.ceil(((n_slides - 1) if title_slide else n_slides) / 10)
n_toc_2 = math.ceil((n_slides - n_toc_1) / 10)
return n_slides - n_toc_2
else:
return n_slides
def get_presentation_outline_model_with_toc(
*,
outline: PresentationOutlineModel,
n_toc_slides: int,
title_slide: bool,
) -> PresentationOutlineModel:
if n_toc_slides <= 0:
return outline
outline_with_toc = outline.model_copy(deep=True)
insertion_index = 1 if title_slide else 0
existing_outlines = outline_with_toc.slides
outlines_for_toc = existing_outlines[insertion_index:]
if not outlines_for_toc:
return outline_with_toc
sections = _split_outlines_evenly(outlines_for_toc, n_toc_slides)
if not sections:
return outline_with_toc
toc_slides: List[SlideOutlineModel] = []
outlines_before_toc = 1 if title_slide else 0
total_toc_slides = len(sections)
global_outline_index = 0
for section_index, section in enumerate(sections):
section_lines = [
"## Table of Contents",
"",
]
for outline in section:
outline_title = _extract_outline_title(outline.content)
page_number = (
outlines_before_toc + total_toc_slides + global_outline_index + 1
)
section_lines.append(
f"- Page number: {page_number}, Title: {outline_title}"
)
global_outline_index += 1
toc_slides.append(
SlideOutlineModel(
content="\n".join(
line for line in section_lines if line is not None
).strip()
)
)
for offset, toc_slide in enumerate(toc_slides):
existing_outlines.insert(insertion_index + offset, toc_slide)
return outline_with_toc
def _split_outlines_evenly(
outlines: Iterable[SlideOutlineModel], n_sections: int
) -> List[List[SlideOutlineModel]]:
"""Split outlines into n contiguous sections with near-equal sizes."""
outlines_list = list(outlines)
if n_sections <= 0 or not outlines_list:
return []
total = len(outlines_list)
n_sections = max(1, n_sections)
base_size = total // n_sections
remainder = total % n_sections
sections: List[List[SlideOutlineModel]] = []
start = 0
for section_index in range(n_sections):
current_size = base_size + (1 if section_index < remainder else 0)
end = start + current_size
sections.append(outlines_list[start:end])
start = end
return sections
def _extract_outline_title(content: str) -> str:
"""Get a human-friendly title from an outline's markdown content."""
text = content or ""
heading_match = HEADING_PATTERN.search(text)
if heading_match:
return heading_match.group(1).strip()
sentence_match = FIRST_SENTENCE_PATTERN.search(text.strip())
if sentence_match:
return sentence_match.group(1).strip()
for line in text.splitlines():
stripped_line = line.strip()
if stripped_line:
return stripped_line
return "Slide"
def get_images_for_slides_from_outline(
slides: List[SlideOutlineModel],
) -> List[List[str]]:
"""
Extract image URLs (png, jpg, jpeg, webp) from each slide's content in the outline.
Args:
outline: PresentationOutlineModel containing slides with content
Returns:
List of lists of image URLs, one list per slide
"""
result: List[List[str]] = []
for slide in slides:
content = slide.content or ""
image_urls = IMAGE_URL_PATTERN.findall(content)
# Remove duplicates while preserving order
unique_urls = list(dict.fromkeys(image_urls))
result.append(unique_urls)
return result

View file

@ -1,4 +1,4 @@
from models.presentation_layout import PresentationLayoutModel
from templates.presentation_layout import PresentationLayoutModel
from models.presentation_outline_model import PresentationOutlineModel
import re
from typing import List

View file

@ -1,6 +1,6 @@
import asyncio
import os
from typing import List, Tuple
from typing import List, Optional, Tuple
from models.image_prompt import ImagePrompt
from models.sql.image_asset import ImageAsset
from models.sql.slide import SlideModel
@ -14,15 +14,27 @@ from utils.path_helpers import get_resource_path
async def process_slide_and_fetch_assets(
image_generation_service: ImageGenerationService,
slide: SlideModel,
outline_image_urls: Optional[List[str]] = None,
) -> List[ImageAsset]:
async_tasks = []
async_task_meta = []
image_paths = get_dict_paths_with_key(slide.content, "__image_prompt__")
icon_paths = get_dict_paths_with_key(slide.content, "__icon_query__")
for image_path in image_paths:
for image_index, image_path in enumerate(image_paths):
__image_prompt__parent = get_dict_at_path(slide.content, image_path)
if (
outline_image_urls
and image_index < len(outline_image_urls)
and outline_image_urls[image_index]
):
__image_prompt__parent["__image_url__"] = outline_image_urls[image_index]
set_dict_at_path(slide.content, image_path, __image_prompt__parent)
continue
async_tasks.append(
image_generation_service.generate_image(
ImagePrompt(
@ -30,37 +42,37 @@ async def process_slide_and_fetch_assets(
)
)
)
async_task_meta.append(("image", image_path))
for icon_path in icon_paths:
__icon_query__parent = get_dict_at_path(slide.content, icon_path)
async_tasks.append(
ICON_FINDER_SERVICE.search_icons(__icon_query__parent["__icon_query__"])
)
async_task_meta.append(("icon", icon_path))
results = await asyncio.gather(*async_tasks)
results.reverse()
results = await asyncio.gather(*async_tasks) if async_tasks else []
return_assets = []
for image_path in image_paths:
image_dict = get_dict_at_path(slide.content, image_path)
result = results.pop()
if isinstance(result, ImageAsset):
return_assets.append(result)
image_dict["__image_url__"] = result.file_url
else:
image_dict["__image_url__"] = result
set_dict_at_path(slide.content, image_path, image_dict)
for (task_type, asset_path), result in zip(async_task_meta, results):
if task_type == "image":
image_dict = get_dict_at_path(slide.content, asset_path)
if isinstance(result, ImageAsset):
return_assets.append(result)
image_dict["__image_url__"] = result.file_url
else:
image_dict["__image_url__"] = result
set_dict_at_path(slide.content, asset_path, image_dict)
continue
for icon_path in icon_paths:
icon_dict = get_dict_at_path(slide.content, icon_path)
icon_result = results.pop()
icon_dict = get_dict_at_path(slide.content, asset_path)
# ICON_FINDER_SERVICE.search_icons returns a list of URLs
if isinstance(icon_result, list) and icon_result:
icon_dict["__icon_url__"] = icon_result[0]
if isinstance(result, list) and result:
icon_dict["__icon_url__"] = result[0]
else:
# Fallback to FastAPI static placeholder if no icon found
icon_dict["__icon_url__"] = "/static/icons/placeholder.svg"
set_dict_at_path(slide.content, icon_path, icon_dict)
set_dict_at_path(slide.content, asset_path, icon_dict)
return return_assets

View file

@ -122,5 +122,17 @@ def set_codex_account_id_env(value: str):
os.environ["CODEX_ACCOUNT_ID"] = value
def set_codex_username_env(value: str):
os.environ["CODEX_USERNAME"] = value
def set_codex_email_env(value: str):
os.environ["CODEX_EMAIL"] = value
def set_codex_is_pro_env(value: str):
os.environ["CODEX_IS_PRO"] = value
def set_codex_model_env(value: str):
os.environ["CODEX_MODEL"] = value

View file

@ -32,6 +32,9 @@ from utils.get_env import (
get_codex_refresh_token_env,
get_codex_token_expires_env,
get_codex_account_id_env,
get_codex_username_env,
get_codex_email_env,
get_codex_is_pro_env,
get_codex_model_env,
)
from utils.parsers import parse_bool_or_none
@ -64,6 +67,9 @@ from utils.set_env import (
set_codex_refresh_token_env,
set_codex_token_expires_env,
set_codex_account_id_env,
set_codex_username_env,
set_codex_email_env,
set_codex_is_pro_env,
set_codex_model_env,
)
@ -133,6 +139,13 @@ def get_user_config():
CODEX_REFRESH_TOKEN=existing_config.CODEX_REFRESH_TOKEN or get_codex_refresh_token_env(),
CODEX_TOKEN_EXPIRES=existing_config.CODEX_TOKEN_EXPIRES or get_codex_token_expires_env(),
CODEX_ACCOUNT_ID=existing_config.CODEX_ACCOUNT_ID or get_codex_account_id_env(),
CODEX_USERNAME=existing_config.CODEX_USERNAME or get_codex_username_env(),
CODEX_EMAIL=existing_config.CODEX_EMAIL or get_codex_email_env(),
CODEX_IS_PRO=(
existing_config.CODEX_IS_PRO
if existing_config.CODEX_IS_PRO is not None
else parse_bool_or_none(get_codex_is_pro_env())
),
)
@ -196,6 +209,12 @@ def update_env_with_user_config():
set_codex_token_expires_env(user_config.CODEX_TOKEN_EXPIRES)
if user_config.CODEX_ACCOUNT_ID:
set_codex_account_id_env(user_config.CODEX_ACCOUNT_ID)
if user_config.CODEX_USERNAME:
set_codex_username_env(user_config.CODEX_USERNAME)
if user_config.CODEX_EMAIL:
set_codex_email_env(user_config.CODEX_EMAIL)
if user_config.CODEX_IS_PRO is not None:
set_codex_is_pro_env(str(user_config.CODEX_IS_PRO))
def save_codex_tokens_to_user_config() -> None:
@ -220,6 +239,9 @@ def save_codex_tokens_to_user_config() -> None:
existing["CODEX_REFRESH_TOKEN"] = get_codex_refresh_token_env()
existing["CODEX_TOKEN_EXPIRES"] = get_codex_token_expires_env()
existing["CODEX_ACCOUNT_ID"] = get_codex_account_id_env()
existing["CODEX_USERNAME"] = get_codex_username_env()
existing["CODEX_EMAIL"] = get_codex_email_env()
existing["CODEX_IS_PRO"] = parse_bool_or_none(get_codex_is_pro_env())
try:
with open(user_config_path, "w") as f:

View file

@ -0,0 +1,133 @@
import * as z from "zod";
export const slideLayoutId = "product-overview-market-opportunity-slide";
export const slideLayoutName = "Product Overview Market Opportunity Slide";
export const slideLayoutDescription =
"A market opportunity slide with title and intro text on the left, four bullet lines extending toward the right, and concentric value circles as the visual focal point.";
const BulletSchema = z.object({
text: z.string().min(12).max(46).meta({
description: "Bullet text shown on the left side of a line.",
}),
});
export const Schema = z.object({
title: z.string().min(8).max(22).default("Market Opportunity").meta({
description: "Main heading shown at the top-left.",
}),
subtitle: z.string().min(40).max(110).default(
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt."
).meta({
description: "Supporting text under the main heading.",
}),
bullets: z
.array(BulletSchema)
.min(4)
.max(4)
.default([
{ text: "Ut enim ad minim veniam, quis" },
{ text: "Ut enim ad minim veniam, quis" },
{ text: "Ut enim ad minim veniam, quis" },
{ text: "Ut enim ad minim veniam, quis" },
])
.meta({
description: "Four bullet-line entries shown on the left.",
}),
values: z
.array(z.string().min(2).max(6))
.min(4)
.max(4)
.default(["$33", "$20", "$120", "$200"])
.meta({
description: "Four values shown from outer to inner circles.",
}),
});
export type SchemaType = z.infer<typeof Schema>;
const COLORS = [
"var(--graph-0,#5f7f79)",
"var(--graph-1,#1f5a4f)",
"var(--graph-2,#0d4f43)",
"var(--graph-3,#06463d)",
];
const MarketOpportunitySlide = ({ data }: { data: Partial<SchemaType> }) => {
const { title, subtitle, bullets, values } = data;
return (
<div
className="relative h-[720px] w-[1280px] overflow-hidden rounded-[24px]"
style={{
backgroundColor: "var(--background-color,#DAE1DE)",
fontFamily: "var(--body-font-family,'Bricolage Grotesque')",
}}
>
<div className="px-[56px] pt-[72px]">
<h2
className="text-[80px] font-semibold leading-[108.4%] tracking-[-2.419px] text-[#15342D]"
style={{ color: "var(--primary-color,#15342D)" }}
>
{title}
</h2>
<p
className="mt-[20px] w-[730px] text-[24px] font-normal text-[#15342DCC]"
style={{ color: "var(--background-text,#15342DCC)" }}
>
{subtitle}
</p>
</div>
<div className="absolute left-[56px] top-[368px] space-y-[42px]">
{bullets?.map((bullet, index) => (
<div key={index} className="relative flex items-center">
<span
className="mr-[14px] h-[14px] w-[14px] rounded-full bg-[#0a4a3f]"
style={{ backgroundColor: "var(--graph-0,#0a4a3f)" }}
/>
<p
className="w-[640px] text-[24px] font-normal text-[#15342DCC]"
style={{ color: "var(--background-text,#15342DCC)" }}
>
{bullet.text}
</p>
<span
className="ml-[8px] h-[2px] w-[80px] bg-[#8ea8a5]"
style={{ backgroundColor: "var(--stroke,#8ea8a5)" }}
/>
<span
className="h-[6px] w-[6px] rounded-full bg-[#edf2f1]"
style={{ backgroundColor: "var(--primary-text,#edf2f1)" }}
/>
</div>
))}
</div>
<div className="absolute bottom-[58px] right-[48px] h-[474px] w-[474px]">
{values?.map((value, index) => (
<div
key={index}
className="absolute rounded-full"
style={{
width: 237 + (index * 50),
height: 237 + (index * 50),
bottom: 0,
right: 0,
backgroundColor: COLORS[index],
}}
>
<p
className="pt-[24px] text-center text-[24px] font-normal text-white"
style={{ color: "var(--primary-text,#ffffff)" }}
>
{value}
</p>
</div>
))}
</div>
</div>
);
};
export default MarketOpportunitySlide;

View file

@ -175,7 +175,7 @@ const ImageProvider = ({ llmConfig, setLlmConfig }: { llmConfig: LLMConfig, setL
<PopoverContent
className="p-0"
align="start"
style={{ width: "var(--radix-popover-trigger-width)" }}
style={{ width: "300px" }}
>
<Command>
<CommandInput placeholder="Search provider..." />

View file

@ -137,12 +137,10 @@ const PrivacySettings = () => {
<div className="w-full space-y-6">
<div className="bg-[#F9F8F8] p-7 rounded-[20px]">
<h4 className="text-sm font-semibold text-[#191919] mb-1">
Anonymous Usage Tracking
Usage analytics
</h4>
<p className="text-xs text-[#6B7280] mb-6 leading-relaxed max-w-lg">
When enabled, Presenton collects anonymous usage data to help us
understand how the app is used and improve your experience.<span className="font-bold"> No
personal information or presentation content is ever collected.</span>
Share anonymous usage data to help us improve Presenton. No personal information or presentation content is collected.
</p>
<div className="flex items-center justify-between gap-4 rounded-[10px] bg-white border border-[#EDEEEF] p-4">
@ -151,12 +149,12 @@ const PrivacySettings = () => {
htmlFor="tracking-toggle"
className="text-sm font-medium text-[#191919] cursor-pointer select-none block"
>
{trackingEnabled ? "Tracking Enabled" : "Tracking Disabled"}
Share anonymous usage data
</label>
<p className="text-xs text-[#9CA3AF] mt-0.5">
{trackingEnabled
? "Anonymous usage data is being sent."
: "No usage data is being collected."}
? "Anonymous usage data is being shared."
: "Anonymous usage data is not being shared"}
</p>
</div>
<div className="flex items-center gap-2">

View file

@ -21,6 +21,9 @@ import TextProvider from "./TextProvider";
import ImageProvider from "./ImageProvider";
import PrivacySettings from "./PrivacySettings";
import { IMAGE_PROVIDERS, LLM_PROVIDERS } from "@/utils/providerConstants";
import { ImagesApi } from "@/app/(presentation-generator)/services/api/images";
const STOCK_IMAGE_PROVIDERS = new Set(["pexels", "pixabay"]);
// Button state interface
interface ButtonState {
@ -72,6 +75,36 @@ const SettingsPage = () => {
return 0;
}, [downloadingModel?.downloaded, downloadingModel?.size]);
const ensureSelectedStockProviderReady = async (): Promise<boolean> => {
if (llmConfig.DISABLE_IMAGE_GENERATION) {
return true;
}
const provider = (llmConfig.IMAGE_PROVIDER || "").toLowerCase();
if (!STOCK_IMAGE_PROVIDERS.has(provider)) {
return true;
}
const providerApiKey =
provider === "pexels" ? llmConfig.PEXELS_API_KEY : llmConfig.PIXABAY_API_KEY;
try {
await ImagesApi.searchStockImages("business", 1, {
provider,
apiKey: providerApiKey,
strictApiKey: true,
});
return true;
} catch (error: any) {
notify.error(
"Cannot save settings",
error?.message ||
`Unable to reach ${provider} with the provided API key. Please verify your settings and try again.`
);
return false;
}
};
const handleSaveConfig = async () => {
trackEvent(MixpanelEvent.Settings_SaveConfiguration_Button_Clicked, { pathname });
const validationError = getLLMConfigValidationError(llmConfig);
@ -79,6 +112,12 @@ const SettingsPage = () => {
notify.error("Cannot save settings", validationError);
return;
}
const providerReady = await ensureSelectedStockProviderReady();
if (!providerReady) {
return;
}
try {
setButtonState(prev => ({
...prev,

View file

@ -9,30 +9,31 @@ const SettingSideBar = ({ mode, setMode, selectedProvider, setSelectedProvider }
const textProviderIcon = LLM_PROVIDERS[llm_config.LLM as keyof typeof LLM_PROVIDERS]?.icon
const imageProviderIcon = IMAGE_PROVIDERS[llm_config.IMAGE_PROVIDER as keyof typeof IMAGE_PROVIDERS]?.icon || '/providers/pexel.png'
return (
<div className='w-full max-w-[230px] h-screen px-4 pt-[22px] bg-[#F9FAFB] flex flex-col'>
<div className='w-full max-w-[230px] h-screen px-3 pt-[22px] bg-[#F9FAFB] flex flex-col'>
<p className='text-xs text-black font-medium border-b mt-[3.15rem] border-[#E1E1E5] pb-3.5'>FILTER BY:</p>
<div className='mt-6 flex-1'>
<p className='text-[#3A3A3A] text-xs font-medium pb-2.5'>Select Mode</p>
<div className='p-1 rounded-[40px] bg-[#ffffff] w-fit border border-[#EDEEEF] flex items-center justify-center mb-[34px] '>
<button className='px-3 py-2 text-xs font-medium text-[#3A3A3A] rounded-[70px]'
<div className='p-0.5 rounded-[40px] bg-[#ffffff] w-fit border border-[#EDEEEF] flex items-center justify-center mb-[34px] '>
<button className='px-3 font-syne h-[26px] text-[10px] font-medium text-[#3A3A3A] rounded-[70px]'
onClick={() => setMode('presenton')}
style={{
background: mode === 'presenton' ? '#F4F3FF' : 'transparent',
color: mode === 'presenton' ? '#5146E5' : '#3A3A3A'
}}
>Presenton</button>
>Template Based
</button>
<svg xmlns="http://www.w3.org/2000/svg" className='mx-1' width="2" height="17" viewBox="0 0 2 17" fill="none">
<path d="M1 0V16.5" stroke="#EDECEC" strokeWidth="2" />
</svg>
<div className='relative'>
<button className='px-3 py-2 text-xs font-medium rounded-[70px] cursor-not-allowed opacity-60'
<button className='px-3 font-syne h-[26px] text-[10px] font-medium rounded-[70px] cursor-not-allowed opacity-60'
disabled
style={{
background: 'transparent',
color: '#9CA3AF'
}}
>
Nanobanana
Image Based
</button>
<span className='absolute -top-2 -right-5 text-[7px] uppercase tracking-wide bg-[#F4F3FF] text-[#5146E5] border border-[#D9D6FE] rounded-full px-1.5 py-0.5 whitespace-nowrap'>
Coming soon
@ -43,15 +44,15 @@ const SettingSideBar = ({ mode, setMode, selectedProvider, setSelectedProvider }
</div>
<p className='text-[#3A3A3A] text-xs font-medium pb-2.5'>Select Provider</p>
{mode === 'presenton' && <div className='space-y-2.5'>
<button className={` w-full rounded-[6px] p-3 py-4 flex items-center gap-1.5 border ${selectedProvider === 'text-provider' ? 'bg-[#F4F3FF] border-[#D9D6FE]' : 'bg-white border-[#EDEEEF]'}`} onClick={() => setSelectedProvider('text-provider')}>
<div className='relative w-6 h-6 rounded-full overflow-hidden border border-[#EDEEEF]'>
<button className={` w-full rounded-[6px] px-3 py-4 flex items-center gap-1.5 border ${selectedProvider === 'text-provider' ? 'bg-[#F4F3FF] border-[#D9D6FE]' : 'bg-white border-[#EDEEEF]'}`} onClick={() => setSelectedProvider('text-provider')}>
<div className='relative w-[18px] h-[18px] rounded-full overflow-hidden border border-[#EDEEEF]'>
<img src={textProviderIcon} className=' object-cover w-full h-full overflow-hidden' alt='google' />
</div>
<p className='text-[#191919] text-xs font-medium' >Text Provider</p>
</button>
<button className={` w-full rounded-[6px] p-3 py-4 flex items-center gap-1.5 border ${selectedProvider === 'image-provider' ? 'bg-[#F4F3FF] border-[#D9D6FE]' : 'bg-white border-[#EDEEEF]'}`} onClick={() => setSelectedProvider('image-provider')}>
<div className='relative w-6 h-6 rounded-full overflow-hidden border border-[#EDEEEF]'>
<button className={` w-full rounded-[6px] px-3 py-4 flex items-center gap-1.5 border ${selectedProvider === 'image-provider' ? 'bg-[#F4F3FF] border-[#D9D6FE]' : 'bg-white border-[#EDEEEF]'}`} onClick={() => setSelectedProvider('image-provider')}>
<div className='relative w-[18px] h-[18px] rounded-full overflow-hidden border border-[#EDEEEF]'>
<img src={imageProviderIcon} className=' object-cover w-full h-full overflow-hidden' alt='google' />
</div>
<p className='text-[#191919] text-xs font-medium' >Image Provider</p>
@ -59,8 +60,8 @@ const SettingSideBar = ({ mode, setMode, selectedProvider, setSelectedProvider }
</div>}
{
mode === 'nanobanana' && <div>
<button className={` w-full rounded-[6px] p-3 py-4 flex items-center gap-1.5 border bg-[#F4F3FF] border-[#D9D6FE]`}>
<div className='relative w-6 h-6 rounded-full overflow-hidden border border-[#EDEEEF]'>
<button className={` w-full rounded-[6px] px-3 py-4 flex items-center gap-1.5 border bg-[#F4F3FF] border-[#D9D6FE]`}>
<div className='relative w-[18px] h-[18px] rounded-full overflow-hidden border border-[#EDEEEF]'>
<img src='/providers/openai.png' className=' object-cover w-full h-full overflow-hidden' alt='google' />
</div>

View file

@ -216,8 +216,8 @@ const TextProvider = ({
return (
<div className="space-y-6 bg-[#F9F8F8] p-7 rounded-[12px] ">
{/* API Key Input */}
<div className="mb-4 flex items-center justify-between rounded-[12px] bg-white pt-5 pb-10 px-10">
<div className=" max-w-[290px] pb-[50px]">
<div className="mb-4 flex items-end justify-between rounded-[12px] bg-white pt-5 pb-10 px-10">
<div className=" max-w-[290px] ">
<div className='w-[60px] h-[60px] rounded-[4px] flex items-center justify-center'
style={{ backgroundColor: '#4C55541A' }}
>
@ -229,14 +229,13 @@ const TextProvider = ({
</div>
<h3 className="text-xl font-normal text-[#191919] py-2.5">Text Generation Settings</h3>
<p className=" text-sm text-gray-500">
Choosing where text contets come from
Choosing where text content comes from
</p>
</div>
<div>
<div className='flex flex-col justify-end items-end gap-4'>
<div className={`flex gap-4 justify-end ${selectedProvider === 'codex' ? 'items-end' : 'items-start'}`}>
<div className={`relative ${selectedProvider === 'codex' ? 'w-[240px]' : 'w-[222px]'}`}>
<div className="flex flex-col justify-start ">
<label className="block text-sm font-medium text-gray-700 mb-2">
Select Text Provider
</label>
@ -265,7 +264,7 @@ const TextProvider = ({
<PopoverContent
className="p-0"
align="start"
style={{ width: "var(--radix-popover-trigger-width)" }}
style={{ width: "300px" }}
>
<Command>
<CommandInput placeholder="Search provider..." />
@ -311,8 +310,6 @@ const TextProvider = ({
</PopoverContent>
</Popover>
</div>
</div>
<div className={`relative flex flex-col justify-end ${selectedProvider === 'codex' ? 'items-end w-[262px] max-w-full' : 'items-end w-[222px]'}`}>
<div className="flex flex-col justify-start w-full ">
@ -431,89 +428,86 @@ const TextProvider = ({
"Check models"
)}
</button>
)}
</div>
{/* Model Selection - only show if models are available */}
{selectedProvider !== 'codex' && modelsChecked && availableModels.length > 0 ? (
<div className="w-[222px]">
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">
{selectedProvider === 'ollama' ? 'Choose a supported model' : `Select ${modelLabel} Model`}
</label>
<div className="w-full">
<Popover
open={openModelSelect}
onOpenChange={setOpenModelSelect}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={openModelSelect}
className="w-full h-12 px-4 py-4 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors hover:border-gray-400 justify-between"
>
<span className="text-sm truncate font-medium text-gray-900">
{currentModel
? availableModels.find(model => model === currentModel) || currentModel
: "Select a model"}
</span>
<ChevronUp className="w-4 h-4 text-gray-500" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
align="start"
style={{ width: "var(--radix-popover-trigger-width)" }}
</div>
{/* Model Selection - only show if models are available */}
{selectedProvider !== 'codex' && modelsChecked && availableModels.length > 0 ? (
<div className="w-[222px]">
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">
{selectedProvider === 'ollama' ? 'Choose a supported model' : `Select ${modelLabel} Model`}
</label>
<div className="w-full">
<Popover
open={openModelSelect}
onOpenChange={setOpenModelSelect}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={openModelSelect}
className="w-full h-12 px-4 py-4 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors hover:border-gray-400 justify-between"
>
<Command>
<CommandInput placeholder="Search models..." />
<CommandList>
<CommandEmpty>No model found.</CommandEmpty>
<CommandGroup>
{availableModels.map((model, index) => (
<CommandItem
key={index}
value={model}
onSelect={(value) => {
if (currentModelField) {
onInputChange(value, currentModelField);
}
setOpenModelSelect(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
currentModel === model
? "opacity-100"
: "opacity-0"
)}
/>
<div className="flex gap-3 items-center">
<div className="flex flex-col space-y-1 flex-1">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium text-gray-900">
{model}
</span>
</div>
<span className="text-sm truncate font-medium text-gray-900">
{currentModel
? availableModels.find(model => model === currentModel) || currentModel
: "Select a model"}
</span>
<ChevronUp className="w-4 h-4 text-gray-500" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
align="start"
style={{ width: "var(--radix-popover-trigger-width)" }}
>
<Command>
<CommandInput placeholder="Search models..." />
<CommandList>
<CommandEmpty>No model found.</CommandEmpty>
<CommandGroup>
{availableModels.map((model, index) => (
<CommandItem
key={index}
value={model}
onSelect={(value) => {
if (currentModelField) {
onInputChange(value, currentModelField);
}
setOpenModelSelect(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
currentModel === model
? "opacity-100"
: "opacity-0"
)}
/>
<div className="flex gap-3 items-center">
<div className="flex flex-col space-y-1 flex-1">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium text-gray-900">
{model}
</span>
</div>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</div>
) : null}
</div>
</div>
) : null}
</div>
</div>
{/* Show message if no models found */}
@ -526,7 +520,7 @@ const TextProvider = ({
)}
{/* Web Grounding Toggle - show at the end, below models dropdown */}
<div className="bg-white flex justify-between items-center p-10 rounded-[12px]">
<div className=' max-w-[290px]'>
@ -536,7 +530,6 @@ const TextProvider = ({
</p>
</div>
<div className="flex items-center gap-4">
<div className="w-[222px]">
<div className="flex items-center mb-4 gap-2.5 ">
<Switch
@ -547,16 +540,9 @@ const TextProvider = ({
Enable Web Grounding
</label>
</div>
</div>
{/* <div className="w-[295px]"></div> */}
</div>
</div>
</div>
)
}

View file

@ -1,76 +1,113 @@
import { Card } from "@/components/ui/card";
export default function LoadingProfile() {
function Shimmer({ className }: { className?: string }) {
return (
<div className="h-screen bg-gradient-to-b font-instrument_sans from-gray-50 to-white flex flex-col overflow-hidden">
{/* Header Skeleton */}
<div className="flex-shrink-0 bg-white border-b border-gray-200 p-4">
<div className="container mx-auto max-w-3xl">
<div className="flex items-center justify-between">
<div className="h-8 w-32 bg-gray-200 animate-pulse rounded-md" />
<div className="flex items-center gap-4">
<div className="h-8 w-8 bg-gray-200 animate-pulse rounded-full" />
<div className="h-8 w-24 bg-gray-200 animate-pulse rounded-md" />
<div
className={`bg-[#E1E1E5] animate-pulse rounded-md ${className ?? ""}`}
aria-hidden
/>
);
}
export default function LoadingSettings() {
return (
<div className="h-screen font-syne flex flex-col overflow-hidden relative">
<div
className="fixed z-0 bottom-[-14.5rem] left-0 w-full h-full pointer-events-none"
style={{
height: "341px",
borderRadius: "1440px",
background:
"radial-gradient(5.92% 104.69% at 50% 100%, rgba(122, 90, 248, 0.00) 0%, rgba(255, 255, 255, 0.00) 100%), radial-gradient(50% 50% at 50% 50%, rgba(122, 90, 248, 0.80) 0%, rgba(122, 90, 248, 0.00) 100%)",
}}
/>
<main className="w-full mx-auto gap-6 overflow-hidden flex">
{/* SettingSideBar structure */}
<div className="w-full max-w-[230px] h-screen px-4 pt-[22px] bg-[#F9FAFB] flex flex-col shrink-0">
<div className="mt-[3.15rem] border-b border-[#E1E1E5] pb-3.5">
<Shimmer className="h-3 w-16" />
</div>
<div className="mt-6 flex-1 min-h-0">
<Shimmer className="h-3 w-24 mb-2.5" />
<div className="p-0.5 rounded-[40px] bg-white w-full max-w-[210px] border border-[#EDEEEF] flex items-center mb-[34px] h-[30px]">
<Shimmer className="h-[26px] flex-1 rounded-[70px] mx-0.5" />
<Shimmer className="h-[26px] flex-1 rounded-[70px] mx-0.5 opacity-70" />
</div>
<Shimmer className="h-3 w-28 mb-2.5" />
<div className="space-y-2.5">
{[0, 1].map((i) => (
<div
key={i}
className="w-full rounded-[6px] px-3 py-4 flex items-center gap-1.5 border border-[#EDEEEF] bg-white"
>
<Shimmer className="h-[18px] w-[18px] rounded-full shrink-0" />
<Shimmer className="h-3 flex-1 max-w-[100px]" />
</div>
))}
</div>
</div>
<div className="border-t border-[#E1E1E5] py-5">
<Shimmer className="h-3 w-12 mb-2.5" />
<div className="w-full rounded-[6px] p-3 py-4 flex items-center gap-1.5 border border-[#EDEEEF] bg-white">
<Shimmer className="h-6 w-6 rounded-full shrink-0" />
<Shimmer className="h-3 w-16" />
</div>
</div>
</div>
</div>
{/* Main Content Skeleton */}
<main className="flex-1 container mx-auto px-4 max-w-3xl overflow-hidden flex flex-col">
<div className="flex-1 overflow-hidden">
{/* LLM Selection Content Skeleton */}
<div className="space-y-6 p-6">
{/* Page Title */}
<div className="space-y-2">
<div className="h-8 w-48 bg-gray-200 animate-pulse rounded-md" />
<div className="h-5 w-72 bg-gray-200 animate-pulse rounded-md" />
{/* Main column — matches SettingPage + TextProvider default */}
<div className="w-full min-w-0 flex flex-col">
<div className="sticky top-0 right-0 z-50 py-[28px] backdrop-blur mb-4">
<div className="flex gap-3 items-center flex-wrap">
<Shimmer className="h-8 w-[132px] rounded-md" />
<Shimmer className="h-[22px] w-[min(320px,55%)] rounded-[50px]" />
</div>
</div>
{/* LLM Provider Cards */}
<div className="space-y-4">
{[...Array(3)].map((_, index) => (
<Card key={index} className="p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="h-10 w-10 bg-gray-200 animate-pulse rounded-md" />
<div className="space-y-1">
<div className="h-5 w-32 bg-gray-200 animate-pulse rounded-md" />
<div className="h-4 w-48 bg-gray-200 animate-pulse rounded-md" />
</div>
</div>
<div className="h-6 w-6 bg-gray-200 animate-pulse rounded-full" />
</div>
{/* Configuration Fields */}
<div className="space-y-4">
{[...Array(2)].map((_, fieldIndex) => (
<div key={fieldIndex} className="space-y-2">
<div className="h-4 w-24 bg-gray-200 animate-pulse rounded-md" />
<div className="h-10 w-full bg-gray-200 animate-pulse rounded-md" />
</div>
))}
</div>
</Card>
))}
</div>
{/* Model Selection */}
<Card className="p-6">
<div className="space-y-4">
<div className="h-5 w-32 bg-gray-200 animate-pulse rounded-md" />
<div className="h-10 w-full bg-gray-200 animate-pulse rounded-md" />
<div className="space-y-6 bg-[#F9F8F8] p-7 rounded-[12px] pr-4 sm:pr-7">
{/* TextProvider top card: white panel, icon + copy left, controls right */}
<div className="mb-4 flex flex-col lg:flex-row lg:items-end lg:justify-between gap-8 rounded-[12px] bg-white pt-5 pb-10 px-6 sm:px-10">
<div className="max-w-[290px] shrink-0">
<Shimmer className="w-[60px] h-[60px] rounded-[4px]" />
<Shimmer className="h-6 w-48 mt-2.5 mb-2" />
<Shimmer className="h-4 w-full max-w-[260px]" />
<Shimmer className="h-4 w-40 mt-1.5" />
</div>
</Card>
<div className="flex flex-col items-stretch lg:items-end gap-4 flex-1 min-w-0">
<div className="flex flex-col sm:flex-row gap-4 sm:justify-end w-full">
<div className="w-full sm:w-[222px]">
<Shimmer className="h-4 w-36 mb-2" />
<Shimmer className="h-12 w-full rounded-lg" />
</div>
<div className="w-full sm:w-[222px]">
<Shimmer className="h-4 w-28 mb-2" />
<Shimmer className="h-12 w-full rounded-lg" />
</div>
</div>
<div className="w-full sm:w-[222px] sm:ml-auto">
<Shimmer className="h-4 w-40 mb-2" />
<Shimmer className="h-12 w-full rounded-lg" />
</div>
</div>
</div>
{/* TextProvider “Advanced” card */}
<div className="bg-white flex flex-col sm:flex-row sm:justify-between sm:items-center gap-6 p-6 sm:p-10 rounded-[12px]">
<div className="max-w-[290px] shrink-0">
<Shimmer className="h-6 w-28 mb-2" />
<Shimmer className="h-4 w-52" />
</div>
<div className="flex items-center gap-2.5 w-full sm:w-[222px] sm:justify-start">
<Shimmer className="h-6 w-11 rounded-full shrink-0" />
<Shimmer className="h-4 flex-1 max-w-[160px]" />
</div>
</div>
</div>
</div>
</main>
{/* Fixed Bottom Button Skeleton */}
<div className="flex-shrink-0 bg-white border-t border-gray-200 p-4">
<div className="container mx-auto max-w-3xl">
<div className="h-12 w-full bg-gray-200 animate-pulse rounded-lg" />
</div>
{/* Fixed save button — matches SettingPage placement */}
<div className="mx-auto fixed bottom-20 right-5 z-40">
<Shimmer className="h-12 w-[200px] sm:w-[240px] rounded-[58px]" />
</div>
</div>
);

View file

@ -11,7 +11,7 @@ const CreateCustomTemplate = () => {
trackEvent(MixpanelEvent.Templates_Build_Template_Clicked);
router.push('/custom-template')
}}
className='w-full rounded-xl border border-[#EDEEEF] cursor-pointer font-syne'>
className='w-full rounded-[22px] border border-[#EDEEEF] cursor-pointer font-syne'>
<div className='relative h-[215px] flex justify-center items-center '>
<img src="/card_bg.svg" alt="" className="absolute top-0 z-[1] left-0 w-full h-full object-cover" />
<div className='w-[36px] h-[36px] relative z-[4] rounded-full bg-[#7A5AF8] flex items-center justify-center'
@ -24,7 +24,7 @@ const CreateCustomTemplate = () => {
</div>
</div>
</div>
<div className='px-5 py-4 bg-white flex items-center gap-4 border-t border-[#EDEEEF]'>
<div className='px-5 py-4 bg-white flex items-center gap-4 overflow-hidden border-t border-[#EDEEEF]'>
<div className='bg-[#7A5AF8] w-[45px] h-[45px] rounded-lg p-2 flex items-center justify-center'>
<Sparkles className='w-6 h-6 text-white' />

View file

@ -2,23 +2,27 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { Card } from "@/components/ui/card";
import { ArrowUpRight, ChevronRight, ExternalLink, Loader2, Plus } from "lucide-react";
import { ArrowUpRight, ChevronRight, Loader2 } from "lucide-react";
import { templates } from "@/app/presentation-templates";
import { TemplateWithData, TemplateLayoutsWithSettings } from "@/app/presentation-templates/utils";
import { TemplateLayoutsWithSettings } from "@/app/presentation-templates/utils";
import {
useCustomTemplateSummaries,
useCustomTemplatePreview,
CustomTemplates,
} from "@/app/hooks/useCustomTemplates";
import { CompiledLayout } from "@/app/hooks/compileLayout";
import CreateCustomTemplate from "./CreateCustomTemplate";
import Link from "next/link";
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
import {
TemplatePreviewStage,
LayoutsBadge,
InbuiltTemplatePreview,
CustomTemplatePreview,
} from "../../../components/TemplatePreviewComponents";
// Component for rendering custom template card with lazy-loaded previews
export const CustomTemplateCard = React.memo(function CustomTemplateCard({ template }: { template: CustomTemplates }) {
const router = useRouter();
const { previewLayouts, loading, totalLayouts } = useCustomTemplatePreview(`${template.id}`);
const { previewLayouts, loading } = useCustomTemplatePreview(`${template.id}`);
const handleOpen = useCallback(() => {
trackEvent(MixpanelEvent.Templates_Custom_Opened, { template_id: template.id, template_name: template.name });
if (template.id.startsWith('custom-')) {
@ -26,73 +30,29 @@ export const CustomTemplateCard = React.memo(function CustomTemplateCard({ templ
} else {
router.push(`/template-preview?slug=custom-${template.id}`)
}
}
, [router, template.id, template.name]);
}, [router, template.id, template.name]);
return (
<Card
className="cursor-pointer flex flex-col justify-between shadow-none sm:shadow-none relative hover:shadow-lg transition-all duration-200 group overflow-hidden"
className="cursor-pointer flex flex-col shadow-none sm:shadow-none relative hover:shadow-sm transition-all duration-200 group overflow-hidden rounded-[22px] border border-[#E8E9EC] bg-white"
onClick={handleOpen}
>
<img src="/card_bg.svg" alt="" className="absolute top-0 left-0 w-full h-full object-cover" />
<span className="text-xs font-syne absolute top-2 flex gap-1 capitalize items-center left-2 rounded-[100px] px-2.5 py-1 bg-[#3A3A3AF5] text-white font-semibold z-40">
{totalLayouts} {totalLayouts === 1 ? 'Layout' : 'Layouts'}
</span>
<div className="p-5">
{/* Layout previews */}
<div className="grid grid-cols-2 gap-2">
{loading ? (
// Loading placeholders
[...Array(Math.min(4, template.layoutCount))].map((_, index) => (
<div
key={`${template.id}-loading-${index}`}
className="relative bg-linear-to-br from-purple-50 to-blue-50 border border-gray-200 overflow-hidden aspect-video rounded flex items-center justify-center"
>
<Loader2 className="w-4 h-4 text-purple-300 animate-spin" />
</div>
))
) : previewLayouts.length > 0 && (
// Actual layout previews
previewLayouts.slice(0, 4).map((layout: CompiledLayout, index: number) => {
const LayoutComponent = layout.component;
return (
<div
key={`${template.id}-preview-${index}`}
className="relative bg-gray-100 border border-gray-200 overflow-hidden aspect-video rounded"
>
<div className="absolute inset-0 bg-transparent z-10" />
<div
className="transform scale-[0.12] origin-top-left"
style={{ width: "833.33%", height: "833.33%" }}
>
<LayoutComponent data={layout.sampleData} />
</div>
</div>
);
})
)}
</div>
</div>
<div className="flex items-center justify-between p-5 bg-white border-t border-[#EDEEEF] relative z-40 ">
<h3 className="text-sm font-bold w-[191px] text-gray-900">
{template.name}
</h3>
<div className="flex items-center gap-2">
<ArrowUpRight className="w-4 h-4 text-gray-400 group-hover:text-purple-600 transition-colors" />
</div>
<TemplatePreviewStage>
<LayoutsBadge count={template.layoutCount} />
<CustomTemplatePreview
previewLayouts={previewLayouts}
loading={loading}
templateId={template.id}
/>
</TemplatePreviewStage>
<div className="relative z-40 flex items-center justify-between border-t border-[#EDEEEF] bg-white px-6 py-5">
<h3 className="max-w-[min(191px,65%)] text-base font-bold text-gray-900">{template.name}</h3>
<ArrowUpRight className="h-4 w-4 shrink-0 text-gray-400 transition-colors group-hover:text-purple-600" />
</div>
</Card>
);
}, (prev, next) => {
// Custom templates may be refetched, producing new object references; compare on fields we render/use.
return (
prev.template.id === next.template.id &&
prev.template.id === next.template.id &&
prev.template.name === next.template.name &&
prev.template.layoutCount === next.template.layoutCount
@ -106,54 +66,24 @@ const InbuiltTemplateCard = React.memo(function InbuiltTemplateCard({
template: TemplateLayoutsWithSettings;
onOpen: (id: string) => void;
}) {
const previewLayouts = useMemo(() => template.layouts.slice(0, 4), [template.layouts]);
const handleOpen = useCallback(() => onOpen(template.id), [onOpen, template.id]);
return (
<Card
key={template.id}
className="cursor-pointer relative sm:shadow-none shadow-none hover:shadow-lg transition-all duration-200 group overflow-hidden"
className="group relative cursor-pointer overflow-hidden rounded-[22px] border border-[#E8E9EC] bg-white shadow-none sm:shadow-none transition-all duration-200 hover:shadow-sm"
onClick={handleOpen}
>
<span className="text-xs font-syne absolute top-2 flex gap-1 capitalize items-center left-2 rounded-[100px] px-2.5 py-1 bg-[#3A3A3AF5] text-white font-semibold z-40">
{template.layouts.length} {template.layouts.length === 1 ? 'Layout' : 'Layouts'}
</span>
<img src="/card_bg.svg" alt="" className="absolute top-0 left-0 w-full h-full object-cover" />
<div className="p-5">
<div className="grid grid-cols-2 gap-2">
{previewLayouts.map((layout: TemplateWithData, index: number) => {
const LayoutComponent = layout.component;
return (
<div
key={`${template.id}-preview-${index}`}
className="relative bg-gray-100 border border-gray-200 overflow-hidden aspect-video rounded"
>
<div className="absolute inset-0 bg-transparent z-10" />
<div
className="transform scale-[0.12] origin-top-left"
style={{ width: "833.33%", height: "833.33%" }}
>
<LayoutComponent data={layout.sampleData} />
</div>
</div>
);
})}
</div>
</div>
<div className="flex items-center justify-between p-5 bg-white border-t border-[#EDEEEF] relative z-40 ">
<div className="w-[191px]">
<h3 className="text-sm font-bold text-gray-900 capitalize">
{template.name}
</h3>
<p className="text-xs text-gray-600 mb-4 line-clamp-2">
{template.description}
</p>
</div>
<div className="flex items-center gap-2">
<ArrowUpRight className="w-4 h-4 text-gray-400 group-hover:text-blue-600 transition-colors" />
<TemplatePreviewStage>
<LayoutsBadge count={template.layouts.length} />
<InbuiltTemplatePreview layouts={template.layouts} templateId={template.id} />
</TemplatePreviewStage>
<div className="relative z-40 flex items-center justify-between gap-4 border-t border-[#EDEEEF] bg-white px-6 py-5">
<div className="min-w-0 flex-1">
<h3 className="text-base font-bold capitalize text-gray-900">{template.name}</h3>
<p className="mt-1 line-clamp-2 text-sm text-gray-500">{template.description}</p>
</div>
<ArrowUpRight className="h-4 w-4 shrink-0 text-gray-400 transition-colors group-hover:text-blue-600" />
</div>
</Card>
);
@ -254,7 +184,7 @@ const LayoutPreview = () => {
{/* Inbuilt Templates Section: non-neo first, then Report (neo) */}
{tab === 'default' && (
<section className="my-12 space-y-12">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{nonNeoInbuilt.map((template) => (
<InbuiltTemplateCard
key={template.id}
@ -268,7 +198,7 @@ const LayoutPreview = () => {
<h4 className="text-base font-semibold text-[#101828] mb-6 font-syne tracking-tight">
Report
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{neoInbuilt.map((template) => (
<InbuiltTemplateCard
key={template.id}
@ -290,7 +220,7 @@ const LayoutPreview = () => {
<span className="ml-3 text-gray-600">Loading custom templates...</span>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="grid grid-cols-1 md:grid-cols-2 items-center lg:grid-cols-4 gap-6">
<CreateCustomTemplate />
{customTemplateCards}
</div>

View file

@ -33,6 +33,7 @@ import ThemeApi from '@/app/(presentation-generator)/services/api/theme'
import { useFontLoader } from '@/app/(presentation-generator)/hooks/useFontLoad'
import Link from 'next/link'
import { trackEvent, MixpanelEvent } from '@/utils/mixpanel'
import { resolveBackendAssetUrl } from '@/utils/api'
// Fallback theme used before defaults are loaded from API (unified Theme type)
const FALLBACK_THEME: Theme = {
@ -153,7 +154,7 @@ const ThemePanel: React.FC = () => {
setUserFonts({
fonts: userFonts.fonts.map((font: any) => ({
name: font.name,
url: `${process.env.NEXT_PUBLIC_FAST_API}${font.url}`,
url: resolveBackendAssetUrl(font.url),
})),
})
@ -458,14 +459,14 @@ const ThemePanel: React.FC = () => {
setCustomFonts({
textFont: {
name: font_name,
url: `${process.env.NEXT_PUBLIC_FAST_API}${font_url}`,
url: resolveBackendAssetUrl(font_url),
}
})
// Add the newly uploaded font to userFonts if not already present
if (!userFonts.fonts.find(f => f.name === font_name)) {
setUserFonts(prev => ({
fonts: [...prev.fonts, { name: font_name, url: `${process.env.NEXT_PUBLIC_FAST_API}${font_url}` }]
fonts: [...prev.fonts, { name: font_name, url: resolveBackendAssetUrl(font_url) }]
}))
}
toast.success(`Font "${font_name}" uploaded successfully`)

View file

@ -11,7 +11,7 @@ import { Search } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button";
import { PresentationGenerationApi } from "../services/api/presentation-generation";
import { getStaticFileUrl } from "../utils/others";
import { resolveBackendAssetUrl } from "@/utils/api";
import { toast } from "sonner";
interface IconsEditorProps {
icon_prompt?: string[] | null;
@ -147,7 +147,7 @@ const IconsEditor = ({
className="w-12 h-12 cursor-pointer group relative rounded-lg overflow-hidden hover:bg-gray-100 p-2 transition-colors"
>
<img
src={iconSrc}
src={resolveBackendAssetUrl(iconSrc)}
alt={`Icon ${idx + 1}`}
className="w-full h-full object-contain"
/>

View file

@ -1,5 +1,6 @@
"use client";
import React, { useEffect, useState, useRef } from "react";
import { useSelector } from "react-redux";
import {
Sheet,
SheetContent,
@ -9,7 +10,7 @@ import {
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Wand2, Upload, Loader2, Delete, Trash } from "lucide-react";
import { Wand2, Upload, Loader2, Trash } from "lucide-react";
import { cn } from "@/lib/utils";
import { PresentationGenerationApi } from "../services/api/presentation-generation";
import { Skeleton } from "@/components/ui/skeleton";
@ -18,6 +19,10 @@ import { PreviousGeneratedImagesResponse } from "../services/api/params";
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
import { ImagesApi } from "../services/api/images";
import { ImageAssetResponse } from "../services/api/types";
import { RootState } from "@/store/store";
const STOCK_IMAGE_PROVIDERS = new Set(["pexels", "pixabay"]);
interface ImageEditorProps {
initialImage: string | null;
imageIdx?: number;
@ -39,6 +44,10 @@ const ImageEditor = ({
onFocusPointClick,
onImageChange,
}: ImageEditorProps) => {
const llmConfig = useSelector((state: RootState) => state.userConfig.llm_config);
const selectedImageProvider = (llmConfig?.IMAGE_PROVIDER || "").toLowerCase();
const isStockImageProvider = STOCK_IMAGE_PROVIDERS.has(selectedImageProvider);
// State management
const [previewImages, setPreviewImages] = useState(initialImage);
const [previousGeneratedImages, setPreviousGeneratedImages] = useState<
@ -53,6 +62,7 @@ const ImageEditor = ({
const [isOpen, setIsOpen] = useState(true);
const [uploadedImages, setUploadedImages] = useState<ImageAssetResponse[]>([]);
const [uploadedImagesLoading, setUploadedImagesLoading] = useState(false);
const [stockSearchResults, setStockSearchResults] = useState<string[]>([]);
// Focus point and object fit for image editing
const [isFocusPointMode, setIsFocusPointMode] = useState(false);
const [focusPoint, setFocusPoint] = useState(
@ -190,18 +200,40 @@ const ImageEditor = ({
setError("Please enter a prompt");
return;
}
const trimmedPrompt = prompt.trim();
if (!trimmedPrompt) {
setError("Please enter a prompt");
return;
}
try {
setIsGenerating(true);
setError(null);
trackEvent(MixpanelEvent.ImageEditor_GenerateImage_API_Call);
const response = await PresentationGenerationApi.generateImage({
prompt: prompt,
});
setPreviewImages(response);
if (isStockImageProvider) {
const providerApiKey =
selectedImageProvider === "pexels"
? llmConfig?.PEXELS_API_KEY
: llmConfig?.PIXABAY_API_KEY;
const results = await ImagesApi.searchStockImages(trimmedPrompt, 12, {
provider: selectedImageProvider,
apiKey: providerApiKey,
});
setStockSearchResults(results);
if (results.length > 0) {
setPreviewImages(results[0]);
}
} else {
const response = await PresentationGenerationApi.generateImage({
prompt: trimmedPrompt,
});
setPreviewImages(response);
}
} catch (err: any) {
console.error("Error in image generation", err);
setError(err.message || "Failed to generate image. Please try again.");
setError(err.message || "Failed to fetch images. Please try again.");
} finally {
setIsGenerating(false);
}
@ -286,7 +318,7 @@ const ImageEditor = ({
<Tabs defaultValue="generate" className="w-full" onValueChange={handleTabChange}>
<TabsList className="grid bg-blue-100 border border-blue-300 w-full grid-cols-3 mx-auto">
<TabsTrigger className="font-medium" value="generate">
AI Generate
{isStockImageProvider ? "Stock Search" : "AI Generate"}
</TabsTrigger>
<TabsTrigger className="font-medium" value="upload">
Upload
@ -305,10 +337,14 @@ const ImageEditor = ({
<div>
<h3 className="text-base font-medium mb-2">
Image Description
{isStockImageProvider ? "Image Keyword" : "Image Description"}
</h3>
<Textarea
placeholder="Describe the image you want to generate..."
placeholder={
isStockImageProvider
? "Enter a keyword to search stock images..."
: "Describe the image you want to generate..."
}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
className="min-h-[100px]"
@ -321,35 +357,62 @@ const ImageEditor = ({
disabled={!prompt || isGenerating}
>
<Wand2 className="w-4 h-4 mr-2" />
{isGenerating ? "Generating..." : "Generate Image"}
{isGenerating
? (isStockImageProvider ? "Searching..." : "Generating...")
: (isStockImageProvider ? "Search Images" : "Generate Image")}
</Button>
{error && <p className="text-red-500 text-sm">{error}</p>}
<div className="grid grid-cols-2 gap-4">
{isGenerating || !previewImages ? (
{isGenerating ? (
Array.from({ length: 4 }).map((_, index) => (
<Skeleton
key={index}
className="aspect-[4/3] w-full rounded-lg"
/>
))
) : isStockImageProvider ? (
stockSearchResults.length > 0 ? (
stockSearchResults.map((imageUrl, index) => (
<div
key={`${imageUrl}-${index}`}
onClick={() => handleImageChange(imageUrl)}
className="aspect-[4/3] w-full overflow-hidden rounded-lg border cursor-pointer hover:border-blue-500 transition-colors"
>
<img
src={imageUrl}
alt={`Stock result ${index + 1}`}
className="w-full h-full object-cover"
/>
</div>
))
) : (
<p className="text-sm text-gray-500 col-span-2">Search with a keyword to view stock images.</p>
)
) : (
<div
onClick={() => handleImageChange(previewImages)}
className="aspect-[4/3] w-full overflow-hidden rounded-lg border cursor-pointer hover:border-blue-500 transition-colors"
>
{previewImages && (
!previewImages ? (
Array.from({ length: 4 }).map((_, index) => (
<Skeleton
key={index}
className="aspect-[4/3] w-full rounded-lg"
/>
))
) : (
<div
onClick={() => handleImageChange(previewImages)}
className="aspect-[4/3] w-full overflow-hidden rounded-lg border cursor-pointer hover:border-blue-500 transition-colors"
>
<img
src={previewImages}
alt={`Preview`}
className="w-full h-full object-cover"
/>
)}
</div>
</div>
)
)}
</div>
{previousGeneratedImages.length > 0 && (
{!isStockImageProvider && previousGeneratedImages.length > 0 && (
<div className="mt-4">
<h3 className="text-sm font-medium mb-2">
Previous Generated Images

View file

@ -0,0 +1,125 @@
"use client";
import React, { memo, useMemo } from "react";
import { Loader2 } from "lucide-react";
import { TemplateWithData } from "@/app/presentation-templates/utils";
import { CompiledLayout } from "@/app/hooks/compileLayout";
export function TemplatePreviewStage({ children }: { children: React.ReactNode }) {
return (
<div className="relative overflow-hidden px-5 pb-5 pt-5 h-[230px]">
<img
src="/card_bg.svg"
alt=""
className="absolute top-0 left-0 w-full h-full object-cover"
/>
{children}
</div>
);
}
export const LayoutsBadge = memo(function LayoutsBadge({ count }: { count: number }) {
return (
<span className="text-xs font-syne absolute top-3.5 left-4 z-40 inline-flex items-center rounded-full bg-[#333333] px-3 py-1 font-semibold text-white">
Layouts-{count}
</span>
);
});
export const ScaledSlidePreview = memo(function ScaledSlidePreview({
children,
id,
index,
isOutline = false,
}: {
children: React.ReactNode;
id: string;
index: number;
isOutline?: boolean;
}) {
const PREVIEW_SCALE = isOutline ? 0.2 : 0.24;
const SLIDE_HEIGHT = 720 * PREVIEW_SCALE;
const SLIDE_WIDTH = 1280;
const SLIDE_NATIVE_HEIGHT = 720;
return (
<div
key={`${id}-preview-${index}`}
className="relative"
style={{ height: `${SLIDE_HEIGHT}px`, overflow: "hidden" }}
>
<div
className={`absolute top-0 ${isOutline ? "left-0" : "left-8"} pointer-events-none`}
style={{
width: SLIDE_WIDTH,
height: SLIDE_NATIVE_HEIGHT,
transformOrigin: "top left",
transform: `scale(${PREVIEW_SCALE})`,
}}
>
{children}
</div>
</div>
);
});
export const InbuiltTemplatePreview = memo(function InbuiltTemplatePreview({
layouts,
templateId,
isOutline = false,
}: {
layouts: TemplateWithData[];
templateId: string;
isOutline?: boolean;
}) {
const previewLayouts = useMemo(() => layouts.slice(0, 2), [layouts]);
return (
<div className="relative z-10 flex flex-col gap-3 overflow-hidden">
{previewLayouts.map((layout, index) => {
const LayoutComponent = layout.component;
return (
<ScaledSlidePreview key={`${templateId}-preview-${index}`} id={templateId} index={index} isOutline={isOutline}>
<LayoutComponent data={layout.sampleData} />
</ScaledSlidePreview>
);
})}
</div>
);
});
export const CustomTemplatePreview = memo(function CustomTemplatePreview({
previewLayouts,
loading,
templateId,
isOutline = false,
}: {
previewLayouts: CompiledLayout[];
loading: boolean;
templateId: string;
isOutline?: boolean;
}) {
return (
<div className="relative z-10 flex flex-col gap-3">
{loading ? (
[...Array(2)].map((_, index) => (
<div
key={`${templateId}-loading-${index}`}
className="relative w-full aspect-video flex items-center justify-center"
>
<Loader2 className="h-4 w-4 animate-spin text-slate-300" />
</div>
))
) : (
previewLayouts.slice(0, 2).map((layout, index) => {
const LayoutComponent = layout.component;
return (
<ScaledSlidePreview key={`${templateId}-preview-${index}`} id={templateId} index={index} isOutline={isOutline}>
<LayoutComponent data={layout.sampleData} />
</ScaledSlidePreview>
);
})
)}
</div>
);
});

View file

@ -0,0 +1,276 @@
"use client";
import React, { useEffect, useCallback, useState } from "react";
import { useRouter } from "next/navigation";
import { useFileUpload } from "./hooks/useFileUpload";
import { useTemplateCreation } from "./hooks/useTemplateCreation";
import { useLayoutSaving } from "./hooks/useLayoutSaving";
import { ProcessedSlide } from "./types";
import { TAILWIND_CDN_URL } from "./constants";
import { TemplateStudioHeader } from "./components/TemplateStudioHeader";
import { TemplateCreationProgress } from "./components/TemplateCreationProgress";
import { Step2FontManagement } from "./components/steps/Step2FontManagement";
import { Step3SlidePreview } from "./components/steps/Step3SlidePreview";
import { Step4TemplateCreation } from "./components/steps/Step4TemplateCreation";
import { SaveLayoutButton } from "./components/SaveLayoutButton";
import { SaveLayoutModal } from "./components/SaveLayoutModal";
import { FileUploadSection } from "./components/FileUploadSection";
import { useFontLoader } from "../hooks/useFontLoad";
import Header from "@/app/(presentation-generator)/(dashboard)/dashboard/components/Header";
const CustomTemplatePage = () => {
const router = useRouter();
const [schemaEditorSlideIndex, setSchemaEditorSlideIndex] = useState<number | null>(null);
const [schemaPreviewData, setSchemaPreviewData] = useState<Record<number, Record<string, any>>>({});
const { selectedFile, handleFileSelect, removeFile } = useFileUpload();
const {
state,
uploadedFonts,
slides,
setSlides,
completedSlides,
checkFonts,
uploadFont,
removeFont,
fontUploadAndPreview,
initTemplateCreation,
retrySlide,
} = useTemplateCreation();
// Layout saving hook
const {
isSavingLayout,
isModalOpen,
openSaveModal,
closeSaveModal,
saveLayout,
} = useLayoutSaving(slides);
useEffect(() => {
const existingScript = document.querySelector('script[src*="tailwindcss.com"]');
if (!existingScript) {
const script = document.createElement("script");
script.src = TAILWIND_CDN_URL;
script.async = true;
document.head.appendChild(script);
}
}, []);
/**
* Step 1: Check fonts in uploaded PPTX
*/
const handleCheckFonts = useCallback(async () => {
if (selectedFile) {
await checkFonts(selectedFile);
}
}, [selectedFile, checkFonts]);
/**
* Step 2: Upload fonts and generate preview
*/
const handleFontUploadAndPreview = useCallback(async () => {
if (selectedFile) {
const data = await fontUploadAndPreview(selectedFile);
if (data) {
useFontLoader(data.fonts);
}
}
}, [selectedFile, fontUploadAndPreview]);
/**
* Step 5: Save template with metadata
*/
const handleSaveTemplate = useCallback(async (
layoutName: string,
description: string,
template_info_id: string
): Promise<string | null> => {
const id = await saveLayout(layoutName, description, template_info_id);
if (id) {
router.push(`/template-preview?slug=custom-${id}`);
}
return id;
}, [saveLayout, router]);
/**
* Update a specific slide's data
*/
const handleSlideUpdate = useCallback((index: number, updatedSlideData: Partial<ProcessedSlide>) => {
setSlides((prevSlides) =>
prevSlides.map((s, i) =>
i === index
? { ...s, ...updatedSlideData, modified: true }
: s
)
);
}, [setSlides]);
/**
* Open schema editor for a specific slide
*/
const handleOpenSchemaEditor = useCallback((index: number | null) => {
setSchemaEditorSlideIndex(index);
}, []);
/**
* Close schema editor
*/
const handleCloseSchemaEditor = useCallback(() => {
setSchemaEditorSlideIndex(null);
}, []);
/**
* Save changes from schema editor
*/
const handleSchemaEditorSave = useCallback((updatedReact: string) => {
if (schemaEditorSlideIndex !== null) {
setSlides(prev => prev.map((s, i) =>
i === schemaEditorSlideIndex ? { ...s, react: updatedReact } : s
));
}
setSchemaEditorSlideIndex(null);
}, [schemaEditorSlideIndex, setSlides]);
/**
* Update schema preview content (for AI fill)
*/
const handleSchemaPreviewContent = useCallback((content: Record<string, any>) => {
if (schemaEditorSlideIndex !== null) {
setSchemaPreviewData(prev => ({
...prev,
[schemaEditorSlideIndex]: content
}));
}
}, [schemaEditorSlideIndex]);
/**
* Clear schema preview data for a specific slide
*/
const handleClearSchemaPreview = useCallback((slideIndex: number) => {
setSchemaPreviewData(prev => {
const newData = { ...prev };
delete newData[slideIndex];
return newData;
});
}, []);
const showFileUpload = state.step === 'file-upload';
const showFontManager = state.step === 'font-check' || state.step === 'font-upload';
const showPreview = state.step === 'slides-preview';
const showSlides = state.step === 'template-creation' || state.step === 'completed';
const isProcessingCompleted = state.step === 'completed';
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100">
<Header />
<TemplateStudioHeader />
{showFileUpload ? (
<div className="pb-24">
<FileUploadSection
selectedFile={selectedFile}
handleFileSelect={handleFileSelect}
removeFile={removeFile}
CheckFonts={handleCheckFonts}
isProcessingPptx={state.isLoading}
slides={[]}
completedSlides={0}
/>
</div>
) : (
<div className="mx-auto min-h-[600px] px-6 pb-24">
<TemplateCreationProgress
currentStep={state.step}
totalSlides={state.totalSlides}
processedSlides={completedSlides}
/>
{/* Step 2: Font Management */}
{showFontManager && (
<Step2FontManagement
fontsData={state.fontsData}
uploadedFonts={uploadedFonts}
uploadFont={uploadFont}
removeFont={removeFont}
onContinue={handleFontUploadAndPreview}
isUploading={state.isLoading}
/>
)}
{/* Step 3: Slide Preview */}
{showPreview && (
<Step3SlidePreview
previewData={state.previewData}
onInitTemplate={initTemplateCreation}
isLoading={state.isLoading}
/>
)}
{/* Step 4: Template Creation & Editing */}
{showSlides && slides.length > 0 && (
<Step4TemplateCreation
slides={slides}
setSlides={setSlides}
retrySlide={retrySlide}
onSlideUpdate={handleSlideUpdate}
schemaEditorSlideIndex={schemaEditorSlideIndex}
onOpenSchemaEditor={handleOpenSchemaEditor}
onCloseSchemaEditor={handleCloseSchemaEditor}
onSchemaEditorSave={handleSchemaEditorSave}
schemaPreviewData={schemaPreviewData}
onSchemaPreviewContent={handleSchemaPreviewContent}
onClearSchemaPreview={handleClearSchemaPreview}
/>
)}
{/* Floating Save Template Button */}
{isProcessingCompleted && slides.some((s) => s.processed) && (
<SaveLayoutButton
onSave={openSaveModal}
isSaving={isSavingLayout}
isProcessing={slides.some((s) => s.processing)}
/>
)}
{/* Save Template Modal */}
<SaveLayoutModal
isOpen={isModalOpen}
onClose={closeSaveModal}
onSave={handleSaveTemplate}
isSaving={isSavingLayout}
template_info_id={state.templateId || ''}
/>
</div>
)}
</div>
);
};
export default CustomTemplatePage;

View file

@ -1,21 +0,0 @@
import React from "react";
import Header from "@/app/(presentation-generator)/(dashboard)/dashboard/components/Header";
export const APIKeyWarning: React.FC = () => {
return (
<div className="min-h-screen font-roboto bg-gradient-to-br from-slate-50 to-slate-100">
<Header />
<div className="flex items-center justify-center aspect-video mx-auto px-6">
<div className="text-center space-y-2 my-6 bg-white p-10 rounded-lg shadow-lg">
<h1 className="text-xl font-bold text-gray-900">
Please add "GOOGLE_API_KEY" to enable template creation via AI.
</h1>
<h1 className="text-xl font-bold text-gray-900">Please add your OpenAI API Key to process the layout</h1>
<p className="text-lg text-gray-600 max-w-2xl mx-auto">
This feature requires an OpenAI model GPT-5. Configure your key in settings or via environment variables.
</p>
</div>
</div>
</div>
);
};

View file

@ -1,167 +0,0 @@
'use client'
import React from "react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Pencil, Eraser, RotateCcw, SendHorizontal, X } from "lucide-react";
import { EditControlsProps } from "../../types";
export const EditControls: React.FC<EditControlsProps> = ({
isEditMode,
prompt,
isUpdating,
strokeWidth,
strokeColor,
eraserMode,
onPromptChange,
onSave,
onCancel,
onStrokeWidthChange,
onStrokeColorChange,
onEraserModeChange,
onClearCanvas,
}) => {
const colors = [
"#000000",
"#FF0000",
"#00FF00",
"#0000FF",
"#FFFF00",
"#FF00FF",
"#00FFFF",
"#FFA500",
];
const strokeWidths = [1, 3, 5, 8, 12];
if (!isEditMode) return null;
return (
<div className="border-2 max-w-[1280px] mx-auto border-blue-200 rounded-lg p-4 bg-blue-50 space-y-4">
{/* Drawing Tools */}
<div className="flex items-center justify-between flex-wrap gap-4">
<div className="flex items-center gap-4 flex-wrap">
{/* Drawing Tools */}
<div className="flex items-center gap-2">
<Button
variant={!eraserMode ? "default" : "outline"}
size="sm"
onClick={() => onEraserModeChange(false)}
className="flex items-center gap-1"
>
<Pencil size={14} />
Draw
</Button>
<Button
variant={eraserMode ? "default" : "outline"}
size="sm"
onClick={() => onEraserModeChange(true)}
className="flex items-center gap-1"
>
<Eraser size={14} />
Erase
</Button>
</div>
{/* Color Picker */}
{!eraserMode && (
<div className="flex items-center gap-1">
{colors.map((color) => (
<button
key={color}
className={`w-5 h-5 rounded-full border-2 ${
strokeColor === color
? "border-gray-800"
: "border-gray-300"
}`}
style={{ backgroundColor: color }}
onClick={() => onStrokeColorChange(color)}
/>
))}
</div>
)}
{/* Stroke Width */}
<div className="flex items-center gap-1">
{strokeWidths.map((width) => (
<button
key={width}
className={`w-7 h-7 rounded border flex items-center justify-center ${
strokeWidth === width
? "bg-blue-100 border-blue-500"
: "border-gray-300"
}`}
onClick={() => onStrokeWidthChange(width)}
>
<div
className="rounded-full bg-gray-800"
style={{
width: `${width + 1}px`,
height: `${width + 1}px`,
}}
/>
</button>
))}
</div>
<Button
variant="outline"
size="sm"
onClick={onClearCanvas}
className="flex items-center gap-1"
>
<RotateCcw size={14} />
Clear
</Button>
</div>
<Button
variant="outline"
size="sm"
onClick={onCancel}
className="flex items-center gap-1"
>
<X size={14} />
Cancel
</Button>
</div>
{/* Prompt Section */}
<div className="space-y-2 mt-2">
<label
htmlFor="edit-prompt"
className="text-sm font-medium font-inter text-gray-700"
>
Describe the changes you want to make:
</label>
<div className="flex gap-2">
<Textarea
id="edit-prompt"
placeholder="Enter your prompt here... (e.g., 'Change the title color to blue', 'Add a border to the image', etc.)"
value={prompt}
onChange={(e) => onPromptChange(e.target.value)}
className="flex-1 font-inter duration-300 h-[70px] border-blue-200 border-2 rounded-lg outline-none focus:border-blue-500 focus:ring-0 max-h-[70px] resize-none"
disabled={isUpdating}
/>
<div>
<Button
onClick={onSave}
disabled={isUpdating || !prompt.trim()}
className="flex flex-col w-28 font-inter font-semibold items-center gap-1 h-full bg-green-600 hover:bg-green-700 px-4"
>
{isUpdating ? (
"Updating..."
) : (
<>
<SendHorizontal size={14} />
Update
</>
)}
</Button>
</div>
</div>
</div>
</div>
);
};

View file

@ -1,97 +0,0 @@
'use client'
import React, { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Save, X, Code } from "lucide-react";
import { ProcessedSlide } from "../../types";
import Editor from 'react-simple-code-editor';
import { highlight, languages } from 'prismjs';
import 'prismjs/components/prism-clike';
import 'prismjs/components/prism-javascript';
import 'prismjs/components/prism-markup';
import 'prismjs/components/prism-jsx';
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet";
interface HtmlEditorProps {
slide: ProcessedSlide;
isHtmlEditMode: boolean;
onSave: (html: string) => void;
onCancel: () => void;
}
export const HtmlEditor: React.FC<HtmlEditorProps> = ({
slide,
isHtmlEditMode,
onSave,
onCancel,
}) => {
const [htmlContent, setHtmlContent] = useState(slide.html || "");
useEffect(() => {
setHtmlContent(slide.html || "");
}, [slide.html]);
if (!isHtmlEditMode) return null;
const handleSave = () => {
onSave(htmlContent);
};
const handleCancel = () => {
setHtmlContent(slide.html || "");
onCancel();
};
return (
<Sheet open={isHtmlEditMode} onOpenChange={(open) => { if (!open) handleCancel(); }}>
<SheetContent side="right" className="w-full sm:max-w-[860px] p-0">
<SheetHeader className="px-6 py-4 border-b">
<SheetTitle className="flex items-center justify-between w-full">
<span className="flex items-center gap-2 text-purple-800">
<Code className="w-5 h-5 text-purple-600" />
HTML Editor
</span>
</SheetTitle>
</SheetHeader>
<div className="space-y-4 px-2 overflow-y-auto h-[85%]">
<div className="container__content_area">
<Editor
value={htmlContent}
onValueChange={html => setHtmlContent(html)}
highlight={code => highlight(code, languages.jsx!, 'jsx')}
padding={10}
id="html-editor"
name="html-editor"
className="container__editor"
/>
</div>
</div>
<SheetFooter className="px-6 py-4 border-b">
<SheetTitle className="flex items-center justify-between w-full">
<div></div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={handleCancel}
className="flex items-center gap-1"
>
<X size={14} />
Cancel
</Button>
<Button
onClick={handleSave}
className="flex items-center gap-1 bg-purple-600 hover:bg-purple-700"
size="sm"
>
<Save size={14} />
Save HTML
</Button>
</div>
</SheetTitle>
</SheetFooter>
</SheetContent>
</Sheet>
);
};

Some files were not shown because too many files have changed in this diff Show more