Add source upload deck starter

This commit is contained in:
Codex 2026-06-08 22:09:12 -07:00
parent eb0a17f75e
commit 2501e86a80
6 changed files with 741 additions and 69 deletions

View file

@ -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.

View file

@ -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"
}
}

View file

@ -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<string> {
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<SourceDocument> {
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<string> {
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<string> {
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<string, unknown>;
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<boolean> {
try {
await access(targetPath);

View file

@ -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<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [dragging, setDragging] = useState(false);
const [result, setResult] = useState<DeckResult | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(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<HTMLLabelElement>) {
event.preventDefault();
setDragging(false);
const dropped = event.dataTransfer.files?.[0];
if (dropped) setSourceFile(dropped);
}
async function onPaste(event: React.ClipboardEvent<HTMLTextAreaElement>) {
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 (
<main className="app-shell">
<section className="workspace">
<header className="topbar">
<div>
<p className="eyebrow">Slide Factory</p>
<h1>Deck builder</h1>
<h1>Source to deck</h1>
</div>
<button onClick={createDeck} disabled={busy}>
<WandSparkles size={18} />
{busy ? "Building" : "Build"}
<button className="primary-action" onClick={createDeck} disabled={busy}>
{busy ? <Loader2 className="spin" size={18} /> : <WandSparkles size={18} />}
{busy ? "Building" : "Build deck"}
</button>
</header>
<div className="grid">
<label className="field">
<span>Instructions</span>
<input value={instructions} onChange={(event) => setInstructions(event.target.value)} />
</label>
<div className="main-grid">
<section className="source-pane">
<label
className={`drop-zone ${dragging ? "is-dragging" : ""}`}
onDragOver={(event) => {
event.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={onDrop}
>
<input
ref={fileInputRef}
type="file"
accept=".md,.markdown,.txt,.pdf,.pptx,image/*"
onChange={(event) => setSourceFile(event.target.files?.[0] || null)}
/>
<Upload size={20} />
<span>Drop source</span>
<button type="button" className="secondary-action" onClick={() => fileInputRef.current?.click()}>
<Paperclip size={16} />
Choose file
</button>
</label>
<label className="field content">
<span>Source Content</span>
<textarea value={content} onChange={(event) => setContent(event.target.value)} />
</label>
<div className="source-status">
<span>{sourceLabel}</span>
{file && (
<button type="button" className="icon-action" onClick={() => setSourceFile(null)} aria-label="Clear file">
<X size={16} />
</button>
)}
</div>
{previewUrl && (
<div className="image-preview">
<img src={previewUrl} alt="" />
</div>
)}
<label className="field">
<span>Instructions</span>
<input value={instructions} onChange={(event) => setInstructions(event.target.value)} />
</label>
<label className="field content">
<span>Paste source</span>
<textarea
value={content}
onPaste={onPaste}
onChange={(event) => {
setContent(event.target.value);
if (file) setSourceFile(null);
}}
/>
</label>
</section>
<aside className="result-panel">
<div className="result-title">
<FileText size={18} />
{file?.type.startsWith("image/") ? <ImageIcon size={18} /> : <FileText size={18} />}
<span>Output</span>
</div>
{error && <p className="error">{error}</p>}
{!result && !error && <p className="muted">Build a deck to get download links.</p>}
{!result && !error && <p className="muted">Ready for a source.</p>}
{result && (
<div className="result">
<strong>{result.title}</strong>
<span>{result.slides} slides</span>
{result.source?.sourceName && <span className="source-chip">{result.source.sourceName}</span>}
<a href={result.pptxUrl}>
<Download size={16} />
PPTX
@ -92,9 +206,14 @@ function App() {
);
}
function formatBytes(value: number) {
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${Math.round(value / 1024)} KB`;
return `${(value / 1024 / 1024).toFixed(1)} MB`;
}
createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View file

@ -1,6 +1,6 @@
:root {
color: #172033;
background: #f5f7fb;
background: #f4f6fa;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
@ -18,13 +18,17 @@ textarea {
font: inherit;
}
button {
cursor: pointer;
}
.app-shell {
min-height: 100vh;
padding: 24px;
}
.workspace {
max-width: 1180px;
max-width: 1200px;
margin: 0 auto;
}
@ -33,7 +37,7 @@ textarea {
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 20px;
margin-bottom: 18px;
}
.eyebrow {
@ -49,30 +53,87 @@ h1 {
font-size: 28px;
}
button {
.primary-action,
.secondary-action,
.icon-action {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 40px;
border: 0;
border-radius: 6px;
border: 0;
}
.primary-action {
min-height: 40px;
padding: 0 16px;
color: #fff;
background: #172033;
cursor: pointer;
}
button:disabled {
.primary-action:disabled {
opacity: 0.65;
cursor: wait;
}
.grid {
.secondary-action {
min-height: 34px;
padding: 0 12px;
color: #172033;
background: #fff;
border: 1px solid #d8dee9;
}
.icon-action {
width: 30px;
height: 30px;
color: #596579;
background: #fff;
border: 1px solid #d8dee9;
}
.main-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: 16px;
}
.source-pane {
display: grid;
gap: 12px;
}
.drop-zone {
display: grid;
grid-template-columns: auto auto 1fr auto;
align-items: center;
gap: 10px;
min-height: 68px;
padding: 14px;
border: 1px dashed #aab4c4;
border-radius: 8px;
background: #fff;
}
.drop-zone input {
display: none;
}
.drop-zone.is-dragging {
border-color: #f05a28;
background: #fff7f3;
}
.source-status {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 38px;
padding: 0 4px;
color: #596579;
font-size: 13px;
}
.field {
display: flex;
flex-direction: column;
@ -87,7 +148,8 @@ button:disabled {
input,
textarea,
.result-panel {
.result-panel,
.image-preview {
border: 1px solid #d8dee9;
border-radius: 8px;
background: #fff;
@ -98,21 +160,31 @@ input {
padding: 0 12px;
}
.content {
grid-column: 1;
}
textarea {
min-height: 520px;
min-height: 390px;
padding: 14px;
resize: vertical;
line-height: 1.45;
}
.image-preview {
display: flex;
align-items: center;
justify-content: center;
min-height: 220px;
overflow: hidden;
}
.image-preview img {
display: block;
max-width: 100%;
max-height: 340px;
object-fit: contain;
}
.result-panel {
grid-column: 2;
grid-row: 1 / span 2;
padding: 16px;
min-height: 420px;
}
.result-title {
@ -135,6 +207,18 @@ textarea {
gap: 10px;
}
.source-chip {
display: inline-flex;
width: fit-content;
max-width: 100%;
padding: 4px 8px;
border-radius: 999px;
color: #596579;
background: #f4f6fa;
font-size: 12px;
overflow-wrap: anywhere;
}
.result a {
display: inline-flex;
align-items: center;
@ -144,13 +228,31 @@ textarea {
text-decoration: none;
}
.spin {
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 860px) {
.grid {
.app-shell {
padding: 16px;
}
.main-grid {
grid-template-columns: 1fr;
}
.result-panel {
grid-column: 1;
grid-row: auto;
.drop-zone {
grid-template-columns: auto 1fr;
}
.drop-zone .secondary-action {
grid-column: 1 / -1;
justify-self: start;
}
}

163
package-lock.json generated
View file

@ -30,13 +30,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"
}
},
@ -1264,6 +1270,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/multer": {
"version": "1.4.13",
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.13.tgz",
"integrity": "sha512-bhhdtPw7JqCiEfC9Jimx5LqX9BDIPJEh2q/fQ4bqbBPtyEZYr3cvF22NwG0DmPZNYA0CAf2CnqDB4KIGGpJcaw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/node": {
"version": "20.19.42",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.42.tgz",
@ -1274,6 +1290,16 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/pdf-parse": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@types/pdf-parse/-/pdf-parse-1.1.5.tgz",
"integrity": "sha512-kBfrSXsloMnUJOKi25s3+hRmkycHfLK6A09eRGqF/N8BkQoPUmaCr+q8Cli5FnfohEz/rsv82zAiPz/LXtOGhA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@ -1382,6 +1408,12 @@
"node": ">= 0.6"
}
},
"node_modules/append-field": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
"license": "MIT"
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@ -1472,6 +1504,23 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT"
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@ -1539,6 +1588,35 @@
"node": ">=18"
}
},
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 6.0"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/concat-stream/node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@ -1838,6 +1916,24 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/fast-xml-parser": {
"version": "4.5.6",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.6.tgz",
"integrity": "sha512-Yd4vkROfJf8AuJrDIVMVmYfULKmIJszVsMv7Vo71aocsKgFxpdlpSHXSaInvyYfgw2PRuObQSW2GFpVMUjxu9A==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"strnum": "^1.0.5"
},
"bin": {
"fxparser": "src/cli/cli.js"
}
},
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
@ -2230,6 +2326,25 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/multer": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
"integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.6.0",
"concat-stream": "^2.0.0",
"type-is": "^1.6.18"
},
"engines": {
"node": ">= 10.16.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/nanoid": {
"version": "5.1.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.11.tgz",
@ -2257,6 +2372,12 @@
"node": ">= 0.6"
}
},
"node_modules/node-ensure": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/node-ensure/-/node-ensure-0.0.0.tgz",
"integrity": "sha512-DRI60hzo2oKN1ma0ckc6nQWlHU69RH6xN0sjQTjMpChPfTYvKZdcQFfdYK2RWbJcKyUizSIy/l8OTGxMAM1QDw==",
"license": "MIT"
},
"node_modules/node-releases": {
"version": "2.0.47",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz",
@ -2320,6 +2441,22 @@
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
},
"node_modules/pdf-parse": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-1.1.4.tgz",
"integrity": "sha512-XRIRcLgk6ZnUbsHsYXExMw+krrPE81hJ6FQPLdBNhhBefqIQKXu/WeTgNBGSwPrfU0v+UCEwn7AoAUOsVKHFvQ==",
"license": "MIT",
"dependencies": {
"node-ensure": "^0.0.0"
},
"engines": {
"node": ">=6.8.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/mehmet-kozan"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -2765,6 +2902,14 @@
"node": ">= 0.8"
}
},
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
@ -2780,6 +2925,18 @@
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/strnum": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz",
"integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@ -2820,6 +2977,12 @@
"node": ">= 0.6"
}
},
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",