Escape

The viewer

Intent

<quario-viewer> puts a compiled report on screen as pages, and wires the export menu that turns those pages into files. Use it when your users should see a document before they download it, or should choose the format themselves.

Design decisions

The viewer compiles nothing. It takes a compiled report as a property and never touches the event stream. Compiling stays your application’s job, on your instance, with your registry.

It paints the same layout the PDF target writes. @quario/layout lays out preview and document against the same faces, which is why the preview breaks its pages where the document does.

The preview approximates rather than imitating. It’s a faithful page break, not a pixel-faithful print simulation. Treat it as the document’s structure on screen, not as a proof of the final raster.

Export targets are yours to choose, in your order. Give it the targets you want, and one Export button opens a menu listing them in array order. A viewer given no targets exports nothing. It can give a reader only the formats it knows a content type for: pdf, docx, xlsx and csv. A target of your own gets no row. The sheet is the layout’s either way. Targets buy downloads, not the render.

One button rather than one per format, because the bar used to widen with every target you passed. Four needed 343px and pushed the view control off the left edge. Now the bar keeps one width whatever you pass: 303px for a report of up to nine pages. Below 384px it drops the first-page and last-page controls, so it fits a 320px phone. While an export renders, the button shows a spinner and disables that format’s row. The button itself stays live, so a reader can start a second format before the first finishes.

A failed render resolves the mount. The element doesn’t hang or throw into your render tree. It reports through an error event and stays mounted.

A viewer you insert again renders once more, unless its last render landed on the sheet. Moving the element, hiding a tab, or re-parenting it can take it out of the document mid-render. Whatever the newest render owed the sheet is still owed when it comes back. Three cases leave that debt standing: a failure that reached nobody, a property written while the element was out, and a host option it rejected. None of them quietly leaves the previous pages up. A failure the panel already showed re-runs and reports once, not twice.

The colour scheme paints chrome only. The sheet stays the document’s, because a document that changed colour with the reader’s theme would stop being the document.

The viewer draws no parameter form. It passes params to the preview and to every export, so a downloaded file shows the same rows as the screen. Which values a person may choose, and how, stays your page’s decision.

Search reads the whole report, not the pages on screen. The browser’s own find sees only the pages the viewer has painted. The viewer’s search sees every page and matches the text as drawn, so a formatted amount matches as 1,234.50. Ctrl+F or Cmd+F opens it while the focus is in the viewer, and the browser’s find stays everywhere else.

Print goes through the PDF, never the page. Printing the host page would print the chrome at whatever zoom the reader left. Print renders your pdf target into a hidden frame and opens the browser’s dialog there. That frame needs frame-src blob: in your Content Security Policy, which is why Print is off until you say the grant is there.

The marking goes on the sheet. An unlicensed report carries the marking in the preview just as it does in an export. The viewer never shows an unmarked page for a render that would export marked.

A reader can read and copy the pages. The viewer lays the report’s text over each page it paints. A screen reader reads it, a magnifier follows it, and a reader selects and copies it. Nothing needs declaring. A screen reader meets each page as a group named “Page n of N” and reads its header, then its body, then its footer. The first line of a group header is a heading at that group’s depth. An image with an alt carries that name, and a screen reader passes over one without an alt as decoration. A table reads as a table, with its column headers as headings. A table that continues onto the next page starts again with its headings there, as the paint does. The unlicensed marking reads on every page and never copies.

A copy takes the text as the report wrote it. A paragraph that wraps on the page copies as one line, and so does a table row, with a space between its cells. The text covers the pages near the view, the same ones the paint covers, so select-all takes those pages. The PDF export is the whole report.

API walkthrough

Mounting

Import @quario/viewer/register to define the element, or import QuarioViewer and define it yourself. Set properties as element properties, not attributes, because a compiled report and a font record aren’t strings.

viewer.ts
import '@quario/viewer/register'
import type { ViewerPage } from '@quario/viewer'
import { pdf } from '@quario/pdf'

const el = document.createElement('quario-viewer')

// One decision, read by the preview and by the export.
const page: ViewerPage = { size: 'A4', margin: 54 }

