bookish-press 0.9.3 → 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/README.md CHANGED
@@ -50,6 +50,22 @@ The content lives in `scripts/demo-book.js` and the images are drawn by `scripts
50
50
 
51
51
  It uses the offset emulator ports from `firebase.test.json`, so it doesn't collide with another project's emulators on the default ports, and it builds the app rather than running `vite dev` — the reader route's server render pulls the chapter model through a module cycle that Vite's dev SSR evaluator can't order, though the production build resolves it fine.
52
52
 
53
+ ## Storage CORS
54
+
55
+ Book images live in Firebase Storage and are fetched by the browser when
56
+ building an EPUB, which requires the bucket to send CORS headers. It does not
57
+ by default, and without them every image is silently dropped from the export.
58
+ `cors.json` holds the policy; apply it to a project's bucket with:
59
+
60
+ ```
61
+ gcloud storage buckets update gs://<project>.appspot.com --cors-file=cors.json --project <project>
62
+ ```
63
+
64
+ It is applied to both `bookish-prod` and `bookish-dev-21ac3`. The objects are
65
+ already world-readable — the thumbnail function calls `makePublic()` on upload —
66
+ so allowing `GET` from any origin grants nothing that a plain HTTP request
67
+ couldn't already do.
68
+
53
69
  ## Deployment notes
54
70
 
55
71
  There are two major components to Bookish: the authoring platform and the reading front end. Changes to the authoring platform that do not affect reading can be verified and deployed to Firebase without any other coordination. However, changes to the reading experience have downstream dependencies that need to be managed. Here's the general deployment workflow, which I'm currently using as reminders for myself:
@@ -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 chooseSize(id) {
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
- <EditableHeader id="print" label="Edit print header" />
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
- <Instructions {editable}>
274
- This link will generate the entire book on a single page, suitable
275
- for printing.
276
- </Instructions>
278
+ <Instructions {editable}>
279
+ This link will generate the entire book on a single page,
280
+ suitable for printing.
281
+ </Instructions>
277
282
 
278
- <PageParagraph>
279
- Want to print this book or generate a PDF? See <Link
280
- to="{$base}/print">all chapters on a single page</Link
281
- > and then print or export. Long books can take some time to render.
282
- </PageParagraph>
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
- <EditableHeader id="epub" label="Edit e-book header" />
290
+ <div class="screen-only">
291
+ <EditableHeader id="epub" label="Edit e-book header" />
285
292
 
286
- <Instructions {editable}>
287
- This builds an EPUB in the reader's browser. Nothing is uploaded and
288
- no server is involved.
289
- </Instructions>
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
- <PageParagraph>
292
- Reading on an e-reader? Build an EPUB of this book. Images are
293
- shrunk to fit the size you choose, so pick the one closest to your
294
- device. Long books with many images can take a minute.
295
- </PageParagraph>
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
- <DownloadEPUB />
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>
@@ -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;
@@ -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
- for (const { embed } of edition.getEmbeds())
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,
@@ -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
+ });
@@ -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 greyscale readers; Large leaves room to zoom
30
- * into diagrams on a tablet.
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.
@@ -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 greyscale readers; Large leaves room to zoom
17
- * into diagrams on a tablet.
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 (size.grayscale)
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');
@@ -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 and shaped for its device', () => {
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
+ });
@@ -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)}). ${reference.url
248
- ? `<a href="${escapeAttribute(encodeURI(String(reference.url)))}">${title}</a>`
249
- : title}${period} <em>${escapeText(reference.source)}</em>${reference.summary ? ` ${escapeText(reference.summary)}` : ''}`;
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) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "bookish-press",
3
3
  "private": false,
4
- "version": "0.9.3",
4
+ "version": "0.9.5",
5
5
  "type": "module",
6
6
  "description": "A platform for reading and writing readable, accessible, beautiful online books.",
7
7
  "license": "MIT",