Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,13 @@ foliate-view::part(head) {
}
```

### The Fixed Layout renderer

This renderer's layout can be configured by setting the following attributes:
- `zoom`: a [boolean attribute](https://developer.mozilla.org/en-US/docs/Glossary/Boolean/HTML), `fit-width` or `fit-page`. Scales the fixed layout accordingly.
- `spread`: changes the `rendition:spread` and respreads the book. It can be used to dynamically enable and disable synthetic spreads.
- `odd-pages`: set to `recto` or `verso` to force the first page to be on the front or back side of the book when spreading. If set to neither one, the first page is determined based on the information embedded in the e-book (if any).

### EPUB CFI

Parsed CFIs are represented as a plain array or object. The basic type is called a "part", which is an object with the following structure: `{ index, id, offset, temporal, spatial, text, side }`, corresponding to a step + offset in the CFI.
Expand Down
118 changes: 117 additions & 1 deletion comic-book.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,97 @@
export const makeComicBook = ({ entries, loadBlob, getSize }, file) => {
async function getJpgDimensions(blob) {
const header = await blob.slice(0, 2).arrayBuffer()
const view = new DataView(header)
if (view.getUint16(0) !== 0xFFD8) return null //JPEG SOI

let offset = 2
while (offset < blob.size) {
const buffer = await blob.slice(offset, offset + 9).arrayBuffer()
if (buffer.byteLength < 4) return null
const view = new DataView(buffer)
const marker = view.getUint16(0)
const length = view.getUint16(2)

// SOF0 (baseline DCT) or SOF2 (progressive DCT)
if (marker === 0xFFC0 || marker === 0xFFC2) {
const height = view.getUint16(5)
const width = view.getUint16(7)
return { width, height }
}

if (marker === 0xFFDA) return null //Image data

offset += 2 + length
}

return null
}

function getUint24LittleEndian(dataView, offset) {
return dataView.getUint8(offset)
| (dataView.getUint8(offset + 1) << 8)
| (dataView.getUint8(offset + 2) << 16)
}

async function getWebpDimensions(blob) {
const header = await blob.slice(0, 30).arrayBuffer()
const view = new DataView(header)

const riff = String.fromCharCode(...new Uint8Array(header.slice(0, 4)))
const webp = String.fromCharCode(...new Uint8Array(header.slice(8, 12)))
if (riff !== 'RIFF' || webp !== 'WEBP') return null

const chunkType = String.fromCharCode(...new Uint8Array(header.slice(12, 16)))

if (chunkType === 'VP8 ') {
const width = view.getUint16(26, true) & 0x3FFF
const height = view.getUint16(28, true) & 0x3FFF
return { width, height }
}

if (chunkType === 'VP8L') {
const b0 = view.getUint8(21)
const b1 = view.getUint8(22)
const b2 = view.getUint8(23)
const b3 = view.getUint8(24)

const width = 1 + (((b1 & 0x3F) << 8) | b0)
const height = 1 + (((b3 & 0xF) << 10) | (b2 << 2) | ((b1 & 0xC0) >> 6))
return { width, height }
}

if (chunkType === 'VP8X') {
const width = 1 + getUint24LittleEndian(view, 24)
const height = 1 + getUint24LittleEndian(view, 27)
return { width, height }
}

return null
}

const pageDimensions = {
jpg: getJpgDimensions,
jpeg: getJpgDimensions,
png: async (blob) => {
const header = await blob.slice(0, 24).arrayBuffer()
const view = new DataView(header)
if (view.getUint32(0) !== 0x89504E47) return null //PNG magic number
const width = view.getUint32(16)
const height = view.getUint32(20)
return { width, height }
},
gif: async (blob) => {
const header = await blob.slice(0, 10).arrayBuffer()
const view = new DataView(header)
const signature = String.fromCharCode(...new Uint8Array(header.slice(0, 6)))
if (!/^GIF8[79]a$/.test(signature)) return null //GIF magic numbers
const width = view.getUint16(6, true)
const height = view.getUint16(8, true)
return { width, height }
},
webp: getWebpDimensions,
}

export const makeComicBook = async ({ entries, loadBlob, getSize }, file, smartSpreads) => {
const cache = new Map()
const urls = new Map()
const load = async name => {
Expand All @@ -23,6 +116,27 @@ export const makeComicBook = ({ entries, loadBlob, getSize }, file) => {
.sort()
if (!files.length) throw new Error('No supported image files in archive')

const spreads = {}
if (smartSpreads) {
const promises = []
files.forEach((name) => {
const extension = name.slice((name.lastIndexOf('.') - 1 >>> 0) + 2)
if (!pageDimensions[extension]) return
promises.push(new Promise(resolve => {
loadBlob(name)
.then(blob => pageDimensions[extension](blob))
.then(dimensions => {
if (dimensions.width > dimensions.height * 1.05) {
spreads[name] = 'center'
}
resolve()
})
}))
})

await Promise.all(promises)
}

const book = {}
book.getCover = () => loadBlob(files[0])
book.metadata = { title: file.name }
Expand All @@ -31,6 +145,7 @@ export const makeComicBook = ({ entries, loadBlob, getSize }, file) => {
load: () => load(name),
unload: () => unload(name),
size: getSize(name),
pageSpread: spreads[name],
}))
book.toc = files.map(name => ({ label: name, href: name }))
book.rendition = { layout: 'pre-paginated' }
Expand All @@ -43,3 +158,4 @@ export const makeComicBook = ({ entries, loadBlob, getSize }, file) => {
}
return book
}

140 changes: 94 additions & 46 deletions fixed-layout.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,15 @@ const getViewport = (doc, viewport) => {
}

export class FixedLayout extends HTMLElement {
static observedAttributes = ['zoom']
static observedAttributes = ['zoom','spread','odd-pages']
#root = this.attachShadow({ mode: 'closed' })
#observer = new ResizeObserver(() => this.#render())
#spreads
#index = -1
defaultViewport
spread
#rtl
#oddPages
#portrait = false
#left
#right
Expand All @@ -57,6 +59,8 @@ export class FixedLayout extends HTMLElement {
overflow: auto;
}`)

