PDF Generation

Svelar ships with two swappable PDF drivers. The API is the same — switch drivers at any time with PDF.configure().

Drivers

Driver Install Best For Limitations
pdfkit (default) Included in scaffold Invoices, reports, tickets, programmatic docs No pixel-perfect HTML rendering, no office docs
gotenberg Docker container Pixel-perfect HTML/CSS, URL→PDF, office docs (docx, xlsx, pptx) Requires running Docker

Setup — PDFKit (Default)

PDFKit is included in scaffolded projects (npx svelar new). No Docker, no external services — it works out of the box:

// src/app.ts
import { PDF } from '@beeblock/svelar/pdf';

PDF.configure({ driver: 'pdfkit' });

// Optional: customize defaults
PDF.configure({
  driver: 'pdfkit',
  pdfkit: {
    pageSize: 'A4',         // or 'Letter', 'Legal', etc.
    font: 'Helvetica',
    fontSize: 12,
    pageNumbers: true,       // auto page numbers in footer
    margins: { top: 72, bottom: 72, left: 72, right: 72 },
  },
});

Setup — Gotenberg

Gotenberg is a Docker-based service using Chromium and LibreOffice. Great for pixel-perfect HTML rendering and office document conversion.

docker run -d -p 3001:3000 gotenberg/gotenberg:8
// src/app.ts
import { PDF } from '@beeblock/svelar/pdf';

PDF.configure({
  driver: 'gotenberg',
  gotenberg: {
    url: process.env.GOTENBERG_URL ?? 'http://localhost:3001',
    timeout: 60_000,
  },
});

Gotenberg is included in the default docker-compose.yml generated by make:docker.

Swapping Drivers

Both drivers share the same PDF.html(), PDF.url(), and PDF.markdown() API. Switch at any time:

// Development: use PDFKit (no Docker needed)
PDF.configure({ driver: 'pdfkit' });

// Production: switch to Gotenberg for pixel-perfect rendering
PDF.configure({
  driver: 'gotenberg',
  gotenberg: { url: process.env.GOTENBERG_URL },
});

// The rest of your code stays the same:
const buffer = await PDF.html('<h1>Invoice</h1>').generate();

Gotenberg-only features (throw a clear error if used with PDFKit):

  • PDF.office() — office document conversion (requires LibreOffice)
  • PDF.merge() — merge multiple PDFs
  • PDF.screenshotHtml() / PDF.screenshotUrl() — screenshots
  • .webhook() / .generateAsync() — async webhook mode

Programmatic PDFs (PDFKit)

For full control over layout, use PDF.create() to access the raw PDFKit document API. This works regardless of the configured driver:

const buffer = await PDF.create()
  .margins({ top: '1in', bottom: '1in' })
  .build(async (doc) => {
    // Full PDFKit API — text, images, vectors, tables
    doc.fontSize(24).text('Invoice #1234', { align: 'center' });
    doc.moveDown();
    doc.fontSize(12).text('Date: 2026-03-29');
    doc.text('Total: $99.00');

    doc.moveDown(2);
    // Draw a table
    doc.font('Helvetica-Bold').text('Item', 72, doc.y);
    doc.text('Price', 300, doc.y - 14);
    doc.font('Helvetica');
    doc.moveDown(0.5);
    doc.text('Widget', 72, doc.y);
    doc.text('$49.00', 300, doc.y - 14);
    doc.text('Service', 72, doc.y);
    doc.text('$50.00', 300, doc.y - 14);

    // Add a second page
    doc.addPage();
    doc.text('Terms and conditions...');
  });

// Save to file
await PDF.create().store('storage/invoices/inv-001.pdf', async (doc) => {
  doc.text('Invoice content...');
});

In Docker Compose the app connects to Gotenberg at http://gotenberg:3000 automatically via the GOTENBERG_URL environment variable.

HTML to PDF

import { PDF } from '@beeblock/svelar/pdf';

// Simple conversion
const buffer = await PDF.html('<h1>Invoice #1234</h1><p>Total: $99.00</p>').generate();

// With options
const buffer = await PDF.html(invoiceTemplate)
  .margins({ top: '1in', bottom: '1in', left: '0.75in', right: '0.75in' })
  .landscape()
  .header('<div style="font-size:10px; text-align:center">My Company</div>')
  .footer('<div style="font-size:10px; text-align:center">Page <span class="pageNumber"></span></div>')
  .printBackground()
  .generate();

