book-cover-generator 1.6.0 → 1.8.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
@@ -5,9 +5,10 @@
5
5
  [![Website](https://img.shields.io/website?url=https%3A%2F%2Fcovers.otsirh.dev&label=covers.otsirh.dev)](https://covers.otsirh.dev)
6
6
  ![Dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)
7
7
  ![Types](https://img.shields.io/badge/types-included-blue)
8
+ [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/otsirh/book-cover-generator/badge)](https://scorecard.dev/viewer/?uri=github.com/otsirh/book-cover-generator)
8
9
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
9
10
 
10
- Generate book covers from a **title** and an **author**. 34 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 and in Node.
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.
11
12
 
12
13
  **[▶ Open the generator](https://covers.otsirh.dev)**
13
14
 
@@ -15,9 +16,9 @@ Generate book covers from a **title** and an **author**. 34 styles, 28 palettes
15
16
 
16
17
  ## Features
17
18
 
18
- - **34 styles in six families.**
19
+ - **39 styles in six families.**
19
20
  - Typographic: Big Type, Minimal, Big Letter, Swiss.
20
- - Genre: Thriller, Botanical, Noir, Blueprint.
21
+ - Genre: Thriller, Botanical, Noir, Blueprint, Hollow (horror), Ribbon (romance), Crest (fantasy), Storybook (children's), Big Idea (non-fiction).
21
22
  - Vintage: Art Deco, Classic Paperback, Engraved, Sunburst, Typewriter.
22
23
  - Scenic: Landscape, Retro Sun, Silhouette, Constellation, Orbit, Waves, Aurora, Bookshelf.
23
24
  - Geometric: Bauhaus, Split, Blocks, Concentric, Stripes.
@@ -33,13 +34,13 @@ Generate book covers from a **title** and an **author**. 34 styles, 28 palettes
33
34
 
34
35
  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
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
+ - **a style**, from a rail that previews your book in all 39 styles (its Genre menu shows only those that suit a genre, and Randomize then stays in it). Fifteen styles let you pin a choice the variation otherwise makes, such as Silhouette's city or forest, or where Aurora puts the title;
37
38
  - **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;
38
39
  - **a palette**, from swatches that show the background, text colour and accents, or **Custom** for your own five colours (it starts from the palette on the cover and shows the text contrast);
39
40
  - **a variation**, the seed behind the artwork: the strip under the preview shows six others to click;
40
41
  - **a format**: paperback, eBook, square, or **Custom** for any size in pixels, typed in or picked from common sizes (Kindle, print trim sizes at 300 dpi, audiobook, social posts).
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
+ **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, and **Copy image link** gives the [HTTP API](#http-api) URL that draws it, to put in any page. The export panel tells you which size suits Kindle, print or audiobook covers.
43
44
 
44
45
  **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
 
@@ -58,6 +59,66 @@ Keyboard shortcuts:
58
59
 
59
60
  The single-key shortcuts can be turned off under the controls (for example with speech input).
60
61
 
62
+ ## HTTP API
63
+
64
+ The site draws covers from a URL too, so a page can show one with nothing installed:
65
+
66
+ ```html
67
+ <img src="https://covers.otsirh.dev/api/cover.svg?title=Dune&author=Frank+Herbert&style=retro" alt="Dune" />
68
+ ```
69
+
70
+ - `GET /api/cover.svg` answers with the cover as SVG.
71
+ - `GET /api/mockup.svg` answers with a [mockup](#mockups) of it. Add `kind` (`book`, `flat`, `ereader`, `card`, `square`, `story`), `text` for the line on a card, square or story, and `background`.
72
+
73
+ The query takes the options' own names (`title`, `author`, `subtitle`, `series`, `credit`, `badge`, `style`, `palette`, `seed`, `genre`, `font`, `lang`, `width`, `height`) and the app's link parameters, so the part of an app link after `#` draws the same design (the server measures text with the widths of the fonts as macOS draws them, so on another system a line can break a little differently from the app's preview):
74
+
75
+ | parameter | value |
76
+ | --------------------- | ---------------------------------------------------------------------------------------------- |
77
+ | `format` | `paperback`, `ebook`, `square`, or a size in px such as `1600x2560` |
78
+ | `case` | the title `upper`, or `normal` (as typed) |
79
+ | `texture` | `1` (on) or `0` (off) |
80
+ | `variant` | pinned choices as `name.value`, joined by `-`: `field.pale-emblem.sun` |
81
+ | `palette` | a palette id, or your own colours as `custom-0b132b-f8f9fa-ff6b6b` (background, text, accents) |
82
+ | `bg`, `ink`, `accent` | your own colours, as hex (the `#` is optional; accents separated by commas) |
83
+
84
+ A title breaks its line at `%0A`. Anyone may embed the answers (CORS and `Cross-Origin-Resource-Policy` allow it), and the headers `X-Cover-Style`, `X-Cover-Palette` and `X-Cover-Version` say what was drawn. The same URL always draws the same cover in a version of the library, so answers are cached for a day. Options the library rejects get a `400` with the reason as plain text.
85
+
86
+ ## On any page: `<book-cover>`
87
+
88
+ One script, then a cover wherever you write the element:
89
+
90
+ ```html
91
+ <script type="module" src="https://covers.otsirh.dev/src/element.js"></script>
92
+
93
+ <book-cover cover-title="Dune" author="Frank Herbert" cover-style="retro" seed="1"></book-cover>
94
+ ```
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'`.
97
+
98
+ ## From an AI assistant (MCP)
99
+
100
+ Book Cover Generator is a [Model Context Protocol](https://modelcontextprotocol.io) server, so an assistant such as Claude can make covers for you: "a cover for my horror novel _The Hollow Night_, in red". It answers with a link to the image and one that opens the cover in the app to change and download.
101
+
102
+ Add it to Claude Code:
103
+
104
+ ```sh
105
+ claude mcp add book-covers -- npx -y book-cover-generator --mcp
106
+ # or, with nothing installed, the hosted server:
107
+ claude mcp add --transport http book-covers https://covers.otsirh.dev/mcp
108
+ ```
109
+
110
+ Or to any client that takes a JSON configuration (Claude Desktop, Cursor and others):
111
+
112
+ ```json
113
+ {
114
+ "mcpServers": {
115
+ "book-covers": { "command": "npx", "args": ["-y", "book-cover-generator", "--mcp"] }
116
+ }
117
+ }
118
+ ```
119
+
120
+ The tools: `create_cover` (a title, and any of author, subtitle, style, genre, palette or your own colours, seed, format, font, pins), `create_mockup` (the same, as a 3D book, on an e-reader or on a social card), `list_styles` and `list_palettes`. Run locally, `create_cover` can also save the SVG to a file (`output`).
121
+
61
122
  ## Library usage
62
123
 
63
124
  ### Browser (no install)
@@ -77,7 +138,7 @@ The single-key shortcuts can be turned off under the controls (for example with
77
138
 
78
139
  `@1` gives you the latest 1.x release. Pin an exact version (such as `@1.1.0`) if you need the artwork to never change.
79
140
 
80
- ### Node / bundlers
141
+ ### Node, Deno, Bun and bundlers
81
142
 
82
143
  ```sh
83
144
  npm install book-cover-generator
@@ -98,6 +159,8 @@ const { svg, style, palette, seed } = createCover({
98
159
  writeFileSync('cover.svg', svg);
99
160
  ```
100
161
 
162
+ In Deno, import `npm:book-cover-generator`; in Bun, `bun add book-cover-generator`. CI checks that Node, Deno and Bun draw the same covers byte for byte.
163
+
101
164
  ### CLI
102
165
 
103
166
  ```sh
@@ -116,7 +179,7 @@ npx book-cover-generator --batch books.csv --style minimal --seed series --out-d
116
179
  npx book-cover-generator "Salt and Static" --count 12 -o ideas/cover.svg
117
180
  ```
118
181
 
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`.
182
+ Options: `-s/--style`, `-p/--palette`, `--bg`, `--ink`, `--accent`, `--subtitle`, `--texture`, `--seed`, `--format` (`paperback`, `ebook` or `square`), `--width`, `--height`, `--size`, `--genre`, `--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`.
120
183
 
121
184
  ## API
122
185
 
@@ -144,9 +207,12 @@ Same as `generateCover`, but also tells you which style and palette were chosen.
144
207
  | `height` | `number` | `width × 1.5` | px, 50–10000 |
145
208
  | `lang` | `string` | inferred | language of the text, such as `'ja'` |
146
209
  | `variants` | `object` | `{}` | pin a style's choices: `{ scene: 'forest' }` |
210
+ | `genre` | `string` | | with a random style, one that suits this genre |
147
211
  | `font` | `string` | the style's | the title's font, one of `FONTS` |
148
212
  | `titleCase` | `string` | the style's | `'upper'` (capitals) or `'normal'` (as typed) |
149
213
 
214
+ Styles, palettes, genres and fonts may be given by id or by name, in any case: `'artdeco'`, `'Art Deco'` and `'ART DECO'` are the same style, and a book list or a link can name them as the app's menus do.
215
+
150
216
  `lang` is set on the cover so Chinese characters get the right glyphs for the language. Without it, text with kana is marked Japanese and text with Hangul Korean; pass `'zh-Hans'`, `'zh-Hant'` or `'ja'` for text in Chinese characters only.
151
217
 
152
218
  Titles and subtitles longer than 300 characters (authors: 200) are cut. With `'random'`, the style and palette are picked from the seed, so you get the same result every time for the same seed. Some styles prefer dark or light palettes when they pick one themselves. Invalid options throw an error that says what's wrong.
@@ -154,13 +220,16 @@ Titles and subtitles longer than 300 characters (authors: 200) are cut. With `'r
154
220
  ### Other exports
155
221
 
156
222
  - `styles`: array of style ids
157
- - `listStyles()`: `[{ id, name, description, group, variants }]`. `variants` lists the choices a style lets you pin, as `[{ name, label, values: [{ value, label }] }]`; pin them with `createCover({ variants: { scene: 'forest' } })`, and `createCover` reports the ones it used in `variants`. Names a style lacks are ignored; a value it lacks throws
223
+ - `genres`: `[{ id, name }]`, the genres styles suit: `literary`, `thriller`, `horror`, `romance`, `fantasy`, `children`, `nonfiction`
224
+ - `listStyles()`: `[{ id, name, description, group, variants, genres }]`. `variants` lists the choices a style lets you pin, as `[{ name, label, values: [{ value, label }] }]`; pin them with `createCover({ variants: { scene: 'forest' } })`, and `createCover` reports the ones it used in `variants`. Names a style lacks are ignored. A value no style offers throws; with a style you name, so does a value that style lacks (a style picked at random takes only the pins it can)
158
225
  - `palettes`: `[{ id, name, bg, ink, accents, dark? }]`
159
226
  - `randomSeed()`: a short random seed string
160
227
  - `pickStyle(options)`: the style id `createCover` would use for these options (resolving `'random'` from the seed), without drawing the cover
161
228
  - `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
162
229
  - `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
230
  - `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
231
+ - `styleIdFor(name)`: the id of the style a name or id names, in any case (`'Art Deco'` → `'artdeco'`), or `undefined`
232
+ - `optionsForBook(book, defaults)`: the options for one of those books over the options every book shares (its own values win; a genre the shared style doesn't suit picks a style that does)
164
233
  - `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
165
234
  - `version`: the library's version (artwork for a seed can change between minor versions)
166
235
  - `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
@@ -256,7 +325,7 @@ registerStyle({
256
325
 
257
326
  ## Notes
258
327
 
259
- - **Fonts.** Covers only use system font stacks (Georgia, Helvetica, Futura, Didot, Impact, Courier and others), each with close matches on Windows, Android and Linux. This is because an SVG drawn into a canvas for PNG/JPEG export can't load web fonts. In the browser, text is measured with a canvas, so it fits exactly. In Node, widths are estimated; plug in a real font engine with `setTextMeasurer` if you need exact fits there.
328
+ - **Fonts.** Covers only use system font stacks (Georgia, Helvetica, Futura, Didot, Impact, Courier and others), each with close matches on Windows, Android and Linux. This is because an SVG drawn into a canvas for PNG/JPEG export can't load web fonts. In the browser, text is measured with a canvas, so it fits exactly. In Node (and the CLI and HTTP API), the built-in fonts are measured from their characters' widths as macOS draws them, so text fits much as it does in the app; fonts of your own are estimated. Plug in a real font engine with `setTextMeasurer` if you need exact fits there.
260
329
  - **Deterministic.** The same options always give the same artwork. In Node the SVG is identical every time. In the browser, text is measured with the fonts installed on that computer, so line breaks can differ slightly between machines. Registering a custom style changes which style `style: 'random'` picks for a given seed.
261
330
  - **Versioning.** Artwork for a given seed can change in minor releases (a style gets better). Pin an exact version if you need byte-identical covers forever.
262
331
  - **Browser support.** Current Chrome, Edge, Firefox and Safari. Every release is tested in Chromium, Firefox and WebKit, including PNG and JPEG export.
@@ -271,9 +340,10 @@ npm run dev # http://127.0.0.1:4173
271
340
 
272
341
  | script | what it does |
273
342
  | ----------------------- | ----------------------------------------------------------------- |
274
- | `npm run check` | lint, format check, type check, package check and unit tests |
343
+ | `npm run check` | lint, format, type and package checks, unit tests and fuzzing |
275
344
  | `npm test` | unit tests (`node --test`) |
276
345
  | `npm run test:coverage` | unit tests with coverage thresholds |
346
+ | `npm run test:fuzz` | property-based fuzzing (fast-check); `FUZZ_RUNS=20` for more |
277
347
  | `npm run test:e2e` | browser tests in Chromium, Firefox, WebKit and mobile Chrome |
278
348
  | `npm run format` | format everything with Prettier |
279
349
  | `npm run assets` | regenerate the app icons, the social preview and the README image |
@@ -285,7 +355,7 @@ Open `examples/contact-sheet.html?palettes=noir,mint&seeds=a,b` on the dev serve
285
355
 
286
356
  ```
287
357
  src/ the library (plain ES modules + .d.ts types)
288
- styles/ the 34 styles, one file per family
358
+ styles/ the 39 styles, in a file per family
289
359
  bin/ the CLI
290
360
  site/ the web app (index.html is at the root)
291
361
  public/ files served at the site root: headers, 404, icons, manifest, service worker
@@ -293,9 +363,10 @@ test/ unit tests (node --test)
293
363
  test-d/ type tests (tsc)
294
364
  e2e/ browser tests (Playwright)
295
365
  scripts/ build, dev server and asset generation
366
+ worker/ the HTTP API (the site's Worker)
296
367
  ```
297
368
 
298
- The site is a static [Cloudflare Workers](https://developers.cloudflare.com/workers/static-assets/) deployment configured in `wrangler.jsonc`, with security headers in `public/_headers`.
369
+ The site is a [Cloudflare Workers](https://developers.cloudflare.com/workers/static-assets/) deployment configured in `wrangler.jsonc`: static assets, with security headers in `public/_headers`, and a Worker (`worker/index.js`) that answers `/api/*`. The dev server answers `/api/*` the same way.
299
370
 
300
371
  ## Contributing
301
372
 
package/bin/book-cover.js CHANGED
@@ -1,14 +1,21 @@
1
1
  #!/usr/bin/env node
2
2
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
- import { join } from 'node:path';
3
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
4
+ import { createInterface } from 'node:readline';
4
5
  import { parseArgs } from 'node:util';
5
- import { createCover, listStyles, palettes, formats, parseBookList } from '../src/index.js';
6
+ import { createCover, listStyles, palettes, parseBookList, optionsForBook, genres } from '../src/index.js';
7
+ import { hex, slug } from '../src/shared.js';
8
+ import { px, formatSize } from '../src/link.js';
9
+ import { handlePayload } from '../src/mcp.js';
6
10
 
7
11
  const HELP = `Usage: book-cover "<title>" ["<author>"] [options]
8
12
 
9
13
  Options:
10
14
  -s, --style <id> style id or "random" (default: random from seed)
11
15
  -p, --palette <id> palette id or "random" (default: random from seed)
16
+ --genre <id> with a random style, one that suits this genre:
17
+ literary, thriller, horror, romance, fantasy, children
18
+ or nonfiction
12
19
  --bg <hex> your own colours instead of a palette: background,
13
20
  --ink <hex> text,
14
21
  --accent <hex> and accents (repeat it, or separate them with commas)
@@ -23,14 +30,16 @@ Options:
23
30
  --badge <text> a seal in a corner clear of the text ("Bestseller")
24
31
  --texture add a fine film-grain texture
25
32
  --seed <value> seed for the artwork (default: title + author)
26
- --format <shape> paperback (2:3, the default), ebook (5:8) or square
33
+ --format <shape> paperback (2:3, the default), ebook (5:8), square, or a
34
+ shape such as 1600x2560; with --width or --height, that
35
+ shape at that width or height
27
36
  --width <px> width (default 600)
28
37
  --height <px> height (default: from the format)
29
38
  --size <WxH> width and height at once, e.g. 1600x2560
30
39
  --lang <tag> language of the text, e.g. ja or zh-Hant (default: inferred)
31
40
  --batch <file> a cover for each book in a list (- reads stdin): CSV or
32
41
  TSV with a header row (title, author, subtitle, series,
33
- credit, badge, style, palette, seed, font, case, lang,
42
+ credit, badge, style, genre, palette, seed, font, case, lang,
34
43
  width, height, texture), JSON lines, or one book per
35
44
  line as "Title | Author | Subtitle"; the other options
36
45
  apply to every book the list doesn't say otherwise for
@@ -41,6 +50,8 @@ Options:
41
50
  --json print the choices (and the SVG, without --out) as JSON,
42
51
  one line per cover
43
52
  --list list styles and palettes
53
+ --mcp serve the Model Context Protocol on stdin and stdout, so an
54
+ AI assistant can make covers (see the README)
44
55
  --help show this help
45
56
 
46
57
  Examples:
@@ -57,6 +68,7 @@ try {
57
68
  options: {
58
69
  style: { type: 'string', short: 's' },
59
70
  palette: { type: 'string', short: 'p' },
71
+ genre: { type: 'string' },
60
72
  bg: { type: 'string' },
61
73
  ink: { type: 'string' },
62
74
  accent: { type: 'string', multiple: true },
@@ -81,6 +93,7 @@ try {
81
93
  json: { type: 'boolean' },
82
94
  list: { type: 'boolean' },
83
95
  help: { type: 'boolean' },
96
+ mcp: { type: 'boolean' },
84
97
  },
85
98
  });
86
99
  } catch (err) {
@@ -95,6 +108,10 @@ const { values, positionals } = args;
95
108
  * set, not exited with, so output piped elsewhere is written in full first.)
96
109
  */
97
110
  function preflight() {
111
+ if (values.mcp) {
112
+ serveMcp();
113
+ return false;
114
+ }
98
115
  if (values.help) {
99
116
  console.log(HELP);
100
117
  return false;
@@ -103,10 +120,13 @@ function preflight() {
103
120
  console.log('Styles:');
104
121
  for (const s of listStyles()) {
105
122
  console.log(` ${s.id.padEnd(14)} ${s.description}`);
123
+ if (s.genres.length) console.log(` ${''.padEnd(14)} genres: ${s.genres.join(', ')}`);
106
124
  for (const v of s.variants) {
107
125
  console.log(` ${''.padEnd(14)} --variant ${v.name}=${v.values.map((x) => x.value).join('|')}`);
108
126
  }
109
127
  }
128
+ console.log('\nGenres (--genre):');
129
+ for (const g of genres) console.log(` ${g.id.padEnd(14)} ${g.name}`);
110
130
  console.log('\nPalettes:');
111
131
  for (const p of palettes) console.log(` ${p.id.padEnd(14)} ${p.name}${p.dark ? ' (dark)' : ''}`);
112
132
  return false;
@@ -129,17 +149,6 @@ function preflight() {
129
149
  return true;
130
150
  }
131
151
 
132
- /**
133
- * A --width/--height value as a number, reporting what was typed if it isn't one.
134
- * @param {string | undefined} value @param {string} name
135
- */
136
- function size(value, name) {
137
- if (value === undefined) return undefined;
138
- const number = Number(value);
139
- if (!value.trim() || !Number.isFinite(number)) throw new RangeError(`--${name} must be a number, got "${value}"`);
140
- return number;
141
- }
142
-
143
152
  /**
144
153
  * Width and height from --format, --width, --height and --size.
145
154
  * @returns {{ width?: number, height?: number }}
@@ -153,22 +162,14 @@ function dimensions() {
153
162
  if (!m) throw new RangeError(`--size must look like 1600x2560, got "${values.size}"`);
154
163
  return { width: Number(m[1]), height: Number(m[2]) };
155
164
  }
156
- const width = size(values.width, 'width');
157
- const height = size(values.height, 'height');
165
+ const width = values.width === undefined ? undefined : px('--width', values.width);
166
+ const height = values.height === undefined ? undefined : px('--height', values.height);
158
167
  if (values.format === undefined) return { width, height };
159
- if (!Object.hasOwn(formats, values.format)) {
160
- throw new Error(`Unknown format "${values.format}". Available: ${Object.keys(formats).join(', ')}`);
161
- }
162
- if (height !== undefined) throw new Error('--format sets the height from the width: use --size for both');
163
- // The shape of the app's format, at the width asked for.
164
- const [w, h] = formats[/** @type {keyof typeof formats} */ (values.format)];
165
- const W = width ?? 600;
166
- return { width: W, height: Math.round((W * h) / w) };
168
+ // A preset or a WxH shape, at the width or height asked for (as the HTTP API reads it).
169
+ const [w, h] = formatSize(values.format, width, height);
170
+ return { width: w, height: h };
167
171
  }
168
172
 
169
- /** A colour as typed, with the # added (it starts a comment in most shells). @param {string} c */
170
- const hex = (c) => (c.trim().startsWith('#') ? c.trim() : `#${c.trim()}`);
171
-
172
173
  /** The palette: an id, or your own colours from --bg, --ink and --accent. */
173
174
  function palette() {
174
175
  const custom = values.bg !== undefined || values.ink !== undefined || values.accent !== undefined;
@@ -231,18 +232,6 @@ function fileFor(out, n, total, seed) {
231
232
  */
232
233
  const safeName = (seed) => seed.replace(/[^\p{L}\p{N}._-]+/gu, '-').replace(/^[.-]+/, '') || 'cover';
233
234
 
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
235
  /**
247
236
  * Write a cover for each book in the --batch list into --out-dir; the command line's options
248
237
  * apply to every book the list doesn't say otherwise for.
@@ -266,14 +255,9 @@ function batch(defaults) {
266
255
  books.forEach((book, i) => {
267
256
  const n = i + 1;
268
257
  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
- });
258
+ // (The book's own values win, a size in the list replaces the command line's, and a genre
259
+ // --style doesn't suit picks a style that does: see optionsForBook.)
260
+ const cover = createCover({ ...optionsForBook(book, defaults), seed: book.seed ?? values.seed });
277
261
  const file = join(dir, `${String(n).padStart(digits, '0')}-${slug(cover.title)}.svg`);
278
262
  writeFileSync(file, cover.svg);
279
263
  const { style, palette: paletteId, seed, width, height } = cover;
@@ -298,6 +282,7 @@ function run() {
298
282
  badge: values.badge,
299
283
  texture: values.texture,
300
284
  style: values.style,
285
+ genre: values.genre,
301
286
  palette: palette(),
302
287
  lang: values.lang,
303
288
  variants: variants(),
@@ -317,6 +302,7 @@ function run() {
317
302
  badge: values.badge,
318
303
  texture: values.texture,
319
304
  style: values.style,
305
+ genre: values.genre,
320
306
  palette: palette(),
321
307
  lang: values.lang,
322
308
  variants: variants(),
@@ -332,6 +318,7 @@ function run() {
332
318
  const choices = { style, palette: paletteId, seed: cover.seed, width, height, lang, variants: cover.variants };
333
319
  if (values.out) {
334
320
  const file = fileFor(values.out, n, total, cover.seed);
321
+ mkdirSync(dirname(file), { recursive: true });
335
322
  writeFileSync(file, cover.svg);
336
323
  if (values.json) process.stdout.write(JSON.stringify({ file, ...choices }) + '\n');
337
324
  else console.error(`Wrote ${file} (style: ${style}, palette: ${paletteId}, ${width}x${height})`);
@@ -347,4 +334,36 @@ function run() {
347
334
  }
348
335
  }
349
336
 
337
+ /**
338
+ * Serve MCP over stdio: a JSON-RPC message per line in, an answer per line out (nothing else on
339
+ * stdout). A cover can be saved to a file, at an absolute path (the host picks the folder it runs in).
340
+ */
341
+ function serveMcp() {
342
+ const lines = createInterface({ input: process.stdin });
343
+ /** @param {string} path @param {string} data */
344
+ const writeFile = (path, data) => {
345
+ // (A host starts the server in a folder of its own choosing: a relative path would land there.)
346
+ if (!isAbsolute(path)) throw new Error(`output must be an absolute path, got "${path}"`);
347
+ const file = resolve(path);
348
+ mkdirSync(dirname(file), { recursive: true });
349
+ writeFileSync(file, data);
350
+ return file;
351
+ };
352
+ /** @param {unknown} message */
353
+ const answer = (message) => {
354
+ if (message) process.stdout.write(`${JSON.stringify(message)}\n`);
355
+ };
356
+ lines.on('line', (line) => {
357
+ if (!line.trim()) return;
358
+ let message;
359
+ try {
360
+ message = JSON.parse(line);
361
+ } catch {
362
+ answer({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } });
363
+ return;
364
+ }
365
+ answer(handlePayload(message, { writeFile }));
366
+ });
367
+ }
368
+
350
369
  if (preflight()) run();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "book-cover-generator",
3
- "version": "1.6.0",
4
- "description": "Generate SVG book covers from a title and an author. 34 styles, 28 palettes, seeded randomizer, zero dependencies.",
3
+ "version": "1.8.0",
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",
7
7
  "cover",
@@ -23,7 +23,9 @@
23
23
  "license": "MIT",
24
24
  "author": "otsirh (https://github.com/otsirh)",
25
25
  "type": "module",
26
- "sideEffects": false,
26
+ "sideEffects": [
27
+ "./src/element.js"
28
+ ],
27
29
  "exports": {
28
30
  ".": {
29
31
  "types": "./src/index.d.ts",
@@ -33,6 +35,10 @@
33
35
  "types": "./src/browser.d.ts",
34
36
  "default": "./src/browser.js"
35
37
  },
38
+ "./element": {
39
+ "types": "./src/element.d.ts",
40
+ "default": "./src/element.js"
41
+ },
36
42
  "./package.json": "./package.json"
37
43
  },
38
44
  "main": "./src/index.js",
@@ -51,15 +57,17 @@
51
57
  "dev": "node scripts/serve.js 4173",
52
58
  "build": "node scripts/build.js",
53
59
  "lint": "eslint .",
54
- "lint:package": "publint --strict && attw --pack . --profile esm-only",
60
+ "lint:package": "publint --strict && attw --pack . --profile esm-only && node scripts/check-bundle.js",
55
61
  "format": "prettier --write .",
56
62
  "format:check": "prettier --check .",
57
63
  "typecheck": "tsc -p tsconfig.json",
58
64
  "test": "node --test",
59
65
  "test:coverage": "node --test --experimental-test-coverage --test-coverage-include=src/**/*.js --test-coverage-exclude=src/browser.js --test-coverage-lines=99 --test-coverage-branches=95 --test-coverage-functions=98",
66
+ "test:fuzz": "node --test fuzz/*.fuzz.js",
60
67
  "test:e2e": "playwright test",
61
- "check": "npm run lint && npm run format:check && npm run typecheck && npm run lint:package && npm test",
68
+ "check": "npm run lint && npm run format:check && npm run typecheck && npm run lint:package && npm test && npm run test:fuzz",
62
69
  "assets": "node scripts/generate-assets.js",
70
+ "metrics": "node scripts/generate-metrics.js",
63
71
  "deploy": "npm run check && npm run test:e2e && wrangler deploy"
64
72
  },
65
73
  "devDependencies": {
@@ -68,7 +76,9 @@
68
76
  "@eslint/js": "10.0.1",
69
77
  "@playwright/test": "1.65.0",
70
78
  "@types/node": "26.6.6",
79
+ "esbuild": "0.28.2",
71
80
  "eslint": "10.14.0",
81
+ "fast-check": "4.10.2",
72
82
  "globals": "17.14.0",
73
83
  "prettier": "3.10.0",
74
84
  "publint": "0.3.25",
package/src/books.js CHANGED
@@ -10,6 +10,7 @@ const COLUMNS = /** @type {const} */ ({
10
10
  credit: 'credit',
11
11
  badge: 'badge',
12
12
  style: 'style',
13
+ genre: 'genre',
13
14
  palette: 'palette',
14
15
  seed: 'seed',
15
16
  font: 'font',
@@ -21,8 +22,12 @@ const COLUMNS = /** @type {const} */ ({
21
22
  texture: 'texture',
22
23
  });
23
24
 
24
- /** Separators of a plain line: a tab, or a bar or dash with spaces around it. */
25
- const LINE_SEPARATOR = /\t| \| | — | – /;
25
+ /**
26
+ * Separators of a plain line, by precedence: a tab, a bar, or a dash, with spaces around it. A line
27
+ * is split at the first kind it uses only, so "Harry Potter – The Stone | J. K. Rowling" keeps
28
+ * its dash in the title.
29
+ */
30
+ const LINE_SEPARATORS = ['\t', ' | ', ' — ', ' – '];
26
31
 
27
32
  /**
28
33
  * Split CSV (or TSV) text into rows of fields: quoted fields may hold the delimiter, quotes ("")
@@ -84,7 +89,8 @@ function optionsFrom(named) {
84
89
  // (Numbers stay numbers for sizes and the seed; any other column is text, a title of 1984 too.)
85
90
  const value = typeof raw === 'number' && option !== 'title' ? raw : String(raw).trim();
86
91
  if (value === '') continue;
87
- if (option === 'width' || option === 'height') out[option] = Number(value);
92
+ // (A size that isn't a number is kept as written, so the error says what the list says.)
93
+ if (option === 'width' || option === 'height') out[option] = Number.isFinite(Number(value)) ? Number(value) : value;
88
94
  else out[option] = option === 'seed' ? value : String(value);
89
95
  }
90
96
  return /** @type {import('./index.js').CoverOptions} */ (out);
@@ -94,8 +100,8 @@ function optionsFrom(named) {
94
100
  * Read a list of books into cover options, one per book (rows without a title are left out):
95
101
  *
96
102
  * - 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);
103
+ * `credit`, `badge`, `style`, `genre`, `palette`, `seed`, `font`, `case`, `lang`, `width`,
104
+ * `height`, `texture`; others are ignored);
99
105
  * - JSON lines, one object per book, with the same names;
100
106
  * - else one book per line: "Title | Author | Subtitle" (a tab, or " — " or " – ", also separates).
101
107
  *
@@ -134,7 +140,8 @@ export function parseBookList(text) {
134
140
  books = rows.map((row) => optionsFrom(Object.fromEntries(names.map((name, i) => [name, row[i] ?? '']))));
135
141
  } else {
136
142
  books = lines.map(({ line }) => {
137
- const [title, author, subtitle] = line.split(LINE_SEPARATOR);
143
+ const separator = LINE_SEPARATORS.find((sep) => line.includes(sep));
144
+ const [title, author, subtitle] = separator ? line.split(separator) : [line];
138
145
  return optionsFrom({ title, author, subtitle });
139
146
  });
140
147
  }
@@ -0,0 +1,21 @@
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.
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
+ }
16
+
17
+ declare global {
18
+ interface HTMLElementTagNameMap {
19
+ 'book-cover': BookCover;
20
+ }
21
+ }