diff --git a/navigator-html-injectables/package.json b/navigator-html-injectables/package.json index b6332de2..c09ca27d 100644 --- a/navigator-html-injectables/package.json +++ b/navigator-html-injectables/package.json @@ -1,6 +1,6 @@ { "name": "@readium/navigator-html-injectables", - "version": "2.4.2", + "version": "2.3.1-alpha.2", "type": "module", "description": "An embeddable solution for connecting frames of HTML publications with a Readium Navigator", "author": "readium", diff --git a/navigator-html-injectables/src/helpers/css.ts b/navigator-html-injectables/src/helpers/css.ts index c609bd4e..cbd2cdce 100644 --- a/navigator-html-injectables/src/helpers/css.ts +++ b/navigator-html-injectables/src/helpers/css.ts @@ -1,5 +1,7 @@ import { ReadiumWindow } from "./dom.ts"; +export const isTypedOMSupported = () => typeof window.CSSTransformValue !== 'undefined'; + export function getProperties(wnd: ReadiumWindow) { const cssProperties: { [key: string]: string } = {}; diff --git a/navigator-html-injectables/src/modules/snapper/ColumnSnapper.ts b/navigator-html-injectables/src/modules/snapper/ColumnSnapper.ts index 62a10fe0..f392a95e 100644 --- a/navigator-html-injectables/src/modules/snapper/ColumnSnapper.ts +++ b/navigator-html-injectables/src/modules/snapper/ColumnSnapper.ts @@ -8,6 +8,7 @@ import { rangeFromLocator } from "../../helpers/locator.ts"; import { ReadiumWindow, deselect, findFirstVisibleLocator } from "../../helpers/dom.ts"; import { PatternAnalyzer } from "../../protection/PatternAnalyzer.ts"; import { BaseSuspiciousActivityEvent } from "../Peripherals.ts"; +import { isTypedOMSupported } from "../../helpers/css.ts"; const COLUMN_SNAPPER_STYLE_ID = "readium-column-snapper-style"; @@ -166,7 +167,13 @@ export class ColumnSnapper extends Snapper { const spos = position(currentScrollLeft, so, elapsed, period); doc.scrollLeft = spos; if(this.overscroll !== 0) - doc.style.transform = `translate3d(${-lpos}px, 0px, 0px)`; + if(isTypedOMSupported()) { + doc.attributeStyleMap.set("transform", new CSSTransformValue([ + new CSSTranslate(CSS.px(-lpos), CSS.px(0), CSS.px(0)) + ])); + } else { + doc.style.transform = `translate3d(${-lpos}px, 0px, 0px)`; + } if (elapsed < period) this.wnd.requestAnimationFrame(step); @@ -282,10 +289,22 @@ export class ColumnSnapper extends Snapper { if(newpos < minScrollLeft) { this.overscroll = newpos; - this.doc().style.transform = `translate3d(${-this.overscroll}px, 0px, 0px)`; + if(isTypedOMSupported()) { + this.doc().attributeStyleMap.set("transform", new CSSTransformValue([ + new CSSTranslate(CSS.px(-newpos), CSS.px(0), CSS.px(0)) + ])); + } else { + this.doc().style.transform = `translate3d(${-newpos}px, 0px, 0px)`; + } } else if(newpos > maxScrollLeft) { this.overscroll = newpos; - this.doc().style.transform = `translate3d(${-newpos}px, 0px, 0px)`; + if(isTypedOMSupported()) { + this.doc().attributeStyleMap.set("transform", new CSSTransformValue([ + new CSSTranslate(CSS.px(-newpos), CSS.px(0), CSS.px(0)) + ])); + } else { + this.doc().style.transform = `translate3d(${-newpos}px, 0px, 0px)`; + } } else { this.overscroll = 0; this.doc().style.removeProperty("transform"); diff --git a/navigator/package.json b/navigator/package.json index e6fc399b..9fb8a5e0 100644 --- a/navigator/package.json +++ b/navigator/package.json @@ -1,6 +1,6 @@ { "name": "@readium/navigator", - "version": "2.5.4", + "version": "2.4.0-alpha.17", "type": "module", "description": "Next generation SDK for publications in Web Apps", "author": "readium", diff --git a/navigator/src/epub/EpubNavigator.ts b/navigator/src/epub/EpubNavigator.ts index 97c92768..3423e059 100644 --- a/navigator/src/epub/EpubNavigator.ts +++ b/navigator/src/epub/EpubNavigator.ts @@ -238,11 +238,12 @@ export class EpubNavigator extends VisualNavigator implements Configurable { + await this.go(this.currentLocation, false, (s) => { + res(s); + }); + }); } public get settings(): Readonly { @@ -885,7 +892,7 @@ export class EpubNavigator extends VisualNavigator implements Configurable 0; if(hasProgression) done = await new Promise((res, _) => { @@ -896,7 +903,42 @@ export class EpubNavigator extends VisualNavigator implements Configurable p.locations.position === locator.locations.position); + if (match) { + locator = match.copyWithLocations(locator.locations); + fellback = true; + } + } + if(!fellback && typeof locator.locations?.totalProgression === "number") { + // If locator has no href, but it does have a totalProgression, + // we can attempt to find the right resource from the positions list. + // This is here to help with conversion from OPDS locators which only + // require the total progression in the publication. + const targetProgression = locator.locations.totalProgression; + let closestIdx = 0; + let closestDist = Infinity; + for (let i = 0; i < this.positions.length; i++) { + const pos = this.positions[i]; + // Use totalProgression if available, otherwise estimate from index + const posProg = pos.locations.totalProgression ?? (i / this.positions.length); + const dist = Math.abs(posProg - targetProgression); + if (dist < closestDist) { + closestDist = dist; + closestIdx = i; + } + } + locator = this.positions[closestIdx].copyWithLocations(locator.locations); + } + } + return locator; + } + public go(locator: Locator, _: boolean, cb: (ok: boolean) => void): void { + locator = this.completeLocator(locator); const href = locator.href.split("#")[0]; let link = this.pub.readingOrder.findWithHref(href); if(!link) { diff --git a/navigator/src/epub/frame/FrameBlobBuilder.ts b/navigator/src/epub/frame/FrameBlobBuilder.ts index e05b49cd..704ada34 100644 --- a/navigator/src/epub/frame/FrameBlobBuilder.ts +++ b/navigator/src/epub/frame/FrameBlobBuilder.ts @@ -1,4 +1,4 @@ -import { Link, MediaType, Publication, ReadingProgression } from "@readium/shared"; +import { Link, MediaType, Publication, ReadingProgression, Resource } from "@readium/shared"; import { Injector } from "../../injection/Injector.ts"; import { getScriptMode } from "../../helpers/scriptMode.ts"; @@ -20,73 +20,121 @@ const csp = (domains: string[]) => { ].join("; "); }; -export default class FrameBlobBuider { - private readonly item: Link; - private readonly burl: string; - private readonly pub: Publication; +export default class FrameBlobBuilder { private readonly cssProperties?: { [key: string]: string }; private readonly injector: Injector | null = null; + private currentUrl?: string; + private currentResource?: Resource; + constructor( - pub: Publication, - baseURL: string, - item: Link, + private readonly pub: Publication, + private readonly baseURL: string, + private readonly 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 reset() { + this.currentUrl && URL.revokeObjectURL(this.currentUrl); + this.currentUrl = undefined; + this.currentResource?.close(); + this.currentResource = undefined; + } + public async build(fxl = false): Promise { - if(!this.item.mediaType.isHTML) { - if(this.item.mediaType.isBitmap || this.item.mediaType.equals(MediaType.SVG)) { - return this.buildImageFrame(); + if(this.currentUrl) return this.currentUrl; + + this.currentResource = this.pub.get(this.item); + const link = await this.currentResource.link(); + if(!this.currentResource) { + // Reset has occured in the meantime + return "about:blank"; + } + if(!link.mediaType.isHTML) { + if(link.mediaType.isBitmap || link.mediaType.equals(MediaType.SVG)) { + const blobUrl = await this.buildImageFrame(); + this.currentUrl = blobUrl; + return blobUrl; } else - throw Error("Unsupported frame mediatype " + this.item.mediaType.string); + throw Error("Unsupported frame mediatype " + link.mediaType.string); } else { - return await this.buildHtmlFrame(fxl); + const blobUrl = await this.buildHtmlFrame(fxl); + this.currentUrl = blobUrl; + return blobUrl; } } private async buildHtmlFrame(fxl = false): Promise { - // Load the HTML resource - const txt = await this.pub.get(this.item).readAsString(); - if(!txt) throw new Error(`Failed reading item ${this.item.href}`); + if(!this.currentResource) throw new Error("No resource loaded"); - const doc = new DOMParser().parseFromString( - txt, - this.item.mediaType.string as DOMParserSupportedType - ); + // Load the HTML resource + const link = await this.currentResource.link(); + const doc = await this.currentResource.readAsXML() as HTMLDocument; + if(!doc) throw new Error(`Failed reading item ${link.href}`); 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}`); + throw new Error(`Failed parsing item ${link.href}: ${details?.textContent || perror.textContent}`); } // Apply resource injections if injection service is provided if (this.injector) { - await this.injector.injectForDocument(doc, this.item); + await this.injector.injectForDocument(doc, link); } - return this.finalizeDOM(doc, this.pub.baseURL, this.burl, this.item.mediaType, fxl, this.cssProperties); + return this.finalizeDOM(doc, this.pub.baseURL, link.toURL(this.baseURL) || "", link.mediaType, fxl, this.cssProperties); } - private buildImageFrame(): string { - // Rudimentary image display - const doc = document.implementation.createHTMLDocument(this.item.title || this.item.href); + private async buildImageFrame(): Promise { + if(!this.currentResource) throw new Error("No resource loaded"); + const link = await this.currentResource.link(); + const burl = link.toURL(this.baseURL) || "" + + // Rudimentary image display in an HTML doc + const doc = document.implementation.createHTMLDocument(link.title || link.href); + + // Add viewport if available + if((link?.height || 0) > 0 && (link?.width || 0) > 0) { + const viewportMeta = doc.createElement("meta"); + viewportMeta.name = "viewport"; + viewportMeta.content = `width=${link.width}, height=${link.height}`; + viewportMeta.dataset.readium = "true"; + doc.head.appendChild(viewportMeta); + } + const simg = document.createElement("img"); - simg.src = this.burl || ""; - simg.alt = this.item.title || ""; - simg.decoding = "async"; + simg.src = burl || ""; + simg.alt = link.title || ""; + await simg.decode(); // Reduce repaints doc.body.appendChild(simg); - return this.finalizeDOM(doc, this.pub.baseURL, this.burl, this.item.mediaType, true); + + // Apply resource injections if injection service is provided + if (this.injector) { + await this.injector.injectForDocument(doc, new Link({ + // Temporary solution to address injector only expecting (X)HTML + // documents for injection, which we are technically providing + href: "readium-image-frame.xhtml", + type: MediaType.XHTML.string + })); + } + + // Add image style + const sstyle = doc.createElement("style"); + sstyle.dataset.readium = "true"; + sstyle.textContent = ` + html, body { width: 100%; height: 100%; margin: 0; padding: 0; font-size: 0; } + img { margin: 0; padding: 0; border: 0; }`; + doc.head.appendChild(sstyle); + + return this.finalizeDOM(doc, this.pub.baseURL, burl, link.mediaType, true); } private setProperties(cssProperties: { [key: string]: string }, doc: Document) { @@ -102,6 +150,10 @@ export default class FrameBlobBuider { // Get allowed domains from injector if it exists const allowedDomains = this.injector?.getAllowedDomains?.() || []; + // Remove query from root if present, as CSP doesn't allow them + root = root?.split("?")[0]; + + // Always include the root domain if provided const domains = [...new Set([ ...(root ? [root] : []), @@ -124,6 +176,7 @@ export default class FrameBlobBuider { // loaded in parallel, greatly increasing overall speed. doc.body.querySelectorAll("img").forEach((img) => { img.setAttribute("fetchpriority", "high"); + img.setAttribute("referrerpolicy", "origin"); }); // We need to ensure that lang is set on the root element diff --git a/navigator/src/epub/frame/FrameManager.ts b/navigator/src/epub/frame/FrameManager.ts index 34856344..bceb4dd9 100644 --- a/navigator/src/epub/frame/FrameManager.ts +++ b/navigator/src/epub/frame/FrameManager.ts @@ -27,7 +27,7 @@ export class FrameManager { this.frame.sandbox.value = "allow-same-origin allow-scripts"; this.frame.classList.add("readium-navigator-iframe"); this.frame.style.visibility = "hidden"; - this.frame.style.setProperty("aria-hidden", "true"); + this.frame.ariaHidden = "true"; this.frame.style.opacity = "0"; this.frame.style.position = "absolute"; this.frame.style.pointerEvents = "none"; @@ -111,7 +111,7 @@ export class FrameManager { async hide(): Promise { if(this.destroyed) return; this.frame.style.visibility = "hidden"; - this.frame.style.setProperty("aria-hidden", "true"); + this.frame.ariaHidden = "true"; this.frame.style.opacity = "0"; this.frame.style.pointerEvents = "none"; this.hidden = true; @@ -140,9 +140,9 @@ export class FrameManager { const remove = () => { this.frame.style.removeProperty("visibility"); - this.frame.style.removeProperty("aria-hidden"); this.frame.style.removeProperty("opacity"); this.frame.style.removeProperty("pointer-events"); + this.frame.ariaHidden = null; this.hidden = false; if (sML.UA.WebKit) { diff --git a/navigator/src/epub/frame/FramePoolManager.ts b/navigator/src/epub/frame/FramePoolManager.ts index 6b869f3c..7ca428a1 100644 --- a/navigator/src/epub/frame/FramePoolManager.ts +++ b/navigator/src/epub/frame/FramePoolManager.ts @@ -1,12 +1,12 @@ import { ModuleName } from "@readium/navigator-html-injectables"; import { Locator, Publication } from "@readium/shared"; -import FrameBlobBuider from "./FrameBlobBuilder.ts"; +import FrameBlobBuilder from "./FrameBlobBuilder.ts"; import { FrameManager } from "./FrameManager.ts"; import { Injector } from "../../injection/Injector.ts"; import { IContentProtectionConfig, IKeyboardPeripheralsConfig } from "../../Navigator.ts"; -const UPPER_BOUNDARY = 5; -const LOWER_BOUNDARY = 3; +const UPPER_BOUNDARY = 10; +const LOWER_BOUNDARY = 5; export class FramePoolManager { private readonly container: HTMLElement; @@ -14,8 +14,8 @@ export class FramePoolManager { private _currentFrame: FrameManager | undefined; private currentCssProperties: { [key: string]: string } | undefined; private readonly pool: Map = new Map(); - private readonly blobs: Map = new Map(); - private readonly inprogress: Map> = new Map(); + private readonly blobs: Map = new Map(); + private readonly inprogress: Map> = new Map(); private pendingUpdates: Map = new Map(); private currentBaseURL: string | undefined; private readonly injector: Injector | null = null; @@ -42,7 +42,7 @@ export class FramePoolManager { // Wait for all in-progress loads to complete let iit = this.inprogress.values(); let inp = iit.next(); - const inprogressPromises: Promise[] = []; + const inprogressPromises: Promise[] = []; while(inp.value) { inprogressPromises.push(inp.value); inp = iit.next(); @@ -62,10 +62,8 @@ export class FramePoolManager { this.pool.clear(); // Revoke all blobs - this.blobs.forEach(v => { - this.injector?.releaseBlobUrl?.(v); - URL.revokeObjectURL(v); - }); + this.blobs.forEach(v => v.reset()); + this.blobs.clear(); // Clean up injector if it exists this.injector?.dispose(); @@ -106,15 +104,18 @@ export class FramePoolManager { this.pool.delete(href); if(this.pendingUpdates.has(href)) this.pendingUpdates.set(href, { inPool: false }); + // Note that we don't reset the blob here, unlike in the FXL pool. + // This is because FXL tends to have a ton more blobs. Maybe we'll adjust + // this at a later point with a much larger boundary for resets to deal + // with extremely long/large reflowable publications. + // Reflowable publication resources also tend to be much larger documents, + // so they're more expensive to preprocess with the FrameBlobBuilder. }); // Check if base URL of publication has changed if(this.currentBaseURL !== undefined && pub.baseURL !== this.currentBaseURL) { // Revoke all blobs - this.blobs.forEach(v => { - this.injector?.releaseBlobUrl?.(v); - URL.revokeObjectURL(v); - }); + this.blobs.forEach(v => v.reset()); this.blobs.clear(); } this.currentBaseURL = pub.baseURL; @@ -127,18 +128,14 @@ export class FramePoolManager { // when navigating backwards, where paginated will go the // start of the resource instead of the end due to the // corrupted width ColumnSnapper (injectables) gets on init - this.blobs.forEach(v => { - this.injector?.releaseBlobUrl?.(v); - URL.revokeObjectURL(v); - }); + this.blobs.forEach(v => v.reset()); this.blobs.clear(); this.pendingUpdates.clear(); } if(this.pendingUpdates.has(href) && this.pendingUpdates.get(href)?.inPool === false) { - const url = this.blobs.get(href); - if(url) { - this.injector?.releaseBlobUrl?.(url); - URL.revokeObjectURL(url); + const v = this.blobs.get(href); + if(v) { + v.reset(); this.blobs.delete(href); this.pendingUpdates.delete(href); } @@ -157,7 +154,7 @@ export class FramePoolManager { const itm = pub.readingOrder.findWithHref(href); if(!itm) return; // TODO throw? if(!this.blobs.has(href)) { - const blobBuilder = new FrameBlobBuider( + this.blobs.set(href, new FrameBlobBuilder( pub, this.currentBaseURL || "", itm, @@ -165,24 +162,33 @@ export class FramePoolManager { cssProperties: this.currentCssProperties, injector: this.injector } - ); - const blobURL = await blobBuilder.build(); - this.blobs.set(href, blobURL); + )); } // Create