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 CHANGED
@@ -1,6 +1,7 @@
1
1
  # Book Cover Generator
2
2
 
3
3
  [![CI](https://github.com/otsirh/book-cover-generator/actions/workflows/ci.yml/badge.svg)](https://github.com/otsirh/book-cover-generator/actions/workflows/ci.yml)
4
+ [![npm](https://img.shields.io/npm/v/book-cover-generator)](https://www.npmjs.com/package/book-cover-generator)
4
5
  [![Website](https://img.shields.io/website?url=https%3A%2F%2Fcovers.otsirh.dev&label=covers.otsirh.dev)](https://covers.otsirh.dev)
5
6
  ![Dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)
6
7
  ![Types](https://img.shields.io/badge/types-included-blue)
@@ -30,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, then choose:
34
+ On [covers.otsirh.dev](https://covers.otsirh.dev) you type a title and author (press Enter in the title to break its line where you want), and if you like a subtitle, a series line ("Book Two of the Ember Cycle") 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/gh/otsirh/book-cover-generator@1/src/index.js';
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 "github:otsirh/book-cover-generator#semver:^1"
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 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
+ 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 github:otsirh/book-cover-generator "Bright Machines" --bg 0b132b --ink f8f9fa --accent ff6b6b,4ecdc4 --format ebook --width 1600 -o cover.svg
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 github:otsirh/book-cover-generator "Neon Tide" --size 1800x2700 --json
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 github:otsirh/book-cover-generator "Salt and Static" --count 12 -o ideas/cover.svg
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 | 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'` |
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()) console.log(` ${s.id.padEnd(14)} ${s.description}`);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "book-cover-generator",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "Generate SVG book covers from a title and an author. 34 styles, 28 palettes, seeded randomizer, zero dependencies.",
5
5
  "keywords": [
6
6
  "book",
package/src/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.4.0';
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 }) => ({ 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
- // eslint-disable-next-line no-control-regex -- matching control characters is the point
202
- const text = stripInvalidXml(String(value ?? '').replace(/[\u0000-\u001F\u007F-\u009F]/g, ' '))
203
- .replace(BREAKING_SPACE, ' ')
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
- const seed = options.seed == null || options.seed === '' ? `${title}␟${author}` : String(options.seed);
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(JSON.stringify([title, author, subtitle, seed, styleId, palette, W, H, !!options.texture])).toString(36);
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: createRng(`${seed}|${styleId}`),
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(`${title}${subtitle ? `: ${subtitle}` : ''}${author ? ` by ${author}` : ''}`);
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
  /**