book-cover-generator 1.5.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -3
- package/bin/book-cover.js +164 -62
- package/package.json +1 -1
- package/src/badge.js +85 -0
- package/src/books.js +143 -0
- package/src/index.d.ts +33 -1
- package/src/index.js +30 -6
- package/src/mockups.js +229 -0
- package/src/styles/pattern.js +2 -1
- package/src/styles/vintage.js +3 -0
- package/src/text.d.ts +11 -0
- package/src/text.js +31 -0
- package/src/type.js +3 -0
package/README.md
CHANGED
|
@@ -31,7 +31,7 @@ Generate book covers from a **title** and an **author**. 34 styles, 28 palettes
|
|
|
31
31
|
|
|
32
32
|
## The web app
|
|
33
33
|
|
|
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")
|
|
34
|
+
On [covers.otsirh.dev](https://covers.otsirh.dev) you type a title and author (press Enter in the title to break its line where you want), and if you like a subtitle, a series line ("Book Two of the Ember Cycle"), a credit ("Translated by…") and a badge ("Winner of the Hugo Award", set as a seal in a corner clear of the text). Then you choose:
|
|
35
35
|
|
|
36
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
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;
|
|
@@ -41,6 +41,10 @@ On [covers.otsirh.dev](https://covers.otsirh.dev) you type a title and author (p
|
|
|
41
41
|
|
|
42
42
|
**Store size** shrinks the preview to the size a store lists covers at, to check the title still reads. **Randomize** reshuffles everything, except what you've locked. Undo and redo step back and forth through your designs, and so do the browser's Back and Forward buttons. **Copy link** shares the exact design, because the URL holds every setting. The export panel tells you which size suits Kindle, print or audiobook covers.
|
|
43
43
|
|
|
44
|
+
**Mockup** shows and downloads the cover as a 3D book, a flat shot, on an e-reader, or on a social card (a 1200 × 630 link preview, a square post or a story, with a line such as "Out now").
|
|
45
|
+
|
|
46
|
+
**Many covers** takes a list of books (one per line as `Title | Author | Subtitle`, or a CSV with a title column) and makes them all with the design you have, as PNG or SVG in one ZIP. With a fixed variation they share one look, as a series does; switch on "A different palette for each" for a set in several colours.
|
|
47
|
+
|
|
44
48
|
**Keep** saves a design to a tray under the preview, together with the cover as it was drawn, so a later version of the app can't change it. **Download all** saves every kept design as a PNG in one ZIP file. **Export** saves the kept designs to a file, and **Import** adds them in another browser.
|
|
45
49
|
|
|
46
50
|
The app works offline once it has loaded.
|
|
@@ -106,11 +110,13 @@ npx book-cover-generator --list # all styles and palettes
|
|
|
106
110
|
npx book-cover-generator "Bright Machines" --bg 0b132b --ink f8f9fa --accent ff6b6b,4ecdc4 --format ebook --width 1600 -o cover.svg
|
|
107
111
|
# any size, and the choices as JSON (with the SVG, when there is no -o)
|
|
108
112
|
npx book-cover-generator "Neon Tide" --size 1800x2700 --json
|
|
113
|
+
# a cover for every book in a list (CSV with a header row, JSON lines, or "Title | Author" lines), one look for all
|
|
114
|
+
npx book-cover-generator --batch books.csv --style minimal --seed series --out-dir covers
|
|
109
115
|
# twelve variations to choose from: ideas/cover-1.svg … ideas/cover-12.svg (seeds 1 to 12)
|
|
110
116
|
npx book-cover-generator "Salt and Static" --count 12 -o ideas/cover.svg
|
|
111
117
|
```
|
|
112
118
|
|
|
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`.
|
|
119
|
+
Options: `-s/--style`, `-p/--palette`, `--bg`, `--ink`, `--accent`, `--subtitle`, `--texture`, `--seed`, `--format` (`paperback`, `ebook` or `square`), `--width`, `--height`, `--size`, `--lang`, `--series`, `--credit`, `--badge`, `--font`, `--case upper|normal`, `--variant name=value` (`--list` shows each style's), `-o/--out`, `--count`, `--batch <file|->`, `--out-dir`, `--json`, `--list`, `--help`.
|
|
114
120
|
|
|
115
121
|
## API
|
|
116
122
|
|
|
@@ -118,7 +124,7 @@ Options: `-s/--style`, `-p/--palette`, `--bg`, `--ink`, `--accent`, `--subtitle`
|
|
|
118
124
|
|
|
119
125
|
Returns the SVG markup.
|
|
120
126
|
|
|
121
|
-
### `createCover(options) → { svg, body, style, palette, seed, width, height, lang }`
|
|
127
|
+
### `createCover(options) → { svg, body, style, palette, colors, seed, width, height, lang, variants, title, author }`
|
|
122
128
|
|
|
123
129
|
Same as `generateCover`, but also tells you which style and palette were chosen. This is useful when either one was random. `body` is the markup inside the outer `<svg>`, for nesting the cover in a larger SVG (its ids are unique to the cover, so several can share a document).
|
|
124
130
|
|
|
@@ -129,6 +135,7 @@ Same as `generateCover`, but also tells you which style and palette were chosen.
|
|
|
129
135
|
| `subtitle` | `string` | `''` | optional subtitle or tagline |
|
|
130
136
|
| `series` | `string` | `''` | a series line above the title |
|
|
131
137
|
| `credit` | `string` | `''` | a credit under the subtitle ("Translated by…") |
|
|
138
|
+
| `badge` | `string` | `''` | a seal in a corner clear of the text |
|
|
132
139
|
| `texture` | `boolean` | `false` | adds a fine film grain |
|
|
133
140
|
| `style` | `string` | `'random'` | a style id, see `styles` |
|
|
134
141
|
| `palette` | `string \| object` | `'random'` | a palette id, or `{ bg, ink, accents: [...] }` |
|
|
@@ -153,6 +160,8 @@ Titles and subtitles longer than 300 characters (authors: 200) are cut. With `'r
|
|
|
153
160
|
- `pickStyle(options)`: the style id `createCover` would use for these options (resolving `'random'` from the seed), without drawing the cover
|
|
154
161
|
- `registerStyle({ id, name, description, group, prefer, render(ctx) })`: add your own style (see below). Replacing a built-in style takes `registerStyle(style, { replace: true })`; `unregisterStyle(id)` removes a style or brings the built-in back
|
|
155
162
|
- `registerPalette({ id, name, bg, ink, accents })`: add a palette to use by id, as `palette: 'brand'` (`{ replace: true }` for a built-in id); `unregisterPalette(id)` removes it. Registered styles and palettes can also be picked by `'random'`, so registering one changes what a seed gives
|
|
163
|
+
- `parseBookList(text)`: a list of books (CSV with commas, semicolons or tabs and a header row naming a `title` column, JSON lines, or one "Title | Author | Subtitle" per line) as cover options, one per book
|
|
164
|
+
- `noteText(x0, y0, x1, y1)` and `recordText(draw)`: note where a custom style writes its own text (so a badge stays clear of it), and collect what `draw` notes
|
|
156
165
|
- `version`: the library's version (artwork for a seed can change between minor versions)
|
|
157
166
|
- `sizeLimits` (`{ min: 50, max: 10000, ratio: 3 }`) and `formats` (`paperback`, `ebook` and `square` as `[width, height]` at 600 px wide): the sizes covers can have, and the app's shapes
|
|
158
167
|
- `titleBlock`, `authorFit`: the typography the built-in styles use (a title with its lead line and subtitle, and the author line)
|
|
@@ -164,6 +173,18 @@ Titles and subtitles longer than 300 characters (authors: 200) are cut. With `'r
|
|
|
164
173
|
|
|
165
174
|
Types are included. `CoverOptions`, `Cover`, `StyleId`, `PaletteId`, `StyleGroup`, `StyleContext`, `CoverStyle`, `TitleBlock` and the rest are exported from the main entry point, and style and palette ids autocomplete.
|
|
166
175
|
|
|
176
|
+
### Mockups
|
|
177
|
+
|
|
178
|
+
```js
|
|
179
|
+
import { createCover, createMockup } from 'book-cover-generator';
|
|
180
|
+
|
|
181
|
+
const cover = createCover({ title: 'Neon Tide', author: 'Kai Moreno' });
|
|
182
|
+
const { svg, width, height } = createMockup(cover, { kind: 'book' }); // or 'flat', 'ereader'
|
|
183
|
+
const card = createMockup(cover, { kind: 'card', text: 'Out now' }); // 1200 × 630; also 'square', 'story'
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Product shots (`flat`, `book`, `ereader`) follow the cover's size on a light backdrop (`background` to change it); social cards have fixed sizes and take the cover's colours. `mockupKinds` lists them.
|
|
187
|
+
|
|
167
188
|
### Export helpers (browser)
|
|
168
189
|
|
|
169
190
|
```js
|
|
@@ -206,6 +227,7 @@ generateCover({
|
|
|
206
227
|
- `p`: the palette, with `bg`, `ink`, `accents`, plus `paper` (lighter) and `night` (darker)
|
|
207
228
|
- `id(name)`: makes ids that are unique per cover, for gradients and filters
|
|
208
229
|
- `titleFont`, `titleCase`: the cover's `font` and `titleCase`, or null; `titleBlock` follows them, so a style that sets its title with it gets them for free
|
|
230
|
+
- Text drawn with `drawText`, `drawStack` or `titleBlock` is noted, so a badge stays clear of it; a style that writes its own text elements can call `noteText(x0, y0, x1, y1)`
|
|
209
231
|
- `choose(name, values)`: picks one of `values` as `rng.pick` would, unless the cover pins the variant `name`. Declare what can be pinned in the style's `variants`: `{ mood: { label: 'Mood', values: { calm: 'Calm', loud: 'Loud' } } }`
|
|
210
232
|
|
|
211
233
|
```js
|
package/bin/book-cover.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { writeFileSync } from 'node:fs';
|
|
2
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
3
4
|
import { parseArgs } from 'node:util';
|
|
4
|
-
import { createCover, listStyles, palettes, formats } from '../src/index.js';
|
|
5
|
+
import { createCover, listStyles, palettes, formats, parseBookList } from '../src/index.js';
|
|
5
6
|
|
|
6
7
|
const HELP = `Usage: book-cover "<title>" ["<author>"] [options]
|
|
7
8
|
|
|
@@ -19,6 +20,7 @@ Options:
|
|
|
19
20
|
--subtitle <text> a subtitle or tagline
|
|
20
21
|
--series <text> a series line above the title ("Book Two of …")
|
|
21
22
|
--credit <text> a credit under the subtitle ("Translated by …")
|
|
23
|
+
--badge <text> a seal in a corner clear of the text ("Bestseller")
|
|
22
24
|
--texture add a fine film-grain texture
|
|
23
25
|
--seed <value> seed for the artwork (default: title + author)
|
|
24
26
|
--format <shape> paperback (2:3, the default), ebook (5:8) or square
|
|
@@ -26,6 +28,13 @@ Options:
|
|
|
26
28
|
--height <px> height (default: from the format)
|
|
27
29
|
--size <WxH> width and height at once, e.g. 1600x2560
|
|
28
30
|
--lang <tag> language of the text, e.g. ja or zh-Hant (default: inferred)
|
|
31
|
+
--batch <file> a cover for each book in a list (- reads stdin): CSV or
|
|
32
|
+
TSV with a header row (title, author, subtitle, series,
|
|
33
|
+
credit, badge, style, palette, seed, font, case, lang,
|
|
34
|
+
width, height, texture), JSON lines, or one book per
|
|
35
|
+
line as "Title | Author | Subtitle"; the other options
|
|
36
|
+
apply to every book the list doesn't say otherwise for
|
|
37
|
+
--out-dir <dir> where --batch writes its covers (default: here)
|
|
29
38
|
-o, --out <file> write SVG to a file instead of stdout
|
|
30
39
|
--count <n> write n variations (seeds 1…n, or <seed>-1…), named
|
|
31
40
|
cover-1.svg… from --out (or put {n} or {seed} in it)
|
|
@@ -38,7 +47,8 @@ Examples:
|
|
|
38
47
|
book-cover "The Lighthouse Keeper" "Mara Ellison" -s artdeco -o cover.svg
|
|
39
48
|
book-cover "Neon Tide" "Kai Moreno" --style retro --palette synth --size 1600x2560 > cover.svg
|
|
40
49
|
book-cover "Bright Machines" --bg 0b132b --ink f8f9fa --accent ff6b6b,4ecdc4 --format ebook -o cover.svg
|
|
41
|
-
book-cover "Salt and Static" "J. R. Okafor" --count 12 -o ideas/cover.svg
|
|
50
|
+
book-cover "Salt and Static" "J. R. Okafor" --count 12 -o ideas/cover.svg
|
|
51
|
+
book-cover --batch books.csv --style minimal --seed series --out-dir covers`;
|
|
42
52
|
|
|
43
53
|
let args;
|
|
44
54
|
try {
|
|
@@ -56,6 +66,7 @@ try {
|
|
|
56
66
|
subtitle: { type: 'string' },
|
|
57
67
|
series: { type: 'string' },
|
|
58
68
|
credit: { type: 'string' },
|
|
69
|
+
badge: { type: 'string' },
|
|
59
70
|
texture: { type: 'boolean' },
|
|
60
71
|
seed: { type: 'string' },
|
|
61
72
|
format: { type: 'string' },
|
|
@@ -64,6 +75,8 @@ try {
|
|
|
64
75
|
size: { type: 'string' },
|
|
65
76
|
lang: { type: 'string' },
|
|
66
77
|
out: { type: 'string', short: 'o' },
|
|
78
|
+
batch: { type: 'string' },
|
|
79
|
+
'out-dir': { type: 'string' },
|
|
67
80
|
count: { type: 'string' },
|
|
68
81
|
json: { type: 'boolean' },
|
|
69
82
|
list: { type: 'boolean' },
|
|
@@ -77,33 +90,43 @@ try {
|
|
|
77
90
|
|
|
78
91
|
const { values, positionals } = args;
|
|
79
92
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
if (values.
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
93
|
+
/**
|
|
94
|
+
* Help, the list, and checks of the arguments: whether to go on and make covers. (Exit codes are
|
|
95
|
+
* set, not exited with, so output piped elsewhere is written in full first.)
|
|
96
|
+
*/
|
|
97
|
+
function preflight() {
|
|
98
|
+
if (values.help) {
|
|
99
|
+
console.log(HELP);
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
if (values.list) {
|
|
103
|
+
console.log('Styles:');
|
|
104
|
+
for (const s of listStyles()) {
|
|
105
|
+
console.log(` ${s.id.padEnd(14)} ${s.description}`);
|
|
106
|
+
for (const v of s.variants) {
|
|
107
|
+
console.log(` ${''.padEnd(14)} --variant ${v.name}=${v.values.map((x) => x.value).join('|')}`);
|
|
108
|
+
}
|
|
91
109
|
}
|
|
110
|
+
console.log('\nPalettes:');
|
|
111
|
+
for (const p of palettes) console.log(` ${p.id.padEnd(14)} ${p.name}${p.dark ? ' (dark)' : ''}`);
|
|
112
|
+
return false;
|
|
92
113
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
}
|
|
102
|
-
if (positionals.length
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
114
|
+
/** @param {string} message */
|
|
115
|
+
const fail = (message) => {
|
|
116
|
+
console.error(message);
|
|
117
|
+
process.exitCode = 1;
|
|
118
|
+
return false;
|
|
119
|
+
};
|
|
120
|
+
if (values.batch !== undefined && positionals.length) {
|
|
121
|
+
return fail('--batch takes the titles from its list: give no title on the command line');
|
|
122
|
+
}
|
|
123
|
+
if (!positionals.length && values.batch === undefined) return fail(HELP);
|
|
124
|
+
if (positionals.length > 2) {
|
|
125
|
+
return fail(
|
|
126
|
+
`Expected a title and an author, got ${positionals.length} arguments. Quote values with spaces: book-cover "The Lighthouse Keeper" "Mara Ellison"`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
return true;
|
|
107
130
|
}
|
|
108
131
|
|
|
109
132
|
/**
|
|
@@ -208,41 +231,120 @@ function fileFor(out, n, total, seed) {
|
|
|
208
231
|
*/
|
|
209
232
|
const safeName = (seed) => seed.replace(/[^\p{L}\p{N}._-]+/gu, '-').replace(/^[.-]+/, '') || 'cover';
|
|
210
233
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
234
|
+
/** A title as part of a file name: lower case, accents off Latin letters, dashes between words. @param {string} title */
|
|
235
|
+
function slug(title) {
|
|
236
|
+
const words = title
|
|
237
|
+
.normalize('NFKD')
|
|
238
|
+
.replace(/(\p{Script=Latin})[\u0300-\u036f]+/gu, '$1')
|
|
239
|
+
.normalize('NFC')
|
|
240
|
+
.toLowerCase()
|
|
241
|
+
.replace(/[^\p{L}\p{M}\p{N}]+/gu, '-')
|
|
242
|
+
.replace(/^-+/, '');
|
|
243
|
+
return Array.from(words).slice(0, 60).join('').replace(/-+$/, '') || 'cover';
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Write a cover for each book in the --batch list into --out-dir; the command line's options
|
|
248
|
+
* apply to every book the list doesn't say otherwise for.
|
|
249
|
+
* @param {import('../src/index.js').CoverOptions} defaults
|
|
250
|
+
*/
|
|
251
|
+
function batch(defaults) {
|
|
252
|
+
if (values.count !== undefined || values.out !== undefined) {
|
|
253
|
+
throw new Error('--batch writes a file per book into --out-dir: use it without --out or --count');
|
|
254
|
+
}
|
|
255
|
+
const source = /** @type {string} */ (values.batch);
|
|
256
|
+
const books = parseBookList(readFileSync(source === '-' ? 0 : source, 'utf8'));
|
|
257
|
+
if (!books.length) {
|
|
258
|
+
throw new Error(
|
|
259
|
+
`No books in ${source === '-' ? 'the input' : source}: a title per line, or a CSV with a "title" column`,
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
const dir = values['out-dir'] ?? '.';
|
|
263
|
+
mkdirSync(dir, { recursive: true });
|
|
264
|
+
const digits = String(books.length).length;
|
|
265
|
+
let failed = 0;
|
|
266
|
+
books.forEach((book, i) => {
|
|
267
|
+
const n = i + 1;
|
|
268
|
+
try {
|
|
269
|
+
// A size in the list replaces the command line's.
|
|
270
|
+
const sized = book.width !== undefined || book.height !== undefined;
|
|
271
|
+
const cover = createCover({
|
|
272
|
+
...defaults,
|
|
273
|
+
...(sized ? { width: undefined, height: undefined } : {}),
|
|
274
|
+
...book,
|
|
275
|
+
seed: book.seed ?? values.seed,
|
|
276
|
+
});
|
|
277
|
+
const file = join(dir, `${String(n).padStart(digits, '0')}-${slug(cover.title)}.svg`);
|
|
236
278
|
writeFileSync(file, cover.svg);
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
279
|
+
const { style, palette: paletteId, seed, width, height } = cover;
|
|
280
|
+
if (values.json)
|
|
281
|
+
process.stdout.write(JSON.stringify({ file, style, palette: paletteId, seed, width, height }) + '\n');
|
|
282
|
+
} catch (err) {
|
|
283
|
+
failed += 1;
|
|
284
|
+
console.error(`Book ${n} ("${book.title}"): ${err instanceof Error ? err.message : String(err)}`);
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
console.error(`Wrote ${books.length - failed} of ${books.length} covers to ${dir}`);
|
|
288
|
+
if (failed) process.exitCode = 1;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function run() {
|
|
292
|
+
try {
|
|
293
|
+
if (values.batch !== undefined) {
|
|
294
|
+
batch({
|
|
295
|
+
subtitle: values.subtitle,
|
|
296
|
+
series: values.series,
|
|
297
|
+
credit: values.credit,
|
|
298
|
+
badge: values.badge,
|
|
299
|
+
texture: values.texture,
|
|
300
|
+
style: values.style,
|
|
301
|
+
palette: palette(),
|
|
302
|
+
lang: values.lang,
|
|
303
|
+
variants: variants(),
|
|
304
|
+
font: values.font,
|
|
305
|
+
titleCase: /** @type {'upper' | 'normal' | undefined} */ (values.case),
|
|
306
|
+
...dimensions(),
|
|
307
|
+
});
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
const total = count();
|
|
311
|
+
const options = {
|
|
312
|
+
title: positionals[0],
|
|
313
|
+
author: positionals[1] ?? '',
|
|
314
|
+
subtitle: values.subtitle,
|
|
315
|
+
series: values.series,
|
|
316
|
+
credit: values.credit,
|
|
317
|
+
badge: values.badge,
|
|
318
|
+
texture: values.texture,
|
|
319
|
+
style: values.style,
|
|
320
|
+
palette: palette(),
|
|
321
|
+
lang: values.lang,
|
|
322
|
+
variants: variants(),
|
|
323
|
+
font: values.font,
|
|
324
|
+
titleCase: /** @type {'upper' | 'normal' | undefined} */ (values.case),
|
|
325
|
+
...dimensions(),
|
|
326
|
+
};
|
|
327
|
+
for (let n = 1; n <= total; n++) {
|
|
328
|
+
// One cover: the seed given (or the title's). Several: seeds 1…n, or <seed>-1…<seed>-n.
|
|
329
|
+
const seed = total === 1 ? values.seed : values.seed ? `${values.seed}-${n}` : String(n);
|
|
330
|
+
const cover = createCover({ ...options, seed });
|
|
331
|
+
const { style, palette: paletteId, width, height, lang } = cover;
|
|
332
|
+
const choices = { style, palette: paletteId, seed: cover.seed, width, height, lang, variants: cover.variants };
|
|
333
|
+
if (values.out) {
|
|
334
|
+
const file = fileFor(values.out, n, total, cover.seed);
|
|
335
|
+
writeFileSync(file, cover.svg);
|
|
336
|
+
if (values.json) process.stdout.write(JSON.stringify({ file, ...choices }) + '\n');
|
|
337
|
+
else console.error(`Wrote ${file} (style: ${style}, palette: ${paletteId}, ${width}x${height})`);
|
|
338
|
+
} else if (values.json) {
|
|
339
|
+
process.stdout.write(JSON.stringify({ ...choices, svg: cover.svg }) + '\n');
|
|
340
|
+
} else {
|
|
341
|
+
process.stdout.write(cover.svg + '\n');
|
|
342
|
+
}
|
|
243
343
|
}
|
|
344
|
+
} catch (err) {
|
|
345
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
346
|
+
process.exitCode = 1;
|
|
244
347
|
}
|
|
245
|
-
} catch (err) {
|
|
246
|
-
console.error(err instanceof Error ? err.message : String(err));
|
|
247
|
-
process.exit(1);
|
|
248
348
|
}
|
|
349
|
+
|
|
350
|
+
if (preflight()) run();
|
package/package.json
CHANGED
package/src/badge.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// A badge on the cover: a seal ("Winner of the …", "10th Anniversary Edition") set in a corner
|
|
2
|
+
// clear of the text.
|
|
3
|
+
|
|
4
|
+
import { n, mix, escapeXml } from './core.js';
|
|
5
|
+
import { FONTS, fitText, drawText } from './text.js';
|
|
6
|
+
import { strongAccents, textColor } from './styles/util.js';
|
|
7
|
+
|
|
8
|
+
/** @typedef {import('./text.js').TextBox} TextBox */
|
|
9
|
+
|
|
10
|
+
/** How much of the square around a circle a box covers. @param {number} cx @param {number} cy @param {number} r @param {TextBox} b */
|
|
11
|
+
function overlap(cx, cy, r, b) {
|
|
12
|
+
const w = Math.min(cx + r, b.x1) - Math.max(cx - r, b.x0);
|
|
13
|
+
const h = Math.min(cy + r, b.y1) - Math.max(cy - r, b.y0);
|
|
14
|
+
return w > 0 && h > 0 ? w * h : 0;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The seal: a star-edged disc with a ring and the text inside, tilted a little, in the first
|
|
19
|
+
* corner (then side) clear of the text, smaller if it must be; else where it covers least.
|
|
20
|
+
* @param {import('./index.js').StyleContext} c
|
|
21
|
+
* @param {string} text
|
|
22
|
+
* @param {TextBox[]} boxes the text already on the cover
|
|
23
|
+
*/
|
|
24
|
+
export function drawBadge(c, text, boxes) {
|
|
25
|
+
const { W, H, S, u, m, p } = c;
|
|
26
|
+
/** Where a seal of radius r could go: corners first, then the middle of each side. @param {number} r */
|
|
27
|
+
const spots = (r) => {
|
|
28
|
+
const inset = m * 0.5 + r;
|
|
29
|
+
return [
|
|
30
|
+
[W - inset, inset],
|
|
31
|
+
[inset, inset],
|
|
32
|
+
[W - inset, H - inset],
|
|
33
|
+
[inset, H - inset],
|
|
34
|
+
[W - inset, H / 2],
|
|
35
|
+
[inset, H / 2],
|
|
36
|
+
];
|
|
37
|
+
};
|
|
38
|
+
const pad = S * 0.01;
|
|
39
|
+
/** @type {[number, number, number] | null} */
|
|
40
|
+
let place = null;
|
|
41
|
+
for (const scale of [1, 0.85, 0.7]) {
|
|
42
|
+
const r = S * 0.12 * scale;
|
|
43
|
+
const free = spots(r).find(([x, y]) => boxes.every((b) => overlap(x, y, r + pad, b) === 0));
|
|
44
|
+
if (free) {
|
|
45
|
+
place = [free[0], free[1], r];
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (!place) {
|
|
50
|
+
const r = S * 0.084;
|
|
51
|
+
const covered = (/** @type {number[]} */ [x, y]) => boxes.reduce((sum, b) => sum + overlap(x, y, r, b), 0);
|
|
52
|
+
const best = spots(r).reduce((a, b) => (covered(b) < covered(a) ? b : a));
|
|
53
|
+
place = [best[0], best[1], r];
|
|
54
|
+
}
|
|
55
|
+
const [cx, cy, r] = place;
|
|
56
|
+
|
|
57
|
+
const fill = strongAccents(p, p.bg, 1.5)[0];
|
|
58
|
+
const ink = textColor(p, fill, [p.paper, p.night]);
|
|
59
|
+
const points = [];
|
|
60
|
+
const spikes = 28;
|
|
61
|
+
for (let i = 0; i < spikes * 2; i++) {
|
|
62
|
+
const a = (i / (spikes * 2)) * Math.PI * 2;
|
|
63
|
+
const rr = i % 2 ? r * 0.9 : r;
|
|
64
|
+
points.push(`${n(cx + Math.cos(a) * rr)},${n(cy + Math.sin(a) * rr)}`);
|
|
65
|
+
}
|
|
66
|
+
const fit = fitText(text, {
|
|
67
|
+
font: FONTS.sans,
|
|
68
|
+
weight: 700,
|
|
69
|
+
upper: true,
|
|
70
|
+
letterSpacing: 0.04,
|
|
71
|
+
maxWidth: r * 1.25,
|
|
72
|
+
maxHeight: r * 1.05,
|
|
73
|
+
maxSize: r * 0.3,
|
|
74
|
+
maxLines: 4,
|
|
75
|
+
lineHeight: 1.05,
|
|
76
|
+
});
|
|
77
|
+
const edge = mix(fill, p.night, 0.35);
|
|
78
|
+
return (
|
|
79
|
+
`<g transform="rotate(-8 ${n(cx)} ${n(cy)})" aria-label="${escapeXml(text)}">` +
|
|
80
|
+
`<polygon points="${points.join(' ')}" fill="${fill}" stroke="${edge}" stroke-width="${n(1.2 * u)}"/>` +
|
|
81
|
+
`<circle cx="${n(cx)}" cy="${n(cy)}" r="${n(r * 0.76)}" fill="none" stroke="${ink}" stroke-width="${n(1.4 * u)}" opacity="0.7"/>` +
|
|
82
|
+
drawText(fit, { x: cx, y: cy - fit.height / 2, anchor: 'middle', fill: ink }) +
|
|
83
|
+
'</g>'
|
|
84
|
+
);
|
|
85
|
+
}
|
package/src/books.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// A list of books, as people paste or export it, read into cover options: CSV or TSV with a
|
|
2
|
+
// header row, JSON lines, or one book per line ("Title | Author | Subtitle").
|
|
3
|
+
|
|
4
|
+
/** The columns a list may have, by the names it may give them, and how each is read. */
|
|
5
|
+
const COLUMNS = /** @type {const} */ ({
|
|
6
|
+
title: 'title',
|
|
7
|
+
author: 'author',
|
|
8
|
+
subtitle: 'subtitle',
|
|
9
|
+
series: 'series',
|
|
10
|
+
credit: 'credit',
|
|
11
|
+
badge: 'badge',
|
|
12
|
+
style: 'style',
|
|
13
|
+
palette: 'palette',
|
|
14
|
+
seed: 'seed',
|
|
15
|
+
font: 'font',
|
|
16
|
+
case: 'titleCase',
|
|
17
|
+
titlecase: 'titleCase',
|
|
18
|
+
lang: 'lang',
|
|
19
|
+
width: 'width',
|
|
20
|
+
height: 'height',
|
|
21
|
+
texture: 'texture',
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/** Separators of a plain line: a tab, or a bar or dash with spaces around it. */
|
|
25
|
+
const LINE_SEPARATOR = /\t| \| | — | – /;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Split CSV (or TSV) text into rows of fields: quoted fields may hold the delimiter, quotes ("")
|
|
29
|
+
* and line breaks.
|
|
30
|
+
* @param {string} text @param {string} delimiter
|
|
31
|
+
* @returns {string[][]}
|
|
32
|
+
*/
|
|
33
|
+
function csvRows(text, delimiter) {
|
|
34
|
+
/** @type {string[][]} */
|
|
35
|
+
const rows = [];
|
|
36
|
+
/** @type {string[]} */
|
|
37
|
+
let row = [];
|
|
38
|
+
let field = '';
|
|
39
|
+
let quoted = false;
|
|
40
|
+
for (let i = 0; i < text.length; i++) {
|
|
41
|
+
const ch = text[i];
|
|
42
|
+
if (quoted) {
|
|
43
|
+
if (ch === '"' && text[i + 1] === '"') {
|
|
44
|
+
field += '"';
|
|
45
|
+
i++;
|
|
46
|
+
} else if (ch === '"') quoted = false;
|
|
47
|
+
else field += ch;
|
|
48
|
+
} else if (ch === '"' && field === '') quoted = true;
|
|
49
|
+
else if (ch === delimiter) {
|
|
50
|
+
row.push(field);
|
|
51
|
+
field = '';
|
|
52
|
+
} else if (ch === '\n' || ch === '\r') {
|
|
53
|
+
if (ch === '\r' && text[i + 1] === '\n') i++;
|
|
54
|
+
row.push(field);
|
|
55
|
+
rows.push(row);
|
|
56
|
+
row = [];
|
|
57
|
+
field = '';
|
|
58
|
+
} else field += ch;
|
|
59
|
+
}
|
|
60
|
+
if (field !== '' || row.length) {
|
|
61
|
+
row.push(field);
|
|
62
|
+
rows.push(row);
|
|
63
|
+
}
|
|
64
|
+
return rows.filter((r) => r.some((f) => f.trim()));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Cover options from a row's named values: known columns only, trimmed, empty ones left out,
|
|
69
|
+
* numbers and switches read as such.
|
|
70
|
+
* @param {Record<string, unknown>} named
|
|
71
|
+
* @returns {import('./index.js').CoverOptions}
|
|
72
|
+
*/
|
|
73
|
+
function optionsFrom(named) {
|
|
74
|
+
/** @type {Record<string, unknown>} */
|
|
75
|
+
const out = {};
|
|
76
|
+
for (const [key, raw] of Object.entries(named)) {
|
|
77
|
+
const option = COLUMNS[/** @type {keyof typeof COLUMNS} */ (key.trim().toLowerCase())];
|
|
78
|
+
if (!option || raw == null) continue;
|
|
79
|
+
if (option === 'texture') {
|
|
80
|
+
const v = String(raw).trim().toLowerCase();
|
|
81
|
+
if (v) out.texture = raw === true || ['1', 'true', 'yes', 'y', 'on'].includes(v);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
// (Numbers stay numbers for sizes and the seed; any other column is text, a title of 1984 too.)
|
|
85
|
+
const value = typeof raw === 'number' && option !== 'title' ? raw : String(raw).trim();
|
|
86
|
+
if (value === '') continue;
|
|
87
|
+
if (option === 'width' || option === 'height') out[option] = Number(value);
|
|
88
|
+
else out[option] = option === 'seed' ? value : String(value);
|
|
89
|
+
}
|
|
90
|
+
return /** @type {import('./index.js').CoverOptions} */ (out);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Read a list of books into cover options, one per book (rows without a title are left out):
|
|
95
|
+
*
|
|
96
|
+
* - CSV or TSV whose first row names its columns (`title`, `author`, `subtitle`, `series`,
|
|
97
|
+
* `credit`, `badge`, `style`, `palette`, `seed`, `font`, `case`, `lang`, `width`, `height`,
|
|
98
|
+
* `texture`; others are ignored);
|
|
99
|
+
* - JSON lines, one object per book, with the same names;
|
|
100
|
+
* - else one book per line: "Title | Author | Subtitle" (a tab, or " — " or " – ", also separates).
|
|
101
|
+
*
|
|
102
|
+
* @param {string} text
|
|
103
|
+
* @returns {import('./index.js').CoverOptions[]}
|
|
104
|
+
*/
|
|
105
|
+
export function parseBookList(text) {
|
|
106
|
+
const src = String(text ?? '').replace(/^\uFEFF/, '');
|
|
107
|
+
// (Each with its line number in the text, for errors.)
|
|
108
|
+
const lines = src
|
|
109
|
+
.split(/\r\n|[\r\n]/)
|
|
110
|
+
.map((line, i) => ({ line, number: i + 1 }))
|
|
111
|
+
.filter(({ line }) => line.trim());
|
|
112
|
+
if (!lines.length) return [];
|
|
113
|
+
/** @type {import('./index.js').CoverOptions[]} */
|
|
114
|
+
let books;
|
|
115
|
+
if (lines.every(({ line }) => line.trim().startsWith('{'))) {
|
|
116
|
+
books = lines.map(({ line, number }) => {
|
|
117
|
+
let value;
|
|
118
|
+
try {
|
|
119
|
+
value = JSON.parse(line);
|
|
120
|
+
} catch {
|
|
121
|
+
throw new SyntaxError(`Line ${number} is not valid JSON`);
|
|
122
|
+
}
|
|
123
|
+
return value && typeof value === 'object' ? optionsFrom(value) : {};
|
|
124
|
+
});
|
|
125
|
+
} else {
|
|
126
|
+
// A header row names a "title" column, separated by tabs, commas or semicolons (as
|
|
127
|
+
// spreadsheets in many countries save CSV).
|
|
128
|
+
const first = lines[0].line;
|
|
129
|
+
const delimiter = ['\t', ',', ';'].find((d) =>
|
|
130
|
+
(csvRows(first, d)[0] ?? []).some((name) => name.trim().toLowerCase() === 'title'),
|
|
131
|
+
);
|
|
132
|
+
if (delimiter) {
|
|
133
|
+
const [names, ...rows] = csvRows(src, delimiter);
|
|
134
|
+
books = rows.map((row) => optionsFrom(Object.fromEntries(names.map((name, i) => [name, row[i] ?? '']))));
|
|
135
|
+
} else {
|
|
136
|
+
books = lines.map(({ line }) => {
|
|
137
|
+
const [title, author, subtitle] = line.split(LINE_SEPARATOR);
|
|
138
|
+
return optionsFrom({ title, author, subtitle });
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return books.filter((book) => typeof book.title === 'string' && book.title.trim());
|
|
143
|
+
}
|
package/src/index.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type { FontSpec, FONTS } from './text.js';
|
|
|
4
4
|
|
|
5
5
|
export type { Rng } from './core.js';
|
|
6
6
|
export type { Palette, PaletteId } from './palettes.js';
|
|
7
|
-
export type { FontSpec, FitOptions, TextFit, DrawOptions, TextSpec, TextMeasurer } from './text.js';
|
|
7
|
+
export type { FontSpec, FitOptions, TextFit, DrawOptions, TextSpec, TextMeasurer, TextBox } from './text.js';
|
|
8
8
|
export type { StackFit, TitleBlock, TitleBlockOptions } from './type.js';
|
|
9
9
|
export { palettes } from './palettes.js';
|
|
10
10
|
export {
|
|
@@ -17,11 +17,36 @@ export {
|
|
|
17
17
|
textBottom,
|
|
18
18
|
textGroupAttrs,
|
|
19
19
|
markExtent,
|
|
20
|
+
noteText,
|
|
21
|
+
recordText,
|
|
20
22
|
} from './text.js';
|
|
21
23
|
export { titleBlock, authorFit, fitStack, drawStack, splitLead } from './type.js';
|
|
22
24
|
export { strongAccents, textColor, tintText, fitTitleWith, textPanel, TEXT_CONTRAST } from './styles/util.js';
|
|
23
25
|
export * as svgUtils from './core.js';
|
|
24
26
|
|
|
27
|
+
export type MockupKind = 'flat' | 'book' | 'ereader' | 'card' | 'square' | 'story';
|
|
28
|
+
/** The kinds of mockup `createMockup` makes. */
|
|
29
|
+
export declare const mockupKinds: readonly MockupKind[];
|
|
30
|
+
export interface MockupOptions {
|
|
31
|
+
/** Default `'book'`. `card` is 1200 × 630, `square` 1080 × 1080, `story` 1080 × 1920; the others follow the cover's size. */
|
|
32
|
+
kind?: MockupKind;
|
|
33
|
+
/** A hex colour behind it. Product shots default to a light studio grey, social cards to the cover's colours. */
|
|
34
|
+
background?: string;
|
|
35
|
+
/** A line for social cards, such as `'Out now'` (up to 80 characters). */
|
|
36
|
+
text?: string;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Read a list of books into cover options, one per book with a title: CSV or TSV whose first row
|
|
40
|
+
* names its columns (title, author, subtitle, series, credit, badge, style, palette, seed, font,
|
|
41
|
+
* case, lang, width, height, texture), JSON lines, or one book per line ("Title | Author | Subtitle").
|
|
42
|
+
*/
|
|
43
|
+
export declare function parseBookList(text: string): CoverOptions[];
|
|
44
|
+
/** The cover as a product shot (flat, book, e-reader) or on a social card, as SVG. */
|
|
45
|
+
export declare function createMockup(
|
|
46
|
+
cover: Cover,
|
|
47
|
+
options?: MockupOptions,
|
|
48
|
+
): { svg: string; width: number; height: number };
|
|
49
|
+
|
|
25
50
|
/** This version of the library, such as `'1.4.0'`. Artwork for a seed can change between minor versions. */
|
|
26
51
|
export declare const version: string;
|
|
27
52
|
/** The sizes a cover can have: each side `min` to `max` px, and neither more than `ratio` times the other. */
|
|
@@ -92,6 +117,8 @@ export interface CoverOptions {
|
|
|
92
117
|
series?: string;
|
|
93
118
|
/** A credit set on a line of its own under the subtitle, such as `'Translated by Edith Grossman'`. Up to 120 characters. */
|
|
94
119
|
credit?: string;
|
|
120
|
+
/** A seal set in a corner clear of the text, such as `'Winner of the Hugo Award'`. Up to 60 characters. */
|
|
121
|
+
badge?: string;
|
|
95
122
|
/** Add a fine film-grain texture over the cover. Default `false`. */
|
|
96
123
|
texture?: boolean;
|
|
97
124
|
/** A style id, a registered custom style, or `'random'` (default; picked from the seed). */
|
|
@@ -132,6 +159,8 @@ export interface Cover {
|
|
|
132
159
|
style: string;
|
|
133
160
|
/** The palette that was used (`'custom'` for a custom palette). */
|
|
134
161
|
palette: string;
|
|
162
|
+
/** The palette's colours, as the cover used them. */
|
|
163
|
+
colors: { bg: string; ink: string; accents: string[]; dark: boolean };
|
|
135
164
|
seed: string;
|
|
136
165
|
width: number;
|
|
137
166
|
height: number;
|
|
@@ -139,6 +168,9 @@ export interface Cover {
|
|
|
139
168
|
lang: string;
|
|
140
169
|
/** The value of each of the style's variants this cover has, pinned or picked by the seed. */
|
|
141
170
|
variants: Record<string, string>;
|
|
171
|
+
/** The title as set (line breaks as "\n"), and the author, after cleaning. */
|
|
172
|
+
title: string;
|
|
173
|
+
author: string;
|
|
142
174
|
}
|
|
143
175
|
|
|
144
176
|
/** A choice a style lets you pin, such as Silhouette's scene. */
|
package/src/index.js
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
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, FONTS, LINE_BREAK, clipGraphemes } from './text.js';
|
|
8
|
+
import { BREAKING_SPACE, FONTS, LINE_BREAK, clipGraphemes, recordText } from './text.js';
|
|
9
|
+
import { drawBadge } from './badge.js';
|
|
9
10
|
import { styleList } from './styles/index.js';
|
|
10
11
|
|
|
11
12
|
export { palettes } from './palettes.js';
|
|
@@ -19,13 +20,17 @@ export {
|
|
|
19
20
|
textBottom,
|
|
20
21
|
textGroupAttrs,
|
|
21
22
|
markExtent,
|
|
23
|
+
noteText,
|
|
24
|
+
recordText,
|
|
22
25
|
} from './text.js';
|
|
23
26
|
export { titleBlock, authorFit, fitStack, drawStack, splitLead } from './type.js';
|
|
24
27
|
export { strongAccents, textColor, tintText, fitTitleWith, textPanel, TEXT_CONTRAST } from './styles/util.js';
|
|
25
28
|
export * as svgUtils from './core.js';
|
|
29
|
+
export { createMockup, mockupKinds } from './mockups.js';
|
|
30
|
+
export { parseBookList } from './books.js';
|
|
26
31
|
|
|
27
32
|
/** This version of the library (artwork for a seed can change between minor versions). */
|
|
28
|
-
export const version = '1.
|
|
33
|
+
export const version = '1.6.0';
|
|
29
34
|
|
|
30
35
|
/** The sizes a cover can have: each side `min` to `max` px, and neither more than `ratio` times the other. */
|
|
31
36
|
export const sizeLimits = Object.freeze({ min: 50, max: 10000, ratio: 3 });
|
|
@@ -371,9 +376,10 @@ export function createCover(options = {}) {
|
|
|
371
376
|
const subtitle = cleanText(options.subtitle, 300);
|
|
372
377
|
const series = cleanText(options.series, 120);
|
|
373
378
|
const credit = cleanText(options.credit, 120);
|
|
379
|
+
const badge = cleanText(options.badge, 60);
|
|
374
380
|
const W = dimension(options.width, 600, 'width');
|
|
375
381
|
const H = dimension(options.height, W * 1.5, 'height');
|
|
376
|
-
const lang = resolveLang(options.lang, `${title} ${subtitle} ${author} ${series} ${credit}`);
|
|
382
|
+
const lang = resolveLang(options.lang, `${title} ${subtitle} ${author} ${series} ${credit} ${badge}`);
|
|
377
383
|
const { ratio } = sizeLimits;
|
|
378
384
|
if (W / H > ratio || H / W > ratio) {
|
|
379
385
|
throw new RangeError(`The aspect ratio must be between 1:${ratio} and ${ratio}:1, got ${W}×${H}`);
|
|
@@ -402,6 +408,7 @@ export function createCover(options = {}) {
|
|
|
402
408
|
...(Object.keys(pins).length ? [pins] : []),
|
|
403
409
|
...(titleType.font || titleType.titleCase ? [options.font ?? '', titleType.titleCase ?? ''] : []),
|
|
404
410
|
...(series || credit ? [series, credit] : []),
|
|
411
|
+
...(badge ? [badge] : []),
|
|
405
412
|
]),
|
|
406
413
|
).toString(36);
|
|
407
414
|
/** The variants the style chose (or was given). @type {Record<string, string>} */
|
|
@@ -435,7 +442,10 @@ export function createCover(options = {}) {
|
|
|
435
442
|
},
|
|
436
443
|
};
|
|
437
444
|
|
|
438
|
-
|
|
445
|
+
// The text the style draws is recorded, so a badge can go where there is none.
|
|
446
|
+
const { result: drawn, boxes } = recordText(() => style.render(ctx));
|
|
447
|
+
let body = drawn;
|
|
448
|
+
if (badge) body += drawBadge(ctx, badge, boxes);
|
|
439
449
|
if (options.texture) {
|
|
440
450
|
// Film grain over the whole cover. Styles paint their own backgrounds, so lay
|
|
441
451
|
// dark and light specks (from different noise) to show on any colour.
|
|
@@ -445,14 +455,28 @@ export function createCover(options = {}) {
|
|
|
445
455
|
}
|
|
446
456
|
const label = escapeXml(
|
|
447
457
|
`${title.replace(/\n/g, ' ')}${subtitle ? `: ${subtitle}` : ''}${author ? ` by ${author}` : ''}` +
|
|
448
|
-
`${series ? ` (${series})` : ''}${credit ? `. ${credit}` : ''}`,
|
|
458
|
+
`${series ? ` (${series})` : ''}${credit ? `. ${credit}` : ''}${badge ? `. ${badge}` : ''}`,
|
|
449
459
|
);
|
|
450
460
|
// (Both spellings: `lang` is SVG 2's, `xml:lang` what older renderers read.)
|
|
451
461
|
const langAttrs = lang ? ` lang="${lang}" xml:lang="${lang}"` : '';
|
|
452
462
|
const svg =
|
|
453
463
|
`<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}"${langAttrs} role="img" aria-label="${label}">` +
|
|
454
464
|
`<title>${label}</title>${body}</svg>`;
|
|
455
|
-
|
|
465
|
+
const colors = { bg: palette.bg, ink: palette.ink, accents: [...palette.accents], dark: palette.dark };
|
|
466
|
+
return {
|
|
467
|
+
svg,
|
|
468
|
+
body,
|
|
469
|
+
style: styleId,
|
|
470
|
+
palette: palette.id,
|
|
471
|
+
colors,
|
|
472
|
+
seed,
|
|
473
|
+
width: W,
|
|
474
|
+
height: H,
|
|
475
|
+
lang,
|
|
476
|
+
variants: chosen,
|
|
477
|
+
title,
|
|
478
|
+
author,
|
|
479
|
+
};
|
|
456
480
|
}
|
|
457
481
|
|
|
458
482
|
/**
|
package/src/mockups.js
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// Mockups: a cover shown as a product (a flat shot, a book, an e-reader) or on a social card,
|
|
2
|
+
// for listings and cover reveals. Each is an SVG with the cover nested in it.
|
|
3
|
+
|
|
4
|
+
import { n, mix, escapeXml, hashString, contrast, readable } from './core.js';
|
|
5
|
+
import { FONTS, fitText, drawText, textBottom } from './text.js';
|
|
6
|
+
|
|
7
|
+
/** The kinds of mockup, with the size of each (or how it follows the cover's). */
|
|
8
|
+
export const mockupKinds = Object.freeze(['flat', 'book', 'ereader', 'card', 'square', 'story']);
|
|
9
|
+
|
|
10
|
+
/** The neutral backdrop of the product shots. */
|
|
11
|
+
const STUDIO = '#ecebe7';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The cover nested at a place and size.
|
|
15
|
+
* @param {import('./index.js').Cover} cover @param {number} x @param {number} y @param {number} w @param {number} h
|
|
16
|
+
*/
|
|
17
|
+
const nest = (cover, x, y, w, h) =>
|
|
18
|
+
`<svg x="${n(x)}" y="${n(y)}" width="${n(w)}" height="${n(h)}" viewBox="0 0 ${cover.width} ${cover.height}" preserveAspectRatio="none" overflow="hidden">${cover.body}</svg>`;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A soft shadow under a shape: blurred black, offset down.
|
|
22
|
+
* @param {string} id @param {number} blur @param {number} dy @param {number} opacity
|
|
23
|
+
*/
|
|
24
|
+
const shadow = (id, blur, dy, opacity) =>
|
|
25
|
+
`<filter id="${id}" x="-30%" y="-30%" width="160%" height="160%">` +
|
|
26
|
+
`<feGaussianBlur in="SourceAlpha" stdDeviation="${n(blur)}"/><feOffset dy="${n(dy)}"/>` +
|
|
27
|
+
`<feComponentTransfer><feFuncA type="linear" slope="${opacity}"/></feComponentTransfer>` +
|
|
28
|
+
`<feMerge><feMergeNode/><feMergeNode in="SourceGraphic"/></feMerge></filter>`;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The cover as a product or a social card.
|
|
32
|
+
*
|
|
33
|
+
* - `flat`: the cover on a backdrop with a soft shadow.
|
|
34
|
+
* - `book`: a book turned a little, its spine showing.
|
|
35
|
+
* - `ereader`: the cover on an e-reader's screen.
|
|
36
|
+
* - `card` (1200 × 630, a link preview), `square` (1080 × 1080) and `story` (1080 × 1920): the
|
|
37
|
+
* cover on a background in its colours, with the title and author, and `text` if given ("Out now").
|
|
38
|
+
*
|
|
39
|
+
* @param {import('./index.js').Cover} cover from createCover
|
|
40
|
+
* @param {{ kind?: string, background?: string, text?: string }} [options]
|
|
41
|
+
* @returns {{ svg: string, width: number, height: number }}
|
|
42
|
+
*/
|
|
43
|
+
export function createMockup(cover, { kind = 'book', background, text = '' } = {}) {
|
|
44
|
+
if (!cover || typeof cover.body !== 'string' || !(cover.width > 0) || !(cover.height > 0)) {
|
|
45
|
+
throw new TypeError('createMockup: pass a cover from createCover');
|
|
46
|
+
}
|
|
47
|
+
if (!mockupKinds.includes(kind)) {
|
|
48
|
+
throw new RangeError(`Unknown mockup "${kind}". Available: ${mockupKinds.join(', ')}`);
|
|
49
|
+
}
|
|
50
|
+
if (background != null && !/^#(?:[0-9a-f]{3}){1,2}$/i.test(background)) {
|
|
51
|
+
throw new RangeError(`createMockup: background must be a hex colour, got ${JSON.stringify(background)}`);
|
|
52
|
+
}
|
|
53
|
+
// Ids unique to this cover and kind of mockup, so several can share a page.
|
|
54
|
+
const tag = `mk${hashString(cover.svg).toString(36)}-${kind}`;
|
|
55
|
+
const id = (/** @type {string} */ name) => `${tag}-${name}`;
|
|
56
|
+
const { width: CW, height: CH, colors } = cover;
|
|
57
|
+
const words = String(text).replace(/\s+/g, ' ').trim().slice(0, 80);
|
|
58
|
+
const label = escapeXml(`${cover.title.replace(/\n/g, ' ')}${cover.author ? ` by ${cover.author}` : ''}`);
|
|
59
|
+
|
|
60
|
+
// (The cover's language too, so Chinese characters keep that language's forms.)
|
|
61
|
+
const lang = cover.lang ? ` lang="${escapeXml(cover.lang)}" xml:lang="${escapeXml(cover.lang)}"` : '';
|
|
62
|
+
/** @param {number} W @param {number} H @param {string} inner */
|
|
63
|
+
const wrap = (W, H, inner) => ({
|
|
64
|
+
svg:
|
|
65
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="${Math.round(W)}" height="${Math.round(H)}" viewBox="0 0 ${n(W)} ${n(H)}"${lang} role="img" aria-label="${label}">` +
|
|
66
|
+
`<title>${label}</title>${inner}</svg>`,
|
|
67
|
+
width: Math.round(W),
|
|
68
|
+
height: Math.round(H),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
if (kind === 'flat') {
|
|
72
|
+
const pad = Math.min(CW, CH) * 0.14;
|
|
73
|
+
const W = CW + pad * 2;
|
|
74
|
+
const H = CH + pad * 2;
|
|
75
|
+
const u = Math.min(CW, CH) / 600;
|
|
76
|
+
return wrap(
|
|
77
|
+
W,
|
|
78
|
+
H,
|
|
79
|
+
`<defs>${shadow(id('shadow'), 14 * u, 10 * u, 0.32)}</defs>` +
|
|
80
|
+
`<rect width="${n(W)}" height="${n(H)}" fill="${background ?? STUDIO}"/>` +
|
|
81
|
+
`<g filter="url(#${id('shadow')})">${nest(cover, pad, pad, CW, CH)}</g>`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (kind === 'book') {
|
|
86
|
+
// The front turned a little away (narrower, its far edge higher), with the spine at its left.
|
|
87
|
+
const turn = 0.9;
|
|
88
|
+
const rise = 0.07;
|
|
89
|
+
const spine = CW * 0.075;
|
|
90
|
+
const pad = Math.min(CW, CH) * 0.16;
|
|
91
|
+
const W = spine + CW * turn + pad * 2;
|
|
92
|
+
const H = CH + CW * rise + pad * 2;
|
|
93
|
+
const x0 = pad + spine;
|
|
94
|
+
const y0 = pad + CW * rise;
|
|
95
|
+
const u = Math.min(CW, CH) / 600;
|
|
96
|
+
const spineFill = mix(colors.bg, '#000000', 0.32);
|
|
97
|
+
const sag = spine * 0.32;
|
|
98
|
+
return wrap(
|
|
99
|
+
W,
|
|
100
|
+
H,
|
|
101
|
+
`<defs>${shadow(id('soft'), 22 * u, 0, 1)}` +
|
|
102
|
+
`<linearGradient id="${id('gloss')}" x1="0" y1="0" x2="1" y2="0">` +
|
|
103
|
+
`<stop offset="0" stop-color="#000" stop-opacity="0.28"/><stop offset="0.04" stop-color="#fff" stop-opacity="0.16"/>` +
|
|
104
|
+
`<stop offset="0.09" stop-color="#000" stop-opacity="0.05"/><stop offset="0.6" stop-color="#fff" stop-opacity="0"/>` +
|
|
105
|
+
`<stop offset="1" stop-color="#fff" stop-opacity="0.08"/></linearGradient>` +
|
|
106
|
+
`<linearGradient id="${id('spine')}" x1="0" y1="0" x2="1" y2="0">` +
|
|
107
|
+
`<stop offset="0" stop-color="#000" stop-opacity="0.3"/><stop offset="0.7" stop-color="#fff" stop-opacity="0.08"/>` +
|
|
108
|
+
`<stop offset="1" stop-color="#000" stop-opacity="0.25"/></linearGradient></defs>` +
|
|
109
|
+
`<rect width="${n(W)}" height="${n(H)}" fill="${background ?? STUDIO}"/>` +
|
|
110
|
+
// The shadow on the floor.
|
|
111
|
+
`<ellipse cx="${n(x0 + (CW * turn) / 2)}" cy="${n(y0 + CH - CW * rise * 0.3)}" rx="${n((CW * turn) / 2 + spine)}" ry="${n(CW * 0.035)}" fill="#000" opacity="0.28" filter="url(#${id('soft')})"/>` +
|
|
112
|
+
`<polygon points="${n(x0 - spine)},${n(y0 + sag)} ${n(x0)},${n(y0)} ${n(x0)},${n(y0 + CH)} ${n(x0 - spine)},${n(y0 + CH - sag)}" fill="${spineFill}"/>` +
|
|
113
|
+
`<polygon points="${n(x0 - spine)},${n(y0 + sag)} ${n(x0)},${n(y0)} ${n(x0)},${n(y0 + CH)} ${n(x0 - spine)},${n(y0 + CH - sag)}" fill="url(#${id('spine')})"/>` +
|
|
114
|
+
`<g transform="matrix(${turn} ${-rise} 0 1 ${n(x0)} ${n(y0)})">${nest(cover, 0, 0, CW, CH)}` +
|
|
115
|
+
`<rect width="${CW}" height="${CH}" fill="url(#${id('gloss')})"/></g>`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (kind === 'ereader') {
|
|
120
|
+
const side = CW * 0.07;
|
|
121
|
+
const top = CH * 0.07;
|
|
122
|
+
const bottom = CH * 0.12;
|
|
123
|
+
const pad = Math.min(CW, CH) * 0.14;
|
|
124
|
+
const DW = CW + side * 2;
|
|
125
|
+
const DH = CH + top + bottom;
|
|
126
|
+
const W = DW + pad * 2;
|
|
127
|
+
const H = DH + pad * 2;
|
|
128
|
+
const u = Math.min(CW, CH) / 600;
|
|
129
|
+
return wrap(
|
|
130
|
+
W,
|
|
131
|
+
H,
|
|
132
|
+
`<defs>${shadow(id('shadow'), 16 * u, 12 * u, 0.35)}</defs>` +
|
|
133
|
+
`<rect width="${n(W)}" height="${n(H)}" fill="${background ?? STUDIO}"/>` +
|
|
134
|
+
`<g filter="url(#${id('shadow')})"><rect x="${n(pad)}" y="${n(pad)}" width="${n(DW)}" height="${n(DH)}" rx="${n(CW * 0.06)}" fill="#1c1c1e"/></g>` +
|
|
135
|
+
`<rect x="${n(pad + 2 * u)}" y="${n(pad + 2 * u)}" width="${n(DW - 4 * u)}" height="${n(DH - 4 * u)}" rx="${n(CW * 0.055)}" fill="none" stroke="#3a3a3c" stroke-width="${n(1.5 * u)}"/>` +
|
|
136
|
+
nest(cover, pad + side, pad + top, CW, CH) +
|
|
137
|
+
`<rect x="${n(pad + DW / 2 - CW * 0.08)}" y="${n(pad + DH - bottom / 2 - 2 * u)}" width="${n(CW * 0.16)}" height="${n(4 * u)}" rx="${n(2 * u)}" fill="#3a3a3c"/>`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Social cards: fixed sizes, in the cover's colours.
|
|
142
|
+
const [W, H] = { card: [1200, 630], square: [1080, 1080], story: [1080, 1920] }[
|
|
143
|
+
/** @type {'card' | 'square' | 'story'} */ (kind)
|
|
144
|
+
];
|
|
145
|
+
const bg = background ?? mix(colors.bg, colors.dark ? '#000000' : colors.ink, colors.dark ? 0.35 : 0.08);
|
|
146
|
+
// Whichever of near-black and near-white reads better on it.
|
|
147
|
+
const ink = readable(bg, ['#141414', '#f7f5f0']);
|
|
148
|
+
const u = Math.min(W, H) / 600;
|
|
149
|
+
// Where the cover goes (as large as its area allows), where the words go, and their sizes.
|
|
150
|
+
const layout = {
|
|
151
|
+
card: { area: { x: 70, y: 63, w: 460, h: 504 }, below: 0, margin: 0, sizes: [64, 24, 28] },
|
|
152
|
+
square: { area: { x: 120, y: 70, w: W - 240, h: 594 }, below: 60, margin: 70, sizes: [56, 26, 30] },
|
|
153
|
+
story: { area: { x: 120, y: 240, w: W - 240, h: 980 }, below: 110, margin: 220, sizes: [72, 30, 36] },
|
|
154
|
+
}[/** @type {'card' | 'square' | 'story'} */ (kind)];
|
|
155
|
+
const { area } = layout;
|
|
156
|
+
const [titleSize, authorSize, noteSize] = layout.sizes;
|
|
157
|
+
const scale = Math.min(area.w / CW, area.h / CH);
|
|
158
|
+
const cw = CW * scale;
|
|
159
|
+
const ch = CH * scale;
|
|
160
|
+
const cx = kind === 'card' ? area.x + (area.w - cw) / 2 : (W - cw) / 2;
|
|
161
|
+
const cy = area.y + (area.h - ch) / 2;
|
|
162
|
+
let s =
|
|
163
|
+
`<defs>${shadow(id('shadow'), 18 * u, 14 * u, 0.4)}</defs>` +
|
|
164
|
+
`<rect width="${W}" height="${H}" fill="${bg}"/>` +
|
|
165
|
+
`<g filter="url(#${id('shadow')})">${nest(cover, cx, cy, cw, ch)}</g>`;
|
|
166
|
+
// The words: beside the cover on a card, under it otherwise.
|
|
167
|
+
const box =
|
|
168
|
+
kind === 'card'
|
|
169
|
+
? { x: 590, y: 120, w: 540, h: 390, anchor: 'start' }
|
|
170
|
+
: {
|
|
171
|
+
x: W / 2,
|
|
172
|
+
y: cy + ch + layout.below,
|
|
173
|
+
w: W - 200,
|
|
174
|
+
h: H - (cy + ch + layout.below) - layout.margin,
|
|
175
|
+
anchor: 'middle',
|
|
176
|
+
};
|
|
177
|
+
const title = fitText(cover.title, {
|
|
178
|
+
font: FONTS.serif,
|
|
179
|
+
weight: 700,
|
|
180
|
+
maxWidth: box.w,
|
|
181
|
+
maxHeight: box.h * 0.55,
|
|
182
|
+
maxSize: titleSize,
|
|
183
|
+
maxLines: 4,
|
|
184
|
+
lineHeight: 1.08,
|
|
185
|
+
});
|
|
186
|
+
const author = cover.author
|
|
187
|
+
? fitText(cover.author, {
|
|
188
|
+
font: FONTS.sans,
|
|
189
|
+
weight: 600,
|
|
190
|
+
upper: true,
|
|
191
|
+
letterSpacing: 0.12,
|
|
192
|
+
maxWidth: box.w,
|
|
193
|
+
maxSize: authorSize,
|
|
194
|
+
maxLines: 2,
|
|
195
|
+
})
|
|
196
|
+
: null;
|
|
197
|
+
const note = words
|
|
198
|
+
? fitText(words, {
|
|
199
|
+
font: FONTS.sans,
|
|
200
|
+
weight: 700,
|
|
201
|
+
maxWidth: box.w,
|
|
202
|
+
maxSize: noteSize,
|
|
203
|
+
maxLines: 2,
|
|
204
|
+
})
|
|
205
|
+
: null;
|
|
206
|
+
const gap = kind === 'card' ? 22 : 18 * u;
|
|
207
|
+
const blockH =
|
|
208
|
+
textBottom(title, 0) + (author ? gap + textBottom(author, 0) : 0) + (note ? gap * 1.6 + textBottom(note, 0) : 0);
|
|
209
|
+
let y = kind === 'card' ? box.y + (box.h - blockH) / 2 : box.y + Math.max(0, (box.h - blockH) / 2);
|
|
210
|
+
const anchor = /** @type {'start' | 'middle'} */ (box.anchor);
|
|
211
|
+
s += drawText(title, { x: box.x, y, anchor, fill: ink });
|
|
212
|
+
y = textBottom(title, y);
|
|
213
|
+
if (author) {
|
|
214
|
+
y += gap;
|
|
215
|
+
s += drawText(author, { x: box.x, y, anchor, fill: ink, opacity: 0.8 });
|
|
216
|
+
y = textBottom(author, y);
|
|
217
|
+
}
|
|
218
|
+
if (note) {
|
|
219
|
+
y += gap * 1.6;
|
|
220
|
+
s += drawText(note, {
|
|
221
|
+
x: box.x,
|
|
222
|
+
y,
|
|
223
|
+
anchor,
|
|
224
|
+
// An accent if one reads on the background (3:1, for large bold text), else the ink.
|
|
225
|
+
fill: colors.accents.find((a) => contrast(a, bg) >= 3) ?? ink,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
return wrap(W, H, s);
|
|
229
|
+
}
|
package/src/styles/pattern.js
CHANGED
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
shadowFilter,
|
|
19
19
|
linearGradient,
|
|
20
20
|
} from '../core.js';
|
|
21
|
-
import { FONTS, fitText, drawText, textBottom, typedLines } from '../text.js';
|
|
21
|
+
import { FONTS, fitText, drawText, textBottom, typedLines, noteText } from '../text.js';
|
|
22
22
|
import { titleBlock, authorFit } from '../type.js';
|
|
23
23
|
import { strongAccents, textPanel } from './util.js';
|
|
24
24
|
|
|
@@ -849,6 +849,7 @@ function drawPixelText(fit, x, y, anchor, color) {
|
|
|
849
849
|
const w = (ln.length * 6 - 1) * q;
|
|
850
850
|
const x0 = anchor === 'middle' ? x - w / 2 : x;
|
|
851
851
|
const y0 = y + li * 10 * q;
|
|
852
|
+
noteText(x0, y0, x0 + w, y0 + 7 * q);
|
|
852
853
|
[...ln].forEach((ch, ci) => {
|
|
853
854
|
const glyph = PIXEL_FONT[ch];
|
|
854
855
|
if (!glyph) return;
|
package/src/styles/vintage.js
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
textGroupAttrs,
|
|
23
23
|
baselineOf,
|
|
24
24
|
typedLines,
|
|
25
|
+
noteText,
|
|
25
26
|
} from '../text.js';
|
|
26
27
|
import { titleBlock, authorFit } from '../type.js';
|
|
27
28
|
import { strongAccents, flower, initialChars, textPanel, textColor } from './util.js';
|
|
@@ -283,6 +284,8 @@ const TYPED_BY_LETTER =
|
|
|
283
284
|
function typedLine(str, x, y, size, weight, rng, color, anchor = 'start') {
|
|
284
285
|
const spec = { font: FONTS.mono, weight };
|
|
285
286
|
const width = measureText(str, size, spec);
|
|
287
|
+
const left = anchor === 'middle' ? x - width / 2 : x;
|
|
288
|
+
noteText(left, y - size * 0.8, left + width, y + size * 0.3);
|
|
286
289
|
if (!TYPED_BY_LETTER.test(str)) {
|
|
287
290
|
// Keep joining, conjuncts and direction intact: one run of text, no per-letter jitter.
|
|
288
291
|
return `<text x="${n(x)}" y="${n(y)}" ${textGroupAttrs({ ...spec, size, fill: color, anchor })}>${escapeXml(str)}</text>`;
|
package/src/text.d.ts
CHANGED
|
@@ -56,6 +56,17 @@ export declare const BREAKING_SPACE: RegExp;
|
|
|
56
56
|
export declare const LINE_BREAK: RegExp;
|
|
57
57
|
/** The number of lines text was typed on: 1 without line breaks. */
|
|
58
58
|
export declare function typedLines(text: string): number;
|
|
59
|
+
/** Where some text was drawn. */
|
|
60
|
+
export interface TextBox {
|
|
61
|
+
x0: number;
|
|
62
|
+
y0: number;
|
|
63
|
+
x1: number;
|
|
64
|
+
y1: number;
|
|
65
|
+
}
|
|
66
|
+
/** Note that text was drawn in this box (drawText and drawStack do it), so a badge can be placed clear of it. */
|
|
67
|
+
export declare function noteText(x0: number, y0: number, x1: number, y1: number): void;
|
|
68
|
+
/** Run `draw` and collect the boxes of the text it draws. */
|
|
69
|
+
export declare function recordText<T>(draw: () => T): { result: T; boxes: TextBox[] };
|
|
59
70
|
/** The start of a string, cut between user-perceived characters (`units`: count UTF-16 code units). */
|
|
60
71
|
export declare function clipGraphemes(str: string, max: number, options?: { units?: boolean }): string;
|
|
61
72
|
/** Split a string into user-perceived characters. */
|
package/src/text.js
CHANGED
|
@@ -864,6 +864,8 @@ export const baselineOf = (fit, y, i) => y + fit.size * ((fit.above ?? 0) + fit.
|
|
|
864
864
|
*/
|
|
865
865
|
export function drawText(fit, { x, y, anchor = 'start', fill = '#000', opacity, attrs = '' }) {
|
|
866
866
|
if (!fit.lines.length) return '';
|
|
867
|
+
const left = anchor === 'middle' ? x - fit.width / 2 : anchor === 'end' ? x - fit.width : x;
|
|
868
|
+
noteText(left, y, left + fit.width, textBottom(fit, y));
|
|
867
869
|
const { size, font, weight, italic, letterSpacing } = fit;
|
|
868
870
|
const ls = letterSpacing * size;
|
|
869
871
|
const dx = spacingShift(anchor, ls);
|
|
@@ -874,5 +876,34 @@ export function drawText(fit, { x, y, anchor = 'start', fill = '#000', opacity,
|
|
|
874
876
|
return `<g ${a}>${rows}</g>`;
|
|
875
877
|
}
|
|
876
878
|
|
|
879
|
+
/** @typedef {{ x0: number, y0: number, x1: number, y1: number }} TextBox where some text was drawn */
|
|
880
|
+
|
|
881
|
+
/** The text drawn while a cover renders (see recordText), or null when nothing records it. @type {TextBox[] | null} */
|
|
882
|
+
let textBoxes = null;
|
|
883
|
+
|
|
884
|
+
/**
|
|
885
|
+
* Note that text was drawn in this box, so a badge can be placed clear of it. drawText and
|
|
886
|
+
* drawStack do this themselves; styles that write their own text elements call it.
|
|
887
|
+
* @param {number} x0 @param {number} y0 @param {number} x1 @param {number} y1
|
|
888
|
+
*/
|
|
889
|
+
export function noteText(x0, y0, x1, y1) {
|
|
890
|
+
if (textBoxes && [x0, y0, x1, y1].every(Number.isFinite)) textBoxes.push({ x0, y0, x1, y1 });
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* Run `draw` and collect the boxes of the text it draws.
|
|
895
|
+
* @template T @param {() => T} draw @returns {{ result: T, boxes: TextBox[] }}
|
|
896
|
+
*/
|
|
897
|
+
export function recordText(draw) {
|
|
898
|
+
const outer = textBoxes;
|
|
899
|
+
textBoxes = [];
|
|
900
|
+
try {
|
|
901
|
+
const result = draw();
|
|
902
|
+
return { result, boxes: textBoxes };
|
|
903
|
+
} finally {
|
|
904
|
+
textBoxes = outer;
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
|
|
877
908
|
/** Visual bottom of a fit placed at `y`, including descenders (and marks below). */
|
|
878
909
|
export const textBottom = (fit, y) => (fit.lines.length ? y + fit.height + fit.size * (fit.below ?? DESCENT) : y);
|
package/src/type.js
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
measureText,
|
|
15
15
|
textBottom,
|
|
16
16
|
textGroupAttrs,
|
|
17
|
+
noteText,
|
|
17
18
|
spacingShift,
|
|
18
19
|
defaultMinSize,
|
|
19
20
|
checkBox,
|
|
@@ -142,6 +143,8 @@ export function fitStack(text, o) {
|
|
|
142
143
|
* @param {import('./text.js').DrawOptions & { lineFills?: (string | undefined)[] }} o
|
|
143
144
|
*/
|
|
144
145
|
export function drawStack(fit, { x, y, anchor = 'start', fill = '#000', opacity, attrs = '', lineFills = [] }) {
|
|
146
|
+
const left = anchor === 'middle' ? x - fit.width / 2 : anchor === 'end' ? x - fit.width : x;
|
|
147
|
+
noteText(left, y, left + fit.width, y + fit.height + fit.descent);
|
|
145
148
|
const a = textGroupAttrs({ font: fit.font, weight: fit.weight, italic: fit.italic, fill, anchor, opacity, attrs });
|
|
146
149
|
let baseline = y;
|
|
147
150
|
const rows = fit.lines.map(({ text, size, above = 0 }, i) => {
|