From 19ece96fe4212076ee9850c30cd99bf288a97f6a Mon Sep 17 00:00:00 2001 From: Abe Jellinek Date: Wed, 11 Dec 2024 10:59:53 -0500 Subject: [PATCH] EPUB: Honor writing-mode set in CSS, not just metadata And refactor CSS rewriting code. --- src/dom/common/lib/collection.ts | 14 ++++++ src/dom/epub/epub-view.ts | 26 +++++----- src/dom/epub/lib/sanitize-and-render.ts | 67 ++++++++++++++++--------- src/dom/epub/section-renderer.ts | 18 ++++--- 4 files changed, 80 insertions(+), 45 deletions(-) create mode 100644 src/dom/common/lib/collection.ts diff --git a/src/dom/common/lib/collection.ts b/src/dom/common/lib/collection.ts new file mode 100644 index 00000000..512e3331 --- /dev/null +++ b/src/dom/common/lib/collection.ts @@ -0,0 +1,14 @@ +export function mode(iter: Iterable): T | undefined { + let maxCount = 0; + let maxValue: T | undefined; + let map = new Map(); + for (let value of iter) { + let count = map.get(value) ?? 0; + map.set(value, ++count); + if (count > maxCount) { + maxCount = count; + maxValue = value; + } + } + return maxValue; +} diff --git a/src/dom/epub/epub-view.ts b/src/dom/epub/epub-view.ts index 603b952f..f16cdfeb 100644 --- a/src/dom/epub/epub-view.ts +++ b/src/dom/epub/epub-view.ts @@ -39,7 +39,7 @@ import { closestElement, getContainingBlock } from "../common/lib/nodes"; -import { StyleScoper } from "./lib/sanitize-and-render"; +import { CSSRewriter } from "./lib/sanitize-and-render"; import PageMapping from "./lib/page-mapping"; import { lengthenCFI, @@ -55,6 +55,7 @@ import { parseAnnotationsFromKOReaderMetadata, koReaderAnnotationToRange } from import { ANNOTATION_COLORS } from "../../common/defines"; import { calibreAnnotationToRange, parseAnnotationsFromCalibreMetadata } from "./lib/calibre"; import LRUCacheMap from "../common/lib/lru-cache-map"; +import { mode } from "../common/lib/collection"; class EPUBView extends DOMView { protected _find: EPUBFindProcessor | null = null; @@ -138,10 +139,6 @@ class EPUBView extends DOMView { } } - if (this.book.packaging.metadata.primary_writing_mode) { - this._iframeDocument.documentElement.style.writingMode = this.book.packaging.metadata.primary_writing_mode; - } - let style = this._iframeDocument.createElement('style'); style.innerHTML = injectCSS; this._iframeDocument.head.append(style); @@ -165,8 +162,7 @@ class EPUBView extends DOMView { this._sectionsContainer.hidden = true; this._iframeDocument.body.append(this._sectionsContainer); - let styleScoper = new StyleScoper(this._iframeDocument); - await this._displaySections(styleScoper); + await this._displaySections(); if (this._sectionRenderers.some(view => view.error) && await this._isEncrypted()) { this._options.onEPUBEncrypted(); @@ -235,24 +231,28 @@ class EPUBView extends DOMView { } } - private async _displaySection(section: Section, styleScoper: StyleScoper) { + private async _displaySection(section: Section, cssRewriter: CSSRewriter) { let renderer = new SectionRenderer({ section, sectionsContainer: this._sectionsContainer, document: this._iframeDocument, - styleScoper, }); - await renderer.render(this.book.archive.request.bind(this.book.archive)); + await renderer.render(this.book.archive.request.bind(this.book.archive), cssRewriter); renderer.body.lang = this.book.packaging.metadata.language; this._sectionRenderers[section.index] = renderer; } - private _displaySections(styleScoper: StyleScoper) { - return Promise.all(this.book.spine.spineItems + private async _displaySections() { + let cssRewriter = new CSSRewriter(this._iframeDocument); + await Promise.all(this.book.spine.spineItems // We should do this: // .filter(section => section.linear) // But we need to be sure it won't break anything - .map(section => this._displaySection(section, styleScoper))); + .map(section => this._displaySection(section, cssRewriter))); + + this._iframeDocument.documentElement.style.writingMode = this.book.packaging.metadata.primary_writing_mode + || mode(this._sectionRenderers.map(r => r.container.dataset.writingMode).filter(Boolean)) + || ''; } private _initPageMapping(json?: string): PageMapping { diff --git a/src/dom/epub/lib/sanitize-and-render.ts b/src/dom/epub/lib/sanitize-and-render.ts index 0361b424..0116f417 100644 --- a/src/dom/epub/lib/sanitize-and-render.ts +++ b/src/dom/epub/lib/sanitize-and-render.ts @@ -3,10 +3,10 @@ import parser from "postcss-selector-parser"; export const SANITIZER_REPLACE_TAGS = new Set(['html', 'head', 'body', 'base', 'meta']); export async function sanitizeAndRender(xhtml: string, options: { - container: Element, - styleScoper: StyleScoper, -}): Promise { - let { container, styleScoper } = options; + container: HTMLElement, + cssRewriter: CSSRewriter, +}): Promise { + let { container, cssRewriter } = options; let doc = container.ownerDocument; let sectionDoc = new DOMParser().parseFromString(xhtml, 'application/xhtml+xml'); @@ -25,7 +25,7 @@ export async function sanitizeAndRender(xhtml: string, options: { switch (localName) { case 'style': container.classList.add( - await styleScoper.add(elem.innerHTML || '') + await cssRewriter.add(elem.innerHTML || '') ); toRemove.push(elem); break; @@ -34,7 +34,7 @@ export async function sanitizeAndRender(xhtml: string, options: { if (link.relList.contains('stylesheet')) { try { container.classList.add( - await styleScoper.addByURL(link.href) + await cssRewriter.addByURL(link.href) ); } catch (e) { @@ -95,8 +95,8 @@ export async function sanitizeAndRender(xhtml: string, options: { container.append(...sectionDoc.childNodes); - // Add classes to elements that emulate , , and - for (let selector of styleScoper.tagEmulatingSelectors.table) { + // Add classes to elements with properties that we handle specially + for (let selector of cssRewriter.trackedSelectors.table) { try { for (let table of Array.from(container.querySelectorAll(selector))) { table.classList.add('table-like'); @@ -109,7 +109,7 @@ export async function sanitizeAndRender(xhtml: string, options: { // Ignore } } - for (let selector of styleScoper.tagEmulatingSelectors.supSub) { + for (let selector of cssRewriter.trackedSelectors.supSub) { try { for (let elem of Array.from(container.querySelectorAll(selector))) { elem.classList.add('sup-sub-like'); @@ -120,13 +120,29 @@ export async function sanitizeAndRender(xhtml: string, options: { } } - return container.querySelector('replaced-body') as HTMLElement; + // Get the primary writing mode for this section + let documentElement = container.querySelector('replaced-html'); + let body = container.querySelector('replaced-body'); + let writingMode = ''; + for (let [selector, writingModePropertyValue] of cssRewriter.trackedSelectors.writingMode) { + try { + if (documentElement?.matches(selector) || body?.matches(selector)) { + writingMode = writingModePropertyValue; + break; + } + } + catch (e) { + // Ignore + } + } + container.dataset.writingMode = writingMode; } -export class StyleScoper { - tagEmulatingSelectors = { - table: new Set(['table', 'mtable', 'pre']), - supSub: new Set(['sup', 'sub']), +export class CSSRewriter { + trackedSelectors = { + table: new Set(['table', 'mtable', 'pre']), + supSub: new Set(['sup', 'sub']), + writingMode: new Map(), }; private _document: Document; @@ -238,23 +254,26 @@ export class StyleScoper { }); }).processSync(styleRule.selectorText); - // Keep track of selectors that emulate
, , and , because we want to add classes - // to matching elements in sanitizeAndRender() - if (styleRule.style.display === 'table' || styleRule.style.display === 'inline-table') { - this.tagEmulatingSelectors.table.add(styleRule.selectorText); + let style = styleRule.style; + + if (style.display === 'table' || style.display === 'inline-table') { + this.trackedSelectors.table.add(styleRule.selectorText); + } + if (style.verticalAlign === 'super' || style.verticalAlign === 'sub') { + this.trackedSelectors.supSub.add(styleRule.selectorText); } - if (styleRule.style.verticalAlign === 'super' || styleRule.style.verticalAlign === 'sub') { - this.tagEmulatingSelectors.supSub.add(styleRule.selectorText); + if (style.writingMode) { + this.trackedSelectors.writingMode.set(styleRule.selectorText, style.writingMode); } // If this rule sets a monospace font, make it !important so that it overrides the default content font - if (styleRule.style.fontFamily && /\bmono(space)?\b/i.test(styleRule.style.fontFamily)) { - styleRule.style.setProperty('font-family', styleRule.style.fontFamily, 'important'); + if (style.fontFamily && /\bmono(space)?\b/i.test(style.fontFamily)) { + style.setProperty('font-family', style.fontFamily, 'important'); } // If this rule sets a font-size, rewrite it to be relative - if (styleRule.style.fontSize) { - styleRule.style.fontSize = rewriteFontSize(styleRule.style.fontSize); + if (style.fontSize) { + style.fontSize = rewriteFontSize(style.fontSize); } } else if (rule.constructor.name === 'CSSImportRule') { diff --git a/src/dom/epub/section-renderer.ts b/src/dom/epub/section-renderer.ts index 3274629e..a3360573 100644 --- a/src/dom/epub/section-renderer.ts +++ b/src/dom/epub/section-renderer.ts @@ -2,7 +2,7 @@ import Section from "epubjs/types/section"; import { getPotentiallyVisibleTextNodes } from "../common/lib/nodes"; import { sanitizeAndRender, - StyleScoper + CSSRewriter } from "./lib/sanitize-and-render"; import { createSearchContext } from "../common/lib/find"; @@ -21,18 +21,14 @@ class SectionRenderer { private readonly _sectionsContainer: HTMLElement; - private readonly _styleScoper: StyleScoper; - constructor(options: { section: Section, sectionsContainer: HTMLElement, document: Document, - styleScoper: StyleScoper, }) { this.section = options.section; this._sectionsContainer = options.sectionsContainer; this._document = options.document; - this._styleScoper = options.styleScoper; let container = this._document.createElement('div'); container.id = 'section-' + this.section.index; @@ -63,7 +59,7 @@ class SectionRenderer { } // eslint-disable-next-line @typescript-eslint/ban-types - async render(requestFn: Function): Promise { + async render(requestFn: Function, cssRewriter: CSSRewriter): Promise { if (this.body) { throw new Error('Already rendered'); } @@ -75,8 +71,14 @@ class SectionRenderer { let xhtml = await this.section.render(requestFn); try { - this.body = await sanitizeAndRender(xhtml, - { container: this.container, styleScoper: this._styleScoper }); + await sanitizeAndRender(xhtml, { container: this.container, cssRewriter }); + let body = this.container.querySelector('replaced-body') as HTMLElement | null; + if (!body) { + console.error('Section has no body', this.section); + this._displayError('Missing content'); + return; + } + this.body = body; } catch (e) { console.error('Error rendering section ' + this.section.index + ' (' + this.section.href + ')', e);