The preview
The engine’s output is a display structure: a book’s worth of glyph positions, rules and image boxes. Turning that into pixels is a painter’s job, and the package ships one. It draws a page as SVG: the glyphs, one <text> element per run with an explicit x for every character, and an invisible layer over them, one <text> per line, for a reader to select and copy from.
Mounting
import { Preview } from 'fleuron';
const preview = await Preview.mount(document.querySelector('#book'));await preview.setStyle(css);await preview.setMarkdown(markdown);
preview.page = 12;preview.zoom = 1.5;Preview starts the worker, loads the module into it, keeps the session that makes a second render cheap, fetches the fonts the book was set in, and paints one page into the element it was given. The encoded buffer, the worker messages and the display structure are handled internally, and all three stay exported.
Images
Nothing in the package fetches a url. The host fetches the file and hands over the bytes, under the same string the manuscript wrote:
// This host reads them from a directory, another might read a// database or an editor's unsaved buffer.async function load(url) { const file = await fetch(`/manuscript/${url}`); return new Uint8Array(await file.arrayBuffer());}
const preview = await Preview.mount(element, { images: { 'images/lilliput.jpg': await load('images/lilliput.jpg') },});
// One that turns up after the book has already been set.await preview.addImage('images/colophon.png', await load('images/colophon.png'));The string the manuscript writes is the name the image is matched under, so the key has to be that string, and it does not have to be a real URL. Where the file was fetched from never reaches the engine, which is why the url appears twice above.
The module reads the header for the intrinsic size, which is how much room the image takes on the page, and decodes nothing. The preview keeps a blob: url over the same bytes and the painter draws from that, so the page on screen and the page in the PDF are one file.
An image the engine has never been handed is a warning and a gap where it would have gone. Pass asset instead of images to supply the urls directly, and the preview hands the display structure’s asset to it rather than resolving a blob of its own.
Faces from a stylesheet
A @font-face rule gives a face a family name, a weight and a style. The host fetches the font file and hands over the bytes under the string that url() holds, the same way as an image. The following example loads one width of Junicode as a family of its own:
const preview = await Preview.mount(element, { fonts: { 'Junicode-Cond.otf': await load('fonts/Junicode-Cond.otf') },});
await preview.setStyle(` @font-face { font-family: "Junicode Cond"; src: url("Junicode-Cond.otf"); font-weight: 400; font-style: normal; }
book { font-family: "Junicode Cond", serif; }`);The engine registers the face under the family, the weight and the style that the rule declares. The engine does not use the name in the file. A family whose files all give the same name can show each width as a separate family.
The file and the sheet can arrive in either order. addFont(bytes, url) hands over a file after the preview is mounted. A rule whose url has no bytes gives a warning, and the text uses the next family in its font-family list.
When a new sheet changes or removes a @font-face rule, the next render uses the faces that the new sheet declares. addFont(bytes) with no url registers a face under the family name in the file.
Everything in the first table changes an input and repaints. Everything in the second only reads or moves what is already there.
setMarkdown(text, name?) | one markdown source as the whole book |
setBook(sources) | several sources, in reading order |
edit(name, text) | one source replaced, the rest of the book left standing |
remove(name) | one source dropped |
setAttributes(name, { classes, id }) | the classes and id of one source’s sections |
setMetadata({ title, author, extra }) | what names the book |
setStyle(css) | the author’s styling: one string, or named sheets in cascade order |
addFont(bytes, url?) | a font file, under the @font-face url that names it, or under the family name in the file when there is no url |
addImage(url, bytes) | one image, by the url the manuscript names it by |
render(ops?) | lay out again, or apply anything the methods above do not reach |
pages | how many pages the book laid out to |
page | the page on screen, counting from 1; assigning to it turns the page |
next(), previous() | the same, one page at a time |
zoom | points to CSS pixels |
warnings | what the run warned about |
onLink | the function that a click on a link calls, and assigning to it replaces the function |
follow(link) | follows a link the same way a click does |
svg(page?) | the markup for a fetched page, painted but not mounted; empty for one that is not |
exportPdf() | the same run as PDF bytes |
exportEpub() | the book as a reflowable EPUB, with its warnings |
exportEpubFiles() | the same EPUB as its files, not zipped, with the spine |
destroy() | closes the worker and empties the element |
Set the dialect and the section-splitting level when the preview is mounted, not after. Changing either means reading every source again, and the preview keeps no copy of them.
A book of several files
setBook takes the sources in reading order:
await preview.setBook([ { name: 'ch01.md', text: one }, { name: 'ch02.md', text: two },]);After that, edit(name, text) is the keystroke path: it reads that one file again and every other file keeps the lines it already has. A name the book has not seen is appended, so edit is also how a file arrives mid-session. remove(name) is how one leaves.
A sheet reaches the sections of a source by the classes and the id that the host gives the source. The names stay with the source through an edit and a reorder. A rule for section.chapter matches the same chapters after the author moves one. The text does not change, so no byte offset of a node moves. The following example names the front matter and the first chapter, and then changes the classes of the chapter:
await preview.setBook([ { name: 'preface.md', text: preface, attributes: { classes: ['front'] } }, { name: 'ch01.md', text: one, attributes: { classes: ['chapter'], id: 'chapter-one' } },]);await preview.setAttributes('ch01.md', { classes: ['chapter', 'opening'] });setAttributes(name, {}) takes the names away.
A book of one file takes its title and author from that file’s frontmatter. A book of several has no frontmatter of its own, so it is left unnamed until setMetadata names it:
await preview.setMetadata({ title: "Gulliver's Travels", author: 'Jonathan Swift', extra: { language: 'en' },});Naming a book costs no layout: the pages already on screen are the pages the export writes under the new name. extra.language is the one field a stage below the PDF writer reads, and a book that changes it is hyphenated again.
When it renders
Every method that changes an input renders. There is no timer.
The worker lets everything the host has already posted arrive before it renders anything, so a burst of edits fired without awaiting them collapses into one render. Every edit is still applied, in order, and the render that survives is the same as if nobody had typed. The ones it overtook resolve to nothing rather than painting a stale page.
Nothing is interrupted to make that happen. A render occupies the worker from start to finish, so the collapsing happens in the gap before one begins. A render already running finishes, its output is dropped as stale, and the next one runs. A superseded render is one that never started, not one abandoned half-way, which is what keeps the session’s caches sound.
The effect is a debounce whose delay is the last render’s duration: a long book waits behind its own slow render, and a short one repaints at once. A host that wants a fixed delay puts it in front of these calls.
Turning pages
Preview fetches one page at a time, not the whole book: the page on screen and the one either side of it. Assigning to page inside that reach paints at once, from what is already fetched. A page further off asks the worker and paints once the reply lands, the frame staying as it was until then. pages reports how many pages the book has regardless of how much of it has crossed.
An edit drops whatever pages were fetched, since the book they came from no longer stands, and fetches the page on screen fresh against the book the edit produced.
onRender is called after every render that reaches the screen, with the reply behind the page on screen: output.pages has that page alone, output.bookPages is the book’s own length, and output.fonts, output.assets and output.warnings are the whole run’s.
const preview = await Preview.mount(element, { onRender: (output) => console.log(`page ${output.first + 1} of ${output.bookPages}`),});In React
fleuron-react is the same thing as a component:
import { Preview } from 'fleuron-react';
<Preview markdown={markdown} css={css} page={page} zoom={1.5} />;The manuscript and the stylesheet are props, so an edit is a re-render and the engine is handed the one input that changed. onLink is a prop, and a new function on a re-render replaces the old one. images is a prop too, so one that arrives after the book does still reaches the page. fonts is a prop in the same way, keyed by the @font-face url. onMount hands back the underlying preview, for the page count and the PDF export.
React is not a dependency of fleuron. The wrapper contains no engine logic, so a host that does not use React downloads none of it, and deleting the wrapper leaves a preview a plain page can still mount.
Painting a page yourself
Call the painter directly for the pages without the mounting, when drawing to a canvas, running a scroll container of your own, or rendering where there is no DOM:
import { decodeDisplayList, paintPage } from 'fleuron';
const output = decodeDisplayList(bytes);element.innerHTML = paintPage(output.pages[0], { fonts: output.fonts, zoom: 2 });paintPage returns the markup of one <svg> element. Its viewBox is the page size in points with the origin at the top left, which is the display structure’s own coordinate system, so nothing is rescaled on the way out.
The second argument is all optional:
| option | default | |
|---|---|---|
fonts | none | The font table, output.fonts. Leave it out and the painter cannot name the faces, so every run falls back to a generic serif. |
zoom | 1 | Points to CSS pixels. This sets the width and height on the <svg> element and nothing else: the coordinates inside it do not move, so the page scales without re-laying out. |
paper | '#ffffff' | The fill of a rectangle painted behind the page. null paints none, leaving the page transparent. |
ink | '#000000' | The fill for a run or a rule the sheet left black. A colored run takes its own color instead. ink reaches the preview only: the export prints black wherever the sheet named no color. |
assets | none | The asset table, output.assets. Leave it out and the painter has no url to resolve, so every image is drawn as an empty box. |
asset | none | (asset, index) => url, for images. |
links | true | Whether each line of each link gets a transparent mark. false leaves the marks out, for a host that marks links itself. See links without the preview. |
Layout never decodes an image. It probes the header for the intrinsic size, reserves a box of the right shape, and records an index into output.assets. asset turns that entry back into something a browser can load: any URL an <img> would take, including a blob: or data: one, or null when there is nothing to supply. On null the painter outlines the box the image would have filled, so the space it takes is still visible.
Why the preview matches the export
A browser handed a string and a font does its own typesetting: it chooses glyphs, applies kerning and ligatures, and works out where each one goes. fleuron has already done that work, and if the browser did it again the two answers would differ a little over a different shaper version, a rounded advance or a substituted font.
So the painter leaves the browser nothing to work out. Each <text> sets an x for every one of its characters, taken from the glyph the engine placed there, and the run’s axes and OpenType features are pinned so the browser reaches the glyphs the engine measured rather than the ones the characters spell.
Where text-transform or small capitals changed the characters, the run has both. The painter draws what was shaped; beside it is what the author wrote, which is what selection and copy and paste return.
The PDF writer reads the same display structure, so the two painters draw the same numbers, glyph for glyph.
Selection and copy
A drag over the page selects text from an invisible layer over the glyphs. The layer has one <text> for each line rather than for each run. Nothing that the painter draws under it takes pointer events. A drag that starts between two lines or beside a line starts at the nearest line.
The highlight on a selected line is one band from the first selected character to the last. The band is as tall as the glyphs of the largest run on the line.
Copy answers from that layer’s own selection rather than the browser’s default serialization across it: a selection spanning several lines joins them in reading order, with nothing duplicated or dropped.
A selection stays within one page: there is no boundary between two of them to drag across.
Links
A click on a link follows it. A link to a place in the book turns the preview to the page of that place. A link to a url opens the url in a new window.
onLink lets the host decide what a click does. The preview calls it with the link and the click event, before it follows the link. If onLink returns false, the preview does not turn the page. If the host gives an onLink, the preview opens no url, and the host decides what to do with the url.
The following example opens a url in a browser tab that the host controls, and turns the page for a link in the book:
const preview = await Preview.mount(element, { onLink: (link) => { if (link.to.kind === 'uri') { openTab(link.to.url); } },});The following example shows the target of a link in an editor and keeps the page on screen:
preview.onLink = (link) => { if (link.to.kind === 'place') { editor.reveal(link.to.node); return false; }};A click at the end of a drag selects text and follows no link. A click whose default the host already prevented follows no link either. follow(link) follows a link the same way a click does.
Links without the preview
Each page has links. A link has areas, one box for each line of the link on the page, in points. It also has to, which is one of these:
{ kind: 'place', node, place }{ kind: 'uri', url }node is the element that the link names, and place is its box on the page where it starts. place.page is the index of that page in the book, counting from 0. It is the first of the range that fetches the page. So a host that holds only some pages can follow a link to any page. The display structure describes the boxes.
paintPage puts a transparent <rect> over each box, above the selection layer. Its data-link is the index of the link in page.links. The rectangle takes no pointer events and is not an <a>. A drag still selects the text under it, and a click does only what the host decides.
linkAt(page, x, y) gives the link at a point in page points, or null. It reads the boxes and not the DOM, so the order of the layers in the SVG does not change the answer. The following example follows a click on a page that the host drew, to a page that the host did not fetch yet:
import { linkAt, paintPage } from 'fleuron';
frame.innerHTML = paintPage(page, { fonts: output.fonts });const svg = frame.querySelector('svg');
svg.addEventListener('click', async (event) => { const box = svg.getBoundingClientRect(); const x = ((event.clientX - box.left) * page.width) / box.width; const y = ((event.clientY - box.top) * page.height) / box.height; const link = linkAt(page, x, y); if (link?.to.kind === 'place') { event.preventDefault(); const reply = await client.preview([], { first: link.to.place.page, count: 1 }); if (reply !== null) { frame.innerHTML = paintPage(reply.pages[0], { fonts: reply.fonts }); } }});Fonts
A page is usually set in several faces at once: body text, an italic aside, a bold heading. Each text run in the display structure names a fontId, and output.fonts is the table those ids index. The painter draws every run in the file registered under that run’s id.
Those files have to reach the browser as FontFaces. The host already has the bytes for a face it registered. The face built into the engine has no URL to fetch it from, so the module hands it back:
import { faceFamily } from 'fleuron';
const bytes = await client.fontBytes(fontId);document.fonts.add(new FontFace(faceFamily(fontId), bytes.buffer));faceFamily(fontId) is the family name paintPage will ask for, exported so both ends agree on it. Preview does all of this itself, for every face a run actually used.
One file can answer for several faces. The bundled EB Garamond is a variable font whose Regular, Medium, Bold and the rest are one file read at different points on a wght axis, and each of those is its own fontId. The font table records the point and the painter writes it out as font-variation-settings. A painter that ignored it would draw every weight at the file’s default.
A face that never arrives does not blank the run. The painter’s font-family list ends in serif, so the text appears in whatever font the reader has.
That fallback is also a setting. Preview.mount(element, { faces: 'host' }) registers nothing and leaves the painter’s list to resolve against whatever the document already has, under the family the display structure names. Use it when the host already serves the same file, or a subset of it with the same metrics: the glyphs land on the x the display structure gave them either way, and parsing a book face twice is main-thread work. The default is 'module', which is the only way to be certain the page on screen is set in the face the export will use.
The harness
examples/preview/ is a page that opens the fixture book, pages through it, and puts the browser’s own PDF viewer beside the preview, showing the same page of the same run.
node examples/preview/serve.mjsIt is written the way a consumer writes one, naming no buffer, no worker and no display structure.