The read-only viewer
A saved template plus data is a PDF. It is also a page somebody can read — in a tab, on a phone, behind a link in your own application — without downloading anything. createReportViewer is that page: read-only, page-shaped, and interactive in the one way a file cannot be.
It is not a second renderer. The same paginator decides the breaks, the same blocks draw the marks, and the chart in the browser is the same SVG the PDF contains. What is on screen is what is in the file, because it is what is in the file.
pnpm add @broadpaper/core @broadpaper/blocks @broadpaper/rendererThe viewer lives in @broadpaper/renderer and needs no editor: a read-only route can be built with none of the designer's bundle in it.
import { createReportViewer, type ReportViewer } from "@broadpaper/renderer";
import { createRegistry } from "@broadpaper/blocks";
import type { DataSource, ReportData, ReportTemplate, Theme } from "@broadpaper/core";
export function showReport(
host: HTMLElement,
template: ReportTemplate, // the saved JSON, exactly as the designer handed it over
data: ReportData, // live data, not the designer's sample
theme: Theme,
dataSources: DataSource[]
): ReportViewer {
return createReportViewer(host, {
template,
registry: createRegistry(),
data,
theme,
dataSources,
locale: "en-GB",
currency: "GBP"
});
}Give the container a height. The viewer fills it and scrolls inside itself, so that the toolbar stays put and fit-to-width has something to measure against; in a box with no height it collapses to its toolbar and nothing else.
In React and Angular
import { ReportViewer } from "@broadpaper/react";
import type { DataSource, ReportData, ReportTemplate, Theme } from "@broadpaper/core";
interface ReportPageProps {
template: ReportTemplate;
data: ReportData;
theme: Theme;
dataSources: DataSource[];
}
export function ReportPage({ template, data, theme, dataSources }: ReportPageProps) {
return (
<div style={{ height: "100vh" }}>
<ReportViewer template={template} data={data} theme={theme} dataSources={dataSources} currency="GBP" />
</div>
);
}<bp-report-viewer [template]="template" [data]="data" [theme]="theme" (pageChange)="onPage($event)" />Both take a ref (React) or expose api (Angular) giving the same handle as the framework-neutral call: goToPage, setZoom, print, and the current page, pageCount and zoom.
What it adds, and what it deliberately does not
Charts you can point at. On by default. Hovering or focusing a mark shows what it stands for, drawn from what the chart block recorded as it drew — no geometry is re-derived and no second chart is drawn for the screen. Every mark is focusable, so a reader on a keyboard gets the same thing. Pass interactions: false to turn it off, or an options object to change the tooltip.
It fits, and never enlarges. fit: "width" (the default) scales the page to the element and stops at 100%. Type set for paper, blown up to 180% on a wide monitor, reads as a rendering fault rather than as a feature. fit: "page" fits a whole page in view; fit: "actual" pins it; a numeric zoom overrides all three.
The width it measures is the element's, never the window's. The viewer is meant to be embedded, so a narrow pane in a wide page and a phone are the same problem, and the window answers neither.
A toolbar, unless you want your own. Page position, zoom and print. toolbar: false hides it and leaves you the handle to drive your own chrome from.
Print gives the reader a file. The default opens the browser's print dialog on the pages as laid out, and because those pages are already exact boxes the result matches the PDF. Supply onPrint to hand over a real rendered PDF instead — see Browserless PDF.
No editing, and no trace of the editor. The viewer always renders in preview mode: real data, and none of design mode's badges, dashed outlines or drop targets. A reader given a report should not be able to tell it was made in an editor.
Against the alternatives
| The viewer | ReportPreview | An embedded PDF | |
|---|---|---|---|
| Page breaks | BroadPaper's paginator | BroadPaper's paginator | BroadPaper's paginator |
| Interactive charts | Yes | Opt in | No |
| Selectable, searchable text | Yes, it is HTML | Yes | Depends on the viewer |
| Responds to the container | Fits and reflows the zoom | Fixed zoom | Browser plugin's own chrome |
| Works on a phone | Yes | Yes | Poorly |
| A file to keep | Print, or render one | — | Yes |
ReportPreview is the lower-level thing: the pages, and none of the chrome. Reach for it when you are building your own surround and want nothing decided for you.
Serving it from a saved template
Nothing above needs the designer. A viewer takes the JSON your database already holds, so the read path can be its own route with no editor bundle in it at all:
import { migrateTemplate, type AnyVersionTemplate, type DataSource, type ReportData, type Theme } from "@broadpaper/core";
import { createReportViewer } from "@broadpaper/renderer";
import { createRegistry } from "@broadpaper/blocks";
interface ReportStore {
getTemplate(id: string): Promise<AnyVersionTemplate>;
getData(id: string): Promise<ReportData>;
}
export async function openSavedReport(host: HTMLElement, store: ReportStore, id: string, theme: Theme, dataSources: DataSource[]) {
const saved = await store.getTemplate(id); // the JSON the designer saved
const { template } = migrateTemplate(saved); // an older template still opens
const data = await store.getData(id);
return createReportViewer(host, { template, registry: createRegistry(), data, theme, dataSources });
}migrateTemplate matters here more than in the designer: a document saved a year ago is exactly the kind of thing a read link is asked for. See Migration & versioning.