book-cover-generator 1.5.0 → 1.6.0

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
@@ -31,7 +31,7 @@ Generate book covers from a **title** and an **author**. 34 styles, 28 palettes
31
31
 
32
32
  ## The web app
33
33
 
34
- On [covers.otsirh.dev](https://covers.otsirh.dev) you type a title and author (press Enter in the title to break its line where you want), and if you like a subtitle, a series line ("Book Two of the Ember Cycle") and a credit ("Translated by…"). Then you choose:
34
+ On [covers.otsirh.dev](https://covers.otsirh.dev) you type a title and author (press Enter in the title to break its line where you want), and if you like a subtitle, a series line ("Book Two of the Ember Cycle"), a credit ("Translated by…") and a badge ("Winner of the Hugo Award", set as a seal in a corner clear of the text). Then you choose:
35
35
 
36
36
  - **a style**, from a rail that previews your book in all 34 styles. Eleven styles let you pin a choice the variation otherwise makes, such as Silhouette's city or forest, or where Aurora puts the title;
37
37
  - **title type**: any of the nine fonts instead of the style's, and the title in capitals or as typed. It stays when you change style, so the rail shows every style in your type;
@@ -41,6 +41,10 @@ On [covers.otsirh.dev](https://covers.otsirh.dev) you type a title and author (p
41
41
 
42
42
  **Store size** shrinks the preview to the size a store lists covers at, to check the title still reads. **Randomize** reshuffles everything, except what you've locked. Undo and redo step back and forth through your designs, and so do the browser's Back and Forward buttons. **Copy link** shares the exact design, because the URL holds every setting. The export panel tells you which size suits Kindle, print or audiobook covers.
43
43
 
44
+ **Mockup** shows and downloads the cover as a 3D book, a flat shot, on an e-reader, or on a social card (a 1200 × 630 link preview, a square post or a story, with a line such as "Out now").
45
+
46
+ **Many covers** takes a list of books (one per line as `Title | Author | Subtitle`, or a CSV with a title column) and makes them all with the design you have, as PNG or SVG in one ZIP. With a fixed variation they share one look, as a series does; switch on "A different palette for each" for a set in several colours.
47
+
44
48
  **Keep** saves a design to a tray under the preview, together with the cover as it was drawn, so a later version of the app can't change it. **Download all** saves every kept design as a PNG in one ZIP file. **Export** saves the kept designs to a file, and **Import** adds them in another browser.
45
49
 
46
50
  The app works offline once it has loaded.
@@ -106,11 +110,13 @@ npx book-cover-generator --list # all styles and palettes
106
110
  npx book-cover-generator "Bright Machines" --bg 0b132b --ink f8f9fa --accent ff6b6b,4ecdc4 --format ebook --width 1600 -o cover.svg
107
111
  # any size, and the choices as JSON (with the SVG, when there is no -o)
108
112
  npx book-cover-generator "Neon Tide" --size 1800x2700 --json
113
+ # a cover for every book in a list (CSV with a header row, JSON lines, or "Title | Author" lines), one look for all
114
+ npx book-cover-generator --batch books.csv --style minimal --seed series --out-dir covers
109
115
  # twelve variations to choose from: ideas/cover-1.svg … ideas/cover-12.svg (seeds 1 to 12)
110
116
  npx book-cover-generator "Salt and Static" --count 12 -o ideas/cover.svg
111
117
  ```
112
118
 
113
- Options: `-s/--style`, `-p/--palette`, `--bg`, `--ink`, `--accent`, `--subtitle`, `--texture`, `--seed`, `--format` (`paperback`, `ebook` or `square`), `--width`, `--height`, `--size`, `--lang`, `--series`, `--credit`, `--font`, `--case upper|normal`, `--variant name=value` (`--list` shows each style's), `-o/--out`, `--count`, `--json`, `--list`, `--help`.
119
+ Options: `-s/--style`, `-p/--palette`, `--bg`, `--ink`, `--accent`, `--subtitle`, `--texture`, `--seed`, `--format` (`paperback`, `ebook` or `square`), `--width`, `--height`, `--size`, `--lang`, `--series`, `--credit`, `--badge`, `--font`, `--case upper|normal`, `--variant name=value` (`--list` shows each style's), `-o/--out`, `--count`, `--batch <file|->`, `--out-dir`, `--json`, `--list`, `--help`.
114
120
 
115
121
  ## API
116
122
 
@@ -118,7 +124,7 @@ Options: `-s/--style`, `-p/--palette`, `--bg`, `--ink`, `--accent`, `--subtitle`
118
124
 
119
125
  Returns the SVG markup.
120
126
 
121
- ### `createCover(options) → { svg, body, style, palette, seed, width, height, lang }`
127
+ ### `createCover(options) → { svg, body, style, palette, colors, seed, width, height, lang, variants, title, author }`
122
128
 
123
129
  Same as `generateCover`, but also tells you which style and palette were chosen. This is useful when either one was random. `body` is the markup inside the outer `<svg>`, for nesting the cover in a larger SVG (its ids are unique to the cover, so several can share a document).
124
130
 
@@ -129,6 +135,7 @@ Same as `generateCover`, but also tells you which style and palette were chosen.
129
135
  | `subtitle` | `string` | `''` | optional subtitle or tagline |
130
136
  | `series` | `string` | `''` | a series line above the title |
131
137
  | `credit` | `string` | `''` | a credit under the subtitle ("Translated by…") |
138
+ | `badge` | `string` | `''` | a seal in a corner clear of the text |
132
139
  | `texture` | `boolean` | `false` | adds a fine film grain |
133
140
  | `style` | `string` | `'random'` | a style id, see `styles` |
134
141
  | `palette` | `string \| object` | `'random'` | a palette id, or `{ bg, ink, accents: [...] }` |
@@ -153,6 +160,8 @@ Titles and subtitles longer than 300 characters (authors: 200) are cut. With `'r
153
160
  - `pickStyle(options)`: the style id `createCover` would use for these options (resolving `'random'` from the seed), without drawing the cover
154
161
  - `registerStyle({ id, name, description, group, prefer, render(ctx) })`: add your own style (see below). Replacing a built-in style takes `registerStyle(style, { replace: true })`; `unregisterStyle(id)` removes a style or brings the built-in back
155
162
  - `registerPalette({ id, name, bg, ink, accents })`: add a palette to use by id, as `palette: 'brand'` (`{ replace: true }` for a built-in id); `unregisterPalette(id)` removes it. Registered styles and palettes can also be picked by `'random'`, so registering one changes what a seed gives
163
+ - `parseBookList(text)`: a list of books (CSV with commas, semicolons or tabs and a header row naming a `title` column, JSON lines, or one "Title | Author | Subtitle" per line) as cover options, one per book
164
+ - `noteText(x0, y0, x1, y1)` and `recordText(draw)`: note where a custom style writes its own text (so a badge stays clear of it), and collect what `draw` notes
156
165
  - `version`: the library's version (artwork for a seed can change between minor versions)
157
166
  - `sizeLimits` (`{ min: 50, max: 10000, ratio: 3 }`) and `formats` (`paperback`, `ebook` and `square` as `[width, height]` at 600 px wide): the sizes covers can have, and the app's shapes
158
167
  - `titleBlock`, `authorFit`: the typography the built-in styles use (a title with its lead line and subtitle, and the author line)
@@ -164,6 +173,18 @@ Titles and subtitles longer than 300 characters (authors: 200) are cut. With `'r
164
173
 
165
174
  Types are included. `CoverOptions`, `Cover`, `StyleId`, `PaletteId`, `StyleGroup`, `StyleContext`, `CoverStyle`, `TitleBlock` and the rest are exported from the main entry point, and style and palette ids autocomplete.
166
175
 
176
+ ### Mockups
177
+
178
+ ```js
179
+ import { createCover, createMockup } from 'book-cover-generator';
180
+
181
+ const cover = createCover({ title: 'Neon Tide', author: 'Kai Moreno' });
182
+ const { svg, width, height } = createMockup(cover, { kind: 'book' }); // or 'flat', 'ereader'
183
+ const card = createMockup(cover, { kind: 'card', text: 'Out now' }); // 1200 × 630; also 'square', 'story'
184
+ ```
185
+
186
+ Product shots (`flat`, `book`, `ereader`) follow the cover's size on a light backdrop (`background` to change it); social cards have fixed sizes and take the cover's colours. `mockupKinds` lists them.
187
+
167
188
  ### Export helpers (browser)
168
189
 
169
190
  ```js
@@ -206,6 +227,7 @@ generateCover({
206
227
  - `p`: the palette, with `bg`, `ink`, `accents`, plus `paper` (lighter) and `night` (darker)
207
228
  - `id(name)`: makes ids that are unique per cover, for gradients and filters
208
229
  - `titleFont`, `titleCase`: the cover's `font` and `titleCase`, or null; `titleBlock` follows them, so a style that sets its title with it gets them for free
230
+ - Text drawn with `drawText`, `drawStack` or `titleBlock` is noted, so a badge stays clear of it; a style that writes its own text elements can call `noteText(x0, y0, x1, y1)`
209
231
  - `choose(name, values)`: picks one of `values` as `rng.pick` would, unless the cover pins the variant `name`. Declare what can be pinned in the style's `variants`: `{ mood: { label: 'Mood', values: { calm: 'Calm', loud: 'Loud' } } }`
210
232
 
211
233
  ```js
package/bin/book-cover.js CHANGED
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
- import { writeFileSync } from 'node:fs';
2
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
3
4
  import { parseArgs } from 'node:util';
4
- import { createCover, listStyles, palettes, formats } from '../src/index.js';
5
+ import { createCover, listStyles, palettes, formats, parseBookList } from '../src/index.js';
5
6
 
6
7
  const HELP = `Usage: book-cover "<title>" ["<author>"] [options]
7
8
 
@@ -19,6 +20,7 @@ Options:
19
20
  --subtitle <text> a subtitle or tagline
20
21
  --series <text> a series line above the title ("Book Two of …")
21
22
  --credit <text> a credit under the subtitle ("Translated by …")
23
+ --badge <text> a seal in a corner clear of the text ("Bestseller")
22
24
  --texture add a fine film-grain texture
23
25
  --seed <value> seed for the artwork (default: title + author)
24
26
  --format <shape> paperback (2:3, the default), ebook (5:8) or square
@@ -26,6 +28,13 @@ Options:
26
28
  --height <px> height (default: from the format)
27
29
  --size <WxH> width and height at once, e.g. 1600x2560
28
30
  --lang <tag> language of the text, e.g. ja or zh-Hant (default: inferred)
31
+ --batch <file> a cover for each book in a list (- reads stdin): CSV or
32
+ TSV with a header row (title, author, subtitle, series,
33
+ credit, badge, style, palette, seed, font, case, lang,
34
+ width, height, texture), JSON lines, or one book per
35
+ line as "Title | Author | Subtitle"; the other options
36
+ apply to every book the list doesn't say otherwise for
37
+ --out-dir <dir> where --batch writes its covers (default: here)
29
38
  -o, --out <file> write SVG to a file instead of stdout
30
39
  --count <n> write n variations (seeds 1…n, or <seed>-1…), named
31
40
  cover-1.svg… from --out (or put {n} or {seed} in it)
@@ -38,7 +47,8 @@ Examples:
38
47
  book-cover "The Lighthouse Keeper" "Mara Ellison" -s artdeco -o cover.svg
39
48
  book-cover "Neon Tide" "Kai Moreno" --style retro --palette synth --size 1600x2560 > cover.svg
40
49
  book-cover "Bright Machines" --bg 0b132b --ink f8f9fa --accent ff6b6b,4ecdc4 --format ebook -o cover.svg
41
- book-cover "Salt and Static" "J. R. Okafor" --count 12 -o ideas/cover.svg`;
50
+ book-cover "Salt and Static" "J. R. Okafor" --count 12 -o ideas/cover.svg
51
+ book-cover --batch books.csv --style minimal --seed series --out-dir covers`;
42
52
 
43
53
  let args;
44
54
  try {
@@ -56,6 +66,7 @@ try {
56
66
  subtitle: { type: 'string' },
57
67
  series: { type: 'string' },
58
68
  credit: { type: 'string' },
69
+ badge: { type: 'string' },
59
70
  texture: { type: 'boolean' },
60
71
  seed: { type: 'string' },
61
72
  format: { type: 'string' },
@@ -64,6 +75,8 @@ try {
64
75
  size: { type: 'string' },
65
76
  lang: { type: 'string' },
66
77
  out: { type: 'string', short: 'o' },
78
+ batch: { type: 'string' },
79
+ 'out-dir': { type: 'string' },
67
80
  count: { type: 'string' },
68
81
  json: { type: 'boolean' },
69
82
  list: { type: 'boolean' },
@@ -77,33 +90,43 @@ try {
77
90
 
78
91
  const { values, positionals } = args;
79
92
 
80
- if (values.help) {
81
- console.log(HELP);
82
- process.exit(0);
83
- }
84
-
85
- if (values.list) {
86
- console.log('Styles:');
87
- for (const s of listStyles()) {
88
- console.log(` ${s.id.padEnd(14)} ${s.description}`);
89
- for (const v of s.variants) {
90
- console.log(` ${''.padEnd(14)} --variant ${v.name}=${v.values.map((x) => x.value).join('|')}`);
93
+ /**
94
+ * Help, the list, and checks of the arguments: whether to go on and make covers. (Exit codes are
95
+ * set, not exited with, so output piped elsewhere is written in full first.)
96
+ */
97
+ function preflight() {
98
+ if (values.help) {
99
+ console.log(HELP);
100
+ return false;
101
+ }
102
+ if (values.list) {
103
+ console.log('Styles:');
104
+ for (const s of listStyles()) {
105
+ console.log(` ${s.id.padEnd(14)} ${s.description}`);
106
+ for (const v of s.variants) {
107
+ console.log(` ${''.padEnd(14)} --variant ${v.name}=${v.values.map((x) => x.value).join('|')}`);
108
+ }
91
109
  }
110
+ console.log('\nPalettes:');
111
+ for (const p of palettes) console.log(` ${p.id.padEnd(14)} ${p.name}${p.dark ? ' (dark)' : ''}`);
112
+ return false;
92
113
  }
93
- console.log('\nPalettes:');
94
- for (const p of palettes) console.log(` ${p.id.padEnd(14)} ${p.name}${p.dark ? ' (dark)' : ''}`);
95
- process.exit(0);
96
- }
97
-
98
- if (!positionals.length) {
99
- console.error(HELP);
100
- process.exit(1);
101
- }
102
- if (positionals.length > 2) {
103
- console.error(
104
- `Expected a title and an author, got ${positionals.length} arguments. Quote values with spaces: book-cover "The Lighthouse Keeper" "Mara Ellison"`,
105
- );
106
- process.exit(1);
114
+ /** @param {string} message */
115
+ const fail = (message) => {
116
+ console.error(message);
117
+ process.exitCode = 1;
118
+ return false;
119
+ };
120
+ if (values.batch !== undefined && positionals.length) {
121
+ return fail('--batch takes the titles from its list: give no title on the command line');
122
+ }
123
+ if (!positionals.length && values.batch === undefined) return fail(HELP);
124
+ if (positionals.length > 2) {
125
+ return fail(
126
+ `Expected a title and an author, got ${positionals.length} arguments. Quote values with spaces: book-cover "The Lighthouse Keeper" "Mara Ellison"`,
127
+ );
128
+ }
129
+ return true;
107
130
  }
108
131
 
109
132
  /**
@@ -208,41 +231,120 @@ function fileFor(out, n, total, seed) {
208
231
  */
209
232
  const safeName = (seed) => seed.replace(/[^\p{L}\p{N}._-]+/gu, '-').replace(/^[.-]+/, '') || 'cover';
210
233
 
211
- try {
212
- const total = count();
213
- const options = {
214
- title: positionals[0],
215
- author: positionals[1] ?? '',
216
- subtitle: values.subtitle,
217
- series: values.series,
218
- credit: values.credit,
219
- texture: values.texture,
220
- style: values.style,
221
- palette: palette(),
222
- lang: values.lang,
223
- variants: variants(),
224
- font: values.font,
225
- titleCase: /** @type {'upper' | 'normal' | undefined} */ (values.case),
226
- ...dimensions(),
227
- };
228
- for (let n = 1; n <= total; n++) {
229
- // One cover: the seed given (or the title's). Several: seeds 1…n, or <seed>-1…<seed>-n.
230
- const seed = total === 1 ? values.seed : values.seed ? `${values.seed}-${n}` : String(n);
231
- const cover = createCover({ ...options, seed });
232
- const { style, palette: paletteId, width, height, lang } = cover;
233
- const choices = { style, palette: paletteId, seed: cover.seed, width, height, lang, variants: cover.variants };
234
- if (values.out) {
235
- const file = fileFor(values.out, n, total, cover.seed);
234
+ /** A title as part of a file name: lower case, accents off Latin letters, dashes between words. @param {string} title */
235
+ function slug(title) {
236
+ const words = title
237
+ .normalize('NFKD')
238
+ .replace(/(\p{Script=Latin})[\u0300-\u036f]+/gu, '$1')
239
+ .normalize('NFC')
240
+ .toLowerCase()
241
+ .replace(/[^\p{L}\p{M}\p{N}]+/gu, '-')
242
+ .replace(/^-+/, '');
243
+ return Array.from(words).slice(0, 60).join('').replace(/-+$/, '') || 'cover';
244
+ }
245
+
246
+ /**
247
+ * Write a cover for each book in the --batch list into --out-dir; the command line's options
248
+ * apply to every book the list doesn't say otherwise for.
249
+ * @param {import('../src/index.js').CoverOptions} defaults
250
+ */
251
+ function batch(defaults) {
252
+ if (values.count !== undefined || values.out !== undefined) {
253
+ throw new Error('--batch writes a file per book into --out-dir: use it without --out or --count');
254
+ }
255
+ const source = /** @type {string} */ (values.batch);
256
+ const books = parseBookList(readFileSync(source === '-' ? 0 : source, 'utf8'));
257
+ if (!books.length) {
258
+ throw new Error(
259
+ `No books in ${source === '-' ? 'the input' : source}: a title per line, or a CSV with a "title" column`,
260
+ );
261
+ }
262
+ const dir = values['out-dir'] ?? '.';
263
+ mkdirSync(dir, { recursive: true });
264
+ const digits = String(books.length).length;
265
+ let failed = 0;
266
+ books.forEach((book, i) => {
267
+ const n = i + 1;
268
+ try {
269
+ // A size in the list replaces the command line's.
270
+ const sized = book.width !== undefined || book.height !== undefined;
271
+ const cover = createCover({
272
+ ...defaults,
273
+ ...(sized ? { width: undefined, height: undefined } : {}),
274
+ ...book,
275
+ seed: book.seed ?? values.seed,
276
+ });
277
+ const file = join(dir, `${String(n).padStart(digits, '0')}-${slug(cover.title)}.svg`);
236
278
  writeFileSync(file, cover.svg);
237
- if (values.json) process.stdout.write(JSON.stringify({ file, ...choices }) + '\n');
238
- else console.error(`Wrote ${file} (style: ${style}, palette: ${paletteId}, ${width}x${height})`);
239
- } else if (values.json) {
240
- process.stdout.write(JSON.stringify({ ...choices, svg: cover.svg }) + '\n');
241
- } else {
242
- process.stdout.write(cover.svg + '\n');
279
+ const { style, palette: paletteId, seed, width, height } = cover;
280
+ if (values.json)
281
+ process.stdout.write(JSON.stringify({ file, style, palette: paletteId, seed, width, height }) + '\n');
282
+ } catch (err) {
283
+ failed += 1;
284
+ console.error(`Book ${n} ("${book.title}"): ${err instanceof Error ? err.message : String(err)}`);
285
+ }
286
+ });
287
+ console.error(`Wrote ${books.length - failed} of ${books.length} covers to ${dir}`);
288
+ if (failed) process.exitCode = 1;
289
+ }
290
+
291
+ function run() {
292
+ try {
293
+ if (values.batch !== undefined) {
294
+ batch({
295
+ subtitle: values.subtitle,
296
+ series: values.series,
297
+ credit: values.credit,
298
+ badge: values.badge,
299
+ texture: values.texture,
300
+ style: values.style,
301
+ palette: palette(),
302
+ lang: values.lang,
303
+ variants: variants(),
304
+ font: values.font,
305
+ titleCase: /** @type {'upper' | 'normal' | undefined} */ (values.case),
306
+ ...dimensions(),
307
+ });
308
+ return;
309
+ }
310
+ const total = count();
311
+ const options = {
312
+ title: positionals[0],
313
+ author: positionals[1] ?? '',
314
+ subtitle: values.subtitle,
315
+ series: values.series,
316
+ credit: values.credit,
317
+ badge: values.badge,
318
+ texture: values.texture,
319
+ style: values.style,
320
+ palette: palette(),
321
+ lang: values.lang,
322
+ variants: variants(),
323
+ font: values.font,
324
+ titleCase: /** @type {'upper' | 'normal' | undefined} */ (values.case),
325
+ ...dimensions(),
326
+ };
327
+ for (let n = 1; n <= total; n++) {
328
+ // One cover: the seed given (or the title's). Several: seeds 1…n, or <seed>-1…<seed>-n.
329
+ const seed = total === 1 ? values.seed : values.seed ? `${values.seed}-${n}` : String(n);
330
+ const cover = createCover({ ...options, seed });
331
+ const { style, palette: paletteId, width, height, lang } = cover;
332
+ const choices = { style, palette: paletteId, seed: cover.seed, width, height, lang, variants: cover.variants };
333
+ if (values.out) {
334
+ const file = fileFor(values.out, n, total, cover.seed);
335
+ writeFileSync(file, cover.svg);
336
+ if (values.json) process.stdout.write(JSON.stringify({ file, ...choices }) + '\n');
337
+ else console.error(`Wrote ${file} (style: ${style}, palette: ${paletteId}, ${width}x${height})`);
338
+ } else if (values.json) {
339
+ process.stdout.write(JSON.stringify({ ...choices, svg: cover.svg }) + '\n');
340
+ } else {
341
+ process.stdout.write(cover.svg + '\n');
342
+ }
243
343
  }
344
+ } catch (err) {
345
+ console.error(err instanceof Error ? err.message : String(err));
346
+ process.exitCode = 1;
244
347
  }
245
- } catch (err) {
246
- console.error(err instanceof Error ? err.message : String(err));
247
- process.exit(1);
248
348
  }
349
+
350
+ if (preflight()) run();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "book-cover-generator",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "Generate SVG book covers from a title and an author. 34 styles, 28 palettes, seeded randomizer, zero dependencies.",
5
5
  "keywords": [
6
6
  "book",
package/src/badge.js ADDED
@@ -0,0 +1,85 @@
1
+ // A badge on the cover: a seal ("Winner of the …", "10th Anniversary Edition") set in a corner
2
+ // clear of the text.
3
+
4
+ import { n, mix, escapeXml } from './core.js';
5
+ import { FONTS, fitText, drawText } from './text.js';
6
+ import { strongAccents, textColor } from './styles/util.js';
7
+
8
+ /** @typedef {import('./text.js').TextBox} TextBox */
9
+
10
+ /** How much of the square around a circle a box covers. @param {number} cx @param {number} cy @param {number} r @param {TextBox} b */
11
+ function overlap(cx, cy, r, b) {
12
+ const w = Math.min(cx + r, b.x1) - Math.max(cx - r, b.x0);
13
+ const h = Math.min(cy + r, b.y1) - Math.max(cy - r, b.y0);
14
+ return w > 0 && h > 0 ? w * h : 0;
15
+ }
16
+
17
+ /**
18
+ * The seal: a star-edged disc with a ring and the text inside, tilted a little, in the first
19
+ * corner (then side) clear of the text, smaller if it must be; else where it covers least.
20
+ * @param {import('./index.js').StyleContext} c
21
+ * @param {string} text
22
+ * @param {TextBox[]} boxes the text already on the cover
23
+ */
24
+ export function drawBadge(c, text, boxes) {
25
+ const { W, H, S, u, m, p } = c;
26
+ /** Where a seal of radius r could go: corners first, then the middle of each side. @param {number} r */
27
+ const spots = (r) => {
28
+ const inset = m * 0.5 + r;
29
+ return [
30
+ [W - inset, inset],
31
+ [inset, inset],
32
+ [W - inset, H - inset],
33
+ [inset, H - inset],
34
+ [W - inset, H / 2],
35
+ [inset, H / 2],
36
+ ];
37
+ };
38
+ const pad = S * 0.01;
39
+ /** @type {[number, number, number] | null} */
40
+ let place = null;
41
+ for (const scale of [1, 0.85, 0.7]) {
42
+ const r = S * 0.12 * scale;
43
+ const free = spots(r).find(([x, y]) => boxes.every((b) => overlap(x, y, r + pad, b) === 0));
44
+ if (free) {
45
+ place = [free[0], free[1], r];
46
+ break;
47
+ }
48
+ }
49
+ if (!place) {
50
+ const r = S * 0.084;
51
+ const covered = (/** @type {number[]} */ [x, y]) => boxes.reduce((sum, b) => sum + overlap(x, y, r, b), 0);
52
+ const best = spots(r).reduce((a, b) => (covered(b) < covered(a) ? b : a));
53
+ place = [best[0], best[1], r];
54
+ }
55
+ const [cx, cy, r] = place;
56
+
57
+ const fill = strongAccents(p, p.bg, 1.5)[0];
58
+ const ink = textColor(p, fill, [p.paper, p.night]);
59
+ const points = [];
60
+ const spikes = 28;
61
+ for (let i = 0; i < spikes * 2; i++) {
62
+ const a = (i / (spikes * 2)) * Math.PI * 2;
63
+ const rr = i % 2 ? r * 0.9 : r;
64
+ points.push(`${n(cx + Math.cos(a) * rr)},${n(cy + Math.sin(a) * rr)}`);
65
+ }
66
+ const fit = fitText(text, {
67
+ font: FONTS.sans,
68
+ weight: 700,
69
+ upper: true,
70
+ letterSpacing: 0.04,
71
+ maxWidth: r * 1.25,
72
+ maxHeight: r * 1.05,
73
+ maxSize: r * 0.3,
74
+ maxLines: 4,
75
+ lineHeight: 1.05,
76
+ });
77
+ const edge = mix(fill, p.night, 0.35);
78
+ return (
79
+ `<g transform="rotate(-8 ${n(cx)} ${n(cy)})" aria-label="${escapeXml(text)}">` +
80
+ `<polygon points="${points.join(' ')}" fill="${fill}" stroke="${edge}" stroke-width="${n(1.2 * u)}"/>` +
81
+ `<circle cx="${n(cx)}" cy="${n(cy)}" r="${n(r * 0.76)}" fill="none" stroke="${ink}" stroke-width="${n(1.4 * u)}" opacity="0.7"/>` +
82
+ drawText(fit, { x: cx, y: cy - fit.height / 2, anchor: 'middle', fill: ink }) +
83
+ '</g>'
84
+ );
85
+ }
package/src/books.js ADDED
@@ -0,0 +1,143 @@
1
+ // A list of books, as people paste or export it, read into cover options: CSV or TSV with a
2
+ // header row, JSON lines, or one book per line ("Title | Author | Subtitle").
3
+
4
+ /** The columns a list may have, by the names it may give them, and how each is read. */
5
+ const COLUMNS = /** @type {const} */ ({
6
+ title: 'title',
7
+ author: 'author',
8
+ subtitle: 'subtitle',
9
+ series: 'series',
10
+ credit: 'credit',
11
+ badge: 'badge',
12
+ style: 'style',
13
+ palette: 'palette',
14
+ seed: 'seed',
15
+ font: 'font',
16
+ case: 'titleCase',
17
+ titlecase: 'titleCase',
18
+ lang: 'lang',
19
+ width: 'width',
20
+ height: 'height',
21
+ texture: 'texture',
22
+ });
23
+
24
+ /** Separators of a plain line: a tab, or a bar or dash with spaces around it. */
25
+ const LINE_SEPARATOR = /\t| \| | — | – /;
26
+
27
+ /**
28
+ * Split CSV (or TSV) text into rows of fields: quoted fields may hold the delimiter, quotes ("")
29
+ * and line breaks.
30
+ * @param {string} text @param {string} delimiter
31
+ * @returns {string[][]}
32
+ */
33
+ function csvRows(text, delimiter) {
34
+ /** @type {string[][]} */
35
+ const rows = [];
36
+ /** @type {string[]} */
37
+ let row = [];
38
+ let field = '';
39
+ let quoted = false;
40
+ for (let i = 0; i < text.length; i++) {
41
+ const ch = text[i];
42
+ if (quoted) {
43
+ if (ch === '"' && text[i + 1] === '"') {
44
+ field += '"';
45
+ i++;
46
+ } else if (ch === '"') quoted = false;
47
+ else field += ch;
48
+ } else if (ch === '"' && field === '') quoted = true;
49
+ else if (ch === delimiter) {
50
+ row.push(field);
51
+ field = '';
52
+ } else if (ch === '\n' || ch === '\r') {
53
+ if (ch === '\r' && text[i + 1] === '\n') i++;
54
+ row.push(field);
55
+ rows.push(row);
56
+ row = [];
57
+ field = '';
58
+ } else field += ch;
59
+ }
60
+ if (field !== '' || row.length) {
61
+ row.push(field);
62
+ rows.push(row);
63
+ }
64
+ return rows.filter((r) => r.some((f) => f.trim()));
65
+ }
66
+
67
+ /**
68
+ * Cover options from a row's named values: known columns only, trimmed, empty ones left out,
69
+ * numbers and switches read as such.
70
+ * @param {Record<string, unknown>} named
71
+ * @returns {import('./index.js').CoverOptions}
72
+ */
73
+ function optionsFrom(named) {
74
+ /** @type {Record<string, unknown>} */
75
+ const out = {};
76
+ for (const [key, raw] of Object.entries(named)) {
77
+ const option = COLUMNS[/** @type {keyof typeof COLUMNS} */ (key.trim().toLowerCase())];
78
+ if (!option || raw == null) continue;
79
+ if (option === 'texture') {
80
+ const v = String(raw).trim().toLowerCase();
81
+ if (v) out.texture = raw === true || ['1', 'true', 'yes', 'y', 'on'].includes(v);
82
+ continue;
83
+ }
84
+ // (Numbers stay numbers for sizes and the seed; any other column is text, a title of 1984 too.)
85
+ const value = typeof raw === 'number' && option !== 'title' ? raw : String(raw).trim();
86
+ if (value === '') continue;
87
+ if (option === 'width' || option === 'height') out[option] = Number(value);
88
+ else out[option] = option === 'seed' ? value : String(value);
89
+ }
90
+ return /** @type {import('./index.js').CoverOptions} */ (out);
91
+ }
92
+
93
+ /**
94
+ * Read a list of books into cover options, one per book (rows without a title are left out):
95
+ *
96
+ * - CSV or TSV whose first row names its columns (`title`, `author`, `subtitle`, `series`,
97
+ * `credit`, `badge`, `style`, `palette`, `seed`, `font`, `case`, `lang`, `width`, `height`,
98
+ * `texture`; others are ignored);
99
+ * - JSON lines, one object per book, with the same names;
100
+ * - else one book per line: "Title | Author | Subtitle" (a tab, or " — " or " – ", also separates).
101
+ *
102
+ * @param {string} text
103
+ * @returns {import('./index.js').CoverOptions[]}
104
+ */
105
+ export function parseBookList(text) {
106
+ const src = String(text ?? '').replace(/^\uFEFF/, '');
107
+ // (Each with its line number in the text, for errors.)
108
+ const lines = src
109
+ .split(/\r\n|[\r\n]/)
110
+ .map((line, i) => ({ line, number: i + 1 }))
111
+ .filter(({ line }) => line.trim());
112
+ if (!lines.length) return [];
113
+ /** @type {import('./index.js').CoverOptions[]} */
114
+ let books;
115
+ if (lines.every(({ line }) => line.trim().startsWith('{'))) {
116
+ books = lines.map(({ line, number }) => {
117
+ let value;
118
+ try {
119
+ value = JSON.parse(line);
120
+ } catch {
121
+ throw new SyntaxError(`Line ${number} is not valid JSON`);
122
+ }
123
+ return value && typeof value === 'object' ? optionsFrom(value) : {};
124
+ });
125
+ } else {
126
+ // A header row names a "title" column, separated by tabs, commas or semicolons (as
127
+ // spreadsheets in many countries save CSV).
128
+ const first = lines[0].line;
129
+ const delimiter = ['\t', ',', ';'].find((d) =>
130
+ (csvRows(first, d)[0] ?? []).some((name) => name.trim().toLowerCase() === 'title'),
131
+ );
132
+ if (delimiter) {
133
+ const [names, ...rows] = csvRows(src, delimiter);
134
+ books = rows.map((row) => optionsFrom(Object.fromEntries(names.map((name, i) => [name, row[i] ?? '']))));
135
+ } else {
136
+ books = lines.map(({ line }) => {
137
+ const [title, author, subtitle] = line.split(LINE_SEPARATOR);
138
+ return optionsFrom({ title, author, subtitle });
139
+ });
140
+ }
141
+ }
142
+ return books.filter((book) => typeof book.title === 'string' && book.title.trim());
143
+ }
package/src/index.d.ts CHANGED
@@ -4,7 +4,7 @@ import type { FontSpec, FONTS } from './text.js';
4
4
 
5
5
  export type { Rng } from './core.js';
6
6
  export type { Palette, PaletteId } from './palettes.js';
7
- export type { FontSpec, FitOptions, TextFit, DrawOptions, TextSpec, TextMeasurer } from './text.js';
7
+ export type { FontSpec, FitOptions, TextFit, DrawOptions, TextSpec, TextMeasurer, TextBox } from './text.js';
8
8
  export type { StackFit, TitleBlock, TitleBlockOptions } from './type.js';
9
9
  export { palettes } from './palettes.js';
10
10
  export {
@@ -17,11 +17,36 @@ export {
17
17
  textBottom,
18
18
  textGroupAttrs,
19
19
  markExtent,
20
+ noteText,
21
+ recordText,
20
22
  } from './text.js';
21
23
  export { titleBlock, authorFit, fitStack, drawStack, splitLead } from './type.js';
22
24
  export { strongAccents, textColor, tintText, fitTitleWith, textPanel, TEXT_CONTRAST } from './styles/util.js';
23
25
  export * as svgUtils from './core.js';
24
26
 
27
+ export type MockupKind = 'flat' | 'book' | 'ereader' | 'card' | 'square' | 'story';
28
+ /** The kinds of mockup `createMockup` makes. */
29
+ export declare const mockupKinds: readonly MockupKind[];
30
+ export interface MockupOptions {
31
+ /** Default `'book'`. `card` is 1200 × 630, `square` 1080 × 1080, `story` 1080 × 1920; the others follow the cover's size. */
32
+ kind?: MockupKind;
33
+ /** A hex colour behind it. Product shots default to a light studio grey, social cards to the cover's colours. */
34
+ background?: string;
35
+ /** A line for social cards, such as `'Out now'` (up to 80 characters). */
36
+ text?: string;
37
+ }
38
+ /**
39
+ * Read a list of books into cover options, one per book with a title: CSV or TSV whose first row
40
+ * names its columns (title, author, subtitle, series, credit, badge, style, palette, seed, font,
41
+ * case, lang, width, height, texture), JSON lines, or one book per line ("Title | Author | Subtitle").
42
+ */
43
+ export declare function parseBookList(text: string): CoverOptions[];
44
+ /** The cover as a product shot (flat, book, e-reader) or on a social card, as SVG. */
45
+ export declare function createMockup(
46
+ cover: Cover,
47
+ options?: MockupOptions,
48
+ ): { svg: string; width: number; height: number };
49
+
25
50
  /** This version of the library, such as `'1.4.0'`. Artwork for a seed can change between minor versions. */
26
51
  export declare const version: string;
27
52
  /** The sizes a cover can have: each side `min` to `max` px, and neither more than `ratio` times the other. */
@@ -92,6 +117,8 @@ export interface CoverOptions {
92
117
  series?: string;
93
118
  /** A credit set on a line of its own under the subtitle, such as `'Translated by Edith Grossman'`. Up to 120 characters. */
94
119
  credit?: string;
120
+ /** A seal set in a corner clear of the text, such as `'Winner of the Hugo Award'`. Up to 60 characters. */
121
+ badge?: string;
95
122
  /** Add a fine film-grain texture over the cover. Default `false`. */
96
123
  texture?: boolean;
97
124
  /** A style id, a registered custom style, or `'random'` (default; picked from the seed). */
@@ -132,6 +159,8 @@ export interface Cover {
132
159
  style: string;
133
160
  /** The palette that was used (`'custom'` for a custom palette). */
134
161
  palette: string;
162
+ /** The palette's colours, as the cover used them. */
163
+ colors: { bg: string; ink: string; accents: string[]; dark: boolean };
135
164
  seed: string;
136
165
  width: number;
137
166
  height: number;
@@ -139,6 +168,9 @@ export interface Cover {
139
168
  lang: string;
140
169
  /** The value of each of the style's variants this cover has, pinned or picked by the seed. */
141
170
  variants: Record<string, string>;
171
+ /** The title as set (line breaks as "\n"), and the author, after cleaning. */
172
+ title: string;
173
+ author: string;
142
174
  }
143
175
 
144
176
  /** A choice a style lets you pin, such as Silhouette's scene. */
package/src/index.js CHANGED
@@ -5,7 +5,8 @@
5
5
 
6
6
  import { createRng, hashString, isHex, luminance, escapeXml, stripInvalidXml, grain } from './core.js';
7
7
  import { palettes } from './palettes.js';
8
- import { BREAKING_SPACE, FONTS, LINE_BREAK, clipGraphemes } from './text.js';
8
+ import { BREAKING_SPACE, FONTS, LINE_BREAK, clipGraphemes, recordText } from './text.js';
9
+ import { drawBadge } from './badge.js';
9
10
  import { styleList } from './styles/index.js';
10
11
 
11
12
  export { palettes } from './palettes.js';
@@ -19,13 +20,17 @@ export {
19
20
  textBottom,
20
21
  textGroupAttrs,
21
22
  markExtent,
23
+ noteText,
24
+ recordText,
22
25
  } from './text.js';
23
26
  export { titleBlock, authorFit, fitStack, drawStack, splitLead } from './type.js';
24
27
  export { strongAccents, textColor, tintText, fitTitleWith, textPanel, TEXT_CONTRAST } from './styles/util.js';
25
28
  export * as svgUtils from './core.js';
29
+ export { createMockup, mockupKinds } from './mockups.js';
30
+ export { parseBookList } from './books.js';
26
31
 
27
32
  /** This version of the library (artwork for a seed can change between minor versions). */
28
- export const version = '1.5.0';
33
+ export const version = '1.6.0';
29
34
 
30
35
  /** The sizes a cover can have: each side `min` to `max` px, and neither more than `ratio` times the other. */
31
36
  export const sizeLimits = Object.freeze({ min: 50, max: 10000, ratio: 3 });
@@ -371,9 +376,10 @@ export function createCover(options = {}) {
371
376
  const subtitle = cleanText(options.subtitle, 300);
372
377
  const series = cleanText(options.series, 120);
373
378
  const credit = cleanText(options.credit, 120);
379
+ const badge = cleanText(options.badge, 60);
374
380
  const W = dimension(options.width, 600, 'width');
375
381
  const H = dimension(options.height, W * 1.5, 'height');
376
- const lang = resolveLang(options.lang, `${title} ${subtitle} ${author} ${series} ${credit}`);
382
+ const lang = resolveLang(options.lang, `${title} ${subtitle} ${author} ${series} ${credit} ${badge}`);
377
383
  const { ratio } = sizeLimits;
378
384
  if (W / H > ratio || H / W > ratio) {
379
385
  throw new RangeError(`The aspect ratio must be between 1:${ratio} and ${ratio}:1, got ${W}×${H}`);
@@ -402,6 +408,7 @@ export function createCover(options = {}) {
402
408
  ...(Object.keys(pins).length ? [pins] : []),
403
409
  ...(titleType.font || titleType.titleCase ? [options.font ?? '', titleType.titleCase ?? ''] : []),
404
410
  ...(series || credit ? [series, credit] : []),
411
+ ...(badge ? [badge] : []),
405
412
  ]),
406
413
  ).toString(36);
407
414
  /** The variants the style chose (or was given). @type {Record<string, string>} */
@@ -435,7 +442,10 @@ export function createCover(options = {}) {
435
442
  },
436
443
  };
437
444
 
438
- let body = style.render(ctx);
445
+ // The text the style draws is recorded, so a badge can go where there is none.
446
+ const { result: drawn, boxes } = recordText(() => style.render(ctx));
447
+ let body = drawn;
448
+ if (badge) body += drawBadge(ctx, badge, boxes);
439
449
  if (options.texture) {
440
450
  // Film grain over the whole cover. Styles paint their own backgrounds, so lay
441
451
  // dark and light specks (from different noise) to show on any colour.
@@ -445,14 +455,28 @@ export function createCover(options = {}) {
445
455
  }
446
456
  const label = escapeXml(
447
457
  `${title.replace(/\n/g, ' ')}${subtitle ? `: ${subtitle}` : ''}${author ? ` by ${author}` : ''}` +
448
- `${series ? ` (${series})` : ''}${credit ? `. ${credit}` : ''}`,
458
+ `${series ? ` (${series})` : ''}${credit ? `. ${credit}` : ''}${badge ? `. ${badge}` : ''}`,
449
459
  );
450
460
  // (Both spellings: `lang` is SVG 2's, `xml:lang` what older renderers read.)
451
461
  const langAttrs = lang ? ` lang="${lang}" xml:lang="${lang}"` : '';
452
462
  const svg =
453
463
  `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}"${langAttrs} role="img" aria-label="${label}">` +
454
464
  `<title>${label}</title>${body}</svg>`;
455
- return { svg, body, style: styleId, palette: palette.id, seed, width: W, height: H, lang, variants: chosen };
465
+ const colors = { bg: palette.bg, ink: palette.ink, accents: [...palette.accents], dark: palette.dark };
466
+ return {
467
+ svg,
468
+ body,
469
+ style: styleId,
470
+ palette: palette.id,
471
+ colors,
472
+ seed,
473
+ width: W,
474
+ height: H,
475
+ lang,
476
+ variants: chosen,
477
+ title,
478
+ author,
479
+ };
456
480
  }
457
481
 
458
482
  /**
package/src/mockups.js ADDED
@@ -0,0 +1,229 @@
1
+ // Mockups: a cover shown as a product (a flat shot, a book, an e-reader) or on a social card,
2
+ // for listings and cover reveals. Each is an SVG with the cover nested in it.
3
+
4
+ import { n, mix, escapeXml, hashString, contrast, readable } from './core.js';
5
+ import { FONTS, fitText, drawText, textBottom } from './text.js';
6
+
7
+ /** The kinds of mockup, with the size of each (or how it follows the cover's). */
8
+ export const mockupKinds = Object.freeze(['flat', 'book', 'ereader', 'card', 'square', 'story']);
9
+
10
+ /** The neutral backdrop of the product shots. */
11
+ const STUDIO = '#ecebe7';
12
+
13
+ /**
14
+ * 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
16
+ */
17
+ const nest = (cover, x, y, w, h) =>
18
+ `<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>`;
19
+
20
+ /**
21
+ * A soft shadow under a shape: blurred black, offset down.
22
+ * @param {string} id @param {number} blur @param {number} dy @param {number} opacity
23
+ */
24
+ const shadow = (id, blur, dy, opacity) =>
25
+ `<filter id="${id}" x="-30%" y="-30%" width="160%" height="160%">` +
26
+ `<feGaussianBlur in="SourceAlpha" stdDeviation="${n(blur)}"/><feOffset dy="${n(dy)}"/>` +
27
+ `<feComponentTransfer><feFuncA type="linear" slope="${opacity}"/></feComponentTransfer>` +
28
+ `<feMerge><feMergeNode/><feMergeNode in="SourceGraphic"/></feMerge></filter>`;
29
+
30
+ /**
31
+ * The cover as a product or a social card.
32
+ *
33
+ * - `flat`: the cover on a backdrop with a soft shadow.
34
+ * - `book`: a book turned a little, its spine showing.
35
+ * - `ereader`: the cover on an e-reader's screen.
36
+ * - `card` (1200 × 630, a link preview), `square` (1080 × 1080) and `story` (1080 × 1920): the
37
+ * cover on a background in its colours, with the title and author, and `text` if given ("Out now").
38
+ *
39
+ * @param {import('./index.js').Cover} cover from createCover
40
+ * @param {{ kind?: string, background?: string, text?: string }} [options]
41
+ * @returns {{ svg: string, width: number, height: number }}
42
+ */
43
+ export function createMockup(cover, { kind = 'book', background, text = '' } = {}) {
44
+ if (!cover || typeof cover.body !== 'string' || !(cover.width > 0) || !(cover.height > 0)) {
45
+ throw new TypeError('createMockup: pass a cover from createCover');
46
+ }
47
+ if (!mockupKinds.includes(kind)) {
48
+ throw new RangeError(`Unknown mockup "${kind}". Available: ${mockupKinds.join(', ')}`);
49
+ }
50
+ if (background != null && !/^#(?:[0-9a-f]{3}){1,2}$/i.test(background)) {
51
+ throw new RangeError(`createMockup: background must be a hex colour, got ${JSON.stringify(background)}`);
52
+ }
53
+ // Ids unique to this cover and kind of mockup, so several can share a page.
54
+ const tag = `mk${hashString(cover.svg).toString(36)}-${kind}`;
55
+ const id = (/** @type {string} */ name) => `${tag}-${name}`;
56
+ const { width: CW, height: CH, colors } = cover;
57
+ const words = String(text).replace(/\s+/g, ' ').trim().slice(0, 80);
58
+ const label = escapeXml(`${cover.title.replace(/\n/g, ' ')}${cover.author ? ` by ${cover.author}` : ''}`);
59
+
60
+ // (The cover's language too, so Chinese characters keep that language's forms.)
61
+ const lang = cover.lang ? ` lang="${escapeXml(cover.lang)}" xml:lang="${escapeXml(cover.lang)}"` : '';
62
+ /** @param {number} W @param {number} H @param {string} inner */
63
+ const wrap = (W, H, inner) => ({
64
+ svg:
65
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${Math.round(W)}" height="${Math.round(H)}" viewBox="0 0 ${n(W)} ${n(H)}"${lang} role="img" aria-label="${label}">` +
66
+ `<title>${label}</title>${inner}</svg>`,
67
+ width: Math.round(W),
68
+ height: Math.round(H),
69
+ });
70
+
71
+ if (kind === 'flat') {
72
+ const pad = Math.min(CW, CH) * 0.14;
73
+ const W = CW + pad * 2;
74
+ const H = CH + pad * 2;
75
+ const u = Math.min(CW, CH) / 600;
76
+ return wrap(
77
+ W,
78
+ H,
79
+ `<defs>${shadow(id('shadow'), 14 * u, 10 * u, 0.32)}</defs>` +
80
+ `<rect width="${n(W)}" height="${n(H)}" fill="${background ?? STUDIO}"/>` +
81
+ `<g filter="url(#${id('shadow')})">${nest(cover, pad, pad, CW, CH)}</g>`,
82
+ );
83
+ }
84
+
85
+ if (kind === 'book') {
86
+ // The front turned a little away (narrower, its far edge higher), with the spine at its left.
87
+ const turn = 0.9;
88
+ const rise = 0.07;
89
+ const spine = CW * 0.075;
90
+ const pad = Math.min(CW, CH) * 0.16;
91
+ const W = spine + CW * turn + pad * 2;
92
+ const H = CH + CW * rise + pad * 2;
93
+ const x0 = pad + spine;
94
+ const y0 = pad + CW * rise;
95
+ const u = Math.min(CW, CH) / 600;
96
+ const spineFill = mix(colors.bg, '#000000', 0.32);
97
+ const sag = spine * 0.32;
98
+ return wrap(
99
+ W,
100
+ H,
101
+ `<defs>${shadow(id('soft'), 22 * u, 0, 1)}` +
102
+ `<linearGradient id="${id('gloss')}" x1="0" y1="0" x2="1" y2="0">` +
103
+ `<stop offset="0" stop-color="#000" stop-opacity="0.28"/><stop offset="0.04" stop-color="#fff" stop-opacity="0.16"/>` +
104
+ `<stop offset="0.09" stop-color="#000" stop-opacity="0.05"/><stop offset="0.6" stop-color="#fff" stop-opacity="0"/>` +
105
+ `<stop offset="1" stop-color="#fff" stop-opacity="0.08"/></linearGradient>` +
106
+ `<linearGradient id="${id('spine')}" x1="0" y1="0" x2="1" y2="0">` +
107
+ `<stop offset="0" stop-color="#000" stop-opacity="0.3"/><stop offset="0.7" stop-color="#fff" stop-opacity="0.08"/>` +
108
+ `<stop offset="1" stop-color="#000" stop-opacity="0.25"/></linearGradient></defs>` +
109
+ `<rect width="${n(W)}" height="${n(H)}" fill="${background ?? STUDIO}"/>` +
110
+ // The shadow on the floor.
111
+ `<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')})"/>` +
112
+ `<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}"/>` +
113
+ `<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')})"/>` +
114
+ `<g transform="matrix(${turn} ${-rise} 0 1 ${n(x0)} ${n(y0)})">${nest(cover, 0, 0, CW, CH)}` +
115
+ `<rect width="${CW}" height="${CH}" fill="url(#${id('gloss')})"/></g>`,
116
+ );
117
+ }
118
+
119
+ if (kind === 'ereader') {
120
+ const side = CW * 0.07;
121
+ const top = CH * 0.07;
122
+ const bottom = CH * 0.12;
123
+ const pad = Math.min(CW, CH) * 0.14;
124
+ const DW = CW + side * 2;
125
+ const DH = CH + top + bottom;
126
+ const W = DW + pad * 2;
127
+ const H = DH + pad * 2;
128
+ const u = Math.min(CW, CH) / 600;
129
+ return wrap(
130
+ W,
131
+ H,
132
+ `<defs>${shadow(id('shadow'), 16 * u, 12 * u, 0.35)}</defs>` +
133
+ `<rect width="${n(W)}" height="${n(H)}" fill="${background ?? STUDIO}"/>` +
134
+ `<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>` +
135
+ `<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)}"/>` +
136
+ nest(cover, pad + side, pad + top, CW, CH) +
137
+ `<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"/>`,
138
+ );
139
+ }
140
+
141
+ // Social cards: fixed sizes, in the cover's colours.
142
+ const [W, H] = { card: [1200, 630], square: [1080, 1080], story: [1080, 1920] }[
143
+ /** @type {'card' | 'square' | 'story'} */ (kind)
144
+ ];
145
+ const bg = background ?? mix(colors.bg, colors.dark ? '#000000' : colors.ink, colors.dark ? 0.35 : 0.08);
146
+ // Whichever of near-black and near-white reads better on it.
147
+ const ink = readable(bg, ['#141414', '#f7f5f0']);
148
+ const u = Math.min(W, H) / 600;
149
+ // Where the cover goes (as large as its area allows), where the words go, and their sizes.
150
+ const layout = {
151
+ card: { area: { x: 70, y: 63, w: 460, h: 504 }, below: 0, margin: 0, sizes: [64, 24, 28] },
152
+ square: { area: { x: 120, y: 70, w: W - 240, h: 594 }, below: 60, margin: 70, sizes: [56, 26, 30] },
153
+ story: { area: { x: 120, y: 240, w: W - 240, h: 980 }, below: 110, margin: 220, sizes: [72, 30, 36] },
154
+ }[/** @type {'card' | 'square' | 'story'} */ (kind)];
155
+ const { area } = layout;
156
+ const [titleSize, authorSize, noteSize] = layout.sizes;
157
+ const scale = Math.min(area.w / CW, area.h / CH);
158
+ const cw = CW * scale;
159
+ const ch = CH * scale;
160
+ const cx = kind === 'card' ? area.x + (area.w - cw) / 2 : (W - cw) / 2;
161
+ const cy = area.y + (area.h - ch) / 2;
162
+ let s =
163
+ `<defs>${shadow(id('shadow'), 18 * u, 14 * u, 0.4)}</defs>` +
164
+ `<rect width="${W}" height="${H}" fill="${bg}"/>` +
165
+ `<g filter="url(#${id('shadow')})">${nest(cover, cx, cy, cw, ch)}</g>`;
166
+ // The words: beside the cover on a card, under it otherwise.
167
+ const box =
168
+ kind === 'card'
169
+ ? { x: 590, y: 120, w: 540, h: 390, anchor: 'start' }
170
+ : {
171
+ x: W / 2,
172
+ y: cy + ch + layout.below,
173
+ w: W - 200,
174
+ h: H - (cy + ch + layout.below) - layout.margin,
175
+ anchor: 'middle',
176
+ };
177
+ const title = fitText(cover.title, {
178
+ font: FONTS.serif,
179
+ weight: 700,
180
+ maxWidth: box.w,
181
+ maxHeight: box.h * 0.55,
182
+ maxSize: titleSize,
183
+ maxLines: 4,
184
+ lineHeight: 1.08,
185
+ });
186
+ const author = cover.author
187
+ ? fitText(cover.author, {
188
+ font: FONTS.sans,
189
+ weight: 600,
190
+ upper: true,
191
+ letterSpacing: 0.12,
192
+ maxWidth: box.w,
193
+ maxSize: authorSize,
194
+ maxLines: 2,
195
+ })
196
+ : null;
197
+ const note = words
198
+ ? fitText(words, {
199
+ font: FONTS.sans,
200
+ weight: 700,
201
+ maxWidth: box.w,
202
+ maxSize: noteSize,
203
+ maxLines: 2,
204
+ })
205
+ : null;
206
+ const gap = kind === 'card' ? 22 : 18 * u;
207
+ const blockH =
208
+ textBottom(title, 0) + (author ? gap + textBottom(author, 0) : 0) + (note ? gap * 1.6 + textBottom(note, 0) : 0);
209
+ let y = kind === 'card' ? box.y + (box.h - blockH) / 2 : box.y + Math.max(0, (box.h - blockH) / 2);
210
+ const anchor = /** @type {'start' | 'middle'} */ (box.anchor);
211
+ s += drawText(title, { x: box.x, y, anchor, fill: ink });
212
+ y = textBottom(title, y);
213
+ if (author) {
214
+ y += gap;
215
+ s += drawText(author, { x: box.x, y, anchor, fill: ink, opacity: 0.8 });
216
+ y = textBottom(author, y);
217
+ }
218
+ if (note) {
219
+ y += gap * 1.6;
220
+ s += drawText(note, {
221
+ x: box.x,
222
+ y,
223
+ anchor,
224
+ // An accent if one reads on the background (3:1, for large bold text), else the ink.
225
+ fill: colors.accents.find((a) => contrast(a, bg) >= 3) ?? ink,
226
+ });
227
+ }
228
+ return wrap(W, H, s);
229
+ }
@@ -18,7 +18,7 @@ import {
18
18
  shadowFilter,
19
19
  linearGradient,
20
20
  } from '../core.js';
21
- import { FONTS, fitText, drawText, textBottom, typedLines } from '../text.js';
21
+ import { FONTS, fitText, drawText, textBottom, typedLines, noteText } from '../text.js';
22
22
  import { titleBlock, authorFit } from '../type.js';
23
23
  import { strongAccents, textPanel } from './util.js';
24
24
 
@@ -849,6 +849,7 @@ function drawPixelText(fit, x, y, anchor, color) {
849
849
  const w = (ln.length * 6 - 1) * q;
850
850
  const x0 = anchor === 'middle' ? x - w / 2 : x;
851
851
  const y0 = y + li * 10 * q;
852
+ noteText(x0, y0, x0 + w, y0 + 7 * q);
852
853
  [...ln].forEach((ch, ci) => {
853
854
  const glyph = PIXEL_FONT[ch];
854
855
  if (!glyph) return;
@@ -22,6 +22,7 @@ import {
22
22
  textGroupAttrs,
23
23
  baselineOf,
24
24
  typedLines,
25
+ noteText,
25
26
  } from '../text.js';
26
27
  import { titleBlock, authorFit } from '../type.js';
27
28
  import { strongAccents, flower, initialChars, textPanel, textColor } from './util.js';
@@ -283,6 +284,8 @@ const TYPED_BY_LETTER =
283
284
  function typedLine(str, x, y, size, weight, rng, color, anchor = 'start') {
284
285
  const spec = { font: FONTS.mono, weight };
285
286
  const width = measureText(str, size, spec);
287
+ const left = anchor === 'middle' ? x - width / 2 : x;
288
+ noteText(left, y - size * 0.8, left + width, y + size * 0.3);
286
289
  if (!TYPED_BY_LETTER.test(str)) {
287
290
  // Keep joining, conjuncts and direction intact: one run of text, no per-letter jitter.
288
291
  return `<text x="${n(x)}" y="${n(y)}" ${textGroupAttrs({ ...spec, size, fill: color, anchor })}>${escapeXml(str)}</text>`;
package/src/text.d.ts CHANGED
@@ -56,6 +56,17 @@ export declare const BREAKING_SPACE: RegExp;
56
56
  export declare const LINE_BREAK: RegExp;
57
57
  /** The number of lines text was typed on: 1 without line breaks. */
58
58
  export declare function typedLines(text: string): number;
59
+ /** Where some text was drawn. */
60
+ export interface TextBox {
61
+ x0: number;
62
+ y0: number;
63
+ x1: number;
64
+ y1: number;
65
+ }
66
+ /** Note that text was drawn in this box (drawText and drawStack do it), so a badge can be placed clear of it. */
67
+ export declare function noteText(x0: number, y0: number, x1: number, y1: number): void;
68
+ /** Run `draw` and collect the boxes of the text it draws. */
69
+ export declare function recordText<T>(draw: () => T): { result: T; boxes: TextBox[] };
59
70
  /** The start of a string, cut between user-perceived characters (`units`: count UTF-16 code units). */
60
71
  export declare function clipGraphemes(str: string, max: number, options?: { units?: boolean }): string;
61
72
  /** Split a string into user-perceived characters. */
package/src/text.js CHANGED
@@ -864,6 +864,8 @@ export const baselineOf = (fit, y, i) => y + fit.size * ((fit.above ?? 0) + fit.
864
864
  */
865
865
  export function drawText(fit, { x, y, anchor = 'start', fill = '#000', opacity, attrs = '' }) {
866
866
  if (!fit.lines.length) return '';
867
+ const left = anchor === 'middle' ? x - fit.width / 2 : anchor === 'end' ? x - fit.width : x;
868
+ noteText(left, y, left + fit.width, textBottom(fit, y));
867
869
  const { size, font, weight, italic, letterSpacing } = fit;
868
870
  const ls = letterSpacing * size;
869
871
  const dx = spacingShift(anchor, ls);
@@ -874,5 +876,34 @@ export function drawText(fit, { x, y, anchor = 'start', fill = '#000', opacity,
874
876
  return `<g ${a}>${rows}</g>`;
875
877
  }
876
878
 
879
+ /** @typedef {{ x0: number, y0: number, x1: number, y1: number }} TextBox where some text was drawn */
880
+
881
+ /** The text drawn while a cover renders (see recordText), or null when nothing records it. @type {TextBox[] | null} */
882
+ let textBoxes = null;
883
+
884
+ /**
885
+ * Note that text was drawn in this box, so a badge can be placed clear of it. drawText and
886
+ * drawStack do this themselves; styles that write their own text elements call it.
887
+ * @param {number} x0 @param {number} y0 @param {number} x1 @param {number} y1
888
+ */
889
+ export function noteText(x0, y0, x1, y1) {
890
+ if (textBoxes && [x0, y0, x1, y1].every(Number.isFinite)) textBoxes.push({ x0, y0, x1, y1 });
891
+ }
892
+
893
+ /**
894
+ * Run `draw` and collect the boxes of the text it draws.
895
+ * @template T @param {() => T} draw @returns {{ result: T, boxes: TextBox[] }}
896
+ */
897
+ export function recordText(draw) {
898
+ const outer = textBoxes;
899
+ textBoxes = [];
900
+ try {
901
+ const result = draw();
902
+ return { result, boxes: textBoxes };
903
+ } finally {
904
+ textBoxes = outer;
905
+ }
906
+ }
907
+
877
908
  /** Visual bottom of a fit placed at `y`, including descenders (and marks below). */
878
909
  export const textBottom = (fit, y) => (fit.lines.length ? y + fit.height + fit.size * (fit.below ?? DESCENT) : y);
package/src/type.js CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  measureText,
15
15
  textBottom,
16
16
  textGroupAttrs,
17
+ noteText,
17
18
  spacingShift,
18
19
  defaultMinSize,
19
20
  checkBox,
@@ -142,6 +143,8 @@ export function fitStack(text, o) {
142
143
  * @param {import('./text.js').DrawOptions & { lineFills?: (string | undefined)[] }} o
143
144
  */
144
145
  export function drawStack(fit, { x, y, anchor = 'start', fill = '#000', opacity, attrs = '', lineFills = [] }) {
146
+ const left = anchor === 'middle' ? x - fit.width / 2 : anchor === 'end' ? x - fit.width : x;
147
+ noteText(left, y, left + fit.width, y + fit.height + fit.descent);
145
148
  const a = textGroupAttrs({ font: fit.font, weight: fit.weight, italic: fit.italic, fill, anchor, opacity, attrs });
146
149
  let baseline = y;
147
150
  const rows = fit.lines.map(({ text, size, above = 0 }, i) => {