book-cover-generator 1.8.0 → 1.9.1

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
@@ -10,7 +10,7 @@
10
10
 
11
11
  Generate book covers from a **title** and an **author**. 39 styles, 28 palettes or your own colours, a seeded randomizer, any size, and export to **SVG, PNG, JPEG or WebP**. The library has no dependencies and ships TypeScript types. It's written as plain ES modules, so it runs in the browser, Node, Deno and Bun.
12
12
 
13
- **[▶ Open the generator](https://covers.otsirh.dev)**
13
+ **[▶ Open the generator](https://covers.otsirh.dev)** · [See every style](https://covers.otsirh.dev/gallery/)
14
14
 
15
15
  ![Sample covers](docs/preview.png)
16
16
 
@@ -24,7 +24,7 @@ Generate book covers from a **title** and an **author**. 39 styles, 28 palettes
24
24
  - Geometric: Bauhaus, Split, Blocks, Concentric, Stripes.
25
25
  - Pattern: Paper Cut, Halftone, Topographic, Flow Field, Low Poly, Mosaic, Memphis, Pixel.
26
26
  - **28 palettes** (15 light, 13 dark), or pass your own. Every style adapts to light and dark palettes.
27
- - **Typography that responds to the title.** Short titles are set large, a leading "The" or "A" becomes a small line above the title, and some styles stack the words edge to edge. Long titles wrap with balanced lines and shrink to fit. Author names stay readable at thumbnail size. Accents, emoji, Chinese, Japanese and Thai are handled.
27
+ - **Typography that responds to the title.** Short titles are set large, a leading "The" (or another article of three letters or more) becomes a small line above the title, and some styles stack the words edge to edge. Long titles wrap with balanced lines and shrink to fit. Author names stay readable at thumbnail size. Accents, emoji, Chinese, Japanese and Thai are handled.
28
28
  - **Subtitle or tagline**, and an optional **paper texture**.
29
29
  - **Randomizer.** Every random choice comes from a seed, so the same seed always gives the same cover. If you don't pass a seed, it is derived from the title and author.
30
30
  - **Any size.** 2:3 paperback, 5:8 eBook, 1:1 audiobook, or any width × height from 50 to 10,000 px with an aspect ratio between 1:3 and 3:1, such as Kindle's 1600 × 2560 or a print trim size at 300 dpi.
@@ -93,7 +93,7 @@ One script, then a cover wherever you write the element:
93
93
  <book-cover cover-title="Dune" author="Frank Herbert" cover-style="retro" seed="1"></book-cover>
94
94
  ```
95
95
 
96
- Its attributes are the app's link parameters, except the title and the style, which are `cover-title` and `cover-style` (`title` and `style` are HTML's own): `cover-title`, `author`, `subtitle`, `series`, `credit`, `badge`, `cover-style`, `genre`, `palette`, `seed`, `format`, `font`, `case`, `variant`, `texture` (present: on) and `lang` (the language of the text, such as `zh-Hant`). Styles, palettes and fonts may be named as the app's menus name them. The cover is 240 px wide unless you style the element's `width`; it draws again when an attribute changes, and fires `cover` (with the cover) or `cover-error` (with what's wrong). It is drawn in the page, measuring the text with the fonts installed there, and shown as an image, so a page with Trusted Types can show it (its CSP must allow `data:` images). An image can't use the page's web fonts: if the page loads one that a cover's font stack names (such as EB Garamond or Oswald), the text is measured in it but drawn in the system's font, and may fit less well. From npm: `import 'book-cover-generator/element'`.
96
+ Its attributes are the app's link parameters, except the title and the style, which are `cover-title` and `cover-style` (`title` and `style` are HTML's own): `cover-title`, `author`, `subtitle`, `series`, `credit`, `badge`, `cover-style`, `genre`, `palette`, `seed`, `format`, `font`, `case`, `variant`, `texture` (present: on) and `lang` (the language of the text, such as `zh-Hant`). Styles, palettes and fonts may be named as the app's menus name them. With `loading="lazy"`, as on an image, a cover draws only once it comes near the viewport (a page of many covers loads faster); until then it holds the cover's shape and is named for screen readers, and attributes changed meanwhile apply when it draws. The cover is 240 px wide unless you style the element's `width`; it draws again when an attribute changes, and fires `cover` (with the cover) or `cover-error` (with what's wrong). It is drawn in the page, measuring the text with the fonts installed there, and shown as an image, so a page with Trusted Types can show it (its CSP must allow `data:` images). An image can't use the page's web fonts: if the page loads one that a cover's font stack names (such as EB Garamond or Oswald), the text is measured in it but drawn in the system's font, and may fit less well. From npm: `import 'book-cover-generator/element'`.
97
97
 
98
98
  ## From an AI assistant (MCP)
99
99
 
package/bin/book-cover.js CHANGED
@@ -6,7 +6,7 @@ import { parseArgs } from 'node:util';
6
6
  import { createCover, listStyles, palettes, parseBookList, optionsForBook, genres } from '../src/index.js';
7
7
  import { hex, slug } from '../src/shared.js';
8
8
  import { px, formatSize } from '../src/link.js';
9
- import { handlePayload } from '../src/mcp.js';
9
+ import { answerPayload } from '../src/mcp.js';
10
10
 
11
11
  const HELP = `Usage: book-cover "<title>" ["<author>"] [options]
12
12
 
@@ -349,9 +349,9 @@ function serveMcp() {
349
349
  writeFileSync(file, data);
350
350
  return file;
351
351
  };
352
- /** @param {unknown} message */
353
- const answer = (message) => {
354
- if (message) process.stdout.write(`${JSON.stringify(message)}\n`);
352
+ /** @param {string | null} text */
353
+ const send = (text) => {
354
+ if (text) process.stdout.write(`${text}\n`);
355
355
  };
356
356
  lines.on('line', (line) => {
357
357
  if (!line.trim()) return;
@@ -359,10 +359,10 @@ function serveMcp() {
359
359
  try {
360
360
  message = JSON.parse(line);
361
361
  } catch {
362
- answer({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } });
362
+ send(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }));
363
363
  return;
364
364
  }
365
- answer(handlePayload(message, { writeFile }));
365
+ send(answerPayload(message, { writeFile }));
366
366
  });
367
367
  }
