From 2501e86a8091b3597d443eabb000f3191896cab8 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 8 Jun 2026 22:09:12 -0700 Subject: [PATCH] Add source upload deck starter --- README.md | 19 ++- apps/api/package.json | 7 +- apps/api/src/server.ts | 302 +++++++++++++++++++++++++++++++++++++--- apps/web/src/main.tsx | 175 +++++++++++++++++++---- apps/web/src/styles.css | 144 ++++++++++++++++--- package-lock.json | 163 ++++++++++++++++++++++ 6 files changed, 741 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index 6aa9276..692d3b0 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,11 @@ The first build is intentionally local-first: a CLI, an API, and a small web UI share the same deck spec, style packs, and PPTX renderer. The same core contract can later run behind `dev.slides.scottfelten.com` and `slides.scottfelten.com`. +The web UI is source-first: paste notes, drop a file, choose a file, or paste an +image into the source area. The MVP extracts text from Markdown/text, PDF, and +PPTX files. Image upload starts a deck from image metadata today; vision/OCR is +reserved for the Model Service Gateway integration. + ## MVP Contract ```text @@ -47,6 +52,19 @@ Run the web shell: npm run dev:web ``` +Then open: + +```text +http://127.0.0.1:5185 +``` + +Supported starter sources: + +- `.md`, `.markdown`, `.txt` +- `.pdf` +- `.pptx` +- image files or pasted clipboard images + ## AgentPlane Notes - Implementation truth should live in Forgejo. @@ -55,4 +73,3 @@ npm run dev:web - Google Slides generation should reuse the existing AgentPlane guidance: copy templates, inventory native containers, fill existing object IDs, and render thumbnails/contact sheets before handoff. - diff --git a/apps/api/package.json b/apps/api/package.json index 010ca47..794c084 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -12,14 +12,19 @@ "@slide-factory/render-pptx": "0.1.0", "cors": "^2.8.5", "express": "^4.19.2", + "fast-xml-parser": "^4.5.0", + "jszip": "^3.10.1", + "multer": "^2.1.1", "nanoid": "^5.0.7", + "pdf-parse": "^1.1.1", "tsx": "^4.16.2", "zod": "^3.23.8" }, "devDependencies": { "@types/cors": "^2.8.17", "@types/express": "^4.17.21", + "@types/multer": "^1.4.12", + "@types/pdf-parse": "^1.1.4", "typescript": "^5.5.4" } } - diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index dd45975..42e47e4 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -1,11 +1,16 @@ import { access, mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { inflateSync } from "node:zlib"; import cors from "cors"; import express from "express"; +import { XMLParser } from "fast-xml-parser"; +import JSZip from "jszip"; +import multer from "multer"; import { nanoid } from "nanoid"; +import pdfParse from "pdf-parse"; import { z } from "zod"; -import { loadStylePack, planDeckFromSource } from "@slide-factory/core"; +import { loadStylePack, planDeckFromSource, SourceDocument } from "@slide-factory/core"; import { renderPptx } from "@slide-factory/render-pptx"; const CreateDeckRequestSchema = z.object({ @@ -22,6 +27,13 @@ const CreateDeckRequestSchema = z.object({ const app = express(); const port = Number(process.env.SLIDE_FACTORY_PORT || 3025); const outputDir = path.resolve(process.env.SLIDE_FACTORY_OUTPUT_DIR || "outputs"); +const upload = multer({ + storage: multer.memoryStorage(), + limits: { + fileSize: 30 * 1024 * 1024, + files: 1 + } +}); app.use(cors()); app.use(express.json({ limit: "5mb" })); @@ -34,29 +46,38 @@ app.get("/api/health", (_req, res) => { app.post("/api/decks", async (req, res, next) => { try { const body = CreateDeckRequestSchema.parse(req.body); - const id = nanoid(10); - const stylePath = await resolveStylePath(body.style); - const style = await loadStylePack(stylePath); - const deck = planDeckFromSource({ + const result = await createDeckArtifacts({ source: body.input, - style, instructions: body.instructions, - audience: body.audience + audience: body.audience, + styleId: body.style }); - const jobDir = path.join(outputDir, id); - const specPath = path.join(jobDir, "deck.json"); - const pptxPath = path.join(jobDir, "deck.pptx"); - await mkdir(jobDir, { recursive: true }); - await writeFile(specPath, `${JSON.stringify(deck, null, 2)}\n`); - await renderPptx({ deck, style, outputPath: pptxPath }); + res.status(201).json(result); + } catch (error) { + next(error); + } +}); + +app.post("/api/decks/from-source", upload.single("source"), async (req, res, next) => { + try { + const file = req.file; + const pastedText = typeof req.body.content === "string" ? req.body.content : ""; + const source = file ? await sourceFromUpload(file) : sourceFromText(pastedText); + const result = await createDeckArtifacts({ + source, + instructions: stringField(req.body.instructions), + audience: stringField(req.body.audience) || "executives", + styleId: stringField(req.body.style) || "incorta" + }); res.status(201).json({ - id, - title: deck.title, - slides: deck.slides.length, - specUrl: `/outputs/${id}/deck.json`, - pptxUrl: `/outputs/${id}/deck.pptx` + ...result, + source: { + title: source.title, + type: source.type, + sourceName: source.sourceName + } }); } catch (error) { next(error); @@ -86,6 +107,251 @@ async function resolveStylePath(styleId: string): Promise { return direct; } +async function createDeckArtifacts(options: { + source: SourceDocument; + instructions?: string; + audience: string; + styleId: string; +}) { + const id = nanoid(10); + const stylePath = await resolveStylePath(options.styleId); + const style = await loadStylePack(stylePath); + const deck = planDeckFromSource({ + source: options.source, + style, + instructions: options.instructions, + audience: options.audience + }); + + const jobDir = path.join(outputDir, id); + const specPath = path.join(jobDir, "deck.json"); + const pptxPath = path.join(jobDir, "deck.pptx"); + await mkdir(jobDir, { recursive: true }); + await writeFile(specPath, `${JSON.stringify(deck, null, 2)}\n`); + await renderPptx({ deck, style, outputPath: pptxPath }); + + return { + id, + title: deck.title, + slides: deck.slides.length, + specUrl: `/outputs/${id}/deck.json`, + pptxUrl: `/outputs/${id}/deck.pptx` + }; +} + +function sourceFromText(content: string): SourceDocument { + const trimmed = content.trim(); + if (!trimmed) { + throw new Error("Provide source text or upload a file."); + } + return { + type: "markdown", + title: deriveTitle(trimmed), + content: trimmed, + sourceName: "pasted-source.md" + }; +} + +async function sourceFromUpload(file: Express.Multer.File): Promise { + const extension = path.extname(file.originalname).toLowerCase(); + const mime = file.mimetype; + + if (extension === ".md" || extension === ".markdown") { + const content = file.buffer.toString("utf8"); + return { + type: "markdown", + title: deriveTitle(content) || baseName(file.originalname), + content, + sourceName: file.originalname + }; + } + + if (extension === ".txt" || mime.startsWith("text/")) { + const content = file.buffer.toString("utf8"); + return { + type: "text", + title: deriveTitle(content) || baseName(file.originalname), + content, + sourceName: file.originalname + }; + } + + if (extension === ".pdf" || mime === "application/pdf") { + const content = await extractPdfText(file.buffer); + if (!content) throw new Error("No text could be extracted from the PDF."); + return { + type: "pdf", + title: baseName(file.originalname), + content, + sourceName: file.originalname + }; + } + + if (extension === ".pptx" || mime.includes("presentation")) { + const content = await extractPptxText(file.buffer); + if (!content.trim()) throw new Error("No text could be extracted from the slide deck."); + return { + type: "pptx", + title: baseName(file.originalname), + content, + sourceName: file.originalname + }; + } + + if (mime.startsWith("image/")) { + return { + type: "text", + title: baseName(file.originalname), + sourceName: file.originalname, + content: [ + `# ${baseName(file.originalname)}`, + "", + "Image source uploaded.", + "", + `File name: ${file.originalname}`, + `Image type: ${mime}`, + `File size: ${Math.round(file.size / 1024)} KB`, + "", + "Vision/OCR is not enabled in this local MVP yet. Use the instructions field to describe the slide goal, audience, and what the image should support." + ].join("\n") + }; + } + + throw new Error(`Unsupported source type: ${file.originalname || mime}`); +} + +async function extractPdfText(buffer: Buffer): Promise { + try { + const parsed = await pdfParse(buffer); + const content = parsed.text.trim(); + if (content) return content; + } catch { + // Fall through to a lightweight stream extractor for simple PDFs. + } + + return extractPdfTextFromStreams(buffer).trim(); +} + +function extractPdfTextFromStreams(buffer: Buffer): string { + const pdf = buffer.toString("latin1"); + const chunks: string[] = []; + const streamPattern = /<<(.*?)>>\s*stream\r?\n([\s\S]*?)\r?\nendstream/g; + let match: RegExpExecArray | null; + while ((match = streamPattern.exec(pdf))) { + const dictionary = match[1] || ""; + const raw = Buffer.from(match[2] || "", "latin1"); + let stream = raw; + if (dictionary.includes("/FlateDecode")) { + try { + stream = inflateSync(raw); + } catch { + continue; + } + } + const text = extractPdfStrings(stream.toString("latin1")).join(" "); + if (text) chunks.push(text); + } + return chunks.join("\n\n").replace(/\s+/g, " ").trim(); +} + +function extractPdfStrings(stream: string): string[] { + const values: string[] = []; + const literalPattern = /\((?:\\.|[^\\)])*\)/g; + for (const match of stream.matchAll(literalPattern)) { + const value = decodePdfLiteral(match[0].slice(1, -1)); + if (value.trim()) values.push(value.trim()); + } + const hexPattern = /<([0-9A-Fa-f\s]{4,})>/g; + for (const match of stream.matchAll(hexPattern)) { + const value = decodePdfHex(match[1] || ""); + if (value.trim()) values.push(value.trim()); + } + return values; +} + +function decodePdfLiteral(value: string): string { + return value + .replace(/\\n/g, "\n") + .replace(/\\r/g, "\r") + .replace(/\\t/g, "\t") + .replace(/\\b/g, "\b") + .replace(/\\f/g, "\f") + .replace(/\\([()\\])/g, "$1") + .replace(/\\([0-7]{1,3})/g, (_match, octal: string) => String.fromCharCode(Number.parseInt(octal, 8))); +} + +function decodePdfHex(value: string): string { + const normalized = value.replace(/\s+/g, ""); + const bytes = (normalized.match(/.{1,2}/g) || []).map((byte) => Number.parseInt(byte.padEnd(2, "0"), 16)); + if (bytes[0] === 0xfe && bytes[1] === 0xff) { + const chars: string[] = []; + for (let index = 2; index + 1 < bytes.length; index += 2) { + chars.push(String.fromCharCode((bytes[index] << 8) + bytes[index + 1])); + } + return chars.join(""); + } + return Buffer.from(bytes).toString("latin1"); +} + +async function extractPptxText(buffer: Buffer): Promise { + const zip = await JSZip.loadAsync(buffer); + const parser = new XMLParser({ + ignoreAttributes: true, + textNodeName: "#text" + }); + const slideFiles = Object.keys(zip.files) + .filter((name) => /^ppt\/slides\/slide\d+\.xml$/.test(name)) + .sort((a, b) => slideNumber(a) - slideNumber(b)); + + const slides: string[] = []; + for (const fileName of slideFiles) { + const xml = await zip.file(fileName)?.async("string"); + if (!xml) continue; + const parsed = parser.parse(xml); + const text = collectText(parsed) + .map((value) => value.trim()) + .filter(Boolean) + .join(" "); + if (text) { + slides.push(`## Slide ${slideNumber(fileName)}\n\n${text}`); + } + } + + return slides.join("\n\n"); +} + +function collectText(value: unknown): string[] { + if (typeof value === "string") return [value]; + if (Array.isArray(value)) return value.flatMap(collectText); + if (!value || typeof value !== "object") return []; + const record = value as Record; + return Object.entries(record).flatMap(([key, child]) => { + if (key === "a:t" || key === "#text") return collectText(child); + return collectText(child); + }); +} + +function slideNumber(fileName: string): number { + return Number(/slide(\d+)\.xml$/.exec(fileName)?.[1] || "0"); +} + +function deriveTitle(content: string): string | undefined { + return content + .split(/\r?\n/) + .map((line) => line.trim()) + .find((line) => line.startsWith("# ")) + ?.replace(/^#\s+/, "") + .trim(); +} + +function baseName(fileName: string): string { + return path.basename(fileName, path.extname(fileName)).replace(/[-_]+/g, " "); +} + +function stringField(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + async function exists(targetPath: string): Promise { try { await access(targetPath); diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 022c16b..0c9685a 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,6 +1,6 @@ -import React, { useState } from "react"; +import React, { DragEvent, useMemo, useRef, useState } from "react"; import { createRoot } from "react-dom/client"; -import { Download, FileText, WandSparkles } from "lucide-react"; +import { Download, FileText, Image as ImageIcon, Loader2, Paperclip, Upload, WandSparkles, X } from "lucide-react"; import "./styles.css"; type DeckResult = { @@ -9,29 +9,71 @@ type DeckResult = { slides: number; specUrl: string; pptxUrl: string; + source?: { + title?: string; + type?: string; + sourceName?: string; + }; }; +const initialContent = `# Customer Discovery Notes + +Paste notes, transcript excerpts, or brief content here. + +- Confirm the top decisions. +- Build an executive narrative. +- Render a PPTX for review.`; + function App() { - const [content, setContent] = useState(`# Customer Discovery Notes\n\nPaste notes, transcript excerpts, or brief content here.\n\n- Confirm the top decisions.\n- Build an executive narrative.\n- Render a PPTX for review.`); + const [content, setContent] = useState(initialContent); const [instructions, setInstructions] = useState("Create a concise executive deck."); + const [file, setFile] = useState(null); + const [previewUrl, setPreviewUrl] = useState(null); + const [dragging, setDragging] = useState(false); const [result, setResult] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); + const fileInputRef = useRef(null); + + const sourceLabel = useMemo(() => { + if (file) return `${file.name} ยท ${formatBytes(file.size)}`; + const chars = content.trim().length; + return chars ? `${chars.toLocaleString()} characters pasted` : "No source selected"; + }, [content, file]); + + function setSourceFile(nextFile: File | null) { + if (previewUrl) URL.revokeObjectURL(previewUrl); + setFile(nextFile); + setResult(null); + setError(null); + if (nextFile?.type.startsWith("image/")) { + setPreviewUrl(URL.createObjectURL(nextFile)); + } else { + setPreviewUrl(null); + } + } + + function onDrop(event: DragEvent) { + event.preventDefault(); + setDragging(false); + const dropped = event.dataTransfer.files?.[0]; + if (dropped) setSourceFile(dropped); + } + + async function onPaste(event: React.ClipboardEvent) { + const image = Array.from(event.clipboardData.files).find((item) => item.type.startsWith("image/")); + if (image) { + event.preventDefault(); + setSourceFile(image); + } + } async function createDeck() { setBusy(true); setError(null); setResult(null); try { - const response = await fetch("/api/decks", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - style: "incorta", - instructions, - input: { type: "markdown", content } - }) - }); + const response = file ? await createDeckFromFile(file) : await createDeckFromText(); const json = await response.json(); if (!response.ok) throw new Error(json.error || "Deck generation failed"); setResult(json); @@ -42,42 +84,114 @@ function App() { } } + function createDeckFromFile(sourceFile: File) { + const formData = new FormData(); + formData.set("source", sourceFile); + formData.set("style", "incorta"); + formData.set("audience", "executives"); + formData.set("instructions", instructions); + return fetch("/api/decks/from-source", { + method: "POST", + body: formData + }); + } + + function createDeckFromText() { + return fetch("/api/decks", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + style: "incorta", + instructions, + input: { type: "markdown", content } + }) + }); + } + return (

Slide Factory

-

Deck builder

+

Source to deck

-
-
- +
+
+ -