Skip to content

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.

bash
pnpm add @broadpaper/core @broadpaper/blocks @broadpaper/renderer

The 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.

ts
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

tsx
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>
  );
}
html
<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 viewerReportPreviewAn embedded PDF
Page breaksBroadPaper's paginatorBroadPaper's paginatorBroadPaper's paginator
Interactive chartsYesOpt inNo
Selectable, searchable textYes, it is HTMLYesDepends on the viewer
Responds to the containerFits and reflows the zoomFixed zoomBrowser plugin's own chrome
Works on a phoneYesYesPoorly
A file to keepPrint, or render oneYes

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:

ts
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.