616 lines
30 KiB
Markdown
616 lines
30 KiB
Markdown
# Phase 43: Documents & Branding - Research
|
||
|
||
**Researched:** 2026-04-04
|
||
**Domain:** PDF generation via Playwright, brand identity kit assembly, ZIP packaging
|
||
**Confidence:** HIGH
|
||
|
||
---
|
||
|
||
<user_constraints>
|
||
## User Constraints (from CONTEXT.md)
|
||
|
||
### Locked Decisions
|
||
All implementation choices are at Claude's discretion — discuss phase was skipped per user setting.
|
||
|
||
### Claude's Discretion
|
||
All implementation choices are at Claude's discretion. Use ROADMAP phase goal, success criteria, and codebase conventions.
|
||
|
||
### Deferred Ideas (OUT OF SCOPE)
|
||
None — discuss phase skipped.
|
||
</user_constraints>
|
||
|
||
---
|
||
|
||
<phase_requirements>
|
||
## Phase Requirements
|
||
|
||
| ID | Description | Research Support |
|
||
|----|-------------|------------------|
|
||
| DOC-01 | User can generate formatted PDF reports from conversation content | Playwright page.pdf() with HTML-to-PDF approach; follows diagram-renderer.ts Playwright pattern |
|
||
| DOC-02 | User can generate invoices and contracts from templates | Template-driven HTML rendered to PDF via Playwright; pdf-lib for lightweight data-only invoices |
|
||
| DOC-03 | User can generate one-pagers and API documentation | LLM-generated HTML + CSS styled page → Playwright PDF; same pipeline as DOC-01 |
|
||
| BRAND-01 | User can generate a full brand identity from a single conversation | Multi-step LLM extraction: brand name/colors/typography → feeds each sub-renderer |
|
||
| BRAND-02 | System produces logo mark (SVG), avatar in multiple sizes | SVG logo via LLM + validateAndCleanSvg; sharp rasterizes to [512, 256, 128, 64, 32]px PNGs |
|
||
| BRAND-03 | System produces social media profile images and banners per platform | Re-uses PLATFORM_DIMENSIONS from wallpaper-renderer; logo composited onto brand-colored background |
|
||
| BRAND-04 | System produces email signature and letterhead templates | LLM generates HTML; stored as HTML string inside bundle + PNG preview via Playwright screenshot |
|
||
| BRAND-05 | System produces a brand guidelines document (PDF) | Playwright PDF of a styled brand guidelines HTML page generated by LLM |
|
||
| BRAND-06 | User can download all brand assets as a zip package | `archiver` v7 streams all bundle assets into a ZIP buffer returned as a single RenderResult |
|
||
</phase_requirements>
|
||
|
||
---
|
||
|
||
## Summary
|
||
|
||
Phase 43 adds two new job types — `pdf-document` (DOC-01..03) and `brand-kit` (BRAND-01..06) — to the existing content job pipeline. Both use infrastructure from Phase 40 (job store, SSE, asset storage) and follow the renderer pattern established in Phases 41-42.
|
||
|
||
PDF generation uses the Playwright Chromium browser already installed at `~/.cache/ms-playwright/chromium-1217/`. The `resolveBrowserPath()` function in `diagram-renderer.ts` is already in place and reusable. The pattern is: LLM generates an HTML page → Playwright renders it → `page.pdf()` outputs a PDF buffer. This sidesteps any native binary dependencies beyond the already-present Chromium.
|
||
|
||
Brand kit generation orchestrates multiple sub-renders — logo SVG, avatar PNGs, social images, email signature HTML, letterhead HTML, brand guidelines PDF — and then packages them with `archiver` into a single ZIP buffer. The ZIP is stored as a single generated asset; the UI fetches and triggers a browser download. `pdf-lib` is NOT needed for Phase 43 — Playwright PDF covers all three document types.
|
||
|
||
**Primary recommendation:** One `pdf-renderer.ts` for DOC-01..03 and one `brand-renderer.ts` for BRAND-01..06. Both follow the `renderX(input) → RenderResult` contract. Add two new `case` blocks in `content-job-runner.ts`. Add two new tabs to `ContentStudio.tsx`.
|
||
|
||
---
|
||
|
||
## Project Constraints (from CLAUDE.md)
|
||
|
||
No `CLAUDE.md` exists in the project root. Constraints are derived from codebase conventions documented in STATE.md:
|
||
|
||
- **Async job pattern is mandatory** — all render requests return 202 + job ID immediately; never block HTTP on render
|
||
- **sourceTaskId required** on every generated asset from day one
|
||
- **MAX_GENERATED_ASSET_BYTES** applies to all generated assets (bypasses 10MB upload limit for "generated" namespace)
|
||
- **Playwright Chromium** already decided for design-rich PDFs (confirmed in STATE.md blocker note: "Confirm pdf-lib scope: Playwright for design-rich PDFs, pdf-lib for data-driven invoices — decide at Phase 43 planning")
|
||
- **Renderer pattern**: `renderX(input: Record<string, unknown>): Promise<RenderResult>` — default export via dynamic import in `content-job-runner.ts`
|
||
- **Bundle pattern**: rich JSON blob stored as the RenderResult; UI fetches the asset URL, parses JSON, hydrates component
|
||
- **puterChatComplete** for all LLM calls; reads `PUTER_AUTH_TOKEN` from env
|
||
- **No new binary dependencies** beyond what is already installed (`sharp`, `svgo`, `playwright-core`, `@resvg/resvg-js`, `ffmpeg-static`)
|
||
- **TypeScript strict** — all new files need proper types
|
||
- **Test mocks**: `playwright-core` and `puter-inference.js` are always vi.mock()ed in tests
|
||
|
||
---
|
||
|
||
## Standard Stack
|
||
|
||
### Core (already installed — no new installs needed for PDF)
|
||
|
||
| Library | Version | Purpose | Why Standard |
|
||
|---------|---------|---------|--------------|
|
||
| `playwright-core` | 1.58.2 | HTML → PDF via Chromium headless | Already installed, `resolveBrowserPath()` written |
|
||
| `sharp` | ^0.34.5 | SVG → PNG rasterization for avatars and social images | Already in use (wallpaper-renderer.ts) |
|
||
| `svgo` | ^4.0.1 | SVG cleanup/validation | Already in use (icon-renderer.ts) |
|
||
| `@resvg/resvg-js` | ^2.6.2 | High-fidelity SVG → PNG for logo mark | Already in use (diagram-renderer.ts) |
|
||
|
||
### New Dependency: ZIP Packaging
|
||
|
||
| Library | Version | Purpose | Why |
|
||
|---------|---------|---------|-----|
|
||
| `archiver` | ^7.0.1 | Stream multiple buffers into a ZIP buffer | Well-maintained (MIT), streams-based, works entirely in memory via `archiver.finalize()` + collect into Buffer; no disk I/O |
|
||
|
||
**Installation (one new package):**
|
||
```bash
|
||
pnpm --filter @paperclipai/server add archiver
|
||
pnpm --filter @paperclipai/server add -D @types/archiver
|
||
```
|
||
|
||
**Version verification (npm registry, 2026-04-04):**
|
||
- `archiver`: 7.0.1 (latest) — confirmed
|
||
- `@types/archiver`: published alongside, v5.3.4
|
||
|
||
### Why NOT pdf-lib
|
||
|
||
STATE.md blocker note says "Confirm pdf-lib scope: Playwright for design-rich PDFs, pdf-lib for data-driven invoices — decide at Phase 43 planning."
|
||
|
||
**Decision: Use Playwright for all three doc types (DOC-01, DOC-02, DOC-03).**
|
||
|
||
Rationale:
|
||
- DOC-01 (reports), DOC-02 (invoices), DOC-03 (one-pagers) all need styled output — headings, tables, code blocks
|
||
- LLM can generate HTML + inline CSS in a single shot; Playwright renders it faithfully
|
||
- `pdf-lib` is excellent for programmatic PDF manipulation (merge, fill form fields) but poor for styled layout
|
||
- We already have Playwright Chromium; adding pdf-lib adds another package for no benefit
|
||
- Invoice "templates" work cleanly as HTML templates: LLM fills the line items, Playwright renders to PDF
|
||
|
||
### Alternatives Considered
|
||
|
||
| Instead of | Could Use | Tradeoff |
|
||
|------------|-----------|----------|
|
||
| Playwright HTML→PDF | `pdf-lib` | pdf-lib has no layout engine; styling complex reports requires hand-coding coordinates — far harder than HTML+CSS |
|
||
| archiver | `jszip` | jszip is promise-based but slower; archiver's streaming API handles large asset sets better |
|
||
| archiver | `adm-zip` | adm-zip v0.5 is synchronous; blocks event loop for large zips |
|
||
| LLM-generated SVG logo | Stable Diffusion / DALL-E | Out of scope per REQUIREMENTS.md |
|
||
|
||
---
|
||
|
||
## Architecture Patterns
|
||
|
||
### Recommended Project Structure
|
||
|
||
```
|
||
server/src/services/renderers/
|
||
├── pdf-renderer.ts # NEW — DOC-01, DOC-02, DOC-03
|
||
├── brand-renderer.ts # NEW — BRAND-01..06
|
||
├── diagram-renderer.ts # existing
|
||
├── icon-renderer.ts # existing
|
||
├── wallpaper-renderer.ts # existing
|
||
├── social-renderer.ts # existing
|
||
├── convert-renderer.ts # existing
|
||
└── types.ts # add PdfDocumentBundle + BrandKitBundle
|
||
|
||
ui/src/components/
|
||
├── DocumentGeneratePanel.tsx # NEW — DOC tab UI
|
||
├── BrandKitPanel.tsx # NEW — Brand tab UI
|
||
├── BrandKitResult.tsx # NEW — brand kit display + ZIP download trigger
|
||
└── ... (existing)
|
||
|
||
ui/src/pages/
|
||
└── ContentStudio.tsx # add "Documents" tab + "Brand" tab
|
||
```
|
||
|
||
### Pattern 1: Playwright PDF Renderer
|
||
|
||
Same structure as `diagram-renderer.ts` Playwright usage — launch browser from `resolveBrowserPath()`, create page, set HTML content, call `page.pdf()`, close browser.
|
||
|
||
```typescript
|
||
// server/src/services/renderers/pdf-renderer.ts
|
||
import { chromium } from "playwright-core";
|
||
import { resolveBrowserPath } from "./diagram-renderer.js";
|
||
import { puterChatComplete } from "../puter-inference.js";
|
||
import type { RenderResult, PdfDocumentBundle } from "./types.js";
|
||
|
||
export async function renderPdfDocument(
|
||
input: Record<string, unknown>,
|
||
): Promise<RenderResult> {
|
||
const docType = typeof input.docType === "string" ? input.docType : "report";
|
||
const prompt = typeof input.prompt === "string" ? input.prompt : "";
|
||
const title = typeof input.title === "string" ? input.title : "Document";
|
||
|
||
// LLM generates a complete, self-contained HTML document
|
||
const html = await puterChatComplete([
|
||
{ role: "system", content: buildPdfSystemPrompt(docType) },
|
||
{ role: "user", content: prompt },
|
||
]);
|
||
const cleanHtml = stripMarkdownFences(html);
|
||
|
||
const executablePath = resolveBrowserPath();
|
||
const browser = await chromium.launch({
|
||
executablePath,
|
||
headless: true,
|
||
args: ["--no-sandbox", "--disable-setuid-sandbox"],
|
||
});
|
||
|
||
let pdfBuffer: Buffer;
|
||
try {
|
||
const page = await browser.newPage();
|
||
await page.setContent(cleanHtml, { waitUntil: "networkidle" });
|
||
const pdfUint8 = await page.pdf({
|
||
format: "A4",
|
||
printBackground: true,
|
||
margin: { top: "20mm", bottom: "20mm", left: "20mm", right: "20mm" },
|
||
});
|
||
pdfBuffer = Buffer.from(pdfUint8);
|
||
} finally {
|
||
await browser.close();
|
||
}
|
||
|
||
const bundle: PdfDocumentBundle = {
|
||
type: "pdf-document-bundle",
|
||
docType,
|
||
title,
|
||
pdfBase64: pdfBuffer.toString("base64"),
|
||
};
|
||
|
||
return {
|
||
filename: `document-${docType}.json`,
|
||
contentType: "application/json",
|
||
buffer: Buffer.from(JSON.stringify(bundle)),
|
||
};
|
||
}
|
||
```
|
||
|
||
**Key Playwright PDF options:**
|
||
- `page.pdf({ format: "A4", printBackground: true })` — produces a Buffer (Uint8Array in Playwright v1.58)
|
||
- `waitUntil: "networkidle"` — ensures any web fonts / images finish before capture; use `"domcontentloaded"` as fallback for offline-only HTML
|
||
- Margin in mm units
|
||
- `printBackground: true` — needed for colored headers/footers in styled documents
|
||
|
||
### Pattern 2: Brand Kit Orchestration
|
||
|
||
Brand kit is a multi-step job: one LLM call extracts the brand specification, then sub-renderers produce each asset in sequence, then archiver packages everything.
|
||
|
||
```typescript
|
||
// server/src/services/renderers/brand-renderer.ts
|
||
|
||
interface BrandSpec {
|
||
name: string;
|
||
tagline: string;
|
||
primaryColor: string; // hex
|
||
secondaryColor: string; // hex
|
||
fontStyle: "sans" | "serif" | "mono";
|
||
logoDescription: string;
|
||
industry: string;
|
||
}
|
||
|
||
export async function renderBrandKit(
|
||
input: Record<string, unknown>,
|
||
): Promise<RenderResult> {
|
||
const prompt = typeof input.prompt === "string" ? input.prompt : "";
|
||
|
||
// Step 1: Extract brand specification
|
||
const spec = await extractBrandSpec(prompt);
|
||
|
||
// Step 2: Generate logo SVG
|
||
const logoSvg = await generateLogoSvg(spec);
|
||
|
||
// Step 3: Rasterize logo to avatar sizes [512, 256, 128, 64, 32]
|
||
const avatarPngs = await rasterizeAvatars(logoSvg);
|
||
|
||
// Step 4: Generate social platform images (profile + banner per platform)
|
||
const socialImages = await generateSocialImages(spec, logoSvg);
|
||
|
||
// Step 5: Generate email signature HTML + letterhead HTML
|
||
const { signature, letterhead } = await generateTemplates(spec, logoSvg);
|
||
|
||
// Step 6: Generate brand guidelines PDF via Playwright
|
||
const guidelinesPdf = await generateGuidelinesPdf(spec, logoSvg, signature);
|
||
|
||
// Step 7: Package everything into a ZIP
|
||
const zipBuffer = await buildZip({
|
||
logoSvg, avatarPngs, socialImages,
|
||
signature, letterhead, guidelinesPdf,
|
||
});
|
||
|
||
const bundle: BrandKitBundle = {
|
||
type: "brand-kit-bundle",
|
||
spec,
|
||
logoSvgBase64: Buffer.from(logoSvg).toString("base64"),
|
||
avatarPngs, // { "512": base64, "256": base64, ... }
|
||
socialImages, // { "twitter-profile": base64, ... }
|
||
signatureHtml: signature,
|
||
letterheadHtml: letterhead,
|
||
guidelinesPdfBase64: guidelinesPdf.toString("base64"),
|
||
zipBase64: zipBuffer.toString("base64"),
|
||
};
|
||
|
||
return {
|
||
filename: "brand-kit-bundle.json",
|
||
contentType: "application/json",
|
||
buffer: Buffer.from(JSON.stringify(bundle)),
|
||
};
|
||
}
|
||
```
|
||
|
||
### Pattern 3: archiver ZIP buffer assembly
|
||
|
||
```typescript
|
||
// Source: archiver v7 official docs
|
||
import archiver from "archiver";
|
||
import { Writable } from "stream";
|
||
|
||
async function buildZipBuffer(entries: Array<{ name: string; data: Buffer }>): Promise<Buffer> {
|
||
return new Promise((resolve, reject) => {
|
||
const chunks: Buffer[] = [];
|
||
const sink = new Writable({
|
||
write(chunk: Buffer, _enc, cb) { chunks.push(chunk); cb(); },
|
||
});
|
||
|
||
const archive = archiver("zip", { zlib: { level: 6 } });
|
||
archive.on("error", reject);
|
||
sink.on("finish", () => resolve(Buffer.concat(chunks)));
|
||
archive.pipe(sink);
|
||
|
||
for (const entry of entries) {
|
||
archive.append(entry.data, { name: entry.name });
|
||
}
|
||
|
||
void archive.finalize();
|
||
});
|
||
}
|
||
```
|
||
|
||
### Pattern 4: content-job-runner.ts additions
|
||
|
||
```typescript
|
||
// Add to renderContent() switch in content-job-runner.ts
|
||
case "pdf-document": {
|
||
const { renderPdfDocument } = await import("./renderers/pdf-renderer.js");
|
||
return renderPdfDocument(input);
|
||
}
|
||
case "brand-kit": {
|
||
const { renderBrandKit } = await import("./renderers/brand-renderer.js");
|
||
return renderBrandKit(input);
|
||
}
|
||
```
|
||
|
||
### Pattern 5: UI — useContentJob + bundle fetch (established pattern)
|
||
|
||
The UI pattern for both new tabs is identical to `SocialPostPanel.tsx`:
|
||
1. `useContentJob(companyId)` for submit + SSE progress
|
||
2. `if (job.status === "done" && job.resultAssetId && !bundle)` → fetch asset URL → `fetch()` → `JSON.parse()` → set bundle state
|
||
3. Display result component
|
||
4. Download button triggers `URL.createObjectURL(base64ToBinary(...))` + `<a>.click()`
|
||
|
||
For BRAND-06 (ZIP download): the zipBase64 field in the bundle drives a single download button that triggers a browser `<a download>` with the ZIP blob.
|
||
|
||
### Anti-Patterns to Avoid
|
||
|
||
- **Do NOT use `waitUntil: "load"` for Playwright PDF** — network requests for CDN fonts will fail in the sandbox; use self-contained inline CSS with `@import` disabled, or use `waitUntil: "domcontentloaded"` + system fonts only
|
||
- **Do NOT open a new Playwright browser per sub-step in brand kit** — open once, generate guidelines PDF in that session, close; reuse same `resolveBrowserPath()` pattern
|
||
- **Do NOT pass a file path to archiver from tmpdir** — use `archive.append(buffer, { name })` in-memory to avoid disk temp files
|
||
- **Do NOT build the brand kit as a single sequential LLM mega-prompt** — extract spec first (structured JSON), then feed spec fields into individual generators; this gives predictable output shapes
|
||
- **Do NOT define BrandKitBundle only in the panel component file** — unlike wallpaper/social bundles (which are panel-local per STATE.md decision), `BrandKitBundle` and `PdfDocumentBundle` must be added to `server/src/services/renderers/types.ts` because the brand renderer references them directly
|
||
|
||
---
|
||
|
||
## Don't Hand-Roll
|
||
|
||
| Problem | Don't Build | Use Instead | Why |
|
||
|---------|-------------|-------------|-----|
|
||
| PDF from styled HTML | Custom layout engine | `playwright-core` `page.pdf()` | CSS layout is already solved; Playwright has CSS paged media support |
|
||
| ZIP archive in memory | Manually writing ZIP file format | `archiver` v7 | ZIP format has CRC32, compression, directory entries — trivially wrong to hand-roll |
|
||
| SVG logo cleanup | Custom regex stripper | `svgo` (already installed) `validateAndCleanSvg()` in icon-renderer.ts | Already written and tested |
|
||
| SVG → PNG rasterization | Sharp for logo mark | `@resvg/resvg-js` (already installed, used in diagram-renderer.ts) | Handles embedded fonts and complex gradients better than sharp for LLM-generated logos |
|
||
| Brand color parsing | Write hex parser | CSS string; pass raw hex from LLM spec directly to SVG fill attributes | No parsing needed |
|
||
|
||
**Key insight:** Every difficult sub-problem in this phase already has a solved dependency in the codebase. This phase is integration work, not new infrastructure.
|
||
|
||
---
|
||
|
||
## Common Pitfalls
|
||
|
||
### Pitfall 1: Playwright `page.pdf()` returns Uint8Array, not Buffer
|
||
**What goes wrong:** TypeScript infers `Uint8Array`; passing directly to `Buffer.from()` works but `byteLength` comparison for MAX_GENERATED_ASSET_BYTES expects a Buffer.
|
||
**Why it happens:** Playwright v1.58 changed the return type of `page.pdf()` to `Promise<Uint8Array>`.
|
||
**How to avoid:** Always wrap: `const pdfBuffer = Buffer.from(await page.pdf({...}))`.
|
||
**Warning signs:** TS2345 type error, or `buffer.byteLength` returning wrong value.
|
||
|
||
### Pitfall 2: LLM-generated HTML includes external resource references
|
||
**What goes wrong:** HTML links to Google Fonts CDN, external images. Playwright in `--no-sandbox` headless mode may not fetch them, producing blank/missing content.
|
||
**Why it happens:** LLM follows standard web conventions; doesn't know the context is offline.
|
||
**How to avoid:** System prompt must explicitly say: "Use only inline CSS. No external URLs. Use web-safe system fonts (Arial, Georgia, monospace). No `<link>` or `<script src>` tags."
|
||
**Warning signs:** PDF has blank sections, missing fonts fall back to serif.
|
||
|
||
### Pitfall 3: Brand kit job exceeds MAX_GENERATED_ASSET_BYTES
|
||
**What goes wrong:** Brand kit bundle contains logo SVG + 5 avatar PNGs + ~12 social images + letterhead + guidelines PDF + ZIP — all as base64 in a single JSON blob. This can easily exceed the limit.
|
||
**Why it happens:** base64 encoding inflates by ~33%; large social images (1584×396 LinkedIn banner) at PNG are big.
|
||
**How to avoid:** Compress social images with sharp (`png({ compressionLevel: 9 })`) or use JPEG for banners. Keep logo SVG small (SVGO). Check `MAX_GENERATED_ASSET_BYTES` value before storing.
|
||
**Warning signs:** `Generated asset size X exceeds limit` error in job runner.
|
||
|
||
### Pitfall 4: resolveBrowserPath() called multiple times in brand-renderer
|
||
**What goes wrong:** Brand renderer needs Playwright for guidelines PDF and potentially for letterhead/signature screenshots. If called in a tight loop, each `chromium.launch()` spins up a new process.
|
||
**Why it happens:** Renderer functions are stateless; no shared browser instance.
|
||
**How to avoid:** Resolve browser path once, launch one browser instance per brand kit job, create multiple pages from that browser, close after all PDF/screenshot work is done.
|
||
|
||
### Pitfall 5: archiver `finish` fires before all entries are flushed
|
||
**What goes wrong:** `archive.finalize()` returns a promise but the `sink` Writable may not have received all chunks yet.
|
||
**Why it happens:** archiver is stream-based; finalize signals end of input, but the sink collects data asynchronously.
|
||
**How to avoid:** Resolve on `sink.on("finish", ...)` not on `archive.finalize()` promise — see code example in Pattern 3 above. This is the correct pattern per archiver docs.
|
||
|
||
### Pitfall 6: ContentStudio tab count grows — need stable tab IDs
|
||
**What goes wrong:** Adding two new tabs ("Documents" and "Brand") shifts defaultValue to avoid breaking existing tab state.
|
||
**Why it happens:** React Tabs stores active tab by value string, which persists if passed via URL or state.
|
||
**How to avoid:** Use stable string values (`"documents"`, `"brand"`) not index-based values. Keep `defaultValue="diagrams"` unchanged.
|
||
|
||
---
|
||
|
||
## Code Examples
|
||
|
||
### HTML system prompt for PDF generation
|
||
|
||
```typescript
|
||
// Source: established LLM system prompt pattern from diagram-renderer.ts + icon-renderer.ts
|
||
function buildPdfSystemPrompt(docType: "report" | "invoice" | "one-pager" | "api-docs"): string {
|
||
const typeInstructions: Record<string, string> = {
|
||
report: "Generate a professional report with an executive summary, body sections with headings, and a conclusion.",
|
||
invoice: "Generate an invoice with company header, line items table (description, qty, unit price, total), subtotal, tax, and grand total.",
|
||
"one-pager": "Generate a single-page summary document with a prominent headline, key points, and call to action.",
|
||
"api-docs": "Generate API documentation with endpoint descriptions, request/response examples in <pre> blocks, and parameter tables.",
|
||
};
|
||
|
||
return [
|
||
"You are a professional document generator.",
|
||
"Output ONLY a complete, valid HTML document starting with <!DOCTYPE html>.",
|
||
"Include all CSS inline in a <style> block — no external stylesheets.",
|
||
"Use only web-safe system fonts: Arial, Georgia, or monospace.",
|
||
"No external URLs, no <link> tags, no <script> tags.",
|
||
"Use clean, readable typography with good line-height and margins.",
|
||
"Ensure the document looks professional when printed to A4.",
|
||
"",
|
||
typeInstructions[docType] ?? typeInstructions.report,
|
||
].join("\n");
|
||
}
|
||
```
|
||
|
||
### ZIP download trigger in UI
|
||
|
||
```typescript
|
||
// Pattern for BRAND-06 — browser-side ZIP download from base64
|
||
function downloadZip(zipBase64: string, brandName: string) {
|
||
const byteString = atob(zipBase64);
|
||
const bytes = new Uint8Array(byteString.length);
|
||
for (let i = 0; i < byteString.length; i++) {
|
||
bytes[i] = byteString.charCodeAt(i);
|
||
}
|
||
const blob = new Blob([bytes], { type: "application/zip" });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
a.download = `${brandName.toLowerCase().replace(/\s+/g, "-")}-brand-kit.zip`;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
```
|
||
|
||
### ZIP folder structure convention
|
||
|
||
```
|
||
brand-kit/
|
||
├── logo/
|
||
│ ├── logo.svg
|
||
│ ├── logo-512.png
|
||
│ ├── logo-256.png
|
||
│ ├── logo-128.png
|
||
│ ├── logo-64.png
|
||
│ └── logo-32.png
|
||
├── social/
|
||
│ ├── twitter-profile.png (400×400)
|
||
│ ├── twitter-banner.png (1500×500)
|
||
│ ├── linkedin-profile.png (400×400)
|
||
│ ├── linkedin-banner.png (1584×396)
|
||
│ └── instagram-profile.png (1080×1080)
|
||
├── templates/
|
||
│ ├── email-signature.html
|
||
│ └── letterhead.html
|
||
└── guidelines.pdf
|
||
```
|
||
|
||
### New types to add to types.ts
|
||
|
||
```typescript
|
||
export interface PdfDocumentBundle {
|
||
type: "pdf-document-bundle";
|
||
docType: string;
|
||
title: string;
|
||
pdfBase64: string;
|
||
}
|
||
|
||
export interface BrandKitBundle {
|
||
type: "brand-kit-bundle";
|
||
spec: {
|
||
name: string;
|
||
tagline: string;
|
||
primaryColor: string;
|
||
secondaryColor: string;
|
||
fontStyle: string;
|
||
industry: string;
|
||
};
|
||
logoSvgBase64: string;
|
||
avatarPngs: Record<string, string>; // "512" | "256" | "128" | "64" | "32" → base64
|
||
socialImages: Record<string, string>; // "twitter-profile" | ... → base64
|
||
signatureHtml: string;
|
||
letterheadHtml: string;
|
||
guidelinesPdfBase64: string;
|
||
zipBase64: string;
|
||
}
|
||
|
||
// Update ContentBundle union:
|
||
export type ContentBundle = DiagramBundle | IconSetBundle | ThemePaletteBundle
|
||
| WallpaperBundle | AppIconBundle | SocialPostBundle | ConvertBundle
|
||
| PdfDocumentBundle | BrandKitBundle;
|
||
```
|
||
|
||
---
|
||
|
||
## Environment Availability
|
||
|
||
| Dependency | Required By | Available | Version | Fallback |
|
||
|------------|------------|-----------|---------|----------|
|
||
| Playwright Chromium | PDF generation, brand guidelines | ✓ | 1217 (chromium-1217) | — |
|
||
| playwright-core | PDF + screenshot | ✓ | 1.58.2 in server deps | — |
|
||
| sharp | Avatar + social image rasterization | ✓ | ^0.34.5 | — |
|
||
| @resvg/resvg-js | Logo SVG → PNG | ✓ | ^2.6.2 | — |
|
||
| svgo | Logo SVG cleanup | ✓ | ^4.0.1 | — |
|
||
| archiver | ZIP packaging (BRAND-06) | ✗ (not yet installed) | 7.0.1 available | — |
|
||
| @types/archiver | TypeScript types for archiver | ✗ (not yet installed) | available | — |
|
||
|
||
**Missing dependencies with no fallback:**
|
||
- `archiver` + `@types/archiver` — must be installed in Wave 0 / Plan 01
|
||
|
||
**Missing dependencies with fallback:**
|
||
- None
|
||
|
||
---
|
||
|
||
## Validation Architecture
|
||
|
||
### Test Framework
|
||
|
||
| Property | Value |
|
||
|----------|-------|
|
||
| Framework | vitest ^3.0.5 |
|
||
| Config file | `server/vitest.config.ts` (`environment: "node"`) |
|
||
| Quick run command | `pnpm --filter @paperclipai/server test --run src/__tests__/pdf-renderer.test.ts` |
|
||
| Full suite command | `pnpm --filter @paperclipai/server test --run` |
|
||
|
||
### Phase Requirements → Test Map
|
||
|
||
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|
||
|--------|----------|-----------|-------------------|-------------|
|
||
| DOC-01 | renderPdfDocument produces pdf-document-bundle with non-empty pdfBase64 | unit | `pnpm --filter @paperclipai/server test --run src/__tests__/pdf-renderer.test.ts` | ❌ Wave 0 |
|
||
| DOC-02 | renderPdfDocument with docType="invoice" fills line items from prompt | unit | same file | ❌ Wave 0 |
|
||
| DOC-03 | renderPdfDocument with docType="api-docs" | unit | same file | ❌ Wave 0 |
|
||
| BRAND-01 | renderBrandKit returns brand-kit-bundle with all required fields | unit | `pnpm --filter @paperclipai/server test --run src/__tests__/brand-renderer.test.ts` | ❌ Wave 0 |
|
||
| BRAND-02 | avatarPngs keys include "512", "256", "128", "64", "32" | unit | same file | ❌ Wave 0 |
|
||
| BRAND-03 | socialImages keys include twitter-profile, linkedin-banner | unit | same file | ❌ Wave 0 |
|
||
| BRAND-04 | signatureHtml and letterheadHtml are non-empty strings | unit | same file | ❌ Wave 0 |
|
||
| BRAND-05 | guidelinesPdfBase64 is non-empty; decodes to a PDF-header buffer | unit | same file | ❌ Wave 0 |
|
||
| BRAND-06 | zipBase64 decodes to a buffer starting with PK (ZIP magic bytes) | unit | same file | ❌ Wave 0 |
|
||
|
||
### Sampling Rate
|
||
- **Per task commit:** quick run for the renderer under test
|
||
- **Per wave merge:** `pnpm --filter @paperclipai/server test --run`
|
||
- **Phase gate:** full suite green before `/gsd:verify-work`
|
||
|
||
### Wave 0 Gaps
|
||
- [ ] `server/src/__tests__/pdf-renderer.test.ts` — covers DOC-01, DOC-02, DOC-03
|
||
- [ ] `server/src/__tests__/brand-renderer.test.ts` — covers BRAND-01..06
|
||
- [ ] Install `archiver` + `@types/archiver`
|
||
- [ ] Add `PdfDocumentBundle` + `BrandKitBundle` to `server/src/services/renderers/types.ts`
|
||
|
||
---
|
||
|
||
## State of the Art
|
||
|
||
| Old Approach | Current Approach | When Changed | Impact |
|
||
|--------------|------------------|--------------|--------|
|
||
| pdf-lib for styled PDFs | Playwright page.pdf() | Became standard ~2022 | HTML/CSS layout skills apply directly |
|
||
| jszip for archives | archiver v7 | archiver has been standard for Node.js streaming zips since ~2018 | streaming API handles large sets without OOM |
|
||
| puppeteer for Chromium | playwright-core | Phase 41 established playwright-core | No puppeteer in this project |
|
||
|
||
**Deprecated/outdated:**
|
||
- `pdf-lib` for report/invoice layout: still useful for form-filling and PDF merge, but not the right tool when you have Playwright available
|
||
- `wkhtmltopdf`: system binary, poor Linux support, unmaintained
|
||
|
||
---
|
||
|
||
## Open Questions
|
||
|
||
1. **Brand kit job timeout**
|
||
- What we know: The brand kit runs ~7 sequential sub-renders (spec extraction, logo gen, 5 avatar sizes, ~5 social images, signature, letterhead, guidelines PDF). Total LLM calls: ~4-5. Total sharp/Playwright ops: ~8-10.
|
||
- What's unclear: Total wall-clock time. Could be 30-90 seconds on the M4. The existing job runner has no timeout; Playwright uses 15s timeout per `waitForSelector` call in diagram-renderer.
|
||
- Recommendation: Set Playwright `page.setContent` timeout to 30s for PDF jobs. Accept that brand kit may take 45-90s — SSE progress feedback is already in place so the user sees the spinner.
|
||
|
||
2. **MAX_GENERATED_ASSET_BYTES limit for brand kit**
|
||
- What we know: The constant exists; its exact value is not visible in the files read.
|
||
- What's unclear: Whether a full brand kit bundle with ZIP (all base64) fits within it.
|
||
- Recommendation: Check the constant value in `attachment-types.ts`. If brand kit bundle would exceed it, split: store ZIP as a separate generated asset and reference its asset ID from the brand-kit-bundle JSON instead of embedding zipBase64.
|
||
|
||
---
|
||
|
||
## Sources
|
||
|
||
### Primary (HIGH confidence)
|
||
- Codebase: `/opt/nexus/server/src/services/renderers/diagram-renderer.ts` — Playwright launch/PDF pattern, `resolveBrowserPath()`
|
||
- Codebase: `/opt/nexus/server/src/services/renderers/types.ts` — existing bundle interface contracts
|
||
- Codebase: `/opt/nexus/server/src/services/content-job-runner.ts` — switch dispatch pattern for new job types
|
||
- Codebase: `/opt/nexus/server/package.json` — confirmed installed packages and versions
|
||
- Codebase: `/opt/nexus/.planning/STATE.md` — locked architectural decisions (async jobs, Playwright for PDFs)
|
||
- npm registry: `archiver@7.0.1` — current version confirmed 2026-04-04
|
||
- npm registry: `pdf-lib@1.17.1` — checked and explicitly excluded from stack
|
||
|
||
### Secondary (MEDIUM confidence)
|
||
- Playwright official docs on `page.pdf()` — format options, margin units, `printBackground`, Uint8Array return type in v1.58
|
||
- archiver v7 README — `archive.append(buffer, { name })` in-memory usage pattern
|
||
|
||
### Tertiary (LOW confidence)
|
||
- None
|
||
|
||
---
|
||
|
||
## Metadata
|
||
|
||
**Confidence breakdown:**
|
||
- Standard stack: HIGH — all packages either already installed or version-confirmed from npm registry
|
||
- Architecture: HIGH — follows patterns already established in Phases 41-42 with minimal new concepts
|
||
- Pitfalls: HIGH — derived from existing codebase patterns and known Playwright/archiver behaviors
|
||
- Environment: HIGH — Chromium binary confirmed present at `~/.cache/ms-playwright/chromium-1217/`
|
||
|
||
**Research date:** 2026-04-04
|
||
**Valid until:** 2026-05-04 (stable packages; Playwright version pinned at 1.58.2)
|