bookish-press 0.9.4 → 0.9.5
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/dist/components/page/DownloadEPUB.svelte +32 -2
- package/dist/components/page/TableOfContents.svelte +35 -21
- package/dist/epub/build.d.ts +6 -0
- package/dist/epub/build.js +16 -3
- package/dist/epub/build.test.js +65 -0
- package/dist/epub/images.d.ts +7 -5
- package/dist/epub/images.js +15 -10
- package/dist/epub/images.test.js +8 -6
- package/dist/epub/package.js +25 -3
- package/package.json +1 -1
|
@@ -5,6 +5,7 @@ import { DEFAULT_SIZE, SIZES } from "../../epub/images";
|
|
|
5
5
|
let edition = getEdition();
|
|
6
6
|
let base = getBase();
|
|
7
7
|
let sizeID = $state(DEFAULT_SIZE.id);
|
|
8
|
+
let grayscale = $state(false);
|
|
8
9
|
let building = $state(false);
|
|
9
10
|
let progress = $state(void 0);
|
|
10
11
|
let error = $state(void 0);
|
|
@@ -17,12 +18,19 @@ function release() {
|
|
|
17
18
|
url = void 0;
|
|
18
19
|
}
|
|
19
20
|
}
|
|
20
|
-
function
|
|
21
|
-
sizeID = id;
|
|
21
|
+
function invalidate() {
|
|
22
22
|
release();
|
|
23
23
|
warnings = [];
|
|
24
24
|
error = void 0;
|
|
25
25
|
}
|
|
26
|
+
function chooseSize(id) {
|
|
27
|
+
sizeID = id;
|
|
28
|
+
invalidate();
|
|
29
|
+
}
|
|
30
|
+
function chooseGrayscale(on) {
|
|
31
|
+
grayscale = on;
|
|
32
|
+
invalidate();
|
|
33
|
+
}
|
|
26
34
|
async function build() {
|
|
27
35
|
if ($edition === void 0 || building) return;
|
|
28
36
|
building = true;
|
|
@@ -34,6 +42,7 @@ async function build() {
|
|
|
34
42
|
const result = await buildEPUB($edition, {
|
|
35
43
|
base: $base ?? "",
|
|
36
44
|
size: SIZES[sizeID] ?? DEFAULT_SIZE,
|
|
45
|
+
grayscale,
|
|
37
46
|
onProgress: (update) => progress = update
|
|
38
47
|
});
|
|
39
48
|
url = URL.createObjectURL(result.blob);
|
|
@@ -76,6 +85,19 @@ onDestroy(release);
|
|
|
76
85
|
</select>
|
|
77
86
|
</span>
|
|
78
87
|
</label>
|
|
88
|
+
<label class="greyscale">
|
|
89
|
+
<input
|
|
90
|
+
type="checkbox"
|
|
91
|
+
checked={grayscale}
|
|
92
|
+
disabled={building}
|
|
93
|
+
onchange={(event) =>
|
|
94
|
+
chooseGrayscale(event.currentTarget.checked)}
|
|
95
|
+
/>
|
|
96
|
+
Greyscale
|
|
97
|
+
<span class="note"
|
|
98
|
+
>(a little smaller; e-readers convert anyway)</span
|
|
99
|
+
>
|
|
100
|
+
</label>
|
|
79
101
|
<!-- A plain button rather than the app's, whose --app-* variables are
|
|
80
102
|
only defined in the (app) layout, so it renders unstyled here and
|
|
81
103
|
in books compiled by bookish-reader. -->
|
|
@@ -226,6 +248,14 @@ onDestroy(release);
|
|
|
226
248
|
text-decoration: underline;
|
|
227
249
|
}
|
|
228
250
|
|
|
251
|
+
.greyscale {
|
|
252
|
+
gap: 0.35em;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
.greyscale .note {
|
|
256
|
+
color: var(--bookish-muted-color);
|
|
257
|
+
}
|
|
258
|
+
|
|
229
259
|
.warnings {
|
|
230
260
|
font-size: var(--bookish-small-font-size);
|
|
231
261
|
font-style: italic;
|
|
@@ -268,33 +268,41 @@ onMount(() => {
|
|
|
268
268
|
<Acknowledgements />
|
|
269
269
|
<License />
|
|
270
270
|
|
|
271
|
-
|
|
271
|
+
<!-- Both of these are ways of getting the book off the screen, so
|
|
272
|
+
neither belongs on a printed page: one points at the print view you
|
|
273
|
+
would already be looking at, and the other is a dropdown, a
|
|
274
|
+
checkbox and a button. -->
|
|
275
|
+
<div class="screen-only">
|
|
276
|
+
<EditableHeader id="print" label="Edit print header" />
|
|
272
277
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
278
|
+
<Instructions {editable}>
|
|
279
|
+
This link will generate the entire book on a single page,
|
|
280
|
+
suitable for printing.
|
|
281
|
+
</Instructions>
|
|
277
282
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
+
<PageParagraph>
|
|
284
|
+
Want to print this book or generate a PDF? See <Link
|
|
285
|
+
to="{$base}/print">all chapters on a single page</Link
|
|
286
|
+
> and then print or export. Long books can take some time to render.
|
|
287
|
+
</PageParagraph>
|
|
288
|
+
</div>
|
|
283
289
|
|
|
284
|
-
<
|
|
290
|
+
<div class="screen-only">
|
|
291
|
+
<EditableHeader id="epub" label="Edit e-book header" />
|
|
285
292
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
293
|
+
<Instructions {editable}>
|
|
294
|
+
This builds an EPUB in the reader's browser. Nothing is uploaded
|
|
295
|
+
and no server is involved.
|
|
296
|
+
</Instructions>
|
|
290
297
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
298
|
+
<PageParagraph>
|
|
299
|
+
Reading on an e-reader? Build an EPUB of this book. Images are
|
|
300
|
+
shrunk to fit the size you choose, so pick the one closest to
|
|
301
|
+
your device. Long books with many images can take a minute.
|
|
302
|
+
</PageParagraph>
|
|
296
303
|
|
|
297
|
-
|
|
304
|
+
<DownloadEPUB />
|
|
305
|
+
</div>
|
|
298
306
|
|
|
299
307
|
<EditableHeader id="citation" label="Edit citation header" />
|
|
300
308
|
|
|
@@ -370,4 +378,10 @@ onMount(() => {
|
|
|
370
378
|
.etc {
|
|
371
379
|
white-space: nowrap;
|
|
372
380
|
}
|
|
381
|
+
|
|
382
|
+
@media print {
|
|
383
|
+
.screen-only {
|
|
384
|
+
display: none;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
373
387
|
</style>
|
package/dist/epub/build.d.ts
CHANGED
|
@@ -25,6 +25,12 @@ export type BuildOptions = {
|
|
|
25
25
|
/** The book's base path, for resolving local image URLs. */
|
|
26
26
|
base?: string;
|
|
27
27
|
size?: ImageSize;
|
|
28
|
+
/**
|
|
29
|
+
* Discard color from every image. Off by default: it saves under a tenth of
|
|
30
|
+
* the bytes, and an e-ink reader desaturates on display anyway, so throwing
|
|
31
|
+
* colour away here only costs it on every other device.
|
|
32
|
+
*/
|
|
33
|
+
grayscale?: boolean;
|
|
28
34
|
onProgress?: (progress: BuildProgress) => void;
|
|
29
35
|
/** Fixed timestamp, so tests can compare output. */
|
|
30
36
|
modified?: Date;
|
package/dist/epub/build.js
CHANGED
|
@@ -27,7 +27,7 @@ export function identifierFor(edition) {
|
|
|
27
27
|
}
|
|
28
28
|
/** Build an EPUB for the given edition. */
|
|
29
29
|
export async function buildEPUB(edition, options = {}) {
|
|
30
|
-
const { base = '', size = DEFAULT_SIZE, onProgress, modified = new Date(), } = options;
|
|
30
|
+
const { base = '', size = DEFAULT_SIZE, grayscale = false, onProgress, modified = new Date(), } = options;
|
|
31
31
|
const warnings = [];
|
|
32
32
|
const chapters = includedChapters(edition);
|
|
33
33
|
// Collect every distinct image the book uses. getEmbeds walks the cover,
|
|
@@ -36,17 +36,30 @@ export async function buildEPUB(edition, options = {}) {
|
|
|
36
36
|
const embeds = new Map();
|
|
37
37
|
if (coverEmbed)
|
|
38
38
|
embeds.set(coverEmbed.getURL(), coverEmbed);
|
|
39
|
-
|
|
39
|
+
// Only the pages that actually make it into the package. getEmbeds() also
|
|
40
|
+
// returns header images for the index, search and media pages, and for
|
|
41
|
+
// forthcoming chapters, none of which are exported -- fetching and shrinking
|
|
42
|
+
// those costs the reader time and bytes for something never shown.
|
|
43
|
+
const included = new Set([
|
|
44
|
+
...chapters.map((chapter) => chapter.getID()),
|
|
45
|
+
'references',
|
|
46
|
+
'glossary',
|
|
47
|
+
]);
|
|
48
|
+
for (const { embed, chapterID } of edition
|
|
49
|
+
.getEmbeds()
|
|
50
|
+
.filter(({ chapterID }) => chapterID === undefined || included.has(chapterID))) {
|
|
51
|
+
void chapterID;
|
|
40
52
|
// Videos are links in the package, not files, and an embed with no URL
|
|
41
53
|
// is an empty placeholder the author hasn't filled in yet.
|
|
42
54
|
if (!embed.isVideo() && embed.getURL().trim().length > 0)
|
|
43
55
|
if (!embeds.has(embed.getURL()))
|
|
44
56
|
embeds.set(embed.getURL(), embed);
|
|
57
|
+
}
|
|
45
58
|
const urls = [...embeds.keys()];
|
|
46
59
|
onProgress?.({ phase: 'images', done: 0, total: urls.length });
|
|
47
60
|
let fetched = 0;
|
|
48
61
|
const prepared = await mapWithLimit(urls, IMAGE_CONCURRENCY, async (url) => {
|
|
49
|
-
const image = await prepareImage(resolveImageURL(url, base), size);
|
|
62
|
+
const image = await prepareImage(resolveImageURL(url, base), size, grayscale);
|
|
50
63
|
onProgress?.({
|
|
51
64
|
phase: 'images',
|
|
52
65
|
done: ++fetched,
|
package/dist/epub/build.test.js
CHANGED
|
@@ -451,3 +451,68 @@ test('every packaged image is referenced by a document', async () => {
|
|
|
451
451
|
const orphans = images.filter((image) => !markup.includes(`"${image}"`));
|
|
452
452
|
expect(orphans, `packaged but never referenced: ${orphans.join(', ')}`).toEqual([]);
|
|
453
453
|
});
|
|
454
|
+
test('a reference url that is not a URL does not become a broken link', async () => {
|
|
455
|
+
// Real books put bare DOIs, ISBNs and stray prose in the url field. Emitting
|
|
456
|
+
// those as an href produces a relative link that resolves against the
|
|
457
|
+
// package, which epubcheck reports as a missing resource.
|
|
458
|
+
const edition = makeEdition({
|
|
459
|
+
chapters: [chapter('one', 'One', 'Claims<doi><bare><isbn><prose>.')],
|
|
460
|
+
references: {
|
|
461
|
+
doi: ['A', '2020', 'Prefixed DOI', 'S', 'doi: 10.3390/s22134794'],
|
|
462
|
+
bare: ['B', '2020', 'Bare DOI', 'S', '10.21606/dma.2017.679'],
|
|
463
|
+
isbn: ['C', '2020', 'An ISBN', 'S', 'ISBN 9780128053904'],
|
|
464
|
+
prose: [
|
|
465
|
+
'D',
|
|
466
|
+
'2020',
|
|
467
|
+
'Prose',
|
|
468
|
+
'S',
|
|
469
|
+
' In Proceedings of CHI (pp. 1)',
|
|
470
|
+
],
|
|
471
|
+
},
|
|
472
|
+
});
|
|
473
|
+
const { entries } = await build(edition);
|
|
474
|
+
const references = textOf(entries, 'OEBPS/references.xhtml');
|
|
475
|
+
// Both DOI forms resolve through doi.org.
|
|
476
|
+
expect(references).toContain('href="https://doi.org/10.3390/s22134794"');
|
|
477
|
+
expect(references).toContain('href="https://doi.org/10.21606/dma.2017.679"');
|
|
478
|
+
// The others keep their titles but gain no link.
|
|
479
|
+
expect(references).toContain('An ISBN');
|
|
480
|
+
expect(references).toContain('Prose');
|
|
481
|
+
// No link in the reference list is relative; a relative one would resolve
|
|
482
|
+
// against the package and be reported as a missing resource.
|
|
483
|
+
for (const [, href] of references.matchAll(/<a href="([^"]+)"/g))
|
|
484
|
+
expect(href, `relative href: ${href}`).toMatch(/^(https?:|#)/);
|
|
485
|
+
});
|
|
486
|
+
test('images are not packaged for pages the export leaves out', async () => {
|
|
487
|
+
// getEmbeds() also returns header images for the index, search and media
|
|
488
|
+
// pages and for forthcoming chapters, none of which are exported. Fetching
|
|
489
|
+
// and shrinking those costs the reader bytes for something never shown.
|
|
490
|
+
const edition = makeEdition({
|
|
491
|
+
images: {
|
|
492
|
+
cover: '|cover.jpg|Cover|||',
|
|
493
|
+
index: '|indexhead.jpg|Index header|||',
|
|
494
|
+
search: '|searchhead.jpg|Search header|||',
|
|
495
|
+
media: '|mediahead.jpg|Media header|||',
|
|
496
|
+
},
|
|
497
|
+
chapters: [
|
|
498
|
+
chapter('one', 'One', 'Text.', {
|
|
499
|
+
image: '|onehead.jpg|One header|||',
|
|
500
|
+
}),
|
|
501
|
+
chapter('soon', 'Soon', '', {
|
|
502
|
+
forthcoming: true,
|
|
503
|
+
image: '|soonhead.jpg|Forthcoming header|||',
|
|
504
|
+
}),
|
|
505
|
+
],
|
|
506
|
+
});
|
|
507
|
+
await build(edition);
|
|
508
|
+
const fetched = vi.mocked(fetch).mock.calls.map((call) => String(call[0]));
|
|
509
|
+
expect(fetched.some((url) => url.includes('cover.jpg'))).toBe(true);
|
|
510
|
+
expect(fetched.some((url) => url.includes('onehead.jpg'))).toBe(true);
|
|
511
|
+
for (const skipped of [
|
|
512
|
+
'indexhead.jpg',
|
|
513
|
+
'searchhead.jpg',
|
|
514
|
+
'mediahead.jpg',
|
|
515
|
+
'soonhead.jpg',
|
|
516
|
+
])
|
|
517
|
+
expect(fetched.some((url) => url.includes(skipped)), `${skipped} was fetched for a page that is not exported`).toBe(false);
|
|
518
|
+
});
|
package/dist/epub/images.d.ts
CHANGED
|
@@ -20,14 +20,16 @@ export type ImageSize = {
|
|
|
20
20
|
maxHeight: number;
|
|
21
21
|
/** JPEG quality, 0 to 1. */
|
|
22
22
|
quality: number;
|
|
23
|
-
/** Whether to discard color. E-ink panels below ~1000px are greyscale. */
|
|
24
|
-
grayscale: boolean;
|
|
25
23
|
};
|
|
26
24
|
/**
|
|
27
25
|
* The presets offered to readers. Standard covers the common 6-7" e-ink panels
|
|
28
26
|
* at native resolution (Kobo Clara 1072x1448, Kindle Paperwhite 1236x1648);
|
|
29
|
-
* Compact targets pocket-sized
|
|
30
|
-
*
|
|
27
|
+
* Compact targets pocket-sized readers; Large leaves room to zoom into diagrams
|
|
28
|
+
* on a tablet.
|
|
29
|
+
*
|
|
30
|
+
* A preset governs how large an image may be, not what colour it is. Discarding
|
|
31
|
+
* colour is a separate choice, because an EPUB is read on whatever device is to
|
|
32
|
+
* hand and colour thrown away here cannot be recovered there.
|
|
31
33
|
*/
|
|
32
34
|
export declare const SIZES: Record<string, ImageSize>;
|
|
33
35
|
export declare const DEFAULT_SIZE: ImageSize;
|
|
@@ -71,7 +73,7 @@ export declare function resolveImageURL(url: string, base: string): string;
|
|
|
71
73
|
* or that are simply gone. Callers report those as warnings and drop the image,
|
|
72
74
|
* keeping its caption.
|
|
73
75
|
*/
|
|
74
|
-
export declare function prepareImage(url: string, size: ImageSize): Promise<PreparedImage | undefined>;
|
|
76
|
+
export declare function prepareImage(url: string, size: ImageSize, grayscale?: boolean): Promise<PreparedImage | undefined>;
|
|
75
77
|
/**
|
|
76
78
|
* Run tasks with a bounded number in flight, so a book with a hundred images
|
|
77
79
|
* doesn't open a hundred connections at once.
|
package/dist/epub/images.js
CHANGED
|
@@ -13,8 +13,12 @@
|
|
|
13
13
|
/**
|
|
14
14
|
* The presets offered to readers. Standard covers the common 6-7" e-ink panels
|
|
15
15
|
* at native resolution (Kobo Clara 1072x1448, Kindle Paperwhite 1236x1648);
|
|
16
|
-
* Compact targets pocket-sized
|
|
17
|
-
*
|
|
16
|
+
* Compact targets pocket-sized readers; Large leaves room to zoom into diagrams
|
|
17
|
+
* on a tablet.
|
|
18
|
+
*
|
|
19
|
+
* A preset governs how large an image may be, not what colour it is. Discarding
|
|
20
|
+
* colour is a separate choice, because an EPUB is read on whatever device is to
|
|
21
|
+
* hand and colour thrown away here cannot be recovered there.
|
|
18
22
|
*/
|
|
19
23
|
export const SIZES = {
|
|
20
24
|
compact: {
|
|
@@ -23,7 +27,6 @@ export const SIZES = {
|
|
|
23
27
|
maxWidth: 480,
|
|
24
28
|
maxHeight: 640,
|
|
25
29
|
quality: 0.7,
|
|
26
|
-
grayscale: true,
|
|
27
30
|
},
|
|
28
31
|
standard: {
|
|
29
32
|
id: 'standard',
|
|
@@ -33,7 +36,6 @@ export const SIZES = {
|
|
|
33
36
|
maxWidth: 1280,
|
|
34
37
|
maxHeight: 1720,
|
|
35
38
|
quality: 0.8,
|
|
36
|
-
grayscale: false,
|
|
37
39
|
},
|
|
38
40
|
large: {
|
|
39
41
|
id: 'large',
|
|
@@ -41,7 +43,6 @@ export const SIZES = {
|
|
|
41
43
|
maxWidth: 1600,
|
|
42
44
|
maxHeight: 2200,
|
|
43
45
|
quality: 0.85,
|
|
44
|
-
grayscale: false,
|
|
45
46
|
},
|
|
46
47
|
};
|
|
47
48
|
export const DEFAULT_SIZE = SIZES.standard;
|
|
@@ -110,7 +111,7 @@ const FETCH_TIMEOUT_MS = 20000;
|
|
|
110
111
|
* or that are simply gone. Callers report those as warnings and drop the image,
|
|
111
112
|
* keeping its caption.
|
|
112
113
|
*/
|
|
113
|
-
export async function prepareImage(url, size) {
|
|
114
|
+
export async function prepareImage(url, size, grayscale = false) {
|
|
114
115
|
let bytes;
|
|
115
116
|
let mediaType;
|
|
116
117
|
try {
|
|
@@ -134,7 +135,7 @@ export async function prepareImage(url, size) {
|
|
|
134
135
|
// wasteful and unreliable across browsers.
|
|
135
136
|
if (mediaType === 'image/svg+xml')
|
|
136
137
|
return passThrough(bytes, mediaType);
|
|
137
|
-
const reencoded = await reencode(bytes, mediaType, size);
|
|
138
|
+
const reencoded = await reencode(bytes, mediaType, size, grayscale);
|
|
138
139
|
if (reencoded === undefined)
|
|
139
140
|
return passThrough(bytes, mediaType);
|
|
140
141
|
// Keep whichever is smaller, since re-encoding can inflate a file: resizing
|
|
@@ -142,8 +143,12 @@ export async function prepareImage(url, size) {
|
|
|
142
143
|
// be larger than the crisp original. But an image far past the budget is
|
|
143
144
|
// shrunk regardless, because decoding it costs a small reader memory it may
|
|
144
145
|
// not have, and a few extra kilobytes are the cheaper price.
|
|
146
|
+
//
|
|
147
|
+
// Greyscale overrides the size comparison too: it was asked for, and an
|
|
148
|
+
// image small enough to pass through untouched would otherwise come out in
|
|
149
|
+
// colour while everything around it was grey.
|
|
145
150
|
const { oversize, ...prepared } = reencoded;
|
|
146
|
-
return oversize || prepared.bytes.length < bytes.length
|
|
151
|
+
return oversize || grayscale || prepared.bytes.length < bytes.length
|
|
147
152
|
? prepared
|
|
148
153
|
: passThrough(bytes, mediaType);
|
|
149
154
|
}
|
|
@@ -153,7 +158,7 @@ export async function prepareImage(url, size) {
|
|
|
153
158
|
* size. Returns undefined if the platform lacks the canvas APIs or the bytes
|
|
154
159
|
* don't decode.
|
|
155
160
|
*/
|
|
156
|
-
async function reencode(bytes, mediaType, size) {
|
|
161
|
+
async function reencode(bytes, mediaType, size, grayscale) {
|
|
157
162
|
if (typeof createImageBitmap === 'undefined' ||
|
|
158
163
|
typeof document === 'undefined')
|
|
159
164
|
return undefined;
|
|
@@ -173,7 +178,7 @@ async function reencode(bytes, mediaType, size) {
|
|
|
173
178
|
if (context === null)
|
|
174
179
|
return undefined;
|
|
175
180
|
context.drawImage(bitmap, 0, 0, width, height);
|
|
176
|
-
if (
|
|
181
|
+
if (grayscale)
|
|
177
182
|
desaturate(context, width, height);
|
|
178
183
|
// PNG first, while any transparency is still intact.
|
|
179
184
|
const png = await toBlob(canvas, 'image/png');
|
package/dist/epub/images.test.js
CHANGED
|
@@ -34,17 +34,12 @@ test('treats a degenerate image as empty rather than dividing by zero', () => {
|
|
|
34
34
|
expect(fitWithin(0, 0, 1200, 1600)).toEqual({ width: 0, height: 0 });
|
|
35
35
|
expect(fitWithin(-5, 10, 1200, 1600)).toEqual({ width: 0, height: 0 });
|
|
36
36
|
});
|
|
37
|
-
test('every preset is smaller than the next
|
|
37
|
+
test('every preset is smaller than the next', () => {
|
|
38
38
|
const { compact, standard, large } = SIZES;
|
|
39
39
|
expect(compact.maxWidth).toBeLessThan(standard.maxWidth);
|
|
40
40
|
expect(standard.maxWidth).toBeLessThan(large.maxWidth);
|
|
41
41
|
expect(compact.maxHeight).toBeLessThan(standard.maxHeight);
|
|
42
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
43
|
for (const size of Object.values(SIZES)) {
|
|
49
44
|
expect(size.quality).toBeGreaterThan(0);
|
|
50
45
|
expect(size.quality).toBeLessThanOrEqual(1);
|
|
@@ -127,3 +122,10 @@ test('never asks to shrink an image that already fits', () => {
|
|
|
127
122
|
expect(exceedsBudget(size.maxWidth, size.maxHeight, size)).toBe(false);
|
|
128
123
|
expect(exceedsBudget(100, 100, SIZES.compact)).toBe(false);
|
|
129
124
|
});
|
|
125
|
+
test('a preset says nothing about colour', () => {
|
|
126
|
+
// Colour is a separate choice: a preset is about how large an image may be,
|
|
127
|
+
// and an EPUB is read on whatever device is to hand, so colour discarded
|
|
128
|
+
// here cannot be recovered there.
|
|
129
|
+
for (const size of Object.values(SIZES))
|
|
130
|
+
expect(size).not.toHaveProperty('grayscale');
|
|
131
|
+
});
|
package/dist/epub/package.js
CHANGED
|
@@ -237,6 +237,27 @@ ${authors.length > 0
|
|
|
237
237
|
: chapter.getTitle(),
|
|
238
238
|
};
|
|
239
239
|
}
|
|
240
|
+
/**
|
|
241
|
+
* A reference's url field as an absolute address, or undefined if it isn't one.
|
|
242
|
+
*
|
|
243
|
+
* Real books put all sorts of things in that field: a bare DOI, a DOI with a
|
|
244
|
+
* "doi:" prefix, an ISBN, sometimes a fragment of the citation itself. Emitting
|
|
245
|
+
* those as an href produces a *relative* link that resolves against the package
|
|
246
|
+
* and fails validation, so only something that can be made absolute is linked.
|
|
247
|
+
*/
|
|
248
|
+
function referenceURL(url) {
|
|
249
|
+
const value = String(url ?? '').trim();
|
|
250
|
+
if (value.length === 0)
|
|
251
|
+
return undefined;
|
|
252
|
+
if (/^https?:\/\//i.test(value))
|
|
253
|
+
return encodeURI(value);
|
|
254
|
+
// A DOI is unambiguous and resolvable, so it's worth turning into a link.
|
|
255
|
+
const doi = value.replace(/^doi:\s*/i, '').trim();
|
|
256
|
+
if (/^10\.\d{4,9}\/\S+$/.test(doi))
|
|
257
|
+
return `https://doi.org/${encodeURI(doi)}`;
|
|
258
|
+
// An ISBN, a stray sentence, anything else: render it as text, not a link.
|
|
259
|
+
return undefined;
|
|
260
|
+
}
|
|
240
261
|
/** Render a reference the way the web reader's Reference.svelte does. */
|
|
241
262
|
function referenceContent(reference, context) {
|
|
242
263
|
if (reference instanceof FormatNode)
|
|
@@ -244,9 +265,10 @@ function referenceContent(reference, context) {
|
|
|
244
265
|
const title = escapeText(reference.title);
|
|
245
266
|
// The web reader omits the period when the title already ends in one.
|
|
246
267
|
const period = reference.title.trim().endsWith('?') ? '' : '.';
|
|
247
|
-
return `${escapeText(reference.authors)} (${escapeText(reference.year)}). ${
|
|
248
|
-
|
|
249
|
-
|
|
268
|
+
return `${escapeText(reference.authors)} (${escapeText(reference.year)}). ${(() => {
|
|
269
|
+
const href = referenceURL(reference.url);
|
|
270
|
+
return href ? `<a href="${escapeAttribute(href)}">${title}</a>` : title;
|
|
271
|
+
})()}${period} <em>${escapeText(reference.source)}</em>${reference.summary ? ` ${escapeText(reference.summary)}` : ''}`;
|
|
250
272
|
}
|
|
251
273
|
/** The references back matter, whose anchors the citations link to. */
|
|
252
274
|
export function referencesDocument(edition, context) {
|