book-cover-generator 1.4.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 +56 -25
- package/bin/book-cover.js +190 -54
- package/package.json +1 -1
- package/src/badge.js +85 -0
- package/src/books.js +143 -0
- package/src/index.d.ts +79 -2
- package/src/index.js +169 -17
- package/src/mockups.js +229 -0
- package/src/styles/genre.js +25 -6
- package/src/styles/geometric.js +17 -4
- package/src/styles/pattern.js +67 -33
- package/src/styles/scenic.js +4 -2
- package/src/styles/typographic.js +4 -2
- package/src/styles/vintage.js +45 -5
- package/src/text.d.ts +15 -0
- package/src/text.js +93 -7
- package/src/type.d.ts +2 -0
- package/src/type.js +82 -29
package/README.md
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# Book Cover Generator
|
|
2
2
|
|
|
3
3
|
[](https://github.com/otsirh/book-cover-generator/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/book-cover-generator)
|
|
4
5
|
[](https://covers.otsirh.dev)
|
|
5
6
|

|
|
6
7
|

|
|
@@ -30,15 +31,20 @@ Generate book covers from a **title** and an **author**. 34 styles, 28 palettes
|
|
|
30
31
|
|
|
31
32
|
## The web app
|
|
32
33
|
|
|
33
|
-
On [covers.otsirh.dev](https://covers.otsirh.dev) you type a title and author,
|
|
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:
|
|
34
35
|
|
|
35
|
-
- **a style**, from a rail that previews your book in all 34 styles;
|
|
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
|
+
- **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;
|
|
36
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);
|
|
37
39
|
- **a variation**, the seed behind the artwork: the strip under the preview shows six others to click;
|
|
38
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).
|
|
39
41
|
|
|
40
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.
|
|
41
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
|
+
|
|
42
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.
|
|
43
49
|
|
|
44
50
|
The app works offline once it has loaded.
|
|
@@ -58,7 +64,7 @@ The single-key shortcuts can be turned off under the controls (for example with
|
|
|
58
64
|
|
|
59
65
|
```html
|
|
60
66
|
<script type="module">
|
|
61
|
-
import { generateCover } from 'https://cdn.jsdelivr.net/
|
|
67
|
+
import { generateCover } from 'https://cdn.jsdelivr.net/npm/book-cover-generator@1/src/index.js';
|
|
62
68
|
|
|
63
69
|
document.body.innerHTML = generateCover({
|
|
64
70
|
title: 'The Lighthouse Keeper',
|
|
@@ -74,7 +80,7 @@ The single-key shortcuts can be turned off under the controls (for example with
|
|
|
74
80
|
### Node / bundlers
|
|
75
81
|
|
|
76
82
|
```sh
|
|
77
|
-
npm install
|
|
83
|
+
npm install book-cover-generator
|
|
78
84
|
```
|
|
79
85
|
|
|
80
86
|
```js
|
|
@@ -95,20 +101,22 @@ writeFileSync('cover.svg', svg);
|
|
|
95
101
|
### CLI
|
|
96
102
|
|
|
97
103
|
```sh
|
|
98
|
-
npx
|
|
99
|
-
npx
|
|
104
|
+
npx book-cover-generator "The Lighthouse Keeper" "Mara Ellison" --style artdeco -o cover.svg
|
|
105
|
+
npx book-cover-generator --list # all styles and palettes
|
|
100
106
|
```
|
|
101
107
|
|
|
102
108
|
```sh
|
|
103
109
|
# your own colours (the # is optional), as an eBook 1600 px wide
|
|
104
|
-
npx
|
|
110
|
+
npx book-cover-generator "Bright Machines" --bg 0b132b --ink f8f9fa --accent ff6b6b,4ecdc4 --format ebook --width 1600 -o cover.svg
|
|
105
111
|
# any size, and the choices as JSON (with the SVG, when there is no -o)
|
|
106
|
-
npx
|
|
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
|
|
107
115
|
# twelve variations to choose from: ideas/cover-1.svg … ideas/cover-12.svg (seeds 1 to 12)
|
|
108
|
-
npx
|
|
116
|
+
npx book-cover-generator "Salt and Static" --count 12 -o ideas/cover.svg
|
|
109
117
|
```
|
|
110
118
|
|
|
111
|
-
Options: `-s/--style`, `-p/--palette`, `--bg`, `--ink`, `--accent`, `--subtitle`, `--texture`, `--seed`, `--format` (`paperback`, `ebook` or `square`), `--width`, `--height`, `--size`, `--lang`, `-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`.
|
|
112
120
|
|
|
113
121
|
## API
|
|
114
122
|
|
|
@@ -116,22 +124,28 @@ Options: `-s/--style`, `-p/--palette`, `--bg`, `--ink`, `--accent`, `--subtitle`
|
|
|
116
124
|
|
|
117
125
|
Returns the SVG markup.
|
|
118
126
|
|
|
119
|
-
### `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 }`
|
|
120
128
|
|
|
121
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).
|
|
122
130
|
|
|
123
|
-
| option
|
|
124
|
-
|
|
|
125
|
-
| `title`
|
|
126
|
-
| `author`
|
|
127
|
-
| `subtitle`
|
|
128
|
-
| `
|
|
129
|
-
| `
|
|
130
|
-
| `
|
|
131
|
-
| `
|
|
132
|
-
| `
|
|
133
|
-
| `
|
|
134
|
-
| `
|
|
131
|
+
| option | type | default | notes |
|
|
132
|
+
| ----------- | ------------------ | ---------------------- | ------------------------------------------------- |
|
|
133
|
+
| `title` | `string` | `'Untitled'` | a line break (`\n`) breaks the title's line there |
|
|
134
|
+
| `author` | `string` | `''` | optional |
|
|
135
|
+
| `subtitle` | `string` | `''` | optional subtitle or tagline |
|
|
136
|
+
| `series` | `string` | `''` | a series line above the title |
|
|
137
|
+
| `credit` | `string` | `''` | a credit under the subtitle ("Translated by…") |
|
|
138
|
+
| `badge` | `string` | `''` | a seal in a corner clear of the text |
|
|
139
|
+
| `texture` | `boolean` | `false` | adds a fine film grain |
|
|
140
|
+
| `style` | `string` | `'random'` | a style id, see `styles` |
|
|
141
|
+
| `palette` | `string \| object` | `'random'` | a palette id, or `{ bg, ink, accents: [...] }` |
|
|
142
|
+
| `seed` | `string \| number` | hash of title + author | controls every random choice |
|
|
143
|
+
| `width` | `number` | `600` | px, 50–10000; aspect ratio 1:3 to 3:1 |
|
|
144
|
+
| `height` | `number` | `width × 1.5` | px, 50–10000 |
|
|
145
|
+
| `lang` | `string` | inferred | language of the text, such as `'ja'` |
|
|
146
|
+
| `variants` | `object` | `{}` | pin a style's choices: `{ scene: 'forest' }` |
|
|
147
|
+
| `font` | `string` | the style's | the title's font, one of `FONTS` |
|
|
148
|
+
| `titleCase` | `string` | the style's | `'upper'` (capitals) or `'normal'` (as typed) |
|
|
135
149
|
|
|
136
150
|
`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.
|
|
137
151
|
|
|
@@ -140,12 +154,14 @@ Titles and subtitles longer than 300 characters (authors: 200) are cut. With `'r
|
|
|
140
154
|
### Other exports
|
|
141
155
|
|
|
142
156
|
- `styles`: array of style ids
|
|
143
|
-
- `listStyles()`: `[{ id, name, description, group }]`
|
|
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
|
|
144
158
|
- `palettes`: `[{ id, name, bg, ink, accents, dark? }]`
|
|
145
159
|
- `randomSeed()`: a short random seed string
|
|
146
160
|
- `pickStyle(options)`: the style id `createCover` would use for these options (resolving `'random'` from the seed), without drawing the cover
|
|
147
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
|
|
148
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
|
|
149
165
|
- `version`: the library's version (artwork for a seed can change between minor versions)
|
|
150
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
|
|
151
167
|
- `titleBlock`, `authorFit`: the typography the built-in styles use (a title with its lead line and subtitle, and the author line)
|
|
@@ -157,6 +173,18 @@ Titles and subtitles longer than 300 characters (authors: 200) are cut. With `'r
|
|
|
157
173
|
|
|
158
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.
|
|
159
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
|
+
|
|
160
188
|
### Export helpers (browser)
|
|
161
189
|
|
|
162
190
|
```js
|
|
@@ -190,7 +218,7 @@ generateCover({
|
|
|
190
218
|
|
|
191
219
|
`render(ctx)` returns SVG markup without the outer `<svg>` element. `ctx` contains:
|
|
192
220
|
|
|
193
|
-
- `title`, `author`, `subtitle` (may be empty)
|
|
221
|
+
- `title`, `author`, `subtitle` (may be empty; a credit follows the subtitle on a line of its own), `series` (set by `titleBlock` above the title)
|
|
194
222
|
- `W`, `H`: the size of the cover
|
|
195
223
|
- `u`: a scale unit (1 at a short side of 600px)
|
|
196
224
|
- `m`: the suggested margin
|
|
@@ -198,6 +226,9 @@ generateCover({
|
|
|
198
226
|
- `rng`: seeded random with `next`, `float`, `int`, `bool`, `pick`, `shuffle`
|
|
199
227
|
- `p`: the palette, with `bg`, `ink`, `accents`, plus `paper` (lighter) and `night` (darker)
|
|
200
228
|
- `id(name)`: makes ids that are unique per cover, for gradients and filters
|
|
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)`
|
|
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' } } }`
|
|
201
232
|
|
|
202
233
|
```js
|
|
203
234
|
import { registerStyle, titleBlock, authorFit, drawText, FONTS } from 'book-cover-generator';
|
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
|
|
|
@@ -11,7 +12,15 @@ Options:
|
|
|
11
12
|
--bg <hex> your own colours instead of a palette: background,
|
|
12
13
|
--ink <hex> text,
|
|
13
14
|
--accent <hex> and accents (repeat it, or separate them with commas)
|
|
15
|
+
--variant <n=v> pin one of the style's choices, e.g. scene=forest
|
|
16
|
+
(repeat it; --list shows each style's)
|
|
17
|
+
--font <name> the title's font: serif, didone, garamond, sans,
|
|
18
|
+
geometric, humanist, condensed, display or mono
|
|
19
|
+
--case <how> the title in capitals (upper) or as typed (normal)
|
|
14
20
|
--subtitle <text> a subtitle or tagline
|
|
21
|
+
--series <text> a series line above the title ("Book Two of …")
|
|
22
|
+
--credit <text> a credit under the subtitle ("Translated by …")
|
|
23
|
+
--badge <text> a seal in a corner clear of the text ("Bestseller")
|
|
15
24
|
--texture add a fine film-grain texture
|
|
16
25
|
--seed <value> seed for the artwork (default: title + author)
|
|
17
26
|
--format <shape> paperback (2:3, the default), ebook (5:8) or square
|
|
@@ -19,6 +28,13 @@ Options:
|
|
|
19
28
|
--height <px> height (default: from the format)
|
|
20
29
|
--size <WxH> width and height at once, e.g. 1600x2560
|
|
21
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)
|
|
22
38
|
-o, --out <file> write SVG to a file instead of stdout
|
|
23
39
|
--count <n> write n variations (seeds 1…n, or <seed>-1…), named
|
|
24
40
|
cover-1.svg… from --out (or put {n} or {seed} in it)
|
|
@@ -31,7 +47,8 @@ Examples:
|
|
|
31
47
|
book-cover "The Lighthouse Keeper" "Mara Ellison" -s artdeco -o cover.svg
|
|
32
48
|
book-cover "Neon Tide" "Kai Moreno" --style retro --palette synth --size 1600x2560 > cover.svg
|
|
33
49
|
book-cover "Bright Machines" --bg 0b132b --ink f8f9fa --accent ff6b6b,4ecdc4 --format ebook -o cover.svg
|
|
34
|
-
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`;
|
|
35
52
|
|
|
36
53
|
let args;
|
|
37
54
|
try {
|
|
@@ -43,7 +60,13 @@ try {
|
|
|
43
60
|
bg: { type: 'string' },
|
|
44
61
|
ink: { type: 'string' },
|
|
45
62
|
accent: { type: 'string', multiple: true },
|
|
63
|
+
variant: { type: 'string', multiple: true },
|
|
64
|
+
font: { type: 'string' },
|
|
65
|
+
case: { type: 'string' },
|
|
46
66
|
subtitle: { type: 'string' },
|
|
67
|
+
series: { type: 'string' },
|
|
68
|
+
credit: { type: 'string' },
|
|
69
|
+
badge: { type: 'string' },
|
|
47
70
|
texture: { type: 'boolean' },
|
|
48
71
|
seed: { type: 'string' },
|
|
49
72
|
format: { type: 'string' },
|
|
@@ -52,6 +75,8 @@ try {
|
|
|
52
75
|
size: { type: 'string' },
|
|
53
76
|
lang: { type: 'string' },
|
|
54
77
|
out: { type: 'string', short: 'o' },
|
|
78
|
+
batch: { type: 'string' },
|
|
79
|
+
'out-dir': { type: 'string' },
|
|
55
80
|
count: { type: 'string' },
|
|
56
81
|
json: { type: 'boolean' },
|
|
57
82
|
list: { type: 'boolean' },
|
|
@@ -65,28 +90,43 @@ try {
|
|
|
65
90
|
|
|
66
91
|
const { values, positionals } = args;
|
|
67
92
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
if (values.
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
+
}
|
|
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;
|
|
113
|
+
}
|
|
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;
|
|
90
130
|
}
|
|
91
131
|
|
|
92
132
|
/**
|
|
@@ -148,6 +188,18 @@ process.stdout.on('error', (err) => {
|
|
|
148
188
|
throw err;
|
|
149
189
|
});
|
|
150
190
|
|
|
191
|
+
/** The pinned variants from --variant name=value. */
|
|
192
|
+
function variants() {
|
|
193
|
+
/** @type {Record<string, string>} */
|
|
194
|
+
const out = {};
|
|
195
|
+
for (const pair of values.variant ?? []) {
|
|
196
|
+
const m = /^\s*([^=\s]+)\s*=\s*(\S+)\s*$/.exec(pair);
|
|
197
|
+
if (!m) throw new RangeError(`--variant must look like scene=forest, got "${pair}"`);
|
|
198
|
+
out[m[1]] = m[2];
|
|
199
|
+
}
|
|
200
|
+
return out;
|
|
201
|
+
}
|
|
202
|
+
|
|
151
203
|
/** How many covers to write: --count, 1 by default. */
|
|
152
204
|
function count() {
|
|
153
205
|
if (values.count === undefined) return 1;
|
|
@@ -179,36 +231,120 @@ function fileFor(out, n, total, seed) {
|
|
|
179
231
|
*/
|
|
180
232
|
const safeName = (seed) => seed.replace(/[^\p{L}\p{N}._-]+/gu, '-').replace(/^[.-]+/, '') || 'cover';
|
|
181
233
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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`);
|
|
202
278
|
writeFileSync(file, cover.svg);
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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)}`);
|
|
209
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
|
+
}
|
|
343
|
+
}
|
|
344
|
+
} catch (err) {
|
|
345
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
346
|
+
process.exitCode = 1;
|
|
210
347
|
}
|
|
211
|
-
} catch (err) {
|
|
212
|
-
console.error(err instanceof Error ? err.message : String(err));
|
|
213
|
-
process.exit(1);
|
|
214
348
|
}
|
|
349
|
+
|
|
350
|
+
if (preflight()) run();
|
package/package.json
CHANGED
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
|
+
}
|