mirror of
https://github.com/john-okeefe/foliate-js.git
synced 2026-09-09 11:29:14 -04:00
EPUB: redo metadata parsing
- Support the `prefix` attribute (for the `property` attr on `<meta>`) - Localized titles, names (including `sortAs`) - `belongsTo`, including `calibre:series` - `altIdentifier`, converted to URN where possible - Remove `producer`. It's now `contributor` with `role: 'bkp'`. NOTE: this is a breaking change if you assumed that `title` will always be a string, `author` will always be an array, etc.
This commit is contained in:
@@ -94,7 +94,7 @@ Processors for each book format return an object that implements the following i
|
|||||||
- `.href`: a string representing the destination of the item. Does not have to be a valid URL.
|
- `.href`: a string representing the destination of the item. Does not have to be a valid URL.
|
||||||
- `.subitems`: a array that contains TOC items
|
- `.subitems`: a array that contains TOC items
|
||||||
- `.pageList`: same as the TOC, but for the [page list](https://www.w3.org/publishing/epub32/epub-packages.html#sec-nav-pagelist).
|
- `.pageList`: same as the TOC, but for the [page list](https://www.w3.org/publishing/epub32/epub-packages.html#sec-nav-pagelist).
|
||||||
- `.metadata`: an object representing the metadata of the book. Currently, it follows more or less the metadata schema of [Readium's webpub manifest](https://github.com/readium/webpub-manifest).
|
- `.metadata`: an object representing the metadata of the book. Currently, it follows more or less the metadata schema of [Readium's webpub manifest](https://github.com/readium/webpub-manifest). Note that titles and names can be a string or an object like `{ ja: "草枕", en: 'Kusamakura' }`, and authors, etc. can be a string, an object, or an array of strings or objects.
|
||||||
- `.rendition`: an object that contains properties that correspond to the [rendition properties](https://www.w3.org/publishing/epub32/epub-packages.html#sec-package-metadata-rendering) in EPUB. If `.layout` is `"pre-paginated"`, the book is rendered with the fixed layout renderer.
|
- `.rendition`: an object that contains properties that correspond to the [rendition properties](https://www.w3.org/publishing/epub32/epub-packages.html#sec-package-metadata-rendering) in EPUB. If `.layout` is `"pre-paginated"`, the book is rendered with the fixed layout renderer.
|
||||||
- `.resolveHref(href)`: given an href string, returns an object representing the destination referenced by the href, which has the following properties:
|
- `.resolveHref(href)`: given an href string, returns an object representing the destination referenced by the href, which has the following properties:
|
||||||
- `.index`: the index of the referenced section in the `.section` array
|
- `.index`: the index of the referenced section in the `.section` array
|
||||||
|
|||||||
@@ -23,6 +23,39 @@ const MIME = {
|
|||||||
JS: /\/(x-)?(javascript|ecmascript)/,
|
JS: /\/(x-)?(javascript|ecmascript)/,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// https://www.w3.org/TR/epub-33/#sec-reserved-prefixes
|
||||||
|
const PREFIX = {
|
||||||
|
a11y: 'http://www.idpf.org/epub/vocab/package/a11y/#',
|
||||||
|
dcterms: 'http://purl.org/dc/terms/',
|
||||||
|
marc: 'http://id.loc.gov/vocabulary/',
|
||||||
|
media: 'http://www.idpf.org/epub/vocab/overlays/#',
|
||||||
|
onix: 'http://www.editeur.org/ONIX/book/codelists/current.html#',
|
||||||
|
rendition: 'http://www.idpf.org/vocab/rendition/#',
|
||||||
|
schema: 'http://schema.org/',
|
||||||
|
xsd: 'http://www.w3.org/2001/XMLSchema#',
|
||||||
|
msv: 'http://www.idpf.org/epub/vocab/structure/magazine/#',
|
||||||
|
prism: 'http://www.prismstandard.org/specifications/3.0/PRISM_CV_Spec_3.0.htm#',
|
||||||
|
}
|
||||||
|
|
||||||
|
const RELATORS = {
|
||||||
|
art: 'artist',
|
||||||
|
aut: 'author',
|
||||||
|
clr: 'colorist',
|
||||||
|
edt: 'editor',
|
||||||
|
ill: 'illustrator',
|
||||||
|
nrt: 'narrator',
|
||||||
|
trl: 'translator',
|
||||||
|
pbl: 'publisher',
|
||||||
|
}
|
||||||
|
|
||||||
|
const ONIX5 = {
|
||||||
|
'02': 'isbn',
|
||||||
|
'06': 'doi',
|
||||||
|
'15': 'isbn',
|
||||||
|
'26': 'doi',
|
||||||
|
'34': 'issn',
|
||||||
|
}
|
||||||
|
|
||||||
// convert to camel case
|
// convert to camel case
|
||||||
const camel = x => x.toLowerCase().replace(/[-:](.)/g, (_, g) => g.toUpperCase())
|
const camel = x => x.toLowerCase().replace(/[-:](.)/g, (_, g) => g.toUpperCase())
|
||||||
|
|
||||||
@@ -98,111 +131,183 @@ const replaceSeries = async (str, regex, f) => {
|
|||||||
|
|
||||||
const regexEscape = str => str.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&')
|
const regexEscape = str => str.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&')
|
||||||
|
|
||||||
const LANGS = { attrs: ['dir', 'xml:lang'] }
|
const tidy = obj => {
|
||||||
const ALTS = { name: 'alternate-script', many: true, ...LANGS, props: ['file-as'] }
|
for (const [key, val] of Object.entries(obj))
|
||||||
const CONTRIB = {
|
if (val == null) delete obj[key]
|
||||||
many: true, ...LANGS,
|
else if (Array.isArray(val)) {
|
||||||
props: [{ name: 'role', many: true, attrs: ['scheme'] }, 'file-as', ALTS],
|
obj[key] = val.filter(x => x).map(x =>
|
||||||
setLegacyAttrs: (obj, el) => {
|
typeof x === 'object' && !Array.isArray(x) ? tidy(x) : x)
|
||||||
if (!obj.role?.length) {
|
if (!obj[key].length) delete obj[key]
|
||||||
const value = el.getAttributeNS(NS.OPF, 'role')
|
else if (obj[key].length === 1) obj[key] = val[0]
|
||||||
if (value) obj.role = [{ value }]
|
|
||||||
}
|
}
|
||||||
obj.fileAs ??= el.getAttributeNS(NS.OPF, 'file-as')
|
else if (typeof val === 'object') {
|
||||||
},
|
obj[key] = tidy(val)
|
||||||
|
if (!Object.keys(val).length) delete obj[key]
|
||||||
}
|
}
|
||||||
const METADATA = [
|
const keys = Object.keys(obj)
|
||||||
{
|
if (keys.length === 1) return obj[keys[0]]
|
||||||
name: 'title', many: true, ...LANGS,
|
return obj
|
||||||
props: ['title-type', 'display-seq', 'file-as', ALTS],
|
}
|
||||||
},
|
|
||||||
{
|
// https://www.w3.org/TR/epub/#sec-prefix-attr
|
||||||
name: 'identifier', many: true,
|
const getPrefixes = doc => {
|
||||||
props: [{ name: 'identifier-type', attrs: ['scheme'] }],
|
const map = new Map(Object.entries(PREFIX))
|
||||||
setLegacyAttrs: (obj, el) => {
|
const value = doc.documentElement.getAttributeNS(NS.EPUB, 'prefix')
|
||||||
if (!obj.identifierType) {
|
|| doc.documentElement.getAttribute('prefix')
|
||||||
const value = el.getAttributeNS(NS.OPF, 'scheme')
|
if (value) for (const [, prefix, url] of value
|
||||||
if (value) obj.identifierType = { value }
|
.matchAll(/(.+): +(.+)[ \t\r\n]*/g)) map.set(prefix, url)
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://www.w3.org/TR/epub-rs/#sec-property-values
|
||||||
|
// but ignoring the case where the prefix is omitted
|
||||||
|
const getPropertyURL = (value, prefixes) => {
|
||||||
|
if (!value) return null
|
||||||
|
const [a, b] = value.split(':')
|
||||||
|
const prefix = b ? a : null
|
||||||
|
const reference = b ? b : a
|
||||||
|
const baseURL = prefixes.get(prefix)
|
||||||
|
return baseURL ? baseURL + reference : null
|
||||||
}
|
}
|
||||||
},
|
|
||||||
},
|
|
||||||
{ name: 'language', many: true },
|
|
||||||
{ name: 'creator', ...CONTRIB },
|
|
||||||
{ name: 'contributor', ...CONTRIB },
|
|
||||||
{ name: 'publisher', ...LANGS, props: ['file-as', ALTS] },
|
|
||||||
{ name: 'description', ...LANGS, props: [ALTS] },
|
|
||||||
{ name: 'rights', ...LANGS, props: [ALTS] },
|
|
||||||
{ name: 'date' },
|
|
||||||
{ name: 'dcterms:modified', type: 'meta' },
|
|
||||||
{
|
|
||||||
name: 'subject', many: true, ...LANGS, props: ['term', 'authority', ALTS],
|
|
||||||
setLegacyAttrs: (obj, el) => {
|
|
||||||
obj.term ??= el.getAttributeNS(NS.OPF, 'term')
|
|
||||||
obj.authority ??= el.getAttributeNS(NS.OPF, 'authority')
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ name: 'source', many: true },
|
|
||||||
{
|
|
||||||
name: 'belongs-to-collection', type: 'meta', many: true, ...LANGS,
|
|
||||||
props: [
|
|
||||||
'collection-type', 'group-position', 'dcterms:identifier', 'file-as',
|
|
||||||
ALTS, { name: 'belongs-to-collection', recursive: true },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const getMetadata = opf => {
|
const getMetadata = opf => {
|
||||||
const { $, $$ } = childGetter(opf, NS.OPF)
|
const { $ } = childGetter(opf, NS.OPF)
|
||||||
const $metadata = $(opf.documentElement, 'metadata')
|
const $metadata = $(opf.documentElement, 'metadata')
|
||||||
const els = Array.from($metadata.children)
|
|
||||||
const getValue = (obj, el) => {
|
// first pass: convert to JS objects
|
||||||
if (!el) return null
|
const els = Object.groupBy($metadata.children, el =>
|
||||||
const { props = [], attrs = [] } = obj
|
el.namespaceURI === NS.DC ? 'dc'
|
||||||
const value = getElementText(el)
|
: el.namespaceURI === NS.OPF && el.localName === 'meta' ?
|
||||||
if (!props.length && !attrs.length) return value
|
(el.hasAttribute('name') ? 'legacyMeta' : 'meta') : '')
|
||||||
const id = el.getAttribute('id')
|
const baseLang = $metadata.getAttribute('xml:lang')
|
||||||
const refines = id ? els.filter(filterAttribute('refines', '#' + id)) : []
|
?? opf.documentElement.getAttribute('xml:lang') ?? 'und'
|
||||||
const result = Object.fromEntries([['value', value]]
|
const prefixes = getPrefixes(opf)
|
||||||
.concat(props.map(prop => {
|
const parse = el => {
|
||||||
const { many, recursive } = prop
|
const property = el.getAttribute('property')
|
||||||
const name = typeof prop === 'string' ? prop : prop.name
|
const scheme = el.getAttribute('scheme')
|
||||||
const filter = filterAttribute('property', name)
|
return {
|
||||||
const subobj = recursive ? obj : prop
|
property: getPropertyURL(property, prefixes) ?? property,
|
||||||
return [camel(name), many
|
scheme: getPropertyURL(scheme, prefixes) ?? scheme,
|
||||||
? refines.filter(filter).map(el => getValue(subobj, el))
|
lang: el.getAttribute('xml:lang'),
|
||||||
: getValue(subobj, refines.find(filter))]
|
value: getElementText(el),
|
||||||
}))
|
props: getProperties(el),
|
||||||
.concat(attrs.map(attr => [camel(attr), el.getAttribute(attr)])))
|
// `opf:` attributes from EPUB 2 & EPUB 3.1 (removed in EPUB 3.2)
|
||||||
obj.setLegacyAttrs?.(result, el)
|
attrs: Object.fromEntries(Array.from(el.attributes)
|
||||||
return result
|
.filter(attr => attr.namespaceURI === NS.OPF)
|
||||||
|
.map(attr => [attr.localName, attr.value])),
|
||||||
}
|
}
|
||||||
const arr = els.filter(filterAttribute('refines', null))
|
|
||||||
const metadata = Object.fromEntries(METADATA.map(obj => {
|
|
||||||
const { type, name, many } = obj
|
|
||||||
const filter = type === 'meta'
|
|
||||||
? el => el.namespaceURI === NS.OPF && el.getAttribute('property') === name
|
|
||||||
: el => el.namespaceURI === NS.DC && el.localName === name
|
|
||||||
return [camel(name), many ? arr.filter(filter).map(el => getValue(obj, el))
|
|
||||||
: getValue(obj, arr.find(filter))]
|
|
||||||
}))
|
|
||||||
|
|
||||||
const $$meta = $$($metadata, 'meta')
|
|
||||||
const getMetasByPrefix = prefix => $$meta
|
|
||||||
.filter(filterAttribute('property', x => x?.startsWith(prefix)))
|
|
||||||
.map(el => [el.getAttribute('property').replace(prefix, ''), el])
|
|
||||||
|
|
||||||
const rendition = Object.fromEntries(getMetasByPrefix('rendition:')
|
|
||||||
.map(([k, el]) => [k, getElementText(el)]))
|
|
||||||
|
|
||||||
const media = { narrator: [], duration: {} }
|
|
||||||
for (const [k, el] of getMetasByPrefix('media:')) {
|
|
||||||
const v = getElementText(el)
|
|
||||||
if (k === 'duration') media.duration[
|
|
||||||
el.getAttribute('refines')?.split('#')?.[1] ?? ''] = parseClock(v)
|
|
||||||
else if (k === 'active-class') media.activeClass = v
|
|
||||||
else if (k === 'narrator') media.narrator.push(v)
|
|
||||||
else if (k === 'playback-active-class') media.playbackActiveClass = v
|
|
||||||
}
|
}
|
||||||
|
const refines = Map.groupBy(els.meta ?? [], el => el.getAttribute('refines'))
|
||||||
|
const getProperties = el => {
|
||||||
|
const els = refines.get(el ? '#' + el.getAttribute('id') : null)
|
||||||
|
if (!els) return null
|
||||||
|
return Object.groupBy(els.map(parse), x => x.property)
|
||||||
|
}
|
||||||
|
const dc = Object.fromEntries(Object.entries(Object.groupBy(els.dc, el => el.localName))
|
||||||
|
.map(([name, els]) => [name, els.map(parse)]))
|
||||||
|
const properties = getProperties() ?? {}
|
||||||
|
const legacyMeta = Object.fromEntries(els.legacyMeta?.map(el =>
|
||||||
|
[el.getAttribute('name'), el.getAttribute('content')]) ?? [])
|
||||||
|
|
||||||
|
// second pass: map to webpub
|
||||||
|
const one = x => x?.[0]?.value
|
||||||
|
const prop = (x, p) => one(x?.props?.[p])
|
||||||
|
const makeLanguageMap = x => {
|
||||||
|
if (!x) return null
|
||||||
|
const alts = x.props?.['alternate-script'] ?? []
|
||||||
|
const altRep = x.attrs['alt-rep']
|
||||||
|
if (!alts.length && (!x.lang || x.lang === baseLang) && !altRep) return x.value
|
||||||
|
const map = { [x.lang ?? baseLang]: x.value }
|
||||||
|
if (altRep) map[x.attrs['alt-rep-lang']] = altRep
|
||||||
|
for (const y of alts) map[y.lang] ??= y.value
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
const makeContributor = x => x ? ({
|
||||||
|
name: makeLanguageMap(x),
|
||||||
|
sortAs: makeLanguageMap(x.props?.['file-as']?.[0]),
|
||||||
|
role: x.props?.role?.filter(x => x.scheme === PREFIX.marc + 'relators')
|
||||||
|
?.map(x => x.value) ?? [x.attrs.role],
|
||||||
|
code: prop(x, 'term') ?? x.attrs.term,
|
||||||
|
scheme: prop(x, 'authority') ?? x.attrs.authority,
|
||||||
|
}) : null
|
||||||
|
const makeCollection = x => ({
|
||||||
|
name: makeLanguageMap(x),
|
||||||
|
// NOTE: webpub requires number but EPUB allows values like "2.2.1"
|
||||||
|
position: one(x.props?.['group-position']),
|
||||||
|
})
|
||||||
|
const makeAltIdentifier = x => {
|
||||||
|
const { value } = x
|
||||||
|
if (/^urn:/i.test(value)) return value
|
||||||
|
if (/^doi:/i.test(value)) return `urn:${value}`
|
||||||
|
const type = x.props?.['identifier-type']
|
||||||
|
if (!type) {
|
||||||
|
const scheme = x.attrs.scheme
|
||||||
|
if (!scheme) return value
|
||||||
|
// https://idpf.github.io/epub-registries/identifiers/
|
||||||
|
// but no "jdcn", which isn't a registered URN namespace
|
||||||
|
if (/^(doi|isbn|uuid)$/i.test(scheme)) return `urn:${scheme}:${value}`
|
||||||
|
// NOTE: webpub requires scheme to be a URI; EPUB allows anything
|
||||||
|
return { scheme, value }
|
||||||
|
}
|
||||||
|
if (type.scheme === PREFIX.onix + 'codelist5') {
|
||||||
|
const nid = ONIX5[type.value]
|
||||||
|
if (nid) return `urn:${nid}:${value}`
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
const belongsTo = Object.groupBy(properties['belongs-to-collection'] ?? [],
|
||||||
|
x => prop(x, 'collection-type') === 'series' ? 'series' : 'collection')
|
||||||
|
const mainTitle = dc.title?.find(x => prop(x, 'title-type') === 'main') ?? dc.title?.[0]
|
||||||
|
const metadata = {
|
||||||
|
identifier: getIdentifier(opf),
|
||||||
|
title: makeLanguageMap(mainTitle),
|
||||||
|
sortAs: makeLanguageMap(mainTitle?.props?.['file-as']?.[0])
|
||||||
|
?? makeLanguageMap(mainTitle?.attrs?.['file-as'])
|
||||||
|
?? legacyMeta?.['calibre:title_sort'],
|
||||||
|
subtitle: dc.title?.find(x => prop(x, 'title-type') === 'subtitle')?.value,
|
||||||
|
language: dc.language?.map(x => x.value),
|
||||||
|
description: one(dc.description),
|
||||||
|
publisher: makeContributor(dc.publisher?.[0]),
|
||||||
|
published: dc.date?.find(x => x.attrs.event === 'publication')?.value
|
||||||
|
?? one(dc.date),
|
||||||
|
modified: one(properties[PREFIX.dcterms + 'modified'])
|
||||||
|
?? dc.date?.find(x => x.attrs.event === 'modification')?.value,
|
||||||
|
subject: dc.subject?.map(makeContributor),
|
||||||
|
belongsTo: {
|
||||||
|
collection: belongsTo.collection?.map(makeCollection),
|
||||||
|
series: belongsTo.series?.map(makeCollection)
|
||||||
|
?? legacyMeta?.['calibre:series'] ? {
|
||||||
|
name: legacyMeta?.['calibre:series'],
|
||||||
|
position: parseFloat(legacyMeta?.['calibre:series_index']),
|
||||||
|
} : null,
|
||||||
|
},
|
||||||
|
altIdentifier: dc.identifier?.map(makeAltIdentifier),
|
||||||
|
source: dc.source?.map(makeAltIdentifier), // NOTE: not in webpub schema
|
||||||
|
rights: one(dc.rights), // NOTE: not in webpub schema
|
||||||
|
}
|
||||||
|
const remapContributor = defaultKey => x => {
|
||||||
|
const keys = new Set(x.role?.map(role => RELATORS[role] ?? defaultKey))
|
||||||
|
return [keys.size ? keys : [defaultKey], x]
|
||||||
|
}
|
||||||
|
for (const [keys, val] of [].concat(
|
||||||
|
dc.creator?.map(makeContributor)?.map(remapContributor('author')) ?? [],
|
||||||
|
dc.contributor?.map(makeContributor)?.map(remapContributor('contributor')) ?? []))
|
||||||
|
for (const key of keys)
|
||||||
|
if (metadata[key]) metadata[key].push(val)
|
||||||
|
else metadata[key] = [val]
|
||||||
|
tidy(metadata)
|
||||||
|
if (metadata.altIdentifier === metadata.identifier)
|
||||||
|
delete metadata.altIdentifier
|
||||||
|
|
||||||
|
const rendition = {}
|
||||||
|
const media = {}
|
||||||
|
for (const [key, val] of Object.entries(properties)) {
|
||||||
|
if (key.startsWith(PREFIX.rendition))
|
||||||
|
rendition[camel(key.replace(PREFIX.rendition, ''))] = one(val)
|
||||||
|
else if (key.startsWith(PREFIX.media))
|
||||||
|
media[camel(key.replace(PREFIX.media, ''))] = one(val)
|
||||||
|
}
|
||||||
|
if (media.duration) media.duration = parseClock(media.duration)
|
||||||
return { metadata, rendition, media }
|
return { metadata, rendition, media }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -886,6 +991,7 @@ ${doc.querySelector('parsererror').innerText}`)
|
|||||||
this.landmarks ??= this.resources.guide
|
this.landmarks ??= this.resources.guide
|
||||||
|
|
||||||
const { metadata, rendition, media } = getMetadata(opf)
|
const { metadata, rendition, media } = getMetadata(opf)
|
||||||
|
this.metadata = metadata
|
||||||
this.rendition = rendition
|
this.rendition = rendition
|
||||||
this.media = media
|
this.media = media
|
||||||
this.dir = this.resources.pageProgressionDirection
|
this.dir = this.resources.pageProgressionDirection
|
||||||
@@ -899,50 +1005,6 @@ ${doc.querySelector('parsererror').innerText}`)
|
|||||||
.find(section => section.linear !== 'no').pageSpread ??=
|
.find(section => section.linear !== 'no').pageSpread ??=
|
||||||
this.dir === 'rtl' ? 'left' : 'right'
|
this.dir === 'rtl' ? 'left' : 'right'
|
||||||
}
|
}
|
||||||
|
|
||||||
this.parsedMetadata = metadata // for debugging or advanced use cases
|
|
||||||
const title = metadata?.title?.[0]
|
|
||||||
this.metadata = {
|
|
||||||
title: title?.value,
|
|
||||||
subtitle: metadata?.title?.find(x => x.titleType === 'subtitle')?.value,
|
|
||||||
sortAs: title?.fileAs,
|
|
||||||
language: metadata?.language,
|
|
||||||
identifier: getIdentifier(opf),
|
|
||||||
description: metadata?.description?.value,
|
|
||||||
publisher: metadata?.publisher?.value,
|
|
||||||
published: metadata?.date,
|
|
||||||
modified: metadata?.dctermsModified,
|
|
||||||
subject: metadata?.subject
|
|
||||||
?.filter(({ value, term }) => value || term)
|
|
||||||
?.map(({ value, term, authority }) =>
|
|
||||||
({ name: value, code: term, scheme: authority })),
|
|
||||||
rights: metadata?.rights?.value,
|
|
||||||
}
|
|
||||||
const relators = {
|
|
||||||
art: 'artist',
|
|
||||||
aut: 'author',
|
|
||||||
bkp: 'producer',
|
|
||||||
clr: 'colorist',
|
|
||||||
edt: 'editor',
|
|
||||||
ill: 'illustrator',
|
|
||||||
nrt: 'narrator',
|
|
||||||
trl: 'translator',
|
|
||||||
pbl: 'publisher',
|
|
||||||
}
|
|
||||||
const mapContributor = defaultKey => obj => {
|
|
||||||
const keys = [...new Set(obj.role?.map(({ value, scheme }) =>
|
|
||||||
(!scheme || scheme === 'marc:relators' ? relators[value] : null)
|
|
||||||
?? defaultKey))]
|
|
||||||
const value = { name: obj.value, sortAs: obj.fileAs }
|
|
||||||
return [keys?.length ? keys : [defaultKey], value]
|
|
||||||
}
|
|
||||||
metadata?.creator?.map(mapContributor('author'))
|
|
||||||
?.concat(metadata?.contributor?.map?.(mapContributor('contributor')))
|
|
||||||
?.forEach(([keys, value]) => keys.forEach(key => {
|
|
||||||
if (this.metadata[key]) this.metadata[key].push(value)
|
|
||||||
else this.metadata[key] = [value]
|
|
||||||
}))
|
|
||||||
|
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
async loadDocument(item) {
|
async loadDocument(item) {
|
||||||
|
|||||||
@@ -248,8 +248,12 @@ export const makeFB2 = async blob => {
|
|||||||
language: getElementText($('title-info lang')),
|
language: getElementText($('title-info lang')),
|
||||||
author: $$('title-info author').map(getPerson),
|
author: $$('title-info author').map(getPerson),
|
||||||
translator: $$('title-info translator').map(getPerson),
|
translator: $$('title-info translator').map(getPerson),
|
||||||
producer: $$('document-info author').map(getPerson)
|
contributor: $$('document-info author').map(getPerson)
|
||||||
.concat($$('document-info program-used').map(getElementText)),
|
// techincially the program probably shouldn't get the `bkp` role
|
||||||
|
// but it has been so used by calibre, so ¯\_(ツ)_/¯
|
||||||
|
.concat($$('document-info program-used').map(getElementText))
|
||||||
|
.map(x => Object.assign(typeof x === 'string' ? { name: x } : x,
|
||||||
|
{ role: 'bkp' })),
|
||||||
publisher: getElementText($('publish-info publisher')),
|
publisher: getElementText($('publish-info publisher')),
|
||||||
published: getDate($('title-info date')),
|
published: getDate($('title-info date')),
|
||||||
modified: getDate($('document-info date')),
|
modified: getDate($('document-info date')),
|
||||||
|
|||||||
@@ -151,6 +151,21 @@ const $ = document.querySelector.bind(document)
|
|||||||
|
|
||||||
const locales = 'en'
|
const locales = 'en'
|
||||||
const percentFormat = new Intl.NumberFormat(locales, { style: 'percent' })
|
const percentFormat = new Intl.NumberFormat(locales, { style: 'percent' })
|
||||||
|
const listFormat = new Intl.ListFormat(locales, { style: 'short', type: 'conjunction' })
|
||||||
|
|
||||||
|
const formatLanguageMap = x => {
|
||||||
|
if (!x) return ''
|
||||||
|
if (typeof x === 'string') return x
|
||||||
|
const keys = Object.keys(x)
|
||||||
|
return x[keys[0]]
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatOneContributor = contributor => typeof contributor === 'string'
|
||||||
|
? contributor : formatLanguageMap(contributor?.name)
|
||||||
|
|
||||||
|
const formatContributor = contributor => Array.isArray(contributor)
|
||||||
|
? listFormat.format(contributor.map(formatOneContributor))
|
||||||
|
: formatOneContributor(contributor)
|
||||||
|
|
||||||
class Reader {
|
class Reader {
|
||||||
#tocView
|
#tocView
|
||||||
@@ -219,15 +234,10 @@ class Reader {
|
|||||||
|
|
||||||
document.addEventListener('keydown', this.#handleKeydown.bind(this))
|
document.addEventListener('keydown', this.#handleKeydown.bind(this))
|
||||||
|
|
||||||
const title = book.metadata?.title ?? 'Untitled Book'
|
const title = formatLanguageMap(book.metadata?.title) || 'Untitled Book'
|
||||||
document.title = title
|
document.title = title
|
||||||
$('#side-bar-title').innerText = title
|
$('#side-bar-title').innerText = title
|
||||||
const author = book.metadata?.author
|
$('#side-bar-author').innerText = formatContributor(book.metadata?.author)
|
||||||
$('#side-bar-author').innerText = typeof author === 'string' ? author
|
|
||||||
: author
|
|
||||||
?.map(author => typeof author === 'string' ? author : author.name)
|
|
||||||
?.join(', ')
|
|
||||||
?? ''
|
|
||||||
Promise.resolve(book.getCover?.())?.then(blob =>
|
Promise.resolve(book.getCover?.())?.then(blob =>
|
||||||
blob ? $('#side-bar-cover').src = URL.createObjectURL(blob) : null)
|
blob ? $('#side-bar-cover').src = URL.createObjectURL(blob) : null)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user