@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.
- package/CHANGELOG.md +76 -0
- package/README.md +296 -11
- package/package.json +33 -5
- package/recipes/brand.json +1143 -0
- package/schema/recipe.schema.json +7300 -770
- package/src/animate/index.d.ts +19 -0
- package/src/animate/index.js +116 -0
- package/src/builtins.js +4 -0
- package/src/effects/ascii.d.ts +7 -0
- package/src/effects/ascii.js +93 -0
- package/src/effects/buffer.d.ts +60 -0
- package/src/effects/buffer.js +472 -0
- package/src/effects/detail.d.ts +12 -0
- package/src/effects/detail.js +124 -0
- package/src/effects/digital.d.ts +14 -0
- package/src/effects/digital.js +216 -0
- package/src/effects/distort.d.ts +20 -0
- package/src/effects/distort.js +314 -0
- package/src/effects/dither.d.ts +8 -0
- package/src/effects/dither.js +147 -0
- package/src/effects/drop-shadow.d.ts +4 -0
- package/src/effects/drop-shadow.js +45 -0
- package/src/effects/extrude.d.ts +5 -0
- package/src/effects/extrude.js +55 -0
- package/src/effects/film.d.ts +11 -0
- package/src/effects/film.js +204 -0
- package/src/effects/glow.d.ts +8 -0
- package/src/effects/glow.js +56 -0
- package/src/effects/grade.d.ts +30 -0
- package/src/effects/grade.js +274 -0
- package/src/effects/halftone.d.ts +8 -0
- package/src/effects/halftone.js +95 -0
- package/src/effects/index.d.ts +125 -0
- package/src/effects/index.js +277 -0
- package/src/effects/ink.d.ts +12 -0
- package/src/effects/ink.js +70 -0
- package/src/effects/lens.d.ts +14 -0
- package/src/effects/lens.js +277 -0
- package/src/effects/light.d.ts +10 -0
- package/src/effects/light.js +59 -0
- package/src/effects/looks.d.ts +9 -0
- package/src/effects/looks.js +74 -0
- package/src/effects/mask.d.ts +52 -0
- package/src/effects/mask.js +314 -0
- package/src/effects/motion.d.ts +6 -0
- package/src/effects/motion.js +60 -0
- package/src/effects/newsprint.d.ts +6 -0
- package/src/effects/newsprint.js +89 -0
- package/src/effects/optics.d.ts +10 -0
- package/src/effects/optics.js +213 -0
- package/src/effects/options.d.ts +6 -0
- package/src/effects/options.js +106 -0
- package/src/effects/paint-presets.d.ts +10 -0
- package/src/effects/paint-presets.js +47 -0
- package/src/effects/paint.d.ts +49 -0
- package/src/effects/paint.js +66 -0
- package/src/effects/pixels.d.ts +24 -0
- package/src/effects/pixels.js +138 -0
- package/src/effects/polar.d.ts +10 -0
- package/src/effects/polar.js +265 -0
- package/src/effects/region.d.ts +8 -0
- package/src/effects/region.js +136 -0
- package/src/effects/round-border.d.ts +5 -0
- package/src/effects/round-border.js +62 -0
- package/src/effects/screen.d.ts +14 -0
- package/src/effects/screen.js +212 -0
- package/src/effects/sort.d.ts +8 -0
- package/src/effects/sort.js +125 -0
- package/src/effects/symmetry.d.ts +6 -0
- package/src/effects/symmetry.js +74 -0
- package/src/effects/tile.d.ts +7 -0
- package/src/effects/tile.js +55 -0
- package/src/effects/tone.d.ts +16 -0
- package/src/effects/tone.js +117 -0
- package/src/effects/transform.d.ts +35 -0
- package/src/effects/transform.js +127 -0
- package/src/effects/util.d.ts +16 -0
- package/src/effects/util.js +85 -0
- package/src/extract.js +4 -3
- package/src/fonts.d.ts +55 -0
- package/src/fonts.js +246 -53
- package/src/formats/cut.d.ts +10 -0
- package/src/formats/cut.js +77 -0
- package/src/formats/index.d.ts +6 -0
- package/src/formats/index.js +9 -0
- package/src/formats/presets.d.ts +41 -0
- package/src/formats/presets.js +89 -0
- package/src/formats/print.d.ts +25 -0
- package/src/formats/print.js +117 -0
- package/src/formats/zones.d.ts +10 -0
- package/src/formats/zones.js +64 -0
- package/src/geometry.js +1 -1
- package/src/icons.js +43 -9
- package/src/index.d.ts +205 -19
- package/src/index.js +12 -4
- package/src/inter-data.js +27 -0
- package/src/json-recipe.js +181 -23
- package/src/kit/index.d.ts +20 -0
- package/src/kit/index.js +58 -0
- package/src/layer-options.js +23 -4
- package/src/layers/barcode.d.ts +19 -0
- package/src/layers/barcode.js +63 -0
- package/src/layers/code128.d.ts +4 -0
- package/src/layers/code128.js +28 -0
- package/src/layers/expr.d.ts +6 -0
- package/src/layers/expr.js +121 -0
- package/src/layers/geometry.d.ts +6 -0
- package/src/layers/geometry.js +238 -0
- package/src/layers/index.d.ts +18 -0
- package/src/layers/index.js +15 -0
- package/src/layers/perforation.d.ts +14 -0
- package/src/layers/perforation.js +47 -0
- package/src/layers/repeat.d.ts +29 -0
- package/src/layers/repeat.js +90 -0
- package/src/layers/shape.d.ts +46 -0
- package/src/layers/shape.js +115 -0
- package/src/layers/soft.d.ts +5 -0
- package/src/layers/soft.js +43 -0
- package/src/layers/svg.d.ts +23 -0
- package/src/layers/svg.js +109 -0
- package/src/layers/table.d.ts +25 -0
- package/src/layers/table.js +80 -0
- package/src/layers/tear.d.ts +21 -0
- package/src/layers/tear.js +74 -0
- package/src/layers/texture.d.ts +17 -0
- package/src/layers/texture.js +106 -0
- package/src/layers/util.d.ts +8 -0
- package/src/layers/util.js +48 -0
- package/src/layers.js +125 -41
- package/src/lint/image-size.d.ts +4 -0
- package/src/lint/image-size.js +34 -0
- package/src/lint/index.d.ts +16 -0
- package/src/lint/index.js +127 -0
- package/src/lint/rules.d.ts +5 -0
- package/src/lint/rules.js +19 -0
- package/src/node.d.ts +2 -0
- package/src/node.js +20 -0
- package/src/platform/bytes.d.ts +22 -0
- package/src/platform/bytes.js +103 -0
- package/src/platform/compress.d.ts +12 -0
- package/src/platform/compress.js +81 -0
- package/src/platform/harfbuzz.d.ts +9 -0
- package/src/platform/harfbuzz.js +80 -0
- package/src/platform/index.d.ts +8 -0
- package/src/platform/index.js +13 -0
- package/src/platform/load.d.ts +6 -0
- package/src/platform/load.js +72 -0
- package/src/platform/no-harfbuzz.d.ts +2 -0
- package/src/platform/no-harfbuzz.js +11 -0
- package/src/platform/node.d.ts +26 -0
- package/src/platform/node.js +86 -0
- package/src/platform/settings.d.ts +28 -0
- package/src/platform/settings.js +51 -0
- package/src/png.js +77 -28
- package/src/print.js +8 -7
- package/src/raster/index.d.ts +24 -0
- package/src/raster/index.js +171 -0
- package/src/recipes.js +4 -3
- package/src/render/backdrop.d.ts +6 -0
- package/src/render/backdrop.js +55 -0
- package/src/render/content.d.ts +5 -0
- package/src/render/content.js +58 -0
- package/src/render/context.d.ts +82 -0
- package/src/render/grain.d.ts +7 -0
- package/src/render/grain.js +113 -0
- package/src/render/group.d.ts +23 -0
- package/src/render/group.js +67 -0
- package/src/render/key.d.ts +5 -0
- package/src/render/key.js +91 -0
- package/src/render/report.d.ts +5 -0
- package/src/render/report.js +19 -0
- package/src/render/tiles.d.ts +6 -0
- package/src/render/tiles.js +77 -0
- package/src/render/use.d.ts +27 -0
- package/src/render/use.js +73 -0
- package/src/render/wrap.d.ts +14 -0
- package/src/render/wrap.js +199 -0
- package/src/render.js +303 -212
- package/src/text.d.ts +29 -0
- package/src/text.js +22 -15
- package/src/type/auto-color.d.ts +9 -0
- package/src/type/auto-color.js +44 -0
- package/src/type/fallback.d.ts +11 -0
- package/src/type/fallback.js +92 -0
- package/src/type/glyphs.d.ts +24 -0
- package/src/type/glyphs.js +87 -0
- package/src/type/index.d.ts +5 -0
- package/src/type/index.js +10 -0
- package/src/type/kern.d.ts +4 -0
- package/src/type/kern.js +144 -0
- package/src/type/layout-ref.d.ts +10 -0
- package/src/type/layout-ref.js +74 -0
- package/src/type/line.d.ts +11 -0
- package/src/type/line.js +105 -0
- package/src/type/live.d.ts +6 -0
- package/src/type/live.js +39 -0
- package/src/type/name.d.ts +4 -0
- package/src/type/name.js +32 -0
- package/src/type/path.d.ts +18 -0
- package/src/type/path.js +89 -0
- package/src/type/pixel.d.ts +9 -0
- package/src/type/pixel.js +115 -0
- package/src/type/sfnt.d.ts +20 -0
- package/src/type/sfnt.js +258 -0
- package/src/type/spans.d.ts +33 -0
- package/src/type/spans.js +138 -0
- package/src/type/symbols.d.ts +7 -0
- package/src/type/symbols.js +89 -0
- package/src/type/typeset.d.ts +10 -0
- package/src/type/typeset.js +352 -0
- package/src/type/woff2.d.ts +6 -0
- package/src/type/woff2.js +301 -0
- package/src/version.js +4 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,82 @@ All notable changes to `@arraypress/covers` are documented here.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.1.0] — 2026-11-05
|
|
9
|
+
|
|
10
|
+
Flyers, posters, release art, social formats and print stationery, in Node, browsers and Cloudflare Workers. Every 1.0 cover renders byte-identical SVG (694 covers checked against hashes taken with the 1.0.0 code).
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Runs anywhere.** No module imports `node:` any more: Node's built-ins are found with `process.getBuiltinModule` (or installed by the new `@arraypress/covers/node` entry), Inter and the built-in recipes are plain modules, PNG and WOFF use `CompressionStream` / `DecompressionStream` outside Node, and cache keys use Web Crypto (same keys). `configure({ load, network, fetch, harfbuzz, shaper, icons, fonts })`: a loader hook for images, fonts, audio and icon sets; URLs fetched in browsers and Workers (in Node only when allowed, as before); HarfBuzz's wasm from a URL, bytes, a `WebAssembly.Module` or a `Response`.
|
|
15
|
+
- **A JavaScript TrueType outliner** (`cmap`, `hmtx`, `glyf` with composites, GPOS and `kern` pair kerning) for Cloudflare Workers, where harfbuzzjs can't run (it compiles wasm at runtime). `#harfbuzz` resolves to a stub under the `workerd` / `worker` conditions so wrangler never bundles it. Byte-identical to HarfBuzz on all 694 test covers.
|
|
16
|
+
- **`renderBlob()` / `rasterize()`**: PNG, JPEG or WebP in the browser through a canvas.
|
|
17
|
+
- **Fonts by `src`** (path, URL, loader) as well as bytes; `registerFont()`; variable fonts (`weight: [100, 900]`, per-layer `axes`).
|
|
18
|
+
- **Poster type** on the `text` layer: baseline placement (`x`, `y`), justification to a width (`width`, `lengthAdjust`), `fit: 'width' | 'lines' | 'shrink'`, `stretch`, `axes`, outline type and stacked keylines (`fill`, `stroke`, `strokes`), text on a path (circle, arc, ellipse, wave, any `d`; `fitPath`), `color: 'auto'` (white or near-black against what's drawn underneath), and `render: 'live'` (`<text>` with `@font-face`).
|
|
19
|
+
- **Every layer** takes `transform`, `effects`, `knockout`, `clip`, `mask` and `area`.
|
|
20
|
+
- **Effects**: `blur`, `motion` (directional), `shadow`, `glow`, `border` (die-cut), `wear` (riso), `rough`, `warp`, `xerox`, `photocopy`, `duotone`, `grayscale`, `levels`, `saturate`, `matrix`, `grit`, `halftone` (any screen angle), `dither` (Bayer), `threshold`, and a raw `filter` escape hatch.
|
|
21
|
+
- **Eight layers**: `group` (a nested stack on its own design grid), `shape` (fifteen exact shapes, deckled and torn edges, gradient paints including reflected foil), `svg` (raw markup with `{{slot}}` and palette tokens, sanitised), `table` (meta grids, lineups, tracklists), `barcode` (real Code 128), `perforation`, `tear` (torn paper with fibres) and `texture` (paper, creases, concrete, speck, wood, folds, dust). `icon` takes a mark's `markup` and `x`/`y`; `image` takes an explicit box and `fit: 'stretch'`.
|
|
22
|
+
- **Formats** (`format`, `FORMATS`): release cover 3000×3000, Spotify Canvas, Bandcamp header, Instagram post/square/story/reel, TikTok cover, YouTube thumbnail and channel art, X, LinkedIn, Facebook, Twitch, Discord and Bluesky banners, Pinterest, avatars, Open Graph, a 4:5 flyer, A6–A2, business cards (85×55 mm and 3.5×2 in), letterhead (A4, US Letter), compliment slips, stickers, tickets, badges and lanyard passes. Screen formats carry their **safe zones** (sourced; anchored text and icons are laid out inside them; `@safeX`… tokens; `overlay: true` previews them). Print formats render at a `dpi` with **bleed**, a **safe** margin, optional **crop marks** and a **die line**.
|
|
23
|
+
- **Two-sided pieces**: `pages` in a recipe, `when: { page: 'back' }`, `renderPages()`.
|
|
24
|
+
- **`kit(brand, { formats, recipe })`**: a brand kit in one call, and a built-in **`brand`** recipe that lays a brand out on every format.
|
|
25
|
+
- **`lint(cover, { target: 'spotify' })`**: Spotify's artwork requirements and distributors' content rules (URLs, handles, contact details, prices, promo wording, dates, platform names, barcodes, metadata), text too small for a 64 px thumbnail, upscaled images.
|
|
26
|
+
- **Animation, designed in**: any option can be keyframed against `t`, and `renderSvg(cover, { t })` draws that frame (static covers ignore it); `loop`, `valueAt()`. Video encoding is a later phase.
|
|
27
|
+
- **JSON recipes**: brand and event slots (`{name}`, `{mark}`, `{photo}`, `{cutout}`, `{lineup.0}`…), `join` / `upper` / `lower` / `slice`, `format` and `page` conditions, print and safe-zone tokens, nested layers validated.
|
|
28
|
+
- Tests: byte identity with 1.0, a headless-Chrome smoke test, and an opt-in Workers test (`npm run test:worker`). `examples/recipes/` re-expresses ~45 flyers and 16 release-cover styles as JSON.
|
|
29
|
+
|
|
30
|
+
- **Repeat and reuse.** `repeat` draws a layer on a grid, round a ring or along a line, with `{#…}` expressions per instance (`i`, `row`, `col`, `odd`, `angle`, …; a small parser, no `eval`), `odd` and `cycle` overrides and `skip`. `defs` names layers and `use` draws them again, placed (one drawing, referenced), or with `with` overrides.
|
|
31
|
+
- **Type.** `spans` (mixed runs on one line: font, weight, size, colour, baseline shift), `glyphs` (per-glyph scale, shift, turn and colour arrays: bulging 70s type is one layer, and lint counts it as one text), `font: 'pixel'` (a built-in 5×7 face as merged cells), glyph fallback (`fallback`, fonts registered with `fallback: true`, then built-in ✺ ✦ ✶ ★ ☆ ♥ ♡ ▶ ◀ ● ◆ ■ ♪), `fit: 'words'` and `'wrap'`, `split: 'words'`, `maxHeight`, `dash` on strokes, `extrude`, and `{layout.<id>.bottom + 12}` so a later layer sits under a wrapped block.
|
|
32
|
+
- **Paints** for shapes, text and icons: elliptical radial gradients (`rx`, `ry`) with per-stop opacities, `tile` patterns (checker, stripes, dots, grid, custom), and presets `chrome`, `steel` and `gold` (a reflected horizon with its keyline, how good Y2K chrome is painted). `icon` takes `fill`.
|
|
33
|
+
- **Shapes**: `rotate` about an `origin`, open arches (`open`), `soft` (a blur drawn padded: no edge fade, no square edge), `dashOffset`, and `kind: 'cut'` (the print format's die-cut, `outset`).
|
|
34
|
+
- **Effects**: `extrude` (an n-step offset in a darker colour, replacing stacks of copies), `border` with `shape: 'circle'` (a round sticker backing), and `colorSpace: 'sRGB' | 'linearRGB'` on every filter effect.
|
|
35
|
+
- **Print cuts**: `cut` takes `{ radius }` (mm) or `{ d, viewBox }`; tickets and lanyard passes have rounded corners; the die line follows it.
|
|
36
|
+
- **Fonts**: WOFF2 is outlined (Brotli from Node's zlib or a browser's `DecompressionStream('brotli')`; glyf, loca and hmtx rebuilt in JavaScript, outlines identical to the TTF builds); recipes declare their fonts (`"fonts": [{ name, src, weight }]`, paths beside the recipe); `scripts/woff2-to-ttf.mjs` for runtimes without Brotli.
|
|
37
|
+
- **`svg` slots** take markup: `{{mark}}` between elements goes in as sanitised shapes (paths and basic shapes, geometry and paint attributes); anything else is still escaped.
|
|
38
|
+
|
|
39
|
+
- **Image filters: 50 new effects**, on any layer or group, each data-first (validated by `defineRecipe`, seedable where random). Filters stay SVG (vector, sized in the layer's units); what filters can't do is computed on pixels: the layer is drawn once at the pixels it's shown at (sharp in Node, tiled past a few megapixels; a canvas in the browser, so `renderBlob` and `rasterize` get them too), processed on a typed array and embedded as one image. One module per family in `src/effects/`, with shared pixel helpers (`buffer.js`) and the runner (`pixels.js`).
|
|
40
|
+
- **Blurs** (pixels): `zoom` (radial), `spin` (rotational: both resampled into polar coordinates and blurred with running sums, so a long blur costs no more than a short one; the centre pixel never moves), `tilt` (tilt-shift: a sharp band, the blur growing beyond it, `saturate` for the miniature look), `lens` (lens blur in linear light with highlights boosted into bokeh discs or `blades`-sided polygons, the aperture convolved a row at a time from running sums).
|
|
41
|
+
- **`mask` on every effect**: `{ gradient: 'linear' | 'radial' | 'band', from, to, center, angle, aspect, invert, ease }` or `{ layers }` (their luminance). An exact mix of before and after: inside the filter (a small greyscale mask image), or per pixel. "The left half motion-blurred, the face sharp" is one effect.
|
|
42
|
+
- **Distortions** (pixels): `wave`, `ripple`, `twirl`, `bulge`, `pinch`, `fisheye`, `glass` (`frosted`, `reeded` (fluted), `pebbled`), `displace` (seeded noise, or the luminance of any layers: a texture, a photo, type), `kaleidoscope` (the frame filled by reflection), `stretch` (pixel stretch), `sort` (pixel sorting by luminance, hue or saturation). `mirror` (vector copies).
|
|
43
|
+
- **Colour** (filters): `curves` (monotone cubic, per channel), `gradientMap` (any stops, sRGB or OKLab), `posterize`, `invert`, `hue` (hue, saturation, lightness), `colorBalance` (shadows, midtones, highlights), `channelMixer`, `sepia`, `look` presets (`fade`, `crossProcess`, `bleachBypass`, `tealOrange`, `warmFilm`, `coolFilm`, `noir`, with `amount`); `vibrance` on pixels. `levels` takes Photoshop's `black`, `white`, `gamma` and `output`.
|
|
44
|
+
- **Light**: `bloom`, `halation`, `vignette` (`ellipse`, `circle`, `rect`; size, softness, colour), `lightLeak`, `lensFlare` (glow, halo, ghosts, optional anamorphic streak: drawn from small greyscale sprites the filter scales and tints), `rays` (god rays on pixels: occluders cast shafts).
|
|
45
|
+
- **Texture and print**: `grain` (film grain overlaid, monochrome or colour), `paper` (`paper`, `canvas`), `scanlines`; on pixels `dust` (specks, hairs, scratches), `crt` (aperture or shadow mask, scanlines, curvature), `vhs` (chroma smear and shift, line jitter, tracking noise), `jpeg` (a real JPEG encoder, blocks at any size, re-saves), `newsprint` (CMYK process halftone with rosettes). `halftone` takes a `shape`: `dot`, `line`, `square`, `diamond`, `cross`.
|
|
46
|
+
- **Digital**: `chromatic` / `rgbSplit` (directional as a filter; `mode: 'radial'` on pixels), `glitch` (channel shift, torn slices, displaced blocks), `pixelate` / `mosaic` (`square` cells embedded at one pixel a cell; `hex`), `ascii` (the built-in pixel face's glyphs by ink, in the source's colours or one ink), `edges` (`ink` or `glow`), `sketch` (pencil), `emboss`, `sharpen` (unsharp mask).
|
|
47
|
+
- `EFFECT_OPTIONS` lists every kind's options (validation, the JSON Schema's autocomplete, the README table). Where nothing can draw pixels (a Worker) `lens` falls back to a Gaussian blur, `tilt` to one through its band, and the other pixel effects are left out.
|
|
48
|
+
|
|
49
|
+
### Fixed
|
|
50
|
+
|
|
51
|
+
- **`motion` blurred in every direction at any angle but 0 in `renderImage`**: librsvg can't apply a one-axis `feGaussianBlur` under a rotation. `renderImage` now draws motion blur on pixels (in a frame turned to the angle, with the edge held, so a full-bleed photo no longer fades at its edges); `renderSvg` keeps the filter, which browsers draw correctly.
|
|
52
|
+
- **WOFF2 fonts with a large `glyf` lost glyphs past 128 KB** (Doto drew no digits and no "/"): the decoder rebuilds `glyf` plainly, a word a coordinate, so it can outgrow the font's short `loca`, whose offsets then wrapped. It now writes a long `loca` (and says so in `head`) when the table needs one; decoded glyphs, metrics and cmap match fontTools on both Doto builds. A generated WOFF2 with composite digits past the limit is in the tests.
|
|
53
|
+
- **`fit: 'words'` (and `'width'`, `'wrap'`) with a `width` stretched every line to the full width** (a lone "&" became a bar). Under those fits only the widest line fills the width, as documented; the others keep their natural width. `width` without a fit still justifies every line.
|
|
54
|
+
- **Images in a `viewBox` group were resampled at design-unit size** (a 400-wide grid embedded a photo 400 px across, however large the output): images now resample at the pixels they're drawn at, through every group and transform scale. They're embedded at no more than the source's pixels (the SVG scales up), and as JPEG when an alpha channel is unused, so two full-bleed photos no longer overflow librsvg's parser at 3000 px. (A top-level image under 4 MP keeps 1.0's box-sized embed, so 1.0 covers stay byte-identical.)
|
|
55
|
+
- Text on the poster typesetter dropped its `opacity` and `blend`.
|
|
56
|
+
- The `filter` sanitiser rejected `specularExponent`, `diffuseConstant` and `exponent` (it matched `on…=` inside names); it now matches attribute names that start with `on`.
|
|
57
|
+
- `feDropShadow` ignored its `in` in librsvg (a felt texture vanished under its own shadow): it's spelled out as primitives.
|
|
58
|
+
- A dither with a transform on the same layer rendered as a solid colour in `renderImage`.
|
|
59
|
+
- A group's own effects, clips, masks and knockouts used the parent's coordinates: with a `viewBox` they now work in the group's grid, so their numbers (noise frequency, wobble) look the same at every output size.
|
|
60
|
+
- `shape` `path`s with a `viewBox` rotated about the wrong point.
|
|
61
|
+
- Recipe validation now checks anchor names (`'bottom-center'` failed only at render).
|
|
62
|
+
- `color: 'auto'` picked black on mid-tones (a mid-pink photo) where designers set white: it now uses the site's rule, luma above 0.72 takes the dark colour (`autoThreshold`).
|
|
63
|
+
- `lint`: no more false positives for store names that are words ("Tidal Archive": only with a cue or on their own), day names in titles ("Second Sun": short names only beside a number), or text spelled from the metadata a letter or a knocked-out word at a time. Legibility measures the whole block as drawn (every line, stretch, justification, group and transform scale), and `upscaled` measures images through groups and transforms (and flags embeds smaller than drawn).
|
|
64
|
+
|
|
65
|
+
### Performance
|
|
66
|
+
|
|
67
|
+
`renderImage` at release size (3000×3000) takes seconds where it took minutes. Rendering every ported flyer and style (104 covers) went from 2662 s to 156 s; the slowest cover is now 11 s. Some before → after: `lofi-hexmoth` 609 → 0.9 s, the three `chrome` styles ~235 → 1.5 s, `ferrova` 220 → 1.1 s, `velora-stickers` 182 → 6.3 s, `lofi-lowbit` 145 → 1.6 s, the riso flyers ~67 → 9 s, the `riso` styles ~46 → 6 s.
|
|
68
|
+
|
|
69
|
+
- SVGs with filters past ~2.4 MP render as parallel 1024 px tiles, each with a margin as wide as the furthest any filter reaches (effects report it at their own resolution); one render cost 5–6× its pixel count.
|
|
70
|
+
- `feMorphology` (the die-cut `border`) is split into an x and a y pass: the same result, O(r) instead of O(r²).
|
|
71
|
+
- Filter regions shrink to the box the layer paints, when the chain provably can't paint outside its source.
|
|
72
|
+
- `dither` is computed on pixels: the layer drawn once at one pixel per cell, thresholded in JavaScript, embedded nearest-neighbour (the filter's pixelation ran at output size).
|
|
73
|
+
|
|
74
|
+
### Known limits
|
|
75
|
+
|
|
76
|
+
- `wear`, `xerox`, lighting and displacement filters are still librsvg's own primitives: a full-bleed two-ink riso cover is ~6–9 s at 3000×3000 (from about a minute). Browsers draw the same SVG far faster. `halftone` stays a filter (it wasn't the bottleneck once tiled).
|
|
77
|
+
- WOFF2 needs Brotli: Node has it; most Workers don't (convert to TTF with `scripts/woff2-to-ttf.mjs`).
|
|
78
|
+
|
|
79
|
+
### Changed
|
|
80
|
+
|
|
81
|
+
- `engines` stays Node 20; on Node before 20.16 import `@arraypress/covers/node`.
|
|
82
|
+
- Cache keys include the version, so 1.0 caches render again once.
|
|
83
|
+
|
|
8
84
|
## [1.0.0] — Unreleased
|
|
9
85
|
|
|
10
86
|
### Added
|
package/README.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# @arraypress/covers
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Cover images, flyers, posters, release art, social formats and print stationery: soft mesh gradients, film grain, riso inks and halftones, knockout and justified type, torn paper, barcodes and more, drawn from one **seed** and a stack of layers. The same slug always gives the same image, and two posts never look alike.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Seventeen recipes cover the editorial looks and a brand kit (full-bleed gradient fields, engineering-blog line diagrams, cut-paper collages, risograph prints, 1-bit dithers, topographic contours, podcast waveforms, Bauhaus grids, magazine issue numbers, palettes from photos, stripe bands, an icon tile, big type, duotone photos, a single-icon mark), and every layer under them is yours to change, remove or add to. Recipes are data: the built-ins are JSON documents, and a theme can write its own cover style the same way, with no JavaScript (see [JSON recipes](#json-recipes)). Output is an SVG string (text as outlines, so it needs no fonts) or WebP / PNG / AVIF / JPEG via [sharp](https://sharp.pixelplumbing.com). Inter ships in the package, so it works with no setup. There's an Astro helper that turns a content collection into static covers in three lines.
|
|
6
6
|
|
|
7
|
-
No
|
|
7
|
+
No telemetry, no DOM required. It runs in Node at build time, in a browser, and in a Cloudflare Worker (see [Running anywhere](#running-anywhere)); in Node it never touches the network unless you ask.
|
|
8
8
|
|
|
9
9
|

|
|
10
10
|
|
|
@@ -58,7 +58,15 @@ const svg = await renderSvg({
|
|
|
58
58
|
|---|---|
|
|
59
59
|
| `cover(options)` | Resolve size and palette and expand the recipe into a layer stack. Pure, synchronous; returns plain data you can inspect or tweak. |
|
|
60
60
|
| `renderSvg(cover)` | `Promise<string>`: an SVG document. Byte-identical for the same input. |
|
|
61
|
-
| `
|
|
61
|
+
| `renderSvg(cover, { t })` | The frame at `t` seconds, for [keyframed](#animation) layers (static covers ignore it). |
|
|
62
|
+
| `renderImage(cover, { format, quality, lossless, effort, cache })` | `Promise<Buffer>` (Node, sharp): `'webp'` (default), `'png'`, `'avif'` or `'jpeg'`; `lossless` for WebP/AVIF (default: the recipe's preference). |
|
|
63
|
+
| `renderBlob(cover, { format, quality, width })`, `rasterize(svg, options)` | `Promise<Blob>` in the browser: PNG, JPEG or WebP through a canvas. |
|
|
64
|
+
| `renderPages(cover)` | `{ front, back }`: both sides of a card, ticket or flyer (see [Pages](#two-sided-pages)). |
|
|
65
|
+
| `kit(brand, { formats, recipe })` | A whole brand kit: every format from one brand (see [Brand kit](#brand-kit)). |
|
|
66
|
+
| `lint(cover, { target: 'spotify' })` | Release-art checks (see [lint](#lint)). |
|
|
67
|
+
| `configure({ load, network, harfbuzz, shaper, icons, fonts })`, `registerFont(font)` | Loading and text, per platform (see [Running anywhere](#running-anywhere)). |
|
|
68
|
+
| `FORMATS`, `KIT_FORMATS`, `safeBox(cover)` | Named formats with their safe zones (see [Formats](#formats)). |
|
|
69
|
+
| `EFFECTS`, `EFFECT_OPTIONS`, `valueAt(keyframes, t)` | Effect kinds and their options; a keyframed value at a time. |
|
|
62
70
|
| `coverKey(cover, options)` | The content-hash cache key (see [Caching](#caching)). |
|
|
63
71
|
| `recipes` | The built-in recipes, by name. |
|
|
64
72
|
| `defineRecipe(json)`, `loadRecipe(path)` | A recipe from a JSON document or file (see [JSON recipes](#json-recipes)). |
|
|
@@ -79,18 +87,37 @@ const svg = await renderSvg({
|
|
|
79
87
|
| `palette` | `'paper'` | A preset, one colour, `[bg, ...colors]`, an object, or `derivePalette()`. |
|
|
80
88
|
| `recipe` | — | A recipe name, your own function, or a JSON recipe document. |
|
|
81
89
|
| `layers` | mesh + grain | Without a recipe: the stack. With one: extra layers on top. |
|
|
82
|
-
| `fonts` | Inter | `[{ name, data, weight, style }]`, `.ttf`/`.otf`/`.woff` (
|
|
90
|
+
| `fonts` | Inter | `[{ name, data \| src, weight, style, axes, fallback }]`, `.ttf`/`.otf`/`.woff`/`.woff2`. `src` is a path, a URL or anything your loader reads; a variable font takes `weight: [100, 900]`; `fallback: true` draws characters other fonts lack. A JSON recipe can declare its own (`"fonts"`). Inter 400/500/600/700 is always available. |
|
|
91
|
+
| `format` | — | A named size (`'release'`, `'instagram-story'`, `'business-card'`, …) or `{ width, height, unit: 'px' \| 'mm' \| 'in' }`. Wins over `width`/`height`/`aspect` (a screen format scales to a given `width`). See [Formats](#formats). |
|
|
92
|
+
| `dpi`, `bleed`, `safe`, `cropMarks`, `cutLine`, `cut` | 300, 3 mm, per format | Print: resolution, bleed (mm, or `false`), safe margin (mm), crop marks in a slug, the die line. |
|
|
93
|
+
| `overlay` | — | Draw the format's safe zones over the art, for previews. |
|
|
94
|
+
| `page` | `'front'` | Which page of a two-sided piece to draw. |
|
|
95
|
+
| `textRender` | `'outline'` | `'live'`: all text as `<text>` with `@font-face`. |
|
|
96
|
+
| `loop` | — | Seconds: an animated cover's loop length. |
|
|
83
97
|
| `alt` | the title | The SVG's `<title>`. |
|
|
84
98
|
| `radius` | — | Rounded corners, transparent outside. |
|
|
85
99
|
| any layer id | — | Customise a recipe's layer (see [Recipes](#recipes)). |
|
|
86
100
|
|
|
87
101
|
### Lengths
|
|
88
102
|
|
|
89
|
-
Sizes are pixels as numbers, or viewport units of the cover as strings: `'6vmin'` (6% of the shorter side), `'4vw'`, `'4vh'`, `'5vmax'`. Defaults use these, so a cover keeps its proportions at any size. Positions (`
|
|
103
|
+
Sizes are pixels as numbers, or viewport units of the cover as strings: `'6vmin'` (6% of the shorter side), `'4vw'`, `'4vh'`, `'5vmax'`. Defaults use these, so a cover keeps its proportions at any size. Positions (`position`, `region`) are fractions of the canvas, 0–1.
|
|
104
|
+
|
|
105
|
+
The poster layers (`group`, `shape`, `svg`, `table`, `barcode`, `perforation`, `tear`, and `x`/`y` on `text`, `icon` and `image`) take coordinates in **pixels of their canvas**, as SVG does, or `'50%'` of it. Inside a `group` with a `viewBox`, the canvas is the group's grid: a flyer drawn on a 400×500 grid keeps its numbers at 1600×2000 or 3000×3000.
|
|
90
106
|
|
|
91
107
|
## Layers
|
|
92
108
|
|
|
93
|
-
Layers paint bottom first. Every option has a default drawn from the palette, the canvas and the layer's own seeded stream, so `{ type: 'mesh' }` alone is a finished background. Every layer also takes `id` (for overrides), `seed` (its own stream), `opacity` and `blend` (a CSS `mix-blend-mode`)
|
|
109
|
+
Layers paint bottom first. Every option has a default drawn from the palette, the canvas and the layer's own seeded stream, so `{ type: 'mesh' }` alone is a finished background. Every layer also takes `id` (for overrides), `seed` (its own stream), `opacity` and `blend` (a CSS `mix-blend-mode`), and:
|
|
110
|
+
|
|
111
|
+
| Option | |
|
|
112
|
+
|---|---|
|
|
113
|
+
| `transform` | `{ translate, rotate, scale, skewX, skewY, mirror: 'x' \| 'y', origin }` about `origin` (`[x, y]` px; default the centre). Misregistration is `{ translate: [1.8, -1.3], rotate: 0.25 }`; a reflection is `{ mirror: 'y' }`. On a `text` layer, `transform: 'uppercase'` is still the case. |
|
|
114
|
+
| `effects` | `[{ kind, … }]`: filters, pixel effects and copies, applied in order, each with an optional `mask` (see [Effects](#effects)). |
|
|
115
|
+
| `knockout` | Layers cut out of this one: knockout type and marks, punched holes. Whatever they paint is cut, whatever colour they are. |
|
|
116
|
+
| `clip` | Layers this one shows only inside (a photo in a circle, in type). |
|
|
117
|
+
| `mask` | Layers whose luminance masks this one (a white-to-black `linear` fades it). |
|
|
118
|
+
| `area` | Draw the layer in a box: `'trim'` or `'safe'` (print), or `{ x, y, width, height }` fractions. |
|
|
119
|
+
|
|
120
|
+
`blend` and `opacity` go on the outermost group, so a masked or filtered layer still blends with what's under it (two riso inks overprint).
|
|
94
121
|
|
|
95
122
|
A layer's random stream is keyed by its `seed`, else its `id`, else its type and position among layers of that type, so adding a layer never reshuffles the others.
|
|
96
123
|
|
|
@@ -131,7 +158,7 @@ Full-bleed fields of blurred colour: the mesh-gradient look.
|
|
|
131
158
|
### `image`
|
|
132
159
|
`src` (a local path, a `data:` URI or bytes; URLs are refused), `fit` (`'cover'` / `'contain'`), `focus` (an anchor, or `'attention'` / `'entropy'` smart crops), `region`, `duotone` (`true` derives shadow and highlight from the palette, or `[shadow, highlight]`), `grayscale`, `normalize` (stretch tones first; on by default with duotone/grayscale), `tint` (a colour, or `{ color, amount, blend }`), `blur`.
|
|
133
160
|
|
|
134
|
-
With sharp installed the image is cropped and resized
|
|
161
|
+
With sharp installed the image is cropped and resized before embedding, at the pixels it's drawn at (its box times any group and transform scale above it), never past the source's own pixels (the SVG scales it up instead). An unused alpha channel embeds as JPEG; PNG only where there's transparency. Without sharp, PNG, JPEG and SVG are embedded as they are.
|
|
135
162
|
|
|
136
163
|
### `grain`
|
|
137
164
|
`amount` (0.08), `size` (px per grain, 1), `monochrome` (`true`), `vary` (`'edges'`: heavier toward the edges and corners, like spray paint), `blend` (`'overlay'`; also `'soft-light'`, `'multiply'`, `'screen'`, `'normal'`).
|
|
@@ -145,7 +172,7 @@ Grid lines are drawn across the canvas, not as an SVG `<pattern>` (which clips a
|
|
|
145
172
|
`kind` (`'circle'`, `'square'`, `'pill'`, `'mixed'`), `count` (6), `size` (`['7vmin', '20vmin']`), `colors`, `placement` (`'scatter'` spreads them out avoiding overlaps; `'row'`, `'column'`), `items` (`[{ kind, x, y, size, color, rotate, opacity }]`), `region`, `rotate`, `radius` (squares, fraction of size), `outline` (`true` or a width).
|
|
146
173
|
|
|
147
174
|
### `icon`
|
|
148
|
-
One of `name` (Iconify: `'lucide:feather'`, `'simple-icons:github'`), `svg` (a raw SVG string; scripts and handlers are stripped) or `path` (+ `viewBox`). `size` (`'24vmin'`, `'15vmin'` on a tile), `color` (the accent on a tile, else `text`), `strokeWidth`, `anchor`, `position`, `offset`, `margin`, and `tile` (`true` or `{ size, radius, color, shadow: 'soft' | 'none', border }`).
|
|
175
|
+
One of `name` (Iconify: `'lucide:feather'`, `'simple-icons:github'`), `svg` (a raw SVG string; scripts and handlers are stripped) or `path` (+ `viewBox`). `size` (`'24vmin'`, `'15vmin'` on a tile), `color` (the accent on a tile, else `text`), `fill` (a gradient, tile or preset [paint](#paints) instead), `strokeWidth`, `anchor`, `position`, `offset`, `margin`, and `tile` (`true` or `{ size, radius, color, shadow: 'soft' | 'none', border }`).
|
|
149
176
|
|
|
150
177
|
Named icons load from `@iconify-json/<prefix>` (resolved from your project), or from a set you pass to `registerIcons()`.
|
|
151
178
|
|
|
@@ -215,6 +242,235 @@ A hairline: `y` (fraction), or `from` / `to` points; `inset`, `color`, `stroke`.
|
|
|
215
242
|
### `frame`
|
|
216
243
|
`inset` (`'3.5vmin'`), `radius` (`'2vmin'`), `stroke` (`'0.15vmin'`), `color` (`text`, at opacity 0.22), `fill` (a colour, to make a card).
|
|
217
244
|
|
|
245
|
+
### Poster type (`text`, 1.1)
|
|
246
|
+
The `text` layer takes these as well. Any of them switches it to the poster typesetter (1.0 text is unchanged, byte for byte).
|
|
247
|
+
|
|
248
|
+
| Option | |
|
|
249
|
+
|---|---|
|
|
250
|
+
| `x`, `y` | The first baseline, like SVG `<text x y>`; `align` acts as the anchor (`'right'` sets the end at `x`). No wrapping unless `maxWidth` is given. |
|
|
251
|
+
| `width` | Justify every line to exactly this width (`textLength`). `lengthAdjust: 'spacing'` opens the letter-spacing instead of scaling the glyphs. `justify: true` uses `maxWidth`. |
|
|
252
|
+
| `fit` | `'width'`: size the block so its widest line fills `width`; `'lines'`: size every line on its own to fill it (the stacked full-width headline); `'words'`: a word a line, the widest filling `width`; `'wrap'`: wrap at the width, then size every line so the widest fills it; `'shrink'`: shrink before wrapping. `split: 'words'` puts a word on each line without fitting; `maxHeight` shrinks a block taller than it. |
|
|
253
|
+
| `spans` | `[{ content, font, weight, style, axes, scale, size, color, letterSpacing, dy }]` instead of `content`: mixed runs on the same lines (a `\n` starts a new one). |
|
|
254
|
+
| `glyphs` | Per-glyph arrays, cycled and counted across the block (spaces skipped): `scale`, `scaleX`, `scaleY`, `dx`, `dy`, `rotate`, `color`; `origin: 'center' \| 'baseline'`; a scaled glyph takes its scaled width unless `advance: false`. Bulging 70s type is one layer. |
|
|
255
|
+
| `font: 'pixel'` | A built-in 5×7 pixel face (A–Z, 0–9, punctuation), drawn as merged cells; everything else here works on it. |
|
|
256
|
+
| `fallback` | A family (or list) for characters the font lacks; then fonts registered with `fallback: true`; then built-in shapes for ✺ ✦ ✶ ★ ☆ ♥ ♡ ▶ ◀ ● ◆ ■ ♪. |
|
|
257
|
+
| `extrude` | A depth, or `{ depth, angle, color, to, steps }`: block extrusion (the [`extrude`](#effects) effect). |
|
|
258
|
+
| `dash` | Dashes on `stroke` (and `dash` on each of `strokes`): stitching. |
|
|
259
|
+
| `stretch` | `[sx, sy]` (or `sy`) about the first baseline: condensed and stretched display type. |
|
|
260
|
+
| `axes` | Variable-font axes: `{ wdth: 50 }`, `{ SOFT: 100, WONK: 1, opsz: 144 }`, `{ YEAR: 1979 }`. A variable font's `wght` follows `weight`. |
|
|
261
|
+
| `content` | An array is one line per item. |
|
|
262
|
+
| `fill`, `stroke`, `strokeWidth` | `fill: 'none'` with a `stroke` is outline type; `fill` takes gradients. |
|
|
263
|
+
| `strokes` | `[{ color, width, dash }]`: keylines under the fill, widest first (varsity lettering). A preset paint (`fill: { preset: 'chrome' }`) brings its own. |
|
|
264
|
+
| `path` | Text on a path: `{ kind: 'circle' \| 'arc' \| 'ellipse' \| 'wave', cx, cy, r, start, end, direction }` or `{ d }`. `startOffset` (0–1 or px; 0.5 centres), `fitPath` (space it round a ring; a length leaves a gap), `dy`. |
|
|
265
|
+
| `color: 'auto'` | White or near-black by what's drawn underneath (rendered small and averaged: sharp in Node, a canvas in a browser, the background colour in a Worker): luma (Rec. 601) above 0.72 takes the dark colour, as designers set it (a mid pink takes white). `autoColors: [light, dark]`, `autoThreshold`. |
|
|
266
|
+
| `{layout.<id>.<field>}` | Any later layer can place itself by where a text block landed: `bottom`, `top`, `left`, `right`, `width`, `height`, `x`, `y`, `baseline`, `size`, `lines`, plus or minus a number (`"y": "{layout.title.bottom + 12}"`), in the text layer's units. |
|
|
267
|
+
| `render: 'live'` | `<text>` with the font embedded by `@font-face` (or linked by URL when it came from one): selectable, fast in a browser, laid out the same. |
|
|
268
|
+
|
|
269
|
+
### Poster layers
|
|
270
|
+
|
|
271
|
+
Coordinates in px of the layer's canvas (or its group's `viewBox`), or `'50%'`. Every option is documented in the layer's module (`src/layers/*.js`).
|
|
272
|
+
|
|
273
|
+
| Layer | Draws | Main options |
|
|
274
|
+
|---|---|---|
|
|
275
|
+
| `group` | A nested stack on its own design grid | `layers`, `viewBox`, `x` `y` `width` `height`, `fit`, `align` |
|
|
276
|
+
| `shape` | One exact shape, or `items` | `kind` (rect, circle, ellipse, line, polygon, polyline, star, burst, scallop, rays, ring, sector, arch, wave, path, `cut`: the print format's die-cut, `outset`), `fill`/`stroke` ([paints](#paints)), `edge` (deckle, torn, zigzag, wave, scallop), `strokes`, `dash`, `dashOffset`, `rotate` (about the centre, or `origin`), `open` (an arch as an arc), `soft` (a blur drawn padded: no edge fade, no square edge) |
|
|
277
|
+
| `repeat` | A layer many times | `layer` (or `layers`); a grid (`rows`, `cols`, `step`), a ring (`count`, `cx`, `cy`, `radius`, `start`, `sweep`, `turn`) or a line (`count`, `step`). `{#…}` expressions per instance (`i`, `n`, `row`, `col`, `x`, `y`, `angle`, `odd`; `+ - * / %`, `? :`, `min` `max` `abs` `floor` `round` `sin` `cos` `pick`); `odd` and `cycle` overrides; `skip` |
|
|
278
|
+
| `defs`, `use` | A named stack drawn again | `defs.layers` (each with an `id`; nothing drawn); `use`: `ref`, `x`, `y`, `rotate`, `scale` (or `place`, a transform), `with` (overrides for that copy). A plain use is the same markup referenced |
|
|
279
|
+
| `svg` | Raw markup: the escape hatch | `markup`, `defs`, `viewBox`, `fit`; tokens `{{@accent}}`, `{{id}}`, `{{seed}}`, and `{{name}}` slots in JSON recipes (a markup value such as `{{mark}}` between elements goes in as sanitised shapes; anything else is escaped). Scripts, handlers and links out are stripped |
|
|
280
|
+
| `table` | Meta grids, lineups, tracklists | `rows`, `columns` (`x`, `align`, `key`, style), `x`, `y`, `size`, `rowHeight`, `header`, `rules` |
|
|
281
|
+
| `barcode` | Real Code 128, or decorative bars | `value`, `kind`, `x` `y` `width` `height`, `text` |
|
|
282
|
+
| `perforation` | A tear-off line | `kind` (holes, dashes, notches), `x1` `y1` `x2` `y2`, `size`, `gap`; punches holes inside a `knockout` |
|
|
283
|
+
| `tear` | Paper torn on a jagged line | `from`, `to`, `side`, `color`, `rip`, `fibres`, `step`, `jitter` (same `seed` on two layers: one tear) |
|
|
284
|
+
| `texture` | Surfaces | `kind` (paper, creases, concrete, speck, wood, folds, dust), `color`, `frequency`, `region` |
|
|
285
|
+
|
|
286
|
+
`icon` also takes `markup` (a mark's paths on a 512 grid) and `x`, `y`; `image` takes `x` `y` `width` `height`, `fit: 'stretch'` and URLs where fetching is allowed. A cut-out subject is another `image` above the masthead.
|
|
287
|
+
|
|
288
|
+
## Effects
|
|
289
|
+
|
|
290
|
+
`effects: [{ kind, … }]` on any layer or group, applied in order (`src/effects/`, one module per family). Most are SVG filters (vector, sized in the layer's units); the ones filters can't do are computed on pixels (◆): the layer is drawn once at the pixels it's shown at (sharp in Node, a canvas in the browser), processed on a typed array and embedded as one image. Copies (▲) redraw the layer's own markup. Lengths are px or `'vmin'` strings; points (`center`, `at`) are `[x, y]` fractions.
|
|
291
|
+
|
|
292
|
+
| Kind | Options | |
|
|
293
|
+
|---|---|---|
|
|
294
|
+
| **Blur** | | |
|
|
295
|
+
| `blur` | `radius`, or `x`, `y`; `edge: 'duplicate'` | Gaussian. |
|
|
296
|
+
| `motion` | `angle`, `length` (σ along), `spread` | Directional blur at any angle (on pixels in `renderImage`: librsvg can't blur along a turned axis). |
|
|
297
|
+
| `zoom` ◆ | `center`, `amount` (0–0.95) | Radial: each point smeared toward the centre. |
|
|
298
|
+
| `spin` ◆ | `center`, `angle` | Rotational blur round a centre; the centre stays sharp. |
|
|
299
|
+
| `tilt` ◆ | `center`, `angle`, `width`, `falloff`, `radius`, `saturate` | Tilt-shift: a sharp band, the blur growing beyond it. |
|
|
300
|
+
| `lens` ◆ | `radius`, `blades` (0 round, 5–8), `rotation`, `highlights`, `threshold` | Lens blur: highlights become bokeh discs or polygons. |
|
|
301
|
+
| **Distort** | | |
|
|
302
|
+
| `wave` ◆ | `amplitude`, `wavelength`, `angle`, `phase`, `shape` (`sine`, `triangle`, `square`) | |
|
|
303
|
+
| `ripple` ◆ | `center`, `amplitude`, `wavelength`, `phase`, `radius` | Rings on water. |
|
|
304
|
+
| `twirl` ◆ | `center`, `angle`, `radius` | Swirl. |
|
|
305
|
+
| `bulge`, `pinch` ◆ | `center`, `amount`, `radius` | |
|
|
306
|
+
| `fisheye` ◆ | `center`, `amount` | Barrel distortion over the frame. |
|
|
307
|
+
| `glass` ◆ | `texture` (`frosted`, `reeded`, `pebbled`), `size`, `amount`, `angle`, `shade`, `seed` | Through frosted, fluted or pebbled glass. |
|
|
308
|
+
| `displace` ◆ | `amount`, `map: { layers }` (else seeded noise: `size`, `octaves`, `seed`), `mode` (`gradient`, `channels`) | Displacement by a texture, a photo or type. |
|
|
309
|
+
| `kaleidoscope` ◆ | `segments`, `center`, `angle`, `scale` | |
|
|
310
|
+
| `mirror` ▲ | `keep` (`left`, `top`, `top-left`, …), `at` | One half (or quarter) reflected. |
|
|
311
|
+
| `stretch` ◆ | `at`, `direction`, `length`, `fade` | Pixel stretch: one row or column smeared to the edge. |
|
|
312
|
+
| `sort` ◆ | `direction`, `threshold` (`[lo, hi]`), `by` (`luma`, `hue`, `saturation`), `reverse`, `length`, `seed` | Pixel sorting. |
|
|
313
|
+
| **Colour** | | |
|
|
314
|
+
| `curves` | `points`, `red`, `green`, `blue` (`[[in, out], …]`, 0–1) | Smooth tone curve, per channel too. |
|
|
315
|
+
| `levels`, `grayscale` | `black`, `white`, `gamma`, `output`; or `slope`, `intercept` (`contrast`, `brightness`) | |
|
|
316
|
+
| `gradientMap` | `colors`, `stops`, `space` (`srgb`, `oklab`) | Tones through a multi-stop gradient. |
|
|
317
|
+
| `duotone` | `colors`, `contrast` | |
|
|
318
|
+
| `posterize` | `levels` | |
|
|
319
|
+
| `threshold` | `level`, `ink`, `paper` | Two colours. |
|
|
320
|
+
| `invert` | `amount` | |
|
|
321
|
+
| `hue` | `angle`, `saturation`, `lightness` | Hue/saturation. |
|
|
322
|
+
| `saturate`, `vibrance` ◆ | `amount`; `amount`, `saturation` | Vibrance lifts muted colours, holds skin. |
|
|
323
|
+
| `colorBalance` | `shadows`, `midtones`, `highlights` (`[r, g, b]`, −1–1) | |
|
|
324
|
+
| `channelMixer` | `red`, `green`, `blue` (`[r, g, b, k]`), `monochrome`, `gray` | |
|
|
325
|
+
| `sepia`, `matrix` | `amount`; `values` | |
|
|
326
|
+
| `look` | `name` (`fade`, `crossProcess`, `bleachBypass`, `tealOrange`, `warmFilm`, `coolFilm`, `noir`), `amount` | Graded presets. |
|
|
327
|
+
| **Light** | | |
|
|
328
|
+
| `bloom` | `threshold`, `radius`, `intensity`, `color` | Highlights glowing (spreads past the layer: neon type). |
|
|
329
|
+
| `halation` | `threshold`, `radius`, `intensity`, `color` | Film's red halo round highlights. |
|
|
330
|
+
| `glow`, `shadow` | `x`, `y`, `blur`, `color`, `opacity`, `spread` | A halo or drop shadow from the alpha. |
|
|
331
|
+
| `vignette` | `shape` (`ellipse`, `circle`, `rect`), `size`, `softness`, `amount`, `color`, `center` | |
|
|
332
|
+
| `lightLeak` | `edge`, `colors`, `intensity`, `seed` | Warm light fogging an edge. |
|
|
333
|
+
| `lensFlare` | `at`, `size`, `intensity`, `color`, `ghosts`, `streak`, `seed` | Soft glow, halo, ghosts, optional anamorphic streak. |
|
|
334
|
+
| `rays` ◆ | `center` (default the brightest patch), `threshold`, `length`, `intensity`, `color` | God rays: occluders cast shafts. |
|
|
335
|
+
| **Texture and print** | | |
|
|
336
|
+
| `grain` | `amount`, `size`, `color`, `seed` | Film grain, strongest in the midtones. |
|
|
337
|
+
| `dust` ◆ | `amount`, `scratches`, `color` (`light`, `dark`, `mixed`), `seed` | Dust, hairs and scratches. |
|
|
338
|
+
| `paper` | `texture` (`paper`, `canvas`), `scale`, `amount`, `angle` | Printed on paper or canvas. |
|
|
339
|
+
| `scanlines` | `spacing`, `thickness`, `amount` | |
|
|
340
|
+
| `crt` ◆ | `mask` (`aperture`, `shadow`), `size`, `amount`, `scanlines`, `curvature` | Phosphor RGB mask. |
|
|
341
|
+
| `vhs` ◆ | `shift`, `bleed`, `jitter`, `tracking`, `noise`, `seed` | Chroma smear and shift, line jitter, tracking noise. |
|
|
342
|
+
| `jpeg` ◆ | `quality`, `block`, `passes`, `subsample` | Real JPEG compression, blocks at any size. |
|
|
343
|
+
| `halftone` | `ink`, `cell`, `angle`, `contrast`, `lift`, `softness`, `shape` (`dot`, `line`, `square`, `diamond`, `cross`) | One-ink screen (two layers, `multiply`, a `transform`: a two-ink overprint). |
|
|
344
|
+
| `newsprint` ◆ | `cell`, `angles` (`[c, m, y, k]`), `paper`, `gain`, `gcr` | CMYK process halftone, rosettes. |
|
|
345
|
+
| `dither` | `ink`, `paper`, `cell`, `contrast`, `brightness`, `pixelate`, `weights`, `matrix` | Ordered 1-bit. |
|
|
346
|
+
| `wear`, `xerox`, `photocopy`, `grit` | `rough`, `uneven`, `specks`; `rough`, `dropout`; `ink`, `contrast`, `lift`, `noise`; `amount`, `frequency` | Riso drum wear, copier toner. |
|
|
347
|
+
| `rough`, `warp` | `scale`, `frequency`, `octaves` | Displacement: a wobble, a melt. |
|
|
348
|
+
| **Digital** | | |
|
|
349
|
+
| `chromatic`, `rgbSplit` | `amount`, `angle`, `mode` (`directional`, `radial` ◆), `center` | Chromatic aberration. |
|
|
350
|
+
| `glitch` ◆ | `amount`, `shift`, `slices`, `blocks`, `seed` | RGB shift, torn slices, displaced blocks. |
|
|
351
|
+
| `pixelate`, `mosaic` ◆ | `size`, `shape` (`square`, `hex`) | |
|
|
352
|
+
| `ascii` ◆ | `cell`, `ramp`, `color` (`source` or a colour), `background`, `invert`, `contrast` | |
|
|
353
|
+
| `edges` | `radius`, `amount`, `style` (`ink`, `glow`), `color`, `mono` | Find edges, glowing edges. |
|
|
354
|
+
| `sketch` | `radius`, `darkness`, `color` | Pencil drawing. |
|
|
355
|
+
| `emboss` | `angle`, `amount`, `radius`, `color` | |
|
|
356
|
+
| `sharpen` | `amount`, `radius` | Unsharp mask. |
|
|
357
|
+
| **Other** | | |
|
|
358
|
+
| `border` | `width`, `color`, `line`, `lineWidth`, `shape: 'circle'` | Die-cut sticker border, or a round backing. |
|
|
359
|
+
| `extrude` ▲ | `depth`, `angle`, `offset`, `steps`, `color`, `to` | Block extrusion. |
|
|
360
|
+
| `filter` | `markup` | Raw primitives (`{{seed}}`, `{{id}}`). |
|
|
361
|
+
|
|
362
|
+
**`mask`, on any effect**: where it applies, from none to full. `{ gradient: 'linear', from: [x, y], to: [x, y] }`, `{ gradient: 'radial', center, from, to, aspect }` (radii), `{ gradient: 'band', center, angle, from, to }` (distances from a line), or `{ layers: [...] }` (their luminance); each takes `invert` and `ease`. The mix is exact: inside the filter, or per pixel. "The left half motion-blurred, the face sharp" is `{ kind: 'motion', length: '6vmin', mask: { gradient: 'linear', from: [0.4, 0.5], to: [0.25, 0.5] } }`.
|
|
363
|
+
|
|
364
|
+
Every effect also takes `colorSpace` (`'sRGB'`, the default, or `'linearRGB'`) and `pad`. Numbers are in the layer's units: inside a group, its grid's, so an effect looks the same at every output size. Random ones take a `seed` (else the layer's stream). JSON recipes are validated kind by kind (`EFFECT_OPTIONS`). Where nothing can draw pixels (a Worker), `lens` falls back to a Gaussian blur, `tilt` to one through its band, and the other ◆ effects are left out.
|
|
365
|
+
|
|
366
|
+
### Paints
|
|
367
|
+
|
|
368
|
+
`fill` and `stroke` on shapes, text and icons:
|
|
369
|
+
|
|
370
|
+
| Paint | |
|
|
371
|
+
|---|---|
|
|
372
|
+
| A colour, `'none'` | |
|
|
373
|
+
| `{ type: 'linear', colors, stops, opacities, angle, spread, length }` | Over the shape's bounds (a text block's whole box); `spread: 'reflect'` with a `length` is foil. |
|
|
374
|
+
| `{ type: 'radial', colors, stops, opacities, cx, cy, r, rx, ry, fx, fy }` | `rx`/`ry` (lengths, or fractions of the box) make it elliptical. |
|
|
375
|
+
| `{ type: 'tile', kind, size, colors, ratio, angle, markup }` | A pattern from the shape's corner: `checker`, `stripes`, `dots`, `grid`, or a `custom` tile of markup. |
|
|
376
|
+
| `{ preset: 'chrome' \| 'steel' \| 'gold', … }` | Y2K metal as it's painted: sky, a sharp dark horizon at ~55%, warm ground, a light bounce; with its keyline (`keyline: false` for none). Fields override it. |
|
|
377
|
+
|
|
378
|
+
## Formats
|
|
379
|
+
|
|
380
|
+
`format` sets the size from a name (`src/formats/presets.js`). Screen formats carry their **safe zone**: anchored text and icons are laid out inside it, recipes read it as `@safeX` `@safeY` `@safeWidth` `@safeHeight`, and `overlay: true` draws the zones (safe green, covered red, crops blue).
|
|
381
|
+
|
|
382
|
+
| Format | Size | Safe zone and where it comes from |
|
|
383
|
+
|---|---|---|
|
|
384
|
+
| `release` | 3000×3000 | Release cover art (Spotify, Apple Music, Bandcamp). See [lint](#lint). |
|
|
385
|
+
| `spotify-canvas` | 1080×1920 | Central; the player covers the bottom third (measured). Canvas should carry no text. |
|
|
386
|
+
| `bandcamp-header` | 975×180 | Bandcamp's custom header width (40–180 tall). |
|
|
387
|
+
| `instagram-post` | 1080×1350 | The central square (some previews crop to it). |
|
|
388
|
+
| `instagram-square` | 1080×1080 | |
|
|
389
|
+
| `instagram-story` | 1080×1920 | Meta: keep ~14% (250 px) top and bottom free of text and logos. |
|
|
390
|
+
| `instagram-reel` | 1080×1920 | Meta: 14% top, 35% bottom, 6% sides; the 3:4 grid crop. |
|
|
391
|
+
| `tiktok-cover` | 1080×1920 | Commonly measured margins (240 top, ~600 bottom, 120 right) and the 3:4 grid crop; TikTok publishes its zones only as template files. |
|
|
392
|
+
| `youtube-thumbnail` | 1280×720 | YouTube Help size; the duration badge bottom right. |
|
|
393
|
+
| `youtube-banner` | 2560×1440 | YouTube Help: keep text and logos in the centre 1235×338 of a 2048×1152 upload (1546×423 here); TV, desktop, tablet and mobile crops. |
|
|
394
|
+
| `x-header` | 1500×500 | The avatar over the bottom left; ~60 px trimmed top and bottom (community measurements; X publishes none). |
|
|
395
|
+
| `linkedin-banner`, `linkedin-company` | 1584×396, 1128×191 | LinkedIn's sizes; the profile photo over the banner's bottom left. |
|
|
396
|
+
| `facebook-cover` | 1640×924 | Meta Help: shows at 820×312 on desktop and 640×360 on phones; the desktop band in the middle, the profile picture bottom left. |
|
|
397
|
+
| `twitch-banner`, `discord-banner`, `discord-profile`, `bluesky-banner` | 1200×480, 960×540, 680×240, 3000×1000 | The platforms' recommended sizes; avatars over the profile banners. |
|
|
398
|
+
| `pinterest` | 1000×1500 | Pinterest's 2:3; the save button bottom right. |
|
|
399
|
+
| `avatar`, `x-avatar`, `youtube-avatar` | 1080², 400², 800² | Every platform crops avatars to a circle (X, Instagram, Threads, LinkedIn, YouTube, TikTok, Bluesky); the safe square is inscribed in it. |
|
|
400
|
+
| `og` | 1200×630 | |
|
|
401
|
+
| `flyer` | 1600×2000 | A digital 4:5 flyer. |
|
|
402
|
+
| `a6`, `a5`, `a4`, `a3`, `a2` | mm | Flyers and posters. |
|
|
403
|
+
| `business-card`, `business-card-us` | 85×55 mm, 3.5×2 in | |
|
|
404
|
+
| `letterhead`, `letterhead-us` | A4, US Letter | |
|
|
405
|
+
| `compliment-slip` | 210×99 mm | |
|
|
406
|
+
| `sticker`, `sticker-square` | 60 mm | Die-cut round, rounded square. |
|
|
407
|
+
| `ticket` | 5.5×2 in | |
|
|
408
|
+
| `badge`, `lanyard-pass` | 54×85.6 mm (CR80), A6 | |
|
|
409
|
+
|
|
410
|
+
A custom format is `{ width, height, unit: 'px' | 'mm' | 'in', bleed, safe, zones }`. A screen format given a `width` scales (its zones too).
|
|
411
|
+
|
|
412
|
+
### Print, pages
|
|
413
|
+
|
|
414
|
+
| Option | |
|
|
415
|
+
|---|---|
|
|
416
|
+
| `dpi` | 300. Print sizes are trim sizes in mm or inches. |
|
|
417
|
+
| `bleed`, `safe` | mm (default per format, bleed 3). The canvas is the bleed box; anchored text sits inside the safe margin. Tokens `@bleed` `@safe` `@trimWidth` `@trimHeight` `@mm` `@dpi`; `area: 'trim' \| 'safe'`. |
|
|
418
|
+
| `cropMarks`, `cutLine` | Crop marks in a slug; the die line (`CutContour`). `renderImage` writes the dpi. |
|
|
419
|
+
| `cut` | The die-cut: `'rounded'` (3 mm), `'circle'`, `{ radius }` (mm), or `{ d, viewBox }` (a path drawn into the trim). Tickets and lanyard passes have rounded corners by default; a `shape` of `kind: 'cut'` draws it (as a fill, or a `clip`). |
|
|
420
|
+
| `page` | A recipe's `"pages": ["front", "back"]` with `when: { "page": "back" }`; `renderPages(cover)` returns `{ front, back }`. |
|
|
421
|
+
|
|
422
|
+
Artwork on objects (a card on a desk, a lanyard on wood) is a mockups library, not covers.
|
|
423
|
+
|
|
424
|
+
## Brand kit
|
|
425
|
+
|
|
426
|
+
```js
|
|
427
|
+
const files = await kit({ name: 'Hollow Fern', tagline: 'Field recordings', palette: { accent: '#2f6b4f' }, mark, lines, person: 'Mira Okafor' }, { formats: ['avatar', 'x-header', 'business-card'] });
|
|
428
|
+
files['business-card'].back; // SVG
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
`kit(brand, { formats, recipe, pages, render, overlay })`: every format in `KIT_FORMATS` by default, with the built-in `brand` recipe (or one per format). Two-sided formats return `{ front, back }`.
|
|
432
|
+
|
|
433
|
+
## lint
|
|
434
|
+
|
|
435
|
+
`lint(cover, { target: 'spotify', artist, title })` → `{ ok, issues: [{ rule, severity, message, text }] }`. Spotify's own rules: square, 640–10000 px, not upscaled (3000×3000 is what stores ask for). The content rules are distributors' (they reject art that breaks them).
|
|
436
|
+
|
|
437
|
+
| Rule | Severity | |
|
|
438
|
+
|---|---|---|
|
|
439
|
+
| `aspect`, `resolution` | error (warning under 3000) | |
|
|
440
|
+
| `url`, `handle`, `contact`, `price`, `promo`, `platform`, `barcode` | error | Addresses, @handles, email/phone, prices and "free", "out now", store names (a name that's also a word, "Tidal", only with a cue: "on Tidal", or on its own), barcodes |
|
|
441
|
+
| `date`, `physical` | warning | Dates; day names spelled out, or short ones beside a number ("Sat 14", not "Second Sun"); CD, vinyl |
|
|
442
|
+
| `legibility` | warning | The block's smallest capitals under 4 px at a 64 px thumbnail, measured as drawn (every line, stretch, justification, group and transform scale) |
|
|
443
|
+
| `upscaled`, `metadata` | warning | An image drawn bigger than its pixels (through groups and transforms), or embedded smaller than drawn; words not in `artist`/`title` (a letter per layer or a knocked-out word spelled from them passes) |
|
|
444
|
+
|
|
445
|
+
## Running anywhere
|
|
446
|
+
|
|
447
|
+
No module imports `node:`; `renderSvg`, `renderPages`, `kit` and `lint` run in Node, browsers and Cloudflare Workers (`src/platform/`).
|
|
448
|
+
|
|
449
|
+
| | Node | Browser | Cloudflare Worker |
|
|
450
|
+
|---|---|---|---|
|
|
451
|
+
| Text | HarfBuzz | HarfBuzz (wasm) | A JavaScript TrueType reader, byte-identical to HarfBuzz on the 1.0 test set (no ligatures, CFF or variable instances) |
|
|
452
|
+
| Files | Paths; URLs with `network: true` | URLs, fetched | URLs, fetched |
|
|
453
|
+
| Raster | `renderImage` (sharp) | `renderBlob`, `rasterize` (canvas) | Return the SVG |
|
|
454
|
+
| `color: 'auto'` | sharp | canvas | The background colour |
|
|
455
|
+
|
|
456
|
+
```js
|
|
457
|
+
configure({
|
|
458
|
+
harfbuzz: '/assets/harfbuzz.wasm', // URL, bytes, WebAssembly.Module or Response
|
|
459
|
+
icons: 'https://cdn.jsdelivr.net/npm/@iconify-json/{prefix}/icons.json',
|
|
460
|
+
load: (src, { kind }) => assets.get(src), // return bytes, or undefined to fall through
|
|
461
|
+
network: true, // let Node fetch URLs
|
|
462
|
+
fonts: [{ name: 'Fraunces', src: '/fonts/Fraunces.ttf', weight: [100, 900] }],
|
|
463
|
+
});
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
Workers resolve `#harfbuzz` to a stub (harfbuzzjs compiles wasm at runtime, which Workers forbid). `@arraypress/covers/node` installs Node's modules up front (Node before 20.16). WOFF2 needs Brotli: Node has it, and browsers with `DecompressionStream('brotli')`; elsewhere (most Workers) convert it first with `node scripts/woff2-to-ttf.mjs font.woff2` (it uses this library's decoder, or fontTools / `woff2_decompress` when present), or use `render: 'live'`.
|
|
467
|
+
|
|
468
|
+
## Animation
|
|
469
|
+
|
|
470
|
+
Designed in, not built yet: any option can be `{ "keyframes": [[t, value], …], "ease", "loop" }` (seconds), and `renderSvg(cover, { t })` draws that frame; static covers ignore `t`. Numbers, colours (OKLab) and same-unit lengths interpolate.
|
|
471
|
+
|
|
472
|
+
The video phase: browser frames to WebCodecs (`VideoEncoder`, MP4/WebM) with `MediaRecorder` as the fallback; Node frames from sharp piped to ffmpeg (`@arraypress/video-utils` parses and embeds video URLs but has no encoder, so the runner lands there or beside it). CSS-animated SVG suits avatars and banners on websites (tiny, sharp, `prefers-reduced-motion`), but only for what CSS animates (transforms, opacity, colour); social platforms take video.
|
|
473
|
+
|
|
218
474
|
## Recipes
|
|
219
475
|
|
|
220
476
|
A recipe returns a stack of layers with ids. Customise any of them by id:
|
|
@@ -335,6 +591,12 @@ Layers `bg`, `mesh` or `photo`, `grain` (+ `title`). The palette of a photo (`im
|
|
|
335
591
|
|
|
336
592
|

|
|
337
593
|
|
|
594
|
+
### `brand`
|
|
595
|
+
A brand on every format, the default for [`kit`](#brand-kit): avatars (the mark, or an initial), headers and banners (mark and name in the safe zone), posts and stories, business cards (front: mark and name; back: `person`, `role`, `lines`), letterhead and compliment slips, tickets (with a Code 128 `ticket` number), passes and stickers. Options: `name`, `tagline`, `mark`, `photo`, `lines`, `person`, `role`, `ticket`, `compliments`.
|
|
596
|
+
|
|
597
|
+
### Flyers (examples)
|
|
598
|
+
`examples/recipes/`: `bloomfall.json` (riso, two misregistered inks, halftoned cut-out), `grid-rest-rhythm.json` (Neo-Swiss grid, justified headline), `wheatpaste-torn.json` (torn sheet, knockout type, creases). `flyers/` holds ~45 more (aura, Neo-Swiss, acid, riso, kinetic, festival, varsity, ephemera, Bauhaus, 70s, lo-fi, zine), each with a release cover, and `release/` 16 cover-only styles. Each is drawn on a design grid in a `group`, so it renders at any `format`.
|
|
599
|
+
|
|
338
600
|
### Your own
|
|
339
601
|
|
|
340
602
|
```js
|
|
@@ -408,7 +670,14 @@ A layer is any [layer](#layers) plus an optional `id` and `when`. Its option val
|
|
|
408
670
|
|---|---|
|
|
409
671
|
| `"@bg"`, `"@text"`, `"@accent"`, `"@onAccent"`, `"@muted"`, `"@surface"`, `"@mode"`, `"@colors"`, `"@colors.N"` | The palette (`colors.N` wraps around). |
|
|
410
672
|
| `"@width"`, `"@height"`, `"@u"` | The canvas in px (`u` = `sqrt(width × height)`, the unit the built-ins size type with). |
|
|
673
|
+
| `"@safeX"`, `"@safeY"`, `"@safeWidth"`, `"@safeHeight"` | The safe box: a format's safe zone, a print piece's trim less its safe margin, else the canvas. |
|
|
674
|
+
| `"@bleed"`, `"@safe"`, `"@trimWidth"`, `"@trimHeight"`, `"@mm"`, `"@dpi"` | Print set-up, px (undefined off print formats). |
|
|
675
|
+
| `"{name}"`, `"{mark}"`, `"{photo}"`, `"{cutout}"`, `"{lines}"`, `"{lineup}"`, `"{dates}"`, `"{tracklist}"`, `"{venue}"`, `"{address}"`, `"{date}"`, `"{time}"`, `"{price}"`, `"{meta}"` | Brand and event data, readable without declaring them. `"{lineup.0}"`, `"{meta.venue}"` reach into lists and objects. |
|
|
676
|
+
| `{ "join": [list, sep] }`, `{ "upper": s }`, `{ "lower": s }`, `{ "slice": [v, start, end] }` | Lists and strings. |
|
|
677
|
+
| `{ "keyframes": [[t, value], …], "ease": "inOut", "loop": true }` | An animated value (see [Animation](#animation)). |
|
|
411
678
|
| `"$name"`, `"$name.0"`, `"$name.x"` | A var (or an item or field of it). |
|
|
679
|
+
| `"{layout.title.bottom}"`, `"{layout.title.bottom + 12}"` | Where an earlier text layer's block landed, filled in when drawn. |
|
|
680
|
+
| `"{#col * 42 + 8}"` | Inside a `repeat`: an expression over the instance, filled in when drawn. |
|
|
412
681
|
| `"{title}"`, `"{label}"`, `"{icon}"`, `"{image}"`, `"{param}"`, `"{opt.anything}"` | A cover option. A whole-string token keeps the value's type (a number stays a number, a label object stays an object); inside longer text it's interpolated (`"Read {title}"`). An unset option drops the key. |
|
|
413
682
|
| `{ "mix": [a, b, t] }`, `{ "mix": [a, b, t, "oklch"] }` | Two colours mixed in OKLab, or in OKLCH (hue the short way, so far-apart hues stay vivid). |
|
|
414
683
|
| `{ "readable": c }`, `{ "readable": [c, light, dark] }` | White (or `light`), or near-black (or `dark`), whichever reads better on `c`. |
|
|
@@ -424,16 +693,20 @@ A layer is any [layer](#layers) plus an optional `id` and `when`. Its option val
|
|
|
424
693
|
| `{ "if": condition, "then": a, "else": b }` | A choice. Only the chosen side is evaluated (and draws from the stream). |
|
|
425
694
|
| `"@@x"`, `"$$x"` | A literal `@x` / `$x`. |
|
|
426
695
|
|
|
427
|
-
Conditions (a layer's `when`, an `if`): `"title"` (the option is set; an override object counts when it has `content`, `name`, `svg` or `src`), `{ "background": "gradient" }` (equals; an array means any of; `mode` is the palette's, `aspect` the canvas's: `'wide'`, `'square'` or `'tall'`), `{ "not": c }`, `{ "all": [...] }`, `{ "any": [...] }`.
|
|
696
|
+
Conditions (a layer's `when`, an `if`): `"title"` (the option is set; an override object counts when it has `content`, `name`, `svg` or `src`), `{ "background": "gradient" }` (equals; an array means any of; `mode` is the palette's, `aspect` the canvas's: `'wide'`, `'square'` or `'tall'`, `format` the format's name, `page` the page being drawn), `{ "not": c }`, `{ "all": [...] }`, `{ "any": [...] }`.
|
|
697
|
+
|
|
698
|
+
Layers nest (`group.layers`, `knockout`, `clip`, `mask`) and are validated the same way, `when` included. An `svg` layer's `markup` and `defs` are left alone except for `{{name}}` slots, which are filled from the options (escaped) and vars (`{{$name}}`); `{{@accent}}`, `{{id}}` and `{{seed}}` are filled when it's drawn.
|
|
428
699
|
|
|
429
700
|
- **`params`** are the options a recipe reads, with defaults; a cover option of the same name overrides one. `title`, `label`, `icon` and `image` are always available. **`requires`** lists options that must be set (`["image"]`).
|
|
430
701
|
- **`vars`** are resolved once, in order, before the layers; their draws use the cover's recipe stream. Draws inside a layer use that layer's own stream, so adding a layer never changes another's choices.
|
|
431
702
|
- **`output`** (`{ "lossless": true }`) sets the encoding the style prefers in `renderImage`.
|
|
703
|
+
- **`pages`** (`["front", "back"]`) makes the style two-sided (see [Two-sided pages](#two-sided-pages)).
|
|
704
|
+
- **`fonts`** (`[{ "name": "Fraunces", "src": "fonts/Fraunces.woff2", "weight": [100, 900] }]`) declares the faces the style uses: a path beside the recipe file, or a URL. Callers don't register them.
|
|
432
705
|
- **Overrides by id** work exactly as for the built-ins (`grain: false`, `mesh: { blur: 30 }`), and so do extra `layers`.
|
|
433
706
|
- **Validation** runs when the recipe is defined, and stops at the first problem with the recipe's name, the JSON path and what to do: `Recipe "house": layers[2] (grid).blurr: "pattern" layers have no option "blurr" (kind, size, …)`, `unknown token "@bgg"`, `unknown param "subtitle" (declare it in "params", or use "{opt.subtitle}")`.
|
|
434
707
|
- The schema is exported as `@arraypress/covers/recipe.schema.json`, and the built-ins as `@arraypress/covers/recipes/<name>.json` to copy and edit.
|
|
435
708
|
|
|
436
|
-
All
|
|
709
|
+
All seventeen built-ins are JSON, with no JS fallback. The tests check that six of them render byte-identical SVG to JavaScript versions kept as a fixture, across palettes, aspects and options, object overrides included (the other ten were written as JSON from the start).
|
|
437
710
|
|
|
438
711
|
## Astro
|
|
439
712
|
|
|
@@ -536,6 +809,8 @@ How it rasterises, and why:
|
|
|
536
809
|
- **Blur** is SVG `feGaussianBlur`, through sharp's librsvg. librsvg clips a filter's input to the canvas, so a blur near an edge pulls in transparency and the field fades there (a vignette). `renderImage` renders each `mesh` layer on its own canvas padded by 3σ, at the smallest scale its finest blur allows (blur this heavy has no detail to lose), then crops and upscales it: full-bleed, and cheaper than the unpadded render. Browsers draw the plain SVG's filters without that clip.
|
|
537
810
|
- **Textures are image tiles.** The riso speckle and halftone are small PNG tiles in an SVG `<pattern>`, and dithers are one PNG pixel per cell scaled with `image-rendering: pixelated` (a minimal in-library PNG encoder, so no sharp needed for SVG). Two librsvg slow paths are avoided on purpose: a pattern of one shape per cell (~1.5 s a canvas) and a mask under a transform (~10×), which is why misregistration moves the shapes, not the texture.
|
|
538
811
|
- **Grain** is not. librsvg's `feTurbulence` costs ~200 ms at that size, so `renderImage` splits the stack at each grain layer: the layers below are rasterised, seeded noise (a triangular distribution, closer to film than flat noise) is blended in JS with the W3C formulas (~10 ms; sharp's own overlay blend flattens partly transparent noise to almost nothing), and the layers above are composited on top. `renderSvg` keeps `feTurbulence`, which browsers draw quickly; the two grains are matched in strength.
|
|
812
|
+
- **Filters at release size.** libvips hands librsvg large documents in a way that makes filter-heavy SVGs slow far past their pixel count (a 3000² displacement map: ~28 s in one piece, ~5 s as tiles), so `renderImage` draws an SVG with filters past ~2.4 MP as 1024 px tiles in parallel, each with a margin round it so blurs and displacements see across the seam. It also splits `feMorphology` into an x pass and a y pass (the same max, O(r) not O(r²)), and shrinks a filter's region to the box the layer paints when the chain can't paint outside its source (worked out from the primitives). Dithers are computed on pixels. Together these took the slowest release covers from minutes to seconds (see the CHANGELOG).
|
|
813
|
+
- **Pixel effects** (spin, zoom, bokeh, distortions…) draw the layer once at its output pixels and work on typed arrays: polar resampling with running sums for spin and zoom (a long blur costs no more than a short one), an aperture convolved a row at a time from running totals for bokeh, and wide blurs on smaller copies. A full 3000² `renderImage` of a photo with any one effect takes 0.4–2.8 s (median 0.8 s; the photo alone 0.4 s); the slowest are `newsprint`, `motion` and `halftone`.
|
|
539
814
|
- **Text** is shaped by HarfBuzz (`harfbuzzjs`, a 440 KB WebAssembly build) and written as path data. opentype.js was tried first and dropped: Inter's kerning lives in GPOS extension lookups that neither its 1.x nor 2.x reads, and 2.0 throws on Inter's contextual substitutions.
|
|
540
815
|
|
|
541
816
|
**Grain versus file size.** Grain is the first thing a lossy encoder throws away. On a cover with grain, WebP quality 82 keeps about 40% of it, 90 about 60%, 94 about 78%, and the file grows each step (a dark aurora: 26 KB at 80, 60 KB at 85, 144 KB at 90; 19 KB at 90 without grain). **The default stays at WebP 90** (72 AVIF, 88 JPEG): the owner compared 80 and 90 on the gallery and 80 visibly loses the grain and starts to band the gradients. For a grid of small thumbnails, pass `quality: 80` or `grain: false` knowingly; for crisp grain, raise it or use PNG.
|
|
@@ -549,6 +824,16 @@ How it rasterises, and why:
|
|
|
549
824
|
|
|
550
825
|
Contour carries grain, so it stays lossy like the rest. Pass `lossless: true` to `renderImage` for any cover.
|
|
551
826
|
|
|
827
|
+
## Tests
|
|
828
|
+
|
|
829
|
+
`npm test` runs the suite, including:
|
|
830
|
+
|
|
831
|
+
- **Byte identity with 1.0**: 694 covers (every built-in recipe across palettes, aspects and options, and every 1.0 layer on its own), hashed with the 1.0.0 code before any 1.1 change, must render the same SVG.
|
|
832
|
+
- **Image filters**: numeric properties per effect (spin and zoom keep the centre pixel, threshold draws two colours, a lens turns a point into a disc of its radius, pixel sort keeps every pixel of a row, a turned motion blur is directional, masks leave the layer exact where they're 0), seeds, validation and the Worker fallback; `COVERS_SLOW=1` renders every effect at 3000².
|
|
833
|
+
- **A browser smoke test** in headless Chrome over the DevTools protocol (`COVERS_CDP`, default `http://localhost:9334`; skipped without one): vector and text covers draw the same bytes as Node, the pixel layers render, `renderBlob` encodes PNG and WebP, and HarfBuzz loads its wasm from a URL.
|
|
834
|
+
|
|
835
|
+
`npm run test:worker` runs covers inside workerd with `wrangler dev` (needs wrangler).
|
|
836
|
+
|
|
552
837
|
## Gallery
|
|
553
838
|
|
|
554
839
|
```bash
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arraypress/covers",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Covers, flyers, posters, release art, social formats and print stationery from one seed and a stack of layers: mesh gradients, grain, riso inks and halftones, knockout and justified type, torn paper, barcodes, safe zones and bleed, as SVG or WebP/PNG/AVIF/JPEG. Recipes are JSON. Runs in Node, browsers and Cloudflare Workers.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
7
7
|
"types": "src/index.d.ts",
|
|
@@ -11,6 +11,11 @@
|
|
|
11
11
|
"import": "./src/index.js",
|
|
12
12
|
"default": "./src/index.js"
|
|
13
13
|
},
|
|
14
|
+
"./node": {
|
|
15
|
+
"types": "./src/node.d.ts",
|
|
16
|
+
"import": "./src/node.js",
|
|
17
|
+
"default": "./src/node.js"
|
|
18
|
+
},
|
|
14
19
|
"./astro": {
|
|
15
20
|
"types": "./src/astro.d.ts",
|
|
16
21
|
"import": "./src/astro.js",
|
|
@@ -30,10 +35,12 @@
|
|
|
30
35
|
"LICENSE"
|
|
31
36
|
],
|
|
32
37
|
"scripts": {
|
|
33
|
-
"test": "node --test tests/*.test.js",
|
|
38
|
+
"test": "node --test --test-concurrency=1 tests/*.test.js",
|
|
39
|
+
"test:worker": "COVERS_WORKER=1 node --test tests/worker.test.js",
|
|
34
40
|
"typecheck": "tsc --noEmit",
|
|
35
41
|
"gallery": "node examples/gallery.mjs",
|
|
36
|
-
"schema": "node scripts/schema.mjs"
|
|
42
|
+
"schema": "node scripts/schema.mjs",
|
|
43
|
+
"generate": "node scripts/generate.mjs"
|
|
37
44
|
},
|
|
38
45
|
"keywords": [
|
|
39
46
|
"cover-image",
|
|
@@ -47,7 +54,21 @@
|
|
|
47
54
|
"sharp",
|
|
48
55
|
"harfbuzz",
|
|
49
56
|
"astro",
|
|
50
|
-
"build-time"
|
|
57
|
+
"build-time",
|
|
58
|
+
"flyer",
|
|
59
|
+
"poster",
|
|
60
|
+
"release-art",
|
|
61
|
+
"cover-art",
|
|
62
|
+
"spotify",
|
|
63
|
+
"social-media",
|
|
64
|
+
"brand-kit",
|
|
65
|
+
"business-card",
|
|
66
|
+
"print",
|
|
67
|
+
"bleed",
|
|
68
|
+
"riso",
|
|
69
|
+
"halftone",
|
|
70
|
+
"cloudflare-workers",
|
|
71
|
+
"browser"
|
|
51
72
|
],
|
|
52
73
|
"author": "ArrayPress Limited",
|
|
53
74
|
"license": "MIT",
|
|
@@ -94,5 +115,12 @@
|
|
|
94
115
|
"ajv": "^8.20.0",
|
|
95
116
|
"sharp": "^0.35.5",
|
|
96
117
|
"typescript": "^6.0.3"
|
|
118
|
+
},
|
|
119
|
+
"imports": {
|
|
120
|
+
"#harfbuzz": {
|
|
121
|
+
"workerd": "./src/platform/no-harfbuzz.js",
|
|
122
|
+
"worker": "./src/platform/no-harfbuzz.js",
|
|
123
|
+
"default": "harfbuzzjs"
|
|
124
|
+
}
|
|
97
125
|
}
|
|
98
126
|
}
|