book-cover-generator 1.7.0 → 1.9.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 +45 -5
- package/bin/book-cover.js +52 -24
- package/package.json +13 -4
- package/src/browser.js +1 -0
- package/src/core.d.ts +1 -1
- package/src/core.js +1 -0
- package/src/element.d.ts +22 -0
- package/src/element.js +201 -0
- package/src/index.d.ts +5 -2
- package/src/index.js +9 -5
- package/src/link.js +194 -1
- package/src/mcp.js +400 -0
- package/src/mockups.js +16 -5
- package/src/palettes.js +1 -0
- package/src/styles/util.js +4 -1
- package/src/text.js +4 -1
- package/src/type.d.ts +4 -1
- package/src/type.js +1 -0
package/README.md
CHANGED
|
@@ -5,11 +5,12 @@
|
|
|
5
5
|
[](https://covers.otsirh.dev)
|
|
6
6
|

|
|
7
7
|

|
|
8
|
+
[](https://scorecard.dev/viewer/?uri=github.com/otsirh/book-cover-generator)
|
|
8
9
|
[](LICENSE)
|
|
9
10
|
|
|
10
|
-
Generate book covers from a **title** and an **author**. 39 styles, 28 palettes or your own colours, a seeded randomizer, any size, and export to **SVG, PNG, JPEG or WebP**. The library has no dependencies and ships TypeScript types. It's written as plain ES modules, so it runs in the browser and
|
|
11
|
+
Generate book covers from a **title** and an **author**. 39 styles, 28 palettes or your own colours, a seeded randomizer, any size, and export to **SVG, PNG, JPEG or WebP**. The library has no dependencies and ships TypeScript types. It's written as plain ES modules, so it runs in the browser, Node, Deno and Bun.
|
|
11
12
|
|
|
12
|
-
**[▶ Open the generator](https://covers.otsirh.dev)**
|
|
13
|
+
**[▶ Open the generator](https://covers.otsirh.dev)** · [See every style](https://covers.otsirh.dev/gallery/)
|
|
13
14
|
|
|
14
15
|

|
|
15
16
|
|
|
@@ -23,7 +24,7 @@ Generate book covers from a **title** and an **author**. 39 styles, 28 palettes
|
|
|
23
24
|
- Geometric: Bauhaus, Split, Blocks, Concentric, Stripes.
|
|
24
25
|
- Pattern: Paper Cut, Halftone, Topographic, Flow Field, Low Poly, Mosaic, Memphis, Pixel.
|
|
25
26
|
- **28 palettes** (15 light, 13 dark), or pass your own. Every style adapts to light and dark palettes.
|
|
26
|
-
- **Typography that responds to the title.** Short titles are set large, a leading "The" or
|
|
27
|
+
- **Typography that responds to the title.** Short titles are set large, a leading "The" (or another article of three letters or more) becomes a small line above the title, and some styles stack the words edge to edge. Long titles wrap with balanced lines and shrink to fit. Author names stay readable at thumbnail size. Accents, emoji, Chinese, Japanese and Thai are handled.
|
|
27
28
|
- **Subtitle or tagline**, and an optional **paper texture**.
|
|
28
29
|
- **Randomizer.** Every random choice comes from a seed, so the same seed always gives the same cover. If you don't pass a seed, it is derived from the title and author.
|
|
29
30
|
- **Any size.** 2:3 paperback, 5:8 eBook, 1:1 audiobook, or any width × height from 50 to 10,000 px with an aspect ratio between 1:3 and 3:1, such as Kindle's 1600 × 2560 or a print trim size at 300 dpi.
|
|
@@ -82,6 +83,42 @@ The query takes the options' own names (`title`, `author`, `subtitle`, `series`,
|
|
|
82
83
|
|
|
83
84
|
A title breaks its line at `%0A`. Anyone may embed the answers (CORS and `Cross-Origin-Resource-Policy` allow it), and the headers `X-Cover-Style`, `X-Cover-Palette` and `X-Cover-Version` say what was drawn. The same URL always draws the same cover in a version of the library, so answers are cached for a day. Options the library rejects get a `400` with the reason as plain text.
|
|
84
85
|
|
|
86
|
+
## On any page: `<book-cover>`
|
|
87
|
+
|
|
88
|
+
One script, then a cover wherever you write the element:
|
|
89
|
+
|
|
90
|
+
```html
|
|
91
|
+
<script type="module" src="https://covers.otsirh.dev/src/element.js"></script>
|
|
92
|
+
|
|
93
|
+
<book-cover cover-title="Dune" author="Frank Herbert" cover-style="retro" seed="1"></book-cover>
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Its attributes are the app's link parameters, except the title and the style, which are `cover-title` and `cover-style` (`title` and `style` are HTML's own): `cover-title`, `author`, `subtitle`, `series`, `credit`, `badge`, `cover-style`, `genre`, `palette`, `seed`, `format`, `font`, `case`, `variant`, `texture` (present: on) and `lang` (the language of the text, such as `zh-Hant`). Styles, palettes and fonts may be named as the app's menus name them. With `loading="lazy"`, as on an image, a cover draws only once it comes near the viewport (a page of many covers loads faster); until then it holds the cover's shape and is named for screen readers, and attributes changed meanwhile apply when it draws. The cover is 240 px wide unless you style the element's `width`; it draws again when an attribute changes, and fires `cover` (with the cover) or `cover-error` (with what's wrong). It is drawn in the page, measuring the text with the fonts installed there, and shown as an image, so a page with Trusted Types can show it (its CSP must allow `data:` images). An image can't use the page's web fonts: if the page loads one that a cover's font stack names (such as EB Garamond or Oswald), the text is measured in it but drawn in the system's font, and may fit less well. From npm: `import 'book-cover-generator/element'`.
|
|
97
|
+
|
|
98
|
+
## From an AI assistant (MCP)
|
|
99
|
+
|
|
100
|
+
Book Cover Generator is a [Model Context Protocol](https://modelcontextprotocol.io) server, so an assistant such as Claude can make covers for you: "a cover for my horror novel _The Hollow Night_, in red". It answers with a link to the image and one that opens the cover in the app to change and download.
|
|
101
|
+
|
|
102
|
+
Add it to Claude Code:
|
|
103
|
+
|
|
104
|
+
```sh
|
|
105
|
+
claude mcp add book-covers -- npx -y book-cover-generator --mcp
|
|
106
|
+
# or, with nothing installed, the hosted server:
|
|
107
|
+
claude mcp add --transport http book-covers https://covers.otsirh.dev/mcp
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Or to any client that takes a JSON configuration (Claude Desktop, Cursor and others):
|
|
111
|
+
|
|
112
|
+
```json
|
|
113
|
+
{
|
|
114
|
+
"mcpServers": {
|
|
115
|
+
"book-covers": { "command": "npx", "args": ["-y", "book-cover-generator", "--mcp"] }
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
The tools: `create_cover` (a title, and any of author, subtitle, style, genre, palette or your own colours, seed, format, font, pins), `create_mockup` (the same, as a 3D book, on an e-reader or on a social card), `list_styles` and `list_palettes`. Run locally, `create_cover` can also save the SVG to a file (`output`).
|
|
121
|
+
|
|
85
122
|
## Library usage
|
|
86
123
|
|
|
87
124
|
### Browser (no install)
|
|
@@ -101,7 +138,7 @@ A title breaks its line at `%0A`. Anyone may embed the answers (CORS and `Cross-
|
|
|
101
138
|
|
|
102
139
|
`@1` gives you the latest 1.x release. Pin an exact version (such as `@1.1.0`) if you need the artwork to never change.
|
|
103
140
|
|
|
104
|
-
### Node
|
|
141
|
+
### Node, Deno, Bun and bundlers
|
|
105
142
|
|
|
106
143
|
```sh
|
|
107
144
|
npm install book-cover-generator
|
|
@@ -122,6 +159,8 @@ const { svg, style, palette, seed } = createCover({
|
|
|
122
159
|
writeFileSync('cover.svg', svg);
|
|
123
160
|
```
|
|
124
161
|
|
|
162
|
+
In Deno, import `npm:book-cover-generator`; in Bun, `bun add book-cover-generator`. CI checks that Node, Deno and Bun draw the same covers byte for byte.
|
|
163
|
+
|
|
125
164
|
### CLI
|
|
126
165
|
|
|
127
166
|
```sh
|
|
@@ -301,9 +340,10 @@ npm run dev # http://127.0.0.1:4173
|
|
|
301
340
|
|
|
302
341
|
| script | what it does |
|
|
303
342
|
| ----------------------- | ----------------------------------------------------------------- |
|
|
304
|
-
| `npm run check` | lint, format
|
|
343
|
+
| `npm run check` | lint, format, type and package checks, unit tests and fuzzing |
|
|
305
344
|
| `npm test` | unit tests (`node --test`) |
|
|
306
345
|
| `npm run test:coverage` | unit tests with coverage thresholds |
|
|
346
|
+
| `npm run test:fuzz` | property-based fuzzing (fast-check); `FUZZ_RUNS=20` for more |
|
|
307
347
|
| `npm run test:e2e` | browser tests in Chromium, Firefox, WebKit and mobile Chrome |
|
|
308
348
|
| `npm run format` | format everything with Prettier |
|
|
309
349
|
| `npm run assets` | regenerate the app icons, the social preview and the README image |
|
package/bin/book-cover.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
-
import { dirname, join } from 'node:path';
|
|
3
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
4
|
+
import { createInterface } from 'node:readline';
|
|
4
5
|
import { parseArgs } from 'node:util';
|
|
5
|
-
import { createCover, listStyles, palettes,
|
|
6
|
+
import { createCover, listStyles, palettes, parseBookList, optionsForBook, genres } from '../src/index.js';
|
|
6
7
|
import { hex, slug } from '../src/shared.js';
|
|
8
|
+
import { px, formatSize } from '../src/link.js';
|
|
9
|
+
import { answerPayload } from '../src/mcp.js';
|
|
7
10
|
|
|
8
11
|
const HELP = `Usage: book-cover "<title>" ["<author>"] [options]
|
|
9
12
|
|
|
@@ -27,7 +30,9 @@ Options:
|
|
|
27
30
|
--badge <text> a seal in a corner clear of the text ("Bestseller")
|
|
28
31
|
--texture add a fine film-grain texture
|
|
29
32
|
--seed <value> seed for the artwork (default: title + author)
|
|
30
|
-
--format <shape> paperback (2:3, the default), ebook (5:8) or
|
|
33
|
+
--format <shape> paperback (2:3, the default), ebook (5:8), square, or a
|
|
34
|
+
shape such as 1600x2560; with --width or --height, that
|
|
35
|
+
shape at that width or height
|
|
31
36
|
--width <px> width (default 600)
|
|
32
37
|
--height <px> height (default: from the format)
|
|
33
38
|
--size <WxH> width and height at once, e.g. 1600x2560
|
|
@@ -45,6 +50,8 @@ Options:
|
|
|
45
50
|
--json print the choices (and the SVG, without --out) as JSON,
|
|
46
51
|
one line per cover
|
|
47
52
|
--list list styles and palettes
|
|
53
|
+
--mcp serve the Model Context Protocol on stdin and stdout, so an
|
|
54
|
+
AI assistant can make covers (see the README)
|
|
48
55
|
--help show this help
|
|
49
56
|
|
|
50
57
|
Examples:
|
|
@@ -86,6 +93,7 @@ try {
|
|
|
86
93
|
json: { type: 'boolean' },
|
|
87
94
|
list: { type: 'boolean' },
|
|
88
95
|
help: { type: 'boolean' },
|
|
96
|
+
mcp: { type: 'boolean' },
|
|
89
97
|
},
|
|
90
98
|
});
|
|
91
99
|
} catch (err) {
|
|
@@ -100,6 +108,10 @@ const { values, positionals } = args;
|
|
|
100
108
|
* set, not exited with, so output piped elsewhere is written in full first.)
|
|
101
109
|
*/
|
|
102
110
|
function preflight() {
|
|
111
|
+
if (values.mcp) {
|
|
112
|
+
serveMcp();
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
103
115
|
if (values.help) {
|
|
104
116
|
console.log(HELP);
|
|
105
117
|
return false;
|
|
@@ -137,17 +149,6 @@ function preflight() {
|
|
|
137
149
|
return true;
|
|
138
150
|
}
|
|
139
151
|
|
|
140
|
-
/**
|
|
141
|
-
* A --width/--height value as a number, reporting what was typed if it isn't one.
|
|
142
|
-
* @param {string | undefined} value @param {string} name
|
|
143
|
-
*/
|
|
144
|
-
function size(value, name) {
|
|
145
|
-
if (value === undefined) return undefined;
|
|
146
|
-
const number = Number(value);
|
|
147
|
-
if (!value.trim() || !Number.isFinite(number)) throw new RangeError(`--${name} must be a number, got "${value}"`);
|
|
148
|
-
return number;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
152
|
/**
|
|
152
153
|
* Width and height from --format, --width, --height and --size.
|
|
153
154
|
* @returns {{ width?: number, height?: number }}
|
|
@@ -161,17 +162,12 @@ function dimensions() {
|
|
|
161
162
|
if (!m) throw new RangeError(`--size must look like 1600x2560, got "${values.size}"`);
|
|
162
163
|
return { width: Number(m[1]), height: Number(m[2]) };
|
|
163
164
|
}
|
|
164
|
-
const width =
|
|
165
|
-
const height =
|
|
165
|
+
const width = values.width === undefined ? undefined : px('--width', values.width);
|
|
166
|
+
const height = values.height === undefined ? undefined : px('--height', values.height);
|
|
166
167
|
if (values.format === undefined) return { width, height };
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
}
|
|
170
|
-
if (height !== undefined) throw new Error('--format sets the height from the width: use --size for both');
|
|
171
|
-
// The shape of the app's format, at the width asked for.
|
|
172
|
-
const [w, h] = formats[/** @type {keyof typeof formats} */ (values.format)];
|
|
173
|
-
const W = width ?? 600;
|
|
174
|
-
return { width: W, height: Math.round((W * h) / w) };
|
|
168
|
+
// A preset or a WxH shape, at the width or height asked for (as the HTTP API reads it).
|
|
169
|
+
const [w, h] = formatSize(values.format, width, height);
|
|
170
|
+
return { width: w, height: h };
|
|
175
171
|
}
|
|
176
172
|
|
|
177
173
|
/** The palette: an id, or your own colours from --bg, --ink and --accent. */
|
|
@@ -338,4 +334,36 @@ function run() {
|
|
|
338
334
|
}
|
|
339
335
|
}
|
|
340
336
|
|
|
337
|
+
/**
|
|
338
|
+
* Serve MCP over stdio: a JSON-RPC message per line in, an answer per line out (nothing else on
|
|
339
|
+
* stdout). A cover can be saved to a file, at an absolute path (the host picks the folder it runs in).
|
|
340
|
+
*/
|
|
341
|
+
function serveMcp() {
|
|
342
|
+
const lines = createInterface({ input: process.stdin });
|
|
343
|
+
/** @param {string} path @param {string} data */
|
|
344
|
+
const writeFile = (path, data) => {
|
|
345
|
+
// (A host starts the server in a folder of its own choosing: a relative path would land there.)
|
|
346
|
+
if (!isAbsolute(path)) throw new Error(`output must be an absolute path, got "${path}"`);
|
|
347
|
+
const file = resolve(path);
|
|
348
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
349
|
+
writeFileSync(file, data);
|
|
350
|
+
return file;
|
|
351
|
+
};
|
|
352
|
+
/** @param {string | null} text */
|
|
353
|
+
const send = (text) => {
|
|
354
|
+
if (text) process.stdout.write(`${text}\n`);
|
|
355
|
+
};
|
|
356
|
+
lines.on('line', (line) => {
|
|
357
|
+
if (!line.trim()) return;
|
|
358
|
+
let message;
|
|
359
|
+
try {
|
|
360
|
+
message = JSON.parse(line);
|
|
361
|
+
} catch {
|
|
362
|
+
send(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }));
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
send(answerPayload(message, { writeFile }));
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
341
369
|
if (preflight()) run();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "book-cover-generator",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.0",
|
|
4
4
|
"description": "Generate SVG book covers from a title and an author. 39 styles, 28 palettes, seeded randomizer, zero dependencies.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"book",
|
|
@@ -23,7 +23,9 @@
|
|
|
23
23
|
"license": "MIT",
|
|
24
24
|
"author": "otsirh (https://github.com/otsirh)",
|
|
25
25
|
"type": "module",
|
|
26
|
-
"sideEffects":
|
|
26
|
+
"sideEffects": [
|
|
27
|
+
"./src/element.js"
|
|
28
|
+
],
|
|
27
29
|
"exports": {
|
|
28
30
|
".": {
|
|
29
31
|
"types": "./src/index.d.ts",
|
|
@@ -33,6 +35,10 @@
|
|
|
33
35
|
"types": "./src/browser.d.ts",
|
|
34
36
|
"default": "./src/browser.js"
|
|
35
37
|
},
|
|
38
|
+
"./element": {
|
|
39
|
+
"types": "./src/element.d.ts",
|
|
40
|
+
"default": "./src/element.js"
|
|
41
|
+
},
|
|
36
42
|
"./package.json": "./package.json"
|
|
37
43
|
},
|
|
38
44
|
"main": "./src/index.js",
|
|
@@ -51,14 +57,15 @@
|
|
|
51
57
|
"dev": "node scripts/serve.js 4173",
|
|
52
58
|
"build": "node scripts/build.js",
|
|
53
59
|
"lint": "eslint .",
|
|
54
|
-
"lint:package": "publint --strict && attw --pack . --profile esm-only",
|
|
60
|
+
"lint:package": "publint --strict && attw --pack . --profile esm-only && node scripts/check-bundle.js",
|
|
55
61
|
"format": "prettier --write .",
|
|
56
62
|
"format:check": "prettier --check .",
|
|
57
63
|
"typecheck": "tsc -p tsconfig.json",
|
|
58
64
|
"test": "node --test",
|
|
59
65
|
"test:coverage": "node --test --experimental-test-coverage --test-coverage-include=src/**/*.js --test-coverage-exclude=src/browser.js --test-coverage-lines=99 --test-coverage-branches=95 --test-coverage-functions=98",
|
|
66
|
+
"test:fuzz": "node --test fuzz/*.fuzz.js",
|
|
60
67
|
"test:e2e": "playwright test",
|
|
61
|
-
"check": "npm run lint && npm run format:check && npm run typecheck && npm run lint:package && npm test",
|
|
68
|
+
"check": "npm run lint && npm run format:check && npm run typecheck && npm run lint:package && npm test && npm run test:fuzz",
|
|
62
69
|
"assets": "node scripts/generate-assets.js",
|
|
63
70
|
"metrics": "node scripts/generate-metrics.js",
|
|
64
71
|
"deploy": "npm run check && npm run test:e2e && wrangler deploy"
|
|
@@ -69,7 +76,9 @@
|
|
|
69
76
|
"@eslint/js": "10.0.1",
|
|
70
77
|
"@playwright/test": "1.65.0",
|
|
71
78
|
"@types/node": "26.6.6",
|
|
79
|
+
"esbuild": "0.28.2",
|
|
72
80
|
"eslint": "10.14.0",
|
|
81
|
+
"fast-check": "4.10.2",
|
|
73
82
|
"globals": "17.14.0",
|
|
74
83
|
"prettier": "3.10.0",
|
|
75
84
|
"publint": "0.3.25",
|
package/src/browser.js
CHANGED
package/src/core.d.ts
CHANGED
|
@@ -20,7 +20,7 @@ export declare function clamp(v: number, lo: number, hi: number): number;
|
|
|
20
20
|
export declare function lerp(a: number, b: number, t: number): number;
|
|
21
21
|
export declare function smoothstep(e0: number, e1: number, x: number): number;
|
|
22
22
|
|
|
23
|
-
export declare function isHex(v: unknown):
|
|
23
|
+
export declare function isHex(v: unknown): boolean;
|
|
24
24
|
export declare function hexToRgb(hex: string): [number, number, number];
|
|
25
25
|
export declare function rgbToHex(rgb: readonly number[]): string;
|
|
26
26
|
/** Linear blend of two hex colours; `t = 0` is `a`, `t = 1` is `b`. */
|
package/src/core.js
CHANGED
package/src/element.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { CoverOptions, createCover } from './index.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `<book-cover>`: a cover on any page, from its attributes (cover-title, author, subtitle, series,
|
|
5
|
+
* credit, badge, cover-style, genre, palette, seed, format, font, case, texture, variant, lang). It
|
|
6
|
+
* fires `cover` (detail: the cover) when it draws, and `cover-error` (detail: the error) when the
|
|
7
|
+
* attributes can't make one. With `loading="lazy"` it draws once it comes near the viewport, as an
|
|
8
|
+
* image does; until then it holds the cover's shape and is named for screen readers.
|
|
9
|
+
*/
|
|
10
|
+
export declare class BookCover extends HTMLElement {
|
|
11
|
+
static observedAttributes: string[];
|
|
12
|
+
/** The cover drawn last, or null (before the first, or after an error). */
|
|
13
|
+
cover: ReturnType<typeof createCover> | null;
|
|
14
|
+
/** The cover's options, read from the attributes as the HTTP API reads a link. */
|
|
15
|
+
options(): CoverOptions;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
declare global {
|
|
19
|
+
interface HTMLElementTagNameMap {
|
|
20
|
+
'book-cover': BookCover;
|
|
21
|
+
}
|
|
22
|
+
}
|
package/src/element.js
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/* @ts-self-types="./element.d.ts" */
|
|
2
|
+
// <book-cover>: a cover on any page, from its attributes.
|
|
3
|
+
//
|
|
4
|
+
// <script type="module" src="https://covers.otsirh.dev/src/element.js"></script>
|
|
5
|
+
// <book-cover cover-title="Dune" author="Frank Herbert" cover-style="retro"></book-cover>
|
|
6
|
+
//
|
|
7
|
+
// Attributes: cover-title and cover-style (`title` and `style` are HTML's own: a tooltip, the
|
|
8
|
+
// element's CSS), author, subtitle, series, credit, badge, genre, palette, seed, format (paperback,
|
|
9
|
+
// ebook, square or 1600x2560), font, case, texture (present: on), variant (as in the app's links:
|
|
10
|
+
// scene.forest) and lang (the language of the text, as on any element). With loading="lazy" it
|
|
11
|
+
// draws once it comes near the viewport, as an image does. The cover
|
|
12
|
+
// is drawn as an image (so pages with Trusted Types can show it: the page's CSP must allow data:
|
|
13
|
+
// images), sized by the element's width (240px unless styled).
|
|
14
|
+
|
|
15
|
+
import { createCover } from './index.js';
|
|
16
|
+
import { svgToDataUrl } from './browser.js';
|
|
17
|
+
import { LINK, optionsFromLink } from './link.js';
|
|
18
|
+
|
|
19
|
+
/** The attributes that draw the cover, and the link parameter each is. */
|
|
20
|
+
const ATTRIBUTES = /** @type {const} */ ({
|
|
21
|
+
'cover-title': 'title',
|
|
22
|
+
author: 'author',
|
|
23
|
+
subtitle: 'subtitle',
|
|
24
|
+
series: 'series',
|
|
25
|
+
credit: 'credit',
|
|
26
|
+
badge: 'badge',
|
|
27
|
+
'cover-style': 'style',
|
|
28
|
+
genre: 'genre',
|
|
29
|
+
palette: 'palette',
|
|
30
|
+
seed: 'seed',
|
|
31
|
+
format: 'format',
|
|
32
|
+
font: 'font',
|
|
33
|
+
case: 'case',
|
|
34
|
+
variant: 'variant',
|
|
35
|
+
lang: 'lang',
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const CSS =
|
|
39
|
+
':host{display:inline-block;width:240px;vertical-align:top}:host([hidden]){display:none}' +
|
|
40
|
+
'img{display:block;width:100%;height:auto}';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* How an image of a cover is named: "Cover: Dune by Frank Herbert" (with no title, "Untitled", as
|
|
44
|
+
* the cover says). @param {string} title @param {string} author
|
|
45
|
+
*/
|
|
46
|
+
const altFor = (title, author) => {
|
|
47
|
+
const [t, a] = [title, author].map((s) => s.replace(/\s+/g, ' ').trim());
|
|
48
|
+
return `Cover: ${t || 'Untitled'}${a ? ` by ${a}` : ''}`;
|
|
49
|
+
};
|
|
50
|
+
/** The element's styles as a constructed sheet: a page whose CSP allows no inline styles still applies it. */
|
|
51
|
+
const sheet =
|
|
52
|
+
typeof CSSStyleSheet !== 'undefined' && 'replaceSync' in CSSStyleSheet.prototype
|
|
53
|
+
? (() => {
|
|
54
|
+
const made = new CSSStyleSheet();
|
|
55
|
+
made.replaceSync(CSS);
|
|
56
|
+
return made;
|
|
57
|
+
})()
|
|
58
|
+
: null;
|
|
59
|
+
|
|
60
|
+
// (In Node there is no HTMLElement: the module still loads, and defines nothing.)
|
|
61
|
+
const Base = /** @type {typeof HTMLElement} */ (typeof HTMLElement === 'undefined' ? class {} : HTMLElement);
|
|
62
|
+
|
|
63
|
+
export class BookCover extends Base {
|
|
64
|
+
static observedAttributes = [...Object.keys(ATTRIBUTES), 'texture', 'loading'];
|
|
65
|
+
|
|
66
|
+
/** The cover drawn last (null before the first, or after an error). @type {ReturnType<typeof createCover> | null} */
|
|
67
|
+
cover = null;
|
|
68
|
+
/** @type {HTMLImageElement} */
|
|
69
|
+
#img;
|
|
70
|
+
#queued = false;
|
|
71
|
+
/** Drawn once connected: until then, attributes set (or present when upgraded) wait for it. */
|
|
72
|
+
#ready = false;
|
|
73
|
+
/** The options last drawn, to skip drawing them again. */
|
|
74
|
+
#drawn = '';
|
|
75
|
+
/** A lazy element waiting to come into view. */
|
|
76
|
+
#waiting = false;
|
|
77
|
+
/** Lazy elements wait for this to see them near the viewport (none outside a browser). */
|
|
78
|
+
static #observer =
|
|
79
|
+
typeof IntersectionObserver === 'undefined'
|
|
80
|
+
? null
|
|
81
|
+
: new IntersectionObserver(
|
|
82
|
+
(entries) => {
|
|
83
|
+
for (const entry of entries) {
|
|
84
|
+
if (!entry.isIntersecting) continue;
|
|
85
|
+
const el = /** @type {BookCover} */ (entry.target);
|
|
86
|
+
BookCover.#observer?.unobserve(el);
|
|
87
|
+
el.#waiting = false;
|
|
88
|
+
el.#render();
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
{ rootMargin: '300px' },
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
constructor() {
|
|
95
|
+
super();
|
|
96
|
+
const root = this.attachShadow({ mode: 'open' });
|
|
97
|
+
if (sheet && 'adoptedStyleSheets' in root) root.adoptedStyleSheets = [sheet];
|
|
98
|
+
else {
|
|
99
|
+
const style = document.createElement('style');
|
|
100
|
+
style.textContent = CSS;
|
|
101
|
+
root.append(style);
|
|
102
|
+
}
|
|
103
|
+
this.#img = document.createElement('img');
|
|
104
|
+
this.#img.decoding = 'async';
|
|
105
|
+
this.#img.setAttribute('part', 'image');
|
|
106
|
+
root.append(this.#img);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
connectedCallback() {
|
|
110
|
+
this.#ready = true;
|
|
111
|
+
// loading="lazy", as on an image: drawn once it comes near the viewport.
|
|
112
|
+
if (this.#lazy() && !this.cover && BookCover.#observer) {
|
|
113
|
+
this.#waiting = true;
|
|
114
|
+
this.#hold();
|
|
115
|
+
BookCover.#observer.observe(this);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
this.#render();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Until it is drawn: named from its attributes, and the cover's own shape held for it. */
|
|
122
|
+
#hold() {
|
|
123
|
+
this.#img.alt = altFor(this.getAttribute('cover-title') ?? '', this.getAttribute('author') ?? '');
|
|
124
|
+
try {
|
|
125
|
+
const { width = 600, height = width * 1.5 } = this.options();
|
|
126
|
+
this.#img.style.aspectRatio = `${width} / ${height}`;
|
|
127
|
+
} catch {
|
|
128
|
+
this.#img.style.aspectRatio = '2 / 3';
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
disconnectedCallback() {
|
|
133
|
+
if (this.#waiting) BookCover.#observer?.unobserve(this);
|
|
134
|
+
this.#waiting = false;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Whether it waits to come near the viewport (loading="lazy", in any case, as on an image). */
|
|
138
|
+
#lazy() {
|
|
139
|
+
return (this.getAttribute('loading') ?? '').toLowerCase() === 'lazy';
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
attributeChangedCallback(/** @type {string} */ name) {
|
|
143
|
+
// A waiting cover no longer lazy is drawn at once, as an image is.
|
|
144
|
+
if (name === 'loading' && this.#waiting && !this.#lazy()) {
|
|
145
|
+
BookCover.#observer?.unobserve(this);
|
|
146
|
+
this.#waiting = false;
|
|
147
|
+
}
|
|
148
|
+
// A cover still waiting: its name and shape follow the attributes.
|
|
149
|
+
if (this.#waiting) {
|
|
150
|
+
this.#hold();
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
// (Several attributes set at once draw once; an element upgraded on the page draws once, when
|
|
154
|
+
// it connects.)
|
|
155
|
+
if (!this.#ready || this.#queued || !this.isConnected) return;
|
|
156
|
+
this.#queued = true;
|
|
157
|
+
queueMicrotask(() => {
|
|
158
|
+
this.#queued = false;
|
|
159
|
+
this.#render();
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The cover's options, read from the attributes as the HTTP API reads a link. */
|
|
164
|
+
options() {
|
|
165
|
+
const q = new URLSearchParams();
|
|
166
|
+
for (const [attribute, key] of Object.entries(ATTRIBUTES)) {
|
|
167
|
+
const value = this.getAttribute(attribute);
|
|
168
|
+
if (value != null) q.set(key, value);
|
|
169
|
+
}
|
|
170
|
+
// (Present is on, as a boolean attribute; 0 and false, as links write them, are off.)
|
|
171
|
+
const texture = this.getAttribute('texture');
|
|
172
|
+
if (texture !== null) q.set('texture', LINK.texture(texture) === false ? '0' : '1');
|
|
173
|
+
return optionsFromLink(q);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
#render() {
|
|
177
|
+
try {
|
|
178
|
+
const options = this.options();
|
|
179
|
+
// (Moved or reconnected with the same attributes: the cover stands.)
|
|
180
|
+
const key = JSON.stringify(options);
|
|
181
|
+
if (this.cover && key === this.#drawn) return;
|
|
182
|
+
this.#drawn = key;
|
|
183
|
+
this.cover = createCover(options);
|
|
184
|
+
this.#img.src = svgToDataUrl(this.cover.svg);
|
|
185
|
+
this.#img.alt = altFor(this.cover.title, this.cover.author);
|
|
186
|
+
// (Its shape held while the image decodes, so nothing below it moves.)
|
|
187
|
+
this.#img.style.aspectRatio = `${this.cover.width} / ${this.cover.height}`;
|
|
188
|
+
this.dispatchEvent(new CustomEvent('cover', { detail: this.cover }));
|
|
189
|
+
} catch (err) {
|
|
190
|
+
this.cover = null;
|
|
191
|
+
this.#drawn = '';
|
|
192
|
+
this.#img.removeAttribute('src');
|
|
193
|
+
this.#img.alt = '';
|
|
194
|
+
this.dispatchEvent(new CustomEvent('cover-error', { detail: err }));
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (typeof customElements !== 'undefined' && !customElements.get('book-cover')) {
|
|
200
|
+
customElements.define('book-cover', BookCover);
|
|
201
|
+
}
|
package/src/index.d.ts
CHANGED
|
@@ -175,7 +175,7 @@ export interface Cover {
|
|
|
175
175
|
body: string;
|
|
176
176
|
/** The style that was used. */
|
|
177
177
|
style: string;
|
|
178
|
-
/** The palette that was used (`'custom'`
|
|
178
|
+
/** The palette that was used: its id, or for a custom palette its own `id` (`'custom'` if it has none). */
|
|
179
179
|
palette: string;
|
|
180
180
|
/** The palette's colours, as the cover used them. */
|
|
181
181
|
colors: { bg: string; ink: string; accents: string[]; dark: boolean };
|
|
@@ -228,7 +228,10 @@ export interface ResolvedPalette extends Palette {
|
|
|
228
228
|
export interface StyleContext {
|
|
229
229
|
readonly title: string;
|
|
230
230
|
readonly author: string;
|
|
231
|
-
/**
|
|
231
|
+
/**
|
|
232
|
+
* The subtitle, then the credit ("Translated by …"), if any, on a line of its own (after "\n"):
|
|
233
|
+
* the credit alone when there is no subtitle, and empty when there is neither.
|
|
234
|
+
*/
|
|
232
235
|
readonly subtitle: string;
|
|
233
236
|
/** The series line, or empty. `titleBlock` sets it above the title. */
|
|
234
237
|
readonly series: string;
|
package/src/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
/* @ts-self-types="./index.d.ts" */
|
|
1
2
|
// book-cover-generator — generate SVG book covers from a title and an author.
|
|
2
3
|
//
|
|
3
4
|
// import { generateCover } from 'book-cover-generator';
|
|
@@ -31,7 +32,7 @@ export { createMockup, mockupKinds } from './mockups.js';
|
|
|
31
32
|
export { parseBookList } from './books.js';
|
|
32
33
|
|
|
33
34
|
/** This version of the library (artwork for a seed can change between minor versions). */
|
|
34
|
-
export const version = '1.
|
|
35
|
+
export const version = '1.9.0';
|
|
35
36
|
|
|
36
37
|
/** The sizes a cover can have: each side `min` to `max` px, and neither more than `ratio` times the other. */
|
|
37
38
|
export const sizeLimits = Object.freeze({ min: 50, max: 10000, ratio: 3 });
|
|
@@ -399,13 +400,15 @@ function dimension(value, fallback, name) {
|
|
|
399
400
|
return Math.round(v);
|
|
400
401
|
}
|
|
401
402
|
|
|
403
|
+
/** @typedef {import('./text.js').FontSpec} FontSpec */
|
|
404
|
+
|
|
402
405
|
/** Ways to set a title's letters: in capitals, or as typed. */
|
|
403
406
|
const TITLE_CASES = ['upper', 'normal'];
|
|
404
407
|
|
|
405
408
|
/**
|
|
406
409
|
* The title's font and case, if the options set them (else each style's own).
|
|
407
410
|
* @param {import('./index.js').CoverOptions} options
|
|
408
|
-
* @returns {{ font:
|
|
411
|
+
* @returns {{ font: FontSpec | null, fontId: string, titleCase: 'upper' | 'normal' | null }}
|
|
409
412
|
*/
|
|
410
413
|
function typeOptions(options) {
|
|
411
414
|
const font = /** @type {unknown} */ (options.font);
|
|
@@ -511,9 +514,10 @@ export function createCover(options = {}) {
|
|
|
511
514
|
H,
|
|
512
515
|
!!options.texture,
|
|
513
516
|
...(Object.keys(pins).length ? [pins] : []),
|
|
514
|
-
|
|
515
|
-
...(
|
|
516
|
-
...(
|
|
517
|
+
// (Each optional part named, so one can't pass for another: a series of "serif" isn't a font.)
|
|
518
|
+
...(titleType.font || titleType.titleCase ? ['font', titleType.fontId, titleType.titleCase ?? ''] : []),
|
|
519
|
+
...(series || credit ? ['series', series, credit] : []),
|
|
520
|
+
...(badge ? ['badge', badge] : []),
|
|
517
521
|
]),
|
|
518
522
|
).toString(36);
|
|
519
523
|
/** The variants the style chose (or was given). @type {Record<string, string>} */
|