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.
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.
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.
{
"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.
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.
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.
Following a link
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.
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>.
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.