generated from dellevin/template
新增阅读器功能,待优化
This commit is contained in:
347
assets/foliate-js/src/fb2.js
Normal file
347
assets/foliate-js/src/fb2.js
Normal file
@@ -0,0 +1,347 @@
|
||||
const normalizeWhitespace = str => str ? str
|
||||
.replace(/[\t\n\f\r ]+/g, ' ')
|
||||
.replace(/^[\t\n\f\r ]+/, '')
|
||||
.replace(/[\t\n\f\r ]+$/, '') : ''
|
||||
const getElementText = el => normalizeWhitespace(el?.textContent)
|
||||
|
||||
const NS = {
|
||||
XLINK: 'http://www.w3.org/1999/xlink',
|
||||
EPUB: 'http://www.idpf.org/2007/ops',
|
||||
}
|
||||
|
||||
const MIME = {
|
||||
XML: 'application/xml',
|
||||
XHTML: 'application/xhtml+xml',
|
||||
}
|
||||
|
||||
const STYLE = {
|
||||
'strong': ['strong', 'self'],
|
||||
'emphasis': ['em', 'self'],
|
||||
'style': ['span', 'self'],
|
||||
'a': 'anchor',
|
||||
'strikethrough': ['s', 'self'],
|
||||
'sub': ['sub', 'self'],
|
||||
'sup': ['sup', 'self'],
|
||||
'code': ['code', 'self'],
|
||||
'image': 'image',
|
||||
}
|
||||
|
||||
const TABLE = {
|
||||
'tr': ['tr', ['align']],
|
||||
'th': ['th', ['colspan', 'rowspan', 'align', 'valign']],
|
||||
'td': ['td', ['colspan', 'rowspan', 'align', 'valign']],
|
||||
}
|
||||
|
||||
const POEM = {
|
||||
'epigraph': ['blockquote'],
|
||||
'subtitle': ['h2', STYLE],
|
||||
'text-author': ['p', STYLE],
|
||||
'date': ['p', STYLE],
|
||||
'stanza': 'stanza',
|
||||
}
|
||||
|
||||
const SECTION = {
|
||||
'title': ['header', {
|
||||
'p': ['h1', STYLE],
|
||||
'empty-line': ['br'],
|
||||
}],
|
||||
'epigraph': ['blockquote', 'self'],
|
||||
'image': 'image',
|
||||
'annotation': ['aside'],
|
||||
'section': ['section', 'self'],
|
||||
'p': ['p', STYLE],
|
||||
'poem': ['blockquote', POEM],
|
||||
'subtitle': ['h2', STYLE],
|
||||
'cite': ['blockquote', 'self'],
|
||||
'empty-line': ['br'],
|
||||
'table': ['table', TABLE],
|
||||
'text-author': ['p', STYLE],
|
||||
}
|
||||
POEM['epigraph'].push(SECTION)
|
||||
|
||||
const BODY = {
|
||||
'image': 'image',
|
||||
'title': ['section', {
|
||||
'p': ['h1', STYLE],
|
||||
'empty-line': ['br'],
|
||||
}],
|
||||
'epigraph': ['section', SECTION],
|
||||
'section': ['section', SECTION],
|
||||
}
|
||||
|
||||
const getImageSrc = el => {
|
||||
const href = el.getAttributeNS(NS.XLINK, 'href')
|
||||
const [, id] = href.split('#')
|
||||
const bin = el.getRootNode().getElementById(id)
|
||||
return bin
|
||||
? `data:${bin.getAttribute('content-type')};base64,${bin.textContent}`
|
||||
: href
|
||||
}
|
||||
|
||||
class FB2Converter {
|
||||
constructor(fb2) {
|
||||
this.fb2 = fb2
|
||||
this.doc = document.implementation.createDocument(NS.XHTML, 'html')
|
||||
}
|
||||
image(node) {
|
||||
const el = this.doc.createElement('img')
|
||||
el.alt = node.getAttribute('alt')
|
||||
el.title = node.getAttribute('title')
|
||||
el.setAttribute('src', getImageSrc(node))
|
||||
return el
|
||||
}
|
||||
anchor(node) {
|
||||
const el = this.convert(node, { 'a': ['a', STYLE] })
|
||||
el.setAttribute('href', node.getAttributeNS(NS.XLINK, 'href'))
|
||||
if (node.getAttribute('type') === 'note')
|
||||
el.setAttributeNS(NS.EPUB, 'epub:type', 'noteref')
|
||||
return el
|
||||
}
|
||||
stanza(node) {
|
||||
const el = this.convert(node, {
|
||||
'stanza': ['p', {
|
||||
'title': ['header', {
|
||||
'p': ['strong', STYLE],
|
||||
'empty-line': ['br'],
|
||||
}],
|
||||
'subtitle': ['p', STYLE],
|
||||
}],
|
||||
})
|
||||
for (const child of node.children) if (child.nodeName === 'v') {
|
||||
el.append(this.doc.createTextNode(child.textContent))
|
||||
el.append(this.doc.createElement('br'))
|
||||
}
|
||||
return el
|
||||
}
|
||||
convert(node, def) {
|
||||
// not an element; return text content
|
||||
if (node.nodeType === 3) return this.doc.createTextNode(node.textContent)
|
||||
if (node.nodeType === 4) return this.doc.createCDATASection(node.textContent)
|
||||
if (node.nodeType === 8) return this.doc.createComment(node.textContent)
|
||||
|
||||
const d = def?.[node.nodeName]
|
||||
if (!d) return null
|
||||
if (typeof d === 'string') return this[d](node)
|
||||
|
||||
const [name, opts] = d
|
||||
const el = this.doc.createElement(name)
|
||||
|
||||
// copy the ID, and set class name from original element name
|
||||
if (node.id) el.id = node.id
|
||||
el.classList.add(node.nodeName)
|
||||
|
||||
// copy attributes
|
||||
if (Array.isArray(opts)) for (const attr of opts)
|
||||
el.setAttribute(attr, node.getAttribute(attr))
|
||||
|
||||
// process child elements recursively
|
||||
const childDef = opts === 'self' ? def : Array.isArray(opts) ? null : opts
|
||||
let child = node.firstChild
|
||||
while (child) {
|
||||
const childEl = this.convert(child, childDef)
|
||||
if (childEl) el.append(childEl)
|
||||
child = child.nextSibling
|
||||
}
|
||||
return el
|
||||
}
|
||||
}
|
||||
|
||||
const parseXML = async blob => {
|
||||
const buffer = await blob.arrayBuffer()
|
||||
const str = new TextDecoder('utf-8').decode(buffer)
|
||||
const parser = new DOMParser()
|
||||
const doc = parser.parseFromString(str, MIME.XML)
|
||||
const encoding = doc.xmlEncoding
|
||||
// `Document.xmlEncoding` is deprecated, and already removed in Firefox
|
||||
// so parse the XML declaration manually
|
||||
|| str.match(/^<\?xml\s+version\s*=\s*["']1.\d+"\s+encoding\s*=\s*["']([A-Za-z0-9._-]*)["']/)?.[1]
|
||||
if (encoding && encoding.toLowerCase() !== 'utf-8') {
|
||||
const str = new TextDecoder(encoding).decode(buffer)
|
||||
return parser.parseFromString(str, MIME.XML)
|
||||
}
|
||||
return doc
|
||||
}
|
||||
|
||||
const style = URL.createObjectURL(new Blob([`
|
||||
@namespace epub "http://www.idpf.org/2007/ops";
|
||||
body > img, section > img {
|
||||
display: block;
|
||||
margin: auto;
|
||||
}
|
||||
.title h1 {
|
||||
text-align: center;
|
||||
}
|
||||
body > section > .title, body.notesBodyType > .title {
|
||||
margin: 3em 0;
|
||||
}
|
||||
body.notesBodyType > section .title h1 {
|
||||
text-align: start;
|
||||
}
|
||||
body.notesBodyType > section .title {
|
||||
margin: 1em 0;
|
||||
}
|
||||
p {
|
||||
text-indent: 1em;
|
||||
margin: 0;
|
||||
}
|
||||
:not(p) + p, p:first-child {
|
||||
text-indent: 0;
|
||||
}
|
||||
.poem p {
|
||||
text-indent: 0;
|
||||
margin: 1em 0;
|
||||
}
|
||||
.text-author, .date {
|
||||
text-align: end;
|
||||
}
|
||||
.text-author:before {
|
||||
content: "—";
|
||||
}
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
td, th {
|
||||
padding: .25em;
|
||||
}
|
||||
a[epub|type~="noteref"] {
|
||||
font-size: .75em;
|
||||
vertical-align: super;
|
||||
}
|
||||
body:not(.notesBodyType) > .title, body:not(.notesBodyType) > .epigraph {
|
||||
margin: 3em 0;
|
||||
}
|
||||
`], { type: 'text/css' }))
|
||||
|
||||
const template = html => `<?xml version="1.0" encoding="utf-8"?>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head><link href="${style}" rel="stylesheet" type="text/css"/></head>
|
||||
<body>${html}</body>
|
||||
</html>`
|
||||
|
||||
// name of custom ID attribute for TOC items
|
||||
const dataID = 'data-foliate-id'
|
||||
|
||||
export const makeFB2 = async blob => {
|
||||
const book = {}
|
||||
const doc = await parseXML(blob)
|
||||
const converter = new FB2Converter(doc)
|
||||
|
||||
const $ = x => doc.querySelector(x)
|
||||
const $$ = x => [...doc.querySelectorAll(x)]
|
||||
const getPerson = el => {
|
||||
const nick = getElementText(el.querySelector('nickname'))
|
||||
if (nick) return nick
|
||||
const first = getElementText(el.querySelector('first-name'))
|
||||
const middle = getElementText(el.querySelector('middle-name'))
|
||||
const last = getElementText(el.querySelector('last-name'))
|
||||
const name = [first, middle, last].filter(x => x).join(' ')
|
||||
const sortAs = last
|
||||
? [last, [first, middle].filter(x => x).join(' ')].join(', ')
|
||||
: null
|
||||
return { name, sortAs }
|
||||
}
|
||||
const getDate = el => el?.getAttribute('value') ?? getElementText(el)
|
||||
const annotation = $('title-info annotation')
|
||||
book.metadata = {
|
||||
title: getElementText($('title-info book-title')),
|
||||
identifier: getElementText($('document-info id')),
|
||||
language: getElementText($('title-info lang')),
|
||||
author: $$('title-info author').map(getPerson),
|
||||
translator: $$('title-info translator').map(getPerson),
|
||||
producer: $$('document-info author').map(getPerson)
|
||||
.concat($$('document-info program-used').map(getElementText)),
|
||||
publisher: getElementText($('publish-info publisher')),
|
||||
published: getDate($('title-info date')),
|
||||
modified: getDate($('document-info date')),
|
||||
description: annotation ? converter.convert(annotation,
|
||||
{ annotation: ['div', SECTION] }).innerHTML : null,
|
||||
subject: $$('title-info genre').map(getElementText),
|
||||
}
|
||||
if ($('coverpage image')) {
|
||||
const src = getImageSrc($('coverpage image'))
|
||||
book.getCover = () => fetch(src).then(res => res.blob())
|
||||
} else book.getCover = () => null
|
||||
|
||||
// get convert each body
|
||||
const bodyData = Array.from(doc.querySelectorAll('body'), body => {
|
||||
const converted = converter.convert(body, { body: ['body', BODY] })
|
||||
return [Array.from(converted.children, el => {
|
||||
// get list of IDs in the section
|
||||
const ids = [el, ...el.querySelectorAll('[id]')].map(el => el.id)
|
||||
return { el, ids }
|
||||
}), converted]
|
||||
})
|
||||
|
||||
const urls = []
|
||||
const sectionData = bodyData[0][0]
|
||||
// make a separate section for each section in the first body
|
||||
.map(({ el, ids }) => {
|
||||
// set up titles for TOC
|
||||
const titles = Array.from(
|
||||
el.querySelectorAll(':scope > section > .title'),
|
||||
(el, index) => {
|
||||
el.setAttribute(dataID, index)
|
||||
return { title: getElementText(el), index }
|
||||
})
|
||||
return { ids, titles, el }
|
||||
})
|
||||
// for additional bodies, only make one section for each body
|
||||
.concat(bodyData.slice(1).map(([sections, body]) => {
|
||||
const ids = sections.map(s => s.ids).flat()
|
||||
body.classList.add('notesBodyType')
|
||||
return { ids, el: body, linear: 'no' }
|
||||
}))
|
||||
.map(({ ids, titles, el, linear }) => {
|
||||
const str = template(el.outerHTML)
|
||||
const blob = new Blob([str], { type: MIME.XHTML })
|
||||
const url = URL.createObjectURL(blob)
|
||||
urls.push(url)
|
||||
const title = normalizeWhitespace(
|
||||
el.querySelector('.title, .subtitle, p')?.textContent
|
||||
?? (el.classList.contains('title') ? el.textContent : ''))
|
||||
return {
|
||||
ids, title, titles, load: () => url,
|
||||
createDocument: () => new DOMParser().parseFromString(str, MIME.XHTML),
|
||||
// doo't count image data as it'd skew the size too much
|
||||
size: blob.size - Array.from(el.querySelectorAll('[src]'),
|
||||
el => el.getAttribute('src')?.length ?? 0)
|
||||
.reduce((a, b) => a + b, 0),
|
||||
linear,
|
||||
}
|
||||
})
|
||||
|
||||
const idMap = new Map()
|
||||
book.sections = sectionData.map((section, index) => {
|
||||
const { ids, load, createDocument, size, linear } = section
|
||||
for (const id of ids) if (id) idMap.set(id, index)
|
||||
return { id: index, load, createDocument, size, linear }
|
||||
})
|
||||
|
||||
book.toc = sectionData.map(({ title, titles }, index) => {
|
||||
const id = index.toString()
|
||||
return {
|
||||
label: title,
|
||||
href: id,
|
||||
subitems: titles?.length ? titles.map(({ title, index }) => ({
|
||||
label: title,
|
||||
href: `${id}#${index}`,
|
||||
})) : null,
|
||||
}
|
||||
}).filter(item => item)
|
||||
|
||||
book.resolveHref = href => {
|
||||
const [a, b] = href.split('#')
|
||||
return a
|
||||
// the link is from the TOC
|
||||
? { index: Number(a), anchor: doc => doc.querySelector(`[${dataID}="${b}"]`) }
|
||||
// link from within the page
|
||||
: { index: idMap.get(b), anchor: doc => doc.getElementById(b) }
|
||||
}
|
||||
book.splitTOCHref = href => href?.split('#')?.map(x => Number(x)) ?? []
|
||||
book.getTOCFragment = (doc, id) => doc.querySelector(`[${dataID}="${id}"]`)
|
||||
|
||||
book.destroy = () => {
|
||||
for (const url of urls) URL.revokeObjectURL(url)
|
||||
}
|
||||
return book
|
||||
}
|
||||
Reference in New Issue
Block a user