@arraypress/covers 0.0.0-stage → 1.0.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/CHANGELOG.md +50 -0
- package/LICENSE +29 -0
- package/README.md +561 -2
- package/fonts/OFL.txt +93 -0
- package/fonts/inter-latin-400.woff +0 -0
- package/fonts/inter-latin-500.woff +0 -0
- package/fonts/inter-latin-600.woff +0 -0
- package/fonts/inter-latin-700.woff +0 -0
- package/package.json +96 -4
- package/recipes/aurora.json +132 -0
- package/recipes/bauhaus.json +113 -0
- package/recipes/bigType.json +103 -0
- package/recipes/contour.json +109 -0
- package/recipes/cutout.json +191 -0
- package/recipes/diagram.json +92 -0
- package/recipes/dither.json +170 -0
- package/recipes/duotone.json +75 -0
- package/recipes/gridTitle.json +136 -0
- package/recipes/iconTile.json +214 -0
- package/recipes/issue.json +221 -0
- package/recipes/mark.json +170 -0
- package/recipes/photo.json +156 -0
- package/recipes/riso.json +387 -0
- package/recipes/stripes.json +206 -0
- package/recipes/waveform.json +154 -0
- package/schema/recipe.schema.json +2292 -0
- package/src/astro.d.ts +31 -0
- package/src/astro.js +86 -0
- package/src/color.js +246 -0
- package/src/diagram.js +248 -0
- package/src/extract.js +90 -0
- package/src/flatten.js +234 -0
- package/src/fonts.js +200 -0
- package/src/geometry.js +187 -0
- package/src/grid.js +41 -0
- package/src/handmade.js +253 -0
- package/src/icons.js +157 -0
- package/src/index.d.ts +897 -0
- package/src/index.js +19 -0
- package/src/json-recipe.js +458 -0
- package/src/layer-options.js +37 -0
- package/src/layers.js +972 -0
- package/src/palette.js +185 -0
- package/src/png.js +77 -0
- package/src/print.js +658 -0
- package/src/recipes.js +78 -0
- package/src/render.js +462 -0
- package/src/rng.js +95 -0
- package/src/svg.js +69 -0
- package/src/text.js +230 -0
package/src/palette.js
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Palettes: the colours a cover draws with.
|
|
3
|
+
*
|
|
4
|
+
* Every layer reads its default colours from one resolved palette:
|
|
5
|
+
*
|
|
6
|
+
* - `bg` the page colour under everything
|
|
7
|
+
* - `colors` the colours of gradients, blobs, stripes and shapes
|
|
8
|
+
* - `text` type (readable on `bg`)
|
|
9
|
+
* - `accent` the one strong colour (icons, labels, spots)
|
|
10
|
+
* - `onAccent` white, or near-black when the accent is light: for marks on the accent
|
|
11
|
+
* - `muted` secondary type (`text` mixed toward `bg`)
|
|
12
|
+
* - `surface` raised things that sit on `bg` (icon tiles, pills)
|
|
13
|
+
* - `mode` `'light'` or `'dark'`, from `bg`
|
|
14
|
+
*
|
|
15
|
+
* A palette can be written as a preset name, an array (`[bg, ...colors]`),
|
|
16
|
+
* a partial object, or derived from a theme's tokens with
|
|
17
|
+
* {@link derivePalette}.
|
|
18
|
+
*
|
|
19
|
+
* @module palette
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { relativeLuminance, contrastRatio, readableOn, readableText } from '@arraypress/color-utils';
|
|
23
|
+
import { toHex, toOklch, fromOklch, mixOklab } from './color.js';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Curated presets. Each is a full palette; the light ones pair with the
|
|
27
|
+
* dark ones of the same family (`paper`/`ink`, `dawn`/`dusk`, …).
|
|
28
|
+
*
|
|
29
|
+
* @type {Readonly<Record<string, Readonly<import('./index.js').PaletteInput>>>}
|
|
30
|
+
*/
|
|
31
|
+
export const palettes = Object.freeze({
|
|
32
|
+
paper: { bg: '#f3efe7', colors: ['#f0a98a', '#a9bdf2', '#f3d27e', '#b7dcc4'], text: '#1c1a17', accent: '#c9502a' },
|
|
33
|
+
ink: { bg: '#0c0d12', colors: ['#3d5afe', '#ff6b3d', '#9b4dff', '#12b5a0'], text: '#f4f4f6', accent: '#8ea0ff' },
|
|
34
|
+
dawn: { bg: '#fbf1ee', colors: ['#ff9f87', '#ffc7d9', '#c9b6ff', '#ffd9a8'], text: '#2a1714', accent: '#e0533d' },
|
|
35
|
+
dusk: { bg: '#100b1c', colors: ['#6a3df0', '#ff7a59', '#d1408f', '#2b3fd6'], text: '#f7f3ff', accent: '#ff9c7e' },
|
|
36
|
+
ocean: { bg: '#eef4f7', colors: ['#7fb8f0', '#8de0cf', '#b9c9ff', '#d7f0ff'], text: '#0f1d29', accent: '#1f6fd1' },
|
|
37
|
+
ember: { bg: '#140c08', colors: ['#ff5a1f', '#ffb020', '#d6264f', '#7a2b10'], text: '#fff4ea', accent: '#ffb35c' },
|
|
38
|
+
moss: { bg: '#f1f3ea', colors: ['#a8cf8e', '#e6d48a', '#7fbfa8', '#cfe3b5'], text: '#18200f', accent: '#3f7d2c' },
|
|
39
|
+
carbon: { bg: '#0a0a0b', colors: ['#3a3a40', '#5b5b66', '#26262b', '#8a8a96'], text: '#fafafa', accent: '#e4e4e7' },
|
|
40
|
+
mono: { bg: '#f4f4f5', colors: ['#d4d4d8', '#e4e4e7', '#a1a1aa', '#fafafa'], text: '#18181b', accent: '#18181b' },
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Derive a full palette from a theme's tokens (or just an accent).
|
|
45
|
+
*
|
|
46
|
+
* Gradient colours are built in OKLCH around the accent's hue: one near the
|
|
47
|
+
* accent, two neighbours (±40°) and a quiet complement, set pastel on light
|
|
48
|
+
* backgrounds and deep on dark ones. Missing `bg` is a faint tint of the
|
|
49
|
+
* accent (light) or a near-black of its hue (dark); missing `text` is a
|
|
50
|
+
* near-black or near-white of the same hue that clears 7:1 on `bg`.
|
|
51
|
+
*
|
|
52
|
+
* @param {object} tokens
|
|
53
|
+
* @param {string} tokens.accent - The theme's accent (hex, `rgb()` or `hsl()`).
|
|
54
|
+
* @param {string} [tokens.bg] - The theme's background.
|
|
55
|
+
* @param {string} [tokens.text] - The theme's text colour.
|
|
56
|
+
* @param {string[]} [tokens.colors] - Gradient colours to use as-is instead of deriving them.
|
|
57
|
+
* @param {'light' | 'dark'} [tokens.mode] - Force a mode (default: from `bg`, else light).
|
|
58
|
+
* @returns {import('./index.js').Palette} A resolved palette.
|
|
59
|
+
*
|
|
60
|
+
* @example
|
|
61
|
+
* derivePalette({ accent: '#2563eb', bg: '#fdfdfd', text: '#1d1d1f' });
|
|
62
|
+
* derivePalette({ accent: 'rgb(209 254 23)', mode: 'dark' });
|
|
63
|
+
*/
|
|
64
|
+
export function derivePalette({ accent, bg, text, colors, mode } = /** @type {any} */ ({})) {
|
|
65
|
+
if (!accent) throw new TypeError('derivePalette() needs an accent');
|
|
66
|
+
const acc = toHex(accent);
|
|
67
|
+
const a = toOklch(acc);
|
|
68
|
+
const resolvedMode = mode ?? (bg ? modeOf(toHex(bg)) : 'light');
|
|
69
|
+
const dark = resolvedMode === 'dark';
|
|
70
|
+
const hue = a.c < 0.02 ? 260 : a.h; // a grey accent has no meaningful hue
|
|
71
|
+
const chroma = Math.min(1, a.c / 0.12); // greys stay grey
|
|
72
|
+
|
|
73
|
+
const base = bg ? toHex(bg) : fromOklch(dark ? { l: 0.16, c: 0.018 * chroma, h: hue } : { l: 0.975, c: 0.014 * chroma, h: hue });
|
|
74
|
+
|
|
75
|
+
const derived = dark
|
|
76
|
+
? [
|
|
77
|
+
{ l: 0.6, c: 0.17, h: hue },
|
|
78
|
+
{ l: 0.48, c: 0.15, h: hue + 40 },
|
|
79
|
+
{ l: 0.64, c: 0.13, h: hue - 40 },
|
|
80
|
+
{ l: 0.36, c: 0.09, h: hue + 180 },
|
|
81
|
+
]
|
|
82
|
+
: [
|
|
83
|
+
{ l: 0.8, c: 0.12, h: hue },
|
|
84
|
+
{ l: 0.87, c: 0.08, h: hue + 40 },
|
|
85
|
+
{ l: 0.83, c: 0.1, h: hue - 40 },
|
|
86
|
+
{ l: 0.92, c: 0.045, h: hue + 180 },
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
return finish({
|
|
90
|
+
bg: base,
|
|
91
|
+
colors: colors?.length ? colors.map(toHex) : derived.map((c) => fromOklch({ ...c, c: c.c * chroma })),
|
|
92
|
+
text: text ? toHex(text) : undefined,
|
|
93
|
+
accent: acc,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Resolve any palette input to a full {@link import('./index.js').Palette}.
|
|
99
|
+
*
|
|
100
|
+
* - `undefined` → the `paper` preset
|
|
101
|
+
* - a preset name (`'dusk'`) → that preset
|
|
102
|
+
* - a single colour (`'#2563eb'`) → {@link derivePalette} from it
|
|
103
|
+
* - an array → `[bg, ...colors]` (a one-item array is an accent)
|
|
104
|
+
* - an object → missing fields filled in (only `accent` → derived)
|
|
105
|
+
*
|
|
106
|
+
* @param {import('./index.js').PaletteInput} [input] - The palette.
|
|
107
|
+
* @returns {import('./index.js').Palette} The resolved palette.
|
|
108
|
+
* @throws {TypeError} On an unknown preset name or an unparseable colour.
|
|
109
|
+
*
|
|
110
|
+
* @example
|
|
111
|
+
* resolvePalette('dusk').mode; // 'dark'
|
|
112
|
+
* resolvePalette(['#0b0b0f', '#ff6a3d', '#3d5afe']).text; // a near-white
|
|
113
|
+
*/
|
|
114
|
+
export function resolvePalette(input) {
|
|
115
|
+
if (input == null) return resolvePalette(palettes.paper);
|
|
116
|
+
if (typeof input === 'string') {
|
|
117
|
+
if (Object.hasOwn(palettes, input)) return resolvePalette(palettes[input]);
|
|
118
|
+
try {
|
|
119
|
+
return derivePalette({ accent: input });
|
|
120
|
+
} catch {
|
|
121
|
+
throw new TypeError(`Unknown palette "${input}". Presets: ${Object.keys(palettes).join(', ')}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (Array.isArray(input)) {
|
|
125
|
+
if (input.length === 0) return resolvePalette(undefined);
|
|
126
|
+
if (input.length === 1) return derivePalette({ accent: input[0] });
|
|
127
|
+
const [bg, ...colors] = input.map(toHex);
|
|
128
|
+
return finish({ bg, colors, accent: colors[0] });
|
|
129
|
+
}
|
|
130
|
+
if (typeof input === 'object') {
|
|
131
|
+
if (!input.bg && !input.colors && input.accent) return derivePalette(/** @type {any} */ (input));
|
|
132
|
+
if (!input.colors?.length && input.accent) return derivePalette(/** @type {any} */ (input));
|
|
133
|
+
const bg = toHex(input.bg ?? '#ffffff');
|
|
134
|
+
const colors = (input.colors?.length ? input.colors : [input.accent ?? (modeOf(bg) === 'dark' ? '#ffffff' : '#000000')]).map(toHex);
|
|
135
|
+
return finish({
|
|
136
|
+
bg,
|
|
137
|
+
colors,
|
|
138
|
+
text: input.text ? toHex(input.text) : undefined,
|
|
139
|
+
accent: input.accent ? toHex(input.accent) : colors[0],
|
|
140
|
+
muted: input.muted ? toHex(input.muted) : undefined,
|
|
141
|
+
surface: input.surface ? toHex(input.surface) : undefined,
|
|
142
|
+
onAccent: input.onAccent ? toHex(input.onAccent) : undefined,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
throw new TypeError('A palette is a preset name, a colour, an array of colours or an object');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* `'light'` or `'dark'` for a background.
|
|
150
|
+
*
|
|
151
|
+
* @param {string} bg - `#rrggbb`.
|
|
152
|
+
* @returns {'light' | 'dark'} The mode.
|
|
153
|
+
*/
|
|
154
|
+
export function modeOf(bg) {
|
|
155
|
+
// 0.18 is where black and white text have equal contrast on a surface.
|
|
156
|
+
return relativeLuminance(bg) > 0.18 ? 'light' : 'dark';
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Fill the derived fields of a partial palette.
|
|
161
|
+
*
|
|
162
|
+
* @param {{ bg: string, colors: string[], accent: string, text?: string, muted?: string, surface?: string, onAccent?: string }} p
|
|
163
|
+
* @returns {import('./index.js').Palette} The palette.
|
|
164
|
+
*/
|
|
165
|
+
function finish(p) {
|
|
166
|
+
const mode = modeOf(p.bg);
|
|
167
|
+
const dark = mode === 'dark';
|
|
168
|
+
const { h, c } = toOklch(p.accent);
|
|
169
|
+
const tint = Math.min(0.02, c * 0.15);
|
|
170
|
+
let text = p.text;
|
|
171
|
+
if (!text) {
|
|
172
|
+
text = fromOklch(dark ? { l: 0.97, c: tint * 0.5, h } : { l: 0.2, c: tint, h });
|
|
173
|
+
if (contrastRatio(text, p.bg) < 7) text = readableOn(text, [p.bg], { toward: dark ? '#ffffff' : '#000000', ratio: 7 });
|
|
174
|
+
}
|
|
175
|
+
return Object.freeze({
|
|
176
|
+
mode,
|
|
177
|
+
bg: p.bg,
|
|
178
|
+
colors: Object.freeze(p.colors.slice()),
|
|
179
|
+
text,
|
|
180
|
+
accent: p.accent,
|
|
181
|
+
onAccent: p.onAccent ?? readableText(p.accent, '#ffffff', '#111111'),
|
|
182
|
+
muted: p.muted ?? mixOklab(text, p.bg, 0.38),
|
|
183
|
+
surface: p.surface ?? (dark ? mixOklab(p.bg, text, 0.07) : mixOklab(p.bg, '#ffffff', 0.75)),
|
|
184
|
+
});
|
|
185
|
+
}
|
package/src/png.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A minimal PNG encoder (8-bit greyscale, RGB or RGBA; no filtering), so
|
|
3
|
+
* layers that are pixels by nature (dithers, print textures) can embed
|
|
4
|
+
* themselves in an SVG without sharp.
|
|
5
|
+
*
|
|
6
|
+
* @module png
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { deflateSync } from 'node:zlib';
|
|
10
|
+
|
|
11
|
+
/** CRC-32 table (PNG's polynomial). */
|
|
12
|
+
const CRC = (() => {
|
|
13
|
+
const t = new Uint32Array(256);
|
|
14
|
+
for (let n = 0; n < 256; n++) {
|
|
15
|
+
let c = n;
|
|
16
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
17
|
+
t[n] = c >>> 0;
|
|
18
|
+
}
|
|
19
|
+
return t;
|
|
20
|
+
})();
|
|
21
|
+
|
|
22
|
+
/** @param {Uint8Array} bytes @returns {number} */
|
|
23
|
+
function crc32(bytes) {
|
|
24
|
+
let c = 0xffffffff;
|
|
25
|
+
for (const b of bytes) c = CRC[(c ^ b) & 0xff] ^ (c >>> 8);
|
|
26
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** @param {string} type @param {Uint8Array} data @returns {Buffer} */
|
|
30
|
+
function chunk(type, data) {
|
|
31
|
+
const out = Buffer.alloc(12 + data.length);
|
|
32
|
+
out.writeUInt32BE(data.length, 0);
|
|
33
|
+
out.write(type, 4, 'latin1');
|
|
34
|
+
Buffer.from(data).copy(out, 8);
|
|
35
|
+
out.writeUInt32BE(crc32(out.subarray(4, 8 + data.length)), 8 + data.length);
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Encode pixels as a PNG.
|
|
41
|
+
*
|
|
42
|
+
* @param {Uint8Array} pixels - Row-major samples.
|
|
43
|
+
* @param {number} width
|
|
44
|
+
* @param {number} height
|
|
45
|
+
* @param {1 | 3 | 4} [channels=4] - Grey, RGB or RGBA.
|
|
46
|
+
* @returns {Buffer} PNG bytes.
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* encodePng(new Uint8Array([0, 255]), 2, 1, 1); // a black and a white pixel
|
|
50
|
+
*/
|
|
51
|
+
export function encodePng(pixels, width, height, channels = 4) {
|
|
52
|
+
const stride = width * channels;
|
|
53
|
+
const raw = Buffer.alloc((stride + 1) * height);
|
|
54
|
+
for (let y = 0; y < height; y++) {
|
|
55
|
+
raw[y * (stride + 1)] = 0;
|
|
56
|
+
Buffer.from(pixels.buffer, pixels.byteOffset + y * stride, stride).copy(raw, y * (stride + 1) + 1);
|
|
57
|
+
}
|
|
58
|
+
const ihdr = Buffer.alloc(13);
|
|
59
|
+
ihdr.writeUInt32BE(width, 0);
|
|
60
|
+
ihdr.writeUInt32BE(height, 4);
|
|
61
|
+
ihdr[8] = 8;
|
|
62
|
+
ihdr[9] = { 1: 0, 3: 2, 4: 6 }[channels];
|
|
63
|
+
return Buffer.concat([
|
|
64
|
+
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
65
|
+
chunk('IHDR', ihdr),
|
|
66
|
+
chunk('IDAT', deflateSync(raw, { level: 9 })),
|
|
67
|
+
chunk('IEND', new Uint8Array(0)),
|
|
68
|
+
]);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A PNG as a data URI.
|
|
73
|
+
*
|
|
74
|
+
* @param {Buffer} png
|
|
75
|
+
* @returns {string}
|
|
76
|
+
*/
|
|
77
|
+
export const pngUri = (png) => `data:image/png;base64,${png.toString('base64')}`;
|