UNPKG

41 kBMarkdownView Raw
1# NodeJS / TypeScript Readium-2 "navigator" component
2
3NodeJS implementation (written in TypeScript) for the navigator module of the Readium2 architecture ( https://github.com/readium/architecture/ ).
4
5[![License](https://img.shields.io/badge/License-BSD%203--Clause-blue.svg)](/LICENSE)
6
7## Build status
8
9[![NPM](https://img.shields.io/npm/v/r2-navigator-js.svg)](https://www.npmjs.com/package/r2-navigator-js) [![David](https://david-dm.org/readium/r2-navigator-js/status.svg)](https://david-dm.org/readium/r2-navigator-js)
10
11[Changelog](/CHANGELOG.md)
12
13## Prerequisites
14
151) https://nodejs.org NodeJS >= 8, NPM >= 5 (check with command line `node --version` and `npm --version`)
162) OPTIONAL: https://yarnpkg.com Yarn >= 1.0 (check with command line `yarn --version`)
17
18## GitHub repository
19
20https://github.com/readium/r2-navigator-js
21
22There is no [github.io](https://readium.github.io/r2-navigator-js) site for this project (no [gh-pages](https://github.com/readium/r2-navigator-js/tree/gh-pages) branch).
23
24## NPM package
25
26https://www.npmjs.com/package/r2-navigator-js
27
28Command line install:
29
30`npm install r2-navigator-js`
31OR
32`yarn add r2-navigator-js`
33
34...or manually add in your `package.json`:
35```json
36 "dependencies": {
37 "r2-navigator-js": "latest"
38 }
39```
40
41The JavaScript code distributed in the NPM package is usable as-is (no transpilation required), as it is automatically-generated from the TypeScript source.
42
43Several ECMAScript flavours are provided out-of-the-box: ES5, ES6-2015, ES7-2016, ES8-2017:
44
45https://unpkg.com/r2-navigator-js/dist/
46
47(alternatively, GitHub mirror with semantic-versioning release tags: https://github.com/edrlab/r2-navigator-js-dist/tree/develop/dist/ )
48
49The JavaScript code is not bundled, and it uses `require()` statement for imports (NodeJS style).
50
51More information about NodeJS compatibility:
52
53http://node.green
54
55Note that web-browser Javascript is currently not supported (only NodeJS runtimes).
56
57The type definitions (aka "typings") are included as `*.d.ts` files in `./node_modules/r2-navigator-js/dist/**`, so this package can be used directly in a TypeScript project.
58
59Example usage:
60
61```javascript
62// currently no index file
63// import { * } from "r2-navigator-js";
64
65// ES5 import (assuming node_modules/r2-navigator-js/):
66import { trackBrowserWindow } from "r2-navigator-js/dist/es5/src/electron/main/browser-window-tracker";
67
68// ... or alternatively using a convenient path alias in the TypeScript config (+ WebPack etc.):
69import { trackBrowserWindow } from "@r2-navigator-js/electron/main/browser-window-tracker";
70```
71
72## Dependencies
73
74https://david-dm.org/readium/r2-navigator-js
75
76A [package-lock.json](https://github.com/readium/r2-navigator-js/blob/develop/package-lock.json) is provided (modern NPM replacement for `npm-shrinkwrap.json`).
77
78A [yarn.lock](https://github.com/readium/r2-navigator-js/blob/develop/yarn.lock) file is currently *not* provided at the root of the source tree.
79
80## Continuous Integration
81
82TODO (unit tests?)
83https://travis-ci.org/readium/r2-navigator-js
84
85Badge: `[![Travis](https://travis-ci.org/readium/r2-navigator-js.svg?branch=develop)](https://travis-ci.org/readium/r2-navigator-js)`
86
87## Version(s), Git revision(s)
88
89NPM package (latest published):
90
91https://unpkg.com/r2-navigator-js/dist/gitrev.json
92
93Alternatively, GitHub mirror with semantic-versioning release tags:
94
95https://raw.githack.com/edrlab/r2-navigator-js-dist/develop/dist/gitrev.json
96
97## Developer Primer
98
99### Quick Start
100
101Command line steps (NPM, but similar with YARN):
102
1031) `cd r2-navigator-js`
1042) `git status` (please ensure there are no local changes, especially in `package-lock.json` and the dependency versions in `package.json`)
1053) `rm -rf node_modules` (to start from a clean slate)
1064) `npm install`, or alternatively `npm ci` (both commands initialize the `node_modules` tree of package dependencies, based on the strict `package-lock.json` definition)
1075) `npm run build:all` (invoke the main build script: clean, lint, compile)
1086) `ls dist` (that's the build output which gets published as NPM package)
109
110### Local Workflow (NPM packages not published yet)
111
112Strictly-speaking, a developer needs to clone only the GitHub repository he/she wants to modify code in.
113However, for this documentation let's assume that all `r2-xxx-js` GitHub repositories are cloned, as siblings within the same parent folder.
114The dependency chain is as follows: `r2-utils-js` - `r2-lcp-js` - `r2-shared-js` - `r2-opds-js` - `r2-streamer-js` - `r2-navigator-js` - `r2-testapp-js`
115(for example, this means that `r2-shared-js` depends on `r2-utils-js` and `r2-lcp-js` to compile and function properly).
116Note that `readium-desktop` can be cloned in there too, and this project has the same level as `r2-testapp-js` in terms of its `r2-XXX-js` dependencies.
117
1181) `cd MY_CODE_FOLDER`
1192) `git clone https://github.com/readium/r2-XXX-js.git` (replace `XXX` for each repository name mentioned above)
1203) `cd r2-XXX-js && npm install && npm run build:all` (the order of the `XXX` repositories does not matter here, as we are just pulling dependencies from NPM, and testing that the build works)
1214) Now change some code in `r2-navigator-js` (for example), and invoke `npm run build` (for a quick ES8-2017 build) or `npm run build:all` (for all ECMAScript variants).
1225) To update the `r2-navigator-js` package in `r2-testapp-js` without having to publish an official package with strict semantic versioning, simply invoke `npm run copydist`. This command is available in each `r2-XXX-js` package to "propagate" (compiled) code changes into all dependants.
1236) From time to time, an `r2-XXX-js` package will have new package dependencies in `node_modules` (for example when `npm install --save` is used to fetch a new utility library). In this case ; using the `r2-navigator-js` example above ; the new package dependencies must be manually copied into the `node_modules` folder of `r2-testapp-js`, as these are not known and therefore not handled by the `npm run copydist` command (which only cares about propagating code changes specific to `r2-XXX-js` packages). Such mismatch may typically occur when working from the `develop` branch, as the formal `package-lock.json` definition of dependencies has not yet been published to NPM.
1247) Lastly, once the `r2-navigator-js` code changes are built and copied across into `r2-testapp-js`, simply invoke `npm run electron PATH_TO_EPUB` to launch the test app and check your code modifications (or with `readium-desktop` use `npm run start:dev`).
125
126## Programmer Documentation
127
128An Electron app has one `main` process, and potentially several `renderer` processes (one per `BrowserWindow`).
129In addition, there is a separate runtime for each `webview` embedded inside each `BrowserWindow`
130(this qualifies as a `renderer` process too).
131Communication between processes occurs via Electron's IPC asynchronous messaging system,
132as the runtime contexts are otherwise isolated from each other.
133Each process launches its own Javascript code bundle. There may be identical / shared code between bundles,
134but the current state of a given context may differ from the state of another.
135Internally, state synchronisation between isolated runtimes is performed using IPC,
136or sometimes by passing URL parameters as this achieves a more instant / synchronous behaviour.
137
138Most of the navigator API surface (i.e. exposed functions) relies on the fact that each process is effectively
139a runtime "singleton", with a ongoing state during its lifecycle. For example, from the moment a `BrowserWindow`
140is opened (e.g. the "reader" view for a given publication), a `renderer` process is spawned,
141and this singleton runtime maintains the internal state of the navigator "instance"
142(this includes the DOM `window` itself).
143This explains why there is no object model in the navigator design pattern,
144i.e. no `const nav = new Navigator()` calls.
145
146### Electron main process
147
148#### Session initialization
149
150```javascript
151// ES5 import (assuming node_modules/r2-navigator-js/):
152import { initSessions, secureSessions } from "r2-navigator-js/dist/es5/src/electron/main/sessions";
153
154// ... or alternatively using a convenient path alias in the TypeScript config (+ WebPack etc.):
155import { initSessions, secureSessions } from "@r2-navigator-js/electron/main/sessions";
156
157// Calls Electron APIs to setup sessions/partitions so that local storage (etc.)
158// for webviews are sandboxed adequately, and to ensure resources are freed when the
159// application shuts down.
160// Also initializes the custom URL protocol / scheme used under the hood to ensure
161// that individual publications have unique origins (to avoid inadvertantly sharing
162// localStorage, IndexedDB, etc.)
163// See `convertCustomSchemeToHttpUrl()` and `convertHttpUrlToCustomScheme()` below.
164initSessions(); // uses app.on("ready", () => {}) internally
165
166app.on("ready", () => {
167
168 // `streamerServer` is an instance of the Server class, see:
169 // https://github.com/readium/r2-streamer-js/blob/develop/README.md
170 const streamerServer = new Server( ... ); // r2-streamer-js
171
172 // This sets up the Electron hooks that protect the transport layer by adding encrypted headers
173 // in every request (see `streamerServer.getSecureHTTPHeader()`).
174 // This relies on the private encryption key managed by `r2-streamer-js` (in secure mode),
175 // which is also used for the self-signed HTTPS certificate.
176 if (streamerServer.isSecured()) {
177 secureSessions(streamerServer);
178 }
179}
180```
181
182#### URL scheme conversion
183
184```javascript
185import { READIUM2_ELECTRON_HTTP_PROTOCOL, convertHttpUrlToCustomScheme, convertCustomSchemeToHttpUrl }
186 from "@r2-navigator-js/electron/common/sessions";
187
188if (url.startsWith(READIUM2_ELECTRON_HTTP_PROTOCOL)) {
189
190 // These functions convert back and forth between regular HTTP
191 // (for example: https://127.0.0.1:3000/pub/PUB_ID/manifest.json)
192 // and the custom URL protocol / scheme used under the hood to ensure
193 // that individual publications have unique origins (to avoid inadvertantly sharing
194 // localStorage, IndexedDB, etc.)
195 const urlHTTP = convertCustomSchemeToHttpUrl(urlCustom);
196} else {
197
198 // Note that it is crucial that the passed URL has a properly-encoded base64 "PUB_ID",
199 // which typically escapes `/` and `=` charaecters that are problematic in URL path component
200 // as well as domain/authority (which the custom URL scheme leverages to create unique PUB_ID origins).
201 const urlCustom = convertHttpUrlToCustomScheme(urlHTTP);
202}
203```
204
205#### Electron browser window tracking
206
207```javascript
208import { trackBrowserWindow } from "@r2-navigator-js/electron/main/browser-window-tracker";
209
210app.on("ready", () => {
211
212 const electronBrowserWindow = new BrowserWindow({
213 // ...
214 });
215 // This tracks created Electron browser windows in order to
216 // intercept / hijack clicked hyperlinks in embedded content webviews
217 // (e.g. user navigation inside EPUB documents)
218 // Note that hyperlinking can work without this low-level,
219 // Electron-specific mechanism (using DOM events),
220 // but this is a powerful "native" interceptor that will listen to
221 // navigation events triggered by non-interactive links (e.g. scripted / programmatic redirections)
222 trackBrowserWindow(electronBrowserWindow);
223}
224```
225
226#### Readium CSS configuration (streamer-level injection)
227
228```javascript
229import { IEventPayload_R2_EVENT_READIUMCSS } from "@r2-navigator-js/electron/common/events";
230import {
231 readiumCSSDefaults,
232} from "@r2-navigator-js/electron/common/readium-css-settings";
233import { setupReadiumCSS } from "@r2-navigator-js/electron/main/readium-css";
234
235app.on("ready", () => {
236 // `streamerServer` is an instance of the Server class, see:
237 // https://github.com/readium/r2-streamer-js/blob/develop/README.md
238 const streamerServer = new Server( ... ); // r2-streamer-js
239
240 // `readiumCSSPath` is a local filesystem path where the folder that contains
241 // ReadiumCSS assets can be found. `path.join(process.cwd(), ...)` or `path.join(__dirname, ...)`
242 // can be used depending on integration / bundling context. The HTTP server will create a static hosting
243 // route to this folder.
244 setupReadiumCSS(streamerServer, readiumCSSPath, getReadiumCss);
245 // `getReadiumCss` is a function "pointer" that will be called when the navigator
246 // needs to obtain an up-to-date ReadiumCSS configuration (this is for initial injection in HTML documents,
247 // via the streamer/server). Note that subsequent requests will originate from the renderer process,
248 // whenever the webview needs to (see further below):
249 const getReadiumCss = (publication: Publication, link: Link | undefined): IEventPayload_R2_EVENT_READIUMCSS => {
250
251 // The built-in default values.
252 // The ReadiumCSS app-level settings would typically be persistent in a store.
253 const readiumCssKeys = Object.keys(readiumCSSDefaults);
254 readiumCssKeys.forEach((key: string) => {
255 const value = (readiumCSSDefaults as any)[key];
256 console.log(key, " => ", value);
257 // fetch values from app store ...
258 });
259
260 // See `electron/common/readium-css-settings.ts` for more information,
261 // including links to the ReadiumCSS exhaustive documentation.
262 return {
263 setCSS: {
264 ...
265 fontSize: "100%",
266 ...
267 textAlign: readiumCSSDefaults.textAlign,
268 ...
269 } // setCSS can actually be undefined, in which case this disables ReadiumCSS completely.
270 };
271 };
272}
273```
274
275### Electron renderer process(es), for each Electron BrowserWindow
276
277#### Navigator initial injection
278
279```javascript
280import {
281 installNavigatorDOM,
282} from "@r2-navigator-js/electron/renderer/index";
283
284// This function attaches the navigator HTML DOM and associated functionality
285// to the app-controlled Electron `BrowserWindow`.
286installNavigatorDOM(
287 publication, // Publication object (see below for an example of how to create it)
288 publicationURL, // For example: "https://127.0.0.1:3000/PUB_ID/manifest.json"
289 rootHtmlElementID, // For example: "rootdiv", assuming <div id="rootdiv"></div> in the BrowserWindow HTML
290 preloadPath, // See below.
291 location); // A `Locator` object representing the initial reading bookmark (can be undefined/null)
292
293// The string parameter `preloadPath` is the path to the JavaScript bundle created for
294// `r2-navigator-js/src/electron/renderer/webview/preload.ts`,
295// for example in development mode this could be (assuming EcmaScript-6 / ES-2015 is used):
296// "node_modules/r2-navigator-js/dist/es6-es2015/src/electron/renderer/webview/preload.js",
297// whereas in production mode this would be the copied JavaScript bundle inside the Electron app's ASAR:
298// `"file://" + path.normalize(path.join((global as any).__dirname, preload.js))`
299// (typically, the final application package contains the following JavaScript bundles:
300// `main.js` alonside `renderer.js` and `preload.js`)
301
302// Here is a typical example of how the Publication object (passed as first parameter) is created:
303import { Publication } from "@r2-shared-js/models/publication";
304
305import { TaJsonDeserialize } from "@r2-lcp-js/serializable";
306
307const response = await fetch(publicationURL);
308const publicationJSON = await response.json();
309const publication = TaJsonDeserialize<Publication>(publicationJSON, Publication);
310```
311
312#### Readium CSS configuration (after stream-level injection)
313
314```javascript
315import {
316 setReadiumCssJsonGetter
317} from "@r2-navigator-js/electron/renderer/index";
318import {
319 readiumCSSDefaults
320} from "@r2-navigator-js/electron/common/readium-css-settings";
321import {
322 IEventPayload_R2_EVENT_READIUMCSS,
323} from "@r2-navigator-js/electron/common/events";
324
325// `getReadiumCss` is a function "pointer" (callback) that will be called when the navigator
326// needs to obtain an up-to-date ReadiumCSS configuration ("pull" design pattern).
327// Unlike the equivalent function in the main process
328// (which is used for the initial injection in HTML documents, via the streamer/server), this one handles
329// requests by the actual content viewport, in an on-demand fashion:
330const getReadiumCss = (publication: Publication, link: Link | undefined): IEventPayload_R2_EVENT_READIUMCSS => {
331
332 // The built-in default values.
333 // The ReadiumCSS app-level settings would typically be persistent in a store.
334 const readiumCssKeys = Object.keys(readiumCSSDefaults);
335 readiumCssKeys.forEach((key: string) => {
336 const value = (readiumCSSDefaults as any)[key];
337 console.log(key, " => ", value);
338 // fetch values from app store ...
339 });
340
341 // See `electron/common/readium-css-settings.ts` for more information,
342 // including links to the ReadiumCSS exhaustive documentation.
343 return {
344
345 // `streamerServer` is an instance of the Server class, see:
346 // https://github.com/readium/r2-streamer-js/blob/develop/README.md
347 // This is actually an optional field (i.e. can be undefined/null),
348 // if not provided, `urlRoot` defaults to `window.location.origin`
349 // (typically, https://127.0.0.1:3000 or whatever the port number happens to be):
350 urlRoot: streamerServer.serverUrl(),
351
352 setCSS: {
353 ...
354 fontSize: "100%",
355 ...
356 textAlign: readiumCSSDefaults.textAlign,
357 ...
358 } // setCSS can actually be undefined, in which case this disables ReadiumCSS completely.
359 };
360};
361setReadiumCssJsonGetter(getReadiumCss);
362```
363
364```javascript
365import {
366 readiumCssOnOff,
367} from "@r2-navigator-js/electron/renderer/index";
368
369// This function simply tells the navigator that the ReadiumCSS settings have changed
370// (for example when the user used the configuration panel to choose a font size).
371// Following this call (in an asynchronous manner) the navigator will trigger a call
372// to the function previously registered via `setReadiumCssJsonGetter()` (see above).
373readiumCssOnOff();
374```
375
376#### EPUB reading system information
377
378```javascript
379import {
380 setEpubReadingSystemInfo
381} from "@r2-navigator-js/electron/renderer/index";
382
383// This sets the EPUB3 `navigator.epubReadingSystem` object with the provided `name` and `version` values:
384setEpubReadingSystemInfo({ name: "My R2 Application", version: "0.0.1-alpha.1" });
385```
386
387#### Logging, redirection from web console to shell
388
389```javascript
390// This should not be called explicitly on the application side,
391// as this is already handled inside the navigator context! (this is currently not configurable)
392// This automatically copies web console messages from the renderer process
393// into the shell ouput (where logging messages from the main process are emitted):
394import { consoleRedirect } from "@r2-navigator-js/electron/renderer/console-redirect";
395
396// By default, the navigator calls the console redirector in both embedded webviews (iframes)
397// and the central index (renderer process):
398const releaseConsoleRedirect = consoleRedirect(loggingTag, process.stdout, process.stderr, true);
399// loggingTag ===
400// "r2:navigator#electron/renderer/webview/preload"
401// and
402// "r2:navigator#electron/renderer/index"
403```
404
405#### Reading location, linking with locators
406
407```javascript
408import {
409 LocatorExtended,
410 getCurrentReadingLocation,
411 setReadingLocationSaver
412} from "@r2-navigator-js/electron/renderer/index";
413
414// `saveReadingLocation` is a function "pointer" (callback) that will be called when the navigator
415// needs to notify the host app that the user's reading location has changed ("push" design pattern).
416// This function call is debounced inside the navigator, to avoid flooding the application with
417// many calls (and consequently putting unnecessary strain on the store/messaging system required to
418// handle the renderer/main process communication).
419// A typical example of when this function is called is when the user scrolls the viewport using the mouse
420// (debouncing is every 250ms on the trailing edge,
421// so there is always a 250ms delay before the first notification).
422const saveReadingLocation = (location: LocatorExtended) => {
423 // Use an application store to make `location` persistent
424 // ...
425};
426setReadingLocationSaver(saveReadingLocation);
427
428// This returns the last reading location as a `LocatorExtended` object (can be undefined).
429// This is equal to the last object received in the `setReadingLocationSaver()` callback (see above)
430const loc = getCurrentReadingLocation();
431```
432
433```javascript
434// Typically, the `LocatorExtended` data structure will be used to store "bookmarks",
435// to render a user interface that provides information about the document (e.g. page "numbers"),
436// or to display an interactive "timeline" / linear scrub bar
437// to rapidely navigate the publication spine / reading order.
438
439// Here is a typical usage example for LocatorExtended.locator.href:
440// (null/undefined sanity checks removed, for brevity)
441
442let _publication: Publication; // set somewhere else
443const locatorExtended = getCurrentReadingLocation();
444
445// That's the HTML <title /> (inside the <head />)
446console.log(locatorExtended.locator.title);
447
448let foundLink = _publication.Spine.find((link, i) => {
449 return link.Href === locatorExtended.locator.href;
450});
451if (!foundLink) {
452 // a publication document is not necessarily the spine / reading order
453 foundLink = _publication.Resources.find((link) => {
454 return link.Href === locatorExtended.locator.href;
455 });
456}
457// then, use `foundLink` as needed ...
458```
459
460```javascript
461// `LocatorExtended.locations.cssSelector` is a CSS Selector that points to an HTML element,
462// (i.e. the reading location / bookmarked reference)
463// and it can be used as-is to restore this using the `handleLinkLocator()` function (see below).
464
465// `LocatorExtended.locations.cfi` is provided as "read-only" information,
466// in the sense that it is not used when ingested back into the navigator via `handleLinkLocator()`.
467// In other words, setting the CFI field to undefined or another string has no effects when passing the parameter.
468
469// `LocatorExtended.locations.position` is not currently supported / implemented,
470// and as with the CFI field, it can be ignored when feeding back into the navigator API.
471
472// `LocatorExtended.locations.progression` is a percentage (floating point number [0, 1])
473// representing the reading location inside a single document,
474// so for fixed layout this has no effect. However, reflowable documents are either scrolled or paginated,
475// so the progression percentage represents how much vertical scrolling / horizontal panning there is.
476// This progression field can be used to ask the navigator to set a specific reading placement
477// using `handleLinkLocator()` (see further below).
478// Typically, for paginated reflowable documents,
479// the calculation of a desired progression could be mapped to "page" information (columns). See below.
480```
481
482```javascript
483// When a reflowable document is currently presented in a paginated view,
484// `LocatorExtended.paginationInfo` reports the current `totalColumns` (number of single "pages"),
485// `currentColumn` (a zero-based index between [0, totalColumns-1]),
486// and if `isTwoPageSpread` is true, then `spreadIndex` reports the zero-based index
487// of the currently-visible two-page spread.
488```
489
490```javascript
491// `LocatorExtended.docInfo` reports `isFixedLayout`, `isRightToLeft` and `isVerticalWritingMode`
492// which are self-explanatory.
493```
494
495```javascript
496// `LocatorExtended.docInfo` reports `isFixedLayout`, `isRightToLeft` and `isVerticalWritingMode`
497// which are self-explanatory.
498```
499
500```javascript
501// Note that `LocatorExtended.selectionInfo` is currently a prototype concept, not a stable API.
502// However, this already provides an accurate representation of user selection / character ranges,
503// which will ; in a future release of r2-navigator-js ; be connected to a highlights / annotations
504// subsystem (i.e. minimal, but stable / robust functionality).
505```
506
507```javascript
508// For convenience, here is the fully-expanded `LocatorExtended` data structure:
509interface LocatorExtended {
510 locator { //Locator
511 href: string;
512 title?: string;
513 text?: { //LocatorLocations
514 before?: string;
515 highlight?: string;
516 after?: string;
517 };
518 locations { //LocatorLocations
519 cfi?: string;
520 cssSelector?: string;
521 position?: number;
522 progression?: number;
523 };
524 };
525 paginationInfo { //IPaginationInfo
526 totalColumns: number | undefined;
527 currentColumn: number | undefined;
528 isTwoPageSpread: boolean | undefined;
529 spreadIndex: number | undefined;
530 };
531 docInfo { //IDocInfo
532 isFixedLayout: boolean;
533 isRightToLeft: boolean;
534 isVerticalWritingMode: boolean;
535 };
536 selectionInfo { //ISelectionInfo
537 rangeInfo { //IRangeInfo
538 startContainerElementCssSelector: string;
539 startContainerChildTextNodeIndex: number;
540 startOffset: number;
541
542 endContainerElementCssSelector: string;
543 endContainerChildTextNodeIndex: number;
544 endOffset: number;
545
546 cfi: string | undefined;
547 };
548 cleanText: string;
549 rawText: string;
550 };
551}
552```
553
554```javascript
555import {
556 handleLinkLocator,
557 handleLinkUrl
558} from "@r2-navigator-js/electron/renderer/index";
559
560// The `handleLinkUrl` function is used to instruct the navigator to load
561// an absolute URL, either internal to the current publication,
562// or external. For example:
563// const href = "https://127.0.0.1:3000/PUB_ID/contents/chapter1.html";
564// or:
565// const href = "https://external-domain.org/out-link";
566handleLinkUrl(href);
567
568// A typical use-case is the publication's Table Of Contents.
569// Each spine/readingOrder item is a `Link` object with a relative href (see `r2-shared-js` models).
570// The final absolute URL may be computed simply by concatenating the publication's manifest.json URL:
571// (although it is recommended to use a URL/URI library in order to handle query parameters, etc.)
572const href = publicationURL + "/../" + link.Href;
573// For example:
574// publicationURL === "https://127.0.0.1:3000/PUB_ID/manifest.json"
575// link.Href === "contents/chapter1.html"
576
577// This can be used to restore a bookmark previously saved via `getCurrentReadingLocation()` (see above).
578handleLinkLocator(locator);
579
580// `locator.href` is obviously required.
581// `locator.locations.cssSelector` can be used as-is (as provided by a prior call to `getCurrentReadingLocation()`)
582// in order to restore a saved reading location.
583// Alternatively, `locator.locations.progression` (percentage) can be used to pan/shift to a desired reading location,
584// based on pagination / scroll information (see description of `LocatorExtended.paginationInfo`, above).
585```
586
587```javascript
588import {
589 getCurrentReadingLocation,
590 isLocatorVisible
591} from "@r2-navigator-js/electron/renderer/index";
592
593const locEx = getCurrentReadingLocation();
594
595// This function returns true (async promise) if the locator
596// is fully or partially visible inside the viewport (scrolled or paginated).
597// Always returns true for fixed layout publications / documents.
598try {
599 const visible = await isLocatorVisible(locEx.locator);
600} catch (err) {
601 console.log(err); // promise rejection
602}
603```
604
605#### Navigating using arrow keys
606
607```javascript
608import {
609 navLeftOrRight
610} from "@r2-navigator-js/electron/renderer/index";
611
612// This function instructs the navigator to "turn the page" left or right.
613// This is litterally in relation to the left-side or right-side of the display.
614// In other words, the navigator automatically handles the fact that with Right-To-Left content,
615// the left-hand-side "page turn" button (or associated left arrow keyboard key) means "progress forward".
616// For example (no need to explicitly handle RTL conditions in this app code):
617window.document.addEventListener("keydown", (ev: KeyboardEvent) => {
618 if (ev.keyCode === 37) { // left
619 navLeftOrRight(true);
620 } else if (ev.keyCode === 39) { // right
621 navLeftOrRight(false);
622 }
623});
624```
625
626#### Selection Highlighting
627
628```javascript
629// The navigator maintains an ordered (visually-stacked) list of character-level highlights,
630// during the lifespan of a loaded / rendered publication document. The app is responsible for instructing
631// the navigator to instantiate these highlights, whenever a document is (re)loaded.
632// There is no persistence at the level of the navigator, the state is constrained to the lifecycle
633// of individual HTML documents. The navigator handles redrawing at the appropriate optimal times,
634// for example when changing the font size. Highlights emit mouse click events which the app can listen to.
635
636import {
637 IHighlight,
638 IHighlightDefinition,
639} from "@r2-navigator-js/electron/common/highlight";
640import {
641 highlightsClickListen,
642 highlightsCreate,
643 highlightsRemove,
644} from "@r2-navigator-js/electron/renderer/index";
645
646// Use the setReadingLocationSaver() notification to detect when the user creates a new selection:
647const saveReadingLocation = (location: LocatorExtended) => {
648
649 if (location.selectionInfo && location.selectionIsNew) {
650 // Note that a RGB `color` can be optionally specified in IHighlightDefinition (default is red-ish):
651 const highlightToCreate = { selectionInfo: location.selectionInfo } as IHighlightDefinition;
652
653 let createdHighlights: Array<IHighlight | null> | undefined;
654 try {
655 // The highlightsCreate() function takes an array of highlight definitions,
656 // here we just pass a single one, derived from the user selection:
657 createdHighlights = await highlightsCreate(location.locator.href, [highlightToCreate]);
658 } catch (err) {
659 console.log(err);
660 }
661 if (createdHighlights) {
662 createdHighlights.forEach((highlight) => {
663 if (highlight) {
664 // ...
665 // The visual highlight created in the navigator can be saved here in the app,
666 // so that it can be restored at a later stage, typically when reloading the document (href).
667 }
668 });
669 }
670 }
671};
672setReadingLocationSaver(saveReadingLocation);
673
674// TIP: the app can detect when a new document has been loaded,
675// in which case the saved / stored highlights (inside the app's persistence layer)
676// must be re-instantiated inside the navigator:
677let _lastSavedReadingLocationHref: string | undefined;
678const saveReadingLocation = async (location: LocatorExtended) => {
679 const hrefHasChanged = _lastSavedReadingLocationHref !== location.locator.href;
680 _lastSavedReadingLocationHref = location.locator.href;
681
682 // ...
683 // here, invoke highlightsCreate() with the saved / stored highlights for this particular document (href)
684};
685
686// here we listen to mouse click events,
687// and we destroy the clicked highlight:
688highlightsClickListen((href: string, highlight: IHighlight) => {
689 highlightsRemove(href, [highlight.id]);
690 // ...
691 // remove the persistent / stored / saved copy too!
692});
693
694```
695
696#### Read aloud, TTS (Text To Speech), Synthetic Speech
697
698```javascript
699import {
700 TTSStateEnum,
701 ttsClickEnable,
702 ttsListen,
703 ttsNext,
704 ttsPause,
705 ttsPlay,
706 ttsPrevious,
707 ttsResume,
708 ttsStop,
709} from "@r2-navigator-js/electron/renderer/index";
710
711// When true, mouse clicks on text inside publication documents
712// trigger TTS readaloud playback at the pointed location.
713// The default is false. Once set to true, this setting persists
714// for any new loading document within the same publication.
715// This resets to false for any newly opened publication.
716// The ALT key modifier triggers playback for the pointed DOM fragment only.
717ttsClickEnable(false);
718
719// Starts playing TTS read aloud for the entire document, from the begining of the document,
720// or from the last-known reading location (i.e. currently visible in the viewport).
721// This does not automatically move to the next document.
722// If called when already playing, stops and starts again from the current location.
723// Highlighted word-by-word synthetic speech is rendered inside a modal overlay
724// with basic previous/next and timeline scrubber controls (which has instant text preview).
725// The textual popup overlay receives mouse clicks to pause/resume playback.
726// The main document text (in the background of the modal overlay) keeps track
727// of the current playback position, and the top-level spoken fragment is highlighted.
728// Note that long paragraphs/sections of text are automatically sentence-fragmented
729// in order to generate short speech utterances.
730// Also note that the engine parses DOM information in order to assign the correct language
731// to utterances, thereby providing support for multilingual documents.
732ttsPlay();
733
734// Stops playback whilst maintaining the read aloud popup overlay,
735// ready for playback to be resumed.
736ttsPause();
737
738// Resumes from a paused state, plays from the begining of the last-played utterance, and onwards.
739ttsResume();
740
741// Stops any ongoing playback and discards the popup modal TTS overlay.
742// Cleans-up allocated resources.
743ttsStops();
744
745// Navigate backward / forward inside the stream of utterances scheduled for the current TTS playback.
746// Equivalent to the command buttons left/right of the timeline scrubber located at the bottom of the read aloud overlay.
747ttsPrevious();
748ttsNext();
749
750// Sets up a callback for event notifications from the read aloud state machine.
751// Currently: TTS paused, stopped, and playing.
752ttsListen((ttsState: TTSStateEnum) => {
753 if (ttsState === TTSStateEnum.PAUSED) {
754 // ...
755 } else if (ttsState === TTSStateEnum.STOPPED) {
756 // ...
757 } else if (ttsState === TTSStateEnum.PLAYING) {
758 // ...
759 }
760});
761```
762
763At this stage there are missing features: voice selection (depending on languages), volume, speech rate and pitch.
764
765#### LCP
766
767```javascript
768import { setLcpNativePluginPath } from "@r2-lcp-js/parser/epub/lcp";
769
770// Registers the filesystem location of the native LCP library
771const lcpPluginPath = path.join(process.cwd(), "LCP", "lcp.node");
772setLcpNativePluginPath(lcpPluginPath);
773```
774
775```javascript
776import { IDeviceIDManager } from "@r2-lcp-js/lsd/deviceid-manager";
777import { launchStatusDocumentProcessing } from "@r2-lcp-js/lsd/status-document-processing";
778import { lsdLcpUpdateInject } from "@r2-navigator-js/electron/main/lsd-injectlcpl";
779
780// App-level implementation of the LSD (License Status Document)
781const deviceIDManager: IDeviceIDManager = {
782
783 async checkDeviceID(key: string): Promise<string | undefined> {
784 //...
785 },
786
787 async getDeviceID(): Promise<string> {
788 //...
789 },
790
791 async getDeviceNAME(): Promise<string> {
792 //...
793 },
794
795 async recordDeviceID(key: string): Promise<void> {
796 //...
797 },
798};
799
800// Assumes a `Publication` object already prepared in memory,
801// loaded from `publicationFilePath`.
802// This performs the LCP-compliant background operations to register the device,
803// and to check for an updated license (as passed in the callback parameter).
804// The lsdLcpUpdateInject() function can be used to immediately inject the updated
805// LCP license (META-INF/license.lcpl) inside the EPUB container on the filesystem.
806// Note that although the `launchStatusDocumentProcessing()` initializes `publication.LCP.LSD`,
807// after `lsdLcpUpdateInject()` is invoked a fresh new `publication.LCP` object is created
808// (which mirrors `META-INF/license.lcpl`), so `launchStatusDocumentProcessing()` must be called again (loop)
809// to ensure the latest LSD is indeed loaded and verified.
810// Below is an example of looping the `launchStatusDocumentProcessing()` calls in order to reset `publication.LCP.LSD`
811// after `lsdLcpUpdateInject()` injects a fresh `publication.LCP` based on the downloaded `META-INF/license.lcpl`.
812async function tryLSD(deviceIDManager: IDeviceIDManager, publication: Publication, publicationFilePath: string): Promise<boolean> {
813
814 return new Promise(async (resolve, reject) => {
815 try {
816 await launchStatusDocumentProcessing(publication.LCP as LCP, deviceIDManager,
817 async (licenseUpdateJson: string | undefined) => {
818
819 if (licenseUpdateJson) {
820 let res: string;
821 try {
822 res = await lsdLcpUpdateInject(
823 licenseUpdateJson,
824 publication as Publication,
825 publicationFilePath);
826
827 try {
828 await tryLSD(publication, publicationFilePath); // loop to re-init LSD
829 resolve(true);
830 } catch (err) {
831 debug(err);
832 reject(err);
833 }
834 } catch (err) {
835 debug(err);
836 reject(err);
837 }
838 } else {
839 resolve(true);
840 }
841 });
842 } catch (err) {
843 debug(err);
844 reject(err);
845 }
846 });
847}
848try {
849 await tryLSD(publication, publicationFilePath);
850} catch (err) {
851 debug(err);
852}
853
854// A less-ideal alterative is to preserve the previous LSD,
855// but in principle the new LCP may contain a LSD link that needs to be requested
856// again in order to get the latest information available for this new license
857// (e.g. associated LSD events list).
858try {
859 await launchStatusDocumentProcessing(publication.LCP, deviceIDManager,
860 async (licenseUpdateJson: string | undefined) => {
861
862 if (licenseUpdateJson) {
863 const LSD_backup = publication.LCP.LSD; // LSD preservation, see comment above.
864
865 let res: string;
866 try {
867 res = await lsdLcpUpdateInject(
868 licenseUpdateJson,
869 publication as Publication,
870 publicationFilePath);
871
872 publication.LCP.LSD = LSD_backup; // LSD preservation, see comment above.
873 } catch (err) {
874 debug(err);
875 }
876 }
877 });
878} catch (err) {
879 debug(err);
880}
881```
882
883```javascript
884import { downloadEPUBFromLCPL } from "@r2-lcp-js/publication-download";
885
886// Downloads the EPUB publication referenced by given LCP license,
887// injects the license at META-INF/license.lcpl inside the EPUB container,
888// and returns the result as an array of two string values:
889// first is the full destination filepath (should be path.join(destinationDirectory, destinationFileName))
890// second is the URL where the EPUB was downloaded from ("publicatiion" link inside the LCP license)
891try {
892 let epub = await downloadEPUBFromLCPL(lcplFilePath, destinationDirectory, destinationFileName);
893} catch (err) {
894 debug(err);
895}
896```
897
898```javascript
899import { doTryLcpPass } from "@r2-navigator-js/electron/main/lcp";
900
901// This asks the LCP library to test an array of passphrases
902// (or the SHA256 digest of the passphrases).
903// The promise is rejected (try+catch) when no valid passphrase was found.
904// The function returns the first valid passphrase.
905try {
906 const lcpPass = "my LCP passphrase";
907 const validPass = await doTryLcpPass(
908 publicationsServer,
909 publicationFilePath,
910 [lcpPass],
911 isSha256Hex);
912
913 let passSha256Hex: string | undefined;
914 if (!isSha256Hex) {
915 const checkSum = crypto.createHash("sha256");
916 checkSum.update(lcpPass);
917 passSha256Hex = checkSum.digest("hex");
918 } else {
919 passSha256Hex = lcpPass;
920 }
921} catch (err) {
922 debug(err);
923}
924```
925
926```javascript
927import { doLsdRenew } from "@r2-navigator-js/electron/main/lsd";
928import { doLsdReturn } from "@r2-navigator-js/electron/main/lsd";
929
930// LSD "renew" (with a specific end date)
931try {
932 const lsdJson = await doLsdRenew(
933 publicationsServer,
934 deviceIDManager,
935 publicationFilePath,
936 endDateStr);
937} catch (err) {
938 debug(err);
939}
940
941// LSD "return"
942try {
943 const lsdJson = await doLsdReturn(
944 publicationsServer,
945 deviceIDManager,
946 publicationFilePath);
947} catch (err) {
948 debug(err);
949}
950
951// Both LSD "renew" and "return" interactions can return errors (i.e. Promise.reject => try/catch with async/await)
952// when the server responds with HTTP statusCode < 200 || >= 300.
953// The `err` object in the above code snippet can be a number (HTTP status code) when no response body is available.
954// Otherwise, it can be an object with the `httpStatusCode` property (number) and httpResponseBody (string)
955// when the response body cannot be parsed to JSON.
956// Otherwise, it can be an object with the `httpStatusCode` property (number) and other arbitrary JSON properties,
957// depending on the server response. Typically, compliant LCP/LSD servers are expected to return Problem Details JSON (RFC7807),
958// which provides `title` `type` and `details` JSON properties.
959// See https://readium.org/technical/readium-lsd-specification/#31-handling-errors
960```