Reader & protection
Folio can show a document as a reader: one page at a time, two-page spreads or a continuous scroll, with a reader bar, page-range exports and protection for content that may be viewed but not taken. This page covers the display modes, the protection layers, and how a host such as a university library (students may only view books) should split the work between Folio and its own server.
Who does what
Folio provides mechanisms. The host owns policy and enforcement.
| Folio (client and server packages) | The host (your app and your server) |
|---|---|
| Display modes, reader bar, page navigation | Who may open which book, and which pages |
| Canvas pages with the watermark in the pixels | Authentication, page-access tokens |
Copy/print/drag/context-menu deterrents, blur, tamper detection + onTamper | Logging tamper reports, deciding what they mean |
| Page-range, hardened-watermark and flattened PDF exports | Download quotas ("max 5 pages"), counting, audit logs |
onExportRequest and beforeRender hooks | Answering those hooks, and re-checking everything on the server |
@nextgensoftwares/folio-render-node: server-side page images and flattened PDFs | Serving only the pages a user may see, with per-user watermarks |
The client can always be bypassed
Everything that runs in the browser (hooks, CSS, canvas, blocked shortcuts) can be switched off by a user who controls that browser. Client-side checks are the user experience of a policy, never its enforcement. If a student must not get page 41, the server must not send page 41.
Display modes
<FolioView ref={view} editor={null} layout={layout} display="page" readOnly />
<FolioView ref={view} editor={null} layout={layout} display="spread" firstPageAlone readingDirection="rtl" />display | What it shows | Mounted pages |
|---|---|---|
'scroll' (default) | the continuous, virtualized column | the visible window + overscan |
'page' | one page, centred; scrolls inside when zoomed | the page ± prefetch (default 1), already painted |
'spread' | two pages side by side, the first alone by default | the spread ± prefetch |
All modes keep zoom (zoom, 'fit-width'), responsive auto-fit and touch. Paged modes add:
- keyboard:
PageDown/PageUpalways;←/→,↑/↓,Space,Home,Endwhen the view is read-only (an editable view keeps arrows for the caret); arrows followreadingDirection; - swipe on touch (a horizontal swipe or a quick flick; a zoomed page pans instead);
- side buttons (
pageButtons, default on) and a page-turn animation (pageTransition: 'slide' | 'fade' | 'none', off underprefers-reduced-motion); - the same
ViewportApias scrolling:scrollTo(page, y)jumps to the page, so the TOC plugin, search and comments work unchanged. The view handle is stable across mode switches; capture it once.
onPageChange(index) reports the first visible page in every mode.
Reader bar
<FolioReaderBar> is reader chrome built from slot items (slot reader): reader.prev, reader.page (page X of Y, type a number + Enter), reader.next, reader.slider, reader.zoom (−, %, +, fit width, fit page), reader.mode (scroll / page / spread). Hosts hide, move, replace or add items like any other slot (Customizing the UI).
<FolioReaderBar view={view} pageCount={layout.pages.length} display={mode} onDisplayChange={setMode}
onZoomChange={setZoom} pageSize={{ width: 794, height: 1123 }} ui={composed.ui} />It drives any ReaderTarget (a FolioView ref, a FolioImagePager ref).
Access windows: which pages a viewer may see
// Preview: first 10 pages. Assignment: pages 20–35 this week. Budget: 5 new pages per session.
<FolioView … pageAccess={{ allowed: [[1, 10]] }} />
<FolioView … pageAccess={{ allowed: [[20, 35]], navigation: 'skip', lockedAction: ({ index }) => <RequestButton page={index} /> }} />
<FolioView … pageAccess={{ maxVisible: 5, budgetKey: sessionId, onPageView: (i) => api.countView(bookId, i) }} />| Field | Meaning |
|---|---|
allowed | 1-based ranges ([[1, 10]], [20, [30, 35]]) or (position, index) => boolean; default every page |
maxVisible | at most this many distinct pages may be opened; revisits are free; a new budgetKey starts a new budget (session, hour, day) |
onPageView(index) | asked the first time each page is about to show; return false (or a promise of it) to deny. Use it to have the server count and decide |
navigation | 'skip' (default): keys, TOC, go-to page and the slider jump to the nearest allowed page in the direction of travel; 'lock': they land on the lock screen |
lockedPage, lockedAction | replace the placeholder, or add a call to action to it |
printLayout | the print layout, when the view shows a reflowed layout (see below) |
Pages outside the window render as a placeholder (a blurred skeleton, "Locked: page 37 isn't available" and your call to action). Their content is never mounted or painted: the page component, including the canvas renderer, never receives them. Pages still waiting for onPageView show "Checking access…" and are not mounted either. Only pages actually on screen spend the budget (overscan and prefetched neighbours don't). Works in scroll, page and spread modes, and in <FolioImagePager pageAccess>, which never requests the image of a locked page.
Reflow. Access is defined on print pages. A reflowed screen page is mapped to every print page holding any of its top-level blocks (reflowUnits); it is open only if all of them are, so a block straddling an allowed and a locked print page locks the screen that shows it (conservative: never leaks). Budgets count print pages too. Pass printLayout; without it positions count screen pages.
UX only
A client-side window hides pages the browser already has (with <FolioView>, the whole document is in memory). For real limits, use server page images: the server issues images (or page tokens through beforeRender) only for allowed pages and counts budgets itself.
Protection, from weakest to strongest
1. Deterrents: <FolioProtect>
<FolioProtect editor={editor} copy={{ maxChars: 200 }} print={false} blurOnFocusLoss onCopy={audit} onBlocked={audit}>
<FolioView … readOnly />
</FolioProtect>Blocks or limits copying ('block', { maxChars, budget }), the context menu, dragging, text selection and the print/save shortcuts; hides content in @media print; optionally blurs while the window is unfocused or docked devtools seem open. Stops casual copying; stops nobody determined.
2. Baked pages: canvas rendering
const pages = canvasPages({ painters: composed.exporters.canvas, forensic: { payload }, onTamper: report });
<FolioView … pageComponent={pages} />Each page is painted into a <canvas> by @nextgensoftwares/folio-render-canvas: text from the layout's positioned runs with the loaded fonts, images, rects, math (KaTeX drawn as vectors), and every page layer composited into the same pixels. There are no text nodes and no watermark element: deleting a node in devtools removes nothing, and there is no text to select or copy. A guard watches each page (MutationObserver plus a periodic check): a removed or replaced canvas, injected nodes, edited inline styles, a canvas hidden by an overriding stylesheet, or pixels drawn over from the console are repaired and reported through onTamper (rate-limited, once per kind and page).
What it does not stop: the document JSON is still in the page's memory (the client laid it out), so a user with devtools can read it. Use it with server-side checks, or go one step further:
3. Server-baked page images
The strongest option: students never receive the document. The server lays the book out with @nextgensoftwares/folio-layout, renders only the pages a user may see with @nextgensoftwares/folio-render-node (watermark with the user's name and a download id in the pixels, optional forensic mark), and the client shows them with <FolioImagePager>, which has the same display modes, lazy loading and neighbour prefetch.
<FolioImagePager ref={pager} pageCount={meta.pages} pageSize={meta.size} display="page"
pageSrc={(i) => `/api/books/${id}/pages/${i}.webp?token=${token(i)}`} beforeRender={fetchTokens}
top={<FolioReaderBar view={pager} pageCount={meta.pages} />} />Exports
| Export | What it protects |
|---|---|
exportPdf(layout, { pages }) | only the selected pages are written |
watermarkMode: 'interleaved' | watermark tiles drawn between content operators in the page's own content stream: no annotation, optional-content group or shared XObject to delete in one step |
permissions: { print: false, copy: false } | viewer flags (RC4-128, empty user password). Weak: many tools ignore them, others remove them in one command |
exportFlattenedPdf(layout, { rasterize, dpi }) | image-only pages: no fonts, no text operators, no OCR layer; the watermark is pixels |
docForPages(doc, layout, pages) (DOCX) | whole blocks touching the pages; DOCX reflows in Word, so a range can't be exact or enforced |
Per-page watermark variables: , , plus anything in variables (user, timestamp, downloadId) and pageVariables(page) for a value unique to each page. The forensic mark (forensicMark, readForensicMark) hides a 32-bit payload (e.g. a hash of user + download id) as a faint dot pattern repeated over the page.
Policy hooks
pdfExportPlugin({ onExportRequest }): called with{ pages, format, totalPages }before every export; returnfalseto veto,{ allow: false, reason }, or{ pages }to narrow (it can't widen). A throwing policy denies.maxPagesPolicy(5)is the demo policy the playground uses.canvasPages({ beforeRender })/<FolioImagePager beforeRender>: runs before pages paint or load, e.g. to fetch page-access tokens.
Both are UX. The server answers the same questions again.
Threat model
| Attack | Deterrents | Canvas pages | Server images | Flattened PDF |
|---|---|---|---|---|
| Select & copy text | stopped | stopped (no text) | stopped | stopped |
| Print / Save page | mostly | mostly | mostly | n/a |
| Delete the watermark element | not addressed | stopped (pixels; repaired + reported) | stopped | stopped (pixels) |
| Read the document from devtools / network | not addressed | not addressed (JSON is on the client) | stopped (only allowed pages are sent) | stopped |
| Download more pages than allowed | client UX only | client UX only | enforced by the server | enforced if the server builds it |
| Screenshot, screen recording, camera | never preventable | never | never | never |
Screenshots and cameras are always possible. Watermarks don't prevent copying; they deter it (the copy carries the user's name) and trace it (the download id and forensic mark identify the source). The forensic mark survives lossless copies and mild scaling; JPEG re-compression, photos of a screen and deliberate blurring degrade or remove it.
Recipe: a library like Alkitab
- Server lays out. On publish, the server lays the book out with
@nextgensoftwares/folio-layoutand the same fonts as the client (@nextgensoftwares/folio-fonts), and stores the page count and size (the same data as@nextgensoftwares/folio-sync's layout report). - Client gets metadata only. Students receive
{ pages, size }and render<FolioImagePager>; never the document JSON for protected books. - Pages on demand.
GET /books/:id/pages/:nchecks the student's access to the book and page (enrolment, faculty, published), thenrenderPageImage(layout, n, { variables: { user, timestamp }, forensic, format: 'webp' }). Cache per (book version, page, user) briefly; log every page served. - Downloads.
POST /books/:id/downloads { pages }checks the quota (e.g. 5 pages per download, N per day), creates a download id, rendersexportFlattenedPdfNode(layout, { pages, render: { variables: { user, downloadId }, forensic } }), counts it and logs it. The client'sonExportRequestonly mirrors the quota for UX. - Access windows. Store per student the allowed ranges (preview = first N pages for non-enrolled students,
[[20, 35]]for this week's assignment) and a page budget (e.g. 30 new pages per day, keyed by date). Send the ranges to the client aspageAccess.allowedfor UX, but haveGET /books/:id/pages/:n(or the token endpoint behindbeforeRender) refuse pages outside them, and makeonPageViewcallPOST /books/:id/views/:n, which counts unique pages per day and answers allow/deny. The client'smaxVisibleonly mirrors that number. - Audit. Store
onTamperandonCopyreports with the user and book; treat them as signals, not proof (extensions and accessibility tools can trigger them). - Leak tracing. For a leaked page, read the visible watermark, or run
readForensicMarkon the image to recover the payload and look up the download.
Staff who edit books keep the normal editor; protection applies to the student view only.