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:
| Endpoint | Output | Content-Type |
|---|---|---|
POST /render | Streamed PDF binary | application/pdf |
POST /render/html | Full HTML document string | text/html |
POST /render/csv | CSV file | text/csv |
POST /render/xlsx | Excel workbook (multi-sheet for multi-table templates) | application/vnd.openxmlformats-officedocument.spreadsheetml.sheet |
POST /render/docx | Word document | application/vnd.openxmlformats-officedocument.wordprocessingml.document |
POST /render/pptx | PowerPoint deck | application/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_PREVIEW | varies |
POST /render/batch, /batch/pptx, /batch/docx | JSON envelope with base64-encoded output | application/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/batchreturns a JSON object withresults[]where each successful item contains aatob()orBuffer.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
-
sandboxattribute: Addsandbox="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 thesandboxattribute provides a browser-enforced backstop.<iframe srcdoc="..." sandbox="allow-same-origin" /> -
Same-origin constraint:
srcdociframes 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, useASSET_ACCESS_MODE=privateso assets are pre-inlined as data URIs. -
CSP headers on the parent page: If your application sets a strict
Content-Security-Policy, addframe-src 'self'(orblob: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:
- Download the pre-built viewer from the PDF.js releases page.
- Host the
web/viewer.htmlalongside your application. - 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 untilURL.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 /renderorPOST /render/csvdirectly from the browser with a productionAPI_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:
- Authenticates the user against your application’s auth system.
- Calls Pulp Engine with the appropriate API key (
API_KEY_RENDERfor production render/CSV,API_KEY_PREVIEWfor previews). - 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
| Approach | Best for | Key library |
|---|---|---|
srcdoc iframe | Quick HTML previews, email-like layouts | None (native browser API) |
| PDF.js library | Custom PDF viewer with your own UI chrome | pdfjs-dist |
| PDF.js viewer app | Full-featured viewer (search, thumbnails, zoom) | Pre-built viewer from PDF.js |
| Backend proxy | Production deployments (any output format) | Your backend framework |
| Batch render | Generating 10+ PDFs in one request (reports, invoices) | None (JSON + base64 decode) |
| XLSX export | Downloading table data as Excel for end users | None (binary download) |
Pulp Engine is the rendering engine. Your application owns the viewing experience.