URL to PDF

const buffer = await PDF.url('https://example.com/report')
  .margins({ top: '20mm', bottom: '20mm' })
  .waitDelay('3s')                         // Wait for JS to render
  .waitForExpression('window.ready === true') // Or wait for a condition
  .httpHeaders({ Authorization: 'Bearer ...' })
  .generate();

Markdown to PDF

const buffer = await PDF.markdown('# Hello World\n\nThis is **bold**.')
  .margins({ top: '1in', bottom: '1in' })
  .generate();

// With a custom HTML wrapper (use {{ toHTML "file.md" }} as placeholder)
const buffer = await PDF.markdown(content, `
  <!DOCTYPE html>
  <html>
  <head>
    <style>body { font-family: sans-serif; }</style>
  </head>
  <body>{{ toHTML "file.md" }}</body>
  </html>
`).generate();

Office Documents to PDF

Convert Word, Excel, PowerPoint, and OpenDocument files via LibreOffice:

// From file path
const buffer = await PDF.office('/path/to/report.docx').generate();

// From buffer (e.g. uploaded file)
const buffer = await PDF.office(uploadedFile.buffer, 'report.docx').generate();

// Multiple files merged into one PDF
const buffer = await PDF.office('/path/to/cover.docx')
  .addFile('/path/to/appendix.xlsx')
  .generate();

Supported formats: .docx, .doc, .xlsx, .xls, .pptx, .ppt, .odt, .ods, .odp, .rtf, .txt, .html, .csv.

Merging PDFs

// Merge existing PDF files
const merged = await PDF.merge()
  .addPdfFile('/path/to/cover.pdf')
  .addPdfFile('/path/to/content.pdf')
  .addPdfFile('/path/to/appendix.pdf')
  .generate();

// Merge HTML pages into one PDF
const report = await PDF.merge()
  .addHtml('<h1>Cover Page</h1>')
  .addHtml(tableOfContents)
  .addHtml(mainContent)
  .generate();

// Mix HTML and existing PDFs
const combined = await PDF.merge()
  .addHtml('<h1>New Cover</h1>')
  .addPdfFile('/path/to/existing-report.pdf')
  .generate();

Screenshots

Gotenberg can also capture screenshots of HTML or URLs:

// Screenshot of HTML
const png = await PDF.screenshotHtml('<div style="background:red; width:800px; height:600px">Hello</div>')
  .format('png')
  .viewport(1920, 1080)
  .generate();

// Screenshot of a URL
const jpg = await PDF.screenshotUrl('https://example.com')
  .format('jpeg')
  .quality(85)
  .clip('#main-content')  // Capture only a specific element
  .generate();

PDF/A Compliance

For archival purposes, request PDF/A format:

const buffer = await PDF.html(content)
  .pdfFormat('PDF/A-2b')
  .generate();

In a Controller

import { Controller } from '@beeblock/svelar';
import { PDF } from '@beeblock/svelar/pdf';

export class InvoiceController extends Controller {
  async download() {
    const invoice = await Invoice.findOrFail(this.params.id);

    const html = renderInvoiceTemplate(invoice);
    const buffer = await PDF.html(html)
      .margins({ top: '1in', bottom: '1in' })
      .footer('<div style="text-align:center; font-size:9px">Page <span class="pageNumber"></span> of <span class="totalPages"></span></div>')
      .generate();

    return new Response(buffer, {
      headers: {
        'Content-Type': 'application/pdf',
        'Content-Disposition': `attachment; filename="invoice-${invoice.number}.pdf"`,
      },
    });
  }
}

Save to File

Use .store() to generate and save in one call:

const buffer = await PDF.html(content)
  .margins({ top: '1in', bottom: '1in' })
  .store('storage/reports/monthly-report.pdf');

Async Generation (Webhooks)

For large files or batch processing, use Gotenberg's webhook mode. Instead of waiting for the PDF, Gotenberg returns 204 No Content immediately and POSTs the result to your webhook URL when done:

