bookish-press 0.8.2 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -9
- package/dist/components/chapter/Code.svelte +1 -1
- package/dist/components/editor/EmbedEditor.svelte +1 -1
- package/dist/components/page/DownloadEPUB.svelte +234 -0
- package/dist/components/page/DownloadEPUB.svelte.d.ts +3 -0
- package/dist/components/page/Rows.svelte +1 -1
- package/dist/components/page/Search.svelte +1 -1
- package/dist/components/page/TableOfContents.svelte +45 -30
- package/dist/epub/build.d.ts +39 -0
- package/dist/epub/build.js +143 -0
- package/dist/epub/build.test.d.ts +1 -0
- package/dist/epub/build.test.js +411 -0
- package/dist/epub/images.d.ts +79 -0
- package/dist/epub/images.js +260 -0
- package/dist/epub/images.test.d.ts +1 -0
- package/dist/epub/images.test.js +129 -0
- package/dist/epub/package.d.ts +75 -0
- package/dist/epub/package.js +392 -0
- package/dist/epub/stylesheet.test.d.ts +1 -0
- package/dist/epub/stylesheet.test.js +243 -0
- package/dist/epub/xhtml.d.ts +69 -0
- package/dist/epub/xhtml.js +367 -0
- package/dist/epub/xhtml.test.d.ts +1 -0
- package/dist/epub/xhtml.test.js +283 -0
- package/dist/epub/zip.d.ts +35 -0
- package/dist/epub/zip.js +202 -0
- package/dist/epub/zip.test.d.ts +1 -0
- package/dist/epub/zip.test.js +171 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/models/book/ChapterID.d.ts +1 -1
- package/dist/models/book/Edition.js +1 -0
- package/dist/models/chapter/TextNode.js +8 -5
- package/dist/models/chapter/TextNode.test.js +8 -0
- package/package.json +3 -1
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds a complete EPUB from a loaded edition, entirely in the browser.
|
|
3
|
+
*
|
|
4
|
+
* No server is involved: the whole book's text is already in memory by the time
|
|
5
|
+
* the table of contents renders, and images are fetched directly (Firebase's
|
|
6
|
+
* download URLs allow cross-origin reads, and compiled books serve theirs from
|
|
7
|
+
* the same origin).
|
|
8
|
+
*/
|
|
9
|
+
import EmbedNode from '../models/chapter/EmbedNode';
|
|
10
|
+
import Parser from '../models/chapter/Parser';
|
|
11
|
+
import Zip from './zip';
|
|
12
|
+
import { DEFAULT_SIZE, mapWithLimit, prepareImage, resolveImageURL, } from './images';
|
|
13
|
+
import { CONTAINER, CONTENT_DIRECTORY, MIMETYPE, STYLESHEET, chapterDocument, coverDocument, glossaryDocument, includedChapters, navigationDocument, ncxDocument, packageDocument, referencesDocument, safeFilename, titleDocument, } from './package';
|
|
14
|
+
import { chapterPath } from './xhtml';
|
|
15
|
+
/** How many images to fetch at once. */
|
|
16
|
+
const IMAGE_CONCURRENCY = 4;
|
|
17
|
+
/**
|
|
18
|
+
* A stable, unique identifier for this edition. EPUB requires one, and readers
|
|
19
|
+
* use it to recognize the same book across downloads.
|
|
20
|
+
*/
|
|
21
|
+
export function identifierFor(edition) {
|
|
22
|
+
const book = edition.getBookRef()?.id;
|
|
23
|
+
const version = edition.getEditionRef()?.id ?? edition.getEditionNumber();
|
|
24
|
+
return book === undefined
|
|
25
|
+
? `urn:bookish:${safeFilename(edition.getTitle())}:${version}`
|
|
26
|
+
: `urn:bookish:${book}:${version}`;
|
|
27
|
+
}
|
|
28
|
+
/** Build an EPUB for the given edition. */
|
|
29
|
+
export async function buildEPUB(edition, options = {}) {
|
|
30
|
+
const { base = '', size = DEFAULT_SIZE, onProgress, modified = new Date(), } = options;
|
|
31
|
+
const warnings = [];
|
|
32
|
+
const chapters = includedChapters(edition);
|
|
33
|
+
// Collect every distinct image the book uses. getEmbeds walks the cover,
|
|
34
|
+
// each chapter's cover, chapter bodies, and the back matter covers.
|
|
35
|
+
const coverEmbed = parseCover(edition);
|
|
36
|
+
const embeds = new Map();
|
|
37
|
+
if (coverEmbed)
|
|
38
|
+
embeds.set(coverEmbed.getURL(), coverEmbed);
|
|
39
|
+
for (const { embed } of edition.getEmbeds())
|
|
40
|
+
// Videos are links in the package, not files, and an embed with no URL
|
|
41
|
+
// is an empty placeholder the author hasn't filled in yet.
|
|
42
|
+
if (!embed.isVideo() && embed.getURL().trim().length > 0)
|
|
43
|
+
if (!embeds.has(embed.getURL()))
|
|
44
|
+
embeds.set(embed.getURL(), embed);
|
|
45
|
+
const urls = [...embeds.keys()];
|
|
46
|
+
onProgress?.({ phase: 'images', done: 0, total: urls.length });
|
|
47
|
+
let fetched = 0;
|
|
48
|
+
const prepared = await mapWithLimit(urls, IMAGE_CONCURRENCY, async (url) => {
|
|
49
|
+
const image = await prepareImage(resolveImageURL(url, base), size);
|
|
50
|
+
onProgress?.({
|
|
51
|
+
phase: 'images',
|
|
52
|
+
done: ++fetched,
|
|
53
|
+
total: urls.length,
|
|
54
|
+
});
|
|
55
|
+
return { url, image };
|
|
56
|
+
});
|
|
57
|
+
// Map each embed URL to its path inside the package.
|
|
58
|
+
const images = [];
|
|
59
|
+
const imagePaths = new Map();
|
|
60
|
+
const coverURL = coverEmbed?.getURL();
|
|
61
|
+
for (const { url, image } of prepared) {
|
|
62
|
+
if (image === undefined) {
|
|
63
|
+
warnings.push(url);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const index = images.length;
|
|
67
|
+
const path = `images/${index}.${image.extension}`;
|
|
68
|
+
imagePaths.set(url, path);
|
|
69
|
+
images.push({
|
|
70
|
+
path,
|
|
71
|
+
id: `image-${index}`,
|
|
72
|
+
mediaType: image.mediaType,
|
|
73
|
+
bytes: image.bytes,
|
|
74
|
+
cover: url === coverURL,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
const context = {
|
|
78
|
+
edition,
|
|
79
|
+
images: imagePaths,
|
|
80
|
+
chapters: new Set(chapters.map((chapter) => chapter.getID())),
|
|
81
|
+
};
|
|
82
|
+
// Front matter, chapters, then back matter, which is also the reading order.
|
|
83
|
+
onProgress?.({ phase: 'chapters', done: 0, total: chapters.length });
|
|
84
|
+
const documents = [];
|
|
85
|
+
const coverPath = coverURL ? imagePaths.get(coverURL) : undefined;
|
|
86
|
+
if (coverPath !== undefined)
|
|
87
|
+
documents.push(coverDocument(coverPath));
|
|
88
|
+
documents.push(titleDocument(edition, context));
|
|
89
|
+
let written = 0;
|
|
90
|
+
for (const chapter of chapters) {
|
|
91
|
+
const document = chapterDocument(edition, chapter, context);
|
|
92
|
+
if (document !== undefined)
|
|
93
|
+
documents.push(document);
|
|
94
|
+
onProgress?.({
|
|
95
|
+
phase: 'chapters',
|
|
96
|
+
done: ++written,
|
|
97
|
+
total: chapters.length,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
const references = referencesDocument(edition, context);
|
|
101
|
+
if (references !== undefined)
|
|
102
|
+
documents.push(references);
|
|
103
|
+
const glossary = glossaryDocument(edition, context);
|
|
104
|
+
if (glossary !== undefined)
|
|
105
|
+
documents.push(glossary);
|
|
106
|
+
onProgress?.({ phase: 'packaging', done: 0, total: 1 });
|
|
107
|
+
const navigation = navigationDocument(documents);
|
|
108
|
+
const identifier = identifierFor(edition);
|
|
109
|
+
const zip = new Zip(modified);
|
|
110
|
+
// The mimetype must be first and uncompressed so readers can identify the
|
|
111
|
+
// file by reading a fixed range of bytes at its head.
|
|
112
|
+
zip.add('mimetype', MIMETYPE, true);
|
|
113
|
+
zip.add('META-INF/container.xml', CONTAINER);
|
|
114
|
+
const content = (path) => `${CONTENT_DIRECTORY}/${path}`;
|
|
115
|
+
zip.add(content('content.opf'), packageDocument(edition, identifier, documents, images, modified));
|
|
116
|
+
zip.add(content('toc.ncx'), ncxDocument(identifier, edition.getTitle(), documents));
|
|
117
|
+
zip.add(content(navigation.path), navigation.content);
|
|
118
|
+
zip.add(content('style.css'), STYLESHEET);
|
|
119
|
+
for (const document of documents)
|
|
120
|
+
zip.add(content(document.path), document.content);
|
|
121
|
+
// Images are already compressed; deflating them again costs time and saves
|
|
122
|
+
// nothing, so they are stored.
|
|
123
|
+
for (const image of images)
|
|
124
|
+
zip.add(content(image.path), image.bytes, true);
|
|
125
|
+
const blob = await zip.toBlob(MIMETYPE);
|
|
126
|
+
onProgress?.({ phase: 'packaging', done: 1, total: 1 });
|
|
127
|
+
return {
|
|
128
|
+
blob,
|
|
129
|
+
filename: `${safeFilename(edition.getTitle())}.epub`,
|
|
130
|
+
warnings,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
/** The cover embed, if the edition has one. */
|
|
134
|
+
function parseCover(edition) {
|
|
135
|
+
const cover = edition.getImage('cover');
|
|
136
|
+
if (cover === null)
|
|
137
|
+
return undefined;
|
|
138
|
+
const node = Parser.parseEmbed(edition, cover);
|
|
139
|
+
return node instanceof EmbedNode && node.getURL().trim().length > 0
|
|
140
|
+
? node
|
|
141
|
+
: undefined;
|
|
142
|
+
}
|
|
143
|
+
export { chapterPath };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
import { beforeEach, expect, test, vi } from 'vitest';
|
|
2
|
+
import Edition from '../models/book/Edition';
|
|
3
|
+
import { buildEPUB, identifierFor } from './build';
|
|
4
|
+
import { SIZES } from './images';
|
|
5
|
+
import { safeFilename } from './package';
|
|
6
|
+
const decoder = new TextDecoder();
|
|
7
|
+
/** Read an archive's entries back, so the EPUB is verified by parsing it. */
|
|
8
|
+
async function entriesOf(blob) {
|
|
9
|
+
const bytes = new Uint8Array(await blob.arrayBuffer());
|
|
10
|
+
const view = new DataView(bytes.buffer);
|
|
11
|
+
let end = bytes.length - 22;
|
|
12
|
+
while (end >= 0 && view.getUint32(end, true) !== 0x06054b50)
|
|
13
|
+
end--;
|
|
14
|
+
if (end < 0)
|
|
15
|
+
throw new Error('No end of central directory record');
|
|
16
|
+
const count = view.getUint16(end + 8, true);
|
|
17
|
+
let offset = view.getUint32(end + 16, true);
|
|
18
|
+
const entries = new Map();
|
|
19
|
+
for (let i = 0; i < count; i++) {
|
|
20
|
+
const nameLength = view.getUint16(offset + 28, true);
|
|
21
|
+
const extraLength = view.getUint16(offset + 30, true);
|
|
22
|
+
const commentLength = view.getUint16(offset + 32, true);
|
|
23
|
+
const method = view.getUint16(offset + 10, true);
|
|
24
|
+
const local = view.getUint32(offset + 42, true);
|
|
25
|
+
const path = decoder.decode(bytes.subarray(offset + 46, offset + 46 + nameLength));
|
|
26
|
+
const localNameLength = view.getUint16(local + 26, true);
|
|
27
|
+
const localExtraLength = view.getUint16(local + 28, true);
|
|
28
|
+
const compressedSize = view.getUint32(local + 18, true);
|
|
29
|
+
const start = local + 30 + localNameLength + localExtraLength;
|
|
30
|
+
const data = bytes.subarray(start, start + compressedSize);
|
|
31
|
+
entries.set(path, method === 0 ? data : await inflate(data));
|
|
32
|
+
offset += 46 + nameLength + extraLength + commentLength;
|
|
33
|
+
}
|
|
34
|
+
return entries;
|
|
35
|
+
}
|
|
36
|
+
async function inflate(data) {
|
|
37
|
+
const stream = new DecompressionStream('deflate-raw');
|
|
38
|
+
const writer = stream.writable.getWriter();
|
|
39
|
+
const written = writer.write(data).then(() => writer.close());
|
|
40
|
+
const reader = stream.readable.getReader();
|
|
41
|
+
const chunks = [];
|
|
42
|
+
let length = 0;
|
|
43
|
+
for (;;) {
|
|
44
|
+
const { done, value } = await reader.read();
|
|
45
|
+
if (done)
|
|
46
|
+
break;
|
|
47
|
+
chunks.push(value);
|
|
48
|
+
length += value.length;
|
|
49
|
+
}
|
|
50
|
+
await written;
|
|
51
|
+
const out = new Uint8Array(length);
|
|
52
|
+
let offset = 0;
|
|
53
|
+
for (const chunk of chunks) {
|
|
54
|
+
out.set(chunk, offset);
|
|
55
|
+
offset += chunk.length;
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
function textOf(entries, path) {
|
|
60
|
+
const bytes = entries.get(path);
|
|
61
|
+
if (bytes === undefined)
|
|
62
|
+
throw new Error(`No ${path}; have ${[...entries.keys()].sort().join(', ')}`);
|
|
63
|
+
return decoder.decode(bytes);
|
|
64
|
+
}
|
|
65
|
+
/** Assert a document is well-formed XML, which EPUB readers require. */
|
|
66
|
+
function expectWellFormed(xml, what) {
|
|
67
|
+
const parsed = new DOMParser().parseFromString(xml, 'application/xml');
|
|
68
|
+
const error = parsed.getElementsByTagName('parsererror')[0];
|
|
69
|
+
expect(error?.textContent ?? undefined, `${what} is not well-formed XML`).toBeUndefined();
|
|
70
|
+
}
|
|
71
|
+
/** A 1x1 GIF, small enough to inline and real enough to fetch. */
|
|
72
|
+
const PIXEL = Uint8Array.from([
|
|
73
|
+
0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00, 0x80, 0x00,
|
|
74
|
+
0x00, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x21, 0xf9, 0x04, 0x01, 0x00,
|
|
75
|
+
0x00, 0x00, 0x00, 0x2c, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00,
|
|
76
|
+
0x00, 0x02, 0x02, 0x44, 0x01, 0x00, 0x3b,
|
|
77
|
+
]);
|
|
78
|
+
function makeEdition(overrides = {}) {
|
|
79
|
+
return Edition.fromJSON(undefined, {
|
|
80
|
+
title: 'A Test Book',
|
|
81
|
+
authors: ['Amy J. Ko', 'Someone Else'],
|
|
82
|
+
number: 1,
|
|
83
|
+
summary: '',
|
|
84
|
+
published: Date.UTC(2026, 0, 15),
|
|
85
|
+
images: {},
|
|
86
|
+
description: 'A book for testing.',
|
|
87
|
+
chapters: [],
|
|
88
|
+
license: 'CC BY 4.0',
|
|
89
|
+
acknowledgements: 'Thanks to everyone.',
|
|
90
|
+
tags: ['testing'],
|
|
91
|
+
sources: {},
|
|
92
|
+
references: {},
|
|
93
|
+
symbols: {},
|
|
94
|
+
glossary: {},
|
|
95
|
+
theme: null,
|
|
96
|
+
uids: [],
|
|
97
|
+
...overrides,
|
|
98
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
function chapter(id, title, text, extra = {}) {
|
|
102
|
+
return {
|
|
103
|
+
id,
|
|
104
|
+
title,
|
|
105
|
+
authors: [],
|
|
106
|
+
image: null,
|
|
107
|
+
numbered: true,
|
|
108
|
+
forthcoming: false,
|
|
109
|
+
section: null,
|
|
110
|
+
text,
|
|
111
|
+
uids: [],
|
|
112
|
+
...extra,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
beforeEach(() => {
|
|
116
|
+
vi.stubGlobal('fetch', vi.fn(async (url) => url.includes('missing')
|
|
117
|
+
? new Response(null, { status: 404 })
|
|
118
|
+
: new Response(PIXEL, {
|
|
119
|
+
headers: { 'Content-Type': 'image/gif' },
|
|
120
|
+
})));
|
|
121
|
+
});
|
|
122
|
+
async function build(edition, options = {}) {
|
|
123
|
+
const result = await buildEPUB(edition, {
|
|
124
|
+
modified: new Date(Date.UTC(2026, 0, 1)),
|
|
125
|
+
...options,
|
|
126
|
+
});
|
|
127
|
+
return { result, entries: await entriesOf(result.blob) };
|
|
128
|
+
}
|
|
129
|
+
test('produces an archive a reader can identify as an EPUB', async () => {
|
|
130
|
+
const edition = makeEdition({
|
|
131
|
+
chapters: [chapter('one', 'One', 'Hello.')],
|
|
132
|
+
});
|
|
133
|
+
const { result, entries } = await build(edition);
|
|
134
|
+
expect(result.blob.type).toBe('application/epub+zip');
|
|
135
|
+
// The mimetype must be the first entry and stored, uncompressed.
|
|
136
|
+
const bytes = new Uint8Array(await result.blob.arrayBuffer());
|
|
137
|
+
expect(decoder.decode(bytes.subarray(30, 38))).toBe('mimetype');
|
|
138
|
+
expect(textOf(entries, 'mimetype')).toBe('application/epub+zip');
|
|
139
|
+
expect(textOf(entries, 'META-INF/container.xml')).toContain('OEBPS/content.opf');
|
|
140
|
+
});
|
|
141
|
+
test('every document it writes is well-formed XML', async () => {
|
|
142
|
+
const edition = makeEdition({
|
|
143
|
+
chapters: [
|
|
144
|
+
chapter('one', 'One & Only', 'Hello {a note} <ref> world.'),
|
|
145
|
+
chapter('two', 'Two', '# Header\n\n* A list'),
|
|
146
|
+
],
|
|
147
|
+
references: { ref: 'Ko, A. (2020). A title. A source.' },
|
|
148
|
+
glossary: {
|
|
149
|
+
bug: { phrase: 'bug', definition: 'A defect', synonyms: ['fault'] },
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
const { entries } = await build(edition);
|
|
153
|
+
for (const [path, bytes] of entries)
|
|
154
|
+
if (path.endsWith('.xhtml') ||
|
|
155
|
+
path.endsWith('.opf') ||
|
|
156
|
+
path.endsWith('.ncx') ||
|
|
157
|
+
path.endsWith('.xml'))
|
|
158
|
+
expectWellFormed(decoder.decode(bytes), path);
|
|
159
|
+
});
|
|
160
|
+
test('lists chapters in reading order in the package, nav, and ncx', async () => {
|
|
161
|
+
const edition = makeEdition({
|
|
162
|
+
chapters: [
|
|
163
|
+
chapter('one', 'First', 'A.'),
|
|
164
|
+
chapter('two', 'Second', 'B.'),
|
|
165
|
+
],
|
|
166
|
+
});
|
|
167
|
+
const { entries } = await build(edition);
|
|
168
|
+
const opf = textOf(entries, 'OEBPS/content.opf');
|
|
169
|
+
expect(opf.indexOf('chapter-one')).toBeLessThan(opf.indexOf('chapter-two'));
|
|
170
|
+
expect(opf).toContain('<itemref idref="title"/>');
|
|
171
|
+
const nav = textOf(entries, 'OEBPS/nav.xhtml');
|
|
172
|
+
expect(nav).toContain('>1. First<');
|
|
173
|
+
expect(nav).toContain('>2. Second<');
|
|
174
|
+
expect(nav.indexOf('First')).toBeLessThan(nav.indexOf('Second'));
|
|
175
|
+
// The EPUB 2 fallback older devices still read.
|
|
176
|
+
const ncx = textOf(entries, 'OEBPS/toc.ncx');
|
|
177
|
+
expect(ncx).toContain('one.xhtml');
|
|
178
|
+
expect(ncx).toContain('two.xhtml');
|
|
179
|
+
});
|
|
180
|
+
test('carries the metadata a reader displays', async () => {
|
|
181
|
+
const edition = makeEdition({
|
|
182
|
+
chapters: [chapter('one', 'One', 'Hi.')],
|
|
183
|
+
});
|
|
184
|
+
const { entries } = await build(edition);
|
|
185
|
+
const opf = textOf(entries, 'OEBPS/content.opf');
|
|
186
|
+
expect(opf).toContain('<dc:title>A Test Book</dc:title>');
|
|
187
|
+
expect(opf).toContain('<dc:creator id="creator-0">Amy J. Ko</dc:creator>');
|
|
188
|
+
expect(opf).toContain('<dc:creator id="creator-1">Someone Else</dc:creator>');
|
|
189
|
+
expect(opf).toContain('<dc:language>en</dc:language>');
|
|
190
|
+
expect(opf).toContain('<dc:rights>CC BY 4.0</dc:rights>');
|
|
191
|
+
expect(opf).toContain('<dc:subject>testing</dc:subject>');
|
|
192
|
+
expect(opf).toContain('2026-01-15');
|
|
193
|
+
// Required by EPUB 3, and must not carry milliseconds.
|
|
194
|
+
expect(opf).toMatch(/<meta property="dcterms:modified">\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z<\/meta>/);
|
|
195
|
+
});
|
|
196
|
+
test('omits forthcoming chapters, as the print view does', async () => {
|
|
197
|
+
const edition = makeEdition({
|
|
198
|
+
chapters: [
|
|
199
|
+
chapter('one', 'Ready', 'Here.'),
|
|
200
|
+
chapter('two', 'Later', '', { forthcoming: true }),
|
|
201
|
+
],
|
|
202
|
+
});
|
|
203
|
+
const { entries } = await build(edition);
|
|
204
|
+
expect(entries.has('OEBPS/one.xhtml')).toBe(true);
|
|
205
|
+
expect(entries.has('OEBPS/two.xhtml')).toBe(false);
|
|
206
|
+
});
|
|
207
|
+
test('packages images and points chapters at them', async () => {
|
|
208
|
+
const edition = makeEdition({
|
|
209
|
+
chapters: [chapter('one', 'One', '|photo.jpg|A photo|A caption||')],
|
|
210
|
+
});
|
|
211
|
+
const { result, entries } = await build(edition);
|
|
212
|
+
expect(result.warnings).toEqual([]);
|
|
213
|
+
const chapterXHTML = textOf(entries, 'OEBPS/one.xhtml');
|
|
214
|
+
const source = chapterXHTML.match(/<img src="([^"]+)"/)?.[1];
|
|
215
|
+
expect(source).toBeDefined();
|
|
216
|
+
expect(entries.has(`OEBPS/${source}`)).toBe(true);
|
|
217
|
+
// Everything in the package must also be in the manifest.
|
|
218
|
+
expect(textOf(entries, 'OEBPS/content.opf')).toContain(`href="${source}"`);
|
|
219
|
+
});
|
|
220
|
+
test('declares a cover both ways, for old and new readers', async () => {
|
|
221
|
+
const edition = makeEdition({
|
|
222
|
+
images: { cover: '|cover.jpg|The cover|||' },
|
|
223
|
+
chapters: [chapter('one', 'One', 'Hi.')],
|
|
224
|
+
});
|
|
225
|
+
const { entries } = await build(edition);
|
|
226
|
+
const opf = textOf(entries, 'OEBPS/content.opf');
|
|
227
|
+
expect(opf).toContain('properties="cover-image"');
|
|
228
|
+
expect(opf).toContain('<meta name="cover"');
|
|
229
|
+
expect(entries.has('OEBPS/cover.xhtml')).toBe(true);
|
|
230
|
+
});
|
|
231
|
+
test('reports an image it could not fetch and keeps reading', async () => {
|
|
232
|
+
const edition = makeEdition({
|
|
233
|
+
chapters: [
|
|
234
|
+
chapter('one', 'One', '|missing.jpg|Alt|A caption||\n\nAfter.'),
|
|
235
|
+
],
|
|
236
|
+
});
|
|
237
|
+
const { result, entries } = await build(edition);
|
|
238
|
+
expect(result.warnings).toHaveLength(1);
|
|
239
|
+
expect(result.warnings[0]).toContain('missing.jpg');
|
|
240
|
+
// The chapter still builds, keeping the caption in place of the image.
|
|
241
|
+
const chapterXHTML = textOf(entries, 'OEBPS/one.xhtml');
|
|
242
|
+
expect(chapterXHTML).not.toContain('<img');
|
|
243
|
+
expect(chapterXHTML).toContain('A caption');
|
|
244
|
+
expect(chapterXHTML).toContain('After.');
|
|
245
|
+
});
|
|
246
|
+
test('resolves local image URLs against the book base path', async () => {
|
|
247
|
+
const edition = makeEdition({
|
|
248
|
+
chapters: [chapter('one', 'One', '|photo.jpg|Alt|||')],
|
|
249
|
+
});
|
|
250
|
+
await build(edition, { base: '/ajko/books/test' });
|
|
251
|
+
expect(fetch).toHaveBeenCalledWith('/ajko/books/test/images/photo.jpg', expect.anything());
|
|
252
|
+
});
|
|
253
|
+
test('leaves a remote image URL alone', async () => {
|
|
254
|
+
const edition = makeEdition({
|
|
255
|
+
chapters: [chapter('one', 'One', '|https://example.com/p.jpg|Alt|||')],
|
|
256
|
+
});
|
|
257
|
+
await build(edition, { base: '/base' });
|
|
258
|
+
expect(fetch).toHaveBeenCalledWith('https://example.com/p.jpg', expect.anything());
|
|
259
|
+
});
|
|
260
|
+
test('fetches each distinct image once, however often it appears', async () => {
|
|
261
|
+
const edition = makeEdition({
|
|
262
|
+
chapters: [
|
|
263
|
+
chapter('one', 'One', '|photo.jpg|Alt|||'),
|
|
264
|
+
chapter('two', 'Two', '|photo.jpg|Alt|||'),
|
|
265
|
+
],
|
|
266
|
+
});
|
|
267
|
+
await build(edition);
|
|
268
|
+
expect(vi.mocked(fetch)).toHaveBeenCalledTimes(1);
|
|
269
|
+
});
|
|
270
|
+
test('includes references and glossary only when the book has them', async () => {
|
|
271
|
+
const without = await build(makeEdition({ chapters: [chapter('one', 'One', 'Hi.')] }));
|
|
272
|
+
expect(without.entries.has('OEBPS/references.xhtml')).toBe(false);
|
|
273
|
+
expect(without.entries.has('OEBPS/glossary.xhtml')).toBe(false);
|
|
274
|
+
const with_ = await build(makeEdition({
|
|
275
|
+
chapters: [chapter('one', 'One', 'Hi <ref>. A ~bug~bug.')],
|
|
276
|
+
references: { ref: ['Ko, A.', '2020', 'A title', 'A source', ''] },
|
|
277
|
+
glossary: {
|
|
278
|
+
bug: { phrase: 'bug', definition: 'A defect', synonyms: [] },
|
|
279
|
+
},
|
|
280
|
+
}));
|
|
281
|
+
expect(with_.entries.has('OEBPS/references.xhtml')).toBe(true);
|
|
282
|
+
expect(with_.entries.has('OEBPS/glossary.xhtml')).toBe(true);
|
|
283
|
+
});
|
|
284
|
+
test('citations and definitions land on anchors that exist', async () => {
|
|
285
|
+
const edition = makeEdition({
|
|
286
|
+
chapters: [chapter('one', 'One', 'Hi <ref>. A ~bug~bug.')],
|
|
287
|
+
references: { ref: ['Ko, A.', '2020', 'A title', 'A source', ''] },
|
|
288
|
+
glossary: {
|
|
289
|
+
bug: { phrase: 'bug', definition: 'A defect', synonyms: [] },
|
|
290
|
+
},
|
|
291
|
+
});
|
|
292
|
+
const { entries } = await build(edition);
|
|
293
|
+
expect(textOf(entries, 'OEBPS/one.xhtml')).toContain('references.xhtml#ref-ref');
|
|
294
|
+
expect(textOf(entries, 'OEBPS/references.xhtml')).toContain('id="ref-ref"');
|
|
295
|
+
expect(textOf(entries, 'OEBPS/one.xhtml')).toContain('glossary.xhtml#gloss-bug');
|
|
296
|
+
// The web glossary omits these ids; here they have to resolve.
|
|
297
|
+
expect(textOf(entries, 'OEBPS/glossary.xhtml')).toContain('id="gloss-bug"');
|
|
298
|
+
});
|
|
299
|
+
test('a footnote and its endnote link to each other', async () => {
|
|
300
|
+
const edition = makeEdition({
|
|
301
|
+
chapters: [chapter('one', 'One', 'Text{the note}.')],
|
|
302
|
+
});
|
|
303
|
+
const { entries } = await build(edition);
|
|
304
|
+
const xhtml = textOf(entries, 'OEBPS/one.xhtml');
|
|
305
|
+
expect(xhtml).toContain('href="#fn-0"');
|
|
306
|
+
expect(xhtml).toContain('id="fn-0"');
|
|
307
|
+
expect(xhtml).toContain('href="#fnref-0"');
|
|
308
|
+
expect(xhtml).toContain('id="fnref-0"');
|
|
309
|
+
expect(xhtml).toContain('the note');
|
|
310
|
+
});
|
|
311
|
+
test('a smaller preset produces a smaller file', async () => {
|
|
312
|
+
const edition = makeEdition({
|
|
313
|
+
chapters: [chapter('one', 'One', 'Text.')],
|
|
314
|
+
});
|
|
315
|
+
const standard = await build(edition, { size: SIZES.standard });
|
|
316
|
+
const compact = await build(edition, { size: SIZES.compact });
|
|
317
|
+
// No images here, so this only confirms the preset threads through without
|
|
318
|
+
// changing the text; the e2e test checks actual re-encoding in a browser.
|
|
319
|
+
expect(compact.result.blob.size).toBeLessThanOrEqual(standard.result.blob.size);
|
|
320
|
+
});
|
|
321
|
+
test('reports progress through every phase', async () => {
|
|
322
|
+
const edition = makeEdition({
|
|
323
|
+
chapters: [
|
|
324
|
+
chapter('one', 'One', '|photo.jpg|Alt|||'),
|
|
325
|
+
chapter('two', 'Two', 'B.'),
|
|
326
|
+
],
|
|
327
|
+
});
|
|
328
|
+
const progress = [];
|
|
329
|
+
await build(edition, {
|
|
330
|
+
onProgress: (p) => progress.push(`${p.phase} ${p.done}/${p.total}`),
|
|
331
|
+
});
|
|
332
|
+
expect(progress).toContain('images 1/1');
|
|
333
|
+
expect(progress).toContain('chapters 1/2');
|
|
334
|
+
expect(progress).toContain('chapters 2/2');
|
|
335
|
+
expect(progress).toContain('packaging 1/1');
|
|
336
|
+
});
|
|
337
|
+
test('names the file after the book', async () => {
|
|
338
|
+
const { result } = await build(makeEdition({
|
|
339
|
+
title: 'Cooperative Software Development!',
|
|
340
|
+
chapters: [chapter('one', 'One', 'Hi.')],
|
|
341
|
+
}));
|
|
342
|
+
expect(result.filename).toBe('cooperative-software-development.epub');
|
|
343
|
+
});
|
|
344
|
+
test('makes a usable filename out of an awkward title', () => {
|
|
345
|
+
expect(safeFilename('A: Book / With <Punctuation>')).toBe('a-book-with-punctuation');
|
|
346
|
+
expect(safeFilename(' ')).toBe('book');
|
|
347
|
+
expect(safeFilename('Ünïcodé Títle')).toBe('ünïcodé-títle');
|
|
348
|
+
});
|
|
349
|
+
test('gives the same edition the same identifier every time', () => {
|
|
350
|
+
const edition = makeEdition();
|
|
351
|
+
expect(identifierFor(edition)).toBe(identifierFor(edition));
|
|
352
|
+
expect(identifierFor(edition)).toContain('urn:bookish:');
|
|
353
|
+
});
|
|
354
|
+
test('builds a book with no chapters at all', async () => {
|
|
355
|
+
const { entries, result } = await build(makeEdition());
|
|
356
|
+
expect(result.warnings).toEqual([]);
|
|
357
|
+
expect(entries.has('OEBPS/title.xhtml')).toBe(true);
|
|
358
|
+
expectWellFormed(textOf(entries, 'OEBPS/content.opf'), 'content.opf');
|
|
359
|
+
});
|
|
360
|
+
test('puts the title, license and acknowledgements on the title page', async () => {
|
|
361
|
+
const { entries } = await build(makeEdition({ chapters: [chapter('one', 'One', 'Hi.')] }));
|
|
362
|
+
const title = textOf(entries, 'OEBPS/title.xhtml');
|
|
363
|
+
expect(title).toContain('A Test Book');
|
|
364
|
+
expect(title).toContain('Amy J. Ko');
|
|
365
|
+
expect(title).toContain('A book for testing.');
|
|
366
|
+
expect(title).toContain('Thanks to everyone.');
|
|
367
|
+
expect(title).toContain('CC BY 4.0');
|
|
368
|
+
});
|
|
369
|
+
test('every spine item is present in the archive', async () => {
|
|
370
|
+
const edition = makeEdition({
|
|
371
|
+
images: { cover: '|cover.jpg|The cover|||' },
|
|
372
|
+
chapters: [chapter('one', 'One', 'Hi <ref>.')],
|
|
373
|
+
references: { ref: ['Ko, A.', '2020', 'T', 'S', ''] },
|
|
374
|
+
});
|
|
375
|
+
const { entries } = await build(edition);
|
|
376
|
+
const opf = textOf(entries, 'OEBPS/content.opf');
|
|
377
|
+
// Every manifest href must resolve to a real entry, or readers error.
|
|
378
|
+
for (const match of opf.matchAll(/href="([^"]+)"/g))
|
|
379
|
+
expect(entries.has(`OEBPS/${match[1]}`), `manifest lists ${match[1]} but it is not in the archive`).toBe(true);
|
|
380
|
+
});
|
|
381
|
+
test('every image fetch carries a timeout', async () => {
|
|
382
|
+
// Books link images on hosts nobody controls. A host that accepts the
|
|
383
|
+
// connection and then never answers used to hang the whole export: fetches
|
|
384
|
+
// run a few at a time, so the dead ones block everything behind them and
|
|
385
|
+
// the progress indicator sits there forever. A failed or timed-out fetch is
|
|
386
|
+
// reported and dropped like any other, which the 404 case above covers.
|
|
387
|
+
const edition = makeEdition({
|
|
388
|
+
chapters: [chapter('one', 'One', '|photo.jpg|Alt|||')],
|
|
389
|
+
});
|
|
390
|
+
await build(edition);
|
|
391
|
+
const options = vi.mocked(fetch).mock.calls[0]?.[1];
|
|
392
|
+
expect(options?.signal, 'the image fetch has no timeout').toBeInstanceOf(AbortSignal);
|
|
393
|
+
});
|
|
394
|
+
test('a reference whose fields are not strings still exports', async () => {
|
|
395
|
+
// Real books predate the current types: years are often integers and URLs
|
|
396
|
+
// are often null, though Reference declares both as strings. One stale
|
|
397
|
+
// field used to throw partway through and lose the entire export.
|
|
398
|
+
const edition = makeEdition({
|
|
399
|
+
chapters: [chapter('one', 'One', 'A claim<old>.')],
|
|
400
|
+
references: {
|
|
401
|
+
// year as a number, url as null, summary missing.
|
|
402
|
+
old: ['Plato', -370, 'The Republic', 'Athens', null],
|
|
403
|
+
},
|
|
404
|
+
});
|
|
405
|
+
const { result, entries } = await build(edition);
|
|
406
|
+
expect(result.blob.size).toBeGreaterThan(0);
|
|
407
|
+
const references = textOf(entries, 'OEBPS/references.xhtml');
|
|
408
|
+
expect(references).toContain('Plato');
|
|
409
|
+
expect(references).toContain('-370');
|
|
410
|
+
expect(references).toContain('The Republic');
|
|
411
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fetches a book's images and shrinks them to something an e-reader can hold.
|
|
3
|
+
*
|
|
4
|
+
* Books are written for the web, where a full resolution photograph is fine.
|
|
5
|
+
* E-ink devices are small, slow, and often storage constrained, so every image
|
|
6
|
+
* is re-encoded to fit the reader's chosen budget. The server-side thumbnails
|
|
7
|
+
* are no help here: they are 320px wide, below even the smallest preset.
|
|
8
|
+
*
|
|
9
|
+
* Every canvas reference lives inside a function rather than at module scope,
|
|
10
|
+
* because this module is imported during server rendering and during
|
|
11
|
+
* bookish-reader's prerender pass, and Node has no OffscreenCanvas.
|
|
12
|
+
*/
|
|
13
|
+
/** A reader-selectable image budget. */
|
|
14
|
+
export type ImageSize = {
|
|
15
|
+
/** Identifier used in the UI and persisted nowhere. */
|
|
16
|
+
id: string;
|
|
17
|
+
/** What the reader sees in the size menu. */
|
|
18
|
+
label: string;
|
|
19
|
+
maxWidth: number;
|
|
20
|
+
maxHeight: number;
|
|
21
|
+
/** JPEG quality, 0 to 1. */
|
|
22
|
+
quality: number;
|
|
23
|
+
/** Whether to discard color. E-ink panels below ~1000px are greyscale. */
|
|
24
|
+
grayscale: boolean;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* The presets offered to readers. Standard covers the common 6-7" e-ink panels
|
|
28
|
+
* at native resolution (Kobo Clara 1072x1448, Kindle Paperwhite 1236x1648);
|
|
29
|
+
* Compact targets pocket-sized greyscale readers; Large leaves room to zoom
|
|
30
|
+
* into diagrams on a tablet.
|
|
31
|
+
*/
|
|
32
|
+
export declare const SIZES: Record<string, ImageSize>;
|
|
33
|
+
export declare const DEFAULT_SIZE: ImageSize;
|
|
34
|
+
/**
|
|
35
|
+
* The dimensions an image should be drawn at to fit within a budget, preserving
|
|
36
|
+
* its aspect ratio. Never enlarges: an image already smaller than the budget is
|
|
37
|
+
* left at its own size.
|
|
38
|
+
*/
|
|
39
|
+
export declare function fitWithin(width: number, height: number, maxWidth: number, maxHeight: number): {
|
|
40
|
+
width: number;
|
|
41
|
+
height: number;
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* How far past a budget an image may sit before it is shrunk even when its
|
|
45
|
+
* original encoding is smaller. Just over the budget is tolerated so a crisp
|
|
46
|
+
* original is not traded for a bigger resampled one; well past it is not,
|
|
47
|
+
* because the decode has to fit in the reader's memory.
|
|
48
|
+
*/
|
|
49
|
+
export declare const OVERSIZE_TOLERANCE = 1.5;
|
|
50
|
+
/** Whether an image is far enough past a budget that it must be shrunk. */
|
|
51
|
+
export declare function exceedsBudget(width: number, height: number, size: ImageSize): boolean;
|
|
52
|
+
/** A packaged image, ready to be added to the archive. */
|
|
53
|
+
export type PreparedImage = {
|
|
54
|
+
bytes: Uint8Array;
|
|
55
|
+
/** The extension to give it in the package, without a dot. */
|
|
56
|
+
extension: string;
|
|
57
|
+
mediaType: string;
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* Resolve an embed's URL to something fetchable. Local (non-http) URLs are
|
|
61
|
+
* relative to the book's base path, exactly as Embed.svelte resolves them --
|
|
62
|
+
* and in compiled books that base is frequently non-empty, so a bare
|
|
63
|
+
* `images/x.jpg` would 404.
|
|
64
|
+
*/
|
|
65
|
+
export declare function resolveImageURL(url: string, base: string): string;
|
|
66
|
+
/**
|
|
67
|
+
* Fetch an image and re-encode it to fit the given budget.
|
|
68
|
+
*
|
|
69
|
+
* Returns undefined for anything that can't be fetched or decoded, which is a
|
|
70
|
+
* real possibility: authors may link images on hosts that send no CORS headers,
|
|
71
|
+
* or that are simply gone. Callers report those as warnings and drop the image,
|
|
72
|
+
* keeping its caption.
|
|
73
|
+
*/
|
|
74
|
+
export declare function prepareImage(url: string, size: ImageSize): Promise<PreparedImage | undefined>;
|
|
75
|
+
/**
|
|
76
|
+
* Run tasks with a bounded number in flight, so a book with a hundred images
|
|
77
|
+
* doesn't open a hundred connections at once.
|
|
78
|
+
*/
|
|
79
|
+
export declare function mapWithLimit<In, Out>(items: In[], limit: number, map: (item: In, index: number) => Promise<Out>): Promise<Out[]>;
|