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 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
- # Temporary Holding Version
1
+ # Book Cover Generator
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+ [![Website](https://img.shields.io/website?url=https%3A%2F%2Fcovers.otsirh.dev&label=covers.otsirh.dev)](https://covers.otsirh.dev)
5
+ ![Dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)
6
+ ![Types](https://img.shields.io/badge/types-included-blue)
7
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](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
+ ![Sample covers](docs/preview.png)
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": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }
@@ -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>;