Guide
Server rendering
Render a document to PDF bytes in Node with renderToBuffer from @ngpdf/core/server - in a plain script or an Angular SSR route, with no DOM - and how images and fonts are fetched on the server.
renderToBuffer from @ngpdf/core/server renders a document component to PDF bytes in Node. It needs no DOM, no jsdom and no Angular SSR app, and it runs the same layout engine as the browser, so the same document gives the same PDF.
import { renderToBuffer } from '@ngpdf/core/server';
const pdf: Uint8Array = await renderToBuffer(Invoice, {
inputs: { invoice },
providers: [providePdfFonts([...])], // optional
fetchImage: (url) => ..., // optional, see "Images and fonts on the server"
});@ngpdf/core/server is an entry point of its own, so a browser app that imports @ngpdf/core never bundles it.
The function
function renderToBuffer<T>(component: Type<T>, options?: RenderToBufferOptions): Promise<Uint8Array>;
interface RenderToBufferOptions {
readonly inputs?: Record<string, unknown>;
readonly providers?: readonly (Provider | EnvironmentProviders)[];
readonly injector?: EnvironmentInjector;
readonly fetchImage?: (url: string) => Promise<Uint8Array>;
readonly maxImageBytes?: number;
}| Option | What it does |
|---|---|
inputs |
Values for the document component's inputs, by input name, as in renderToBlob. |
providers |
Providers for the document, such as providePdfFonts([...]) or your own services. |
injector |
An injector the document resolves its services from, such as your SSR app's. providers are added on top of it. |
fetchImage |
Reads an absolute image or font URL. Without it, the server refuses them. |
maxImageBytes |
The most bytes an image URL may have, checked on what fetchImage returns. No cap by default. See A size cap. |
- The result is a
Uint8Array, which is what Node'sBufferextends. Pass it tores.end(pdf),fs.writeFileor an email attachment as it is. - Every call is isolated like
renderToBlob: its own child injector, tree and writer, and the document is destroyed when the render settles. - Without
injector, the document gets a root injector of its own with onlyproviders, running zoneless. Services withprovidedIn: 'root'resolve in it, and are destroyed with it after the render, so two renders at once share no service instances. - Injectors do not copy provider values. An object created once and passed to every render with
useValueis the same object in each, and a change one render makes shows in the others. Create the value inside the call, or provide it withuseFactory, which runs once per render:{ provide: CART, useFactory: () => new Cart() }. - With
injector, renders that pass the same one share what it holds: its services and their state. Keep state that belongs to one render ininputsorproviders. Fonts are the exception: each render reads font URLs with its ownfetchImageand keeps them for that render only. - A failed render rejects with the same
ngpdf:errors as in the browser.
In a plain Node script
A document is an Angular component, so it has to be compiled. In a project built with the Angular CLI it already is. In a plain script, load @angular/compiler first so Angular compiles it when it is first used:
// render-invoice.mjs
import '@angular/compiler';
import { Component } from '@angular/core';
import { PDF_ELEMENTS } from '@ngpdf/core';
import { renderToBuffer } from '@ngpdf/core/server';
import { writeFile } from 'node:fs/promises';
class Invoice {
number = '';
}
Component({
selector: 'app-invoice',
imports: [PDF_ELEMENTS],
inputs: ['number'],
template: `
<pdf-document title="Invoice">
<pdf-page size="A4">
<pdf-text style="font-size: 18; font-weight: bold">Invoice {{ number }}</pdf-text>
</pdf-page>
</pdf-document>
`,
})(Invoice);
await writeFile('invoice.pdf', await renderToBuffer(Invoice, { inputs: { number: 'INV-0042' } }));npm install @ngpdf/core @angular/core @angular/compiler, then node render-invoice.mjs. In TypeScript, write the component with its decorator and input() as usual.
In an Angular SSR app
In an app with Angular SSR, a PDF is one more route of the Express server in server.ts. The id in the URL comes from the caller, so check that the signed-in user may see that invoice before rendering it, and keep the PDF out of caches:
import { providePdfFonts } from '@ngpdf/core';
import { renderToBuffer } from '@ngpdf/core/server';
import { readFile } from 'node:fs/promises';
import { Invoice } from './src/app/invoice'; // your document component
const notoSans = new Uint8Array(await readFile('fonts/NotoSans-Regular.ttf'));
app.get('/invoices/:id.pdf', async (req, res, next) => {
try {
// Your own authentication and data access: a user may read only their own invoices.
const user = await currentUser(req);
const invoice = user && (await invoices.find(req.params.id));
if (!invoice || invoice.customerId !== user.customerId) {
res.sendStatus(404);
return;
}
const pdf = await renderToBuffer(Invoice, {
inputs: { invoice },
providers: [providePdfFonts([{ family: 'Noto Sans', src: notoSans }])],
});
// An invoice is personal: no browser or shared cache may keep a copy.
res.set('Cache-Control', 'private, no-store').type('application/pdf').end(pdf);
} catch (error) {
next(error);
}
});That route renders with an injector of its own. To have the document resolve your app's services, its LOCALE_ID and its providePdfFonts, pass an EnvironmentInjector of the app instead. inject() works only in an injection context, so take it in a field of a service and use the field later:
import { EnvironmentInjector, Service, inject } from '@angular/core';
import { renderToBuffer } from '@ngpdf/core/server';
import { Invoice } from './invoice'; // your document component
@Service()
export class InvoicePdf {
private readonly injector = inject(EnvironmentInjector);
render(inputs: Record<string, unknown>): Promise<Uint8Array> {
return renderToBuffer(Invoice, { inputs, injector: this.injector });
}
}Images and fonts on the server
A server that fetched any URL found in a document could be made to fetch internal addresses, such as a cloud metadata service or an admin port. Image URLs often come from user data, so this is a server-side request forgery (SSRF) risk. renderToBuffer therefore fetches nothing on its own:
data:URLs and bytes (Blob,ArrayBuffer, or aUint8Arraysuch as a NodeBuffer) always work, for images and fonts.- An absolute URL, such as
https://…, is read only throughfetchImage. Without it, the render rejects withngpdf: remote image "<url>" refused on the server; pass fetchImage. - A relative URL, such as
/assets/logo.png, has no page to resolve against. It rejects withngpdf: relative image URL "<url>" on the server, with or withoutfetchImage. - Only
http:andhttps:URLs reachfetchImage. Any other scheme, such asfile:///etc/passwd,ftp://…, orlocalhost:3000/a.pngandC:\logo.png(which parse as the schemeslocalhost:andc:), rejects withngpdf: image URL "<url>" is not http or https; the server reads no other scheme. - Font URLs in
providePdfFontsfollow the same rules through the samefetchImage, withfontin place ofimagein the messages. - Emoji image URLs from
providePdfEmojifollow them too. A refused or failed emoji does not reject the render: it is drawn as text, as in the browser. <url>is shown without its query string and fragment, which often carry a token, and so is the URL where afetchImageerror repeats it.fetchImagegets the whole URL.- If
fetchImagerejects, the render rejects withngpdf: could not load image "<url>": <reason>(orfont).
fetchImage is where you decide what the server may fetch: an allow-list of hosts, a timeout and a size limit. maxImageBytes checks the bytes fetchImage returns, but only once it has returned them, so the limit inside fetchImage is what stops a large download. For example:
// Whole origins (scheme, host and port), so another port on the same host stays out.
const ALLOWED_ORIGINS = new Set(['https://cdn.example.com']);
const MAX_BYTES = 5 * 1024 * 1024;
async function fetchImage(url: string): Promise<Uint8Array> {
if (!ALLOWED_ORIGINS.has(new URL(url).origin)) {
throw new Error('not on the allow-list');
}
const response = await fetch(url, { redirect: 'error', signal: AbortSignal.timeout(5000) });
if (!response.ok || !response.body) {
// Cancel the body, so a slow error response does not hold the connection.
await response.body?.cancel();
throw new Error(`HTTP ${response.status}`);
}
// Count while reading, so a huge response is cut off instead of held in memory.
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let size = 0;
for (let read = await reader.read(); !read.done; read = await reader.read()) {
size += read.value.length;
if (size > MAX_BYTES) {
await reader.cancel();
throw new Error('too large');
}
chunks.push(read.value);
}
const bytes = new Uint8Array(size);
let at = 0;
for (const chunk of chunks) {
bytes.set(chunk, at);
at += chunk.length;
}
return bytes;
}
const pdf = await renderToBuffer(Invoice, { inputs: { invoice }, fetchImage });redirect: 'error' stops an allowed origin from redirecting the request to an internal one. For files on disk, read them yourself and pass the bytes: pdf-image takes the Buffer that readFile(path) returns, as it takes any Uint8Array.
What needs a browser
downloadPdf, PdfRenderer.download, PdfDownload, pdfResource and pdf-viewer save a file or show a PDF in the page, so they need a browser. "On the server" means no global document, or Angular's server platform. There:
downloadPdfandPdfRenderer.downloadthrowngpdf: downloadPdf needs a browser; on the server, use renderToBuffer from @ngpdf/core/serverbefore rendering anything.PdfDownloadcalls it only when clicked, which never happens on the server.pdfResourcemade in an injection context, such as a component field in an SSR page, staysidleand renders nothing, so the page still renders. In the browser it renders as usual. Called with aninjectoroption, it throwsngpdf: pdfResource needs a browser; …instead.pdf-viewerin an SSR page renders itspdfViewerLoadingcontent and starts no render. The browser renders the PDF once the app is running.
renderToBlob and PdfRenderer.toBlob are not blocked on the server, but they read image and font URLs with fetch, as in a browser, without the policy above. On the server, use renderToBuffer.
Limits
- Node versions: those Angular 22 supports,
^22.22.3,^24.15.0or^26.0.0in its version table. The check in CI runs Node 22 and 24, with@angular/core22.0.0 as well as the version this repository builds with. - Nothing that needs a DOM: the APIs in "What needs a browser", and anything in your document that uses
DOCUMENT,windowor browser APIs. A document's injector withoutinjectorhas a placeholderDOCUMENTwith nothing in it. - The engine uses pdfkit's Node build, which Node picks through pdfkit's package exports, with its own standard font metrics. Yoga's WebAssembly is compiled once per process.