Pulp Engine Document Rendering
Get started

Pulp Engine — Embedding Rendered Output

Looking to embed the template editor (not rendered output) in your app? See embed-integration.md.

Pulp Engine produces several output formats via the render API:

EndpointOutputContent-Type
POST /renderStreamed PDF binaryapplication/pdf
POST /render/htmlFull HTML document stringtext/html
POST /render/csvCSV filetext/csv
POST /render/xlsxExcel workbook (multi-sheet for multi-table templates)application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
POST /render/docxWord documentapplication/vnd.openxmlformats-officedocument.wordprocessingml.document
POST /render/pptxPowerPoint deckapplication/vnd.openxmlformats-officedocument.presentationml.presentation
POST /render/preview/{pdf,html,docx,pptx,csv,xlsx}Same payloads as their non-preview counterparts, but authenticated with API_KEY_PREVIEWvaries
POST /render/batch, /batch/pptx, /batch/docxJSON envelope with base64-encoded outputapplication/json

Neither the PDF nor HTML endpoints ship with a built-in viewer. This guide covers how to embed Pulp Engine output in your own application using standard browser APIs and open-source libraries.

Batch rendering: POST /render/batch returns a JSON object with results[] where each successful item contains a pdf field (base64-encoded). Decode with atob() or Buffer.from(pdf, 'base64') to get the PDF binary. This is useful for generating many PDFs in a single request without multiple round trips. See the API guide for the full request/response schema.


1. Embedding HTML output in an iframe

The simplest approach: call POST /render/html, receive the HTML string, and inject it into an <iframe> using srcdoc.

Vanilla JavaScript

Note: Never call Pulp Engine directly from the browser with a production API key. These examples assume you have a backend proxy endpoint (see Section 3) that authenticates the user and adds the API key server-side.

// Calls your backend proxy, which forwards to Pulp Engine with the API key
const resp = await fetch('/api/pulp-engine/render/html', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    template: 'invoice-v2',
    data: { customer: { name: 'Acme Corp' }, lineItems: [...] },
  }),
});

const html = await resp.text();
const iframe = document.getElementById('preview-frame');
iframe.srcdoc = html;
<iframe id="preview-frame" style="width:100%;height:800px;border:1px solid #ccc"></iframe>

React

function DocumentPreview({ templateKey, data }: { templateKey: string; data: object }) {
  const [html, setHtml] = useState<string | null>(null);

  useEffect(() => {
    fetch('/api/pulp-engine/render/html', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ template: templateKey, data }),
    })
      .then(r => r.text())
      .then(setHtml);
  }, [templateKey, data]);

  if (!html) return <div>Loading preview...</div>;
  return <iframe srcDoc={html} style={{ width: '100%', height: '800px', border: 'none' }} />;
}

Vue

<template>
  <iframe :srcdoc="html" style="width:100%;height:800px;border:none" v-if="html" />
  <div v-else>Loading preview...</div>
</template>

<script setup lang="ts">
import { ref, watch } from 'vue';

const props = defineProps<{ templateKey: string; data: object }>();
const html = ref<string | null>(null);

watch(
  () => [props.templateKey, props.data],
  async () => {
    const resp = await fetch('/api/pulp-engine/render/html', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ template: props.templateKey, data: props.data }),
    });
    html.value = await resp.text();
  },
  { immediate: true },
);
</script>

Security considerations for iframe embedding

  • sandbox attribute: Add sandbox="allow-same-origin" to the iframe to block scripts, forms, and navigation inside the rendered HTML. Pulp Engine output includes a <meta> CSP tag that blocks script execution (script-src 'none'), but the sandbox attribute provides a browser-enforced backstop.

    <iframe srcdoc="..." sandbox="allow-same-origin" />
  • Same-origin constraint: srcdoc iframes inherit the parent page’s origin, so asset URLs (images, fonts) in the rendered HTML must be resolvable from the parent origin. If your frontend proxies Pulp Engine, relative /assets/ URLs will resolve correctly. For cross-origin deployments, use ASSET_ACCESS_MODE=private so assets are pre-inlined as data URIs.

  • CSP headers on the parent page: If your application sets a strict Content-Security-Policy, add frame-src 'self' (or blob: if using blob URLs) to allow the iframe.


2. Displaying PDF output with PDF.js

For in-browser PDF viewing with pagination, search, and zoom, use PDF.js — Mozilla’s open-source PDF renderer.

Installation

npm install pdfjs-dist

Vanilla JavaScript

Note: As with the HTML examples above, call your backend proxy — not Pulp Engine directly. See Section 3.

import * as pdfjsLib from 'pdfjs-dist';

// Set the worker source (required)
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
  'pdfjs-dist/build/pdf.worker.min.mjs',
  import.meta.url,
).toString();

async function renderPdf(canvas, templateKey, data) {
  // Calls your backend proxy, which forwards to Pulp Engine with the API key
  const resp = await fetch('/api/pulp-engine/render', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ template: templateKey, data }),
  });

  const arrayBuffer = await resp.arrayBuffer();
  const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;

  // Render first page
  const page = await pdf.getPage(1);
  const viewport = page.getViewport({ scale: 1.5 });
  canvas.width = viewport.width;
  canvas.height = viewport.height;

  await page.render({
    canvasContext: canvas.getContext('2d'),
    viewport,
  }).promise;
}

React with pagination

import { useState, useEffect, useRef } from 'react';
import * as pdfjsLib from 'pdfjs-dist';

pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
  'pdfjs-dist/build/pdf.worker.min.mjs',
  import.meta.url,
).toString();

function PdfViewer({ templateKey, data }: { templateKey: string; data: object }) {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const [pdf, setPdf] = useState<pdfjsLib.PDFDocumentProxy | null>(null);
  const [page, setPage] = useState(1);
  const [numPages, setNumPages] = useState(0);

  // Fetch and load PDF
  useEffect(() => {
    (async () => {
      const resp = await fetch('/api/pulp-engine/render', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ template: templateKey, data }),
      });
      const buf = await resp.arrayBuffer();
      const doc = await pdfjsLib.getDocument({ data: buf }).promise;
      setPdf(doc);
      setNumPages(doc.numPages);
      setPage(1);
    })();
  }, [templateKey, data]);

  // Render current page
  useEffect(() => {
    if (!pdf || !canvasRef.current) return;
    (async () => {
      const pg = await pdf.getPage(page);
      const viewport = pg.getViewport({ scale: 1.5 });
      const canvas = canvasRef.current!;
      canvas.width = viewport.width;
      canvas.height = viewport.height;
      await pg.render({ canvasContext: canvas.getContext('2d')!, viewport }).promise;
    })();
  }, [pdf, page]);

  return (
    <div>
      <div style={{ marginBottom: 8 }}>
        <button disabled={page <= 1} onClick={() => setPage(p => p - 1)}>Previous</button>
        <span style={{ margin: '0 12px' }}>Page {page} of {numPages}</span>
        <button disabled={page >= numPages} onClick={() => setPage(p => p + 1)}>Next</button>
      </div>
      <canvas ref={canvasRef} />
    </div>
  );
}

Using the PDF.js viewer application

For a full-featured viewer (toolbar, search, thumbnails, zoom), use the PDF.js viewer application instead of the library API:

  1. Download the pre-built viewer from the PDF.js releases page.
  2. Host the web/viewer.html alongside your application.
  3. Pass a blob URL to the viewer via query parameter:
const resp = await fetch('/api/pulp-engine/render', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ template: 'invoice-v2', data: { ... } }),
});
const blob = await resp.blob();
const blobUrl = URL.createObjectURL(blob);

// Open in the PDF.js viewer
window.open(`/pdfjs/web/viewer.html?file=${encodeURIComponent(blobUrl)}`);

// Or embed in an iframe
document.getElementById('viewer-frame').src =
  `/pdfjs/web/viewer.html?file=${encodeURIComponent(blobUrl)}`;

Security considerations for PDF viewing

  • Blob URL lifetime: URL.createObjectURL() creates a reference that persists until URL.revokeObjectURL() is called or the document is unloaded. Revoke blob URLs when the viewer is closed to free memory.

  • API key exposure: Never call POST /render or POST /render/csv directly from the browser with a production API_KEY_RENDER. Instead, proxy the request through your backend, which adds the API key server-side. The browser only sees your application’s own auth.

  • CSP for canvas rendering: PDF.js renders to <canvas>, which does not require additional CSP directives beyond what a standard page needs.


3. Backend proxy pattern

In production, your frontend should not call Pulp Engine directly. Instead, create a backend proxy endpoint that:

  1. Authenticates the user against your application’s auth system.
  2. Calls Pulp Engine with the appropriate API key (API_KEY_RENDER for production render/CSV, API_KEY_PREVIEW for previews).
  3. Streams the response back to the browser.

Node.js / Express example

import { Readable } from 'node:stream';

app.post('/api/render-document', requireAuth, async (req, res) => {
  const { templateKey, data } = req.body;

  const pulpEngineResp = await fetch('http://pulp-engine:3000/render', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-Api-Key': process.env.PULP_ENGINE_RENDER_KEY,
    },
    body: JSON.stringify({ template: templateKey, data }),
  });

  if (!pulpEngineResp.ok) {
    const err = await pulpEngineResp.json();
    return res.status(pulpEngineResp.status).json(err);
  }

  res.setHeader('Content-Type', 'application/pdf');
  res.setHeader('Content-Disposition', `inline; filename="${templateKey}.pdf"`);
  // fetch().body is a WHATWG ReadableStream — bridge to Node stream for .pipe()
  Readable.fromWeb(pulpEngineResp.body).pipe(res);
});

Python / Flask example

@app.route('/api/render-document', methods=['POST'])
@login_required
def render_document():
    resp = requests.post(
        'http://pulp-engine:3000/render',
        json={
            'template': request.json['templateKey'],
            'data': request.json['data'],
        },
        headers={'X-Api-Key': os.environ['PULP_ENGINE_RENDER_KEY']},
        stream=True,
    )

    if not resp.ok:
        return resp.json(), resp.status_code

    return Response(
        resp.iter_content(chunk_size=8192),
        content_type='application/pdf',
        headers={'Content-Disposition': f'inline; filename="{request.json["templateKey"]}.pdf"'},
    )

Summary

ApproachBest forKey library
srcdoc iframeQuick HTML previews, email-like layoutsNone (native browser API)
PDF.js libraryCustom PDF viewer with your own UI chromepdfjs-dist
PDF.js viewer appFull-featured viewer (search, thumbnails, zoom)Pre-built viewer from PDF.js
Backend proxyProduction deployments (any output format)Your backend framework
Batch renderGenerating 10+ PDFs in one request (reports, invoices)None (JSON + base64 decode)
XLSX exportDownloading table data as Excel for end usersNone (binary download)

Pulp Engine is the rendering engine. Your application owns the viewing experience.