diff --git a/README.md b/README.md index a3fe2d5..6c4416e 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/comic-book.js b/comic-book.js index 88c7a35..47ae823 100644 --- a/comic-book.js +++ b/comic-book.js @@ -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 => { @@ -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 } @@ -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' } @@ -43,3 +158,4 @@ export const makeComicBook = ({ entries, loadBlob, getSize }, file) => { } return book } + diff --git a/fixed-layout.js b/fixed-layout.js index c582aba..e60c053 100644 --- a/fixed-layout.js +++ b/fixed-layout.js @@ -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 @@ -57,6 +59,8 @@ export class FixedLayout extends HTMLElement { overflow: auto; }`) + this.#oddPages = this.getAttribute('odd-pages') + this.#observer.observe(this) } attributeChangedCallback(name, _, value) { @@ -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 }) { @@ -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] @@ -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 } @@ -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 => ({ diff --git a/view.js b/view.js index 23d8357..33baf93 100644 --- a/view.js +++ b/view.js @@ -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) { @@ -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') @@ -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)