368
368
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "book-cover-generator",
3
- "version": "1.8.0",
3
+ "version": "1.9.1",
4
4
  "description": "Generate SVG book covers from a title and an author. 39 styles, 28 palettes, seeded randomizer, zero dependencies.",
5
5
  "keywords": [
6
6
  "book",
package/src/browser.js CHANGED
@@ -1,3 +1,4 @@
1
+ /* @ts-self-types="./browser.d.ts" */
1
2
  // Browser helpers for exporting covers: copy, download, rasterise, zip. (`setDpi` and
2
3
  // `zipFiles` also work in Node.)
3
4
 
package/src/core.d.ts CHANGED
@@ -20,7 +20,7 @@ export declare function clamp(v: number, lo: number, hi: number): number;
20
20
  export declare function lerp(a: number, b: number, t: number): number;
21
21
  export declare function smoothstep(e0: number, e1: number, x: number): number;
22
22
 
23
- export declare function isHex(v: unknown): v is string;
23
+ export declare function isHex(v: unknown): boolean;
24
24
  export declare function hexToRgb(hex: string): [number, number, number];
25
25
  export declare function rgbToHex(rgb: readonly number[]): string;
26
26
  /** Linear blend of two hex colours; `t = 0` is `a`, `t = 1` is `b`. */
package/src/core.js CHANGED
@@ -1,3 +1,4 @@
1
+ /* @ts-self-types="./core.d.ts" */
1
2
  // Core utilities: seeded randomness, colour math, noise and SVG helpers.
2
3
 
3
4
  /** 32-bit string hash (cyrb53 variant folded to 32 bits). */
@@ -0,0 +1,19 @@
1
+ import type { CoverOptions, createCover } from './index.js';
2
+
3
+ /**
4
+ * `<book-cover>`: a cover on any page, from its attributes (cover-title, author, subtitle, series,
5
+ * credit, badge, cover-style, genre, palette, seed, format, font, case, texture, variant, lang). It
6
+ * fires `cover` (detail: the cover) when it draws, and `cover-error` (detail: the error) when the
7
+ * attributes can't make one. With `loading="lazy"` it draws once it comes near the viewport, as an
8
+ * image does; until then it holds the cover's shape and is named for screen readers.
9
+ *
10
+ * (The class alone: Deno and JSR use these types, as a package there may not declare global ones.
11
+ * element.d.ts adds the tag to `document.createElement`'s types for TypeScript from npm.)
12
+ */
13
+ export declare class BookCover extends HTMLElement {
14
+ static observedAttributes: string[];
15
+ /** The cover drawn last, or null (before the first, or after an error). */
16
+ cover: ReturnType<typeof createCover> | null;
17
+ /** The cover's options, read from the attributes as the HTTP API reads a link. */
18
+ options(): CoverOptions;
19
+ }
package/src/element.d.ts CHANGED
@@ -1,18 +1,8 @@
1
- import type { CoverOptions, createCover } from './index.js';
1
+ // The types of book-cover-generator/element from npm: the class, and the tag in
2
+ // `document.createElement('book-cover')`'s types.
3
+ import { BookCover } from './element-types.js';
2
4
 
3
- /**
4
- * `<book-cover>`: a cover on any page, from its attributes (cover-title, author, subtitle, series,
5
- * credit, badge, cover-style, genre, palette, seed, format, font, case, texture, variant, lang). It
6
- * fires `cover` (detail: the cover) when it draws, and `cover-error` (detail: the error) when the
7
- * attributes can't make one.
8
- */
9
- export declare class BookCover extends HTMLElement {
10
- static observedAttributes: string[];
11
- /** The cover drawn last, or null (before the first, or after an error). */
12
- cover: ReturnType<typeof createCover> | null;
13
- /** The cover's options, read from the attributes as the HTTP API reads a link. */
14
- options(): CoverOptions;
15
- }
5
+ export { BookCover };
16
6
 
17
7
  declare global {
18
8
  interface HTMLElementTagNameMap {
package/src/element.js CHANGED
@@ -1,3 +1,4 @@
1
+ /* @ts-self-types="./element-types.d.ts" */
1
2
  // <book-cover>: a cover on any page, from its attributes.
2
3
  //
3
4
  // <script type="module" src="https://covers.otsirh.dev/src/element.js"></script>
@@ -6,7 +7,8 @@
6
7
  // Attributes: cover-title and cover-style (`title` and `style` are HTML's own: a tooltip, the
7
8
  // element's CSS), author, subtitle, series, credit, badge, genre, palette, seed, format (paperback,
8
9
  // ebook, square or 1600x2560), font, case, texture (present: on), variant (as in the app's links:
9
- // scene.forest) and lang (the language of the text, as on any element). The cover
10
+ // scene.forest) and lang (the language of the text, as on any element). With loading="lazy" it
11
+ // draws once it comes near the viewport, as an image does. The cover
10
12
  // is drawn as an image (so pages with Trusted Types can show it: the page's CSP must allow data:
11
13
  // images), sized by the element's width (240px unless styled).
12
14
 
@@ -36,6 +38,15 @@ const ATTRIBUTES = /** @type {const} */ ({
36
38
  const CSS =
37
39
  ':host{display:inline-block;width:240px;vertical-align:top}:host([hidden]){display:none}' +
38
40
  'img{display:block;width:100%;height:auto}';
41
+
42
+ /**
43
+ * How an image of a cover is named: "Cover: Dune by Frank Herbert" (with no title, "Untitled", as
44
+ * the cover says). @param {string} title @param {string} author
45
+ */
46
+ const altFor = (title, author) => {
47
+ const [t, a] = [title, author].map((s) => s.replace(/\s+/g, ' ').trim());
48
+ return `Cover: ${t || 'Untitled'}${a ? ` by ${a}` : ''}`;
49
+ };
39
50
  /** The element's styles as a constructed sheet: a page whose CSP allows no inline styles still applies it. */
40
51
  const sheet =
41
52
  typeof CSSStyleSheet !== 'undefined' && 'replaceSync' in CSSStyleSheet.prototype
@@ -50,7 +61,7 @@ const sheet =
50
61
  const Base = /** @type {typeof HTMLElement} */ (typeof HTMLElement === 'undefined' ? class {} : HTMLElement);
51
62
 
52
63
  export class BookCover extends Base {
53
- static observedAttributes = [...Object.keys(ATTRIBUTES), 'texture'];
64
+ static observedAttributes = [...Object.keys(ATTRIBUTES), 'texture', 'loading'];
54
65
 
55
66
  /** The cover drawn last (null before the first, or after an error). @type {ReturnType<typeof createCover> | null} */
56
67
  cover = null;
@@ -61,6 +72,24 @@ export class BookCover extends Base {
61
72
  #ready = false;
62
73
  /** The options last drawn, to skip drawing them again. */
63
74
  #drawn = '';
75
+ /** A lazy element waiting to come into view. */
76
+ #waiting = false;
77
+ /** Lazy elements wait for this to see them near the viewport (none outside a browser). */
78
+ static #observer =
79
+ typeof IntersectionObserver === 'undefined'
80
+ ? null
81
+ : new IntersectionObserver(
82
+ (entries) => {
83
+ for (const entry of entries) {
84
+ if (!entry.isIntersecting) continue;
85
+ const el = /** @type {BookCover} */ (entry.target);
86
+ BookCover.#observer?.unobserve(el);
87
+ el.#waiting = false;
88
+ el.#render();
89
+ }
90
+ },
91
+ { rootMargin: '300px' },
92
+ );
64
93
 
65
94
  constructor() {
66
95
  super();
@@ -79,10 +108,48 @@ export class BookCover extends Base {
79
108
 
80
109
  connectedCallback() {
81
110
  this.#ready = true;
111
+ // loading="lazy", as on an image: drawn once it comes near the viewport.
112
+ if (this.#lazy() && !this.cover && BookCover.#observer) {
113
+ this.#waiting = true;
114
+ this.#hold();
115
+ BookCover.#observer.observe(this);
116
+ return;
117
+ }
82
118
  this.#render();
83
119
  }
84
120
 
85
- attributeChangedCallback() {
121
+ /** Until it is drawn: named from its attributes, and the cover's own shape held for it. */
122
+ #hold() {
123
+ this.#img.alt = altFor(this.getAttribute('cover-title') ?? '', this.getAttribute('author') ?? '');
124
+ try {
125
+ const { width = 600, height = width * 1.5 } = this.options();
126
+ this.#img.style.aspectRatio = `${width} / ${height}`;
127
+ } catch {
128
+ this.#img.style.aspectRatio = '2 / 3';
129
+ }
130
+ }
131
+
132
+ disconnectedCallback() {
133
+ if (this.#waiting) BookCover.#observer?.unobserve(this);
134
+ this.#waiting = false;
135
+ }
136
+
137
+ /** Whether it waits to come near the viewport (loading="lazy", in any case, as on an image). */
138
+ #lazy() {
139
+ return (this.getAttribute('loading') ?? '').toLowerCase() === 'lazy';
140
+ }
141
+
142
+ attributeChangedCallback(/** @type {string} */ name) {
143
+ // A waiting cover no longer lazy is drawn at once, as an image is.
144
+ if (name === 'loading' && this.#waiting && !this.#lazy()) {
145
+ BookCover.#observer?.unobserve(this);
146
+ this.#waiting = false;
147
+ }
148
+ // A cover still waiting: its name and shape follow the attributes.
149
+ if (this.#waiting) {
150
+ this.#hold();
151
+ return;
152
+ }
86
153
  // (Several attributes set at once draw once; an element upgraded on the page draws once, when
87
154
  // it connects.)
88
155
  if (!this.#ready || this.#queued || !this.isConnected) return;
@@ -115,9 +182,9 @@ export class BookCover extends Base {
115
182
  this.#drawn = key;
116
183
  this.cover = createCover(options);
117
184
  this.#img.src = svgToDataUrl(this.cover.svg);
118
- this.#img.alt = this.cover.author
119
- ? `Cover: ${this.cover.title.replace(/\n/g, ' ')} by ${this.cover.author}`
120
- : `Cover: ${this.cover.title.replace(/\n/g, ' ')}`;
185
+ this.#img.alt = altFor(this.cover.title, this.cover.author);
186
+ // (Its shape held while the image decodes, so nothing below it moves.)
187
+ this.#img.style.aspectRatio = `${this.cover.width} / ${this.cover.height}`;
121
188
  this.dispatchEvent(new CustomEvent('cover', { detail: this.cover }));
122
189
  } catch (err) {
123
190
  this.cover = null;
package/src/index.d.ts CHANGED
@@ -175,7 +175,7 @@ export interface Cover {
175
175
  body: string;
176
176
  /** The style that was used. */
177
177
  style: string;
178
- /** The palette that was used (`'custom'` for a custom palette). */
178
+ /** The palette that was used: its id, or for a custom palette its own `id` (`'custom'` if it has none). */
179
179
  palette: string;
180
180
  /** The palette's colours, as the cover used them. */
181
181
  colors: { bg: string; ink: string; accents: string[]; dark: boolean };
@@ -228,7 +228,10 @@ export interface ResolvedPalette extends Palette {
228
228
  export interface StyleContext {
229
229
  readonly title: string;
230
230
  readonly author: string;
231
- /** Empty when there is no subtitle. A credit, if any, follows on a line of its own (after "\n"). */
231
+ /**
232
+ * The subtitle, then the credit ("Translated by …"), if any, on a line of its own (after "\n"):
233
+ * the credit alone when there is no subtitle, and empty when there is neither.
234
+ */
232
235
  readonly subtitle: string;
233
236
  /** The series line, or empty. `titleBlock` sets it above the title. */
234
237
  readonly series: string;
package/src/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ /* @ts-self-types="./index.d.ts" */
1
2
  // book-cover-generator — generate SVG book covers from a title and an author.
2
3
  //
3
4
  // import { generateCover } from 'book-cover-generator';
@@ -31,7 +32,7 @@ export { createMockup, mockupKinds } from './mockups.js';
31
32
  export { parseBookList } from './books.js';
32
33
 
33
34
  /** This version of the library (artwork for a seed can change between minor versions). */
34
- export const version = '1.8.0';
35
+ export const version = '1.9.1';
35
36
 
36
37
  /** The sizes a cover can have: each side `min` to `max` px, and neither more than `ratio` times the other. */
37
38
  export const sizeLimits = Object.freeze({ min: 50, max: 10000, ratio: 3 });
@@ -399,13 +400,15 @@ function dimension(value, fallback, name) {
399
400
  return Math.round(v);
400
401
  }
401
402
 
403
+ /** @typedef {import('./text.js').FontSpec} FontSpec */
404
+
402
405
  /** Ways to set a title's letters: in capitals, or as typed. */
403
406
  const TITLE_CASES = ['upper', 'normal'];
404
407
 
405
408
  /**
406
409
  * The title's font and case, if the options set them (else each style's own).
407
410
  * @param {import('./index.js').CoverOptions} options
408
- * @returns {{ font: import('./text.js').FontSpec | null, fontId: string, titleCase: 'upper' | 'normal' | null }}
411
+ * @returns {{ font: FontSpec | null, fontId: string, titleCase: 'upper' | 'normal' | null }}
409
412
  */
410
413
  function typeOptions(options) {
411
414
  const font = /** @type {unknown} */ (options.font);
@@ -511,9 +514,10 @@ export function createCover(options = {}) {
511
514
  H,
512
515
  !!options.texture,
513
516
  ...(Object.keys(pins).length ? [pins] : []),
514
- ...(titleType.font || titleType.titleCase ? [titleType.fontId, titleType.titleCase ?? ''] : []),
515
- ...(series || credit ? [series, credit] : []),
516
- ...(badge ? [badge] : []),
517
+ // (Each optional part named, so one can't pass for another: a series of "serif" isn't a font.)
518
+ ...(titleType.font || titleType.titleCase ? ['font', titleType.fontId, titleType.titleCase ?? ''] : []),
519
+ ...(series || credit ? ['series', series, credit] : []),
520
+ ...(badge ? ['badge', badge] : []),
517
521
  ]),
518
522
  ).toString(36);
519
523
  /** The variants the style chose (or was given). @type {Record<string, string>} */
package/src/mcp.js CHANGED
@@ -185,7 +185,7 @@ function linkOf(options, cover) {
185
185
  /** @type {Record<string, string>} */
186
186
  const pinned = {};
187
187
  for (const name of Object.keys(options.variants ?? {}).map((n) => n.toLowerCase())) {
188
- if (cover.variants[name]) pinned[name] = cover.variants[name];
188
+ if (Object.hasOwn(cover.variants, name)) pinned[name] = cover.variants[name];
189
189
  }
190
190
  const own = options.palette && typeof options.palette === 'object' ? options.palette : null;
191
191
  const preset = Object.entries(formats).find(([, [w, h]]) => w === cover.width && h === cover.height);
@@ -330,8 +330,24 @@ export function handlePayload(payload, ctx = {}) {
330
330
  return answers.length ? answers : null;
331
331
  }
332
332
 
333
+ /**
334
+ * The answer to what a client sent as the JSON text to send back, or null for none. Whatever goes
335
+ * wrong while answering is an internal error, not a crash of the server.
336
+ * @param {unknown} payload @param {Context} [ctx]
337
+ */
338
+ export function answerPayload(payload, ctx = {}) {
339
+ try {
340
+ const answer = handlePayload(payload, ctx);
341
+ return answer ? JSON.stringify(answer) : null;
342
+ } catch {
343
+ return JSON.stringify(failure(null, -32603, 'Internal error'));
344
+ }
345
+ }
346
+
333
347
  /** @param {unknown} id @param {unknown} result */
334
348
  const ok = (id, result) => ({ jsonrpc: '2.0', id, result });
349
+ /** An id as JSON-RPC allows it, so it can be sent back: a string, a number or null. @param {unknown} id */
350
+ const validId = (id) => id === null || typeof id === 'string' || (typeof id === 'number' && Number.isFinite(id));
335
351
  /** @param {unknown} id @param {number} code @param {string} message */
336
352
  const failure = (id, code, message) => ({ jsonrpc: '2.0', id, error: { code, message } });
337
353
 
@@ -343,11 +359,17 @@ export function handleMessage(message, ctx = {}) {
343
359
  if (!message || typeof message !== 'object' || Array.isArray(message))
344
360
  return failure(null, -32600, 'Invalid request');
345
361
  const { jsonrpc, id, method, params } = /** @type {Record<string, any>} */ (message);
346
- if (jsonrpc !== '2.0' || typeof method !== 'string') {
347
- return 'id' in message ? failure(id ?? null, -32600, 'Invalid request') : null;
362
+ const hasId = 'id' in message;
363
+ // A response (this server sends no requests, so it answers none): nothing to say.
364
+ if (jsonrpc === '2.0' && !('method' in message) && hasId && ('result' in message || 'error' in message)) {
365
+ return null;
366
+ }
367
+ // Not a request: an error, with its id if it is one JSON-RPC allows (else null, as for none).
368
+ if (jsonrpc !== '2.0' || typeof method !== 'string' || (hasId && !validId(id))) {
369
+ return failure(hasId && validId(id) ? id : null, -32600, 'Invalid request');
348
370
  }
349
371
  // A notification (initialized, cancelled…) has no answer.
350
- if (!('id' in message)) return null;
372
+ if (!hasId) return null;
351
373
  switch (method) {
352
374
  case 'initialize': {
353
375
  const asked = params?.protocolVersion;
package/src/mockups.js CHANGED
@@ -10,9 +10,19 @@ export const mockupKinds = Object.freeze(['flat', 'book', 'ereader', 'card', 'sq
10
10
  /** The neutral backdrop of the product shots. */
11
11
  const STUDIO = '#ecebe7';
12
12
 
13
+ /**
14
+ * The cover's markup with its ids made a mockup's own (the mockup's tag before the cover's `bc…-`
15
+ * prefix), so the cover and its mockups, or two mockups of it, can share a page.
16
+ * @param {string} body @param {string} tag
17
+ */
18
+ const ownIds = (body, tag) => {
19
+ const uid = /\bid="(bc[0-9a-z]+)-/.exec(body)?.[1];
20
+ return uid ? body.replace(new RegExp(`(id="|url\\(#|href="#)${uid}-`, 'g'), `$1${tag}-${uid}-`) : body;
21
+ };
22
+
13
23
  /**
14
24
  * The cover nested at a place and size.
15
- * @param {import('./index.js').Cover} cover @param {number} x @param {number} y @param {number} w @param {number} h
25
+ * @param {{ width: number, height: number, body: string }} cover @param {number} x @param {number} y @param {number} w @param {number} h
16
26
  */
17
27
  const nest = (cover, x, y, w, h) =>
18
28
  `<svg x="${n(x)}" y="${n(y)}" width="${n(w)}" height="${n(h)}" viewBox="0 0 ${cover.width} ${cover.height}" preserveAspectRatio="none" overflow="hidden">${cover.body}</svg>`;
@@ -53,6 +63,7 @@ export function createMockup(cover, { kind = 'book', background, text = '' } = {
53
63
  // Ids unique to this cover and kind of mockup, so several can share a page.
54
64
  const tag = `mk${hashString(cover.svg).toString(36)}-${kind}`;
55
65
  const id = (/** @type {string} */ name) => `${tag}-${name}`;
66
+ const nested = { width: cover.width, height: cover.height, body: ownIds(cover.body, tag) };
56
67
  const { width: CW, height: CH, colors } = cover;
57
68
  // (Cut between whole characters: an emoji or accented letter is never split.)
58
69
  const words = clipGraphemes(String(text).replace(/\s+/g, ' ').trim(), 80).trim();
@@ -79,7 +90,7 @@ export function createMockup(cover, { kind = 'book', background, text = '' } = {
79
90
  H,
80
91
  `<defs>${shadow(id('shadow'), 14 * u, 10 * u, 0.32)}</defs>` +
81
92
  `<rect width="${n(W)}" height="${n(H)}" fill="${background ?? STUDIO}"/>` +
82
- `<g filter="url(#${id('shadow')})">${nest(cover, pad, pad, CW, CH)}</g>`,
93
+ `<g filter="url(#${id('shadow')})">${nest(nested, pad, pad, CW, CH)}</g>`,
83
94
  );
84
95
  }
85
96
 
@@ -112,7 +123,7 @@ export function createMockup(cover, { kind = 'book', background, text = '' } = {
112
123
  `<ellipse cx="${n(x0 + (CW * turn) / 2)}" cy="${n(y0 + CH - CW * rise * 0.3)}" rx="${n((CW * turn) / 2 + spine)}" ry="${n(CW * 0.035)}" fill="#000" opacity="0.28" filter="url(#${id('soft')})"/>` +
113
124
  `<polygon points="${n(x0 - spine)},${n(y0 + sag)} ${n(x0)},${n(y0)} ${n(x0)},${n(y0 + CH)} ${n(x0 - spine)},${n(y0 + CH - sag)}" fill="${spineFill}"/>` +
114
125
  `<polygon points="${n(x0 - spine)},${n(y0 + sag)} ${n(x0)},${n(y0)} ${n(x0)},${n(y0 + CH)} ${n(x0 - spine)},${n(y0 + CH - sag)}" fill="url(#${id('spine')})"/>` +
115
- `<g transform="matrix(${turn} ${-rise} 0 1 ${n(x0)} ${n(y0)})">${nest(cover, 0, 0, CW, CH)}` +
126
+ `<g transform="matrix(${turn} ${-rise} 0 1 ${n(x0)} ${n(y0)})">${nest(nested, 0, 0, CW, CH)}` +
116
127
  `<rect width="${CW}" height="${CH}" fill="url(#${id('gloss')})"/></g>`,
117
128
  );
118
129
  }
@@ -134,7 +145,7 @@ export function createMockup(cover, { kind = 'book', background, text = '' } = {
134
145
  `<rect width="${n(W)}" height="${n(H)}" fill="${background ?? STUDIO}"/>` +
135
146
  `<g filter="url(#${id('shadow')})"><rect x="${n(pad)}" y="${n(pad)}" width="${n(DW)}" height="${n(DH)}" rx="${n(CW * 0.06)}" fill="#1c1c1e"/></g>` +
136
147
  `<rect x="${n(pad + 2 * u)}" y="${n(pad + 2 * u)}" width="${n(DW - 4 * u)}" height="${n(DH - 4 * u)}" rx="${n(CW * 0.055)}" fill="none" stroke="#3a3a3c" stroke-width="${n(1.5 * u)}"/>` +
137
- nest(cover, pad + side, pad + top, CW, CH) +
148
+ nest(nested, pad + side, pad + top, CW, CH) +
138
149
  `<rect x="${n(pad + DW / 2 - CW * 0.08)}" y="${n(pad + DH - bottom / 2 - 2 * u)}" width="${n(CW * 0.16)}" height="${n(4 * u)}" rx="${n(2 * u)}" fill="#3a3a3c"/>`,
139
150
  );
140
151
  }
@@ -163,7 +174,7 @@ export function createMockup(cover, { kind = 'book', background, text = '' } = {
163
174
  let s =
164
175
  `<defs>${shadow(id('shadow'), 18 * u, 14 * u, 0.4)}</defs>` +
165
176
  `<rect width="${W}" height="${H}" fill="${bg}"/>` +
166
- `<g filter="url(#${id('shadow')})">${nest(cover, cx, cy, cw, ch)}</g>`;
177
+ `<g filter="url(#${id('shadow')})">${nest(nested, cx, cy, cw, ch)}</g>`;
167
178
  // The words: beside the cover on a card, under it otherwise.
168
179
  const box =
169
180
  kind === 'card'
package/src/palettes.js CHANGED
@@ -1,3 +1,4 @@
1
+ /* @ts-self-types="./palettes.d.ts" */
1
2
  // Curated palettes. Every palette has a background, an ink colour with strong
2
3
  // contrast against it, and three accents. `dark` marks dark backgrounds.
3
4
 
@@ -1,7 +1,10 @@
1
+ /* @ts-self-types="./util.d.ts" */
1
2
  import { n, circle, contrast, readable, clamp, mix, inkCandidates, hexToRgb } from '../core.js';
2
3
  import { titleBlock } from '../type.js';
3
4
  import { graphemes } from '../text.js';
4
5
 
6
+ /** @typedef {import('../type.js').TitleBlock} TitleBlock */
7
+
5
8
  /**
6
9
  * The title's characters, upper-cased, from the first that isn't punctuation or a
7
10
  * space: an initial skips opening quotes, brackets, "¿" and the like (but keeps
@@ -70,7 +73,7 @@ export function tintText(base, toward, k, bg) {
70
73
  * @param {readonly V[]} candidates layout values, from the most to the least room for the art
71
74
  * @param {(value: V) => import('../type.js').TitleBlockOptions} optionsFor
72
75
  * @param {number} [minSize] fraction of the short side
73
- * @returns {{ block: import('../type.js').TitleBlock, value: V }}
76
+ * @returns {{ block: TitleBlock, value: V }}
74
77
  */
75
78
  export function fitTitleWith(c, candidates, optionsFor, minSize = 0.06) {
76
79
  let block = titleBlock(c, optionsFor(candidates[0]));
package/src/text.js CHANGED
@@ -1,3 +1,4 @@
1
+ /* @ts-self-types="./text.d.ts" */
1
2
  // Text measurement, fitting (wrap + shrink-to-fit) and rendering.
2
3
  //
3
4
  // Only system font stacks are used so that covers render identically when the
@@ -74,6 +75,8 @@ export const FONTS = {
74
75
  },
75
76
  };
76
77
 
78
+ /** @typedef {import('./text.js').FontSpec} FontSpec */
79
+
77
80
  /* ------------------------------------------------------------- measurement */
78
81
 
79
82
  /** @type {CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D | null | undefined} */
@@ -875,7 +878,7 @@ export function fitText(text, opts) {
875
878
  /**
876
879
  * Attributes shared by every line of a text block, for its wrapping `<g>`.
877
880
  * Colours and font names are escaped; `attrs` is raw SVG.
878
- * @param {{ font: import('./text.js').FontSpec, fill: string, size?: number, weight?: number, italic?: boolean, letterSpacing?: number, anchor?: 'start' | 'middle' | 'end', opacity?: number, attrs?: string }} o
881
+ * @param {{ font: FontSpec, fill: string, size?: number, weight?: number, italic?: boolean, letterSpacing?: number, anchor?: 'start' | 'middle' | 'end', opacity?: number, attrs?: string }} o
879
882
  */
880
883
  export function textGroupAttrs({ font, fill, size, weight, italic, letterSpacing, anchor = 'start', opacity, attrs }) {
881
884
  let a = `font-family="${escapeXml(font.family)}"`;
package/src/type.d.ts CHANGED
@@ -38,7 +38,10 @@ export interface TitleBlockOptions {
38
38
  letterSpacing?: number;
39
39
  lineHeight?: number;
40
40
  maxLines?: number;
41
- /** Set a leading "The" / "A" / "An" as a small line above the title. Default `true`. */
41
+ /**
42
+ * Set a leading article of three letters or more ("The", "Les", "Der") as a small line above the
43
+ * title; a shorter one ("A", "An", "La") stays with the title. Default `true`.
44
+ */
42
45
  lead?: boolean;
43
46
  /** Try a justified stack (each line full width) before regular wrapping. */
44
47
  stack?: boolean;
package/src/type.js CHANGED
@@ -1,3 +1,4 @@
1
+ /* @ts-self-types="./type.d.ts" */
1
2
  // Shared cover typography: title blocks and author lines.
2
3
  //
3
4
  // A title block is an optional small leading article ("THE"), the title itself