book-cover-generator 1.4.0 → 1.5.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 +33 -24
- package/bin/book-cover.js +36 -2
- package/package.json +1 -1
- package/src/index.d.ts +46 -1
- package/src/index.js +144 -16
- package/src/styles/genre.js +25 -6
- package/src/styles/geometric.js +17 -4
- package/src/styles/pattern.js +66 -33
- package/src/styles/scenic.js +4 -2
- package/src/styles/typographic.js +4 -2
- package/src/styles/vintage.js +42 -5
- package/src/text.d.ts +4 -0
- package/src/text.js +62 -7
- package/src/type.d.ts +2 -0
- package/src/type.js +79 -29
package/README.md
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# Book Cover Generator
|
|
2
2
|
|
|
3
3
|
[](https://github.com/otsirh/book-cover-generator/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/book-cover-generator)
|
|
4
5
|
[](https://covers.otsirh.dev)
|
|
5
6
|

|
|
6
7
|

|
|
@@ -30,9 +31,10 @@ Generate book covers from a **title** and an **author**. 34 styles, 28 palettes
|
|
|
30
31
|
|
|
31
32
|
## The web app
|
|
32
33
|
|
|
33
|
-
On [covers.otsirh.dev](https://covers.otsirh.dev) you type a title and author,
|
|
34
|
+
On [covers.otsirh.dev](https://covers.otsirh.dev) you type a title and author (press Enter in the title to break its line where you want), and if you like a subtitle, a series line ("Book Two of the Ember Cycle") and a credit ("Translated by…"). 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).
|
|
@@ -58,7 +60,7 @@ The single-key shortcuts can be turned off under the controls (for example with
|
|
|
58
60
|
|
|
59
61
|
```html
|
|
60
62
|
<script type="module">
|
|
61
|
-
import { generateCover } from 'https://cdn.jsdelivr.net/
|
|
63
|
+
import { generateCover } from 'https://cdn.jsdelivr.net/npm/book-cover-generator@1/src/index.js';
|
|
62
64
|
|
|
63
65
|
document.body.innerHTML = generateCover({
|
|
64
66
|
title: 'The Lighthouse Keeper',
|
|
@@ -74,7 +76,7 @@ The single-key shortcuts can be turned off under the controls (for example with
|
|
|
74
76
|
### Node / bundlers
|
|
75
77
|
|
|
76
78
|
```sh
|
|
77
|
-
npm install
|
|
79
|
+
npm install book-cover-generator
|
|
78
80
|
```
|
|
79
81
|
|
|
80
82
|
```js
|
|
@@ -95,20 +97,20 @@ writeFileSync('cover.svg', svg);
|
|
|
95
97
|
### CLI
|
|
96
98
|
|
|
97
99
|
```sh
|
|
98
|
-
npx
|
|
99
|
-
npx
|
|
100
|
+
npx book-cover-generator "The Lighthouse Keeper" "Mara Ellison" --style artdeco -o cover.svg
|
|
101
|
+
npx book-cover-generator --list # all styles and palettes
|
|
100
102
|
```
|
|
101
103
|
|
|
102
104
|
```sh
|
|
103
105
|
# your own colours (the # is optional), as an eBook 1600 px wide
|
|
104
|
-
npx
|
|
106
|
+
npx book-cover-generator "Bright Machines" --bg 0b132b --ink f8f9fa --accent ff6b6b,4ecdc4 --format ebook --width 1600 -o cover.svg
|
|
105
107
|
# any size, and the choices as JSON (with the SVG, when there is no -o)
|
|
106
|
-
npx
|
|
108
|
+
npx book-cover-generator "Neon Tide" --size 1800x2700 --json
|
|
107
109
|
# twelve variations to choose from: ideas/cover-1.svg … ideas/cover-12.svg (seeds 1 to 12)
|
|
108
|
-
npx
|
|
110
|
+
npx book-cover-generator "Salt and Static" --count 12 -o ideas/cover.svg
|
|
109
111
|
```
|
|
110
112
|
|
|
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`.
|
|
113
|
+
Options: `-s/--style`, `-p/--palette`, `--bg`, `--ink`, `--accent`, `--subtitle`, `--texture`, `--seed`, `--format` (`paperback`, `ebook` or `square`), `--width`, `--height`, `--size`, `--lang`, `--series`, `--credit`, `--font`, `--case upper|normal`, `--variant name=value` (`--list` shows each style's), `-o/--out`, `--count`, `--json`, `--list`, `--help`.
|
|
112
114
|
|
|
113
115
|
## API
|
|
114
116
|
|
|
@@ -120,18 +122,23 @@ Returns the SVG markup.
|
|
|
120
122
|
|
|
121
123
|
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
124
|
|
|
123
|
-
| option
|
|
124
|
-
|
|
|
125
|
-
| `title`
|
|
126
|
-
| `author`
|
|
127
|
-
| `subtitle`
|
|
128
|
-
| `
|
|
129
|
-
| `
|
|
130
|
-
| `
|
|
131
|
-
| `
|
|
132
|
-
| `
|
|
133
|
-
| `
|
|
134
|
-
| `
|
|
125
|
+
| option | type | default | notes |
|
|
126
|
+
| ----------- | ------------------ | ---------------------- | ------------------------------------------------- |
|
|
127
|
+
| `title` | `string` | `'Untitled'` | a line break (`\n`) breaks the title's line there |
|
|
128
|
+
| `author` | `string` | `''` | optional |
|
|
129
|
+
| `subtitle` | `string` | `''` | optional subtitle or tagline |
|
|
130
|
+
| `series` | `string` | `''` | a series line above the title |
|
|
131
|
+
| `credit` | `string` | `''` | a credit under the subtitle ("Translated by…") |
|
|
132
|
+
| `texture` | `boolean` | `false` | adds a fine film grain |
|
|
133
|
+
| `style` | `string` | `'random'` | a style id, see `styles` |
|
|
134
|
+
| `palette` | `string \| object` | `'random'` | a palette id, or `{ bg, ink, accents: [...] }` |
|
|
135
|
+
| `seed` | `string \| number` | hash of title + author | controls every random choice |
|
|
136
|
+
| `width` | `number` | `600` | px, 50–10000; aspect ratio 1:3 to 3:1 |
|
|
137
|
+
| `height` | `number` | `width × 1.5` | px, 50–10000 |
|
|
138
|
+
| `lang` | `string` | inferred | language of the text, such as `'ja'` |
|
|
139
|
+
| `variants` | `object` | `{}` | pin a style's choices: `{ scene: 'forest' }` |
|
|
140
|
+
| `font` | `string` | the style's | the title's font, one of `FONTS` |
|
|
141
|
+
| `titleCase` | `string` | the style's | `'upper'` (capitals) or `'normal'` (as typed) |
|
|
135
142
|
|
|
136
143
|
`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
144
|
|
|
@@ -140,7 +147,7 @@ Titles and subtitles longer than 300 characters (authors: 200) are cut. With `'r
|
|
|
140
147
|
### Other exports
|
|
141
148
|
|
|
142
149
|
- `styles`: array of style ids
|
|
143
|
-
- `listStyles()`: `[{ id, name, description, group }]`
|
|
150
|
+
- `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
151
|
- `palettes`: `[{ id, name, bg, ink, accents, dark? }]`
|
|
145
152
|
- `randomSeed()`: a short random seed string
|
|
146
153
|
- `pickStyle(options)`: the style id `createCover` would use for these options (resolving `'random'` from the seed), without drawing the cover
|
|
@@ -190,7 +197,7 @@ generateCover({
|
|
|
190
197
|
|
|
191
198
|
`render(ctx)` returns SVG markup without the outer `<svg>` element. `ctx` contains:
|
|
192
199
|
|
|
193
|
-
- `title`, `author`, `subtitle` (may be empty)
|
|
200
|
+
- `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
201
|
- `W`, `H`: the size of the cover
|
|
195
202
|
- `u`: a scale unit (1 at a short side of 600px)
|
|
196
203
|
- `m`: the suggested margin
|
|
@@ -198,6 +205,8 @@ generateCover({
|
|
|
198
205
|
- `rng`: seeded random with `next`, `float`, `int`, `bool`, `pick`, `shuffle`
|
|
199
206
|
- `p`: the palette, with `bg`, `ink`, `accents`, plus `paper` (lighter) and `night` (darker)
|
|
200
207
|
- `id(name)`: makes ids that are unique per cover, for gradients and filters
|
|
208
|
+
- `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
|
|
209
|
+
- `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
210
|
|
|
202
211
|
```js
|
|
203
212
|
import { registerStyle, titleBlock, authorFit, drawText, FONTS } from 'book-cover-generator';
|
package/bin/book-cover.js
CHANGED
|
@@ -11,7 +11,14 @@ Options:
|
|
|
11
11
|
--bg <hex> your own colours instead of a palette: background,
|
|
12
12
|
--ink <hex> text,
|
|
13
13
|
--accent <hex> and accents (repeat it, or separate them with commas)
|
|
14
|
+
--variant <n=v> pin one of the style's choices, e.g. scene=forest
|
|
15
|
+
(repeat it; --list shows each style's)
|
|
16
|
+
--font <name> the title's font: serif, didone, garamond, sans,
|
|
17
|
+
geometric, humanist, condensed, display or mono
|
|
18
|
+
--case <how> the title in capitals (upper) or as typed (normal)
|
|
14
19
|
--subtitle <text> a subtitle or tagline
|
|
20
|
+
--series <text> a series line above the title ("Book Two of …")
|
|
21
|
+
--credit <text> a credit under the subtitle ("Translated by …")
|
|
15
22
|
--texture add a fine film-grain texture
|
|
16
23
|
--seed <value> seed for the artwork (default: title + author)
|
|
17
24
|
--format <shape> paperback (2:3, the default), ebook (5:8) or square
|
|
@@ -43,7 +50,12 @@ try {
|
|
|
43
50
|
bg: { type: 'string' },
|
|
44
51
|
ink: { type: 'string' },
|
|
45
52
|
accent: { type: 'string', multiple: true },
|
|
53
|
+
variant: { type: 'string', multiple: true },
|
|
54
|
+
font: { type: 'string' },
|
|
55
|
+
case: { type: 'string' },
|
|
46
56
|
subtitle: { type: 'string' },
|
|
57
|
+
series: { type: 'string' },
|
|
58
|
+
credit: { type: 'string' },
|
|
47
59
|
texture: { type: 'boolean' },
|
|
48
60
|
seed: { type: 'string' },
|
|
49
61
|
format: { type: 'string' },
|
|
@@ -72,7 +84,12 @@ if (values.help) {
|
|
|
72
84
|
|
|
73
85
|
if (values.list) {
|
|
74
86
|
console.log('Styles:');
|
|
75
|
-
for (const s of listStyles())
|
|
87
|
+
for (const s of listStyles()) {
|
|
88
|
+
console.log(` ${s.id.padEnd(14)} ${s.description}`);
|
|
89
|
+
for (const v of s.variants) {
|
|
90
|
+
console.log(` ${''.padEnd(14)} --variant ${v.name}=${v.values.map((x) => x.value).join('|')}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
76
93
|
console.log('\nPalettes:');
|
|
77
94
|
for (const p of palettes) console.log(` ${p.id.padEnd(14)} ${p.name}${p.dark ? ' (dark)' : ''}`);
|
|
78
95
|
process.exit(0);
|
|
@@ -148,6 +165,18 @@ process.stdout.on('error', (err) => {
|
|
|
148
165
|
throw err;
|
|
149
166
|
});
|
|
150
167
|
|
|
168
|
+
/** The pinned variants from --variant name=value. */
|
|
169
|
+
function variants() {
|
|
170
|
+
/** @type {Record<string, string>} */
|
|
171
|
+
const out = {};
|
|
172
|
+
for (const pair of values.variant ?? []) {
|
|
173
|
+
const m = /^\s*([^=\s]+)\s*=\s*(\S+)\s*$/.exec(pair);
|
|
174
|
+
if (!m) throw new RangeError(`--variant must look like scene=forest, got "${pair}"`);
|
|
175
|
+
out[m[1]] = m[2];
|
|
176
|
+
}
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
|
|
151
180
|
/** How many covers to write: --count, 1 by default. */
|
|
152
181
|
function count() {
|
|
153
182
|
if (values.count === undefined) return 1;
|
|
@@ -185,10 +214,15 @@ try {
|
|
|
185
214
|
title: positionals[0],
|
|
186
215
|
author: positionals[1] ?? '',
|
|
187
216
|
subtitle: values.subtitle,
|
|
217
|
+
series: values.series,
|
|
218
|
+
credit: values.credit,
|
|
188
219
|
texture: values.texture,
|
|
189
220
|
style: values.style,
|
|
190
221
|
palette: palette(),
|
|
191
222
|
lang: values.lang,
|
|
223
|
+
variants: variants(),
|
|
224
|
+
font: values.font,
|
|
225
|
+
titleCase: /** @type {'upper' | 'normal' | undefined} */ (values.case),
|
|
192
226
|
...dimensions(),
|
|
193
227
|
};
|
|
194
228
|
for (let n = 1; n <= total; n++) {
|
|
@@ -196,7 +230,7 @@ try {
|
|
|
196
230
|
const seed = total === 1 ? values.seed : values.seed ? `${values.seed}-${n}` : String(n);
|
|
197
231
|
const cover = createCover({ ...options, seed });
|
|
198
232
|
const { style, palette: paletteId, width, height, lang } = cover;
|
|
199
|
-
const choices = { style, palette: paletteId, seed: cover.seed, width, height, lang };
|
|
233
|
+
const choices = { style, palette: paletteId, seed: cover.seed, width, height, lang, variants: cover.variants };
|
|
200
234
|
if (values.out) {
|
|
201
235
|
const file = fileFor(values.out, n, total, cover.seed);
|
|
202
236
|
writeFileSync(file, cover.svg);
|
package/package.json
CHANGED
package/src/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Rng } from './core.js';
|
|
2
2
|
import type { Palette, PaletteId } from './palettes.js';
|
|
3
|
+
import type { FontSpec, FONTS } from './text.js';
|
|
3
4
|
|
|
4
5
|
export type { Rng } from './core.js';
|
|
5
6
|
export type { Palette, PaletteId } from './palettes.js';
|
|
@@ -87,6 +88,10 @@ export interface CoverOptions {
|
|
|
87
88
|
author?: string;
|
|
88
89
|
/** A subtitle or tagline, set small under the title. Up to 300 characters are used. */
|
|
89
90
|
subtitle?: string;
|
|
91
|
+
/** A series line set small above the title, such as `'Book Two of the Ember Cycle'`. Up to 120 characters. */
|
|
92
|
+
series?: string;
|
|
93
|
+
/** A credit set on a line of its own under the subtitle, such as `'Translated by Edith Grossman'`. Up to 120 characters. */
|
|
94
|
+
credit?: string;
|
|
90
95
|
/** Add a fine film-grain texture over the cover. Default `false`. */
|
|
91
96
|
texture?: boolean;
|
|
92
97
|
/** A style id, a registered custom style, or `'random'` (default; picked from the seed). */
|
|
@@ -104,6 +109,19 @@ export interface CoverOptions {
|
|
|
104
109
|
* that language's glyphs. Default: `'ja'` for text with kana, `'ko'` for text with Hangul, else none.
|
|
105
110
|
*/
|
|
106
111
|
lang?: string;
|
|
112
|
+
/**
|
|
113
|
+
* Choices to pin instead of leaving them to the seed, such as `{ scene: 'forest' }` for
|
|
114
|
+
* Silhouette (see each style's `variants` in `listStyles()`). Names the style doesn't have
|
|
115
|
+
* are ignored; a value it doesn't offer throws.
|
|
116
|
+
*/
|
|
117
|
+
variants?: Readonly<Record<string, string>>;
|
|
118
|
+
/**
|
|
119
|
+
* The title's font, one of `FONTS` (`'didone'`, `'geometric'`…), instead of the style's. Styles
|
|
120
|
+
* that draw their own lettering (Typewriter, Pixel) keep theirs.
|
|
121
|
+
*/
|
|
122
|
+
font?: keyof typeof FONTS | (string & {});
|
|
123
|
+
/** Set the title in capitals (`'upper'`) or as typed (`'normal'`) instead of as the style does. */
|
|
124
|
+
titleCase?: 'upper' | 'normal';
|
|
107
125
|
}
|
|
108
126
|
|
|
109
127
|
export interface Cover {
|
|
@@ -119,6 +137,15 @@ export interface Cover {
|
|
|
119
137
|
height: number;
|
|
120
138
|
/** The language set on the cover (given or inferred), or `''`. */
|
|
121
139
|
lang: string;
|
|
140
|
+
/** The value of each of the style's variants this cover has, pinned or picked by the seed. */
|
|
141
|
+
variants: Record<string, string>;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** A choice a style lets you pin, such as Silhouette's scene. */
|
|
145
|
+
export interface StyleVariant {
|
|
146
|
+
name: string;
|
|
147
|
+
label: string;
|
|
148
|
+
values: { value: string; label: string }[];
|
|
122
149
|
}
|
|
123
150
|
|
|
124
151
|
export interface StyleInfo {
|
|
@@ -127,6 +154,8 @@ export interface StyleInfo {
|
|
|
127
154
|
description: string;
|
|
128
155
|
/** The family the style belongs to; `'Custom'` for registered styles without one. */
|
|
129
156
|
group: StyleGroup | (string & {});
|
|
157
|
+
/** The choices you can pin with `createCover({ variants })`; empty for styles without any. */
|
|
158
|
+
variants: StyleVariant[];
|
|
130
159
|
}
|
|
131
160
|
|
|
132
161
|
/** A palette as seen by styles, with derived light/dark colours. */
|
|
@@ -143,8 +172,10 @@ export interface ResolvedPalette extends Palette {
|
|
|
143
172
|
export interface StyleContext {
|
|
144
173
|
readonly title: string;
|
|
145
174
|
readonly author: string;
|
|
146
|
-
/** Empty when there is no subtitle. */
|
|
175
|
+
/** Empty when there is no subtitle. A credit, if any, follows on a line of its own (after "\n"). */
|
|
147
176
|
readonly subtitle: string;
|
|
177
|
+
/** The series line, or empty. `titleBlock` sets it above the title. */
|
|
178
|
+
readonly series: string;
|
|
148
179
|
/** Cover width in px. */
|
|
149
180
|
readonly W: number;
|
|
150
181
|
/** Cover height in px. */
|
|
@@ -160,6 +191,15 @@ export interface StyleContext {
|
|
|
160
191
|
readonly p: ResolvedPalette;
|
|
161
192
|
/** Make an id that is unique to this cover, for gradients, filters, masks… */
|
|
162
193
|
id(name: string): string;
|
|
194
|
+
/**
|
|
195
|
+
* Pick one of `values` for the variant `name`, as `rng.pick(values)` would, unless the cover
|
|
196
|
+
* pins it (`createCover({ variants: { [name]: value } })`). Repeat a value to make it likelier.
|
|
197
|
+
*/
|
|
198
|
+
choose<T extends string>(name: string, values: readonly T[]): T;
|
|
199
|
+
/** The title font the cover asks for (`createCover`'s `font`), or null; `titleBlock` follows it. */
|
|
200
|
+
readonly titleFont: FontSpec | null;
|
|
201
|
+
/** The title case the cover asks for (`titleCase`), or null; `titleBlock` follows it. */
|
|
202
|
+
readonly titleCase: 'upper' | 'normal' | null;
|
|
163
203
|
}
|
|
164
204
|
|
|
165
205
|
export interface CoverStyle {
|
|
@@ -169,6 +209,11 @@ export interface CoverStyle {
|
|
|
169
209
|
group?: StyleGroup | (string & {});
|
|
170
210
|
/** Background preference when the palette is picked automatically. */
|
|
171
211
|
prefer?: 'light' | 'dark';
|
|
212
|
+
/**
|
|
213
|
+
* The choices `render` makes with `ctx.choose(name, values)` that people can pin, with labels:
|
|
214
|
+
* `{ scene: { label: 'Scene', values: { city: 'City', forest: 'Forest' } } }`.
|
|
215
|
+
*/
|
|
216
|
+
variants?: Readonly<Record<string, { label?: string; values: Readonly<Record<string, string>> }>>;
|
|
172
217
|
/** Return SVG markup without the outer `<svg>` element. */
|
|
173
218
|
render(ctx: StyleContext): string;
|
|
174
219
|
}
|
package/src/index.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { createRng, hashString, isHex, luminance, escapeXml, stripInvalidXml, grain } from './core.js';
|
|
7
7
|
import { palettes } from './palettes.js';
|
|
8
|
-
import { BREAKING_SPACE, clipGraphemes } from './text.js';
|
|
8
|
+
import { BREAKING_SPACE, FONTS, LINE_BREAK, clipGraphemes } from './text.js';
|
|
9
9
|
import { styleList } from './styles/index.js';
|
|
10
10
|
|
|
11
11
|
export { palettes } from './palettes.js';
|
|
@@ -25,7 +25,7 @@ export { strongAccents, textColor, tintText, fitTitleWith, textPanel, TEXT_CONTR
|
|
|
25
25
|
export * as svgUtils from './core.js';
|
|
26
26
|
|
|
27
27
|
/** This version of the library (artwork for a seed can change between minor versions). */
|
|
28
|
-
export const version = '1.
|
|
28
|
+
export const version = '1.5.0';
|
|
29
29
|
|
|
30
30
|
/** The sizes a cover can have: each side `min` to `max` px, and neither more than `ratio` times the other. */
|
|
31
31
|
export const sizeLimits = Object.freeze({ min: 50, max: 10000, ratio: 3 });
|
|
@@ -40,6 +40,35 @@ export const formats = Object.freeze({
|
|
|
40
40
|
/** An id as registries take it: a string, not empty, without spaces at either end. @param {unknown} id */
|
|
41
41
|
const isId = (id) => typeof id === 'string' && id !== '' && id === id.trim();
|
|
42
42
|
|
|
43
|
+
/** A name or value as people read it: 'bouquet' → 'Bouquet'. @param {string} id */
|
|
44
|
+
const labelFor = (id) => id.charAt(0).toUpperCase() + id.slice(1);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A style's variants (the choices it lets you pin), checked and in one shape: each with its
|
|
48
|
+
* name, a label, and its values with theirs.
|
|
49
|
+
* @param {string} id the style's id, for errors
|
|
50
|
+
* @param {import('./index.js').CoverStyle['variants']} variants
|
|
51
|
+
* @returns {import('./index.js').StyleVariant[]}
|
|
52
|
+
*/
|
|
53
|
+
function variantsOf(id, variants) {
|
|
54
|
+
if (variants == null) return [];
|
|
55
|
+
if (typeof variants !== 'object') throw new TypeError(`Style "${id}": variants must be an object`);
|
|
56
|
+
return Object.entries(variants).map(([name, spec]) => {
|
|
57
|
+
const values = spec && typeof spec === 'object' && !Array.isArray(spec) ? spec.values : null;
|
|
58
|
+
const entries = values && typeof values === 'object' ? Object.entries(values) : [];
|
|
59
|
+
if (!isId(name) || !entries.length || !entries.every(([v, l]) => isId(v) && typeof l === 'string')) {
|
|
60
|
+
throw new TypeError(
|
|
61
|
+
`Style "${id}": variant "${name}" needs { values: { value: 'Label', … } } with at least one value`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
name,
|
|
66
|
+
label: typeof spec.label === 'string' ? spec.label : labelFor(name),
|
|
67
|
+
values: entries.map(([value, label]) => ({ value, label: label || labelFor(value) })),
|
|
68
|
+
};
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
43
72
|
/**
|
|
44
73
|
* A registry entry: the style's details with defaults filled in, and a render that calls
|
|
45
74
|
* the style's own method on the style itself (so class styles keep `this` and private fields).
|
|
@@ -52,9 +81,33 @@ const entryFor = (style) => ({
|
|
|
52
81
|
description: style.description ?? '',
|
|
53
82
|
group: style.group ?? 'Custom',
|
|
54
83
|
prefer: style.prefer,
|
|
84
|
+
variants: variantsOf(style.id, style.variants),
|
|
55
85
|
render: (/** @type {import('./index.js').StyleContext} */ ctx) => style.render(ctx),
|
|
56
86
|
});
|
|
57
87
|
|
|
88
|
+
/**
|
|
89
|
+
* The variants pinned for a style: the given ones it has (others are ignored, so the same
|
|
90
|
+
* options can go to any style), each checked against the values it offers.
|
|
91
|
+
* @param {unknown} given @param {ReturnType<typeof entryFor>} style
|
|
92
|
+
* @returns {Record<string, string>}
|
|
93
|
+
*/
|
|
94
|
+
function pinnedVariants(given, style) {
|
|
95
|
+
if (given == null) return {};
|
|
96
|
+
if (typeof given !== 'object') throw new TypeError('variants must be an object such as { scene: "forest" }');
|
|
97
|
+
/** @type {Record<string, string>} */
|
|
98
|
+
const pins = {};
|
|
99
|
+
for (const variant of style.variants) {
|
|
100
|
+
const value = /** @type {Record<string, unknown>} */ (given)[variant.name];
|
|
101
|
+
if (value == null || value === '') continue;
|
|
102
|
+
if (!variant.values.some((v) => v.value === value)) {
|
|
103
|
+
const offered = variant.values.map((v) => v.value).join(', ');
|
|
104
|
+
throw new RangeError(`Style "${style.id}" has no ${variant.name} "${String(value)}". Available: ${offered}`);
|
|
105
|
+
}
|
|
106
|
+
pins[variant.name] = /** @type {string} */ (value);
|
|
107
|
+
}
|
|
108
|
+
return pins;
|
|
109
|
+
}
|
|
110
|
+
|
|
58
111
|
const builtInStyles = new Map(styleList.map((s) => [s.id, entryFor(s)]));
|
|
59
112
|
const registry = new Map(builtInStyles);
|
|
60
113
|
/** The palettes, as this module changes them (registering adds to the list everyone sees). */
|
|
@@ -64,7 +117,13 @@ const builtInPalettes = new Map(paletteList.map((p) => [p.id, p]));
|
|
|
64
117
|
|
|
65
118
|
/** Metadata for every available style: [{ id, name, description, group }] */
|
|
66
119
|
export function listStyles() {
|
|
67
|
-
return [...registry.values()].map(({ id, name, description, group }) => ({
|
|
120
|
+
return [...registry.values()].map(({ id, name, description, group, variants }) => ({
|
|
121
|
+
id,
|
|
122
|
+
name,
|
|
123
|
+
description,
|
|
124
|
+
group,
|
|
125
|
+
variants: variants.map((v) => ({ ...v, values: v.values.map((x) => ({ ...x })) })),
|
|
126
|
+
}));
|
|
68
127
|
}
|
|
69
128
|
|
|
70
129
|
/** Ids of every available style. */
|
|
@@ -191,17 +250,23 @@ function resolvePalette(palette, rng, style) {
|
|
|
191
250
|
return normalizePalette(found);
|
|
192
251
|
}
|
|
193
252
|
|
|
253
|
+
// eslint-disable-next-line no-control-regex -- matching control characters is the point
|
|
254
|
+
const CONTROL = /[\u0000-\u001F\u007F-\u009F]/g;
|
|
255
|
+
|
|
194
256
|
/**
|
|
195
257
|
* Collapse control characters and whitespace, drop anything illegal in XML and
|
|
196
258
|
* cap the length (so huge inputs can't make layout slow).
|
|
197
259
|
* @param {unknown} value
|
|
198
260
|
* @param {number} max maximum length in characters
|
|
261
|
+
* @param {{ breaks?: boolean }} [options]
|
|
199
262
|
*/
|
|
200
|
-
function cleanText(value, max) {
|
|
201
|
-
//
|
|
202
|
-
const text =
|
|
203
|
-
.
|
|
204
|
-
.trim()
|
|
263
|
+
function cleanText(value, max, { breaks = false } = {}) {
|
|
264
|
+
// With `breaks`, line breaks stay (as "\n", one between lines): the title's lines break there.
|
|
265
|
+
const text = String(value ?? '')
|
|
266
|
+
.split(breaks ? LINE_BREAK : /(?!)/)
|
|
267
|
+
.map((line) => stripInvalidXml(line.replace(CONTROL, ' ')).replace(BREAKING_SPACE, ' ').trim())
|
|
268
|
+
.filter(Boolean)
|
|
269
|
+
.join('\n');
|
|
205
270
|
// Cut between visible characters (segmenting lazily), so an emoji or accented letter is never split.
|
|
206
271
|
return text.length > max ? clipGraphemes(text, max).trimEnd() : text;
|
|
207
272
|
}
|
|
@@ -228,6 +293,29 @@ function dimension(value, fallback, name) {
|
|
|
228
293
|
return Math.round(v);
|
|
229
294
|
}
|
|
230
295
|
|
|
296
|
+
/** Ways to set a title's letters: in capitals, or as typed. */
|
|
297
|
+
const TITLE_CASES = ['upper', 'normal'];
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* The title's font and case, if the options set them (else each style's own).
|
|
301
|
+
* @param {import('./index.js').CoverOptions} options
|
|
302
|
+
* @returns {{ font: import('./text.js').FontSpec | null, titleCase: 'upper' | 'normal' | null }}
|
|
303
|
+
*/
|
|
304
|
+
function typeOptions(options) {
|
|
305
|
+
const font = /** @type {unknown} */ (options.font);
|
|
306
|
+
const titleCase = /** @type {unknown} */ (options.titleCase);
|
|
307
|
+
if (font != null && font !== '' && !Object.hasOwn(FONTS, /** @type {string} */ (font))) {
|
|
308
|
+
throw new RangeError(`Unknown font "${String(font)}". Available: ${Object.keys(FONTS).join(', ')}`);
|
|
309
|
+
}
|
|
310
|
+
if (titleCase != null && titleCase !== '' && !TITLE_CASES.includes(/** @type {string} */ (titleCase))) {
|
|
311
|
+
throw new RangeError(`titleCase must be 'upper' or 'normal', got ${JSON.stringify(titleCase)}`);
|
|
312
|
+
}
|
|
313
|
+
return {
|
|
314
|
+
font: font ? FONTS[/** @type {keyof typeof FONTS} */ (font)] : null,
|
|
315
|
+
titleCase: titleCase ? /** @type {'upper' | 'normal'} */ (titleCase) : null,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
231
319
|
/** A BCP 47 language tag, loosely: `ja`, `zh-Hant`, `pt-BR`, `sr-Latn-RS`. */
|
|
232
320
|
const LANG_TAG = /^[a-z]{2,3}(?:-[a-z\d]{1,8})*$/i;
|
|
233
321
|
const KANA = /[\p{Script=Hiragana}\p{Script=Katakana}]/u;
|
|
@@ -255,9 +343,11 @@ function resolveLang(lang, text) {
|
|
|
255
343
|
* @param {Pick<import('./index.js').CoverOptions, 'title' | 'author' | 'seed' | 'style'>} options
|
|
256
344
|
*/
|
|
257
345
|
function resolveText(options) {
|
|
258
|
-
const title = cleanText(options.title, 300) || 'Untitled';
|
|
346
|
+
const title = cleanText(options.title, 300, { breaks: true }) || 'Untitled';
|
|
259
347
|
const author = cleanText(options.author, 200);
|
|
260
|
-
|
|
348
|
+
// (A line break in the title doesn't change the seed it makes: same words, same artwork.)
|
|
349
|
+
const seed =
|
|
350
|
+
options.seed == null || options.seed === '' ? `${title.replace(/\n/g, ' ')}␟${author}` : String(options.seed);
|
|
261
351
|
return { title, author, seed, styleId: resolveStyle(options.style, createRng(`${seed}|style`)) };
|
|
262
352
|
}
|
|
263
353
|
|
|
@@ -279,9 +369,11 @@ export function pickStyle(options = {}) {
|
|
|
279
369
|
export function createCover(options = {}) {
|
|
280
370
|
const { title, author, seed, styleId } = resolveText(options);
|
|
281
371
|
const subtitle = cleanText(options.subtitle, 300);
|
|
372
|
+
const series = cleanText(options.series, 120);
|
|
373
|
+
const credit = cleanText(options.credit, 120);
|
|
282
374
|
const W = dimension(options.width, 600, 'width');
|
|
283
375
|
const H = dimension(options.height, W * 1.5, 'height');
|
|
284
|
-
const lang = resolveLang(options.lang, `${title} ${subtitle} ${author}`);
|
|
376
|
+
const lang = resolveLang(options.lang, `${title} ${subtitle} ${author} ${series} ${credit}`);
|
|
285
377
|
const { ratio } = sizeLimits;
|
|
286
378
|
if (W / H > ratio || H / W > ratio) {
|
|
287
379
|
throw new RangeError(`The aspect ratio must be between 1:${ratio} and ${ratio}:1, got ${W}×${H}`);
|
|
@@ -291,23 +383,56 @@ export function createCover(options = {}) {
|
|
|
291
383
|
const [paper, night] =
|
|
292
384
|
luminance(palette.bg) >= luminance(palette.ink) ? [palette.bg, palette.ink] : [palette.ink, palette.bg];
|
|
293
385
|
|
|
386
|
+
const pins = pinnedVariants(options.variants, style);
|
|
387
|
+
const titleType = typeOptions(options);
|
|
294
388
|
const uid =
|
|
295
389
|
'bc' +
|
|
296
|
-
hashString(
|
|
390
|
+
hashString(
|
|
391
|
+
// (Pins only when there are some, so ids of covers without them stay as they were.)
|
|
392
|
+
JSON.stringify([
|
|
393
|
+
title,
|
|
394
|
+
author,
|
|
395
|
+
subtitle,
|
|
396
|
+
seed,
|
|
397
|
+
styleId,
|
|
398
|
+
palette,
|
|
399
|
+
W,
|
|
400
|
+
H,
|
|
401
|
+
!!options.texture,
|
|
402
|
+
...(Object.keys(pins).length ? [pins] : []),
|
|
403
|
+
...(titleType.font || titleType.titleCase ? [options.font ?? '', titleType.titleCase ?? ''] : []),
|
|
404
|
+
...(series || credit ? [series, credit] : []),
|
|
405
|
+
]),
|
|
406
|
+
).toString(36);
|
|
407
|
+
/** The variants the style chose (or was given). @type {Record<string, string>} */
|
|
408
|
+
const chosen = {};
|
|
409
|
+
const rng = createRng(`${seed}|${styleId}`);
|
|
297
410
|
const short = Math.min(W, H);
|
|
298
411
|
/** @type {import('./index.js').StyleContext} */
|
|
299
412
|
const ctx = {
|
|
300
413
|
title,
|
|
301
414
|
author,
|
|
302
|
-
subtitle,
|
|
415
|
+
// A credit ("Translated by …") is a line of its own under the subtitle, so every style sets it.
|
|
416
|
+
subtitle: [subtitle, credit].filter(Boolean).join('\n'),
|
|
417
|
+
series,
|
|
303
418
|
W,
|
|
304
419
|
H,
|
|
305
420
|
S: short,
|
|
306
421
|
u: short / 600,
|
|
307
422
|
m: short * 0.07,
|
|
308
|
-
rng
|
|
423
|
+
rng,
|
|
309
424
|
p: { ...palette, paper, night },
|
|
310
425
|
id: (name) => `${uid}-${name}`,
|
|
426
|
+
titleFont: titleType.font,
|
|
427
|
+
titleCase: titleType.titleCase,
|
|
428
|
+
// The seed's pick is always drawn, so pinning a variant changes nothing drawn before it.
|
|
429
|
+
choose: (name, values) => {
|
|
430
|
+
const picked = rng.pick(values);
|
|
431
|
+
const pin = pins[name];
|
|
432
|
+
const value = pin !== undefined && values.includes(/** @type {any} */ (pin)) ? /** @type {any} */ (pin) : picked;
|
|
433
|
+
chosen[name] = value;
|
|
434
|
+
return value;
|
|
435
|
+
},
|
|
311
436
|
};
|
|
312
437
|
|
|
313
438
|
let body = style.render(ctx);
|
|
@@ -318,13 +443,16 @@ export function createCover(options = {}) {
|
|
|
318
443
|
const light = grain(ctx, { opacity: 0.08, light: true, id: 'texture-light', seed: 11 });
|
|
319
444
|
body += `<defs>${dark.defs}${light.defs}</defs>${dark.layer}${light.layer}`;
|
|
320
445
|
}
|
|
321
|
-
const label = escapeXml(
|
|
446
|
+
const label = escapeXml(
|
|
447
|
+
`${title.replace(/\n/g, ' ')}${subtitle ? `: ${subtitle}` : ''}${author ? ` by ${author}` : ''}` +
|
|
448
|
+
`${series ? ` (${series})` : ''}${credit ? `. ${credit}` : ''}`,
|
|
449
|
+
);
|
|
322
450
|
// (Both spellings: `lang` is SVG 2's, `xml:lang` what older renderers read.)
|
|
323
451
|
const langAttrs = lang ? ` lang="${lang}" xml:lang="${lang}"` : '';
|
|
324
452
|
const svg =
|
|
325
453
|
`<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}"${langAttrs} role="img" aria-label="${label}">` +
|
|
326
454
|
`<title>${label}</title>${body}</svg>`;
|
|
327
|
-
return { svg, body, style: styleId, palette: palette.id, seed, width: W, height: H, lang };
|
|
455
|
+
return { svg, body, style: styleId, palette: palette.id, seed, width: W, height: H, lang, variants: chosen };
|
|
328
456
|
}
|
|
329
457
|
|
|
330
458
|
/**
|