@arraypress/covers 1.0.0 → 1.1.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 (213) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/README.md +296 -11
  3. package/package.json +33 -5
  4. package/recipes/brand.json +1143 -0
  5. package/schema/recipe.schema.json +7300 -770
  6. package/src/animate/index.d.ts +19 -0
  7. package/src/animate/index.js +116 -0
  8. package/src/builtins.js +4 -0
  9. package/src/effects/ascii.d.ts +7 -0
  10. package/src/effects/ascii.js +93 -0
  11. package/src/effects/buffer.d.ts +60 -0
  12. package/src/effects/buffer.js +472 -0
  13. package/src/effects/detail.d.ts +12 -0
  14. package/src/effects/detail.js +124 -0
  15. package/src/effects/digital.d.ts +14 -0
  16. package/src/effects/digital.js +216 -0
  17. package/src/effects/distort.d.ts +20 -0
  18. package/src/effects/distort.js +314 -0
  19. package/src/effects/dither.d.ts +8 -0
  20. package/src/effects/dither.js +147 -0
  21. package/src/effects/drop-shadow.d.ts +4 -0
  22. package/src/effects/drop-shadow.js +45 -0
  23. package/src/effects/extrude.d.ts +5 -0
  24. package/src/effects/extrude.js +55 -0
  25. package/src/effects/film.d.ts +11 -0
  26. package/src/effects/film.js +204 -0
  27. package/src/effects/glow.d.ts +8 -0
  28. package/src/effects/glow.js +56 -0
  29. package/src/effects/grade.d.ts +30 -0
  30. package/src/effects/grade.js +274 -0
  31. package/src/effects/halftone.d.ts +8 -0
  32. package/src/effects/halftone.js +95 -0
  33. package/src/effects/index.d.ts +125 -0
  34. package/src/effects/index.js +277 -0
  35. package/src/effects/ink.d.ts +12 -0
  36. package/src/effects/ink.js +70 -0
  37. package/src/effects/lens.d.ts +14 -0
  38. package/src/effects/lens.js +277 -0
  39. package/src/effects/light.d.ts +10 -0
  40. package/src/effects/light.js +59 -0
  41. package/src/effects/looks.d.ts +9 -0
  42. package/src/effects/looks.js +74 -0
  43. package/src/effects/mask.d.ts +52 -0
  44. package/src/effects/mask.js +314 -0
  45. package/src/effects/motion.d.ts +6 -0
  46. package/src/effects/motion.js +60 -0
  47. package/src/effects/newsprint.d.ts +6 -0
  48. package/src/effects/newsprint.js +89 -0
  49. package/src/effects/optics.d.ts +10 -0
  50. package/src/effects/optics.js +213 -0
  51. package/src/effects/options.d.ts +6 -0
  52. package/src/effects/options.js +106 -0
  53. package/src/effects/paint-presets.d.ts +10 -0
  54. package/src/effects/paint-presets.js +47 -0
  55. package/src/effects/paint.d.ts +49 -0
  56. package/src/effects/paint.js +66 -0
  57. package/src/effects/pixels.d.ts +24 -0
  58. package/src/effects/pixels.js +138 -0
  59. package/src/effects/polar.d.ts +10 -0
  60. package/src/effects/polar.js +265 -0
  61. package/src/effects/region.d.ts +8 -0
  62. package/src/effects/region.js +136 -0
  63. package/src/effects/round-border.d.ts +5 -0
  64. package/src/effects/round-border.js +62 -0
  65. package/src/effects/screen.d.ts +14 -0
  66. package/src/effects/screen.js +212 -0
  67. package/src/effects/sort.d.ts +8 -0
  68. package/src/effects/sort.js +125 -0
  69. package/src/effects/symmetry.d.ts +6 -0
  70. package/src/effects/symmetry.js +74 -0
  71. package/src/effects/tile.d.ts +7 -0
  72. package/src/effects/tile.js +55 -0
  73. package/src/effects/tone.d.ts +16 -0
  74. package/src/effects/tone.js +117 -0
  75. package/src/effects/transform.d.ts +35 -0
  76. package/src/effects/transform.js +127 -0
  77. package/src/effects/util.d.ts +16 -0
  78. package/src/effects/util.js +85 -0
  79. package/src/extract.js +4 -3
  80. package/src/fonts.d.ts +55 -0
  81. package/src/fonts.js +246 -53
  82. package/src/formats/cut.d.ts +10 -0
  83. package/src/formats/cut.js +77 -0
  84. package/src/formats/index.d.ts +6 -0
  85. package/src/formats/index.js +9 -0
  86. package/src/formats/presets.d.ts +41 -0
  87. package/src/formats/presets.js +89 -0
  88. package/src/formats/print.d.ts +25 -0
  89. package/src/formats/print.js +117 -0
  90. package/src/formats/zones.d.ts +10 -0
  91. package/src/formats/zones.js +64 -0
  92. package/src/geometry.js +1 -1
  93. package/src/icons.js +43 -9
  94. package/src/index.d.ts +205 -19
  95. package/src/index.js +12 -4
  96. package/src/inter-data.js +27 -0
  97. package/src/json-recipe.js +181 -23
  98. package/src/kit/index.d.ts +20 -0
  99. package/src/kit/index.js +58 -0
  100. package/src/layer-options.js +23 -4
  101. package/src/layers/barcode.d.ts +19 -0
  102. package/src/layers/barcode.js +63 -0
  103. package/src/layers/code128.d.ts +4 -0
  104. package/src/layers/code128.js +28 -0
  105. package/src/layers/expr.d.ts +6 -0
  106. package/src/layers/expr.js +121 -0
  107. package/src/layers/geometry.d.ts +6 -0
  108. package/src/layers/geometry.js +238 -0
  109. package/src/layers/index.d.ts +18 -0
  110. package/src/layers/index.js +15 -0
  111. package/src/layers/perforation.d.ts +14 -0
  112. package/src/layers/perforation.js +47 -0
  113. package/src/layers/repeat.d.ts +29 -0
  114. package/src/layers/repeat.js +90 -0
  115. package/src/layers/shape.d.ts +46 -0
  116. package/src/layers/shape.js +115 -0
  117. package/src/layers/soft.d.ts +5 -0
  118. package/src/layers/soft.js +43 -0
  119. package/src/layers/svg.d.ts +23 -0
  120. package/src/layers/svg.js +109 -0
  121. package/src/layers/table.d.ts +25 -0
  122. package/src/layers/table.js +80 -0
  123. package/src/layers/tear.d.ts +21 -0
  124. package/src/layers/tear.js +74 -0
  125. package/src/layers/texture.d.ts +17 -0
  126. package/src/layers/texture.js +106 -0
  127. package/src/layers/util.d.ts +8 -0
  128. package/src/layers/util.js +48 -0
  129. package/src/layers.js +125 -41
  130. package/src/lint/image-size.d.ts +4 -0
  131. package/src/lint/image-size.js +34 -0
  132. package/src/lint/index.d.ts +16 -0
  133. package/src/lint/index.js +127 -0
  134. package/src/lint/rules.d.ts +5 -0
  135. package/src/lint/rules.js +19 -0
  136. package/src/node.d.ts +2 -0
  137. package/src/node.js +20 -0
  138. package/src/platform/bytes.d.ts +22 -0
  139. package/src/platform/bytes.js +103 -0
  140. package/src/platform/compress.d.ts +12 -0
  141. package/src/platform/compress.js +81 -0
  142. package/src/platform/harfbuzz.d.ts +9 -0
  143. package/src/platform/harfbuzz.js +80 -0
  144. package/src/platform/index.d.ts +8 -0
  145. package/src/platform/index.js +13 -0
  146. package/src/platform/load.d.ts +6 -0
  147. package/src/platform/load.js +72 -0
  148. package/src/platform/no-harfbuzz.d.ts +2 -0
  149. package/src/platform/no-harfbuzz.js +11 -0
  150. package/src/platform/node.d.ts +26 -0
  151. package/src/platform/node.js +86 -0
  152. package/src/platform/settings.d.ts +28 -0
  153. package/src/platform/settings.js +51 -0
  154. package/src/png.js +77 -28
  155. package/src/print.js +8 -7
  156. package/src/raster/index.d.ts +24 -0
  157. package/src/raster/index.js +171 -0
  158. package/src/recipes.js +4 -3
  159. package/src/render/backdrop.d.ts +6 -0
  160. package/src/render/backdrop.js +55 -0
  161. package/src/render/content.d.ts +5 -0
  162. package/src/render/content.js +58 -0
  163. package/src/render/context.d.ts +82 -0
  164. package/src/render/grain.d.ts +7 -0
  165. package/src/render/grain.js +113 -0
  166. package/src/render/group.d.ts +23 -0
  167. package/src/render/group.js +67 -0
  168. package/src/render/key.d.ts +5 -0
  169. package/src/render/key.js +91 -0
  170. package/src/render/report.d.ts +5 -0
  171. package/src/render/report.js +19 -0
  172. package/src/render/tiles.d.ts +6 -0
  173. package/src/render/tiles.js +77 -0
  174. package/src/render/use.d.ts +27 -0
  175. package/src/render/use.js +73 -0
  176. package/src/render/wrap.d.ts +14 -0
  177. package/src/render/wrap.js +199 -0
  178. package/src/render.js +303 -212
  179. package/src/text.d.ts +29 -0
  180. package/src/text.js +22 -15
  181. package/src/type/auto-color.d.ts +9 -0
  182. package/src/type/auto-color.js +44 -0
  183. package/src/type/fallback.d.ts +11 -0
  184. package/src/type/fallback.js +92 -0
  185. package/src/type/glyphs.d.ts +24 -0
  186. package/src/type/glyphs.js +87 -0
  187. package/src/type/index.d.ts +5 -0
  188. package/src/type/index.js +10 -0
  189. package/src/type/kern.d.ts +4 -0
  190. package/src/type/kern.js +144 -0
  191. package/src/type/layout-ref.d.ts +10 -0
  192. package/src/type/layout-ref.js +74 -0
  193. package/src/type/line.d.ts +11 -0
  194. package/src/type/line.js +105 -0
  195. package/src/type/live.d.ts +6 -0
  196. package/src/type/live.js +39 -0
  197. package/src/type/name.d.ts +4 -0
  198. package/src/type/name.js +32 -0
  199. package/src/type/path.d.ts +18 -0
  200. package/src/type/path.js +89 -0
  201. package/src/type/pixel.d.ts +9 -0
  202. package/src/type/pixel.js +115 -0
  203. package/src/type/sfnt.d.ts +20 -0
  204. package/src/type/sfnt.js +258 -0
  205. package/src/type/spans.d.ts +33 -0
  206. package/src/type/spans.js +138 -0
  207. package/src/type/symbols.d.ts +7 -0
  208. package/src/type/symbols.js +89 -0
  209. package/src/type/typeset.d.ts +10 -0
  210. package/src/type/typeset.js +352 -0
  211. package/src/type/woff2.d.ts +6 -0
  212. package/src/type/woff2.js +301 -0
  213. package/src/version.js +4 -0
