NodeJS / TypeScript Readium-2 "navigator" component
NodeJS implementation (written in TypeScript) for the navigator module of the Readium2 architecture ( https://github.com/readium/architecture/ ).
Build status
Changelog
Prerequisites
- https://nodejs.org NodeJS >= 8, NPM >= 5 (check with command line
node --version
and npm --version
) - OPTIONAL: https://yarnpkg.com Yarn >= 1.0 (check with command line
yarn --version
)
GitHub repository
https://github.com/readium/r2-navigator-js
There is no github.io site for this project (no gh-pages branch).
NPM package
https://www.npmjs.com/package/r2-navigator-js
Command line install:
npm install r2-navigator-js
OR
yarn add r2-navigator-js
...or manually add in your package.json
:
"dependencies": {
"r2-navigator-js": "latest"
}
The JavaScript code distributed in the NPM package is usable as-is (no transpilation required), as it is automatically-generated from the TypeScript source.
Several ECMAScript flavours are provided out-of-the-box: ES5, ES6-2015, ES7-2016, ES8-2017:
https://unpkg.com/r2-navigator-js/dist/
(alternatively, GitHub mirror with semantic-versioning release tags: https://github.com/edrlab/r2-navigator-js-dist/tree/develop/dist/ )
The JavaScript code is not bundled, and it uses require()
statement for imports (NodeJS style).
More information about NodeJS compatibility:
http://node.green
Note that web-browser Javascript is currently not supported (only NodeJS runtimes).
The 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.
Example usage:
import { trackBrowserWindow } from "r2-navigator-js/dist/es5/src/electron/main/browser-window-tracker";
import { trackBrowserWindow } from "@r2-navigator-js/electron/main/browser-window-tracker";
Dependencies
https://david-dm.org/readium/r2-navigator-js
A package-lock.json is provided (modern NPM replacement for npm-shrinkwrap.json
).
A yarn.lock file is currently not provided at the root of the source tree.
Continuous Integration
TODO (unit tests?)
https://travis-ci.org/readium/r2-navigator-js
Badge: [![Travis](https://travis-ci.org/readium/r2-navigator-js.svg?branch=develop)](https://travis-ci.org/readium/r2-navigator-js)
Version(s), Git revision(s)
NPM package (latest published):
https://unpkg.com/r2-navigator-js/dist/gitrev.json
Alternatively, GitHub mirror with semantic-versioning release tags:
https://raw.githack.com/edrlab/r2-navigator-js-dist/develop/dist/gitrev.json
Developer Primer
Quick Start
Command line steps (NPM, but similar with YARN):
cd r2-navigator-js
git status
(please ensure there are no local changes, especially in package-lock.json
and the dependency versions in package.json
)rm -rf node_modules
(to start from a clean slate)npm install
, or alternatively npm ci
(both commands initialize the node_modules
tree of package dependencies, based on the strict package-lock.json
definition)npm run build:all
(invoke the main build script: clean, lint, compile)ls dist
(that's the build output which gets published as NPM package)
Local Workflow (NPM packages not published yet)
Strictly-speaking, a developer needs to clone only the GitHub repository he/she wants to modify code in.
However, for this documentation let's assume that all r2-xxx-js
GitHub repositories are cloned, as siblings within the same parent folder.
The 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
(for example, this means that r2-shared-js
depends on r2-utils-js
and r2-lcp-js
to compile and function properly).
Note 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.
cd MY_CODE_FOLDER
git clone https://github.com/readium/r2-XXX-js.git
(replace XXX
for each repository name mentioned above)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)- 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). - 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. - 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. - 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
).
Programmer Documentation
An Electron app has one main
process, and potentially several renderer
processes (one per BrowserWindow
).
In addition, there is a separate runtime for each webview
embedded inside each BrowserWindow
(this qualifies as a renderer
process too).
Communication between processes occurs via Electron's IPC asynchronous messaging system,
as the runtime contexts are otherwise isolated from each other.
Each process launches its own Javascript code bundle. There may be identical / shared code between bundles,
but the current state of a given context may differ from the state of another.
Internally, state synchronisation between isolated runtimes is performed using IPC,
or sometimes by passing URL parameters as this achieves a more instant / synchronous behaviour.
Most of the navigator API surface (i.e. exposed functions) relies on the fact that each process is effectively
a runtime "singleton", with a ongoing state during its lifecycle. For example, from the moment a BrowserWindow
is opened (e.g. the "reader" view for a given publication), a renderer
process is spawned,
and this singleton runtime maintains the internal state of the navigator "instance"
(this includes the DOM window
itself).
This explains why there is no object model in the navigator design pattern,
i.e. no const nav = new Navigator()
calls.
Electron main process
Session initialization
import { initSessions, secureSessions } from "r2-navigator-js/dist/es5/src/electron/main/sessions";
import { initSessions, secureSessions } from "@r2-navigator-js/electron/main/sessions";
initSessions();
app.on("ready", () => {
const streamerServer = new Server( ... );
if (streamerServer.isSecured()) {
secureSessions(streamerServer);
}
}
URL scheme conversion
import { READIUM2_ELECTRON_HTTP_PROTOCOL, convertHttpUrlToCustomScheme, convertCustomSchemeToHttpUrl }
from "@r2-navigator-js/electron/common/sessions";
if (url.startsWith(READIUM2_ELECTRON_HTTP_PROTOCOL)) {
const urlHTTP = convertCustomSchemeToHttpUrl(urlCustom);
} else {
const urlCustom = convertHttpUrlToCustomScheme(urlHTTP);
}
Electron browser window tracking
import { trackBrowserWindow } from "@r2-navigator-js/electron/main/browser-window-tracker";
app.on("ready", () => {
const electronBrowserWindow = new BrowserWindow({
});
trackBrowserWindow(electronBrowserWindow);
}
Readium CSS configuration (streamer-level injection)
import { IEventPayload_R2_EVENT_READIUMCSS } from "@r2-navigator-js/electron/common/events";
import {
readiumCSSDefaults,
} from "@r2-navigator-js/electron/common/readium-css-settings";
import { setupReadiumCSS } from "@r2-navigator-js/electron/main/readium-css";
app.on("ready", () => {
const streamerServer = new Server( ... );
setupReadiumCSS(streamerServer, readiumCSSPath, getReadiumCss);
const getReadiumCss = (publication: Publication, link: Link | undefined): IEventPayload_R2_EVENT_READIUMCSS => {
const readiumCssKeys = Object.keys(readiumCSSDefaults);
readiumCssKeys.forEach((key: string) => {
const value = (readiumCSSDefaults as any)[key];
console.log(key, " => ", value);
});
return {
setCSS: {
...
fontSize: "100%",
...
textAlign: readiumCSSDefaults.textAlign,
...
}
};
};
}
Electron renderer process(es), for each Electron BrowserWindow
Navigator initial injection
import {
installNavigatorDOM,
} from "@r2-navigator-js/electron/renderer/index";
installNavigatorDOM(
publication,
publicationURL,
rootHtmlElementID,
preloadPath,
location);
import { Publication } from "@r2-shared-js/models/publication";
import { TaJsonDeserialize } from "@r2-lcp-js/serializable";
const response = await fetch(publicationURL);
const publicationJSON = await response.json();
const publication = TaJsonDeserialize<Publication>(publicationJSON, Publication);
Readium CSS configuration (after stream-level injection)
import {
setReadiumCssJsonGetter
} from "@r2-navigator-js/electron/renderer/index";
import {
readiumCSSDefaults
} from "@r2-navigator-js/electron/common/readium-css-settings";
import {
IEventPayload_R2_EVENT_READIUMCSS,
} from "@r2-navigator-js/electron/common/events";
const getReadiumCss = (publication: Publication, link: Link | undefined): IEventPayload_R2_EVENT_READIUMCSS => {
const readiumCssKeys = Object.keys(readiumCSSDefaults);
readiumCssKeys.forEach((key: string) => {
const value = (readiumCSSDefaults as any)[key];
console.log(key, " => ", value);
});
return {
urlRoot: streamerServer.serverUrl(),
setCSS: {
...
fontSize: "100%",
...
textAlign: readiumCSSDefaults.textAlign,
...
}
};
};
setReadiumCssJsonGetter(getReadiumCss);
import {
readiumCssOnOff,
} from "@r2-navigator-js/electron/renderer/index";
readiumCssOnOff();
EPUB reading system information
import {
setEpubReadingSystemInfo
} from "@r2-navigator-js/electron/renderer/index";
setEpubReadingSystemInfo({ name: "My R2 Application", version: "0.0.1-alpha.1" });
Logging, redirection from web console to shell
import { consoleRedirect } from "@r2-navigator-js/electron/renderer/console-redirect";
const releaseConsoleRedirect = consoleRedirect(loggingTag, process.stdout, process.stderr, true);
Reading location, linking with locators
import {
LocatorExtended,
getCurrentReadingLocation,
setReadingLocationSaver
} from "@r2-navigator-js/electron/renderer/index";
const saveReadingLocation = (location: LocatorExtended) => {
};
setReadingLocationSaver(saveReadingLocation);
const loc = getCurrentReadingLocation();
let _publication: Publication;
const locatorExtended = getCurrentReadingLocation();
console.log(locatorExtended.locator.title);
let foundLink = _publication.Spine.find((link, i) => {
return link.Href === locatorExtended.locator.href;
});
if (!foundLink) {
foundLink = _publication.Resources.find((link) => {
return link.Href === locatorExtended.locator.href;
});
}
interface LocatorExtended {
locator {
href: string;
title?: string;
text?: {
before?: string;
highlight?: string;
after?: string;
};
locations {
cfi?: string;
cssSelector?: string;
position?: number;
progression?: number;
};
};
paginationInfo {
totalColumns: number | undefined;
currentColumn: number | undefined;
isTwoPageSpread: boolean | undefined;
spreadIndex: number | undefined;
};
docInfo {
isFixedLayout: boolean;
isRightToLeft: boolean;
isVerticalWritingMode: boolean;
};
selectionInfo {
rangeInfo {
startContainerElementCssSelector: string;
startContainerChildTextNodeIndex: number;
startOffset: number;
endContainerElementCssSelector: string;
endContainerChildTextNodeIndex: number;
endOffset: number;
cfi: string | undefined;
};
cleanText: string;
rawText: string;
};
}
import {
handleLinkLocator,
handleLinkUrl
} from "@r2-navigator-js/electron/renderer/index";
handleLinkUrl(href);
const href = publicationURL + "/../" + link.Href;
handleLinkLocator(locator);
import {
getCurrentReadingLocation,
isLocatorVisible
} from "@r2-navigator-js/electron/renderer/index";
const locEx = getCurrentReadingLocation();
try {
const visible = await isLocatorVisible(locEx.locator);
} catch (err) {
console.log(err);
}
Navigating using arrow keys
import {
navLeftOrRight
} from "@r2-navigator-js/electron/renderer/index";
window.document.addEventListener("keydown", (ev: KeyboardEvent) => {
if (ev.keyCode === 37) {
navLeftOrRight(true);
} else if (ev.keyCode === 39) {
navLeftOrRight(false);
}
});
Selection Highlighting
import {
IHighlight,
IHighlightDefinition,
} from "@r2-navigator-js/electron/common/highlight";
import {
highlightsClickListen,
highlightsCreate,
highlightsRemove,
} from "@r2-navigator-js/electron/renderer/index";
const saveReadingLocation = (location: LocatorExtended) => {
if (location.selectionInfo && location.selectionIsNew) {
const highlightToCreate = { selectionInfo: location.selectionInfo } as IHighlightDefinition;
let createdHighlights: Array<IHighlight | null> | undefined;
try {
createdHighlights = await highlightsCreate(location.locator.href, [highlightToCreate]);
} catch (err) {
console.log(err);
}
if (createdHighlights) {
createdHighlights.forEach((highlight) => {
if (highlight) {
}
});
}
}
};
setReadingLocationSaver(saveReadingLocation);
let _lastSavedReadingLocationHref: string | undefined;
const saveReadingLocation = async (location: LocatorExtended) => {
const hrefHasChanged = _lastSavedReadingLocationHref !== location.locator.href;
_lastSavedReadingLocationHref = location.locator.href;
};
highlightsClickListen((href: string, highlight: IHighlight) => {
highlightsRemove(href, [highlight.id]);
});
Read aloud, TTS (Text To Speech), Synthetic Speech
import {
TTSStateEnum,
ttsClickEnable,
ttsListen,
ttsNext,
ttsPause,
ttsPlay,
ttsPrevious,
ttsResume,
ttsStop,
} from "@r2-navigator-js/electron/renderer/index";
ttsClickEnable(false);
ttsPlay();
ttsPause();
ttsResume();
ttsStops();
ttsPrevious();
ttsNext();
ttsListen((ttsState: TTSStateEnum) => {
if (ttsState === TTSStateEnum.PAUSED) {
} else if (ttsState === TTSStateEnum.STOPPED) {
} else if (ttsState === TTSStateEnum.PLAYING) {
}
});
At this stage there are missing features: voice selection (depending on languages), volume, speech rate and pitch.
LCP
import { setLcpNativePluginPath } from "@r2-lcp-js/parser/epub/lcp";
const lcpPluginPath = path.join(process.cwd(), "LCP", "lcp.node");
setLcpNativePluginPath(lcpPluginPath);
import { IDeviceIDManager } from "@r2-lcp-js/lsd/deviceid-manager";
import { launchStatusDocumentProcessing } from "@r2-lcp-js/lsd/status-document-processing";
import { lsdLcpUpdateInject } from "@r2-navigator-js/electron/main/lsd-injectlcpl";
const deviceIDManager: IDeviceIDManager = {
async checkDeviceID(key: string): Promise<string | undefined> {
},
async getDeviceID(): Promise<string> {
},
async getDeviceNAME(): Promise<string> {
},
async recordDeviceID(key: string): Promise<void> {
},
};
async function tryLSD(deviceIDManager: IDeviceIDManager, publication: Publication, publicationFilePath: string): Promise<boolean> {
return new Promise(async (resolve, reject) => {
try {
await launchStatusDocumentProcessing(publication.LCP as LCP, deviceIDManager,
async (licenseUpdateJson: string | undefined) => {
if (licenseUpdateJson) {
let res: string;
try {
res = await lsdLcpUpdateInject(
licenseUpdateJson,
publication as Publication,
publicationFilePath);
try {
await tryLSD(publication, publicationFilePath);
resolve(true);
} catch (err) {
debug(err);
reject(err);
}
} catch (err) {
debug(err);
reject(err);
}
} else {
resolve(true);
}
});
} catch (err) {
debug(err);
reject(err);
}
});
}
try {
await tryLSD(publication, publicationFilePath);
} catch (err) {
debug(err);
}
try {
await launchStatusDocumentProcessing(publication.LCP, deviceIDManager,
async (licenseUpdateJson: string | undefined) => {
if (licenseUpdateJson) {
const LSD_backup = publication.LCP.LSD;
let res: string;
try {
res = await lsdLcpUpdateInject(
licenseUpdateJson,
publication as Publication,
publicationFilePath);
publication.LCP.LSD = LSD_backup;
} catch (err) {
debug(err);
}
}
});
} catch (err) {
debug(err);
}
import { downloadEPUBFromLCPL } from "@r2-lcp-js/publication-download";
try {
let epub = await downloadEPUBFromLCPL(lcplFilePath, destinationDirectory, destinationFileName);
} catch (err) {
debug(err);
}
import { doTryLcpPass } from "@r2-navigator-js/electron/main/lcp";
try {
const lcpPass = "my LCP passphrase";
const validPass = await doTryLcpPass(
publicationsServer,
publicationFilePath,
[lcpPass],
isSha256Hex);
let passSha256Hex: string | undefined;
if (!isSha256Hex) {
const checkSum = crypto.createHash("sha256");
checkSum.update(lcpPass);
passSha256Hex = checkSum.digest("hex");
} else {
passSha256Hex = lcpPass;
}
} catch (err) {
debug(err);
}
import { doLsdRenew } from "@r2-navigator-js/electron/main/lsd";
import { doLsdReturn } from "@r2-navigator-js/electron/main/lsd";
try {
const lsdJson = await doLsdRenew(
publicationsServer,
deviceIDManager,
publicationFilePath,
endDateStr);
} catch (err) {
debug(err);
}
try {
const lsdJson = await doLsdReturn(
publicationsServer,
deviceIDManager,
publicationFilePath);
} catch (err) {
debug(err);
}