book-cover-generator 1.5.0 → 1.7.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 +65 -12
- package/bin/book-cover.js +158 -65
- package/package.json +3 -2
- package/src/badge.js +85 -0
- package/src/books.js +150 -0
- package/src/index.d.ts +68 -5
- package/src/index.js +214 -38
- package/src/link.js +86 -0
- package/src/metrics.js +183 -0
- package/src/mockups.js +230 -0
- package/src/shared.js +98 -0
- package/src/styles/genre-pack.js +981 -0
- package/src/styles/genre.js +1 -18
- package/src/styles/index.js +60 -0
- package/src/styles/pattern.js +2 -1
- package/src/styles/util.d.ts +14 -0
- package/src/styles/util.js +45 -1
- package/src/styles/vintage.js +3 -0
- package/src/text.d.ts +13 -0
- package/src/text.js +82 -4
- package/src/type.js +3 -0
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|

|
|
8
8
|
[](LICENSE)
|
|
9
9
|
|
|
10
|
-
Generate book covers from a **title** and an **author**.
|
|
10
|
+
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 and in Node.
|
|
11
11
|
|
|
12
12
|
**[▶ Open the generator](https://covers.otsirh.dev)**
|
|
13
13
|
|
|
@@ -15,9 +15,9 @@ Generate book covers from a **title** and an **author**. 34 styles, 28 palettes
|
|
|
15
15
|
|
|
16
16
|
## Features
|
|
17
17
|
|
|
18
|
-
- **
|
|
18
|
+
- **39 styles in six families.**
|
|
19
19
|
- Typographic: Big Type, Minimal, Big Letter, Swiss.
|
|
20
|
-
- Genre: Thriller, Botanical, Noir, Blueprint.
|
|
20
|
+
- Genre: Thriller, Botanical, Noir, Blueprint, Hollow (horror), Ribbon (romance), Crest (fantasy), Storybook (children's), Big Idea (non-fiction).
|
|
21
21
|
- Vintage: Art Deco, Classic Paperback, Engraved, Sunburst, Typewriter.
|
|
22
22
|
- Scenic: Landscape, Retro Sun, Silhouette, Constellation, Orbit, Waves, Aurora, Bookshelf.
|
|
23
23
|
- Geometric: Bauhaus, Split, Blocks, Concentric, Stripes.
|
|
@@ -31,15 +31,19 @@ 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")
|
|
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
|
-
- **a style**, from a rail that previews your book in all
|
|
36
|
+
- **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
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;
|
|
38
38
|
- **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
39
|
- **a variation**, the seed behind the artwork: the strip under the preview shows six others to click;
|
|
40
40
|
- **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
41
|
|
|
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.
|
|
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, 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
|
+
**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.
|
|
43
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
|
|
|
@@ -54,6 +58,30 @@ Keyboard shortcuts:
|
|
|
54
58
|
|
|
55
59
|
The single-key shortcuts can be turned off under the controls (for example with speech input).
|
|
56
60
|
|
|
61
|
+
## HTTP API
|
|
62
|
+
|
|
63
|
+
The site draws covers from a URL too, so a page can show one with nothing installed:
|
|
64
|
+
|
|
65
|
+
```html
|
|
66
|
+
<img src="https://covers.otsirh.dev/api/cover.svg?title=Dune&author=Frank+Herbert&style=retro" alt="Dune" />
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
- `GET /api/cover.svg` answers with the cover as SVG.
|
|
70
|
+
- `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`.
|
|
71
|
+
|
|
72
|
+
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):
|
|
73
|
+
|
|
74
|
+
| parameter | value |
|
|
75
|
+
| --------------------- | ---------------------------------------------------------------------------------------------- |
|
|
76
|
+
| `format` | `paperback`, `ebook`, `square`, or a size in px such as `1600x2560` |
|
|
77
|
+
| `case` | the title `upper`, or `normal` (as typed) |
|
|
78
|
+
| `texture` | `1` (on) or `0` (off) |
|
|
79
|
+
| `variant` | pinned choices as `name.value`, joined by `-`: `field.pale-emblem.sun` |
|
|
80
|
+
| `palette` | a palette id, or your own colours as `custom-0b132b-f8f9fa-ff6b6b` (background, text, accents) |
|
|
81
|
+
| `bg`, `ink`, `accent` | your own colours, as hex (the `#` is optional; accents separated by commas) |
|
|
82
|
+
|
|
83
|
+
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.
|
|
84
|
+
|
|
57
85
|
## Library usage
|
|
58
86
|
|
|
59
87
|
### Browser (no install)
|
|
@@ -106,11 +134,13 @@ npx book-cover-generator --list # all styles and palettes
|
|
|
106
134
|
npx book-cover-generator "Bright Machines" --bg 0b132b --ink f8f9fa --accent ff6b6b,4ecdc4 --format ebook --width 1600 -o cover.svg
|
|
107
135
|
# any size, and the choices as JSON (with the SVG, when there is no -o)
|
|
108
136
|
npx book-cover-generator "Neon Tide" --size 1800x2700 --json
|
|
137
|
+
# a cover for every book in a list (CSV with a header row, JSON lines, or "Title | Author" lines), one look for all
|
|
138
|
+
npx book-cover-generator --batch books.csv --style minimal --seed series --out-dir covers
|
|
109
139
|
# twelve variations to choose from: ideas/cover-1.svg … ideas/cover-12.svg (seeds 1 to 12)
|
|
110
140
|
npx book-cover-generator "Salt and Static" --count 12 -o ideas/cover.svg
|
|
111
141
|
```
|
|
112
142
|
|
|
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`.
|
|
143
|
+
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`.
|
|
114
144
|
|
|
115
145
|
## API
|
|
116
146
|
|
|
@@ -118,7 +148,7 @@ Options: `-s/--style`, `-p/--palette`, `--bg`, `--ink`, `--accent`, `--subtitle`
|
|
|
118
148
|
|
|
119
149
|
Returns the SVG markup.
|
|
120
150
|
|
|
121
|
-
### `createCover(options) → { svg, body, style, palette, seed, width, height, lang }`
|
|
151
|
+
### `createCover(options) → { svg, body, style, palette, colors, seed, width, height, lang, variants, title, author }`
|
|
122
152
|
|
|
123
153
|
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
154
|
|
|
@@ -129,6 +159,7 @@ Same as `generateCover`, but also tells you which style and palette were chosen.
|
|
|
129
159
|
| `subtitle` | `string` | `''` | optional subtitle or tagline |
|
|
130
160
|
| `series` | `string` | `''` | a series line above the title |
|
|
131
161
|
| `credit` | `string` | `''` | a credit under the subtitle ("Translated by…") |
|
|
162
|
+
| `badge` | `string` | `''` | a seal in a corner clear of the text |
|
|
132
163
|
| `texture` | `boolean` | `false` | adds a fine film grain |
|
|
133
164
|
| `style` | `string` | `'random'` | a style id, see `styles` |
|
|
134
165
|
| `palette` | `string \| object` | `'random'` | a palette id, or `{ bg, ink, accents: [...] }` |
|
|
@@ -137,9 +168,12 @@ Same as `generateCover`, but also tells you which style and palette were chosen.
|
|
|
137
168
|
| `height` | `number` | `width × 1.5` | px, 50–10000 |
|
|
138
169
|
| `lang` | `string` | inferred | language of the text, such as `'ja'` |
|
|
139
170
|
| `variants` | `object` | `{}` | pin a style's choices: `{ scene: 'forest' }` |
|
|
171
|
+
| `genre` | `string` | | with a random style, one that suits this genre |
|
|
140
172
|
| `font` | `string` | the style's | the title's font, one of `FONTS` |
|
|
141
173
|
| `titleCase` | `string` | the style's | `'upper'` (capitals) or `'normal'` (as typed) |
|
|
142
174
|
|
|
175
|
+
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.
|
|
176
|
+
|
|
143
177
|
`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.
|
|
144
178
|
|
|
145
179
|
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.
|
|
@@ -147,12 +181,17 @@ Titles and subtitles longer than 300 characters (authors: 200) are cut. With `'r
|
|
|
147
181
|
### Other exports
|
|
148
182
|
|
|
149
183
|
- `styles`: array of style ids
|
|
150
|
-
- `
|
|
184
|
+
- `genres`: `[{ id, name }]`, the genres styles suit: `literary`, `thriller`, `horror`, `romance`, `fantasy`, `children`, `nonfiction`
|
|
185
|
+
- `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)
|
|
151
186
|
- `palettes`: `[{ id, name, bg, ink, accents, dark? }]`
|
|
152
187
|
- `randomSeed()`: a short random seed string
|
|
153
188
|
- `pickStyle(options)`: the style id `createCover` would use for these options (resolving `'random'` from the seed), without drawing the cover
|
|
154
189
|
- `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
190
|
- `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
|
|
191
|
+
- `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
|
|
192
|
+
- `styleIdFor(name)`: the id of the style a name or id names, in any case (`'Art Deco'` → `'artdeco'`), or `undefined`
|
|
193
|
+
- `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)
|
|
194
|
+
- `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
195
|
- `version`: the library's version (artwork for a seed can change between minor versions)
|
|
157
196
|
- `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
197
|
- `titleBlock`, `authorFit`: the typography the built-in styles use (a title with its lead line and subtitle, and the author line)
|
|
@@ -164,6 +203,18 @@ Titles and subtitles longer than 300 characters (authors: 200) are cut. With `'r
|
|
|
164
203
|
|
|
165
204
|
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
205
|
|
|
206
|
+
### Mockups
|
|
207
|
+
|
|
208
|
+
```js
|
|
209
|
+
import { createCover, createMockup } from 'book-cover-generator';
|
|
210
|
+
|
|
211
|
+
const cover = createCover({ title: 'Neon Tide', author: 'Kai Moreno' });
|
|
212
|
+
const { svg, width, height } = createMockup(cover, { kind: 'book' }); // or 'flat', 'ereader'
|
|
213
|
+
const card = createMockup(cover, { kind: 'card', text: 'Out now' }); // 1200 × 630; also 'square', 'story'
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
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.
|
|
217
|
+
|
|
167
218
|
### Export helpers (browser)
|
|
168
219
|
|
|
169
220
|
```js
|
|
@@ -206,6 +257,7 @@ generateCover({
|
|
|
206
257
|
- `p`: the palette, with `bg`, `ink`, `accents`, plus `paper` (lighter) and `night` (darker)
|
|
207
258
|
- `id(name)`: makes ids that are unique per cover, for gradients and filters
|
|
208
259
|
- `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
|
|
260
|
+
- 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
261
|
- `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
262
|
|
|
211
263
|
```js
|
|
@@ -234,7 +286,7 @@ registerStyle({
|
|
|
234
286
|
|
|
235
287
|
## Notes
|
|
236
288
|
|
|
237
|
-
- **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
|
|
289
|
+
- **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.
|
|
238
290
|
- **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.
|
|
239
291
|
- **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.
|
|
240
292
|
- **Browser support.** Current Chrome, Edge, Firefox and Safari. Every release is tested in Chromium, Firefox and WebKit, including PNG and JPEG export.
|
|
@@ -263,7 +315,7 @@ Open `examples/contact-sheet.html?palettes=noir,mint&seeds=a,b` on the dev serve
|
|
|
263
315
|
|
|
264
316
|
```
|
|
265
317
|
src/ the library (plain ES modules + .d.ts types)
|
|
266
|
-
styles/ the
|
|
318
|
+
styles/ the 39 styles, in a file per family
|
|
267
319
|
bin/ the CLI
|
|
268
320
|
site/ the web app (index.html is at the root)
|
|
269
321
|
public/ files served at the site root: headers, 404, icons, manifest, service worker
|
|
@@ -271,9 +323,10 @@ test/ unit tests (node --test)
|
|
|
271
323
|
test-d/ type tests (tsc)
|
|
272
324
|
e2e/ browser tests (Playwright)
|
|
273
325
|
scripts/ build, dev server and asset generation
|
|
326
|
+
worker/ the HTTP API (the site's Worker)
|
|
274
327
|
```
|
|
275
328
|
|
|
276
|
-
The site is a
|
|
329
|
+
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.
|
|
277
330
|
|
|
278
331
|
## Contributing
|
|
279
332
|
|
package/bin/book-cover.js
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { writeFileSync } from 'node:fs';
|
|
2
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, 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, optionsForBook, genres } from '../src/index.js';
|
|
6
|
+
import { hex, slug } from '../src/shared.js';
|
|
5
7
|
|
|
6
8
|
const HELP = `Usage: book-cover "<title>" ["<author>"] [options]
|
|
7
9
|
|
|
8
10
|
Options:
|
|
9
11
|
-s, --style <id> style id or "random" (default: random from seed)
|
|
10
12
|
-p, --palette <id> palette id or "random" (default: random from seed)
|
|
13
|
+
--genre <id> with a random style, one that suits this genre:
|
|
14
|
+
literary, thriller, horror, romance, fantasy, children
|
|
15
|
+
or nonfiction
|
|
11
16
|
--bg <hex> your own colours instead of a palette: background,
|
|
12
17
|
--ink <hex> text,
|
|
13
18
|
--accent <hex> and accents (repeat it, or separate them with commas)
|
|
@@ -19,6 +24,7 @@ Options:
|
|
|
19
24
|
--subtitle <text> a subtitle or tagline
|
|
20
25
|
--series <text> a series line above the title ("Book Two of …")
|
|
21
26
|
--credit <text> a credit under the subtitle ("Translated by …")
|
|
27
|
+
--badge <text> a seal in a corner clear of the text ("Bestseller")
|
|
22
28
|
--texture add a fine film-grain texture
|
|
23
29
|
--seed <value> seed for the artwork (default: title + author)
|
|
24
30
|
--format <shape> paperback (2:3, the default), ebook (5:8) or square
|
|
@@ -26,6 +32,13 @@ Options:
|
|
|
26
32
|
--height <px> height (default: from the format)
|
|
27
33
|
--size <WxH> width and height at once, e.g. 1600x2560
|
|
28
34
|
--lang <tag> language of the text, e.g. ja or zh-Hant (default: inferred)
|
|
35
|
+
--batch <file> a cover for each book in a list (- reads stdin): CSV or
|
|
36
|
+
TSV with a header row (title, author, subtitle, series,
|
|
37
|
+
credit, badge, style, genre, palette, seed, font, case, lang,
|
|
38
|
+
width, height, texture), JSON lines, or one book per
|
|
39
|
+
line as "Title | Author | Subtitle"; the other options
|
|
40
|
+
apply to every book the list doesn't say otherwise for
|
|
41
|
+
--out-dir <dir> where --batch writes its covers (default: here)
|
|
29
42
|
-o, --out <file> write SVG to a file instead of stdout
|
|
30
43
|
--count <n> write n variations (seeds 1…n, or <seed>-1…), named
|
|
31
44
|
cover-1.svg… from --out (or put {n} or {seed} in it)
|
|
@@ -38,7 +51,8 @@ Examples:
|
|
|
38
51
|
book-cover "The Lighthouse Keeper" "Mara Ellison" -s artdeco -o cover.svg
|
|
39
52
|
book-cover "Neon Tide" "Kai Moreno" --style retro --palette synth --size 1600x2560 > cover.svg
|
|
40
53
|
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
|
|
54
|
+
book-cover "Salt and Static" "J. R. Okafor" --count 12 -o ideas/cover.svg
|
|
55
|
+
book-cover --batch books.csv --style minimal --seed series --out-dir covers`;
|
|
42
56
|
|
|
43
57
|
let args;
|
|
44
58
|
try {
|
|
@@ -47,6 +61,7 @@ try {
|
|
|
47
61
|
options: {
|
|
48
62
|
style: { type: 'string', short: 's' },
|
|
49
63
|
palette: { type: 'string', short: 'p' },
|
|
64
|
+
genre: { type: 'string' },
|
|
50
65
|
bg: { type: 'string' },
|
|
51
66
|
ink: { type: 'string' },
|
|
52
67
|
accent: { type: 'string', multiple: true },
|
|
@@ -56,6 +71,7 @@ try {
|
|
|
56
71
|
subtitle: { type: 'string' },
|
|
57
72
|
series: { type: 'string' },
|
|
58
73
|
credit: { type: 'string' },
|
|
74
|
+
badge: { type: 'string' },
|
|
59
75
|
texture: { type: 'boolean' },
|
|
60
76
|
seed: { type: 'string' },
|
|
61
77
|
format: { type: 'string' },
|
|
@@ -64,6 +80,8 @@ try {
|
|
|
64
80
|
size: { type: 'string' },
|
|
65
81
|
lang: { type: 'string' },
|
|
66
82
|
out: { type: 'string', short: 'o' },
|
|
83
|
+
batch: { type: 'string' },
|
|
84
|
+
'out-dir': { type: 'string' },
|
|
67
85
|
count: { type: 'string' },
|
|
68
86
|
json: { type: 'boolean' },
|
|
69
87
|
list: { type: 'boolean' },
|
|
@@ -77,33 +95,46 @@ try {
|
|
|
77
95
|
|
|
78
96
|
const { values, positionals } = args;
|
|
79
97
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
if (values.
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
98
|
+
/**
|
|
99
|
+
* Help, the list, and checks of the arguments: whether to go on and make covers. (Exit codes are
|
|
100
|
+
* set, not exited with, so output piped elsewhere is written in full first.)
|
|
101
|
+
*/
|
|
102
|
+
function preflight() {
|
|
103
|
+
if (values.help) {
|
|
104
|
+
console.log(HELP);
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
if (values.list) {
|
|
108
|
+
console.log('Styles:');
|
|
109
|
+
for (const s of listStyles()) {
|
|
110
|
+
console.log(` ${s.id.padEnd(14)} ${s.description}`);
|
|
111
|
+
if (s.genres.length) console.log(` ${''.padEnd(14)} genres: ${s.genres.join(', ')}`);
|
|
112
|
+
for (const v of s.variants) {
|
|
113
|
+
console.log(` ${''.padEnd(14)} --variant ${v.name}=${v.values.map((x) => x.value).join('|')}`);
|
|
114
|
+
}
|
|
91
115
|
}
|
|
116
|
+
console.log('\nGenres (--genre):');
|
|
117
|
+
for (const g of genres) console.log(` ${g.id.padEnd(14)} ${g.name}`);
|
|
118
|
+
console.log('\nPalettes:');
|
|
119
|
+
for (const p of palettes) console.log(` ${p.id.padEnd(14)} ${p.name}${p.dark ? ' (dark)' : ''}`);
|
|
120
|
+
return false;
|
|
92
121
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
}
|
|
102
|
-
if (positionals.length
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
122
|
+
/** @param {string} message */
|
|
123
|
+
const fail = (message) => {
|
|
124
|
+
console.error(message);
|
|
125
|
+
process.exitCode = 1;
|
|
126
|
+
return false;
|
|
127
|
+
};
|
|
128
|
+
if (values.batch !== undefined && positionals.length) {
|
|
129
|
+
return fail('--batch takes the titles from its list: give no title on the command line');
|
|
130
|
+
}
|
|
131
|
+
if (!positionals.length && values.batch === undefined) return fail(HELP);
|
|
132
|
+
if (positionals.length > 2) {
|
|
133
|
+
return fail(
|
|
134
|
+
`Expected a title and an author, got ${positionals.length} arguments. Quote values with spaces: book-cover "The Lighthouse Keeper" "Mara Ellison"`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
return true;
|
|
107
138
|
}
|
|
108
139
|
|
|
109
140
|
/**
|
|
@@ -143,9 +174,6 @@ function dimensions() {
|
|
|
143
174
|
return { width: W, height: Math.round((W * h) / w) };
|
|
144
175
|
}
|
|
145
176
|
|
|
146
|
-
/** A colour as typed, with the # added (it starts a comment in most shells). @param {string} c */
|
|
147
|
-
const hex = (c) => (c.trim().startsWith('#') ? c.trim() : `#${c.trim()}`);
|
|
148
|
-
|
|
149
177
|
/** The palette: an id, or your own colours from --bg, --ink and --accent. */
|
|
150
178
|
function palette() {
|
|
151
179
|
const custom = values.bg !== undefined || values.ink !== undefined || values.accent !== undefined;
|
|
@@ -208,41 +236,106 @@ function fileFor(out, n, total, seed) {
|
|
|
208
236
|
*/
|
|
209
237
|
const safeName = (seed) => seed.replace(/[^\p{L}\p{N}._-]+/gu, '-').replace(/^[.-]+/, '') || 'cover';
|
|
210
238
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
239
|
+
/**
|
|
240
|
+
* Write a cover for each book in the --batch list into --out-dir; the command line's options
|
|
241
|
+
* apply to every book the list doesn't say otherwise for.
|
|
242
|
+
* @param {import('../src/index.js').CoverOptions} defaults
|
|
243
|
+
*/
|
|
244
|
+
function batch(defaults) {
|
|
245
|
+
if (values.count !== undefined || values.out !== undefined) {
|
|
246
|
+
throw new Error('--batch writes a file per book into --out-dir: use it without --out or --count');
|
|
247
|
+
}
|
|
248
|
+
const source = /** @type {string} */ (values.batch);
|
|
249
|
+
const books = parseBookList(readFileSync(source === '-' ? 0 : source, 'utf8'));
|
|
250
|
+
if (!books.length) {
|
|
251
|
+
throw new Error(
|
|
252
|
+
`No books in ${source === '-' ? 'the input' : source}: a title per line, or a CSV with a "title" column`,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
const dir = values['out-dir'] ?? '.';
|
|
256
|
+
mkdirSync(dir, { recursive: true });
|
|
257
|
+
const digits = String(books.length).length;
|
|
258
|
+
let failed = 0;
|
|
259
|
+
books.forEach((book, i) => {
|
|
260
|
+
const n = i + 1;
|
|
261
|
+
try {
|
|
262
|
+
// (The book's own values win, a size in the list replaces the command line's, and a genre
|
|
263
|
+
// --style doesn't suit picks a style that does: see optionsForBook.)
|
|
264
|
+
const cover = createCover({ ...optionsForBook(book, defaults), seed: book.seed ?? values.seed });
|
|
265
|
+
const file = join(dir, `${String(n).padStart(digits, '0')}-${slug(cover.title)}.svg`);
|
|
236
266
|
writeFileSync(file, cover.svg);
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
267
|
+
const { style, palette: paletteId, seed, width, height } = cover;
|
|
268
|
+
if (values.json)
|
|
269
|
+
process.stdout.write(JSON.stringify({ file, style, palette: paletteId, seed, width, height }) + '\n');
|
|
270
|
+
} catch (err) {
|
|
271
|
+
failed += 1;
|
|
272
|
+
console.error(`Book ${n} ("${book.title}"): ${err instanceof Error ? err.message : String(err)}`);
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
console.error(`Wrote ${books.length - failed} of ${books.length} covers to ${dir}`);
|
|
276
|
+
if (failed) process.exitCode = 1;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function run() {
|
|
280
|
+
try {
|
|
281
|
+
if (values.batch !== undefined) {
|
|
282
|
+
batch({
|
|
283
|
+
subtitle: values.subtitle,
|
|
284
|
+
series: values.series,
|
|
285
|
+
credit: values.credit,
|
|
286
|
+
badge: values.badge,
|
|
287
|
+
texture: values.texture,
|
|
288
|
+
style: values.style,
|
|
289
|
+
genre: values.genre,
|
|
290
|
+
palette: palette(),
|
|
291
|
+
lang: values.lang,
|
|
292
|
+
variants: variants(),
|
|
293
|
+
font: values.font,
|
|
294
|
+
titleCase: /** @type {'upper' | 'normal' | undefined} */ (values.case),
|
|
295
|
+
...dimensions(),
|
|
296
|
+
});
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
const total = count();
|
|
300
|
+
const options = {
|
|
301
|
+
title: positionals[0],
|
|
302
|
+
author: positionals[1] ?? '',
|
|
303
|
+
subtitle: values.subtitle,
|
|
304
|
+
series: values.series,
|
|
305
|
+
credit: values.credit,
|
|
306
|
+
badge: values.badge,
|
|
307
|
+
texture: values.texture,
|
|
308
|
+
style: values.style,
|
|
309
|
+
genre: values.genre,
|
|
310
|
+
palette: palette(),
|
|
311
|
+
lang: values.lang,
|
|
312
|
+
variants: variants(),
|
|
313
|
+
font: values.font,
|
|
314
|
+
titleCase: /** @type {'upper' | 'normal' | undefined} */ (values.case),
|
|
315
|
+
...dimensions(),
|
|
316
|
+
};
|
|
317
|
+
for (let n = 1; n <= total; n++) {
|
|
318
|
+
// One cover: the seed given (or the title's). Several: seeds 1…n, or <seed>-1…<seed>-n.
|
|
319
|
+
const seed = total === 1 ? values.seed : values.seed ? `${values.seed}-${n}` : String(n);
|
|
320
|
+
const cover = createCover({ ...options, seed });
|
|
321
|
+
const { style, palette: paletteId, width, height, lang } = cover;
|
|
322
|
+
const choices = { style, palette: paletteId, seed: cover.seed, width, height, lang, variants: cover.variants };
|
|
323
|
+
if (values.out) {
|
|
324
|
+
const file = fileFor(values.out, n, total, cover.seed);
|
|
325
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
326
|
+
writeFileSync(file, cover.svg);
|
|
327
|
+
if (values.json) process.stdout.write(JSON.stringify({ file, ...choices }) + '\n');
|
|
328
|
+
else console.error(`Wrote ${file} (style: ${style}, palette: ${paletteId}, ${width}x${height})`);
|
|
329
|
+
} else if (values.json) {
|
|
330
|
+
process.stdout.write(JSON.stringify({ ...choices, svg: cover.svg }) + '\n');
|
|
331
|
+
} else {
|
|
332
|
+
process.stdout.write(cover.svg + '\n');
|
|
333
|
+
}
|
|
243
334
|
}
|
|
335
|
+
} catch (err) {
|
|
336
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
337
|
+
process.exitCode = 1;
|
|
244
338
|
}
|
|
245
|
-
} catch (err) {
|
|
246
|
-
console.error(err instanceof Error ? err.message : String(err));
|
|
247
|
-
process.exit(1);
|
|
248
339
|
}
|
|
340
|
+
|
|
341
|
+
if (preflight()) run();
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "book-cover-generator",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Generate SVG book covers from a title and an author.
|
|
3
|
+
"version": "1.7.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",
|
|
@@ -60,6 +60,7 @@
|
|
|
60
60
|
"test:e2e": "playwright test",
|
|
61
61
|
"check": "npm run lint && npm run format:check && npm run typecheck && npm run lint:package && npm test",
|
|
62
62
|
"assets": "node scripts/generate-assets.js",
|
|
63
|
+
"metrics": "node scripts/generate-metrics.js",
|
|
63
64
|
"deploy": "npm run check && npm run test:e2e && wrangler deploy"
|
|
64
65
|
},
|
|
65
66
|
"devDependencies": {
|
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
|
+
}
|