forked from johnfactotum/foliate-js
-
Notifications
You must be signed in to change notification settings - Fork 33
Expand file tree
/
Copy pathcomic-book.js
More file actions
140 lines (136 loc) · 5.42 KB
/
comic-book.js
File metadata and controls
140 lines (136 loc) · 5.42 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
// Read series metadata from a ComicInfo.xml entry, if present.
// Spec: https://anansi-project.github.io/docs/comicinfo/intro
const readComicInfoXML = async ({ entries, loadBlob }) => {
const entry = entries.find(e => e.filename.toLowerCase() === 'comicinfo.xml')
?? entries.find(e => e.filename.split('/').pop()?.toLowerCase() === 'comicinfo.xml')
if (!entry) return null
let text
try {
text = await (await loadBlob(entry.filename)).text()
} catch {
return null
}
let doc
try {
doc = new DOMParser().parseFromString(text, 'application/xml')
} catch {
return null
}
if (!doc || doc.getElementsByTagName('parsererror').length) return null
const get = name => doc.getElementsByTagName(name).item(0)?.textContent?.trim() || undefined
const getPositiveInteger = name => {
const value = Number.parseInt(get(name), 10)
return Number.isFinite(value) && value > 0 ? value : undefined
}
const getSubjects = () => [...new Set([get('Genre'), get('Tags')]
.flatMap(value => value?.split(/[,;|]/).map(x => x.trim()).filter(Boolean) ?? []))]
const getPublished = () => {
const year = getPositiveInteger('Year')
if (!year) return undefined
const month = getPositiveInteger('Month')
const day = getPositiveInteger('Day')
const yyyy = String(year).padStart(4, '0')
if (!month || month > 12) return yyyy
const yyyyMm = `${yyyy}-${String(month).padStart(2, '0')}`
if (!day || day > 31) return yyyyMm
return `${yyyyMm}-${String(day).padStart(2, '0')}`
}
const subjects = getSubjects()
return {
title: get('Title'),
publisher: get('Publisher'),
language: get('LanguageISO'),
author: get('Writer'),
published: getPublished(),
description: get('Summary'),
subject: subjects.length ? subjects : undefined,
identifier: get('Web'),
series: get('Series'),
seriesPosition: get('Number'),
seriesTotal: get('Count'),
}
}
const readComicBookInfo = async ({ getComment }) => {
let info
try {
info = JSON.parse(await getComment() || '')['ComicBookInfo/1.0']
} catch {
return null
}
if (!info) return null
const year = info.publicationYear
const month = info.publicationMonth
const mm = month && month >= 1 && month <= 12 ? String(month).padStart(2, '0') : null
return {
title: info.title,
publisher: info.publisher,
language: info.language || info.lang,
author: info.credits ? info.credits.map(c => `${c.person} (${c.role})`).join(', ') : '',
published: year && month ? `${year}-${mm}` : undefined,
series: info.series,
seriesPosition: info.issue == null ? undefined : String(info.issue),
}
}
export const makeComicBook = async ({ entries, loadBlob, getSize, getComment }, file) => {
const cache = new Map()
const urls = new Map()
const load = async name => {
if (cache.has(name)) return cache.get(name)
const src = URL.createObjectURL(await loadBlob(name))
const page = URL.createObjectURL(
new Blob([`<!DOCTYPE html><html><head><meta charset="utf-8"></head><body style="margin: 0"><img src="${src}"></body></html>`], { type: 'text/html' }))
urls.set(name, [src, page])
cache.set(name, page)
return page
}
const unload = name => {
urls.get(name)?.forEach?.(url => URL.revokeObjectURL(url))
urls.delete(name)
cache.delete(name)
}
const exts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg', '.jxl', '.avif']
const files = entries
.map(entry => entry.filename)
.filter(name => exts.some(ext => name.endsWith(ext)))
.sort()
if (!files.length) throw new Error('No supported image files in archive')
const book = {}
// Prefer ComicInfo.xml (Anansi standard) over ComicBookInfo (JSON in zip comment).
// Fields missing from the preferred source fall through to the secondary one.
const xml = await readComicInfoXML({ entries, loadBlob })
const cbi = await readComicBookInfo({ getComment })
const merged = { ...(cbi || {}), ...(xml || {}) }
book.metadata = {
title: merged.title || file.name,
publisher: merged.publisher,
language: merged.language,
author: merged.author,
published: merged.published,
description: merged.description,
subject: merged.subject,
identifier: merged.identifier,
}
if (merged.series) {
const series = { name: merged.series }
if (merged.seriesPosition) series.position = merged.seriesPosition
if (merged.seriesTotal) series.total = merged.seriesTotal
book.metadata.belongsTo = { series }
}
book.getCover = () => loadBlob(files[0])
book.sections = files.map(name => ({
id: name,
load: () => load(name),
unload: () => unload(name),
size: getSize(name),
}))
book.toc = files.map(name => ({ label: name, href: name }))
book.rendition = { layout: 'pre-paginated' }
book.resolveHref = href => ({ index: book.sections.findIndex(s => s.id === href) })
book.splitTOCHref = href => [href, null]
book.getTOCFragment = doc => doc.documentElement
book.destroy = () => {
for (const arr of urls.values())
for (const url of arr) URL.revokeObjectURL(url)
}
return book
}