this.#oddPages = this.getAttribute('odd-pages')

this.#observer.observe(this)
}
attributeChangedCallback(name, _, value) {
Expand All @@ -66,6 +70,15 @@ export class FixedLayout extends HTMLElement {
? parseFloat(value) : value
this.#render()
break
case 'spread':
this.spread = value
this.respread()
break
case 'odd-pages':
this.#oddPages = value
this.#determineOddPages()
this.respread()
break
}
}
async #createFrame({ index, src: srcOption }) {
Expand Down Expand Up @@ -198,50 +211,85 @@ export class FixedLayout extends HTMLElement {
return true
}
}
#determineOddPages() {
if (this.#oddPages !== 'recto' && this.#oddPages !== 'verso') {
this.#oddPages = 'recto'
const firstPageSpread = this.book.sections[0]?.pageSpread
if (this.#rtl && firstPageSpread === 'right' || !this.#rtl && firstPageSpread === 'left') {
this.#oddPages = 'verso'
}
}
}
#respread() {
if (this.spread === 'none')
this.#spreads = this.book.sections.map(section => ({ center: section }))
else {
const rtl = this.#rtl
const ltr = !this.#rtl
const oddPagesVerso = this.#oddPages === 'verso'

//Work out first page position based on settings and epub standard assumptions
let firstPageSpread = this.book.sections[0]?.pageSpread
|| (ltr && oddPagesVerso || rtl && !oddPagesVerso ? 'left' : 'right')

//Determine if the spread must be flipped (never flip based on just embedded pagination direction)
const flipSpreads = this.#oddPages === 'recto' && (ltr && firstPageSpread === 'left' || rtl && firstPageSpread === 'right')
|| this.#oddPages === 'verso' && (ltr && firstPageSpread === 'right' || rtl && firstPageSpread === 'left')

this.#spreads = this.book.sections.reduce((arr, section, i) => {
const last = arr[arr.length - 1]
const isFirstPage = !i

let { pageSpread } = section
if (!pageSpread && isFirstPage) pageSpread = firstPageSpread
if (flipSpreads) {
if (pageSpread === 'left') pageSpread = 'right'
else if (pageSpread === 'right') pageSpread = 'left'
}

const newSpread = () => {
const spread = {}
arr.push(spread)
return spread
}

if (pageSpread === 'center') {
const spread = newSpread()
spread.center = section
}
else if (pageSpread === 'left') {
const spread = !last || last.center || last.left || ltr ? newSpread() : last
spread.left = section
}
else if (pageSpread === 'right') {
const spread = !last || last.center || last.right || rtl ? newSpread() : last
spread.right = section
}
else if (ltr) {
if (!last || last.center || last.right) newSpread().left = section
else last.right = section
}
else {
if (!last || last.center || last.left) newSpread().right = section
else last.left = section
}
return arr
}, [])
}
}
respread() {
this.#respread()
this.goToSpread(this.#index, this.#side, 'respread')
}
open(book) {
this.book = book
const { rendition } = book
this.spread = rendition?.spread
this.spread = this.getAttribute('spread')
if (!this.spread || this.spread === 'auto') this.spread = rendition?.spread
this.defaultViewport = rendition?.viewport

const rtl = book.dir === 'rtl'
const ltr = !rtl
this.rtl = rtl

if (rendition?.spread === 'none')
this.#spreads = book.sections.map(section => ({ center: section }))
else this.#spreads = book.sections.reduce((arr, section, i) => {
const last = arr[arr.length - 1]
const { pageSpread } = section
const newSpread = () => {
const spread = {}
arr.push(spread)
return spread
}
if (pageSpread === 'center') {
const spread = last.left || last.right ? newSpread() : last
spread.center = section
}
else if (pageSpread === 'left') {
const spread = last.center || last.left || ltr && i ? newSpread() : last
spread.left = section
}
else if (pageSpread === 'right') {
const spread = last.center || last.right || rtl && i ? newSpread() : last
spread.right = section
}
else if (ltr) {
if (last.center || last.right) newSpread().left = section
else if (last.left || !i) last.right = section
else last.left = section
}
else {
if (last.center || last.left) newSpread().right = section
else if (last.right || !i) last.left = section
else last.right = section
}
return arr
}, [{}])
this.#rtl = book.dir === 'rtl'
this.#determineOddPages()
this.#respread()
}
get index() {
const spread = this.#spreads[this.#index]
Expand All @@ -264,7 +312,7 @@ export class FixedLayout extends HTMLElement {
}
async goToSpread(index, side, reason) {
if (index < 0 || index > this.#spreads.length - 1) return
if (index === this.#index) {
if (index === this.#index && reason !== 'respread') {
this.#render(side)
return
}
Expand Down Expand Up @@ -298,12 +346,12 @@ export class FixedLayout extends HTMLElement {
await this.goToSpread(index, side)
}
async next() {
const s = this.rtl ? this.#goLeft() : this.#goRight()
if (!s) return this.goToSpread(this.#index + 1, this.rtl ? 'right' : 'left', 'page')
const s = this.#rtl ? this.#goLeft() : this.#goRight()
if (!s) return this.goToSpread(this.#index + 1, this.#rtl ? 'right' : 'left', 'page')
}
async prev() {
const s = this.rtl ? this.#goRight() : this.#goLeft()
if (!s) return this.goToSpread(this.#index - 1, this.rtl ? 'left' : 'right', 'page')
const s = this.#rtl ? this.#goRight() : this.#goLeft()
if (!s) return this.goToSpread(this.#index - 1, this.#rtl ? 'left' : 'right', 'page')
}
getContents() {
return Array.from(this.#root.querySelectorAll('iframe'), frame => ({
Expand Down
9 changes: 5 additions & 4 deletions view.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ const fetchFile = async url => {
return new File([await res.blob()], new URL(res.url).pathname)
}

export const makeBook = async file => {
export const makeBook = async (file, options) => {
if (typeof file === 'string') file = await fetchFile(file)
let book
if (file.isDirectory) {
Expand All @@ -89,7 +89,7 @@ export const makeBook = async file => {
const loader = await makeZipLoader(file)
if (isCBZ(file)) {
const { makeComicBook } = await import('./comic-book.js')
book = makeComicBook(loader, file)
book = makeComicBook(loader, file, options?.smartSpreads)
}
else if (isFBZ(file)) {
const { makeFB2 } = await import('./fb2.js')
Expand Down Expand Up @@ -228,10 +228,11 @@ export class View extends HTMLElement {
this.renderer.goTo(resolved)
})
}
async open(book) {
async open(book, options) {
options = Object.assign({fileToBook: makeBook}, options || {})
if (typeof book === 'string'
|| typeof book.arrayBuffer === 'function'
|| book.isDirectory) book = await makeBook(book)
|| book.isDirectory) book = await options.fileToBook(book, options)
this.book = book
this.language = languageInfo(book.metadata?.language)

Expand Down