package/src/png.js CHANGED
@@ -6,7 +6,8 @@
6
6
  * @module png
7
7
  */
8
8
 
9
- import { deflateSync } from 'node:zlib';
9
+ import { deflateSync, deflate } from './platform/index.js';
10
+ import { concat, toBase64 } from './platform/bytes.js';
10
11
 
11
12
  /** CRC-32 table (PNG's polynomial). */
12
13
  const CRC = (() => {
@@ -26,52 +27,100 @@ function crc32(bytes) {
26
27
  return (c ^ 0xffffffff) >>> 0;
27
28
  }
28
29
 
29
- /** @param {string} type @param {Uint8Array} data @returns {Buffer} */
30
+ /** @param {string} type @param {Uint8Array} data @returns {Uint8Array} */
30
31
  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);
32
+ const out = new Uint8Array(12 + data.length);
33
+ const v = new DataView(out.buffer);
34
+ v.setUint32(0, data.length);
35
+ for (let i = 0; i < 4; i++) out[4 + i] = type.charCodeAt(i);
36
+ out.set(data, 8);
37
+ v.setUint32(8 + data.length, crc32(out.subarray(4, 8 + data.length)));
36
38
  return out;
37
39
  }
38
40
 
39
41
  /**
40
- * Encode pixels as a PNG.
42
+ * The filtered scanlines and header of a PNG.
43
+ *
44
+ * @param {Uint8Array} pixels @param {number} width @param {number} height @param {1 | 3 | 4} channels
45
+ * @returns {{ raw: Uint8Array, ihdr: Uint8Array }}
46
+ */
47
+ function scanlines(pixels, width, height, channels) {
48
+ const stride = width * channels;
49
+ const raw = new Uint8Array((stride + 1) * height);
50
+ for (let y = 0; y < height; y++) {
51
+ raw[y * (stride + 1)] = 0;
52
+ raw.set(pixels.subarray(y * stride, y * stride + stride), y * (stride + 1) + 1);
53
+ }
54
+ const ihdr = new Uint8Array(13);
55
+ const v = new DataView(ihdr.buffer);
56
+ v.setUint32(0, width);
57
+ v.setUint32(4, height);
58
+ ihdr[8] = 8;
59
+ ihdr[9] = /** @type {Record<number, number>} */ ({ 1: 0, 3: 2, 4: 6 })[channels];
60
+ return { raw, ihdr };
61
+ }
62
+
63
+ const SIGNATURE = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
64
+
65
+ /**
66
+ * zlib "stored" blocks: valid, uncompressed. The synchronous fallback where
67
+ * the platform has no synchronous deflate (browsers, Workers).
68
+ *
69
+ * @param {Uint8Array} data
70
+ * @returns {Uint8Array}
71
+ */
72
+ function stored(data) {
73
+ const parts = [new Uint8Array([0x78, 0x01])];
74
+ for (let i = 0; i < data.length || i === 0; i += 65535) {
75
+ const block = data.subarray(i, i + 65535);
76
+ const last = i + 65535 >= data.length ? 1 : 0;
77
+ parts.push(new Uint8Array([last, block.length & 255, block.length >> 8, ~block.length & 255, (~block.length >> 8) & 255]), block);
78
+ if (last) break;
79
+ }
80
+ let a = 1;
81
+ let b = 0;
82
+ for (const x of data) { a = (a + x) % 65521; b = (b + a) % 65521; }
83
+ const adler = new Uint8Array(4);
84
+ new DataView(adler.buffer).setUint32(0, ((b << 16) | a) >>> 0);
85
+ parts.push(adler);
86
+ return concat(parts);
87
+ }
88
+
89
+ /**
90
+ * Encode pixels as a PNG (synchronously: zlib in Node, stored blocks where
91
+ * there's no synchronous deflate; {@link encodePngAsync} compresses
92
+ * everywhere).
41
93
  *
42
94
  * @param {Uint8Array} pixels - Row-major samples.
43
95
  * @param {number} width
44
96
  * @param {number} height
45
97
  * @param {1 | 3 | 4} [channels=4] - Grey, RGB or RGBA.
46
- * @returns {Buffer} PNG bytes.
98
+ * @returns {Uint8Array} PNG bytes.
47
99
  *
48
100
  * @example
49
101
  * encodePng(new Uint8Array([0, 255]), 2, 1, 1); // a black and a white pixel
50
102
  */
