UNPKG

@readium/navigator

Version:

Next generation SDK for publications in Web Apps

197 lines (173 loc) 8.28 kB
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 }) ); } }