mirror of
https://github.com/john-okeefe/foliate-js.git
synced 2026-09-09 11:29:14 -04:00
Make <foliate-view> handle opening files directly
This commit is contained in:
@@ -46,7 +46,7 @@ There are mainly three kinds of modules:
|
|||||||
|
|
||||||
The modules are designed to be modular. In general, they don't directly depend on each other. Instead they depend on certain interfaces, detailed below. The exception is `view.js`. It is the higher level renderer that strings most of the things together, and you can think of it as the main entry point of the library. See "Basic Usage" below.
|
The modules are designed to be modular. In general, they don't directly depend on each other. Instead they depend on certain interfaces, detailed below. The exception is `view.js`. It is the higher level renderer that strings most of the things together, and you can think of it as the main entry point of the library. See "Basic Usage" below.
|
||||||
|
|
||||||
The repo also includes a still higher level reader, though strictly speaking, `reader.html` (along with `reader.js` and its associated files in `ui/` and `vendor/`) is not considered part of the library itself. It's akin to [Epub.js Reader](https://github.com/futurepress/epubjs-reader). You are expected to modify it or replace it with your own code.
|
The repo also includes a still higher level reader, though strictly speaking, `reader.html` (along with `reader.js` and its associated files in `ui/`) is not considered part of the library itself. It's akin to [Epub.js Reader](https://github.com/futurepress/epubjs-reader). You are expected to modify it or replace it with your own code.
|
||||||
|
|
||||||
### Basic Usage
|
### Basic Usage
|
||||||
|
|
||||||
@@ -61,8 +61,9 @@ view.addEventListener('relocate', e => {
|
|||||||
console.log(e.detail)
|
console.log(e.detail)
|
||||||
})
|
})
|
||||||
|
|
||||||
const book = /* an object implementing the "book" interface */
|
// can open a File/Blob object or a URL
|
||||||
await view.open(book)
|
// or any object that implements the "book" interface
|
||||||
|
await view.open('example.epub')
|
||||||
await view.goTo(/* path, section index, or CFI */)
|
await view.goTo(/* path, section index, or CFI */)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -3,111 +3,6 @@ import { createTOCView } from './ui/tree.js'
|
|||||||
import { createMenu } from './ui/menu.js'
|
import { createMenu } from './ui/menu.js'
|
||||||
import { Overlayer } from './overlayer.js'
|
import { Overlayer } from './overlayer.js'
|
||||||
|
|
||||||
const isZip = async file => {
|
|
||||||
const arr = new Uint8Array(await file.slice(0, 4).arrayBuffer())
|
|
||||||
return arr[0] === 0x50 && arr[1] === 0x4b && arr[2] === 0x03 && arr[3] === 0x04
|
|
||||||
}
|
|
||||||
|
|
||||||
const isPDF = async file => {
|
|
||||||
const arr = new Uint8Array(await file.slice(0, 5).arrayBuffer())
|
|
||||||
return arr[0] === 0x25
|
|
||||||
&& arr[1] === 0x50 && arr[2] === 0x44 && arr[3] === 0x46
|
|
||||||
&& arr[4] === 0x2d
|
|
||||||
}
|
|
||||||
|
|
||||||
const makeZipLoader = async file => {
|
|
||||||
const { configure, ZipReader, BlobReader, TextWriter, BlobWriter } =
|
|
||||||
await import('./vendor/zip.js')
|
|
||||||
configure({ useWebWorkers: false })
|
|
||||||
const reader = new ZipReader(new BlobReader(file))
|
|
||||||
const entries = await reader.getEntries()
|
|
||||||
const map = new Map(entries.map(entry => [entry.filename, entry]))
|
|
||||||
const load = f => (name, ...args) =>
|
|
||||||
map.has(name) ? f(map.get(name), ...args) : null
|
|
||||||
const loadText = load(entry => entry.getData(new TextWriter()))
|
|
||||||
const loadBlob = load((entry, type) => entry.getData(new BlobWriter(type)))
|
|
||||||
const getSize = name => map.get(name)?.uncompressedSize ?? 0
|
|
||||||
return { entries, loadText, loadBlob, getSize }
|
|
||||||
}
|
|
||||||
|
|
||||||
const getFileEntries = async entry => entry.isFile ? entry
|
|
||||||
: (await Promise.all(Array.from(
|
|
||||||
await new Promise((resolve, reject) => entry.createReader()
|
|
||||||
.readEntries(entries => resolve(entries), error => reject(error))),
|
|
||||||
getFileEntries))).flat()
|
|
||||||
|
|
||||||
const makeDirectoryLoader = async entry => {
|
|
||||||
const entries = await getFileEntries(entry)
|
|
||||||
const files = await Promise.all(
|
|
||||||
entries.map(entry => new Promise((resolve, reject) =>
|
|
||||||
entry.file(file => resolve([file, entry.fullPath]),
|
|
||||||
error => reject(error)))))
|
|
||||||
const map = new Map(files.map(([file, path]) =>
|
|
||||||
[path.replace(entry.fullPath + '/', ''), file]))
|
|
||||||
const decoder = new TextDecoder()
|
|
||||||
const decode = x => x ? decoder.decode(x) : null
|
|
||||||
const getBuffer = name => map.get(name)?.arrayBuffer() ?? null
|
|
||||||
const loadText = async name => decode(await getBuffer(name))
|
|
||||||
const loadBlob = name => map.get(name)
|
|
||||||
const getSize = name => map.get(name)?.size ?? 0
|
|
||||||
return { loadText, loadBlob, getSize }
|
|
||||||
}
|
|
||||||
|
|
||||||
const isCBZ = ({ name, type }) =>
|
|
||||||
type === 'application/vnd.comicbook+zip' || name.endsWith('.cbz')
|
|
||||||
|
|
||||||
const isFB2 = ({ name, type }) =>
|
|
||||||
type === 'application/x-fictionbook+xml' || name.endsWith('.fb2')
|
|
||||||
|
|
||||||
const isFBZ = ({ name, type }) =>
|
|
||||||
type === 'application/x-zip-compressed-fb2'
|
|
||||||
|| name.endsWith('.fb2.zip') || name.endsWith('.fbz')
|
|
||||||
|
|
||||||
const getView = async file => {
|
|
||||||
let book
|
|
||||||
if (file.isDirectory) {
|
|
||||||
const loader = await makeDirectoryLoader(file)
|
|
||||||
const { EPUB } = await import('./epub.js')
|
|
||||||
book = await new EPUB(loader).init()
|
|
||||||
}
|
|
||||||
else if (!file.size) throw new Error('File not found')
|
|
||||||
else if (await isZip(file)) {
|
|
||||||
const loader = await makeZipLoader(file)
|
|
||||||
if (isCBZ(file)) {
|
|
||||||
const { makeComicBook } = await import('./comic-book.js')
|
|
||||||
book = makeComicBook(loader, file)
|
|
||||||
} else if (isFBZ(file)) {
|
|
||||||
const { makeFB2 } = await import('./fb2.js')
|
|
||||||
const { entries } = loader
|
|
||||||
const entry = entries.find(entry => entry.filename.endsWith('.fb2'))
|
|
||||||
const blob = await loader.loadBlob((entry ?? entries[0]).filename)
|
|
||||||
book = await makeFB2(blob)
|
|
||||||
} else {
|
|
||||||
const { EPUB } = await import('./epub.js')
|
|
||||||
book = await new EPUB(loader).init()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (await isPDF(file)) {
|
|
||||||
const { makePDF } = await import('./pdf.js')
|
|
||||||
book = await makePDF(file)
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
const { isMOBI, MOBI } = await import('./mobi.js')
|
|
||||||
if (await isMOBI(file)) {
|
|
||||||
const fflate = await import('./vendor/fflate.js')
|
|
||||||
book = await new MOBI({ unzlib: fflate.unzlibSync }).open(file)
|
|
||||||
} else if (isFB2(file)) {
|
|
||||||
const { makeFB2 } = await import('./fb2.js')
|
|
||||||
book = await makeFB2(file)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!book) throw new Error('File type not supported')
|
|
||||||
const view = document.createElement('foliate-view')
|
|
||||||
document.body.append(view)
|
|
||||||
await view.open(book)
|
|
||||||
return view
|
|
||||||
}
|
|
||||||
|
|
||||||
const getCSS = ({ spacing, justify, hyphenate }) => `
|
const getCSS = ({ spacing, justify, hyphenate }) => `
|
||||||
@namespace epub "http://www.idpf.org/2007/ops";
|
@namespace epub "http://www.idpf.org/2007/ops";
|
||||||
html {
|
html {
|
||||||
@@ -209,7 +104,9 @@ class Reader {
|
|||||||
menu.groups.layout.select('paginated')
|
menu.groups.layout.select('paginated')
|
||||||
}
|
}
|
||||||
async open(file) {
|
async open(file) {
|
||||||
this.view = await getView(file)
|
this.view = document.createElement('foliate-view')
|
||||||
|
document.body.append(this.view)
|
||||||
|
await this.view.open(file)
|
||||||
this.view.addEventListener('load', this.#onLoad.bind(this))
|
this.view.addEventListener('load', this.#onLoad.bind(this))
|
||||||
this.view.addEventListener('relocate', this.#onRelocate.bind(this))
|
this.view.addEventListener('relocate', this.#onRelocate.bind(this))
|
||||||
|
|
||||||
@@ -332,8 +229,5 @@ $('#file-button').addEventListener('click', () => $('#file-input').click())
|
|||||||
|
|
||||||
const params = new URLSearchParams(location.search)
|
const params = new URLSearchParams(location.search)
|
||||||
const url = params.get('url')
|
const url = params.get('url')
|
||||||
if (url) fetch(url)
|
if (url) open(url).catch(e => console.error(e))
|
||||||
.then(res => res.blob())
|
|
||||||
.then(blob => open(new File([blob], new URL(url, window.location.origin).pathname)))
|
|
||||||
.catch(e => console.error(e))
|
|
||||||
else dropTarget.style.visibility = 'visible'
|
else dropTarget.style.visibility = 'visible'
|
||||||
|
|||||||
@@ -5,6 +5,123 @@ import { textWalker } from './text-walker.js'
|
|||||||
|
|
||||||
const SEARCH_PREFIX = 'foliate-search:'
|
const SEARCH_PREFIX = 'foliate-search:'
|
||||||
|
|
||||||
|
const isZip = async file => {
|
||||||
|
const arr = new Uint8Array(await file.slice(0, 4).arrayBuffer())
|
||||||
|
return arr[0] === 0x50 && arr[1] === 0x4b && arr[2] === 0x03 && arr[3] === 0x04
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPDF = async file => {
|
||||||
|
const arr = new Uint8Array(await file.slice(0, 5).arrayBuffer())
|
||||||
|
return arr[0] === 0x25
|
||||||
|
&& arr[1] === 0x50 && arr[2] === 0x44 && arr[3] === 0x46
|
||||||
|
&& arr[4] === 0x2d
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCBZ = ({ name, type }) =>
|
||||||
|
type === 'application/vnd.comicbook+zip' || name.endsWith('.cbz')
|
||||||
|
|
||||||
|
const isFB2 = ({ name, type }) =>
|
||||||
|
type === 'application/x-fictionbook+xml' || name.endsWith('.fb2')
|
||||||
|
|
||||||
|
const isFBZ = ({ name, type }) =>
|
||||||
|
type === 'application/x-zip-compressed-fb2'
|
||||||
|
|| name.endsWith('.fb2.zip') || name.endsWith('.fbz')
|
||||||
|
|
||||||
|
const makeZipLoader = async file => {
|
||||||
|
const { configure, ZipReader, BlobReader, TextWriter, BlobWriter } =
|
||||||
|
await import('./vendor/zip.js')
|
||||||
|
configure({ useWebWorkers: false })
|
||||||
|
const reader = new ZipReader(new BlobReader(file))
|
||||||
|
const entries = await reader.getEntries()
|
||||||
|
const map = new Map(entries.map(entry => [entry.filename, entry]))
|
||||||
|
const load = f => (name, ...args) =>
|
||||||
|
map.has(name) ? f(map.get(name), ...args) : null
|
||||||
|
const loadText = load(entry => entry.getData(new TextWriter()))
|
||||||
|
const loadBlob = load((entry, type) => entry.getData(new BlobWriter(type)))
|
||||||
|
const getSize = name => map.get(name)?.uncompressedSize ?? 0
|
||||||
|
return { entries, loadText, loadBlob, getSize }
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFileEntries = async entry => entry.isFile ? entry
|
||||||
|
: (await Promise.all(Array.from(
|
||||||
|
await new Promise((resolve, reject) => entry.createReader()
|
||||||
|
.readEntries(entries => resolve(entries), error => reject(error))),
|
||||||
|
getFileEntries))).flat()
|
||||||
|
|
||||||
|
const makeDirectoryLoader = async entry => {
|
||||||
|
const entries = await getFileEntries(entry)
|
||||||
|
const files = await Promise.all(
|
||||||
|
entries.map(entry => new Promise((resolve, reject) =>
|
||||||
|
entry.file(file => resolve([file, entry.fullPath]),
|
||||||
|
error => reject(error)))))
|
||||||
|
const map = new Map(files.map(([file, path]) =>
|
||||||
|
[path.replace(entry.fullPath + '/', ''), file]))
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
const decode = x => x ? decoder.decode(x) : null
|
||||||
|
const getBuffer = name => map.get(name)?.arrayBuffer() ?? null
|
||||||
|
const loadText = async name => decode(await getBuffer(name))
|
||||||
|
const loadBlob = name => map.get(name)
|
||||||
|
const getSize = name => map.get(name)?.size ?? 0
|
||||||
|
return { loadText, loadBlob, getSize }
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ResponseError extends Error {}
|
||||||
|
export class NotFoundError extends Error {}
|
||||||
|
export class UnsupportedTypeError extends Error {}
|
||||||
|
|
||||||
|
const fetchFile = async url => {
|
||||||
|
const res = await fetch(url)
|
||||||
|
if (!res.ok) throw new ResponseError(
|
||||||
|
`${res.status} ${res.statusText}`, { cause: res })
|
||||||
|
return new File([await res.blob()], new URL(res.url).pathname)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const makeBook = async file => {
|
||||||
|
if (typeof file === 'string') file = await fetchFile(file)
|
||||||
|
let book
|
||||||
|
if (file.isDirectory) {
|
||||||
|
const loader = await makeDirectoryLoader(file)
|
||||||
|
const { EPUB } = await import('./epub.js')
|
||||||
|
book = await new EPUB(loader).init()
|
||||||
|
}
|
||||||
|
else if (!file.size) throw new NotFoundError('File not found')
|
||||||
|
else if (await isZip(file)) {
|
||||||
|
const loader = await makeZipLoader(file)
|
||||||
|
if (isCBZ(file)) {
|
||||||
|
const { makeComicBook } = await import('./comic-book.js')
|
||||||
|
book = makeComicBook(loader, file)
|
||||||
|
}
|
||||||
|
else if (isFBZ(file)) {
|
||||||
|
const { makeFB2 } = await import('./fb2.js')
|
||||||
|
const { entries } = loader
|
||||||
|
const entry = entries.find(entry => entry.filename.endsWith('.fb2'))
|
||||||
|
const blob = await loader.loadBlob((entry ?? entries[0]).filename)
|
||||||
|
book = await makeFB2(blob)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const { EPUB } = await import('./epub.js')
|
||||||
|
book = await new EPUB(loader).init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (await isPDF(file)) {
|
||||||
|
const { makePDF } = await import('./pdf.js')
|
||||||
|
book = await makePDF(file)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const { isMOBI, MOBI } = await import('./mobi.js')
|
||||||
|
if (await isMOBI(file)) {
|
||||||
|
const fflate = await import('./vendor/fflate.js')
|
||||||
|
book = await new MOBI({ unzlib: fflate.unzlibSync }).open(file)
|
||||||
|
}
|
||||||
|
else if (isFB2(file)) {
|
||||||
|
const { makeFB2 } = await import('./fb2.js')
|
||||||
|
book = await makeFB2(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!book) throw new UnsupportedTypeError('File type not supported')
|
||||||
|
return book
|
||||||
|
}
|
||||||
|
|
||||||
class CursorAutohider {
|
class CursorAutohider {
|
||||||
#timeout
|
#timeout
|
||||||
#el
|
#el
|
||||||
@@ -112,6 +229,9 @@ export class View extends HTMLElement {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
async open(book) {
|
async open(book) {
|
||||||
|
if (typeof book === 'string'
|
||||||
|
|| typeof book.arrayBuffer === 'function'
|
||||||
|
|| book.isDirectory) book = await makeBook(book)
|
||||||
this.book = book
|
this.book = book
|
||||||
this.language = languageInfo(book.metadata?.language)
|
this.language = languageInfo(book.metadata?.language)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user