el.report = report
el.data = data
el.params = { region: 'EU' }
el.page = page
el.targets = [pdf({ page })]
el.filename = 'invoice-2043'

el.addEventListener('error', (event) => {
  console.error(event.detail.kind, event.detail.error)
})

document.body.append(el)

The element and the export target are two consumers of one decision, and nothing reconciles them for you. Declare the geometry once and give it to both, as above.

Letting the reader choose what the report shows

params is the record you’d pass as the last argument to render. The viewer passes it to the preview and to each export, and writing it renders again. A render needs a value for every parameter declared without a default. Until params holds one, every render fails and the error panel names it.

report.params lists each declaration as { name, required, type?, default?, label? }. A label marks a parameter a person supplies, so your page can draw its form from that list.

params-form.ts
const form = document.querySelector<HTMLFormElement>('#params')!
const inputTypes: Record<string, string> = { string: 'text', number: 'number', boolean: 'checkbox' }

for (const param of viewer.report?.params ?? []) {
  if (param.label === undefined || param.type === undefined || !(param.type in inputTypes)) continue
  const input = document.createElement('input')
  input.name = param.name
  input.type = inputTypes[param.type]
  if (param.type === 'boolean') input.checked = param.default === true
  else if (param.default != null) input.value = String(param.default)
  form.append(param.label, input)
}

form.addEventListener('submit', (event) => {
  event.preventDefault()
  const params: Record<string, string | number | boolean> = {}
  for (const param of viewer.report?.params ?? []) {
    const input = form.elements.namedItem(param.name)
    if (!(input instanceof HTMLInputElement)) continue
    if (input.type === 'checkbox') params[param.name] = input.checked
    else if (input.value !== '') params[param.name] = param.type === 'number' ? input.valueAsNumber : input.value
  }
  viewer.params = params
})

An empty field adds no key, so that parameter keeps its default. A boolean whose default is null takes three values, so give it a select rather than a checkbox.

A parameter can change the rows themselves, not only a heading. A sort key may read one, so a select on your page orders the report.

a sort the reader chooses
{
  "params": { "sortBy": { "type": "string", "default": "date", "label": "Sort by" } },
  "data": "$.orders[*]",
  "sort": [{ "by": "=$.params.sortBy == 'amount' ? [email protected] : @.date", "dir": "asc" }],
  "detail": {
    "columns": [
      { "header": "Date", "value": "{{ @.date }}", "style": { "format": "date" } },
      { "header": "Amount", "value": "{{ @.amount }}", "style": { "format": "currency" } }
    ]
  }
}

dir is a literal, so an expression can’t flip it. Negating the key, as [email protected] does, sorts a number from the largest.

sort.ts
const sort = document.querySelector<HTMLSelectElement>('#sort')!

sort.addEventListener('change', () => {
  viewer.params = { ...viewer.params, sortBy: sort.value }
})

A row’s visible may read one too, as "row": { "visible": "=$.params.detailed" } does. Hiding the detail rows that way keeps the group headers, footers and subtotals, because a hidden row still counts. Use where when a row should leave the totals as well.

Fonts

fonts takes the same record pdf({ fonts }) takes. Pass the identical object to both so the preview measures against the faces the document will embed, and hold it across renders. The record and its buffers are the identity the viewer remembers a face by, so a fresh buffer means parsing the file again.

Reacting

rendered fires when a render lands on the sheet. error carries { error, kind }. kind is mount-render before any render lands, and update-render after. It’s export for a download or a print the viewer couldn’t produce, and host-option for a property it rejected before rendering anything. The mounting sample above listens for it.

pagechange fires with { pageNumber, pageCount } whenever either changes, the first render included. searchchange fires with { query, match, matches }. link fires before a URL link opens, and it’s cancelable.

renderComplete resolves once the pages within reach have painted, not every page, which is what a test awaits rather than a timer. A host that reads pixels off a page far down the document finds it blank until it’s scrolled to. It answers for the newest render at the moment it settles. A host awaiting it while properties keep changing waits for the last change, not for whichever render happened to be in flight when it asked.

