# Format Conversion Ecosystem **Project:** Nexus v1.7 — supplemental research for two-tier format conversion system **Researched:** 2026-04-04 **Scope:** Direct conversion tools, format registry pattern, AI-bridged conversion boundary, UI patterns, security and performance pitfalls **Confidence:** HIGH for tool choices, MEDIUM for version numbers (npm registry cross-checked) --- ## Context: What Is Already Available These are confirmed installed and must not be re-added: | Package | Location | Version | Relevant To Conversion | |---------|----------|---------|----------------------| | `sharp ^0.34.5` | `server/` | 0.34.5 | Raster image conversion (resize, format, SVG→PNG) | | `ffmpeg-static ^5.3.0` | `server/` | 5.3.0 | Audio/video conversion binary | | `mermaid ^11.12.0` | `ui/` | 11.12.0 | Client-side Mermaid rendering | | `playwright-chromium ^1.50.0` | `server/` | 1.50.0 | HTML→PDF already decided in STACK.md | --- ## Tier 1: Direct Conversion Tools ### Image Formats **Primary: `sharp ^0.34.5` (already installed)** Sharp handles the majority of image format pairs without any additional dependency: | Source | Target | Method | |--------|--------|--------| | JPEG/PNG/WebP/AVIF/TIFF/GIF | Any raster | `sharp(input).toFormat('webp').toBuffer()` | | SVG | PNG/JPEG/WebP | `sharp(svgBuffer).png().toBuffer()` — libvips handles SVG via librsvg | | PNG/JPEG | WebP/AVIF | `sharp(input).webp({ quality: 80 }).toBuffer()` | **Important SVG caveat:** sharp's SVG→PNG conversion uses librsvg. It works well for most SVGs but does NOT support all CSS features. For agent-generated SVGs with embedded fonts (produced by `satori`), use `@resvg/resvg-js` as specified in STACK.md. For user-uploaded SVGs without special fonts, `sharp` is sufficient. **No ImageMagick needed.** ImageMagick via CLI or WASM adds complexity: - `imagemagick` npm is an unmaintained CLI wrapper (last release 2020) - WASM ImageMagick (`@imagemagick/magick-wasm`) works but runs at ~0.3× native speed - `sharp` via libvips is 4–5× faster than ImageMagick for every supported format pair - For the format pairs Nexus needs (JPEG, PNG, WebP, AVIF, TIFF, SVG→raster), sharp covers everything **No Inkscape needed** for Nexus's scope (vector conversion beyond SVG→PNG is out of scope for v1.7). --- ### Audio / Video Formats **Wrapper: `fluent-ffmpeg ^2.1.3`** **Important maintenance note:** The `fluent-ffmpeg` repository was archived on May 22, 2025. The package is no longer receiving new features. However: - It remains published on npm and functional with Node.js >=18 - The `ffmpeg-static ^5.3.0` binary it wraps is still actively maintained - `@types/fluent-ffmpeg ^2.1.28` provides TypeScript types (last updated October 2025) - For Nexus's use case (spawn ffmpeg with known args), the archived state is low risk - Alternative: write a thin `child_process.spawn` wrapper directly — this is ~30 lines and removes the archived dependency entirely **Recommendation for Nexus:** Implement a minimal `ffmpegConvert(inputPath, outputPath, extraArgs)` wrapper using `child_process.spawn` instead of taking on the archived `fluent-ffmpeg`. The full fluent API is unnecessary — format conversion is a single `ffmpeg -i input.mp4 output.webm` call. ```typescript // server/src/services/converters/ffmpeg-converter.ts import { spawn } from "child_process"; import ffmpegPath from "ffmpeg-static"; export function ffmpegConvert( inputPath: string, outputPath: string, extraArgs: string[] = [] ): Promise { return new Promise((resolve, reject) => { const proc = spawn(ffmpegPath!, [ "-i", inputPath, ...extraArgs, "-y", // overwrite output outputPath, ]); proc.on("close", (code) => code === 0 ? resolve() : reject(new Error(`ffmpeg exited ${code}`)) ); proc.stderr.on("data", () => {}); // consume stderr to prevent backpressure }); } ``` **Format coverage via ffmpeg-static:** | Source | Target | Extra args | |--------|--------|-----------| | MP4/MKV/AVI/MOV | WebM | `["-c:v", "libvpx-vp9", "-c:a", "libopus"]` | | MP4/WebM | MP3/AAC | `["-vn", "-c:a", "libmp3lame"]` (audio extract) | | MP3/WAV/FLAC/OGG | MP3 | `["-c:a", "libmp3lame", "-b:a", "192k"]` | | MP3/WAV/OGG | WAV | `["-c:a", "pcm_s16le"]` | | Image sequence | MP4 | `["-r", "30", "-c:v", "libx264"]` | | MP4 | GIF | `["-vf", "fps=10,scale=640:-1:flags=lanczos"]` | | Any video | Audio-only MP3 | `["-vn"]` | --- ### Documents #### DOCX to HTML: `mammoth ^1.12.0` Mammoth converts `.docx` → HTML with semantic preservation. It does NOT create DOCX. ```bash pnpm --filter @paperclipai/server add mammoth ``` ```typescript import mammoth from "mammoth"; const { value: html } = await mammoth.convertToHtml({ path: docxPath }); // html is a clean HTML string; images are embedded as base64 data URIs by default ``` **Why mammoth over pandoc for DOCX→HTML:** Mammoth preserves heading hierarchy, tables, lists, and images correctly. Its output is cleaner HTML than pandoc's for Word documents. Single-purpose library, no system binary required. **TypeScript types:** Included in the package since v1.7 (`@types/mammoth` not needed). **Confidence: HIGH** — official npm, v1.12.0, actively maintained (last publish 20 days ago). --- #### Markdown → DOCX / PDF / HTML: Pandoc (system binary + thin wrapper) **Integration approach:** Pandoc is a Haskell binary — there is no pure-Node.js implementation. Existing Node.js wrappers are thin `child_process` shims: - `node-pandoc ^0.2.7` — 13K weekly downloads, most popular, but last updated 2021 - `pandoc-ts` — TypeScript wrapper, smaller community - **Recommended: write a 20-line `child_process.spawn` wrapper** — same as ffmpeg approach ```typescript // server/src/services/converters/pandoc-converter.ts import { spawn } from "child_process"; export function pandocConvert( inputPath: string, outputPath: string, from: string, to: string ): Promise { return new Promise((resolve, reject) => { const proc = spawn("pandoc", [ inputPath, "-f", from, "-t", to, "-o", outputPath, ]); proc.on("close", (code) => code === 0 ? resolve() : reject(new Error(`pandoc exited ${code}`)) ); }); } ``` **Supported format pairs via pandoc:** | Source | Target | |--------|--------| | Markdown | DOCX, HTML, RST, LaTeX, EPUB | | RST | Markdown, HTML, DOCX | | HTML | Markdown, DOCX | | LaTeX | HTML, Markdown | | DOCX | Markdown (lossier than mammoth for HTML) | **System dependency:** pandoc must be installed on the Mac Mini. Install via `brew install pandoc`. Check at server startup with `which pandoc`; if absent, degrade gracefully. **Confidence: HIGH** — pandoc is the de-facto standard; brew install is 1 command; child_process wrapper is trivial. --- #### DOCX / ODT / PPTX → PDF: LibreOffice headless **Package: `libreoffice-convert ^1.8.1`** ```bash pnpm --filter @paperclipai/server add libreoffice-convert # System dependency: brew install --cask libreoffice ``` ```typescript import { convertAsync } from "libreoffice-convert"; import fs from "fs/promises"; const inputBuffer = await fs.readFile(docxPath); const pdfBuffer = await convertAsync(inputBuffer, ".pdf", undefined); ``` **Format pairs:** | Source | Target | |--------|--------| | DOCX / DOC | PDF, ODT, HTML | | PPTX / PPT | PDF, ODP | | XLSX / XLS | PDF, ODS, CSV | | ODT / ODP / ODS | PDF, DOCX, PPTX | **Performance warning:** LibreOffice launches a JVM-equivalent runtime on first call. Cold start: ~3-5 seconds. Warm subsequent calls: ~500ms. Serialize LibreOffice jobs (no concurrent renders) — run maximum one at a time. **System dependency:** LibreOffice must be installed at `/Applications/LibreOffice.app` on macOS. Check at server startup; degrade gracefully if absent. **Confidence: MEDIUM** — package v1.8.1 confirmed. macOS arm64 LibreOffice runs natively on M4 (confirmed via LibreOffice download page). Single source for LibreOffice npm package maintenance status. --- #### HTML → PDF: playwright-chromium (already decided in STACK.md) Use `playwright-chromium ^1.50.0` for HTML→PDF. Already researched; do not re-add. --- ### Data Formats #### Spreadsheets: `xlsx` (SheetJS Community Edition) **Package: `xlsx ^0.20.x`** (SheetJS Community Edition) ```bash pnpm --filter @paperclipai/server add xlsx ``` **Format coverage:** | Source | Target | Method | |--------|--------|--------| | XLSX / XLS / ODS | CSV | `XLSX.utils.sheet_to_csv(ws)` | | XLSX / XLS / ODS | JSON | `XLSX.utils.sheet_to_json(ws)` | | CSV / JSON | XLSX | `XLSX.utils.json_to_sheet(data)` → `XLSX.writeFile(wb, path)` | SheetJS has ~7.8M weekly downloads and handles all Excel formats including legacy `.xls`. It is the default choice with no alternatives needed for basic spreadsheet conversion. **Licensing note:** SheetJS Community Edition is free (Apache 2.0 for historical versions; check current license at install time). SheetJS Pro adds streaming for very large files — not needed at single-user scale. **Confidence: MEDIUM-HIGH** — widely used, 7.8M weekly downloads confirmed. Version 0.20.x confirmed. License nuance warrants a check at install time. --- #### CSV Parsing: `csv-parse ^5.6.0` (part of the `csv` ecosystem) ```bash pnpm --filter @paperclipai/server add csv-parse ``` The `csv-parse ^6.2.1` package (latest as of April 2026) implements `stream.Transform` and supports both streaming and synchronous/callback modes. It includes TypeScript types. **When to use:** Parsing user-uploaded CSV files before transformation (CSV→JSON, CSV→XLSX). For generating CSV output from JSON/objects, use SheetJS or `csv-stringify` (same ecosystem as `csv-parse`). **Confidence: HIGH** — v6.2.1 confirmed from npm search. Maintained (last publish 4 days ago). --- #### JSON ↔ CSV: Use `csv-parse` + `csv-stringify` (not `json2csv`) The `json2csv` package is in maintenance mode at v6.0.0-alpha (3 years old). The `json-2-csv` package (v5.5.10, different package) is active but adds a dependency for something `csv-stringify` already handles. **Recommendation:** Use `csv-stringify ^6.x` (same ecosystem as `csv-parse`, same maintainer) for JSON→CSV. This avoids pulling in a separate package. ```bash pnpm --filter @paperclipai/server add csv-stringify ``` --- ### Code Formats #### Code Formatting (JS/TS/CSS/HTML): `prettier ^3.x` ```bash pnpm --filter @paperclipai/server add --save-dev prettier # For programmatic use in server: pnpm --filter @paperclipai/server add prettier ``` Prettier exposes a programmatic API: ```typescript import { format } from "prettier"; const formatted = await format(sourceCode, { parser: "typescript", // or "babel", "css", "html", "markdown", etc. semi: true, singleQuote: true, }); ``` **Use case:** Agent generates code → prettier formats it before saving. Also enables code→code conversions like "reformat this JSON" or "convert CommonJS to ESM style." **Confidence: HIGH** — Prettier API is well-documented at prettier.io/docs/api. --- #### TypeScript Type Generation (JSON Schema → TypeScript): `json-schema-to-typescript ^15.x` For the AI-bridged case where an agent converts a JSON schema into TypeScript type definitions, this library handles it deterministically: ```bash pnpm --filter @paperclipai/server add json-schema-to-typescript ``` ```typescript import { compile } from "json-schema-to-typescript"; const ts = await compile(jsonSchema, "MyType"); ``` **Confidence: MEDIUM** — widely used library; version verified as 15.x on npm (as of late 2025). Use for JSON Schema→TypeScript specifically; TypeScript→TypeScript reformatting uses the compiler API or prettier. --- ## Format Coverage Matrix | Source → | PNG | JPEG | WebP | AVIF | SVG | PDF | DOCX | XLSX | CSV | JSON | MP4 | MP3 | WebM | |----------|-----|------|------|------|-----|-----|------|------|-----|------|-----|-----|------| | PNG | — | sharp | sharp | sharp | — | playwright | — | — | — | — | — | — | — | | JPEG | sharp | — | sharp | sharp | — | playwright | — | — | — | — | — | — | — | | WebP | sharp | sharp | — | sharp | — | playwright | — | — | — | — | — | — | — | | SVG | sharp/@resvg | sharp/@resvg | sharp/@resvg | — | — | playwright | — | — | — | — | — | — | — | | DOCX | — | — | — | — | — | LibreOffice | — | — | — | — | — | — | — | | PPTX | — | — | — | — | — | LibreOffice | — | — | — | — | — | — | — | | XLSX/XLS | — | — | — | — | — | LibreOffice | — | — | SheetJS | SheetJS | — | — | — | | HTML | — | — | — | — | — | playwright | mammoth→† | — | — | — | — | — | — | | Markdown | — | — | — | — | — | pandoc | pandoc | — | — | — | — | — | — | | CSV | — | — | — | — | — | AI-bridged | — | SheetJS | — | csv-parse | — | — | — | | JSON | — | — | — | — | — | AI-bridged | — | SheetJS | csv-stringify | — | — | — | — | | MP4/MKV | — | — | — | — | — | — | — | — | — | — | — | ffmpeg | ffmpeg | | MP3/WAV | — | — | — | — | — | — | — | — | — | — | — | — | — | | WAV/OGG | — | — | — | — | — | — | — | — | — | — | — | ffmpeg | — | † HTML→DOCX requires pandoc (mammoth is one-way: DOCX→HTML only) **AI-bridged**: Format pairs without a deterministic tool path. See Tier 2 below. --- ## Tier 2: Format Registry Pattern ### Dispatch Table Design The registry is a map of `"source/target"` → handler function. This is simpler than a class hierarchy and matches the existing Nexus factory function pattern. ```typescript // server/src/services/converters/registry.ts export type ConversionHandler = ( inputPath: string, outputPath: string, opts?: Record ) => Promise; export type ConversionCapability = "direct" | "ai-bridged" | "unavailable"; export interface ConversionRoute { capability: ConversionCapability; handler?: ConversionHandler; // present when capability = "direct" aiHint?: string; // present when capability = "ai-bridged" requiresSystemDep?: string; // e.g. "pandoc", "libreoffice" } // Key format: "source.ext/target.ext" — always lowercase, no leading dot const registry = new Map(); export function registerConverter( sourceExt: string, targetExt: string, route: ConversionRoute ): void { registry.set(`${sourceExt}/${targetExt}`, route); } export function getConverter(sourceExt: string, targetExt: string): ConversionRoute { return registry.get(`${sourceExt}/${targetExt}`) ?? { capability: "unavailable" }; } export function listSupportedTargets(sourceExt: string): string[] { const results: string[] = []; for (const [key, route] of registry.entries()) { if (key.startsWith(`${sourceExt}/`) && route.capability !== "unavailable") { results.push(key.split("/")[1]); } } return results; } ``` **Registration (in server startup):** ```typescript // server/src/services/converters/index.ts import { registerConverter } from "./registry"; import { sharpConvert } from "./sharp-converter"; import { ffmpegConvert } from "./ffmpeg-converter"; import { pandocConvert } from "./pandoc-converter"; // Image registerConverter("png", "webp", { capability: "direct", handler: (i, o) => sharpConvert(i, o, "webp") }); registerConverter("jpg", "webp", { capability: "direct", handler: (i, o) => sharpConvert(i, o, "webp") }); registerConverter("svg", "png", { capability: "direct", handler: (i, o) => sharpConvert(i, o, "png") }); // Documents registerConverter("docx", "html", { capability: "direct", handler: mammothConvert, requiresSystemDep: undefined }); registerConverter("docx", "pdf", { capability: "direct", handler: libreofficeConvert, requiresSystemDep: "libreoffice" }); registerConverter("md", "docx", { capability: "direct", handler: (i, o) => pandocConvert(i, o, "markdown", "docx"), requiresSystemDep: "pandoc" }); // Data registerConverter("xlsx", "csv", { capability: "direct", handler: sheetjsConvert }); registerConverter("csv", "xlsx", { capability: "direct", handler: sheetjsConvert }); registerConverter("csv", "pdf", { capability: "ai-bridged", aiHint: "format as a formatted table report PDF" }); registerConverter("json", "pdf", { capability: "ai-bridged", aiHint: "render as a structured document report" }); // Audio/Video registerConverter("mp4", "webm", { capability: "direct", handler: (i, o) => ffmpegConvert(i, o, ["-c:v", "libvpx-vp9"]) }); registerConverter("mp3", "wav", { capability: "direct", handler: (i, o) => ffmpegConvert(i, o, ["-c:a", "pcm_s16le"]) }); ``` **Extensibility:** Adding a new format pair is one `registerConverter()` call. The route handler and the registry are decoupled. System dependency checks happen at `registerConverter()` time, not at request time — unavailable converters are registered as `capability: "unavailable"` when their system dep is absent. --- ## Tier 2: AI-Bridged Conversion ### When to Use AI (vs Direct Tool) | Criterion | Direct Tool | AI-Bridged | |-----------|-------------|------------| | Output is byte-for-byte deterministic | Yes | No | | Format pair has an established tool | Yes | No | | Conversion is purely structural (no semantic change) | Yes | — | | Conversion requires understanding content meaning | — | Yes | | Source format is machine-readable but lacks a direct path | — | Yes | | Example pairs | PNG→WebP, DOCX→PDF, MP4→WebM | CSV→PDF report, JSON→DOCX narrative, schema→TypeScript | **Decision rule:** > Use direct tool when: `getConverter(src, tgt).capability === "direct"`. > Use AI when: capability is `"ai-bridged"` AND the source is a text/data format that an LLM can read as context. > Return `"unavailable"` error when: capability is `"unavailable"` (binary formats with no path, e.g. PDF→XLSX). **AI-bridged is NOT a fallback for when a tool is missing.** If LibreOffice is not installed, DOCX→PDF is `"unavailable"`, not `"ai-bridged"`. AI-bridged is only for semantically complex conversions where no deterministic tool exists. --- ### AI-Bridged Prompt Structure The prompt must be deterministic in its output format requirements. Vague prompts produce vague output. ```typescript // server/src/services/converters/ai-bridged-converter.ts export async function aiBridgedConvert( sourceExt: string, targetExt: string, sourceContent: string, // text content of the source file outputPath: string, aiHint: string, // from registry route agentAdapter: AgentAdapter // existing adapter interface ): Promise { const prompt = buildConversionPrompt(sourceExt, targetExt, sourceContent, aiHint); const result = await agentAdapter.complete(prompt); await writeConversionResult(result, targetExt, outputPath); } function buildConversionPrompt( sourceExt: string, targetExt: string, sourceContent: string, aiHint: string ): string { const outputSpec = OUTPUT_SPEC[targetExt] ?? "the target format"; return `Convert the following ${sourceExt.toUpperCase()} content to ${targetExt.toUpperCase()}. Instruction: ${aiHint} Requirements: - Output ONLY the converted content, no explanation, no preamble - Output format: ${outputSpec} - Preserve all data values exactly; do not summarize or truncate Source content: \`\`\` ${sourceContent} \`\`\``; } const OUTPUT_SPEC: Record = { html: "Valid HTML5. No , no / wrapper. Only the content fragment.", md: "GitHub Flavored Markdown.", ts: "Valid TypeScript. No imports unless required. Export all types.", pdf: "HTML that will be rendered to PDF. Use inline