fix(reader): support rendering and pagination of vertical writing epubs

Normalize legacy prefixed writing-mode and orientation CSS rules in the
Foliate paginator to ensure vertical writing is correctly rendered. Also,
fix paginator scroll bounds and scrolling direction offsets for vertical RTL
documents to prevent empty pages or rendering errors.

Closes #555
This commit is contained in:
neon
2026-07-07 16:14:11 +00:00
parent a49686d2ba
commit 4da5554e05
2 changed files with 68 additions and 18 deletions
+24 -17
View File
@@ -201,6 +201,23 @@ export const getPageProgressionRtl = (bookDir, contentRtl) => {
return contentRtl
}
export const usesNegativePageScroll = (vertical, pageProgressionRtl) => pageProgressionRtl && !vertical
export const getPageScrollOffset = (page, size, vertical, pageProgressionRtl) =>
size * (usesNegativePageScroll(vertical, pageProgressionRtl) ? -page : page)
export const normalizeStylesheetForReader = (data, width = innerWidth, height = innerHeight) =>
data
// unprefix as most of the props are (only) supported unprefixed
.replace(/(?<=[{\s;])-(?:epub|webkit)-(writing-mode|text-orientation|line-break)\s*:/gi, (_, prop) => `${prop}:`)
.replace(/(?<=[{\s;])-epub-/gi, '')
// replace vw and vh as they cause problems with layout
.replace(/(\d*\.?\d+)vw/gi, (_, d) => (parseFloat(d) * width) / 100 + 'px')
.replace(/(\d*\.?\d+)vh/gi, (_, d) => (parseFloat(d) * height) / 100 + 'px')
// `page-break-*` unsupported in columns; replace with `column-break-*`
.replace(/page-break-(after|before|inside)\s*:/gi, (_, x) => `-webkit-column-break-${x}:`)
.replace(/break-(after|before|inside)\s*:\s*(avoid-)?page/gi, (_, x, y) => `break-${x}: ${y ?? ''}column`)
const makeMarginals = (length, part) =>
Array.from({ length }, () => {
const div = document.createElement('div')
@@ -661,17 +678,7 @@ export class Paginator extends HTMLElement {
if (detail.type !== 'text/css') return
const w = innerWidth
const h = innerHeight
detail.data = Promise.resolve(detail.data).then((data) =>
data
// unprefix as most of the props are (only) supported unprefixed
.replace(/(?<=[{\s;])-epub-/gi, '')
// replace vw and vh as they cause problems with layout
.replace(/(\d*\.?\d+)vw/gi, (_, d) => (parseFloat(d) * w) / 100 + 'px')
.replace(/(\d*\.?\d+)vh/gi, (_, d) => (parseFloat(d) * h) / 100 + 'px')
// `page-break-*` unsupported in columns; replace with `column-break-*`
.replace(/page-break-(after|before|inside)\s*:/gi, (_, x) => `-webkit-column-break-${x}:`)
.replace(/break-(after|before|inside)\s*:\s*(avoid-)?page/gi, (_, x, y) => `break-${x}: ${y ?? ''}column`),
)
detail.data = Promise.resolve(detail.data).then((data) => normalizeStylesheetForReader(data, w, h))
})
}
#createView() {
@@ -805,7 +812,7 @@ export class Paginator extends HTMLElement {
const element = this.#container
const { scrollProp } = this
const [offset, a, b] = this.#scrollBounds
const rtl = this.#rtl
const rtl = usesNegativePageScroll(this.#vertical, this.#rtl)
const min = rtl ? offset - b : offset - a
const max = rtl ? offset + a : offset + b
element[scrollProp] = Math.max(min, Math.min(max, element[scrollProp] + delta))
@@ -816,7 +823,7 @@ export class Paginator extends HTMLElement {
const { start, end, pages, size } = this
const min = Math.abs(offset) - a
const max = Math.abs(offset) + b
const d = velocity * (this.#rtl ? -size : size)
const d = velocity * (usesNegativePageScroll(this.#vertical, this.#rtl) ? -size : size)
const page = Math.floor(Math.max(min, Math.min(max, (start + end) / 2 + (isNaN(d) ? 0 : d))) / size)
this.#scrollToPage(page, 'snap').then(() => {
@@ -883,7 +890,7 @@ export class Paginator extends HTMLElement {
: ({ top, bottom }) => ({ left: top + margin, right: bottom + margin })
}
const pxSize = this.pages * this.size
return this.#rtl
return usesNegativePageScroll(this.#vertical, this.#rtl)
? ({ left, right }) => ({ left: pxSize - right, right: pxSize - left })
: this.#vertical
? ({ top, bottom }) => ({ left: top, right: bottom })
@@ -895,7 +902,7 @@ export class Paginator extends HTMLElement {
return this.#scrollTo(offset, reason)
}
const offset = this.#getRectMapper()(rect).left
return this.#scrollToPage(Math.floor(offset / this.size) + (this.#rtl ? -1 : 1), reason)
return this.#scrollToPage(Math.floor(offset / this.size) + (usesNegativePageScroll(this.#vertical, this.#rtl) ? -1 : 1), reason)
}
async #scrollTo(offset, reason, smooth) {
const element = this.#container
@@ -919,7 +926,7 @@ export class Paginator extends HTMLElement {
}
}
async #scrollToPage(page, reason, smooth) {
const offset = this.size * (this.#rtl ? -page : page)
const offset = getPageScrollOffset(page, this.size, this.#vertical, this.#rtl)
return this.#scrollTo(offset, reason, smooth)
}
async scrollToAnchor(anchor, select) {
@@ -950,7 +957,7 @@ export class Paginator extends HTMLElement {
}
#getVisibleRange() {
if (this.scrolled) return getVisibleRange(this.#view.document, this.start + this.#margin, this.end - this.#margin, this.#getRectMapper())
const size = this.#rtl ? -this.size : this.size
const size = usesNegativePageScroll(this.#vertical, this.#rtl) ? -this.size : this.size
return getVisibleRange(this.#view.document, this.start - size, this.end - size, this.#getRectMapper())
}
#afterScroll(reason) {
@@ -45,6 +45,9 @@ describe('Foliate navigation', () => {
let getKoreaderDocFragmentIndex: (sections: { id: string }[], index: number) => number | null
let getKoboSpanValue: (range: Range | null) => string | null
let getPageProgressionRtl: (bookDir: string | null | undefined, contentRtl: boolean) => boolean
let usesNegativePageScroll: (vertical: boolean, pageProgressionRtl: boolean) => boolean
let getPageScrollOffset: (page: number, size: number, vertical: boolean, pageProgressionRtl: boolean) => number
let normalizeStylesheetForReader: (data: string, width?: number, height?: number) => string
let FixedLayout: new () => HTMLElement & {
open: (book: { rendition: { layout: string; spread: string }; sections: { load: () => Promise<string> }[] }) => void
goTo: (target: { index: number }) => Promise<void>
@@ -68,9 +71,14 @@ describe('Foliate navigation', () => {
getKoboSpanValue: typeof getKoboSpanValue
})
;({ FixedLayout } = (await import(fixedLayoutModulePath)) as { FixedLayout: typeof FixedLayout })
;({ Paginator, getPageProgressionRtl } = (await import(paginatorModulePath)) as {
;({ Paginator, getPageProgressionRtl, usesNegativePageScroll, getPageScrollOffset, normalizeStylesheetForReader } = (await import(
paginatorModulePath
)) as {
Paginator: typeof Paginator
getPageProgressionRtl: typeof getPageProgressionRtl
usesNegativePageScroll: typeof usesNegativePageScroll
getPageScrollOffset: typeof getPageScrollOffset
normalizeStylesheetForReader: typeof normalizeStylesheetForReader
})
})
@@ -177,6 +185,41 @@ describe('Foliate navigation', () => {
expect(getPageProgressionRtl(undefined, false)).toBe(false)
})
it('normalizes legacy vertical writing CSS used by Japanese EPUBs', () => {
const css = `
.vrtl {
-webkit-writing-mode: vertical-rl;
-epub-text-orientation: upright;
-webkit-line-break: strict;
}
.page {
width: 50vw;
height: 25vh;
page-break-after: always;
}
`
const normalized = normalizeStylesheetForReader(css, 1200, 800)
expect(normalized).toContain('writing-mode: vertical-rl;')
expect(normalized).toContain('text-orientation: upright;')
expect(normalized).toContain('line-break: strict;')
expect(normalized).toContain('width: 600px;')
expect(normalized).toContain('height: 200px;')
expect(normalized).toContain('-webkit-column-break-after: always;')
expect(normalized).not.toContain('-webkit-writing-mode')
expect(normalized).not.toContain('-epub-text-orientation')
expect(normalized).not.toContain('-webkit-line-break')
})
it('uses positive page scroll offsets for vertical RTL paginated EPUBs', () => {
expect(usesNegativePageScroll(false, true)).toBe(true)
expect(usesNegativePageScroll(true, true)).toBe(false)
expect(getPageScrollOffset(1, 800, false, true)).toBe(-800)
expect(getPageScrollOffset(1, 800, true, true)).toBe(800)
expect(getPageScrollOffset(1, 800, true, false)).toBe(800)
})
it('maps physical left and right navigation using OPF RTL page progression', async () => {
const view = new View()
const prev = vi.fn<() => void>()