@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.
Files changed (50) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/LICENSE +29 -0
  3. package/README.md +561 -2
  4. package/fonts/OFL.txt +93 -0
  5. package/fonts/inter-latin-400.woff +0 -0
  6. package/fonts/inter-latin-500.woff +0 -0
  7. package/fonts/inter-latin-600.woff +0 -0
  8. package/fonts/inter-latin-700.woff +0 -0
  9. package/package.json +96 -4
  10. package/recipes/aurora.json +132 -0
  11. package/recipes/bauhaus.json +113 -0
  12. package/recipes/bigType.json +103 -0
  13. package/recipes/contour.json +109 -0
  14. package/recipes/cutout.json +191 -0
  15. package/recipes/diagram.json +92 -0
  16. package/recipes/dither.json +170 -0
  17. package/recipes/duotone.json +75 -0
  18. package/recipes/gridTitle.json +136 -0
  19. package/recipes/iconTile.json +214 -0
  20. package/recipes/issue.json +221 -0
  21. package/recipes/mark.json +170 -0
  22. package/recipes/photo.json +156 -0
  23. package/recipes/riso.json +387 -0
  24. package/recipes/stripes.json +206 -0
  25. package/recipes/waveform.json +154 -0
  26. package/schema/recipe.schema.json +2292 -0
  27. package/src/astro.d.ts +31 -0
  28. package/src/astro.js +86 -0
  29. package/src/color.js +246 -0
  30. package/src/diagram.js +248 -0
  31. package/src/extract.js +90 -0
  32. package/src/flatten.js +234 -0
  33. package/src/fonts.js +200 -0
  34. package/src/geometry.js +187 -0
  35. package/src/grid.js +41 -0
  36. package/src/handmade.js +253 -0
  37. package/src/icons.js +157 -0
  38. package/src/index.d.ts +897 -0
  39. package/src/index.js +19 -0
  40. package/src/json-recipe.js +458 -0
  41. package/src/layer-options.js +37 -0
  42. package/src/layers.js +972 -0
  43. package/src/palette.js +185 -0
  44. package/src/png.js +77 -0
  45. package/src/print.js +658 -0
  46. package/src/recipes.js +78 -0
  47. package/src/render.js +462 -0
  48. package/src/rng.js +95 -0
  49. package/src/svg.js +69 -0
  50. 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')}`;