bookish-press 0.8.2 → 0.9.3

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.
@@ -0,0 +1,260 @@
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
+ /**
14
+ * The presets offered to readers. Standard covers the common 6-7" e-ink panels
15
+ * at native resolution (Kobo Clara 1072x1448, Kindle Paperwhite 1236x1648);
16
+ * Compact targets pocket-sized greyscale readers; Large leaves room to zoom
17
+ * into diagrams on a tablet.
18
+ */
19
+ export const SIZES = {
20
+ compact: {
21
+ id: 'compact',
22
+ label: 'Compact — small e-ink readers',
23
+ maxWidth: 480,
24
+ maxHeight: 640,
25
+ quality: 0.7,
26
+ grayscale: true,
27
+ },
28
+ standard: {
29
+ id: 'standard',
30
+ label: 'Standard — most e-readers',
31
+ // Just above the Kindle Paperwhite's 1236x1648, so a full-page image is
32
+ // never scaled down here only to be scaled back up by the device.
33
+ maxWidth: 1280,
34
+ maxHeight: 1720,
35
+ quality: 0.8,
36
+ grayscale: false,
37
+ },
38
+ large: {
39
+ id: 'large',
40
+ label: 'Large — tablets and zooming',
41
+ maxWidth: 1600,
42
+ maxHeight: 2200,
43
+ quality: 0.85,
44
+ grayscale: false,
45
+ },
46
+ };
47
+ export const DEFAULT_SIZE = SIZES.standard;
48
+ /**
49
+ * The dimensions an image should be drawn at to fit within a budget, preserving
50
+ * its aspect ratio. Never enlarges: an image already smaller than the budget is
51
+ * left at its own size.
52
+ */
53
+ export function fitWithin(width, height, maxWidth, maxHeight) {
54
+ if (width <= 0 || height <= 0)
55
+ return { width: 0, height: 0 };
56
+ const scale = Math.min(maxWidth / width, maxHeight / height, 1);
57
+ return {
58
+ width: Math.max(1, Math.round(width * scale)),
59
+ height: Math.max(1, Math.round(height * scale)),
60
+ };
61
+ }
62
+ /**
63
+ * How far past a budget an image may sit before it is shrunk even when its
64
+ * original encoding is smaller. Just over the budget is tolerated so a crisp
65
+ * original is not traded for a bigger resampled one; well past it is not,
66
+ * because the decode has to fit in the reader's memory.
67
+ */
68
+ export const OVERSIZE_TOLERANCE = 1.5;
69
+ /** Whether an image is far enough past a budget that it must be shrunk. */
70
+ export function exceedsBudget(width, height, size) {
71
+ return (Math.max(width / size.maxWidth, height / size.maxHeight) >
72
+ OVERSIZE_TOLERANCE);
73
+ }
74
+ /** Media types EPUB 3 requires every reading system to support. */
75
+ const PASS_THROUGH = {
76
+ 'image/jpeg': { extension: 'jpg' },
77
+ 'image/png': { extension: 'png' },
78
+ 'image/gif': { extension: 'gif' },
79
+ 'image/svg+xml': { extension: 'svg' },
80
+ 'image/webp': { extension: 'webp' },
81
+ };
82
+ function passThrough(bytes, mediaType) {
83
+ const known = PASS_THROUGH[mediaType];
84
+ return known === undefined
85
+ ? { bytes, extension: 'jpg', mediaType: 'image/jpeg' }
86
+ : { bytes, extension: known.extension, mediaType };
87
+ }
88
+ /**
89
+ * Resolve an embed's URL to something fetchable. Local (non-http) URLs are
90
+ * relative to the book's base path, exactly as Embed.svelte resolves them --
91
+ * and in compiled books that base is frequently non-empty, so a bare
92
+ * `images/x.jpg` would 404.
93
+ */
94
+ export function resolveImageURL(url, base) {
95
+ return url.startsWith('http') ? url : `${base}/images/${url}`;
96
+ }
97
+ /**
98
+ * How long to wait for a single image before giving up on it. Books link
99
+ * images on hosts we don't control, and a host that accepts a connection then
100
+ * never answers would otherwise hang the whole export: with a bounded number
101
+ * of fetches in flight, a few dead ones stall every remaining image behind
102
+ * them and the progress indicator sits there forever.
103
+ */
104
+ const FETCH_TIMEOUT_MS = 20000;
105
+ /**
106
+ * Fetch an image and re-encode it to fit the given budget.
107
+ *
108
+ * Returns undefined for anything that can't be fetched or decoded, which is a
109
+ * real possibility: authors may link images on hosts that send no CORS headers,
110
+ * or that are simply gone. Callers report those as warnings and drop the image,
111
+ * keeping its caption.
112
+ */
113
+ export async function prepareImage(url, size) {
114
+ let bytes;
115
+ let mediaType;
116
+ try {
117
+ const response = await fetch(url, {
118
+ signal: typeof AbortSignal !== 'undefined' && 'timeout' in AbortSignal
119
+ ? AbortSignal.timeout(FETCH_TIMEOUT_MS)
120
+ : undefined,
121
+ });
122
+ if (!response.ok)
123
+ return undefined;
124
+ const blob = await response.blob();
125
+ bytes = new Uint8Array(await blob.arrayBuffer());
126
+ mediaType = blob.type || 'image/jpeg';
127
+ }
128
+ catch {
129
+ return undefined;
130
+ }
131
+ if (bytes.length === 0)
132
+ return undefined;
133
+ // SVG is resolution independent and usually tiny; rasterizing it is both
134
+ // wasteful and unreliable across browsers.
135
+ if (mediaType === 'image/svg+xml')
136
+ return passThrough(bytes, mediaType);
137
+ const reencoded = await reencode(bytes, mediaType, size);
138
+ if (reencoded === undefined)
139
+ return passThrough(bytes, mediaType);
140
+ // Keep whichever is smaller, since re-encoding can inflate a file: resizing
141
+ // a diagram resamples its hairlines into shades of grey, and the result can
142
+ // be larger than the crisp original. But an image far past the budget is
143
+ // shrunk regardless, because decoding it costs a small reader memory it may
144
+ // not have, and a few extra kilobytes are the cheaper price.
145
+ const { oversize, ...prepared } = reencoded;
146
+ return oversize || prepared.bytes.length < bytes.length
147
+ ? prepared
148
+ : passThrough(bytes, mediaType);
149
+ }
150
+ /**
151
+ * Draw the image at its budgeted size and encode it, reporting whether the
152
+ * original was far enough past the budget that it must be shrunk regardless of
153
+ * size. Returns undefined if the platform lacks the canvas APIs or the bytes
154
+ * don't decode.
155
+ */
156
+ async function reencode(bytes, mediaType, size) {
157
+ if (typeof createImageBitmap === 'undefined' ||
158
+ typeof document === 'undefined')
159
+ return undefined;
160
+ let bitmap;
161
+ try {
162
+ bitmap = await createImageBitmap(new Blob([bytes], { type: mediaType }));
163
+ }
164
+ catch {
165
+ return undefined;
166
+ }
167
+ try {
168
+ const { width, height } = fitWithin(bitmap.width, bitmap.height, size.maxWidth, size.maxHeight);
169
+ if (width === 0 || height === 0)
170
+ return undefined;
171
+ const canvas = makeCanvas(width, height);
172
+ const context = canvas.getContext('2d');
173
+ if (context === null)
174
+ return undefined;
175
+ context.drawImage(bitmap, 0, 0, width, height);
176
+ if (size.grayscale)
177
+ desaturate(context, width, height);
178
+ // PNG first, while any transparency is still intact.
179
+ const png = await toBlob(canvas, 'image/png');
180
+ // Then white behind what is already drawn, because a JPEG has no alpha
181
+ // and transparent areas would otherwise come out black.
182
+ context.globalCompositeOperation = 'destination-over';
183
+ context.fillStyle = '#ffffff';
184
+ context.fillRect(0, 0, width, height);
185
+ context.globalCompositeOperation = 'source-over';
186
+ const jpeg = await toBlob(canvas, 'image/jpeg', size.quality);
187
+ const candidates = [];
188
+ if (jpeg !== null)
189
+ candidates.push({
190
+ bytes: new Uint8Array(await jpeg.arrayBuffer()),
191
+ extension: 'jpg',
192
+ mediaType: 'image/jpeg',
193
+ });
194
+ if (png !== null)
195
+ candidates.push({
196
+ bytes: new Uint8Array(await png.arrayBuffer()),
197
+ extension: 'png',
198
+ mediaType: 'image/png',
199
+ });
200
+ if (candidates.length === 0)
201
+ return undefined;
202
+ return {
203
+ // Whichever encoding came out smaller: JPEG wins on photographs,
204
+ // PNG on the flat colors and hairlines of a diagram.
205
+ ...candidates.reduce((a, b) => b.bytes.length < a.bytes.length ? b : a),
206
+ oversize: exceedsBudget(bitmap.width, bitmap.height, size),
207
+ };
208
+ }
209
+ catch {
210
+ return undefined;
211
+ }
212
+ finally {
213
+ bitmap.close();
214
+ }
215
+ }
216
+ function makeCanvas(width, height) {
217
+ if (typeof OffscreenCanvas !== 'undefined')
218
+ return new OffscreenCanvas(width, height);
219
+ const canvas = document.createElement('canvas');
220
+ canvas.width = width;
221
+ canvas.height = height;
222
+ return canvas;
223
+ }
224
+ function toBlob(canvas, type, quality) {
225
+ return 'convertToBlob' in canvas
226
+ ? canvas.convertToBlob({ type, quality }).catch(() => null)
227
+ : new Promise((resolve) => canvas.toBlob((blob) => resolve(blob), type, quality));
228
+ }
229
+ /** Replace each pixel with its luminance, weighted the way human vision is. */
230
+ function desaturate(context, width, height) {
231
+ const image = context.getImageData(0, 0, width, height);
232
+ const pixels = image.data;
233
+ for (let i = 0; i < pixels.length; i += 4) {
234
+ const luminance = 0.2126 * pixels[i] +
235
+ 0.7152 * pixels[i + 1] +
236
+ 0.0722 * pixels[i + 2];
237
+ pixels[i] = luminance;
238
+ pixels[i + 1] = luminance;
239
+ pixels[i + 2] = luminance;
240
+ }
241
+ context.putImageData(image, 0, 0);
242
+ }
243
+ /**
244
+ * Run tasks with a bounded number in flight, so a book with a hundred images
245
+ * doesn't open a hundred connections at once.
246
+ */
247
+ export async function mapWithLimit(items, limit, map) {
248
+ const results = new Array(items.length);
249
+ let next = 0;
250
+ const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
251
+ for (;;) {
252
+ const index = next++;
253
+ if (index >= items.length)
254
+ return;
255
+ results[index] = await map(items[index], index);
256
+ }
257
+ });
258
+ await Promise.all(workers);
259
+ return results;
260
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,129 @@
1
+ import { expect, test } from 'vitest';
2
+ import { DEFAULT_SIZE, exceedsBudget, fitWithin, mapWithLimit, resolveImageURL, SIZES, } from './images';
3
+ test('shrinks a landscape image to the width budget', () => {
4
+ expect(fitWithin(2400, 1200, 1200, 1600)).toEqual({
5
+ width: 1200,
6
+ height: 600,
7
+ });
8
+ });
9
+ test('shrinks a portrait image to the height budget', () => {
10
+ expect(fitWithin(1200, 3200, 1200, 1600)).toEqual({
11
+ width: 600,
12
+ height: 1600,
13
+ });
14
+ });
15
+ test('never enlarges an image that already fits', () => {
16
+ expect(fitWithin(320, 240, 1200, 1600)).toEqual({
17
+ width: 320,
18
+ height: 240,
19
+ });
20
+ });
21
+ test('preserves aspect ratio when both dimensions exceed the budget', () => {
22
+ const { width, height } = fitWithin(4000, 3000, 1200, 1600);
23
+ expect(width).toBe(1200);
24
+ expect(height).toBe(900);
25
+ expect(width / height).toBeCloseTo(4000 / 3000, 5);
26
+ });
27
+ test('never rounds a dimension away to nothing', () => {
28
+ // An extreme panorama would otherwise round its height to 0.
29
+ const { width, height } = fitWithin(10000, 3, 480, 640);
30
+ expect(width).toBeGreaterThan(0);
31
+ expect(height).toBeGreaterThan(0);
32
+ });
33
+ test('treats a degenerate image as empty rather than dividing by zero', () => {
34
+ expect(fitWithin(0, 0, 1200, 1600)).toEqual({ width: 0, height: 0 });
35
+ expect(fitWithin(-5, 10, 1200, 1600)).toEqual({ width: 0, height: 0 });
36
+ });
37
+ test('every preset is smaller than the next and shaped for its device', () => {
38
+ const { compact, standard, large } = SIZES;
39
+ expect(compact.maxWidth).toBeLessThan(standard.maxWidth);
40
+ expect(standard.maxWidth).toBeLessThan(large.maxWidth);
41
+ expect(compact.maxHeight).toBeLessThan(standard.maxHeight);
42
+ expect(standard.maxHeight).toBeLessThan(large.maxHeight);
43
+ // Pocket e-ink panels are greyscale; the bigger presets are read on
44
+ // devices that can show color.
45
+ expect(compact.grayscale).toBe(true);
46
+ expect(standard.grayscale).toBe(false);
47
+ expect(large.grayscale).toBe(false);
48
+ for (const size of Object.values(SIZES)) {
49
+ expect(size.quality).toBeGreaterThan(0);
50
+ expect(size.quality).toBeLessThanOrEqual(1);
51
+ expect(size.label.length).toBeGreaterThan(0);
52
+ }
53
+ });
54
+ test('every preset is keyed by its own id', () => {
55
+ for (const [key, size] of Object.entries(SIZES))
56
+ expect(size.id).toBe(key);
57
+ });
58
+ test('the default is one of the presets', () => {
59
+ expect(SIZES[DEFAULT_SIZE.id]).toBe(DEFAULT_SIZE);
60
+ });
61
+ // Standard has to cover the common panels at native resolution, or images
62
+ // would be upscaled by the device and look worse than the web version.
63
+ test('the standard preset covers common e-ink panels at native resolution', () => {
64
+ const { standard } = SIZES;
65
+ for (const [width, height] of [
66
+ [1072, 1448], // Kobo Clara
67
+ [1236, 1648], // Kindle Paperwhite
68
+ ]) {
69
+ const fitted = fitWithin(width, height, standard.maxWidth, standard.maxHeight);
70
+ expect(fitted.width).toBeGreaterThanOrEqual(width);
71
+ expect(fitted.height).toBeGreaterThanOrEqual(height);
72
+ }
73
+ });
74
+ test('resolves a remote image URL unchanged', () => {
75
+ expect(resolveImageURL('https://example.com/a.jpg', '/base')).toBe('https://example.com/a.jpg');
76
+ });
77
+ // Compiled books are often served from a subdirectory, so a bare `images/x`
78
+ // would miss.
79
+ test('resolves a local image URL against the book base path', () => {
80
+ expect(resolveImageURL('a.jpg', '/ajko/books/design-methods')).toBe('/ajko/books/design-methods/images/a.jpg');
81
+ });
82
+ test('resolves a local image URL when the book is at the root', () => {
83
+ expect(resolveImageURL('a.jpg', '')).toBe('/images/a.jpg');
84
+ });
85
+ test('maps every item, in order, with results aligned to inputs', async () => {
86
+ const items = [1, 2, 3, 4, 5, 6, 7];
87
+ const doubled = await mapWithLimit(items, 3, async (n) => n * 2);
88
+ expect(doubled).toEqual([2, 4, 6, 8, 10, 12, 14]);
89
+ });
90
+ test('never exceeds the concurrency limit', async () => {
91
+ let running = 0;
92
+ let peak = 0;
93
+ await mapWithLimit(Array.from({ length: 20 }, (_, i) => i), 4, async () => {
94
+ running++;
95
+ peak = Math.max(peak, running);
96
+ await new Promise((resolve) => setTimeout(resolve, 1));
97
+ running--;
98
+ });
99
+ expect(peak).toBeLessThanOrEqual(4);
100
+ expect(peak).toBeGreaterThan(1);
101
+ });
102
+ test('handles fewer items than the limit', async () => {
103
+ expect(await mapWithLimit([1], 8, async (n) => n)).toEqual([1]);
104
+ });
105
+ test('handles no items at all', async () => {
106
+ expect(await mapWithLimit([], 4, async (n) => n)).toEqual([]);
107
+ });
108
+ // The rule that decides whether a crisp original may stay, or has to be
109
+ // resampled even though that can make the file bigger.
110
+ test('tolerates an image just past the budget', () => {
111
+ // 1600x1200 against Standard's 1280x1720: 1.25x on the long edge. Resizing
112
+ // line art this close to budget can double its size for no real gain.
113
+ expect(exceedsBudget(1600, 1200, SIZES.standard)).toBe(false);
114
+ });
115
+ test('does not tolerate an image well past the budget', () => {
116
+ expect(exceedsBudget(900, 900, SIZES.compact)).toBe(true);
117
+ expect(exceedsBudget(2400, 1600, SIZES.standard)).toBe(true);
118
+ });
119
+ test('measures the budget on whichever edge binds', () => {
120
+ // A panorama busts the width budget while sitting well inside the height.
121
+ expect(exceedsBudget(3000, 600, SIZES.standard)).toBe(true);
122
+ // A tall image busts the height budget while sitting inside the width.
123
+ expect(exceedsBudget(400, 2400, SIZES.compact)).toBe(true);
124
+ });
125
+ test('never asks to shrink an image that already fits', () => {
126
+ for (const size of Object.values(SIZES))
127
+ expect(exceedsBudget(size.maxWidth, size.maxHeight, size)).toBe(false);
128
+ expect(exceedsBudget(100, 100, SIZES.compact)).toBe(false);
129
+ });
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Assembles the documents that make up an EPUB 3 package.
3
+ *
4
+ * Nothing here touches the network or the DOM, so it can be unit tested and is
5
+ * safe to import during server rendering.
6
+ */
7
+ import type Edition from '../models/book/Edition';
8
+ import type Chapter from '../models/book/Chapter';
9
+ import { type SerializationContext } from './xhtml';
10
+ export declare const MIMETYPE = "application/epub+zip";
11
+ /** Where the package's documents live inside the archive. */
12
+ export declare const CONTENT_DIRECTORY = "OEBPS";
13
+ export declare const CONTAINER = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<container version=\"1.0\" xmlns=\"urn:oasis:names:tc:opendocument:xmlns:container\">\n<rootfiles>\n<rootfile full-path=\"OEBPS/content.opf\" media-type=\"application/oebps-package+xml\"/>\n</rootfiles>\n</container>";
14
+ /**
15
+ * The packaged stylesheet.
16
+ *
17
+ * Two rules govern it. First, it sets no typeface and no absolute size for
18
+ * prose: e-reader users choose their own, and overriding that is the most
19
+ * common way an EPUB becomes unpleasant to read. (Code is the one exception --
20
+ * code that doesn't look like code is just wrong.) The book's own theme is not
21
+ * carried across either, since it imports fonts an offline reader can't fetch
22
+ * and encodes color e-ink can't show.
23
+ *
24
+ * Second, and learned the hard way on an XTeink X3: it assumes the reading
25
+ * system has *no* default stylesheet. Small readers often have barely any, so
26
+ * anything left unsaid is left unrendered -- headings came out all one size,
27
+ * ordered lists came out bulleted, and a definition list ran together into a
28
+ * paragraph. Every element the serializer can emit is given its display,
29
+ * spacing and emphasis here rather than being assumed. The contract test in
30
+ * stylesheet.test.ts derives that element list from the serializer itself, so
31
+ * a new node type that emits a new element fails until this file covers it.
32
+ */
33
+ export declare const STYLESHEET = "/* Reading systems with a minimal default stylesheet render unknown elements\n inline, so every block element states that it is one. */\narticle, aside, details, div, dl, dd, dt, figcaption, figure, footer,\nheader, hgroup, main, nav, section, blockquote, p, h1, h2, h3, h4, h5, h6,\nhr, ol, ul, pre { display: block; }\nli { display: list-item; }\ntable { display: table; }\nthead { display: table-header-group; }\ntbody { display: table-row-group; }\ntr { display: table-row; }\nth, td { display: table-cell; }\ncaption { display: table-caption; }\n\nbody { margin: 0 5%; line-height: 1.5; widows: 2; orphans: 2; }\n\n/* An explicit scale, in em so the reader's chosen size still governs. Without\n it a reader with no defaults renders every level at body size. */\nh1, h2, h3, h4, h5, h6 {\n font-weight: bold;\n line-height: 1.2;\n margin: 1.2em 0 0.6em;\n page-break-after: avoid;\n break-after: avoid;\n}\nh1 { font-size: 1.6em; margin: 2em 0 1em; }\nh2 { font-size: 1.35em; }\nh3 { font-size: 1.2em; }\nh4 { font-size: 1.1em; }\nh5 { font-size: 1em; }\nh6 { font-size: 0.9em; }\n\np { margin: 0; text-indent: 1.2em; }\n/* The first paragraph of a section is not a continuation, so it isn't indented. */\np.first, h1 + p, h2 + p, h3 + p, h4 + p, h5 + p, h6 + p,\nblockquote p:first-child, li > p:first-child { text-indent: 0; }\n\nstrong { font-weight: bold; }\nem { font-style: italic; }\nsup { vertical-align: super; font-size: 0.75em; line-height: 0; }\nsub { vertical-align: sub; font-size: 0.75em; line-height: 0; }\na { text-decoration: underline; }\n\n.chapter-number { display: block; margin-bottom: 0.5em; text-transform: uppercase; letter-spacing: 0.1em; font-size: 0.8em; }\n.section-name { display: block; font-style: italic; }\n.authors { display: block; font-style: italic; }\n\nfigure { margin: 1.5em 0; text-align: center; page-break-inside: avoid; break-inside: avoid; }\nimg { max-width: 100%; }\nfigcaption { font-size: 0.85em; text-align: left; margin-top: 0.5em; }\nfigcaption p { margin: 0; text-indent: 0; }\n.credit { font-style: italic; }\n.missing-image figcaption { text-align: center; font-style: italic; }\n\nblockquote { margin: 1.5em 2em; font-style: italic; }\nblockquote p { text-indent: 0; }\ncite { font-style: normal; font-size: 0.85em; }\n\n.callout { margin: 1.5em 1em; padding-left: 1em; border-left: 3px solid currentColor; }\n.callout p { text-indent: 0; }\n\n/* The one place a family is set: monospace is what distinguishes code from\n prose, and without it the two are indistinguishable. */\npre, code, pre code, kbd, samp { font-family: monospace; }\npre { font-size: 0.8em; white-space: pre-wrap; overflow-wrap: break-word; margin: 1.5em 0; }\ncode { font-size: 0.9em; }\n\ntable { border-collapse: collapse; margin: 1.5em auto; font-size: 0.9em; }\nth, td { border: 1px solid currentColor; padding: 0.3em 0.5em; text-align: left; }\nth { font-weight: bold; }\ncaption { font-size: 0.85em; margin-bottom: 0.5em; }\ncaption p { margin: 0; text-indent: 0; }\n\n/* Outside, so a wrapped item hangs with its text aligned rather than tucking\n under the marker. Types are stated because a reader with no defaults numbers\n nothing, and mirror how the web reader nests them. */\nul, ol { margin: 1em 0; padding-left: 1.5em; list-style-position: outside; }\nul { list-style-type: disc; }\nul ul { list-style-type: circle; }\nul ul ul { list-style-type: square; }\nol { list-style-type: decimal; }\nol ol { list-style-type: lower-alpha; }\nol ol ol { list-style-type: upper-alpha; }\nli { margin-bottom: 0.25em; }\n\nhr { border: 0; border-top: 1px solid currentColor; margin: 2em auto; width: 30%; }\n\n.citation, .noteref { font-size: 0.75em; }\n.notes { margin-top: 3em; font-size: 0.9em; page-break-before: always; break-before: page; }\n.note { margin-bottom: 0.5em; }\n.note p { text-indent: 0; margin-bottom: 0.5em; }\n\n.reference { margin-bottom: 0.8em; }\n\n/* The glossary is paragraphs rather than a definition list: see\n glossaryDocument. Terms and meanings are distinguished by class. */\n.definition { margin-bottom: 1em; }\n.term { font-weight: bold; text-indent: 0; margin-top: 1em; }\n.meaning { text-indent: 0; margin-left: 1.5em; }\n.synonyms { font-size: 0.85em; font-style: italic; text-indent: 0; margin: 0.25em 0 0 1.5em; }\n";
34
+ /** One document in the package. */
35
+ export type Document = {
36
+ /** Path relative to the content directory. */
37
+ path: string;
38
+ /** Manifest id. */
39
+ id: string;
40
+ content: string;
41
+ /** Title as it should appear in the navigation, or undefined to omit it. */
42
+ navTitle?: string;
43
+ };
44
+ /** An image that made it into the package. */
45
+ export type PackagedImage = {
46
+ path: string;
47
+ id: string;
48
+ mediaType: string;
49
+ bytes: Uint8Array;
50
+ /** True for the book's cover image. */
51
+ cover?: boolean;
52
+ };
53
+ /** Chapters that get their own document, in reading order. */
54
+ export declare function includedChapters(edition: Edition): Chapter[];
55
+ /** Turn a title into something usable as a filename. */
56
+ export declare function safeFilename(title: string): string;
57
+ /** The title page, which carries the metadata a reader wants up front. */
58
+ export declare function titleDocument(edition: Edition, context: SerializationContext): Document;
59
+ /** A page that displays the cover image, which some readers expect. */
60
+ export declare function coverDocument(imagePath: string): Document;
61
+ /** One chapter, with its footnotes gathered into endnotes at the end. */
62
+ export declare function chapterDocument(edition: Edition, chapter: Chapter, context: SerializationContext): Document | undefined;
63
+ /** The references back matter, whose anchors the citations link to. */
64
+ export declare function referencesDocument(edition: Edition, context: SerializationContext): Document | undefined;
65
+ /** The glossary back matter, whose anchors the definitions link to. */
66
+ export declare function glossaryDocument(edition: Edition, context: SerializationContext): Document | undefined;
67
+ /** The EPUB 3 navigation document. */
68
+ export declare function navigationDocument(documents: Document[]): Document;
69
+ /**
70
+ * The EPUB 2 navigation document. Superseded by nav.xhtml, but older Kindle and
71
+ * Kobo firmware still looks for it, and it costs a few hundred bytes.
72
+ */
73
+ export declare function ncxDocument(identifier: string, title: string, documents: Document[]): string;
74
+ /** The package document: metadata, manifest, and reading order. */
75
+ export declare function packageDocument(edition: Edition, identifier: string, documents: Document[], images: PackagedImage[], modified: Date): string;