51
103
  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
- ]);
104
+ const { raw, ihdr } = scanlines(pixels, width, height, channels);
105
+ return concat([SIGNATURE, chunk('IHDR', ihdr), chunk('IDAT', deflateSync(raw) ?? stored(raw)), chunk('IEND', new Uint8Array(0))]);
106
+ }
107
+
108
+ /**
109
+ * Encode pixels as a PNG, compressed on every platform (Node's zlib, else
110
+ * `CompressionStream`).
111
+ *
112
+ * @param {Uint8Array} pixels @param {number} width @param {number} height @param {1 | 3 | 4} [channels=4]
113
+ * @returns {Promise<Uint8Array>}
114
+ */
115
+ export async function encodePngAsync(pixels, width, height, channels = 4) {
116
+ const { raw, ihdr } = scanlines(pixels, width, height, channels);
117
+ return concat([SIGNATURE, chunk('IHDR', ihdr), chunk('IDAT', await deflate(raw)), chunk('IEND', new Uint8Array(0))]);
69
118
  }
70
119
 
71
120
  /**
72
121
  * A PNG as a data URI.
73
122
  *
74
- * @param {Buffer} png
123
+ * @param {Uint8Array} png
75
124
  * @returns {string}
76
125
  */
77
- export const pngUri = (png) => `data:image/png;base64,${png.toString('base64')}`;
126
+ export const pngUri = (png) => `data:image/png;base64,${toBase64(png)}`;
package/src/print.js CHANGED
@@ -9,7 +9,7 @@ import { attrs, num, layerAttrs, esc } from './svg.js';
9
9
  import { len, regionPx } from './geometry.js';
10
10
  import { toHex } from './color.js';
11
11
  import { createRng } from './rng.js';
12
- import { encodePng, pngUri } from './png.js';
12
+ import { encodePngAsync, pngUri } from './png.js';
13
13
  import { text as textLayer } from './layers.js';
14
14
 
15
15
  /** Classic risograph inks, by name (`'riso:pink'`). Approximations, not trademarks. */
