Skip to content

Commit

Permalink
EPUB: Honor writing-mode set in CSS, not just metadata
Browse files Browse the repository at this point in the history
And refactor CSS rewriting code.
  • Loading branch information
AbeJellinek committed Dec 11, 2024
1 parent be81e76 commit 19ece96
Show file tree
Hide file tree
Showing 4 changed files with 80 additions and 45 deletions.
14 changes: 14 additions & 0 deletions src/dom/common/lib/collection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export function mode<T>(iter: Iterable<T>): T | undefined {
let maxCount = 0;
let maxValue: T | undefined;
let map = new Map<T, number>();
for (let value of iter) {
let count = map.get(value) ?? 0;
map.set(value, ++count);
if (count > maxCount) {
maxCount = count;
maxValue = value;
}
}
return maxValue;
}
26 changes: 13 additions & 13 deletions src/dom/epub/epub-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<EPUBViewState, EPUBViewData> {
protected _find: EPUBFindProcessor | null = null;
Expand Down Expand Up @@ -138,10 +139,6 @@ class EPUBView extends DOMView<EPUBViewState, EPUBViewData> {
}
}

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);
Expand All @@ -165,8 +162,7 @@ class EPUBView extends DOMView<EPUBViewState, EPUBViewData> {
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();
Expand Down Expand Up @@ -235,24 +231,28 @@ class EPUBView extends DOMView<EPUBViewState, EPUBViewData> {
}
}

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 {
Expand Down
67 changes: 43 additions & 24 deletions src/dom/epub/lib/sanitize-and-render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement> {
let { container, styleScoper } = options;
container: HTMLElement,
cssRewriter: CSSRewriter,
}): Promise<void> {
let { container, cssRewriter } = options;

let doc = container.ownerDocument;
let sectionDoc = new DOMParser().parseFromString(xhtml, 'application/xhtml+xml');
Expand All @@ -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;
Expand All @@ -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) {
Expand Down Expand Up @@ -95,8 +95,8 @@ export async function sanitizeAndRender(xhtml: string, options: {

container.append(...sectionDoc.childNodes);

// Add classes to elements that emulate <table>, <sup>, and <sub>
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');
Expand All @@ -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');
Expand All @@ -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<string>(['table', 'mtable', 'pre']),
supSub: new Set<string>(['sup', 'sub']),
export class CSSRewriter {
trackedSelectors = {
table: new Set(['table', 'mtable', 'pre']),
supSub: new Set(['sup', 'sub']),
writingMode: new Map<string, string>(),
};

private _document: Document;
Expand Down Expand Up @@ -238,23 +254,26 @@ export class StyleScoper {
});
}).processSync(styleRule.selectorText);

// Keep track of selectors that emulate <table>, <sup>, and <sub>, 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') {
Expand Down
18 changes: 10 additions & 8 deletions src/dom/epub/section-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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;
Expand Down Expand Up @@ -63,7 +59,7 @@ class SectionRenderer {
}

// eslint-disable-next-line @typescript-eslint/ban-types
async render(requestFn: Function): Promise<void> {
async render(requestFn: Function, cssRewriter: CSSRewriter): Promise<void> {
if (this.body) {
throw new Error('Already rendered');
}
Expand All @@ -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);
Expand Down

0 comments on commit 19ece96

Please sign in to comment.