book-cover-generator 0.0.0-stage → 1.4.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/LICENSE +21 -0
- package/README.md +274 -2
- package/bin/book-cover.js +214 -0
- package/package.json +84 -4
- package/src/browser.d.ts +52 -0
- package/src/browser.js +352 -0
- package/src/core.d.ts +100 -0
- package/src/core.js +283 -0
- package/src/index.d.ts +205 -0
- package/src/index.js +338 -0
- package/src/palettes.d.ts +44 -0
- package/src/palettes.js +77 -0
- package/src/styles/genre.js +555 -0
- package/src/styles/geometric.js +419 -0
- package/src/styles/index.js +50 -0
- package/src/styles/pattern.js +991 -0
- package/src/styles/scenic.js +732 -0
- package/src/styles/typographic.js +220 -0
- package/src/styles/util.d.ts +54 -0
- package/src/styles/util.js +120 -0
- package/src/styles/vintage.js +559 -0
- package/src/text.d.ts +143 -0
- package/src/text.js +823 -0
- package/src/type.d.ts +92 -0
- package/src/type.js +422 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 otsirh
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,275 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Book Cover Generator
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://github.com/otsirh/book-cover-generator/actions/workflows/ci.yml)
|
|
4
|
+
[](https://covers.otsirh.dev)
|
|
5
|
+

|
|
6
|
+

|
|
7
|
+
[](LICENSE)
|
|
8
|
+
|
|
9
|
+
Generate book covers from a **title** and an **author**. 34 styles, 28 palettes or your own colours, a seeded randomizer, any size, and export to **SVG, PNG, JPEG or WebP**. The library has no dependencies and ships TypeScript types. It's written as plain ES modules, so it runs in the browser and in Node.
|
|
10
|
+
|
|
11
|
+
**[▶ Open the generator](https://covers.otsirh.dev)**
|
|
12
|
+
|
|
13
|
+

|
|
14
|
+
|
|
15
|
+
## Features
|
|
16
|
+
|
|
17
|
+
- **34 styles in six families.**
|
|
18
|
+
- Typographic: Big Type, Minimal, Big Letter, Swiss.
|
|
19
|
+
- Genre: Thriller, Botanical, Noir, Blueprint.
|
|
20
|
+
- Vintage: Art Deco, Classic Paperback, Engraved, Sunburst, Typewriter.
|
|
21
|
+
- Scenic: Landscape, Retro Sun, Silhouette, Constellation, Orbit, Waves, Aurora, Bookshelf.
|
|
22
|
+
- Geometric: Bauhaus, Split, Blocks, Concentric, Stripes.
|
|
23
|
+
- Pattern: Paper Cut, Halftone, Topographic, Flow Field, Low Poly, Mosaic, Memphis, Pixel.
|
|
24
|
+
- **28 palettes** (15 light, 13 dark), or pass your own. Every style adapts to light and dark palettes.
|
|
25
|
+
- **Typography that responds to the title.** Short titles are set large, a leading "The" or "A" becomes a small line above the title, and some styles stack the words edge to edge. Long titles wrap with balanced lines and shrink to fit. Author names stay readable at thumbnail size. Accents, emoji, Chinese, Japanese and Thai are handled.
|
|
26
|
+
- **Subtitle or tagline**, and an optional **paper texture**.
|
|
27
|
+
- **Randomizer.** Every random choice comes from a seed, so the same seed always gives the same cover. If you don't pass a seed, it is derived from the title and author.
|
|
28
|
+
- **Any size.** 2:3 paperback, 5:8 eBook, 1:1 audiobook, or any width × height from 50 to 10,000 px with an aspect ratio between 1:3 and 3:1, such as Kindle's 1600 × 2560 or a print trim size at 300 dpi.
|
|
29
|
+
- **Export.** Download SVG, PNG, JPEG or WebP. PNG and JPEG files record 300 dpi, so print software sizes them right. Copy the image or the SVG, or share it from a phone.
|
|
30
|
+
|
|
31
|
+
## The web app
|
|
32
|
+
|
|
33
|
+
On [covers.otsirh.dev](https://covers.otsirh.dev) you type a title and author, then choose:
|
|
34
|
+
|
|
35
|
+
- **a style**, from a rail that previews your book in all 34 styles;
|
|
36
|
+
- **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
|
+
- **a variation**, the seed behind the artwork: the strip under the preview shows six others to click;
|
|
38
|
+
- **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
|
+
|
|
40
|
+
**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
|
+
|
|
42
|
+
**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
|
+
|
|
44
|
+
The app works offline once it has loaded.
|
|
45
|
+
|
|
46
|
+
Keyboard shortcuts:
|
|
47
|
+
|
|
48
|
+
- `R` randomizes.
|
|
49
|
+
- `←` and `→` step through styles.
|
|
50
|
+
- `[` and `]` step through palettes.
|
|
51
|
+
- `Z` undoes and `Shift+Z` redoes.
|
|
52
|
+
|
|
53
|
+
The single-key shortcuts can be turned off under the controls (for example with speech input).
|
|
54
|
+
|
|
55
|
+
## Library usage
|
|
56
|
+
|
|
57
|
+
### Browser (no install)
|
|
58
|
+
|
|
59
|
+
```html
|
|
60
|
+
<script type="module">
|
|
61
|
+
import { generateCover } from 'https://cdn.jsdelivr.net/gh/otsirh/book-cover-generator@1/src/index.js';
|
|
62
|
+
|
|
63
|
+
document.body.innerHTML = generateCover({
|
|
64
|
+
title: 'The Lighthouse Keeper',
|
|
65
|
+
author: 'Mara Ellison',
|
|
66
|
+
style: 'artdeco',
|
|
67
|
+
palette: 'midnight',
|
|
68
|
+
});
|
|
69
|
+
</script>
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`@1` gives you the latest 1.x release. Pin an exact version (such as `@1.1.0`) if you need the artwork to never change.
|
|
73
|
+
|
|
74
|
+
### Node / bundlers
|
|
75
|
+
|
|
76
|
+
```sh
|
|
77
|
+
npm install "github:otsirh/book-cover-generator#semver:^1"
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
```js
|
|
81
|
+
import { createCover } from 'book-cover-generator';
|
|
82
|
+
import { writeFileSync } from 'node:fs';
|
|
83
|
+
|
|
84
|
+
const { svg, style, palette, seed } = createCover({
|
|
85
|
+
title: 'Neon Tide',
|
|
86
|
+
author: 'Kai Moreno',
|
|
87
|
+
style: 'random', // or a style id, see `styles`
|
|
88
|
+
seed: 42, // same seed, same cover
|
|
89
|
+
width: 1600,
|
|
90
|
+
height: 2560,
|
|
91
|
+
});
|
|
92
|
+
writeFileSync('cover.svg', svg);
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### CLI
|
|
96
|
+
|
|
97
|
+
```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
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
```sh
|
|
103
|
+
# 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
|
|
105
|
+
# 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
|
|
107
|
+
# 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
|
|
109
|
+
```
|
|
110
|
+
|
|
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`.
|
|
112
|
+
|
|
113
|
+
## API
|
|
114
|
+
|
|
115
|
+
### `generateCover(options) → string`
|
|
116
|
+
|
|
117
|
+
Returns the SVG markup.
|
|
118
|
+
|
|
119
|
+
### `createCover(options) → { svg, body, style, palette, seed, width, height, lang }`
|
|
120
|
+
|
|
121
|
+
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
|
+
|
|
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'` |
|
|
135
|
+
|
|
136
|
+
`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
|
+
|
|
138
|
+
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.
|
|
139
|
+
|
|
140
|
+
### Other exports
|
|
141
|
+
|
|
142
|
+
- `styles`: array of style ids
|
|
143
|
+
- `listStyles()`: `[{ id, name, description, group }]`
|
|
144
|
+
- `palettes`: `[{ id, name, bg, ink, accents, dark? }]`
|
|
145
|
+
- `randomSeed()`: a short random seed string
|
|
146
|
+
- `pickStyle(options)`: the style id `createCover` would use for these options (resolving `'random'` from the seed), without drawing the cover
|
|
147
|
+
- `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
|
+
- `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
|
|
149
|
+
- `version`: the library's version (artwork for a seed can change between minor versions)
|
|
150
|
+
- `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
|
+
- `titleBlock`, `authorFit`: the typography the built-in styles use (a title with its lead line and subtitle, and the author line)
|
|
152
|
+
- `fitText`, `drawText`, `fitStack`, `drawStack`, `splitLead`, `measureText`, `setTextMeasurer`, `FONTS`: lower-level text helpers (`fitText` can hyphenate one long word with `hyphenate: px`), and `baselineOf`, `textBottom`, `textGroupAttrs`, `markExtent` for styles that draw text themselves
|
|
153
|
+
- `strongAccents`, `textColor`, `tintText`, `fitTitleWith`, `textPanel`, `TEXT_CONTRAST`: the colour and layout helpers the built-in styles use (accents that stand out, text colours that stay readable, giving up art for a longer title, a panel for title and author)
|
|
154
|
+
- `svgUtils`: the SVG, colour and noise helpers the built-in styles use
|
|
155
|
+
|
|
156
|
+
### TypeScript
|
|
157
|
+
|
|
158
|
+
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
|
+
|
|
160
|
+
### Export helpers (browser)
|
|
161
|
+
|
|
162
|
+
```js
|
|
163
|
+
import { downloadCover, rasterize, copyText, copyImage, zipFiles } from 'book-cover-generator/browser';
|
|
164
|
+
|
|
165
|
+
await copyText(svg); // copy SVG markup
|
|
166
|
+
await copyImage(svg, { scale: 2 }); // copy a PNG (where canCopyImage() is true)
|
|
167
|
+
await downloadCover(svg, 'svg', 'my-cover'); // my-cover.svg
|
|
168
|
+
await downloadCover(svg, 'png', 'my-cover', { scale: 3, dpi: 300 }); // 1800×2700 PNG that prints at 6 × 9 in
|
|
169
|
+
await downloadCover(svg, 'jpeg', 'my-cover', { width: 1600, quality: 0.9 }); // exactly 1600 px wide
|
|
170
|
+
const name = await downloadCover(svg, 'webp', 'my-cover'); // 'my-cover.png' where WebP can't be made
|
|
171
|
+
const blob = await rasterize(svg, { type: 'image/webp', width: 1600, height: 2560 });
|
|
172
|
+
const zip = await zipFiles([
|
|
173
|
+
{ name: 'cover.png', data: blob },
|
|
174
|
+
{ name: 'cover.svg', data: svg },
|
|
175
|
+
]);
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
`setDpi(blob, dpi)` records the pixel density in a PNG or JPEG you made yourself. It and `zipFiles` also work in Node.
|
|
179
|
+
|
|
180
|
+
### Custom palette
|
|
181
|
+
|
|
182
|
+
```js
|
|
183
|
+
generateCover({
|
|
184
|
+
title: 'Bright Machines',
|
|
185
|
+
palette: { bg: '#0b132b', ink: '#f8f9fa', accents: ['#ff6b6b', '#4ecdc4', '#ffe66d'] },
|
|
186
|
+
});
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
### Custom style
|
|
190
|
+
|
|
191
|
+
`render(ctx)` returns SVG markup without the outer `<svg>` element. `ctx` contains:
|
|
192
|
+
|
|
193
|
+
- `title`, `author`, `subtitle` (may be empty)
|
|
194
|
+
- `W`, `H`: the size of the cover
|
|
195
|
+
- `u`: a scale unit (1 at a short side of 600px)
|
|
196
|
+
- `m`: the suggested margin
|
|
197
|
+
- `S`: the short side; base text sizes on it so wide covers work
|
|
198
|
+
- `rng`: seeded random with `next`, `float`, `int`, `bool`, `pick`, `shuffle`
|
|
199
|
+
- `p`: the palette, with `bg`, `ink`, `accents`, plus `paper` (lighter) and `night` (darker)
|
|
200
|
+
- `id(name)`: makes ids that are unique per cover, for gradients and filters
|
|
201
|
+
|
|
202
|
+
```js
|
|
203
|
+
import { registerStyle, titleBlock, authorFit, drawText, FONTS } from 'book-cover-generator';
|
|
204
|
+
|
|
205
|
+
registerStyle({
|
|
206
|
+
id: 'plain',
|
|
207
|
+
name: 'Plain',
|
|
208
|
+
group: 'Typographic',
|
|
209
|
+
render(c) {
|
|
210
|
+
const title = titleBlock(c, {
|
|
211
|
+
font: FONTS.serif,
|
|
212
|
+
maxWidth: c.W - c.m * 2,
|
|
213
|
+
maxHeight: c.H * 0.5,
|
|
214
|
+
maxSize: c.S * 0.2,
|
|
215
|
+
});
|
|
216
|
+
const author = authorFit(c);
|
|
217
|
+
return (
|
|
218
|
+
`<rect width="${c.W}" height="${c.H}" fill="${c.p.bg}"/>` +
|
|
219
|
+
title.draw({ x: c.m, y: c.m, fill: c.p.ink }) +
|
|
220
|
+
drawText(author, { x: c.m, y: c.H - c.m - author.height, fill: c.p.ink })
|
|
221
|
+
);
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
## Notes
|
|
227
|
+
|
|
228
|
+
- **Fonts.** Covers only use system font stacks (Georgia, Helvetica, Futura, Didot, Impact, Courier and others), each with close matches on Windows, Android and Linux. This is because an SVG drawn into a canvas for PNG/JPEG export can't load web fonts. In the browser, text is measured with a canvas, so it fits exactly. In Node, widths are estimated; plug in a real font engine with `setTextMeasurer` if you need exact fits there.
|
|
229
|
+
- **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.
|
|
230
|
+
- **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.
|
|
231
|
+
- **Browser support.** Current Chrome, Edge, Firefox and Safari. Every release is tested in Chromium, Firefox and WebKit, including PNG and JPEG export.
|
|
232
|
+
|
|
233
|
+
## Development
|
|
234
|
+
|
|
235
|
+
```sh
|
|
236
|
+
npm ci # install dev tooling (the library itself has no dependencies)
|
|
237
|
+
npx playwright install # browsers for the end-to-end tests
|
|
238
|
+
npm run dev # http://127.0.0.1:4173
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
| script | what it does |
|
|
242
|
+
| ----------------------- | ----------------------------------------------------------------- |
|
|
243
|
+
| `npm run check` | lint, format check, type check, package check and unit tests |
|
|
244
|
+
| `npm test` | unit tests (`node --test`) |
|
|
245
|
+
| `npm run test:coverage` | unit tests with coverage thresholds |
|
|
246
|
+
| `npm run test:e2e` | browser tests in Chromium, Firefox, WebKit and mobile Chrome |
|
|
247
|
+
| `npm run format` | format everything with Prettier |
|
|
248
|
+
| `npm run assets` | regenerate the app icons, the social preview and the README image |
|
|
249
|
+
| `npm run deploy` | run every check, then build and deploy to Cloudflare (`wrangler`) |
|
|
250
|
+
|
|
251
|
+
Open `examples/contact-sheet.html?palettes=noir,mint&seeds=a,b` on the dev server to see many covers at once (also `styles`, `titles`, `subtitle`, `w`, `h`, `cols` and `texture=1`).
|
|
252
|
+
|
|
253
|
+
### Project layout
|
|
254
|
+
|
|
255
|
+
```
|
|
256
|
+
src/ the library (plain ES modules + .d.ts types)
|
|
257
|
+
styles/ the 34 styles, one file per family
|
|
258
|
+
bin/ the CLI
|
|
259
|
+
site/ the web app (index.html is at the root)
|
|
260
|
+
public/ files served at the site root: headers, 404, icons, manifest, service worker
|
|
261
|
+
test/ unit tests (node --test)
|
|
262
|
+
test-d/ type tests (tsc)
|
|
263
|
+
e2e/ browser tests (Playwright)
|
|
264
|
+
scripts/ build, dev server and asset generation
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
The site is a static [Cloudflare Workers](https://developers.cloudflare.com/workers/static-assets/) deployment configured in `wrangler.jsonc`, with security headers in `public/_headers`.
|
|
268
|
+
|
|
269
|
+
## Contributing
|
|
270
|
+
|
|
271
|
+
Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md), and report security issues as described in [SECURITY.md](SECURITY.md).
|
|
272
|
+
|
|
273
|
+
## License
|
|
274
|
+
|
|
275
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { writeFileSync } from 'node:fs';
|
|
3
|
+
import { parseArgs } from 'node:util';
|
|
4
|
+
import { createCover, listStyles, palettes, formats } from '../src/index.js';
|
|
5
|
+
|
|
6
|
+
const HELP = `Usage: book-cover "<title>" ["<author>"] [options]
|
|
7
|
+
|
|
8
|
+
Options:
|
|
9
|
+
-s, --style <id> style id or "random" (default: random from seed)
|
|
10
|
+
-p, --palette <id> palette id or "random" (default: random from seed)
|
|
11
|
+
--bg <hex> your own colours instead of a palette: background,
|
|
12
|
+
--ink <hex> text,
|
|
13
|
+
--accent <hex> and accents (repeat it, or separate them with commas)
|
|
14
|
+
--subtitle <text> a subtitle or tagline
|
|
15
|
+
--texture add a fine film-grain texture
|
|
16
|
+
--seed <value> seed for the artwork (default: title + author)
|
|
17
|
+
--format <shape> paperback (2:3, the default), ebook (5:8) or square
|
|
18
|
+
--width <px> width (default 600)
|
|
19
|
+
--height <px> height (default: from the format)
|
|
20
|
+
--size <WxH> width and height at once, e.g. 1600x2560
|
|
21
|
+
--lang <tag> language of the text, e.g. ja or zh-Hant (default: inferred)
|
|
22
|
+
-o, --out <file> write SVG to a file instead of stdout
|
|
23
|
+
--count <n> write n variations (seeds 1…n, or <seed>-1…), named
|
|
24
|
+
cover-1.svg… from --out (or put {n} or {seed} in it)
|
|
25
|
+
--json print the choices (and the SVG, without --out) as JSON,
|
|
26
|
+
one line per cover
|
|
27
|
+
--list list styles and palettes
|
|
28
|
+
--help show this help
|
|
29
|
+
|
|
30
|
+
Examples:
|
|
31
|
+
book-cover "The Lighthouse Keeper" "Mara Ellison" -s artdeco -o cover.svg
|
|
32
|
+
book-cover "Neon Tide" "Kai Moreno" --style retro --palette synth --size 1600x2560 > cover.svg
|
|
33
|
+
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`;
|
|
35
|
+
|
|
36
|
+
let args;
|
|
37
|
+
try {
|
|
38
|
+
args = parseArgs({
|
|
39
|
+
allowPositionals: true,
|
|
40
|
+
options: {
|
|
41
|
+
style: { type: 'string', short: 's' },
|
|
42
|
+
palette: { type: 'string', short: 'p' },
|
|
43
|
+
bg: { type: 'string' },
|
|
44
|
+
ink: { type: 'string' },
|
|
45
|
+
accent: { type: 'string', multiple: true },
|
|
46
|
+
subtitle: { type: 'string' },
|
|
47
|
+
texture: { type: 'boolean' },
|
|
48
|
+
seed: { type: 'string' },
|
|
49
|
+
format: { type: 'string' },
|
|
50
|
+
width: { type: 'string' },
|
|
51
|
+
height: { type: 'string' },
|
|
52
|
+
size: { type: 'string' },
|
|
53
|
+
lang: { type: 'string' },
|
|
54
|
+
out: { type: 'string', short: 'o' },
|
|
55
|
+
count: { type: 'string' },
|
|
56
|
+
json: { type: 'boolean' },
|
|
57
|
+
list: { type: 'boolean' },
|
|
58
|
+
help: { type: 'boolean' },
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
} catch (err) {
|
|
62
|
+
console.error((err instanceof Error ? err.message : String(err)) + '\n\n' + HELP);
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const { values, positionals } = args;
|
|
67
|
+
|
|
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);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* A --width/--height value as a number, reporting what was typed if it isn't one.
|
|
94
|
+
* @param {string | undefined} value @param {string} name
|
|
95
|
+
*/
|
|
96
|
+
function size(value, name) {
|
|
97
|
+
if (value === undefined) return undefined;
|
|
98
|
+
const number = Number(value);
|
|
99
|
+
if (!value.trim() || !Number.isFinite(number)) throw new RangeError(`--${name} must be a number, got "${value}"`);
|
|
100
|
+
return number;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Width and height from --format, --width, --height and --size.
|
|
105
|
+
* @returns {{ width?: number, height?: number }}
|
|
106
|
+
*/
|
|
107
|
+
function dimensions() {
|
|
108
|
+
if (values.size !== undefined) {
|
|
109
|
+
if (values.width !== undefined || values.height !== undefined || values.format !== undefined) {
|
|
110
|
+
throw new Error('--size sets the width and height: use it without --width, --height or --format');
|
|
111
|
+
}
|
|
112
|
+
const m = /^\s*(\d+(?:\.\d+)?)\s*[x×]\s*(\d+(?:\.\d+)?)\s*$/i.exec(values.size);
|
|
113
|
+
if (!m) throw new RangeError(`--size must look like 1600x2560, got "${values.size}"`);
|
|
114
|
+
return { width: Number(m[1]), height: Number(m[2]) };
|
|
115
|
+
}
|
|
116
|
+
const width = size(values.width, 'width');
|
|
117
|
+
const height = size(values.height, 'height');
|
|
118
|
+
if (values.format === undefined) return { width, height };
|
|
119
|
+
if (!Object.hasOwn(formats, values.format)) {
|
|
120
|
+
throw new Error(`Unknown format "${values.format}". Available: ${Object.keys(formats).join(', ')}`);
|
|
121
|
+
}
|
|
122
|
+
if (height !== undefined) throw new Error('--format sets the height from the width: use --size for both');
|
|
123
|
+
// The shape of the app's format, at the width asked for.
|
|
124
|
+
const [w, h] = formats[/** @type {keyof typeof formats} */ (values.format)];
|
|
125
|
+
const W = width ?? 600;
|
|
126
|
+
return { width: W, height: Math.round((W * h) / w) };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** A colour as typed, with the # added (it starts a comment in most shells). @param {string} c */
|
|
130
|
+
const hex = (c) => (c.trim().startsWith('#') ? c.trim() : `#${c.trim()}`);
|
|
131
|
+
|
|
132
|
+
/** The palette: an id, or your own colours from --bg, --ink and --accent. */
|
|
133
|
+
function palette() {
|
|
134
|
+
const custom = values.bg !== undefined || values.ink !== undefined || values.accent !== undefined;
|
|
135
|
+
if (!custom) return values.palette;
|
|
136
|
+
if (values.palette !== undefined)
|
|
137
|
+
throw new Error('Use --palette or your own colours (--bg, --ink, --accent), not both');
|
|
138
|
+
const accents = (values.accent ?? []).flatMap((a) => a.split(',')).filter((a) => a.trim());
|
|
139
|
+
if (values.bg === undefined || values.ink === undefined || !accents.length) {
|
|
140
|
+
throw new Error('Your own colours need --bg, --ink and at least one --accent');
|
|
141
|
+
}
|
|
142
|
+
return { bg: hex(values.bg), ink: hex(values.ink), accents: accents.map(hex) };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// A reader that stops early (book-cover … | head) closes the pipe: that's fine, not a crash.
|
|
146
|
+
process.stdout.on('error', (err) => {
|
|
147
|
+
if (/** @type {NodeJS.ErrnoException} */ (err).code === 'EPIPE') process.exit(0);
|
|
148
|
+
throw err;
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
/** How many covers to write: --count, 1 by default. */
|
|
152
|
+
function count() {
|
|
153
|
+
if (values.count === undefined) return 1;
|
|
154
|
+
const n = Number(values.count);
|
|
155
|
+
if (!/^\s*\d+\s*$/.test(values.count) || n < 1 || n > 1000) {
|
|
156
|
+
throw new RangeError(`--count must be a whole number from 1 to 1000, got "${values.count}"`);
|
|
157
|
+
}
|
|
158
|
+
if (n > 1 && !values.out) throw new Error('--count writes files: give --out, e.g. -o covers/cover.svg');
|
|
159
|
+
return n;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The file for cover `n` of `total`: --out with {n} and {seed} filled in, or (for several
|
|
164
|
+
* covers) with "-n" before the extension.
|
|
165
|
+
* @param {string} out @param {number} n @param {number} total @param {string} seed
|
|
166
|
+
*/
|
|
167
|
+
function fileFor(out, n, total, seed) {
|
|
168
|
+
if (/\{n\}|\{seed\}/.test(out)) return out.replaceAll('{n}', String(n)).replaceAll('{seed}', safeName(seed));
|
|
169
|
+
if (total === 1) return out;
|
|
170
|
+
const dot = out.lastIndexOf('.');
|
|
171
|
+
const slash = Math.max(out.lastIndexOf('/'), out.lastIndexOf('\\'));
|
|
172
|
+
return dot > slash + 1 ? `${out.slice(0, dot)}-${n}${out.slice(dot)}` : `${out}-${n}`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* A seed as part of a file name: letters, digits, dots, dashes and underscores only (so a seed
|
|
177
|
+
* can't add folders, leave the folder or break names on Windows), never starting with a dot.
|
|
178
|
+
* @param {string} seed
|
|
179
|
+
*/
|
|
180
|
+
const safeName = (seed) => seed.replace(/[^\p{L}\p{N}._-]+/gu, '-').replace(/^[.-]+/, '') || 'cover';
|
|
181
|
+
|
|
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);
|
|
202
|
+
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');
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
} catch (err) {
|
|
212
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
213
|
+
process.exit(1);
|
|
214
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,86 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "book-cover-generator",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.4.0",
|
|
4
|
+
"description": "Generate SVG book covers from a title and an author. 34 styles, 28 palettes, seeded randomizer, zero dependencies.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"book",
|
|
7
|
+
"cover",
|
|
8
|
+
"book-cover",
|
|
9
|
+
"svg",
|
|
10
|
+
"generator",
|
|
11
|
+
"generative-art",
|
|
12
|
+
"placeholder",
|
|
13
|
+
"typescript"
|
|
14
|
+
],
|
|
15
|
+
"homepage": "https://covers.otsirh.dev/",
|
|
16
|
+
"bugs": {
|
|
17
|
+
"url": "https://github.com/otsirh/book-cover-generator/issues"
|
|
18
|
+
},
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/otsirh/book-cover-generator.git"
|
|
22
|
+
},
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"author": "otsirh (https://github.com/otsirh)",
|
|
25
|
+
"type": "module",
|
|
26
|
+
"sideEffects": false,
|
|
27
|
+
"exports": {
|
|
28
|
+
".": {
|
|
29
|
+
"types": "./src/index.d.ts",
|
|
30
|
+
"default": "./src/index.js"
|
|
31
|
+
},
|
|
32
|
+
"./browser": {
|
|
33
|
+
"types": "./src/browser.d.ts",
|
|
34
|
+
"default": "./src/browser.js"
|
|
35
|
+
},
|
|
36
|
+
"./package.json": "./package.json"
|
|
37
|
+
},
|
|
38
|
+
"main": "./src/index.js",
|
|
39
|
+
"types": "./src/index.d.ts",
|
|
40
|
+
"bin": {
|
|
41
|
+
"book-cover": "bin/book-cover.js"
|
|
42
|
+
},
|
|
43
|
+
"files": [
|
|
44
|
+
"src",
|
|
45
|
+
"bin"
|
|
46
|
+
],
|
|
47
|
+
"engines": {
|
|
48
|
+
"node": ">=18.3"
|
|
49
|
+
},
|
|
50
|
+
"scripts": {
|
|
51
|
+
"dev": "node scripts/serve.js 4173",
|
|
52
|
+
"build": "node scripts/build.js",
|
|
53
|
+
"lint": "eslint .",
|
|
54
|
+
"lint:package": "publint --strict && attw --pack . --profile esm-only",
|
|
55
|
+
"format": "prettier --write .",
|
|
56
|
+
"format:check": "prettier --check .",
|
|
57
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
58
|
+
"test": "node --test",
|
|
59
|
+
"test:coverage": "node --test --experimental-test-coverage --test-coverage-include=src/**/*.js --test-coverage-exclude=src/browser.js --test-coverage-lines=99 --test-coverage-branches=95 --test-coverage-functions=98",
|
|
60
|
+
"test:e2e": "playwright test",
|
|
61
|
+
"check": "npm run lint && npm run format:check && npm run typecheck && npm run lint:package && npm test",
|
|
62
|
+
"assets": "node scripts/generate-assets.js",
|
|
63
|
+
"deploy": "npm run check && npm run test:e2e && wrangler deploy"
|
|
64
|
+
},
|
|
65
|
+
"devDependencies": {
|
|
66
|
+
"@arethetypeswrong/cli": "0.18.5",
|
|
67
|
+
"@axe-core/playwright": "4.14.0",
|
|
68
|
+
"@eslint/js": "10.0.1",
|
|
69
|
+
"@playwright/test": "1.65.0",
|
|
70
|
+
"@types/node": "26.6.6",
|
|
71
|
+
"eslint": "10.14.0",
|
|
72
|
+
"globals": "17.14.0",
|
|
73
|
+
"prettier": "3.10.0",
|
|
74
|
+
"publint": "0.3.25",
|
|
75
|
+
"typescript": "7.0.2",
|
|
76
|
+
"wrangler": "4.160.1"
|
|
77
|
+
},
|
|
78
|
+
"allowScripts": {
|
|
79
|
+
"esbuild@0.28.2": true,
|
|
80
|
+
"workerd@1.20261110.1": true,
|
|
81
|
+
"fsevents": false
|
|
82
|
+
},
|
|
83
|
+
"overrides": {
|
|
84
|
+
"minimatch": "11.0.8"
|
|
85
|
+
}
|
|
86
|
+
}
|
package/src/browser.d.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export declare function svgToDataUrl(svg: string): string;
|
|
2
|
+
export declare function svgToBlob(svg: string): Blob;
|
|
3
|
+
|
|
4
|
+
export interface RasterizeOptions {
|
|
5
|
+
type?: 'image/png' | 'image/jpeg' | 'image/webp';
|
|
6
|
+
/** Multiplier applied to the SVG's width and height. Default 1. Ignored when `width` or `height` is given. */
|
|
7
|
+
scale?: number;
|
|
8
|
+
/** The image's exact width in px. Alone, the height follows the SVG's shape. */
|
|
9
|
+
width?: number;
|
|
10
|
+
/** The image's exact height in px. Alone, the width follows the SVG's shape. */
|
|
11
|
+
height?: number;
|
|
12
|
+
/** Pixels per inch to record in PNG and JPEG files (what print software reads for the printed size). */
|
|
13
|
+
dpi?: number;
|
|
14
|
+
/** 0–1, for JPEG and WebP. Default 0.92. */
|
|
15
|
+
quality?: number;
|
|
16
|
+
/** Fill colour behind the cover. JPEG defaults to white. */
|
|
17
|
+
background?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Render SVG markup to an image Blob. Browsers that can't encode `type` return PNG; check `blob.type`. */
|
|
21
|
+
export declare function rasterize(svg: string, opts?: RasterizeOptions): Promise<Blob>;
|
|
22
|
+
/** Trigger a file download for a Blob. */
|
|
23
|
+
export declare function downloadBlob(blob: Blob, filename: string): void;
|
|
24
|
+
|
|
25
|
+
export type ExportFormat = 'svg' | 'png' | 'jpeg' | 'jpg' | 'webp';
|
|
26
|
+
/**
|
|
27
|
+
* Download a cover; `basename` gets the right extension appended. Resolves to the file name,
|
|
28
|
+
* whose extension says what was made (browsers that can't encode WebP make a PNG).
|
|
29
|
+
*/
|
|
30
|
+
export declare function downloadCover(
|
|
31
|
+
svg: string,
|
|
32
|
+
format?: ExportFormat,
|
|
33
|
+
basename?: string,
|
|
34
|
+
opts?: Omit<RasterizeOptions, 'type'>,
|
|
35
|
+
): Promise<string>;
|
|
36
|
+
/** `'png'`, `'jpg'` or `'webp'` for an image Blob of that type, else undefined. */
|
|
37
|
+
export declare function extensionOf(blob: Blob): 'png' | 'jpg' | 'webp' | undefined;
|
|
38
|
+
/** A copy of a PNG or JPEG Blob that records `dpi` pixels per inch. Other types come back unchanged. Works in Node too. */
|
|
39
|
+
export declare function setDpi(blob: Blob, dpi: number): Promise<Blob>;
|
|
40
|
+
/** A ZIP archive (stored, not compressed) of the given files. Names are UTF-8 and should be unique. Works in Node too. */
|
|
41
|
+
export declare function zipFiles(
|
|
42
|
+
files: ReadonlyArray<{ name: string; data: Blob | Uint8Array | string }>,
|
|
43
|
+
): Promise<Blob>;
|
|
44
|
+
/** Copy a cover to the clipboard as a PNG. Rejects where `canCopyImage()` is false. */
|
|
45
|
+
export declare function copyImage(
|
|
46
|
+
svg: string,
|
|
47
|
+
opts?: Omit<RasterizeOptions, 'type' | 'quality' | 'dpi'>,
|
|
48
|
+
): Promise<void>;
|
|
49
|
+
/** Whether this browser can copy images to the clipboard on this page. */
|
|
50
|
+
export declare function canCopyImage(): boolean;
|
|
51
|
+
/** Copy text to the clipboard, with a fallback for non-secure contexts. */
|
|
52
|
+
export declare function copyText(text: string): Promise<void>;
|