A targets entry naming something other than pdf, xlsx, csv, docx, or html is a host mistake like any other malformed property. The error panel names the entry by its index and the name it carried, and the last good render stays up. The viewer accepts an html entry and doesn’t read it, so one list can serve both render and the viewer.

Driving it from your page

pageNumber reads the current page, counted from 1, and writing it goes there. A number past the end goes to the last page. One written before a render lands waits for it. pageCount reads the length. Both read 0 until a render lands.

query reads and writes the search. Writing it opens the find row and goes to the first match on or after the current page.

status.ts
viewer.pageNumber = 12

viewer.addEventListener('pagechange', ({ detail }) => {
  console.log(`page ${detail.pageNumber} of ${detail.pageCount}`)
})

viewer.query = 'laptop'

viewer.addEventListener('searchchange', ({ detail: { match, matches } }) => {
  console.log(matches ? `${match} of ${matches}` : 'No matches')
})

bar: false removes the bar and its controls. The page keys, the progress strip and the outline panel stay, so your own controls drive the viewer through these properties. To keep the bar and add to it, give the element children with slot="bar". They sit between the page controls and the search, view, and export controls.

A run carrying an href is a link in the text the viewer lays over the page. A reader clicks it, or reaches it with Tab and opens it with Enter. A URL opens in a tab of its own. An href naming a group’s label steps the sheet to that instance, the way an outline row does. A label no instance carried leaves plain text a reader can still select. Links follow the reach as the pages do, and move with the zoom.

A host page can catch the link event to drill through. Add a scheme of your own to the instance’s schemes, write it in the href, and call preventDefault(). Then assign the next report and its params. A #label link moves within the report and fires no event.

drill-through.ts
import { quario } from 'quario'

const q = quario({ schemes: ['https:', 'app:'] })

viewer.addEventListener('link', (event) => {
  const url = new URL(event.detail.href)
  if (url.protocol !== 'app:') return
  event.preventDefault()
  viewer.report = q.report(definitions[url.pathname])
  viewer.params = { region: url.searchParams.get('region') ?? '' }
})

Zoom, view, and chrome

zoom sets what the viewer opens at: 'width' (the default), 'page', or a percentage from 25 to 200. Both fits only shrink, so a viewer with room to spare shows the report at its true size. view takes 'continuous' (the default) or 'single', one page at a time. A single page turns by the page controls and keys, never by the wheel. A reader zooms by pinch, ctrl+wheel, or +, - and 0 with the sheet focused. Zooming repaints the pages, and every line breaks where it did.

colorScheme takes 'light', 'dark' or 'auto' and reaches the surrounding chrome, never the sheet.

outline takes true and adds one more control to the bar, opening a panel beside the sheet. The panel lists the report’s group instances in document order, nested by depth. A row scrolls the sheet to where that instance starts. The list is the same group tree the PDF target writes as bookmarks, so a report declares nothing for it. An instance whose bands all resolve hidden isn’t in it. The module is off by default, and the reader opens and closes the panel.

Printing

Set print: true to add Print to the export menu. The row shows only with a pdf target and in a browser that shows a PDF in a frame. The printed pages are the PDF export’s, marking included. Without the frame-src blob: grant, Print fails as an export error rather than printing a blank page. A page that can’t grant it leaves print off, and the reader prints the downloaded PDF.

Speaking your readers’ language

strings replaces the chrome’s English key by key, and a key you leave out stays English. Fixed text takes a string. A message that carries a value takes a function of it. A wrong key or shape is a host-option error naming strings.<key>.

strings.ts
viewer.strings = {
  firstPage: 'Erste Seite',
  pageCount: (count) => `von ${count}`,
}
viewer.lang = 'de'

The instance’s locale doesn’t set the chrome’s language. Set the element’s lang to match your strings, so a screen reader pronounces them right.

When not to use it

A server rendering a PDF into a response needs none of this. The viewer is for a browser where someone is looking. If your users only ever receive a file, render the PDF target directly and skip the element and its layout dependency.