@@ -126,7 +126,7 @@ export async function ink(layer, ctx) {
126
126
  for (let i = 0; i < px.length; i++) px[i] = rng.next() < hole ? 0 : 255;
127
127
  // Clump the holes a little (riso misses ink in specks, not single pixels).
128
128
  for (let i = 0; i < px.length; i++) if (px[i] === 0 && rng.next() < 0.5) px[(i + 1) % px.length] = 0;
129
- defs += `<pattern${attrs({ id: `${id}t`, patternUnits: 'userSpaceOnUse', width: size, height: size })}><image${attrs({ width: size, height: size, href: pngUri(encodePng(px, size, size, 1)) })}/></pattern>`;
129
+ defs += `<pattern${attrs({ id: `${id}t`, patternUnits: 'userSpaceOnUse', width: size, height: size })}><image${attrs({ width: size, height: size, href: pngUri(await encodePngAsync(px, size, size, 1)) })}/></pattern>`;
130
130
  defs += `<mask${attrs({ id: `${id}tm`, maskUnits: 'userSpaceOnUse', ...bleed })}><rect${attrs({ ...bleed, fill: `url(#${id}t)` })}/></mask>`;
131
131
  inner = `<g mask="url(#${id}tm)">${inner}</g>`;
132
132
  } else if (texture === 'halftone') {
@@ -153,7 +153,7 @@ export async function ink(layer, ctx) {
153
153
  px[y * T + x] = Math.round((hit / 4) * 255);
154
154
  }
155
155
  }
156
- defs += `<pattern${attrs({ id: `${id}t`, patternUnits: 'userSpaceOnUse', width: T, height: T })}><image${attrs({ width: T, height: T, href: pngUri(encodePng(px, T, T, 1)) })}/></pattern>`;
156
+ defs += `<pattern${attrs({ id: `${id}t`, patternUnits: 'userSpaceOnUse', width: T, height: T })}><image${attrs({ width: T, height: T, href: pngUri(await encodePngAsync(px, T, T, 1)) })}/></pattern>`;
157
157
  defs += `<mask${attrs({ id: `${id}tm`, maskUnits: 'userSpaceOnUse', ...bleed })}><rect${attrs({ ...bleed, fill: `url(#${id}t)` })}/></mask>`;
158
158
  inner = `<g mask="url(#${id}tm)">${inner}</g>`;
159
159
  }
@@ -242,9 +242,9 @@ const BAYER8 = (() => {
242
242
  *
243
243
  * @param {import('./index.js').DitherLayer} layer
244
244
  * @param {import('./layers.js').RenderContext} ctx
245
- * @returns {import('./layers.js').Markup}
245
+ * @returns {Promise<import('./layers.js').Markup>}
246
246
  */
247
- export function dither(layer, ctx) {
247
+ export async function dither(layer, ctx) {
248
248
  const { width: w, height: h, rng, palette: p } = ctx;
249
249
  const cell = Math.max(1, Math.round(len(layer.cell, ctx.canvas, '0.9vmin')));
250
250
  const cw = Math.ceil(w / cell);
@@ -309,7 +309,7 @@ export function dither(layer, ctx) {
309
309
  px[i] = c[0]; px[i + 1] = c[1]; px[i + 2] = c[2];
310
310
  }
311
311
  }
312
- const body = `<image${attrs({ x: 0, y: 0, width: cw * cell, height: chh * cell, preserveAspectRatio: 'none', 'image-rendering': 'optimizeSpeed', style: 'image-rendering:pixelated', href: pngUri(encodePng(px, cw, chh, 3)) })}/>`;
312
+ const body = `<image${attrs({ x: 0, y: 0, width: cw * cell, height: chh * cell, preserveAspectRatio: 'none', 'image-rendering': 'optimizeSpeed', style: 'image-rendering:pixelated', href: pngUri(await encodePngAsync(px, cw, chh, 3)) })}/>`;
313
313
  return { defs: '', body: wrap(layer, body) };
314
314
  }
315
315
 
@@ -515,7 +515,8 @@ export async function waveform(layer, ctx) {
515
515
  if (!peaks && layer.audio) {
516
516
  let gen;
517
517
  try {
518
- gen = await import('@arraypress/waveform-gen');
518
+ const name = '@arraypress/waveform-gen';
519
+ gen = await import(/* @vite-ignore */ name);
519
520
  } catch {
520
521
  throw new Error('Decoding audio needs @arraypress/waveform-gen: npm install @arraypress/waveform-gen (or pass peaks)');
521
522
  }
@@ -0,0 +1,24 @@
1
+ // Rasterising in the browser.
2
+
3
+ /** Browser raster options. */
4
+ export interface RasterizeOptions {
5
+ /** Default `'png'`. */
6
+ format?: 'png' | 'jpeg' | 'webp';
7
+ /** 0–1 (JPEG, WebP). Default 0.9. */
8
+ quality?: number;
9
+ /** Output width or height, px (the other follows), or a `scale`. */
10
+ width?: number;
11
+ height?: number;
12
+ scale?: number;
13
+ /** A colour under the art (JPEG defaults to the page colour). */
14
+ background?: string;
15
+ }
16
+
17
+ /** Rasterise an SVG string in a browser page. */
18
+ export function rasterize(svg: string, options?: RasterizeOptions): Promise<Blob>;
19
+ /** RGBA pixels of an SVG at a size: sharp in Node, a canvas in a page, undefined elsewhere. */
20
+ export function pixelsOf(svg: string, w: number, h: number, sharp?: unknown): Promise<Uint8Array | undefined>;
21
+ /** Encode RGBA pixels for embedding: JPEG where opaque (unless `lossless`), else PNG. */
22
+ export function encodePixels(data: Uint8Array | Uint8ClampedArray, w: number, h: number, o?: { opaque?: boolean; lossless?: boolean; quality?: number }, sharp?: unknown): Promise<{ mime: 'image/jpeg' | 'image/png'; bytes: Uint8Array }>;
23
+ /** Pixels through a real JPEG encoder and back; undefined where none is available. */
24
+ export function jpegRoundTrip(data: Uint8ClampedArray, w: number, h: number, quality: number, subsample: boolean, sharp?: unknown): Promise<Uint8ClampedArray | undefined>;
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Rasterising without sharp: an SVG drawn into a canvas in the browser,
3
+ * encoded as PNG, JPEG or WebP. `renderImage()` stays the Node path (sharp,
4
+ * with its grain and padded-blur handling); this is the browser one.
5
+ *
6
+ * The SVG is decoded by the browser as an `<img>` (so it needs a page:
7
+ * `document` and `Image`), drawn onto an `OffscreenCanvas` when there is
8
+ * one (else a `<canvas>`), and encoded with `convertToBlob` / `toBlob`.
9
+ *
10
+ * @module raster
11
+ */
12
+
13
+ const MIMES = /** @type {const} */ ({ png: 'image/png', jpeg: 'image/jpeg', webp: 'image/webp' });
14
+
15
+ /**
16
+ * Decode an SVG string into a drawable image (browser).
17
+ *
18
+ * @param {string} svg
19
+ * @returns {Promise<HTMLImageElement>}
20
+ */
21
+ async function decodeSvg(svg) {
22
+ const g = /** @type {any} */ (globalThis);
23
+ if (typeof g.Image !== 'function' || typeof g.document === 'undefined') {
24
+ throw new Error('rasterize() needs a browser page (Image and canvas). In Node use renderImage(); in a Worker return the SVG, or rasterise it with a wasm renderer.');
25
+ }
26
+ const url = URL.createObjectURL(new Blob([svg], { type: 'image/svg+xml' }));
27
+ try {
28
+ const img = new g.Image();
29
+ img.decoding = 'sync';
30
+ img.src = url;
31
+ await img.decode();
32
+ return img;
33
+ } finally {
34
+ // Revoked after decode: the decoded image keeps its pixels.
35
+ setTimeout(() => URL.revokeObjectURL(url), 0);
36
+ }
37
+ }
38
+
39
+ /**
40
+ * A 2D canvas of a size.
41
+ *
42
+ * @param {number} w @param {number} h
43
+ * @returns {{ canvas: any, ctx: CanvasRenderingContext2D }}
44
+ */
45
+ function canvasOf(w, h) {
46
+ const g = /** @type {any} */ (globalThis);
47
+ const canvas = typeof g.OffscreenCanvas === 'function' ? new g.OffscreenCanvas(w, h) : Object.assign(g.document.createElement('canvas'), { width: w, height: h });
48
+ return { canvas, ctx: canvas.getContext('2d') };
49
+ }
50
+
51
+ /**
52
+ * Rasterise an SVG in the browser.
53
+ *
54
+ * @param {string} svg - An SVG document (from `renderSvg`).
55
+ * @param {{ format?: 'png' | 'jpeg' | 'webp', quality?: number, width?: number, height?: number, scale?: number, background?: string }} [options]
56
+ * @returns {Promise<Blob>}
57
+ *
58
+ * @example
59
+ * const blob = await rasterize(await renderSvg(cover), { format: 'webp', quality: 0.9 });
60
+ * img.src = URL.createObjectURL(blob);
61
+ */
62
+ export async function rasterize(svg, options = {}) {
63
+ const format = options.format ?? 'png';
64
+ if (!Object.hasOwn(MIMES, format)) throw new TypeError(`Unknown format "${format}" (png, jpeg, webp)`);
65
+ const vw = Number(/\bwidth="([\d.]+)"/.exec(svg)?.[1] ?? 1200);
66
+ const vh = Number(/\bheight="([\d.]+)"/.exec(svg)?.[1] ?? 1200);
67
+ const scale = options.scale ?? (options.width ? options.width / vw : options.height ? options.height / vh : 1);
68
+ const w = Math.max(1, Math.round(vw * scale));
69
+ const h = Math.max(1, Math.round(vh * scale));
70
+ const img = await decodeSvg(svg);
71
+ const { canvas, ctx } = canvasOf(w, h);
72
+ if (format === 'jpeg' || options.background) {
73
+ ctx.fillStyle = options.background ?? /fill="(#[0-9a-f]{6})"/i.exec(svg)?.[1] ?? '#ffffff';
74
+ ctx.fillRect(0, 0, w, h);
75
+ }
76
+ ctx.drawImage(img, 0, 0, w, h);
77
+ const type = MIMES[format];
78
+ const quality = options.quality ?? (format === 'png' ? undefined : 0.9);
79
+ if (typeof canvas.convertToBlob === 'function') return canvas.convertToBlob({ type, quality });
80
+ return new Promise((resolve, reject) => canvas.toBlob((/** @type {Blob | null} */ b) => (b ? resolve(b) : reject(new Error('Canvas encoding failed'))), type, quality));
81
+ }
82
+
83
+ /**
84
+ * RGBA pixels of an SVG at a size: sharp in Node, a canvas in a page.
85
+ * Undefined where neither is available (a Worker).
86
+ *
87
+ * @param {string} svg
88
+ * @param {number} w
89
+ * @param {number} h
90
+ * @param {any} [sharp] - The sharp module, when loaded.
91
+ * @returns {Promise<Uint8Array | undefined>}
92
+ */
93
+ export async function pixelsOf(svg, w, h, sharp) {
94
+ if (sharp) {
95
+ const out = await sharp(new TextEncoder().encode(svg)).resize(w, h, { fit: 'fill' }).ensureAlpha().raw().toBuffer();
96
+ return new Uint8Array(out.buffer, out.byteOffset, out.byteLength);
97
+ }
98
+ const g = /** @type {any} */ (globalThis);
99
+ if (typeof g.Image !== 'function' || typeof g.document === 'undefined') return undefined;
100
+ const img = await decodeSvg(svg);
101
+ const { ctx } = canvasOf(w, h);
102
+ ctx.drawImage(img, 0, 0, w, h);
103
+ return new Uint8Array(ctx.getImageData(0, 0, w, h).data.buffer);
104
+ }
105
+
106
+ /**
107
+ * Encode RGBA pixels for embedding: JPEG where they're opaque (smaller),
108
+ * PNG where they aren't or where `lossless` (flat colour, crisp cells).
109
+ * sharp in Node, a canvas in a page, else the in-library PNG encoder.
110
+ *
111
+ * @param {Uint8Array | Uint8ClampedArray} data - RGBA, unpremultiplied.
112
+ * @param {number} w
113
+ * @param {number} h
114
+ * @param {{ opaque?: boolean, lossless?: boolean, quality?: number }} [o]
115
+ * @param {any} [sharp]
116
+ * @returns {Promise<{ mime: 'image/jpeg' | 'image/png', bytes: Uint8Array }>}
117
+ */
118
+ export async function encodePixels(data, w, h, o = {}, sharp) {
119
+ const jpeg = Boolean(o.opaque) && !o.lossless;
120
+ const quality = o.quality ?? 92;
121
+ if (sharp) {
122
+ let img = sharp(data, { raw: { width: w, height: h, channels: 4 } });
123
+ if (o.opaque) img = img.removeAlpha();
124
+ const out = jpeg ? await img.jpeg({ quality, chromaSubsampling: '4:4:4' }).toBuffer() : await img.png({ compressionLevel: 6 }).toBuffer();
125
+ return { mime: jpeg ? 'image/jpeg' : 'image/png', bytes: new Uint8Array(out.buffer, out.byteOffset, out.byteLength) };
126
+ }
127
+ const g = /** @type {any} */ (globalThis);
128
+ if (typeof g.OffscreenCanvas === 'function' || typeof g.document !== 'undefined') {
129
+ const { canvas, ctx } = canvasOf(w, h);
130
+ ctx.putImageData(new g.ImageData(new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength), w, h), 0, 0);
131
+ const type = jpeg ? 'image/jpeg' : 'image/png';
132
+ const blob = typeof canvas.convertToBlob === 'function'
133
+ ? await canvas.convertToBlob({ type, quality: quality / 100 })
134
+ : await new Promise((resolve, reject) => canvas.toBlob((/** @type {Blob | null} */ b) => (b ? resolve(b) : reject(new Error('Canvas encoding failed'))), type, quality / 100));
135
+ return { mime: type, bytes: new Uint8Array(await blob.arrayBuffer()) };
136
+ }
137
+ const { encodePngAsync } = await import('../png.js');
138
+ return { mime: 'image/png', bytes: await encodePngAsync(new Uint8Array(data.buffer, data.byteOffset, data.byteLength), w, h, 4) };
139
+ }
140
+
141
+ /**
142
+ * Pixels through a real JPEG encoder and back (compression artefacts):
143
+ * libjpeg through sharp in Node, the browser's own encoder in a page.
144
+ * Undefined where neither is available.
145
+ *
146
+ * @param {Uint8ClampedArray} data - RGBA (alpha ignored).
147
+ * @param {number} w
148
+ * @param {number} h
149
+ * @param {number} quality - 1–100.
150
+ * @param {boolean} subsample - 4:2:0 chroma (blocky colour).
151
+ * @param {any} [sharp]
152
+ * @returns {Promise<Uint8ClampedArray | undefined>} RGBA, opaque.
153
+ */
154
+ export async function jpegRoundTrip(data, w, h, quality, subsample, sharp) {
155
+ if (sharp) {
156
+ const jpg = await sharp(data, { raw: { width: w, height: h, channels: 4 } }).removeAlpha().jpeg({ quality, chromaSubsampling: subsample ? '4:2:0' : '4:4:4' }).toBuffer();
157
+ const out = await sharp(jpg).ensureAlpha().raw().toBuffer();
158
+ return new Uint8ClampedArray(out.buffer, out.byteOffset, out.byteLength);
159
+ }
160
+ const g = /** @type {any} */ (globalThis);
161
+ if (typeof g.createImageBitmap !== 'function' || (typeof g.OffscreenCanvas !== 'function' && typeof g.document === 'undefined')) return undefined;
162
+ const { canvas, ctx } = canvasOf(w, h);
163
+ ctx.putImageData(new g.ImageData(new Uint8ClampedArray(data), w, h), 0, 0);
164
+ const blob = typeof canvas.convertToBlob === 'function'
165
+ ? await canvas.convertToBlob({ type: 'image/jpeg', quality: quality / 100 })
166
+ : await new Promise((resolve) => canvas.toBlob(resolve, 'image/jpeg', quality / 100));
167
+ const bitmap = await g.createImageBitmap(blob);
168
+ ctx.clearRect(0, 0, w, h);
169
+ ctx.drawImage(bitmap, 0, 0);
170
+ return new Uint8ClampedArray(ctx.getImageData(0, 0, w, h).data.buffer);
171
+ }
package/src/recipes.js CHANGED
@@ -23,8 +23,8 @@
23
23
  * @module recipes
24
24
  */
25
25
 
26
- import { readFileSync } from 'node:fs';
27
26
  import { defineRecipe } from './json-recipe.js';
27
+ import { BUILTIN_JSON } from './builtins.js';
28
28
 
29
29
  /**
30
30
  * What a recipe receives besides the options.
@@ -51,12 +51,13 @@ export function contentOf(v) {
51
51
  }
52
52
 
53
53
  /** The built-in recipe names (each is `recipes/<name>.json`). */
54
- const BUILT_IN = ['aurora', 'gridTitle', 'iconTile', 'bigType', 'stripes', 'duotone', 'mark', 'diagram', 'cutout', 'riso', 'dither', 'contour', 'waveform', 'bauhaus', 'issue', 'photo'];
54
+ const BUILT_IN = ['aurora', 'gridTitle', 'iconTile', 'bigType', 'stripes', 'duotone', 'mark', 'diagram', 'cutout', 'riso', 'dither', 'contour', 'waveform', 'bauhaus', 'issue', 'photo', 'brand'];
55
55
 
56
56
  /** Every built-in recipe, by name, defined from its JSON document. */
57
57
  export const recipes = Object.freeze(Object.fromEntries(BUILT_IN.map((name) => [
58
58
  name,
59
- defineRecipe(JSON.parse(readFileSync(new URL(`../recipes/${name}.json`, import.meta.url), 'utf8'))),
59
+ // A fresh copy: the documents are shared module data.
60
+ defineRecipe(structuredClone(/** @type {any} */ (BUILTIN_JSON)[name])),
60
61
  ])));
61
62
 
62
63
  /**
@@ -0,0 +1,6 @@
1
+ // The colour under a box, for auto contrast.
2
+ import type { Cover, Layer } from '../index.js';
3
+ import type { Markup } from './context.js';
4
+
5
+ /** A sampler: the average colour (`#rrggbb`) under a box of the parts drawn so far, or undefined where nothing can rasterise. */
6
+ export function backdropSampler(c: Cover, sharp: unknown, documentFor: (c: Cover, parts: Markup[]) => string, loadSharp: (required: boolean) => Promise<unknown>): (box: { x: number; y: number; width: number; height: number }, parts: (Markup & { layer: Layer })[]) => Promise<string | undefined>;
@@ -0,0 +1,55 @@
1
+ /**
2
+ * What's under a box: the layers drawn so far, rendered small and averaged,
3
+ * for `color: 'auto'`.
4
+ *
5
+ * @module render/backdrop
6
+ */
7
+
8
+ import { toHex } from '../color.js';
9
+ import { pixelsOf } from '../raster/index.js';
10
+
11
+ /**
12
+ * The average colour under a box, from the layers drawn so far: rendered
13
+ * small (sharp in Node, a canvas in a page) and averaged. Undefined where
14
+ * nothing can rasterise (a Worker): the text keeps to the palette.
15
+ *
16
+ * @param {import('../index.js').Cover} c
17
+ * @param {any} sharp
18
+ * @param {(c: any, parts: any[]) => string} documentFor - Assembles an SVG from rendered parts.
19
+ * @param {(required: boolean) => Promise<any>} loadSharp
20
+ * @returns {(box: { x: number, y: number, width: number, height: number }, parts: any[]) => Promise<string | undefined>}
21
+ */
22
+ export function backdropSampler(c, sharp, documentFor, loadSharp) {
23
+ return async (box, parts) => {
24
+ if (!parts.length) return undefined;
25
+ const s = Math.min(1, 240 / Math.max(c.width, c.height));
26
+ const w = Math.max(1, Math.round(c.width * s));
27
+ const h = Math.max(1, Math.round(c.height * s));
28
+ let px;
29
+ try {
30
+ px = await pixelsOf(documentFor(/** @type {any} */ ({ ...c, radius: undefined, alt: undefined, print: undefined, overlay: undefined }), parts), w, h, sharp ?? await loadSharp(false));
31
+ } catch {
32
+ px = undefined;
33
+ }
34
+ if (!px) {
35
+ // Analytic fallback: the last full-canvas solid colour.
36
+ const solid = [...parts].reverse().find((p) => p.layer.type === 'solid' && !p.layer.area);
37
+ return solid ? toHex(solid.layer.color ?? c.palette.bg) : undefined;
38
+ }
39
+ const x0 = Math.max(0, Math.floor(box.x * s));
40
+ const y0 = Math.max(0, Math.floor(box.y * s));
41
+ const x1 = Math.min(w, Math.ceil((box.x + box.width) * s));
42
+ const y1 = Math.min(h, Math.ceil((box.y + box.height) * s));
43
+ let r = 0; let g = 0; let b = 0; let n = 0;
44
+ for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) {
45
+ const i = (y * w + x) * 4;
46
+ const a = px[i + 3] / 255;
47
+ // Unpainted pixels read as the page colour.
48
+ const bg = c.palette.bg;
49
+ const br = parseInt(bg.slice(1, 3), 16); const bgc = parseInt(bg.slice(3, 5), 16); const bb = parseInt(bg.slice(5, 7), 16);
50
+ r += px[i] * a + br * (1 - a); g += px[i + 1] * a + bgc * (1 - a); b += px[i + 2] * a + bb * (1 - a); n++;
51
+ }
52
+ if (!n) return undefined;
53
+ return '#' + [r, g, b].map((v) => Math.round(v / n).toString(16).padStart(2, '0')).join('');
54
+ };
55
+ }
@@ -0,0 +1,5 @@
1
+ // Where a layer paints, for content-sized filter regions.
2
+ import type { Markup, RenderContext } from './context.js';
3
+
4
+ /** The box a layer's markup paints, in its units (drawn small with sharp); undefined when it can't be probed. */
5
+ export function contentBox(m: Markup, ctx: RenderContext): Promise<{ x: number; y: number; width: number; height: number } | undefined>;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Where a layer paints: its markup drawn small and the box round every
3
+ * pixel it covers, so filters can work over that box instead of the whole
4
+ * canvas (in `renderImage`, where librsvg's cost is per pixel of region).
5
+ *
6
+ * @module render/content
7
+ */
8
+
9
+ import { num } from '../svg.js';
10
+
11
+ /** The probe's longer side, px. */
12
+ const PROBE = 256;
13
+
14
+ /** Markup carrying more embedded data than this isn't probed (decoding it would cost more than it saves). */
15
+ const MAX_PROBE_BYTES = 400_000;
16
+
17
+ /**
18
+ * The box a layer's markup paints, in its units: drawn at most 256 px
19
+ * across over the canvas and half again round it, one probe pixel of slack
20
+ * on every side. Undefined when it can't be probed (no sharp, heavy
21
+ * embedded images) or paints nothing.
22
+ *
23
+ * @param {{ defs: string, body: string }} m
24
+ * @param {any} ctx
25
+ * @returns {Promise<{ x: number, y: number, width: number, height: number } | undefined>}
26
+ */
27
+ export async function contentBox(m, ctx) {
28
+ if (!ctx.raster || !ctx.sharp || m.body.length + m.defs.length > MAX_PROBE_BYTES) return undefined;
29
+ const ox = -ctx.width / 2;
30
+ const oy = -ctx.height / 2;
31
+ const uw = ctx.width * 2;
32
+ const uh = ctx.height * 2;
33
+ const s = PROBE / Math.max(uw, uh);
34
+ const w = Math.max(1, Math.round(uw * s));
35
+ const h = Math.max(1, Math.round(uh * s));
36
+ const doc = `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="${num(ox, 4)} ${num(oy, 4)} ${num(uw, 4)} ${num(uh, 4)}" preserveAspectRatio="none">${m.defs ? `<defs>${m.defs}</defs>` : ''}${m.body}</svg>`;
37
+ let px;
38
+ try {
39
+ px = await ctx.sharp(new TextEncoder().encode(doc)).ensureAlpha().raw().toBuffer();
40
+ } catch {
41
+ return undefined;
42
+ }
43
+ let x0 = w;
44
+ let y0 = h;
45
+ let x1 = -1;
46
+ let y1 = -1;
47
+ for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
48
+ if (!px[(y * w + x) * 4 + 3]) continue;
49
+ if (x < x0) x0 = x;
50
+ if (x > x1) x1 = x;
51
+ if (y < y0) y0 = y;
52
+ if (y > y1) y1 = y;
53
+ }
54
+ if (x1 < 0) return undefined;
55
+ // Anything touching the probe's edge may run further: the whole region then.
56
+ if (x0 === 0 || y0 === 0 || x1 === w - 1 || y1 === h - 1) return undefined;
57
+ return { x: ox + (x0 - 1) / s, y: oy + (y0 - 1) / s, width: (x1 - x0 + 3) / s, height: (y1 - y0 + 3) / s };
58
+ }
@@ -0,0 +1,82 @@
1
+ // What a layer renderer receives and returns (types only).
2
+ import type { Palette, Rng, FontSource, Layer } from '../index.js';
3
+
4
+ /** A rendered layer: markup for `<defs>` and for the body. */
5
+ export interface Markup {
6
+ defs: string;
7
+ body: string;
8
+ /** Mesh layers: the blur's 3σ padding, for the padded raster path. */
9
+ pad?: number;
10
+ /** Mesh layers: the finest blur, px. */
11
+ finest?: number;
12
+ }
13
+
14
+ /** A text block as drawn, reported to `lint`. */
15
+ export interface DrawnText {
16
+ kind: 'text';
17
+ id?: string;
18
+ content: string;
19
+ /** Type size, canvas px (the first line, stretched). */
20
+ size: number;
21
+ /** The smallest line's capital height, canvas px (stretched, through every group and transform). */
22
+ capHeight: number;
23
+ box: { x: number; y: number; width: number; height: number } | null;
24
+ /** The narrowest horizontal scale a line was set at (justification, stretch). */
25
+ squeeze?: number;
26
+ }
27
+
28
+ /** An image as drawn, reported to `lint`. */
29
+ export interface DrawnImage {
30
+ kind: 'image';
31
+ id?: string;
32
+ /** Its box at the cover's size, canvas px. */
33
+ drawn: { width: number; height: number };
34
+ /** The source's pixels. */
35
+ source?: { width: number; height: number };
36
+ /** The pixels embedded. */
37
+ embedded?: { width: number; height: number };
38
+ fit: 'cover' | 'contain' | 'stretch';
39
+ }
40
+
41
+ /** Everything a layer renderer needs. */
42
+ export interface RenderContext {
43
+ /** Canvas width, px (a group's grid width inside a group). */
44
+ width: number;
45
+ height: number;
46
+ canvas: { width: number; height: number };
47
+ palette: Palette;
48
+ /** This layer's own seeded stream. */
49
+ rng: Rng;
50
+ /** A unique id prefix for this layer's defs. */
51
+ id: string;
52
+ /** Available fonts, Inter last. */
53
+ fonts: FontSource[];
54
+ /** The sharp module, when loaded (Node). */
55
+ sharp?: unknown;
56
+ /** Print: bleed plus safe margin, px (anchored text and icons default their margin to it). */
57
+ inset?: number;
58
+ /** `'live'` draws text as `<text>`. */
59
+ textRender?: 'outline' | 'live';
60
+ /** Collects what was drawn, for `lint`. */
61
+ report?: (entry: DrawnText | DrawnImage) => void;
62
+ /** Canvas px per unit here, the larger axis (images and raster effects resample for it). */
63
+ res?: number;
64
+ /** This context's units to canvas px. */
65
+ matrix?: [number, number, number, number, number, number];
66
+ /** Drawn for `renderImage` (librsvg): effects may take their faster forms. */
67
+ raster?: boolean;
68
+ /** The print format's trim and die-cut, canvas px. */
69
+ cut?: { x: number; y: number; width: number; height: number; cut?: import('../formats/cut.js').Cut; mm: number };
70
+ /** Where text blocks landed, by layer id, for `{layout.<id>.<field>}`. */
71
+ layout?: Map<string, Record<string, number>>;
72
+ /** Named layers (`defs`) and drawn copies, for `use`. */
73
+ defs?: Map<string, unknown>;
74
+ /** Identity of the document being rendered (one `@font-face` per face per document). */
75
+ doc?: object;
76
+ /** The average colour under a box (`color: 'auto'`), top-level layers only. */
77
+ backdrop?: (box: { x: number; y: number; width: number; height: number }) => Promise<string | undefined>;
78
+ /** Render a nested stack (groups, masks). */
79
+ stack?: (layers: Layer[], sub: { tag: string; width?: number; height?: number; scale?: number; res?: number; matrix?: [number, number, number, number, number, number] }) => Promise<(Markup & { layer: Layer })[]>;
80
+ /** A group's scale to canvas px. */
81
+ scale?: number;
82
+ }
@@ -0,0 +1,7 @@
1
+ // Raster grain for renderImage.
2
+ import type { GrainLayer, Rng } from '../index.js';
3
+
4
+ /** Seeded film-grain values at full size (one channel, or three with `monochrome: false`). */
5
+ export function noise(sharp: unknown, width: number, height: number, layer: GrainLayer, rng: Rng): Promise<{ data: Uint8Array; channels: 1 | 3 }>;
6
+ /** Blend grain into RGBA pixels in place. */
7
+ export function applyGrain(pixels: Uint8Array, grain: { data: Uint8Array; channels: 1 | 3; width: number; height: number }, layer: GrainLayer): void;