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 CHANGED
@@ -1,6 +1,7 @@
1
1
  # Book Cover Generator
2
2
 
3
3
  [![CI](https://github.com/otsirh/book-cover-generator/actions/workflows/ci.yml/badge.svg)](https://github.com/otsirh/book-cover-generator/actions/workflows/ci.yml)
4
+ [![npm](https://img.shields.io/npm/v/book-cover-generator)](https://www.npmjs.com/package/book-cover-generator)
4
5
  [![Website](https://img.shields.io/website?url=https%3A%2F%2Fcovers.otsirh.dev&label=covers.otsirh.dev)](https://covers.otsirh.dev)
5
6
  ![Dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)
6
7
  ![Types](https://img.shields.io/badge/types-included-blue)
@@ -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, then choose:
34
+ On [covers.otsirh.dev](https://covers.otsirh.dev) you type a title and author (press Enter in the title to break its line where you want), and if you like a subtitle, a series line ("Book Two of the Ember Cycle"), a credit ("Translated by…") and a badge ("Winner of the Hugo Award", set as a seal in a corner clear of the text). Then you choose:
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/gh/otsirh/book-cover-generator@1/src/index.js';
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 "github:otsirh/book-cover-generator#semver:^1"
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 github:otsirh/book-cover-generator "The Lighthouse Keeper" "Mara Ellison" --style artdeco -o cover.svg
99
- npx github:otsirh/book-cover-generator --list # all styles and palettes
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 github:otsirh/book-cover-generator "Bright Machines" --bg 0b132b --ink f8f9fa --accent ff6b6b,4ecdc4 --format ebook --width 1600 -o cover.svg
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 github:otsirh/book-cover-generator "Neon Tide" --size 1800x2700 --json
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 github:otsirh/book-cover-generator "Salt and Static" --count 12 -o ideas/cover.svg
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 | type | default | notes |
124
- | ---------- | ------------------ | ---------------------- | ---------------------------------------------- |
125
- | `title` | `string` | `'Untitled'` | |
126
- | `author` | `string` | `''` | optional |
127
- | `subtitle` | `string` | `''` | optional subtitle or tagline |
128
- | `texture` | `boolean` | `false` | adds a fine film grain |
129
- | `style` | `string` | `'random'` | a style id, see `styles` |
130
- | `palette` | `string \| object` | `'random'` | a palette id, or `{ bg, ink, accents: [...] }` |
131
- | `seed` | `string \| number` | hash of title + author | controls every random choice |
132
- | `width` | `number` | `600` | px, 50–10000; aspect ratio 1:3 to 3:1 |
133
- | `height` | `number` | `width × 1.5` | px, 50–10000 |
134
- | `lang` | `string` | inferred | language of the text, such as `'ja'` |
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
- if (values.help) {
69
- console.log(HELP);
70
- process.exit(0);
71
- }
72
-
73
- if (values.list) {
74
- console.log('Styles:');
75
- for (const s of listStyles()) console.log(` ${s.id.padEnd(14)} ${s.description}`);
76
- console.log('\nPalettes:');
77
- for (const p of palettes) console.log(` ${p.id.padEnd(14)} ${p.name}${p.dark ? ' (dark)' : ''}`);
78
- process.exit(0);
79
- }
80
-
81
- if (!positionals.length) {
82
- console.error(HELP);
83
- process.exit(1);
84
- }
85
- if (positionals.length > 2) {
86
- console.error(
87
- `Expected a title and an author, got ${positionals.length} arguments. Quote values with spaces: book-cover "The Lighthouse Keeper" "Mara Ellison"`,
88
- );
89
- process.exit(1);
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
- try {
183
- const total = count();
184
- const options = {
185
- title: positionals[0],
186
- author: positionals[1] ?? '',
187
- subtitle: values.subtitle,
188
- texture: values.texture,
189
- style: values.style,
190
- palette: palette(),
191
- lang: values.lang,
192
- ...dimensions(),
193
- };
194
- for (let n = 1; n <= total; n++) {
195
- // One cover: the seed given (or the title's). Several: seeds 1…n, or <seed>-1…<seed>-n.
196
- const seed = total === 1 ? values.seed : values.seed ? `${values.seed}-${n}` : String(n);
197
- const cover = createCover({ ...options, seed });
198
- const { style, palette: paletteId, width, height, lang } = cover;
199
- const choices = { style, palette: paletteId, seed: cover.seed, width, height, lang };
200
- if (values.out) {
201
- const file = fileFor(values.out, n, total, cover.seed);
234
+ /** A title as part of a file name: lower case, accents off Latin letters, dashes between words. @param {string} title */
235
+ function slug(title) {
236
+ const words = title
237
+ .normalize('NFKD')
238
+ .replace(/(\p{Script=Latin})[\u0300-\u036f]+/gu, '$1')
239
+ .normalize('NFC')
240
+ .toLowerCase()
241
+ .replace(/[^\p{L}\p{M}\p{N}]+/gu, '-')
242
+ .replace(/^-+/, '');
243
+ return Array.from(words).slice(0, 60).join('').replace(/-+$/, '') || 'cover';
244
+ }
245
+
246
+ /**
247
+ * Write a cover for each book in the --batch list into --out-dir; the command line's options
248
+ * apply to every book the list doesn't say otherwise for.
249
+ * @param {import('../src/index.js').CoverOptions} defaults
250
+ */
251
+ function batch(defaults) {
252
+ if (values.count !== undefined || values.out !== undefined) {
253
+ throw new Error('--batch writes a file per book into --out-dir: use it without --out or --count');
254
+ }
255
+ const source = /** @type {string} */ (values.batch);
256
+ const books = parseBookList(readFileSync(source === '-' ? 0 : source, 'utf8'));
257
+ if (!books.length) {
258
+ throw new Error(
259
+ `No books in ${source === '-' ? 'the input' : source}: a title per line, or a CSV with a "title" column`,
260
+ );
261
+ }
262
+ const dir = values['out-dir'] ?? '.';
263
+ mkdirSync(dir, { recursive: true });
264
+ const digits = String(books.length).length;
265
+ let failed = 0;
266
+ books.forEach((book, i) => {
267
+ const n = i + 1;
268
+ try {
269
+ // A size in the list replaces the command line's.
270
+ const sized = book.width !== undefined || book.height !== undefined;
271
+ const cover = createCover({
272
+ ...defaults,
273
+ ...(sized ? { width: undefined, height: undefined } : {}),
274
+ ...book,
275
+ seed: book.seed ?? values.seed,
276
+ });
277
+ const file = join(dir, `${String(n).padStart(digits, '0')}-${slug(cover.title)}.svg`);
202
278
  writeFileSync(file, cover.svg);
203
- if (values.json) process.stdout.write(JSON.stringify({ file, ...choices }) + '\n');
204
- else console.error(`Wrote ${file} (style: ${style}, palette: ${paletteId}, ${width}x${height})`);
205
- } else if (values.json) {
206
- process.stdout.write(JSON.stringify({ ...choices, svg: cover.svg }) + '\n');
207
- } else {
208
- process.stdout.write(cover.svg + '\n');
279
+ const { style, palette: paletteId, seed, width, height } = cover;
280
+ if (values.json)
281
+ process.stdout.write(JSON.stringify({ file, style, palette: paletteId, seed, width, height }) + '\n');
282
+ } catch (err) {
283
+ failed += 1;
284
+ console.error(`Book ${n} ("${book.title}"): ${err instanceof Error ? err.message : String(err)}`);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "book-cover-generator",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "description": "Generate SVG book covers from a title and an author. 34 styles, 28 palettes, seeded randomizer, zero dependencies.",
5
5
  "keywords": [
6
6
  "book",
package/src/badge.js ADDED
@@ -0,0 +1,85 @@
1
+ // A badge on the cover: a seal ("Winner of the …", "10th Anniversary Edition") set in a corner
2
+ // clear of the text.
3
+
4
+ import { n, mix, escapeXml } from './core.js';
5
+ import { FONTS, fitText, drawText } from './text.js';
6
+ import { strongAccents, textColor } from './styles/util.js';
7
+
8
+ /** @typedef {import('./text.js').TextBox} TextBox */
9
+
10
+ /** How much of the square around a circle a box covers. @param {number} cx @param {number} cy @param {number} r @param {TextBox} b */
11
+ function overlap(cx, cy, r, b) {
12
+ const w = Math.min(cx + r, b.x1) - Math.max(cx - r, b.x0);
13
+ const h = Math.min(cy + r, b.y1) - Math.max(cy - r, b.y0);
14
+ return w > 0 && h > 0 ? w * h : 0;
15
+ }
16
+
17
+ /**
18
+ * The seal: a star-edged disc with a ring and the text inside, tilted a little, in the first
19
+ * corner (then side) clear of the text, smaller if it must be; else where it covers least.
20
+ * @param {import('./index.js').StyleContext} c
21
+ * @param {string} text
22
+ * @param {TextBox[]} boxes the text already on the cover
23
+ */
24
+ export function drawBadge(c, text, boxes) {
25
+ const { W, H, S, u, m, p } = c;
26
+ /** Where a seal of radius r could go: corners first, then the middle of each side. @param {number} r */
27
+ const spots = (r) => {
28
+ const inset = m * 0.5 + r;
29
+ return [
30
+ [W - inset, inset],
31
+ [inset, inset],
32
+ [W - inset, H - inset],
33
+ [inset, H - inset],
34
+ [W - inset, H / 2],
35
+ [inset, H / 2],
36
+ ];
37
+ };
38
+ const pad = S * 0.01;
39
+ /** @type {[number, number, number] | null} */
40
+ let place = null;
41
+ for (const scale of [1, 0.85, 0.7]) {
42
+ const r = S * 0.12 * scale;
43
+ const free = spots(r).find(([x, y]) => boxes.every((b) => overlap(x, y, r + pad, b) === 0));
44
+ if (free) {
45
+ place = [free[0], free[1], r];
46
+ break;
47
+ }
48
+ }
49
+ if (!place) {
50
+ const r = S * 0.084;
51
+ const covered = (/** @type {number[]} */ [x, y]) => boxes.reduce((sum, b) => sum + overlap(x, y, r, b), 0);
52
+ const best = spots(r).reduce((a, b) => (covered(b) < covered(a) ? b : a));
53
+ place = [best[0], best[1], r];
54
+ }
55
+ const [cx, cy, r] = place;
56
+
57
+ const fill = strongAccents(p, p.bg, 1.5)[0];
58
+ const ink = textColor(p, fill, [p.paper, p.night]);
59
+ const points = [];
60
+ const spikes = 28;
61
+ for (let i = 0; i < spikes * 2; i++) {
62
+ const a = (i / (spikes * 2)) * Math.PI * 2;
63
+ const rr = i % 2 ? r * 0.9 : r;
64
+ points.push(`${n(cx + Math.cos(a) * rr)},${n(cy + Math.sin(a) * rr)}`);
65
+ }
66
+ const fit = fitText(text, {
67
+ font: FONTS.sans,
68
+ weight: 700,
69
+ upper: true,
70
+ letterSpacing: 0.04,
71
+ maxWidth: r * 1.25,
72
+ maxHeight: r * 1.05,
73
+ maxSize: r * 0.3,
74
+ maxLines: 4,
75
+ lineHeight: 1.05,
76
+ });
77
+ const edge = mix(fill, p.night, 0.35);
78
+ return (
79
+ `<g transform="rotate(-8 ${n(cx)} ${n(cy)})" aria-label="${escapeXml(text)}">` +
80
+ `<polygon points="${points.join(' ')}" fill="${fill}" stroke="${edge}" stroke-width="${n(1.2 * u)}"/>` +
81
+ `<circle cx="${n(cx)}" cy="${n(cy)}" r="${n(r * 0.76)}" fill="none" stroke="${ink}" stroke-width="${n(1.4 * u)}" opacity="0.7"/>` +
82
+ drawText(fit, { x: cx, y: cy - fit.height / 2, anchor: 'middle', fill: ink }) +
83
+ '</g>'
84
+ );
85
+ }