Standalone reader
@nextgensoftwares/folio-reader shows published books to readers without shipping the editor: no @nextgensoftwares/folio-editor, no ProseMirror, no editing plugins. It paints Folio layouts (the same pages the editor and the PDF export produce) into a virtualized, accessible page view, one chapter at a time.
pnpm add @nextgensoftwares/folio-reader @nextgensoftwares/folio-layout @nextgensoftwares/folio-model
# optional: @nextgensoftwares/folio-fonts (client-side layout), @nextgensoftwares/folio-export-pdf (PDF export), reactA book is chapters
A ReaderSource lists the parts of a book cheaply, then hands over each chapter when the reader needs it:
import { createReader, localLayout, type ReaderSource } from '@nextgensoftwares/folio-reader';
const source: ReaderSource = {
listChapters: async () => (await api.get(`/books/${id}/chapters`)).map((c) => ({
id: c.id, title: c.title, kind: c.kind, // 'chapter' | 'front-matter' | 'inserted'
pageCount: c.layoutReport?.pages, // exact counts size the scrollbar up front
headings: c.headings, // TOC before the chapter loads
})),
loadChapter: async (chapterId) => api.get(`/books/${id}/chapters/${chapterId}/content`), // { doc } or { layout }
search: async (q) => api.get(`/books/${id}/search`, { q }), // optional
};
const reader = createReader(el, { source, layout: localLayout({ measurer: engine, theme }) });- Chapters near the viewport are loaded (two at a time), the next one is prefetched near a chapter's end, and laid-out chapters beyond
keepChapters(default 4) are dropped again: memory stays flat in a 7,000-page book. - Chapters whose count the metadata doesn't give are laid out in idle time just to learn it (
countInBackground), so "Page X of Y" becomes exact; until then totals show≈. Navigation to a book-wide page number lays out the chapters before it first, so it always lands right. - A chapter can arrive as a document (laid out on the client), a precomputed layout (
toPlainLayout(layout)on the server; validated byreadLayouton the client) or both (layout for pages, document for find and block ids). - Windowed chapters: with
loadPages(id, from, to)and a knownpageCount, a chapter is never loaded whole; pages arrive in windows of 8 as they near the viewport and are dropped far from it. The client then holds only what it shows: the strongest option for protected books.
Where layout runs
localLayout(options, { prepare }) | main thread; prepare loads the chapter's fonts first (readerFonts(...).prepare) |
workerLayout(new Worker(...), { math }) | a Web Worker running serveLayout(setup); math boxes (KaTeX needs the DOM) are measured on the main thread on request |
| server | send { layout } (or windows) and skip layout code on the client entirely |
Page numbers
reader.setNumbering('global'); // book pages: 1…N over chapters, front matter i, ii…, inserted pages unnumbered
reader.setNumbering('local'); // chapter pages: 1…n in each chapterstate.current.position is "Page X of Y" in the current mode. The go-to box (goToInput) takes 12, xiv, 3:12 (chapter 3, page 12) and Arabic-Indic digits. insertedPart(id, title, pages) builds covers, plates, title and blank pages: no number, no header/footer, skipped by "Page X of Y".
Deep links and restore: goTo({ chapterId, page }), goTo({ chapterId, blockId }), goTo({ chapterId, point }), goTo({ global: 120 }); save onLocationChange's ReaderLocation and pass it back as initial.
Plugins
Reader plugins have the FolioPlugin shape a reader understands (pageLayers, colors, renderers, exporters, ui.items with reader: true) plus attach:
const hl = highlights({ store: { load: api.highlights, save: api.saveHighlight, remove: api.deleteHighlight } });
createReader(el, {
source, layout,
variables: { nid: user.nationalId, email: user.email },
plugins: [
watermarkPlugin({ text: 'NID:{{nid}}\n{{email}}', opacity: 0.07, angle: -40 }),
protectPlugin({ copy: { maxChars: 200 }, print: 'current-page' }),
hl.plugin,
],
});- Highlights: anchors are
TextAnchors: block id + textblock path + offsets, plusquote/prefix/suffix. On load each one is re-found withreanchor(exact, else by quote and context, elseorphaned); the same DOM-free function runs on a server after a republish.hl.flash(range)serves?text=/?annotation=deep links.visibleLayersfiltersmine/shared/teaching. - Find:
createFind(reader)searches loaded chapters (Arabic diacritics, tatweel and letter variants ignored) and merges the source'ssearchhits. - Themes, zoom, display:
setTheme('light' | 'sepia' | 'dark'),setZoom(n | 'fit-width' | 'fit-page'),setDisplay('single' | 'spread'),toggleFullscreen(). RTL books: spreads and ←/→ followdirection. view: 'paged'(orsetView('paged'), the toolbar's Reading switch) shows one page, or one spread, at a time at fit-page zoom: side buttons, ←/→, PageUp/PageDown/Space, Home/End, the wheel, swipes and click zones on the outer fifths (turnZones: falseturns those off) all turn pages, mirrored in right-to-left books. The new page slides in from the reading direction, except underprefers-reduced-motion.pageHash: truekeeps#page=3:12in the URL and follows any go-to-page input put there (12,iv,3:12).
Accessibility
Pages are region landmarks named "Page 3 of 120, Chapter 2"; text is real text in reading order (bidi runs in logical order), headings are headings, math has its LaTeX as a label, decorative chrome (headers/footers, repeated table headers, watermarks) is hidden from assistive tech, and page changes are announced politely. Keys: ←/→ page, [/] chapter, Ctrl/⌘ +/−/0 zoom, Ctrl/⌘ F find; PageUp/PageDown/Space/Home/End scroll natively.
Security
- Document text is only ever inserted as text nodes; link targets and image URLs pass an allowlist (no
javascript:, nodata:text/html); colours go tobackground-color(neverurl()); styles use constructed stylesheets, so the reader runs under a strict CSP.renderMathoutput is inserted as HTML: use KaTeX withtrust: false. - Copy and print protection are deterrents. Anyone controlling the browser can bypass them. Print policies:
allow,current-page(only the page on screen prints, watermarked: Alkitab's current behaviour) andblock. - The watermark deters and traces; it can't prevent screenshots or cameras.
- Real limits belong on the server: send only allowed pages (windowed sources), enforce
pageAccessthere, count downloads incanExport's server twin. See Reader & protection.
Export
const { exportReaderPdf } = await import('@nextgensoftwares/folio-reader/export-pdf'); // pdf-lib loads only now
const pdf = await exportReaderPdf(reader, {
scope: 'chapter', // 'chapter' | 'selection' | 'book'
canExport: (req) => api.allowDownload(req), // asked before anything is exported
pdf: { fonts, shaper: engine },
variables: { downloadId },
});The watermark (and any page layer with a PDF painter) is drawn into every page as vector text with this download's variables.
Server-side watermarked download (Node / NestJS)
The authoritative copy should be stamped on the server, per user, without a browser. Everything involved is DOM-free: lay the chapters out with @nextgensoftwares/folio-layout and HarfBuzz (@nextgensoftwares/folio-fonts), then exportPagesPdf bakes the reader plugins' page layers into every page:
import { FontEngine } from '@nextgensoftwares/folio-fonts';
import { layoutDocument } from '@nextgensoftwares/folio-layout';
import { mapZoneConfig } from '@nextgensoftwares/folio-import';
import { watermarkPlugin } from '@nextgensoftwares/folio-reader';
import { exportPagesPdf } from '@nextgensoftwares/folio-reader/export-pdf';
const engine = await FontEngine.create({ fallback: ['Noto Sans Arabic'] }); // once per worker
for (const f of fontFiles) engine.addFont({ family: f.family, data: f.bytes });
const fonts = (spec) => fontBytesFor(spec); // the same files, by family/weight
let first = 1;
const layouts = [];
for (const ch of chapters) { // Folio documents, in book order
const hf = mapZoneConfig(ch.headerFooterConfig, { title: ch.title, bookTitle, author, firstPageNumber: first });
const layout = layoutDocument(ch.doc, { measurer: engine, theme, headerFooter: hf.config, variables: hf.variables, firstPageNumber: first, totalPages: bookTotal });
layouts.push(layout);
first += layout.pages.length;
}
const pdf = await exportPagesPdf(layouts, {
pdf: { fonts, shaper: engine, images: (src) => loadImageBytes(src), math }, // images/math as for exportPdf
plugins: [watermarkPlugin({ text: 'NID:{{nid}}\n{{email}}', opacity: 0.07, angle: -40, fontFamily: 'Geist', guard: false })],
variables: { nid: user.nationalId, email: user.email, downloadId },
});- Cache the unwatermarked layouts (or their page reports) per published version; only
exportPagesPdfruns per download. - The watermark is real text drawn at 7% opacity: it survives printing and copying the file, and it can be traced. It is not encryption; add
encryptPdf(permissions) from@nextgensoftwares/folio-export-pdfif needed. - Run it in a queue worker (it is CPU-bound), and keep one
FontEngineper worker.
A university library (Alkitab-style)
- On publish, the server lays out each chapter and stores the layout report (page count, headings, block ids) and, for protected books, the pages.
- The student client gets
listChaptersmetadata only;loadPagesreturns page windows after checking enrolment and access (token per request). - The reader shows them with the user's watermark,
protectPlugin, and highlights stored by the host;onPageViewfeeds progress and analytics. - Downloads go through
exportReaderPdffor UX, and the server re-checks quotas and stamps its own watermark withexportPagesPdf(above).
Not yet covered
- Laying out from a mid-chapter block cursor (
loadWindow → { blocks, cursor }): layout can't start inside a split block yet; windowed sources send page layouts instead. - Highlight hover callbacks with page rects; checkpoint markers at block positions.
- Canvas pages repaint (not rescale) on zoom; mixed page sizes in one spread row are top-aligned.