book-cover-generator 1.8.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 +3 -3
- package/bin/book-cover.js +6 -6
- package/package.json +1 -1
- package/src/browser.js +1 -0
- package/src/core.d.ts +1 -1
- package/src/core.js +1 -0
- package/src/element.d.ts +2 -1
- package/src/element.js +73 -6
- package/src/index.d.ts +5 -2
- package/src/index.js +9 -5
- package/src/mcp.js +26 -4
- 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
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
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.
|
|
12
12
|
|
|
13
|
-
**[▶ Open the generator](https://covers.otsirh.dev)**
|
|
13
|
+
**[▶ Open the generator](https://covers.otsirh.dev)** · [See every style](https://covers.otsirh.dev/gallery/)
|
|
14
14
|
|
|
15
15
|

|
|
16
16
|
|
|
@@ -24,7 +24,7 @@ Generate book covers from a **title** and an **author**. 39 styles, 28 palettes
|
|
|
24
24
|
- Geometric: Bauhaus, Split, Blocks, Concentric, Stripes.
|
|
25
25
|
- Pattern: Paper Cut, Halftone, Topographic, Flow Field, Low Poly, Mosaic, Memphis, Pixel.
|
|
26
26
|
- **28 palettes** (15 light, 13 dark), or pass your own. Every style adapts to light and dark palettes.
|
|
27
|
-
- **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.
|
|
28
28
|
- **Subtitle or tagline**, and an optional **paper texture**.
|
|
29
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.
|
|
30
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.
|
|
@@ -93,7 +93,7 @@ One script, then a cover wherever you write the element:
|
|
|
93
93
|
<book-cover cover-title="Dune" author="Frank Herbert" cover-style="retro" seed="1"></book-cover>
|
|
94
94
|
```
|
|
95
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. 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'`.
|
|
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
97
|
|
|
98
98
|
## From an AI assistant (MCP)
|
|
99
99
|
|
package/bin/book-cover.js
CHANGED
|
@@ -6,7 +6,7 @@ import { parseArgs } from 'node:util';
|
|
|
6
6
|
import { createCover, listStyles, palettes, parseBookList, optionsForBook, genres } from '../src/index.js';
|
|
7
7
|
import { hex, slug } from '../src/shared.js';
|
|
8
8
|
import { px, formatSize } from '../src/link.js';
|
|
9
|
-
import {
|
|
9
|
+
import { answerPayload } from '../src/mcp.js';
|
|
10
10
|
|
|
11
11
|
const HELP = `Usage: book-cover "<title>" ["<author>"] [options]
|
|
12
12
|
|
|
@@ -349,9 +349,9 @@ function serveMcp() {
|
|
|
349
349
|
writeFileSync(file, data);
|
|
350
350
|
return file;
|
|
351
351
|
};
|
|
352
|
-
/** @param {
|
|
353
|
-
const
|
|
354
|
-
if (
|
|
352
|
+
/** @param {string | null} text */
|
|
353
|
+
const send = (text) => {
|
|
354
|
+
if (text) process.stdout.write(`${text}\n`);
|
|
355
355
|
};
|
|
356
356
|
lines.on('line', (line) => {
|
|
357
357
|
if (!line.trim()) return;
|
|
@@ -359,10 +359,10 @@ function serveMcp() {
|
|
|
359
359
|
try {
|
|
360
360
|
message = JSON.parse(line);
|
|
361
361
|
} catch {
|
|
362
|
-
|
|
362
|
+
send(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }));
|
|
363
363
|
return;
|
|
364
364
|
}
|
|
365
|
-
|
|
365
|
+
send(answerPayload(message, { writeFile }));
|
|
366
366
|
});
|
|
367
367
|
}
|
|
368
368
|
|
package/package.json
CHANGED
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
CHANGED
|
@@ -4,7 +4,8 @@ import type { CoverOptions, createCover } from './index.js';
|
|
|
4
4
|
* `<book-cover>`: a cover on any page, from its attributes (cover-title, author, subtitle, series,
|
|
5
5
|
* credit, badge, cover-style, genre, palette, seed, format, font, case, texture, variant, lang). It
|
|
6
6
|
* fires `cover` (detail: the cover) when it draws, and `cover-error` (detail: the error) when the
|
|
7
|
-
* attributes can't make one.
|
|
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.
|
|
8
9
|
*/
|
|
9
10
|
export declare class BookCover extends HTMLElement {
|
|
10
11
|
static observedAttributes: string[];
|
package/src/element.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
/* @ts-self-types="./element.d.ts" */
|
|
1
2
|
// <book-cover>: a cover on any page, from its attributes.
|
|
2
3
|
//
|
|
3
4
|
// <script type="module" src="https://covers.otsirh.dev/src/element.js"></script>
|
|
@@ -6,7 +7,8 @@
|
|
|
6
7
|
// Attributes: cover-title and cover-style (`title` and `style` are HTML's own: a tooltip, the
|
|
7
8
|
// element's CSS), author, subtitle, series, credit, badge, genre, palette, seed, format (paperback,
|
|
8
9
|
// ebook, square or 1600x2560), font, case, texture (present: on), variant (as in the app's links:
|
|
9
|
-
// scene.forest) and lang (the language of the text, as on any element).
|
|
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
|
|
10
12
|
// is drawn as an image (so pages with Trusted Types can show it: the page's CSP must allow data:
|
|
11
13
|
// images), sized by the element's width (240px unless styled).
|
|
12
14
|
|
|
@@ -36,6 +38,15 @@ const ATTRIBUTES = /** @type {const} */ ({
|
|
|
36
38
|
const CSS =
|
|
37
39
|
':host{display:inline-block;width:240px;vertical-align:top}:host([hidden]){display:none}' +
|
|
38
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
|
+
};
|
|
39
50
|
/** The element's styles as a constructed sheet: a page whose CSP allows no inline styles still applies it. */
|
|
40
51
|
const sheet =
|
|
41
52
|
typeof CSSStyleSheet !== 'undefined' && 'replaceSync' in CSSStyleSheet.prototype
|
|
@@ -50,7 +61,7 @@ const sheet =
|
|
|
50
61
|
const Base = /** @type {typeof HTMLElement} */ (typeof HTMLElement === 'undefined' ? class {} : HTMLElement);
|
|
51
62
|
|
|
52
63
|
export class BookCover extends Base {
|
|
53
|
-
static observedAttributes = [...Object.keys(ATTRIBUTES), 'texture'];
|
|
64
|
+
static observedAttributes = [...Object.keys(ATTRIBUTES), 'texture', 'loading'];
|
|
54
65
|
|
|
55
66
|
/** The cover drawn last (null before the first, or after an error). @type {ReturnType<typeof createCover> | null} */
|
|
56
67
|
cover = null;
|
|
@@ -61,6 +72,24 @@ export class BookCover extends Base {
|
|
|
61
72
|
#ready = false;
|
|
62
73
|
/** The options last drawn, to skip drawing them again. */
|
|
63
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
|
+
);
|
|
64
93
|
|
|
65
94
|
constructor() {
|
|
66
95
|
super();
|
|
@@ -79,10 +108,48 @@ export class BookCover extends Base {
|
|
|
79
108
|
|
|
80
109
|
connectedCallback() {
|
|
81
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
|
+
}
|
|
82
118
|
this.#render();
|
|
83
119
|
}
|
|
84
120
|
|
|
85
|
-
|
|
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
|
+
}
|
|
86
153
|
// (Several attributes set at once draw once; an element upgraded on the page draws once, when
|
|
87
154
|
// it connects.)
|
|
88
155
|
if (!this.#ready || this.#queued || !this.isConnected) return;
|
|
@@ -115,9 +182,9 @@ export class BookCover extends Base {
|
|
|
115
182
|
this.#drawn = key;
|
|
116
183
|
this.cover = createCover(options);
|
|
117
184
|
this.#img.src = svgToDataUrl(this.cover.svg);
|
|
118
|
-
this.#img.alt = this.cover.author
|
|
119
|
-
|
|
120
|
-
|
|
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}`;
|
|
121
188
|
this.dispatchEvent(new CustomEvent('cover', { detail: this.cover }));
|
|
122
189
|
} catch (err) {
|
|
123
190
|
this.cover = null;
|
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>} */
|
package/src/mcp.js
CHANGED
|
@@ -185,7 +185,7 @@ function linkOf(options, cover) {
|
|
|
185
185
|
/** @type {Record<string, string>} */
|
|
186
186
|
const pinned = {};
|
|
187
187
|
for (const name of Object.keys(options.variants ?? {}).map((n) => n.toLowerCase())) {
|
|
188
|
-
if (cover.variants
|
|
188
|
+
if (Object.hasOwn(cover.variants, name)) pinned[name] = cover.variants[name];
|
|
189
189
|
}
|
|
190
190
|
const own = options.palette && typeof options.palette === 'object' ? options.palette : null;
|
|
191
191
|
const preset = Object.entries(formats).find(([, [w, h]]) => w === cover.width && h === cover.height);
|
|
@@ -330,8 +330,24 @@ export function handlePayload(payload, ctx = {}) {
|
|
|
330
330
|
return answers.length ? answers : null;
|
|
331
331
|
}
|
|
332
332
|
|
|
333
|
+
/**
|
|
334
|
+
* The answer to what a client sent as the JSON text to send back, or null for none. Whatever goes
|
|
335
|
+
* wrong while answering is an internal error, not a crash of the server.
|
|
336
|
+
* @param {unknown} payload @param {Context} [ctx]
|
|
337
|
+
*/
|
|
338
|
+
export function answerPayload(payload, ctx = {}) {
|
|
339
|
+
try {
|
|
340
|
+
const answer = handlePayload(payload, ctx);
|
|
341
|
+
return answer ? JSON.stringify(answer) : null;
|
|
342
|
+
} catch {
|
|
343
|
+
return JSON.stringify(failure(null, -32603, 'Internal error'));
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
333
347
|
/** @param {unknown} id @param {unknown} result */
|
|
334
348
|
const ok = (id, result) => ({ jsonrpc: '2.0', id, result });
|
|
349
|
+
/** An id as JSON-RPC allows it, so it can be sent back: a string, a number or null. @param {unknown} id */
|
|
350
|
+
const validId = (id) => id === null || typeof id === 'string' || (typeof id === 'number' && Number.isFinite(id));
|
|
335
351
|
/** @param {unknown} id @param {number} code @param {string} message */
|
|
336
352
|
const failure = (id, code, message) => ({ jsonrpc: '2.0', id, error: { code, message } });
|
|
337
353
|
|
|
@@ -343,11 +359,17 @@ export function handleMessage(message, ctx = {}) {
|
|
|
343
359
|
if (!message || typeof message !== 'object' || Array.isArray(message))
|
|
344
360
|
return failure(null, -32600, 'Invalid request');
|
|
345
361
|
const { jsonrpc, id, method, params } = /** @type {Record<string, any>} */ (message);
|
|
346
|
-
|
|
347
|
-
|
|
362
|
+
const hasId = 'id' in message;
|
|
363
|
+
// A response (this server sends no requests, so it answers none): nothing to say.
|
|
364
|
+
if (jsonrpc === '2.0' && !('method' in message) && hasId && ('result' in message || 'error' in message)) {
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
// Not a request: an error, with its id if it is one JSON-RPC allows (else null, as for none).
|
|
368
|
+
if (jsonrpc !== '2.0' || typeof method !== 'string' || (hasId && !validId(id))) {
|
|
369
|
+
return failure(hasId && validId(id) ? id : null, -32600, 'Invalid request');
|
|
348
370
|
}
|
|
349
371
|
// A notification (initialized, cancelled…) has no answer.
|
|
350
|
-
if (!
|
|
372
|
+
if (!hasId) return null;
|
|
351
373
|
switch (method) {
|
|
352
374
|
case 'initialize': {
|
|
353
375
|
const asked = params?.protocolVersion;
|
package/src/mockups.js
CHANGED
|
@@ -10,9 +10,19 @@ export const mockupKinds = Object.freeze(['flat', 'book', 'ereader', 'card', 'sq
|
|
|
10
10
|
/** The neutral backdrop of the product shots. */
|
|
11
11
|
const STUDIO = '#ecebe7';
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* The cover's markup with its ids made a mockup's own (the mockup's tag before the cover's `bc…-`
|
|
15
|
+
* prefix), so the cover and its mockups, or two mockups of it, can share a page.
|
|
16
|
+
* @param {string} body @param {string} tag
|
|
17
|
+
*/
|
|
18
|
+
const ownIds = (body, tag) => {
|
|
19
|
+
const uid = /\bid="(bc[0-9a-z]+)-/.exec(body)?.[1];
|
|
20
|
+
return uid ? body.replace(new RegExp(`(id="|url\\(#|href="#)${uid}-`, 'g'), `$1${tag}-${uid}-`) : body;
|
|
21
|
+
};
|
|
22
|
+
|
|
13
23
|
/**
|
|
14
24
|
* The cover nested at a place and size.
|
|
15
|
-
* @param {
|
|
25
|
+
* @param {{ width: number, height: number, body: string }} cover @param {number} x @param {number} y @param {number} w @param {number} h
|
|
16
26
|
*/
|
|
17
27
|
const nest = (cover, x, y, w, h) =>
|
|
18
28
|
`<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>`;
|
|
@@ -53,6 +63,7 @@ export function createMockup(cover, { kind = 'book', background, text = '' } = {
|
|
|
53
63
|
// Ids unique to this cover and kind of mockup, so several can share a page.
|
|
54
64
|
const tag = `mk${hashString(cover.svg).toString(36)}-${kind}`;
|
|
55
65
|
const id = (/** @type {string} */ name) => `${tag}-${name}`;
|
|
66
|
+
const nested = { width: cover.width, height: cover.height, body: ownIds(cover.body, tag) };
|
|
56
67
|
const { width: CW, height: CH, colors } = cover;
|
|
57
68
|
// (Cut between whole characters: an emoji or accented letter is never split.)
|
|
58
69
|
const words = clipGraphemes(String(text).replace(/\s+/g, ' ').trim(), 80).trim();
|
|
@@ -79,7 +90,7 @@ export function createMockup(cover, { kind = 'book', background, text = '' } = {
|
|
|
79
90
|
H,
|
|
80
91
|
`<defs>${shadow(id('shadow'), 14 * u, 10 * u, 0.32)}</defs>` +
|
|
81
92
|
`<rect width="${n(W)}" height="${n(H)}" fill="${background ?? STUDIO}"/>` +
|
|
82
|
-
`<g filter="url(#${id('shadow')})">${nest(
|
|
93
|
+
`<g filter="url(#${id('shadow')})">${nest(nested, pad, pad, CW, CH)}</g>`,
|
|
83
94
|
);
|
|
84
95
|
}
|
|
85
96
|
|
|
@@ -112,7 +123,7 @@ export function createMockup(cover, { kind = 'book', background, text = '' } = {
|
|
|
112
123
|
`<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')})"/>` +
|
|
113
124
|
`<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}"/>` +
|
|
114
125
|
`<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')})"/>` +
|
|
115
|
-
`<g transform="matrix(${turn} ${-rise} 0 1 ${n(x0)} ${n(y0)})">${nest(
|
|
126
|
+
`<g transform="matrix(${turn} ${-rise} 0 1 ${n(x0)} ${n(y0)})">${nest(nested, 0, 0, CW, CH)}` +
|
|
116
127
|
`<rect width="${CW}" height="${CH}" fill="url(#${id('gloss')})"/></g>`,
|
|
117
128
|
);
|
|
118
129
|
}
|
|
@@ -134,7 +145,7 @@ export function createMockup(cover, { kind = 'book', background, text = '' } = {
|
|
|
134
145
|
`<rect width="${n(W)}" height="${n(H)}" fill="${background ?? STUDIO}"/>` +
|
|
135
146
|
`<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>` +
|
|
136
147
|
`<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)}"/>` +
|
|
137
|
-
nest(
|
|
148
|
+
nest(nested, pad + side, pad + top, CW, CH) +
|
|
138
149
|
`<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"/>`,
|
|
139
150
|
);
|
|
140
151
|
}
|
|
@@ -163,7 +174,7 @@ export function createMockup(cover, { kind = 'book', background, text = '' } = {
|
|
|
163
174
|
let s =
|
|
164
175
|
`<defs>${shadow(id('shadow'), 18 * u, 14 * u, 0.4)}</defs>` +
|
|
165
176
|
`<rect width="${W}" height="${H}" fill="${bg}"/>` +
|
|
166
|
-
`<g filter="url(#${id('shadow')})">${nest(
|
|
177
|
+
`<g filter="url(#${id('shadow')})">${nest(nested, cx, cy, cw, ch)}</g>`;
|
|
167
178
|
// The words: beside the cover on a card, under it otherwise.
|
|
168
179
|
const box =
|
|
169
180
|
kind === 'card'
|
package/src/palettes.js
CHANGED
package/src/styles/util.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
/* @ts-self-types="./util.d.ts" */
|
|
1
2
|
import { n, circle, contrast, readable, clamp, mix, inkCandidates, hexToRgb } from '../core.js';
|
|
2
3
|
import { titleBlock } from '../type.js';
|
|
3
4
|
import { graphemes } from '../text.js';
|
|
4
5
|
|
|
6
|
+
/** @typedef {import('../type.js').TitleBlock} TitleBlock */
|
|
7
|
+
|
|
5
8
|
/**
|
|
6
9
|
* The title's characters, upper-cased, from the first that isn't punctuation or a
|
|
7
10
|
* space: an initial skips opening quotes, brackets, "¿" and the like (but keeps
|
|
@@ -70,7 +73,7 @@ export function tintText(base, toward, k, bg) {
|
|
|
70
73
|
* @param {readonly V[]} candidates layout values, from the most to the least room for the art
|
|
71
74
|
* @param {(value: V) => import('../type.js').TitleBlockOptions} optionsFor
|
|
72
75
|
* @param {number} [minSize] fraction of the short side
|
|
73
|
-
* @returns {{ block:
|
|
76
|
+
* @returns {{ block: TitleBlock, value: V }}
|
|
74
77
|
*/
|
|
75
78
|
export function fitTitleWith(c, candidates, optionsFor, minSize = 0.06) {
|
|
76
79
|
let block = titleBlock(c, optionsFor(candidates[0]));
|
package/src/text.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
/* @ts-self-types="./text.d.ts" */
|
|
1
2
|
// Text measurement, fitting (wrap + shrink-to-fit) and rendering.
|
|
2
3
|
//
|
|
3
4
|
// Only system font stacks are used so that covers render identically when the
|
|
@@ -74,6 +75,8 @@ export const FONTS = {
|
|
|
74
75
|
},
|
|
75
76
|
};
|
|
76
77
|
|
|
78
|
+
/** @typedef {import('./text.js').FontSpec} FontSpec */
|
|
79
|
+
|
|
77
80
|
/* ------------------------------------------------------------- measurement */
|
|
78
81
|
|
|
79
82
|
/** @type {CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D | null | undefined} */
|
|
@@ -875,7 +878,7 @@ export function fitText(text, opts) {
|
|
|
875
878
|
/**
|
|
876
879
|
* Attributes shared by every line of a text block, for its wrapping `<g>`.
|
|
877
880
|
* Colours and font names are escaped; `attrs` is raw SVG.
|
|
878
|
-
* @param {{ font:
|
|
881
|
+
* @param {{ font: FontSpec, fill: string, size?: number, weight?: number, italic?: boolean, letterSpacing?: number, anchor?: 'start' | 'middle' | 'end', opacity?: number, attrs?: string }} o
|
|
879
882
|
*/
|
|
880
883
|
export function textGroupAttrs({ font, fill, size, weight, italic, letterSpacing, anchor = 'start', opacity, attrs }) {
|
|
881
884
|
let a = `font-family="${escapeXml(font.family)}"`;
|
package/src/type.d.ts
CHANGED
|
@@ -38,7 +38,10 @@ export interface TitleBlockOptions {
|
|
|
38
38
|
letterSpacing?: number;
|
|
39
39
|
lineHeight?: number;
|
|
40
40
|
maxLines?: number;
|
|
41
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* Set a leading article of three letters or more ("The", "Les", "Der") as a small line above the
|
|
43
|
+
* title; a shorter one ("A", "An", "La") stays with the title. Default `true`.
|
|
44
|
+
*/
|
|
42
45
|
lead?: boolean;
|
|
43
46
|
/** Try a justified stack (each line full width) before regular wrapping. */
|
|
44
47
|
stack?: boolean;
|
package/src/type.js
CHANGED