@readium/navigator
Version:
Next generation SDK for publications in Web Apps
197 lines (173 loc) • 8.28 kB
text/typescript
import { Link, MediaType, Publication, ReadingProgression } from "@readium/shared";
import { Injector } from "../../injection/Injector.ts";
import { getScriptMode } from "../../helpers/scriptMode.ts";
const csp = (domains: string[]) => {
const d = domains.join(" ");
return [
// 'self' is useless because the document is loaded from a blob: URL
`upgrade-insecure-requests`,
`default-src ${d} blob:`,
`connect-src 'none'`, // No fetches to anywhere. TODO: change?
`script-src ${d} blob: 'unsafe-inline'`, // JS scripts
`style-src ${d} blob: 'unsafe-inline'`, // CSS styles
`img-src ${d} blob: data:`, // Images
`font-src ${d} blob: data:`, // Fonts
`object-src ${d} blob:`, // Despite not being recommended, still necessary in EPUBs for <object>
`child-src ${d}`, // <iframe>, web workers
`form-action 'none'`, // No form submissions
//`report-uri ?`,
].join("; ");
};
export default class FrameBlobBuider {
private readonly item: Link;
private readonly burl: string;
private readonly pub: Publication;
private readonly cssProperties?: { [key: string]: string };
private readonly injector: Injector | null = null;
constructor(
pub: Publication,
baseURL: string,
item: Link,
options: {
cssProperties?: { [key: string]: string };
injector?: Injector | null;
}
) {
this.pub = pub;
this.item = item;
this.burl = item.toURL(baseURL) || "";
this.cssProperties = options.cssProperties;
this.injector = options.injector ?? null;
}
public async build(fxl = false): Promise<string> {
if(!this.item.mediaType.isHTML) {
if(this.item.mediaType.isBitmap || this.item.mediaType.equals(MediaType.SVG)) {
return this.buildImageFrame();
} else
throw Error("Unsupported frame mediatype " + this.item.mediaType.string);
} else {
return await this.buildHtmlFrame(fxl);
}
}
private async buildHtmlFrame(fxl = false): Promise<string> {
// Load the HTML resource
const txt = await this.pub.get(this.item).readAsString();
if(!txt) throw new Error(`Failed reading item ${this.item.href}`);
const doc = new DOMParser().parseFromString(
txt,
this.item.mediaType.string as DOMParserSupportedType
);
const perror = doc.querySelector("parsererror");
if (perror) {
const details = perror.querySelector("div");
throw new Error(`Failed parsing item ${this.item.href}: ${details?.textContent || perror.textContent}`);
}
// Apply resource injections if injection service is provided
if (this.injector) {
await this.injector.injectForDocument(doc, this.item);
}
return this.finalizeDOM(doc, this.pub.baseURL, this.burl, this.item.mediaType, fxl, this.cssProperties);
}
private buildImageFrame(): string {
// Rudimentary image display
const doc = document.implementation.createHTMLDocument(this.item.title || this.item.href);
const simg = document.createElement("img");
simg.src = this.burl || "";
simg.alt = this.item.title || "";
simg.decoding = "async";
doc.body.appendChild(simg);
return this.finalizeDOM(doc, this.pub.baseURL, this.burl, this.item.mediaType, true);
}
private setProperties(cssProperties: { [key: string]: string }, doc: Document) {
for (const key in cssProperties) {
const value = cssProperties[key];
if (value) doc.documentElement.style.setProperty(key, value);
}
}
private finalizeDOM(doc: Document, root: string | undefined, base: string | undefined, mediaType: MediaType, fxl = false, cssProperties?: { [key: string]: string }): string {
if(!doc) return "";
// Get allowed domains from injector if it exists
const allowedDomains = this.injector?.getAllowedDomains?.() || [];
// Always include the root domain if provided
const domains = [...new Set([
...(root ? [root] : []),
...allowedDomains
])].filter(Boolean);
// CSS and script injection is now handled by the Injector system
// Apply CSS properties if provided (only for reflowable)
if (cssProperties && !fxl) {
this.setProperties(cssProperties, doc);
}
// Set all <img> elements to high priority
// From what I understand, browser heuristics
// de-prioritize <iframe> resources. This causes the <img>
// elements to be loaded in sequence, which in documents
// with many images causes significant impact to rendering
// speed. When you increase the priority, the <img> data is
// loaded in parallel, greatly increasing overall speed.
doc.body.querySelectorAll("img").forEach((img) => {
img.setAttribute("fetchpriority", "high");
});
// We need to ensure that lang is set on the root element
// since it is used for settings such as font-family, hyphens, ligatures, etc.
// but also screen readers, etc.
// Metadata’s effectiveReadingProgression uses first item in array as primary language
// so we keep it consistent.
if (mediaType.isHTML && this.pub.metadata.languages?.[0]) {
const primaryLanguage = this.pub.metadata.languages[0];
if (mediaType === MediaType.XHTML) {
// InDesign is infamous for setting xml:lang on the body instead of the root element
// So we have to check whether lang is set on the body and move it to the root element
const rootLang = doc.documentElement.lang || doc.documentElement.getAttribute("xml:lang");
const bodyLang = doc.body.lang || doc.body.getAttribute("xml:lang");
if (bodyLang && !rootLang) {
doc.documentElement.lang = bodyLang;
doc.documentElement.setAttribute("xml:lang", bodyLang);
doc.body.removeAttribute("xml:lang");
doc.body.removeAttribute("lang");
} else if (!rootLang) {
doc.documentElement.lang = primaryLanguage;
doc.documentElement.setAttribute("xml:lang", primaryLanguage);
}
} else if (
mediaType === MediaType.HTML &&
!doc.documentElement.lang
) {
doc.documentElement.lang = primaryLanguage;
}
}
// Set dir="rtl" on the root element for RTL publications if the author
// has not already declared a direction on html or body.
// See: https://github.com/readium/readium-css/blob/develop/docs/CSS03-injection_and_pagination.md#be-cautious-the-direction-propagates
// CJK modes must NEVER receive a dir attribute — writing-mode handles
// directionality visually, and dir="rtl" would break vertical layout.
const scriptMode = getScriptMode(this.pub.metadata);
if (scriptMode === "rtl" &&
!doc.documentElement.dir &&
!doc.body.dir
) {
doc.documentElement.dir = ReadingProgression.rtl;
}
if (base !== undefined) {
// Set all URL bases. Very convenient!
const b = doc.createElement("base");
b.href = base;
b.dataset.readium = "true";
doc.head.firstChild!.before(b);
}
// Add CSP with allowed domains
const meta = doc.createElement("meta");
meta.httpEquiv = "Content-Security-Policy";
meta.content = csp(domains);
meta.dataset.readium = "true";
doc.head.firstChild!.before(meta);
// Make blob from doc
return URL.createObjectURL(
new Blob([new XMLSerializer().serializeToString(doc)], {
type: mediaType.isHTML
? mediaType.string
: "application/xhtml+xml", // Fallback to XHTML
})
);
}
}