// Fire and forget — Gotenberg calls your webhook when done
await PDF.html(largeReport)
  .webhook({
    url: 'https://myapp.com/api/pdf/webhook',
    errorUrl: 'https://myapp.com/api/pdf/webhook-error',
    method: 'POST',
    extraHeaders: { Authorization: 'Bearer secret-token' },
  })
  .generateAsync();

Create a webhook receiver route to handle the result:

// src/routes/api/pdf/webhook/+server.ts
import type { RequestHandler } from '@sveltejs/kit';
import { writeFileSync, mkdirSync } from 'node:fs';
import { Broadcast } from '@beeblock/svelar/broadcasting';

export const POST: RequestHandler = async ({ request }) => {
  const contentType = request.headers.get('content-type') ?? '';

  if (contentType.includes('application/pdf')) {
    // Success: Gotenberg sent the PDF
    const buffer = Buffer.from(await request.arrayBuffer());
    const meta = request.headers.get('x-svelar-pdf-meta');
    const parsed = meta ? JSON.parse(meta) : {};

    // Save the file
    const outputPath = parsed.outputPath ?? `storage/pdfs/${Date.now()}.pdf`;
    mkdirSync('storage/pdfs', { recursive: true });
    writeFileSync(outputPath, buffer);

    // Notify the client via broadcasting (optional)
    if (parsed.channel) {
      await Broadcast.to(parsed.channel).send('PdfReady', {
        path: outputPath,
        size: buffer.length,
      });
    }
  }

  return new Response(null, { status: 200 });
};

Error webhook receiver:

// src/routes/api/pdf/webhook-error/+server.ts
export const POST: RequestHandler = async ({ request }) => {
  const body = await request.text();
  console.error('[PDF Webhook Error]', body);
  return new Response(null, { status: 200 });
};

You can also set a default webhook URL in PDF.configure() so all builders use it:

PDF.configure({
  url: process.env.GOTENBERG_URL,
  webhookUrl: 'https://myapp.com/api/pdf/webhook',
  webhookErrorUrl: 'https://myapp.com/api/pdf/webhook-error',
});

Queue-Based PDF Generation

For the best of both worlds — non-blocking requests + reliable retries — dispatch PDF generation as a queue job:

import { PDF } from '@beeblock/svelar/pdf';

// Dispatch to background worker — returns immediately
await PDF.dispatch({
  type: 'html',
  content: invoiceHtml,
  outputPath: `storage/invoices/inv-${invoice.id}.pdf`,
  options: {
    margins: { top: '1in', bottom: '1in' },
    landscape: false,
  },
  // Optional: broadcast when done so the client knows
  broadcastEvent: 'PdfReady',
  broadcastChannel: `private-user.${userId}`,
  meta: { invoiceId: invoice.id },
});

The GeneratePdfJob handles the generation in a worker process. Register it once in src/app.ts:

import { Queue } from '@beeblock/svelar/queue';
import { GeneratePdfJob } from '@beeblock/svelar/pdf';

Queue.register(GeneratePdfJob);

You can also combine queue + webhook — the job dispatches to Gotenberg in webhook mode, and the webhook receiver handles the result:

await PDF.dispatch({
  type: 'url',
  content: 'https://internal-app.com/big-report',
  webhook: {
    url: 'https://myapp.com/api/pdf/webhook',
    errorUrl: 'https://myapp.com/api/pdf/webhook-error',
  },
  meta: { reportId: 42, userId: user.id },
});

Remote Files (downloadFrom)

Instead of uploading files to Gotenberg, you can tell it to fetch them from remote URLs (S3, Azure Blob, CDN, etc.):

// Convert a file from S3 without downloading it first
const buffer = await PDF.office()
  .downloadFrom([
    {
      url: 'https://s3.amazonaws.com/mybucket/report.docx',
      extraHttpHeaders: { 'X-Api-Key': 'my-s3-key' },
    },
  ])
  .generate();

// Multiple remote files merged into one PDF
const buffer = await PDF.office()
  .downloadFrom([
    { url: 'https://cdn.example.com/cover.docx' },
    { url: 'https://cdn.example.com/appendix.xlsx' },
  ])
  .generate();

The remote server must return a Content-Disposition header with a filename parameter.

Health Check

Verify Gotenberg is reachable from your app:

const health = await PDF.health();
// { status: 'up', details: { chromium: { status: 'up' }, libreoffice: { status: 'up' } } }
Svelar © 2026 · MIT License