@arraypress/covers 1.5.1 → 1.7.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 CHANGED
@@ -5,6 +5,40 @@ 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.7.0] — 2026-11-07
9
+
10
+ ### Added
11
+
12
+ - **`terminal-poster`**, on the 400-wide grid (4:5, 1:1, 9:16 and A-series portrait): a terminal / crypto launch poster in acid lime (`#c8ff00`, any `ink`) on black (`ground`), set like a HUD readout. A lime bar across the top with the terminal line, a small nested-square icon and a seeded code (`480-A<3.56.7.128>`); `INIT` in outline type; a small mono log under it (`CORE REVIEW [START]`, `CORE 1`–`CORE 6 …… OK` in three columns with dot leaders, `CORES STABLE`, `RESULTS [PRNTD]`); a big lime panel with chamfered corners, a tab and a notch along its top and foot, holding the mark large in the ground colour (or, with `pixel: true`, rastered into square cells through `@logomarks/core`'s `pixelCells`, `cells` across), tiny serials mirrored in two corners and a 3 × 3 dot grid; a row of HUD glyphs (a barcode, a crosshair, an ×, a square target, two seeded two-digit numbers); the `headline` (`Launch`) huge in a lime panel, fitted to its width and stretched taller on tall canvases; a notched footer with the name and a sign-off; short ticks down both margins. Options `name`, `headline`, `ink`, `ground`, `pixel`, `cells`, and the meta strings `init`, `terminal`, `code`, `serial`, `log` (`[start, stable, results]`), `core`, `status`, `signoff`. The display face is `font` (`Rubik Mono One` by default, OFL, not bundled: register it, or Inter stands in), the small type `mono` (`Space Mono`). The code, serial and numbers follow the seed.
13
+
14
+ ## [1.6.1] — 2026-11-06
15
+
16
+ ### Added
17
+
18
+ - **`image` `focus` points** (with sharp): `[x, y]` (0–1 in the photo) centres that point in the box, and `'subject'` centres a cut-out's head across (the alpha-weighted centre of the top 45% of its opaque rows), its top at the box's top. A cut-out moves freely sideways, the ground showing round it; an opaque photo keeps its box filled. Without sharp, `'subject'` falls back to `'top'` and a point to `'center'`.
19
+
20
+ ### Changed
21
+
22
+ - **`dissolve-poster`**: the cut-out runs full bleed and is centred on its face (`focus: 'subject'`; `focus: [x, y]` overrides), so a cropped top runs off the poster instead of showing a flat cut. The fade starts lower so the eyes stay clean: from 0.5 without a title, and 0.24 above the merge with one.
23
+
24
+ ## [1.6.0] — 2026-11-06
25
+
26
+ ### Added
27
+
28
+ - **`dissolve-poster`**, on the 400-wide grid (4:5, 1:1, 9:16): one flat strong ground (red `#e10a14` by default), the subject in the ink (a `cutout` or `photo` portrait, its tones mapped from the ink to the ground, or the mark large with no photo) dissolving into the ground through a growing diamond dot screen, tiny at the top of the fade and merging at its foot, a few seeded strays above. Small Swiss corner type (the name, a series or Vol. line, a credit, the year) and an optional big tight `title` along the foot sit over the dissolve; with a title the dots merge just above it. Options `ground`, `ink`, `dots` (default the ground), `direction`, `fadeStart`, `fadeEnd`, `cell`.
29
+ - The `halftoneFade` effect from `@logomarks/image-effects` 0.4.0 on any layer, and in the schema.
30
+
31
+ ### Changed
32
+
33
+ - Depends on `@logomarks/image-effects` `^0.4.0`.
34
+
35
+ ## [1.5.2] — 2026-11-06
36
+
37
+ ### Changed
38
+
39
+ - **`fluted-poster`**: `subject: 'name'` now sets the name's initial huge behind the glass, filling the height between the year and the title (stretched taller on tall canvases), the way a single letter stays legible and glassy behind ribs; the full name stays in the title and corner type. `initial` overrides the letter (one or two characters). The whole name across the width is `subject: 'word'`. With no mark, the initial is drawn.
40
+ - The gentle convex glass 1.5.1 gave the name is gone: every subject uses the `fluted` preset unless `preset` names another.
41
+
8
42
  ## [1.5.1] — 2026-11-06
9
43
 
10
44
  ### Changed
package/README.md CHANGED
@@ -36,7 +36,7 @@ await writeFile('public/covers/introducing-covers.webp', webp);
36
36
  - [API](https://github.com/arraypress/covers/blob/main/docs/api.md): every export and cover option
37
37
  - [Layers](https://github.com/arraypress/covers/blob/main/docs/layers.md): mesh, grain, text, diagrams, riso inks, dithers, poster type and more
38
38
  - [Effects](https://github.com/arraypress/covers/blob/main/docs/effects.md): filters and pixel effects on any layer, masks, paints
39
- - [Recipes](https://github.com/arraypress/covers/blob/main/docs/recipes.md): seventeen built-ins, 90 flyers and release covers, your own
39
+ - [Recipes](https://github.com/arraypress/covers/blob/main/docs/recipes.md): seventeen built-ins, 92 flyers and release covers, your own
40
40
  - [JSON recipes](https://github.com/arraypress/covers/blob/main/docs/json-recipes.md): a cover style as data, with no JavaScript
41
41
  - [Palettes](https://github.com/arraypress/covers/blob/main/docs/palettes.md): presets, one accent, a theme's tokens
42
42
  - [Formats, print and brand kits](https://github.com/arraypress/covers/blob/main/docs/formats.md): social sizes with safe zones, print with bleed, `kit`, `lint`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arraypress/covers",
3
- "version": "1.5.1",
3
+ "version": "1.7.0",
4
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",
@@ -86,7 +86,7 @@
86
86
  "dependencies": {
87
87
  "@arraypress/color-utils": "^1.3.0",
88
88
  "@logomarks/core": "^0.1.5",
89
- "@logomarks/image-effects": "^0.3.2",
89
+ "@logomarks/image-effects": "^0.4.0",
90
90
  "harfbuzzjs": "^1.6.3"
91
91
  },
92
92
  "peerDependencies": {
@@ -0,0 +1,72 @@
1
+ {
2
+ "$schema": "../../schema/recipe.schema.json",
3
+ "name": "dissolve-poster",
4
+ "description": "Halftone dissolve poster: one flat strong ground (red by default), the subject in the ink over it (a portrait, cut out or plain, its tones mapped from the ink to the ground; or, with no photo, the mark large), dissolving into the ground through a growing dot screen (@logomarks/image-effects' halftoneFade): tiny dots at the top of the fade, growing and merging at its foot, a few strays above, in the ground colour unless dots names another. By default the dots begin at half the height and merge by 92%; with a title they merge just above it, so the title stands on clear ground (fadeStart and fadeEnd, 0–1, set them). A cut-out runs full bleed, centred across on its face (focus [x, y], 0–1 in the photo, overrides), so a cropped top runs off the poster. Over it, small Swiss type the dots never touch: the name and a Vol. line across the top, an optional big tight title along the foot, a credit and the year under it. Drawn on a 400-wide grid as tall as the canvas, so it sets at 4:5, 1:1 and 9:16 alike; the strays follow the seed.",
5
+ "params": {
6
+ "name": "Kestrel",
7
+ "series": "Vol. 07 — Dissolve",
8
+ "year": "2026",
9
+ "credit": "",
10
+ "title": "",
11
+ "ground": "#e10a14",
12
+ "ink": "#0a0a0a",
13
+ "dots": "",
14
+ "direction": "up",
15
+ "fadeStart": "",
16
+ "fadeEnd": "",
17
+ "cell": "2.2vmin",
18
+ "focus": ""
19
+ },
20
+ "vars": {
21
+ "h": { "mul": [400, { "div": ["@height", "@width"] }] },
22
+ "end": { "if": "fadeEnd", "then": "{fadeEnd}", "else": { "if": "title", "then": { "sub": [0.78, { "div": [40, "$h"] }] }, "else": 0.92 } },
23
+ "start": { "if": "fadeStart", "then": "{fadeStart}", "else": { "if": "title", "then": { "sub": ["$end", 0.24] }, "else": { "min": [0.5, { "sub": ["$end", 0.1] }] } } }
24
+ },
25
+ "layers": [
26
+ { "id": "ground", "type": "solid", "color": "{ground}" },
27
+ {
28
+ "id": "subject",
29
+ "type": "group",
30
+ "viewBox": [0, 0, 400, "$h"],
31
+ "fit": "contain",
32
+ "effects": [{ "kind": "halftoneFade", "color": { "if": "dots", "then": "{dots}", "else": "{ground}" }, "direction": "{direction}", "start": "$start", "end": "$end", "cell": "{cell}", "grid": "diamond", "shape": "dot", "jitter": 0.3 }],
33
+ "layers": [
34
+ { "id": "field", "type": "solid", "color": "{ground}" },
35
+ { "id": "cutout", "type": "image", "when": "cutout", "src": "{cutout}", "fit": "cover", "focus": { "if": "focus", "then": "{focus}", "else": "subject" }, "duotone": ["{ink}", "{ground}"] },
36
+ { "id": "photo", "type": "image", "when": { "all": ["photo", { "not": "cutout" }] }, "src": "{photo}", "fit": "cover", "focus": { "if": "focus", "then": "{focus}", "else": "center" }, "duotone": ["{ink}", "{ground}"] },
37
+ { "id": "mark", "type": "outline", "when": { "all": ["mark", { "not": "cutout" }, { "not": "photo" }] }, "markup": "{mark}", "lines": 0, "x": 40, "y": 60, "width": 320, "height": { "sub": ["$h", 120] }, "color": "{ink}" }
38
+ ]
39
+ },
40
+ {
41
+ "id": "type",
42
+ "type": "group",
43
+ "viewBox": [0, 0, 400, "$h"],
44
+ "fit": "contain",
45
+ "layers": [
46
+ { "id": "name", "type": "text", "content": "{name}", "when": "name", "font": "Inter Tight", "weight": 700, "size": 8, "letterSpacing": 0, "x": 16, "y": 24, "maxWidth": 180, "maxLines": 1, "color": "{ink}" },
47
+ { "id": "series", "type": "text", "content": "{series}", "when": "series", "font": "Inter Tight", "weight": 500, "size": 8, "letterSpacing": 0, "x": 384, "y": 24, "align": "right", "maxWidth": 180, "maxLines": 1, "color": "{ink}" },
48
+ {
49
+ "id": "title",
50
+ "type": "text",
51
+ "content": "{title}",
52
+ "when": "title",
53
+ "transform": "uppercase",
54
+ "font": "Inter Tight",
55
+ "weight": 800,
56
+ "size": 80,
57
+ "lineHeight": 0.86,
58
+ "letterSpacing": -0.05,
59
+ "fit": "width",
60
+ "maxWidth": 368,
61
+ "maxHeight": { "mul": ["$h", 0.22] },
62
+ "anchor": "bottom-left",
63
+ "margin": 0,
64
+ "offset": { "x": 16, "y": -34 },
65
+ "color": "{ink}"
66
+ },
67
+ { "id": "credit", "type": "text", "content": { "if": "credit", "then": "{credit}", "else": "Design — {name}" }, "font": "Inter Tight", "weight": 500, "size": 7, "letterSpacing": 0, "x": 16, "y": { "sub": ["$h", 16] }, "maxWidth": 200, "maxLines": 1, "color": "{ink}" },
68
+ { "id": "year", "type": "text", "content": "{year}", "when": "year", "font": "Inter Tight", "weight": 500, "size": 7, "letterSpacing": 0, "x": 384, "y": { "sub": ["$h", 16] }, "align": "right", "maxWidth": 100, "maxLines": 1, "color": "{ink}" }
69
+ ]
70
+ }
71
+ ]
72
+ }
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "$schema": "../../schema/recipe.schema.json",
3
3
  "name": "fluted-poster",
4
- "description": "Fluted glass poster: the mark (or the name, set huge) in white on black, seen through reeded glass (@logomarks/image-effects' fluted effect: by default its fluted preset for a mark, and gentler glass for the name, 12 wide convex ribs at light refraction (0.15) and little defocus, so the word steps at the rib edges but never doubles; a preset given, fluted, reeded-deep, reeded-prism or reeded-soft, is used as it is), with a soft light in one accent colour behind it (none with accent \"\"). Over the glass, small Swiss type stays vector: the year stacked large top-left, the series top-right, a two-line title bottom-left with a glitched || ornament of plain bars right of it in the accent, credits and an edition line along the foot. The glass is rastered at the output size and embedded; the type is outlines. Drawn on a 400-wide grid as tall as the canvas, so it sets at 4:5, 1:1, 9:16 and A-series portrait alike; the ornament's glitch follows the seed.",
4
+ "description": "Fluted glass poster: a subject in white on black, seen through reeded glass (@logomarks/image-effects' fluted effect, its fluted preset unless preset names another: reeded-deep, reeded-prism, reeded-soft): the mark (subject 'mark', the default), the name's initial set huge, filling the height between the year and the title (about two thirds of a 4:5 poster, three quarters of a 9:16 one, stretched taller there) (subject 'name', or with no mark; initial overrides it, one or two characters), or the whole name across the width (subject 'word'). A soft light in one accent colour sits behind it (none with accent \"\"). Over the glass, small Swiss type stays vector: the year stacked large top-left, the series top-right, a two-line title bottom-left with a glitched || ornament of plain bars right of it in the accent, credits and an edition line along the foot. The glass is rastered at the output size and embedded; the type is outlines. Drawn on a 400-wide grid as tall as the canvas, so it sets at 4:5, 1:1, 9:16 and A-series portrait alike; the ornament's glitch follows the seed.",
5
5
  "params": {
6
6
  "name": "Kestrel",
7
7
  "subject": "mark",
8
8
  "preset": "",
9
+ "initial": "",
9
10
  "title": ["Through", "the glass"],
10
11
  "year": "2026",
11
12
  "series": "",
@@ -31,14 +32,23 @@
31
32
  "type": "group",
32
33
  "viewBox": [0, 0, 400, "$h"],
33
34
  "fit": "contain",
34
- "effects": [{ "kind": "fluted", "preset": { "if": "preset", "then": "{preset}", "else": "fluted" }, "profile": { "if": { "all": [{ "any": [{ "subject": "name" }, { "not": "mark" }] }, { "not": "preset" }] }, "then": "convex" }, "ribs": { "if": { "all": [{ "any": [{ "subject": "name" }, { "not": "mark" }] }, { "not": "preset" }] }, "then": 12 }, "refraction": { "if": { "all": [{ "any": [{ "subject": "name" }, { "not": "mark" }] }, { "not": "preset" }] }, "then": 0.15 }, "stretch": { "if": { "all": [{ "any": [{ "subject": "name" }, { "not": "mark" }] }, { "not": "preset" }] }, "then": "0.4vmin" }, "chroma": { "if": { "all": [{ "any": [{ "subject": "name" }, { "not": "mark" }] }, { "not": "preset" }] }, "then": 0.2 } }],
35
+ "effects": [{ "kind": "fluted", "preset": { "if": "preset", "then": "{preset}", "else": "fluted" } }],
35
36
  "layers": [
36
37
  { "id": "light", "type": "radial", "when": "accent", "x": 0.5, "y": 0.47, "colors": ["{accent}", "{ground}"], "radius": "52vmin", "opacity": 0.75 },
37
- { "id": "mark", "type": "outline", "when": { "all": ["mark", { "not": { "subject": "name" } }] }, "markup": "{mark}", "lines": 0, "x": 70, "y": { "sub": [{ "mul": ["$h", 0.47] }, 130] }, "width": 260, "height": 260, "color": "{ink}" },
38
+ { "id": "mark", "type": "outline", "when": { "all": ["mark", { "subject": "mark" }] }, "markup": "{mark}", "lines": 0, "x": 70, "y": { "sub": [{ "mul": ["$h", 0.47] }, 130] }, "width": 260, "height": 260, "color": "{ink}" },
39
+ {
40
+ "id": "initial",
41
+ "type": "group",
42
+ "when": { "any": [{ "subject": "name" }, { "all": [{ "not": "mark" }, { "subject": "mark" }] }] },
43
+ "transform": { "scale": [1, "$tall"], "origin": [200, { "div": [{ "sub": ["$h", 36] }, 2] }] },
44
+ "layers": [
45
+ { "id": "letter", "type": "text", "content": { "if": "initial", "then": { "slice": ["{initial}", 0, 2] }, "else": { "slice": ["{name}", 0, 1] } }, "transform": "uppercase", "font": "Inter Tight", "weight": 800, "size": 400, "lineHeight": 0.73, "letterSpacing": -0.04, "fit": "width", "maxWidth": 330, "maxHeight": { "div": [{ "sub": ["$h", 164] }, "$tall"] }, "anchor": "center", "margin": 0, "offset": { "x": 0, "y": -18 }, "color": "{ink}" }
46
+ ]
47
+ },
38
48
  {
39
49
  "id": "word",
40
50
  "type": "group",
41
- "when": { "any": [{ "subject": "name" }, { "not": "mark" }] },
51
+ "when": { "subject": "word" },
42
52
  "transform": { "scale": [1, "$tall"], "origin": [200, { "mul": ["$h", 0.47] }] },
43
53
  "layers": [
44
54
  { "id": "name", "type": "text", "content": "{name}", "transform": "uppercase", "font": "Inter Tight", "weight": 800, "size": 120, "lineHeight": 0.9, "letterSpacing": -0.05, "fit": "width", "maxWidth": 360, "maxHeight": { "div": [{ "mul": ["$h", 0.42] }, "$tall"] }, "anchor": "center", "margin": 0, "offset": { "x": 0, "y": { "mul": ["$h", -0.03] } }, "color": "{ink}" }
@@ -0,0 +1,188 @@
1
+ {
2
+ "$schema": "../../schema/recipe.schema.json",
3
+ "name": "terminal-poster",
4
+ "description": "Terminal launch poster: acid lime (any ink) on black, set like a HUD readout. A lime bar across the top with the terminal line, a small nested-square icon and a seeded code (480-A<3.56.7.128>); INIT in outline type; a small mono log under it (CORE REVIEW [START], CORE 1–6 … OK in three columns with dot leaders, CORES STABLE, RESULTS [PRNTD]); a big lime panel with chamfered corners, a tab and a notch along its top and foot, holding the mark large in the ground colour (or, with pixel: true, rastered into square cells, @logomarks/core's pixelCells), tiny serials mirrored in two corners and a 3 × 3 dot grid; a row of HUD glyphs (a barcode, a crosshair, an ×, a square target, two seeded two-digit numbers); the headline huge in a lime panel, fitted to its width; and a notched lime footer with the name. Short ticks run down both margins. The display face is font (Rubik Mono One by default: wide, mono, OFL; register it, or it falls back to Inter); the small type is mono (Space Mono). Drawn on a 400-wide grid as tall as the canvas, so it sets at 4:5, 1:1, 9:16 and A-series portrait alike; the code, serial and numbers follow the seed.",
5
+ "params": { "name": "Kestrel", "headline": "Launch", "init": "Init", "terminal": "Public access terminal", "code": "", "serial": "", "log": ["Core review [start]", "Cores stable", "Results [prntd]"], "core": "Core", "status": "OK", "signoff": "End of line", "pixel": false, "cells": 20, "font": "Rubik Mono One", "mono": "Space Mono", "ink": "#c8ff00", "ground": "#000000" },
6
+ "vars": {
7
+ "h": { "mul": [400, { "div": ["@height", "@width"] }] },
8
+ "tall": { "min": [1.45, { "max": [0.9, { "div": ["$h", 500] }] }] },
9
+ "pt": 131,
10
+ "fb": { "sub": ["$h", 10] },
11
+ "ft": { "sub": ["$h", 24] },
12
+ "hb": { "sub": ["$h", 32] },
13
+ "ht": { "sub": [{ "sub": ["$h", 32] }, { "mul": [66, "$tall"] }] },
14
+ "gy": { "sub": ["$ht", 30] },
15
+ "pb": { "sub": ["$ht", 42] },
16
+ "c1": { "int": [100, 999] },
17
+ "c2": { "pick": ["A", "B", "C", "D", "E", "F", "H", "K", "M", "R", "X"] },
18
+ "c3": { "int": [1, 9] },
19
+ "c4": { "int": [10, 99] },
20
+ "c5": { "int": [1, 9] },
21
+ "c6": { "int": [100, 255] },
22
+ "s1": { "int": [1000, 9999] },
23
+ "s2": { "pick": ["A", "C", "K", "R", "T", "X", "Z"] },
24
+ "s3": { "int": [10, 99] },
25
+ "s4": { "int": [10, 99] },
26
+ "v1": { "int": [1, 9] },
27
+ "v2": { "int": [10, 99] },
28
+ "n1": { "int": [10, 99] },
29
+ "n2": { "int": [10, 99] }
30
+ },
31
+ "layers": [
32
+ { "id": "ground", "type": "solid", "color": "{ground}" },
33
+ {
34
+ "id": "poster",
35
+ "type": "group",
36
+ "viewBox": [0, 0, 400, "$h"],
37
+ "fit": "contain",
38
+ "layers": [
39
+ { "id": "ticks", "type": "repeat", "count": { "floor": { "div": [{ "sub": ["$h", 28] }, 6] } }, "x": 0, "y": 14, "step": [0, 6], "layer": { "type": "shape", "items": [{ "kind": "line", "x1": 5, "y1": 0, "x2": "{#i % 5 ? 8 : 10}", "y2": 0 }, { "kind": "line", "x1": 395, "y1": 0, "x2": "{#i % 5 ? 392 : 390}", "y2": 0 }], "stroke": "{ink}", "strokeWidth": 0.6, "opacity": 0.7 } },
40
+ { "id": "band", "type": "shape", "kind": "polygon", "points": [[16, 14], [384, 14], [384, 26], [378, 32], [16, 32]], "fill": "{ink}" },
41
+ { "id": "icon", "type": "shape", "items": [{ "kind": "rect", "x": 21.5, "y": 17.5, "width": 11, "height": 11, "fill": "none", "stroke": "{ground}", "strokeWidth": 1 }, { "kind": "rect", "x": 24.5, "y": 20.5, "width": 5, "height": 5, "fill": "{ground}" }], "fill": "{ground}" },
42
+ { "id": "terminal", "type": "text", "content": "{terminal}", "font": "{mono}", "weight": 700, "size": 7.5, "letterSpacing": 0.02, "x": 38, "y": 26, "maxWidth": 200, "maxLines": 1, "color": "{ground}", "transform": "uppercase" },
43
+ { "id": "code", "type": "text", "content": { "if": "code", "then": "{code}", "else": { "join": [["$c1", "-", "$c2", "<", "$c3", ".", "$c4", ".", "$c5", ".", "$c6", ">"], ""] } }, "font": "{mono}", "weight": 700, "size": 7.5, "letterSpacing": 0.02, "x": 376, "y": 26, "maxWidth": 130, "maxLines": 1, "color": "{ground}", "align": "right" },
44
+ { "id": "init", "type": "text", "content": "{init}", "when": "init", "transform": "uppercase", "font": "{font}", "weight": 700, "size": 34, "letterSpacing": 0.04, "x": 15, "y": 72, "maxWidth": 200, "maxLines": 1, "fill": "none", "stroke": "{ink}", "strokeWidth": 0.8, "color": "{ink}" },
45
+ { "id": "boot", "type": "text", "content": { "join": [["SYS", "$v1", ".", "$v2", " // ", "{name}"], ""] }, "font": "{mono}", "weight": 700, "size": 6, "letterSpacing": 0.02, "x": 384, "y": 52, "maxWidth": 170, "maxLines": 1, "color": "{ink}", "align": "right", "transform": "uppercase", "opacity": 0.75 },
46
+ { "id": "rule", "type": "shape", "kind": "line", "x1": 214, "y1": 61, "x2": 384, "y2": 61, "stroke": "{ink}", "strokeWidth": 0.6, "dash": [1.2, 1.6] },
47
+ { "id": "mode", "type": "text", "content": { "join": [["RUN ", "$n1", "/", "$n2", " ▶"], ""] }, "font": "{mono}", "weight": 700, "size": 6, "letterSpacing": 0.02, "x": 384, "y": 72, "maxWidth": 170, "maxLines": 1, "color": "{ink}", "align": "right", "opacity": 0.75 },
48
+ { "id": "start", "type": "text", "content": "{log.0}", "font": "{mono}", "weight": 700, "size": 6, "letterSpacing": 0.02, "x": 16, "y": 88, "maxWidth": 170, "maxLines": 1, "color": "{ink}", "transform": "uppercase" },
49
+ { "id": "core1", "type": "text", "content": "{core} 1", "font": "{mono}", "weight": 700, "size": 6, "letterSpacing": 0.02, "x": 16, "y": 99, "maxWidth": 70, "maxLines": 1, "color": "{ink}", "transform": "uppercase" },
50
+ { "id": "ok1", "type": "text", "content": "{status}", "font": "{mono}", "weight": 700, "size": 6, "letterSpacing": 0.02, "x": 134, "y": 99, "maxWidth": 40, "maxLines": 1, "color": "{ink}", "align": "right", "transform": "uppercase" },
51
+ { "id": "core2", "type": "text", "content": "{core} 2", "font": "{mono}", "weight": 700, "size": 6, "letterSpacing": 0.02, "x": 141, "y": 99, "maxWidth": 70, "maxLines": 1, "color": "{ink}", "transform": "uppercase" },
52
+ { "id": "ok2", "type": "text", "content": "{status}", "font": "{mono}", "weight": 700, "size": 6, "letterSpacing": 0.02, "x": 259, "y": 99, "maxWidth": 40, "maxLines": 1, "color": "{ink}", "align": "right", "transform": "uppercase" },
53
+ { "id": "core3", "type": "text", "content": "{core} 3", "font": "{mono}", "weight": 700, "size": 6, "letterSpacing": 0.02, "x": 266, "y": 99, "maxWidth": 70, "maxLines": 1, "color": "{ink}", "transform": "uppercase" },
54
+ { "id": "ok3", "type": "text", "content": "{status}", "font": "{mono}", "weight": 700, "size": 6, "letterSpacing": 0.02, "x": 384, "y": 99, "maxWidth": 40, "maxLines": 1, "color": "{ink}", "align": "right", "transform": "uppercase" },
55
+ { "id": "core4", "type": "text", "content": "{core} 4", "font": "{mono}", "weight": 700, "size": 6, "letterSpacing": 0.02, "x": 16, "y": 108, "maxWidth": 70, "maxLines": 1, "color": "{ink}", "transform": "uppercase" },
56
+ { "id": "ok4", "type": "text", "content": "{status}", "font": "{mono}", "weight": 700, "size": 6, "letterSpacing": 0.02, "x": 134, "y": 108, "maxWidth": 40, "maxLines": 1, "color": "{ink}", "align": "right", "transform": "uppercase" },
57
+ { "id": "core5", "type": "text", "content": "{core} 5", "font": "{mono}", "weight": 700, "size": 6, "letterSpacing": 0.02, "x": 141, "y": 108, "maxWidth": 70, "maxLines": 1, "color": "{ink}", "transform": "uppercase" },
58
+ { "id": "ok5", "type": "text", "content": "{status}", "font": "{mono}", "weight": 700, "size": 6, "letterSpacing": 0.02, "x": 259, "y": 108, "maxWidth": 40, "maxLines": 1, "color": "{ink}", "align": "right", "transform": "uppercase" },
59
+ { "id": "core6", "type": "text", "content": "{core} 6", "font": "{mono}", "weight": 700, "size": 6, "letterSpacing": 0.02, "x": 266, "y": 108, "maxWidth": 70, "maxLines": 1, "color": "{ink}", "transform": "uppercase" },
60
+ { "id": "ok6", "type": "text", "content": "{status}", "font": "{mono}", "weight": 700, "size": 6, "letterSpacing": 0.02, "x": 384, "y": 108, "maxWidth": 40, "maxLines": 1, "color": "{ink}", "align": "right", "transform": "uppercase" },
61
+ {
62
+ "id": "leaders",
63
+ "type": "shape",
64
+ "items": [
65
+ { "kind": "line", "x1": "{layout.core1.right + 3}", "y1": 98.6, "x2": "{layout.ok1.left - 3}", "y2": 98.6 },
66
+ { "kind": "line", "x1": "{layout.core2.right + 3}", "y1": 98.6, "x2": "{layout.ok2.left - 3}", "y2": 98.6 },
67
+ { "kind": "line", "x1": "{layout.core3.right + 3}", "y1": 98.6, "x2": "{layout.ok3.left - 3}", "y2": 98.6 },
68
+ { "kind": "line", "x1": "{layout.core4.right + 3}", "y1": 107.6, "x2": "{layout.ok4.left - 3}", "y2": 107.6 },
69
+ { "kind": "line", "x1": "{layout.core5.right + 3}", "y1": 107.6, "x2": "{layout.ok5.left - 3}", "y2": 107.6 },
70
+ { "kind": "line", "x1": "{layout.core6.right + 3}", "y1": 107.6, "x2": "{layout.ok6.left - 3}", "y2": 107.6 }
71
+ ],
72
+ "stroke": "{ink}",
73
+ "strokeWidth": 0.9,
74
+ "dash": [0.9, 1.5]
75
+ },
76
+ { "id": "stable", "type": "text", "content": "{log.1}", "font": "{mono}", "weight": 700, "size": 6, "letterSpacing": 0.02, "x": 16, "y": 119, "maxWidth": 170, "maxLines": 1, "color": "{ink}", "transform": "uppercase" },
77
+ { "id": "results", "type": "text", "content": "{log.2}", "font": "{mono}", "weight": 700, "size": 6, "letterSpacing": 0.02, "x": 384, "y": 119, "maxWidth": 170, "maxLines": 1, "color": "{ink}", "transform": "uppercase", "align": "right" },
78
+ {
79
+ "id": "panel",
80
+ "type": "shape",
81
+ "kind": "polygon",
82
+ "points": [
83
+ [16, { "add": ["$pt", 8] }],
84
+ [24, "$pt"],
85
+ [60, "$pt"],
86
+ [65, { "add": ["$pt", -5] }],
87
+ [136, { "add": ["$pt", -5] }],
88
+ [141, "$pt"],
89
+ [250, "$pt"],
90
+ [255, { "add": ["$pt", 5] }],
91
+ [300, { "add": ["$pt", 5] }],
92
+ [305, "$pt"],
93
+ [384, "$pt"],
94
+ [384, { "add": ["$pb", -8] }],
95
+ [376, "$pb"],
96
+ [340, "$pb"],
97
+ [335, { "add": ["$pb", 5] }],
98
+ [264, { "add": ["$pb", 5] }],
99
+ [259, "$pb"],
100
+ [150, "$pb"],
101
+ [145, { "add": ["$pb", -5] }],
102
+ [100, { "add": ["$pb", -5] }],
103
+ [95, "$pb"],
104
+ [16, "$pb"]
105
+ ],
106
+ "fill": "{ink}"
107
+ },
108
+ { "id": "mark", "type": "outline", "when": { "all": ["mark", { "not": "pixel" }] }, "markup": "{mark}", "lines": 0, "x": 40, "y": { "add": ["$pt", 22] }, "width": 320, "height": { "sub": [{ "sub": ["$pb", "$pt"] }, 44] }, "color": "{ground}" },
109
+ { "id": "pixels", "type": "mosaic", "when": { "all": ["mark", "pixel"] }, "markup": "{mark}", "x": 40, "y": { "add": ["$pt", 22] }, "width": 320, "height": { "sub": [{ "sub": ["$pb", "$pt"] }, 44] }, "cells": "{cells}", "drop": 0, "gap": 0.08, "color": "{ground}" },
110
+ { "id": "serial", "type": "text", "content": { "if": "serial", "then": "{serial}", "else": { "join": [["SN.", "$s1", "-", "$s2", "$s3", " / LOT ", "$s4"], ""] } }, "font": "{mono}", "weight": 700, "size": 5, "letterSpacing": 0.02, "x": 26, "y": { "add": ["$pt", 15] }, "maxWidth": 120, "maxLines": 1, "color": "{ground}" },
111
+ {
112
+ "id": "mirror",
113
+ "type": "group",
114
+ "transform": { "rotate": 180, "origin": [374, { "sub": ["$pb", 15] }] },
115
+ "layers": [{ "id": "serialMirror", "type": "text", "content": { "if": "serial", "then": "{serial}", "else": { "join": [["SN.", "$s1", "-", "$s2", "$s3", " / LOT ", "$s4"], ""] } }, "font": "{mono}", "weight": 700, "size": 5, "letterSpacing": 0.02, "x": 374, "y": { "sub": ["$pb", 15] }, "maxWidth": 120, "maxLines": 1, "color": "{ground}" }]
116
+ },
117
+ {
118
+ "id": "dots",
119
+ "type": "shape",
120
+ "fill": "{ground}",
121
+ "items": [
122
+ { "kind": "circle", "cx": 362, "cy": { "add": ["$pt", 10] }, "r": 1.1 },
123
+ { "kind": "circle", "cx": 368, "cy": { "add": ["$pt", 10] }, "r": 1.1 },
124
+ { "kind": "circle", "cx": 374, "cy": { "add": ["$pt", 10] }, "r": 1.1 },
125
+ { "kind": "circle", "cx": 362, "cy": { "add": ["$pt", 16] }, "r": 1.1 },
126
+ { "kind": "circle", "cx": 368, "cy": { "add": ["$pt", 16] }, "r": 1.1 },
127
+ { "kind": "circle", "cx": 374, "cy": { "add": ["$pt", 16] }, "r": 1.1 },
128
+ { "kind": "circle", "cx": 362, "cy": { "add": ["$pt", 22] }, "r": 1.1 },
129
+ { "kind": "circle", "cx": 368, "cy": { "add": ["$pt", 22] }, "r": 1.1 },
130
+ { "kind": "circle", "cx": 374, "cy": { "add": ["$pt", 22] }, "r": 1.1 }
131
+ ]
132
+ },
133
+ { "id": "barcode", "type": "barcode", "kind": "seeded", "x": 16, "y": "$gy", "width": 76, "height": 18, "color": "{ink}" },
134
+ {
135
+ "id": "glyphs",
136
+ "type": "shape",
137
+ "stroke": "{ink}",
138
+ "strokeWidth": 1,
139
+ "fill": "none",
140
+ "items": [
141
+ { "kind": "circle", "cx": 116, "cy": { "add": ["$gy", 9] }, "r": 6 },
142
+ { "kind": "line", "x1": 106, "y1": { "add": ["$gy", 9] }, "x2": 126, "y2": { "add": ["$gy", 9] } },
143
+ { "kind": "line", "x1": 116, "y1": { "add": ["$gy", -1] }, "x2": 116, "y2": { "add": ["$gy", 19] } },
144
+ { "kind": "line", "x1": 140, "y1": { "add": ["$gy", 3] }, "x2": 152, "y2": { "add": ["$gy", 15] } },
145
+ { "kind": "line", "x1": 152, "y1": { "add": ["$gy", 3] }, "x2": 140, "y2": { "add": ["$gy", 15] } },
146
+ { "kind": "rect", "x": 166.5, "y": { "add": ["$gy", 2.5] }, "width": 13, "height": 13 },
147
+ { "kind": "rect", "x": 171, "y": { "add": ["$gy", 7] }, "width": 4, "height": 4, "fill": "{ink}", "stroke": "none" }
148
+ ]
149
+ },
150
+ { "id": "readout", "type": "text", "content": [{ "join": [["X ", "$c4", ".", "$c6"], ""] }, { "join": [["Y ", "$s3", ".", "$c1"], ""] }], "font": "{mono}", "weight": 700, "size": 5.5, "letterSpacing": 0.02, "x": 196, "y": { "add": ["$gy", 7.5] }, "maxWidth": 60, "maxLines": 2, "color": "{ink}", "lineHeight": 1.45, "opacity": 0.75 },
151
+ { "id": "n1", "type": "text", "content": "$n1", "font": "{font}", "weight": 700, "size": 15, "letterSpacing": 0, "x": 330, "y": { "add": ["$gy", 16.5] }, "align": "right", "maxWidth": 60, "maxLines": 1, "color": "{ink}" },
152
+ { "id": "slash", "type": "shape", "kind": "line", "x1": 340, "y1": { "add": ["$gy", 17] }, "x2": 348, "y2": { "add": ["$gy", 1] }, "stroke": "{ink}", "strokeWidth": 1 },
153
+ { "id": "n2", "type": "text", "content": "$n2", "font": "{font}", "weight": 700, "size": 15, "letterSpacing": 0, "x": 384, "y": { "add": ["$gy", 16.5] }, "align": "right", "maxWidth": 60, "maxLines": 1, "color": "{ink}" },
154
+ { "id": "headPanel", "type": "shape", "kind": "polygon", "points": [[16, "$ht"], [22, "$ht"], [26, { "add": ["$ht", -4] }], [84, { "add": ["$ht", -4] }], [88, "$ht"], [372, "$ht"], [384, { "add": ["$ht", 12] }], [384, "$hb"], [16, "$hb"]], "fill": "{ink}" },
155
+ {
156
+ "id": "stretch",
157
+ "type": "group",
158
+ "transform": { "scale": [1, "$tall"], "origin": [200, { "div": [{ "add": ["$ht", "$hb"] }, 2] }] },
159
+ "layers": [
160
+ {
161
+ "id": "headline",
162
+ "type": "text",
163
+ "content": "{headline}",
164
+ "when": "headline",
165
+ "transform": "uppercase",
166
+ "font": "{font}",
167
+ "weight": 800,
168
+ "size": 80,
169
+ "lineHeight": 1,
170
+ "letterSpacing": -0.01,
171
+ "fit": "width",
172
+ "maxWidth": 352,
173
+ "maxHeight": { "div": [{ "sub": [{ "sub": ["$hb", "$ht"] }, 18] }, "$tall"] },
174
+ "maxLines": 1,
175
+ "anchor": "center",
176
+ "margin": 0,
177
+ "offset": { "x": 0, "y": { "sub": [{ "div": [{ "add": ["$ht", "$hb"] }, 2] }, { "div": ["$h", 2] }] } },
178
+ "color": "{ground}"
179
+ }
180
+ ]
181
+ },
182
+ { "id": "footer", "type": "shape", "kind": "polygon", "points": [[16, "$ft"], [206, "$ft"], [211, { "add": ["$ft", 4] }], [252, { "add": ["$ft", 4] }], [257, "$ft"], [300, "$ft"], [306, { "add": ["$ft", 6] }], [384, { "add": ["$ft", 6] }], [384, "$fb"], [16, "$fb"]], "fill": "{ink}" },
183
+ { "id": "name", "type": "text", "content": "{name}", "font": "{mono}", "weight": 700, "size": 7, "letterSpacing": 0.02, "x": 22, "y": { "sub": ["$h", 14] }, "maxWidth": 180, "maxLines": 1, "color": "{ground}", "transform": "uppercase", "when": "name" },
184
+ { "id": "signoff", "type": "text", "content": "{signoff}", "font": "{mono}", "weight": 700, "size": 5.5, "letterSpacing": 0.02, "x": 378, "y": { "sub": ["$h", 12] }, "maxWidth": 76, "maxLines": 1, "color": "{ground}", "transform": "uppercase", "align": "right", "when": "signoff" }
185
+ ]
186
+ }
187
+ ]
188
+ }
@@ -739,6 +739,7 @@
739
739
  "vhs",
740
740
  "jpeg",
741
741
  "print",
742
+ "halftoneFade",
742
743
  "newsprint",
743
744
  "chromatic",
744
745
  "rgbSplit",
@@ -4317,6 +4318,97 @@
4317
4318
  }
4318
4319
  }
4319
4320
  },
4321
+ {
4322
+ "if": {
4323
+ "properties": {
4324
+ "kind": {
4325
+ "const": "halftoneFade"
4326
+ }
4327
+ },
4328
+ "required": [
4329
+ "kind"
4330
+ ]
4331
+ },
4332
+ "then": {
4333
+ "properties": {
4334
+ "color": {
4335
+ "$ref": "#/$defs/value",
4336
+ "description": "The dots: a colour, none (they erase), or the colour round the edge (default)."
4337
+ },
4338
+ "direction": {
4339
+ "$ref": "#/$defs/value",
4340
+ "description": "up (default: dissolves from the bottom) | down | left | right | radial (from the edges in)"
4341
+ },
4342
+ "angle": {
4343
+ "$ref": "#/$defs/value",
4344
+ "description": "Degrees the dissolve travels (0 along x, 90 down); wins over direction."
4345
+ },
4346
+ "start": {
4347
+ "$ref": "#/$defs/value",
4348
+ "description": "0–1 along the fade: where the dots begin. Default 0.45."
4349
+ },
4350
+ "end": {
4351
+ "$ref": "#/$defs/value",
4352
+ "description": "0–1: where they have merged into solid. Default 0.92."
4353
+ },
4354
+ "curve": {
4355
+ "$ref": "#/$defs/value",
4356
+ "description": "linear (default) | ease-in | ease-out | smooth"
4357
+ },
4358
+ "cell": {
4359
+ "$ref": "#/$defs/value",
4360
+ "description": "Length: the dot pitch. Default \"2.2vmin\"."
4361
+ },
4362
+ "grid": {
4363
+ "$ref": "#/$defs/value",
4364
+ "description": "diamond (default, a 45° screen) | square"
4365
+ },
4366
+ "shape": {
4367
+ "$ref": "#/$defs/value",
4368
+ "description": "dot (default) | square | diamond"
4369
+ },
4370
+ "jitter": {
4371
+ "$ref": "#/$defs/value",
4372
+ "description": "0–1: dot sizes varied, a few strays above start."
4373
+ },
4374
+ "seed": {
4375
+ "$ref": "#/$defs/value",
4376
+ "description": "Number or string."
4377
+ },
4378
+ "mask": {
4379
+ "$ref": "#/$defs/value",
4380
+ "description": "{ gradient: linear | radial | band, from, to, center, angle, aspect, invert, ease } or { layers, invert }: where the effect applies."
4381
+ },
4382
+ "colorSpace": {
4383
+ "$ref": "#/$defs/value",
4384
+ "description": "sRGB (default) | linearRGB: where filter primitives do their arithmetic."
4385
+ },
4386
+ "pad": {
4387
+ "$ref": "#/$defs/value",
4388
+ "description": "Filter region padding, a fraction of the canvas."
4389
+ }
4390
+ },
4391
+ "propertyNames": {
4392
+ "enum": [
4393
+ "color",
4394
+ "direction",
4395
+ "angle",
4396
+ "start",
4397
+ "end",
4398
+ "curve",
4399
+ "cell",
4400
+ "grid",
4401
+ "shape",
4402
+ "jitter",
4403
+ "seed",
4404
+ "kind",
4405
+ "mask",
4406
+ "colorSpace",
4407
+ "pad"
4408
+ ]
4409
+ }
4410
+ }
4411
+ },
4320
4412
  {
4321
4413
  "if": {
4322
4414
  "properties": {
@@ -5662,7 +5754,7 @@
5662
5754
  },
5663
5755
  "focus": {
5664
5756
  "$ref": "#/$defs/value",
5665
- "description": "An anchor, attention or entropy."
5757
+ "description": "An anchor, attention or entropy; [x, y] (0–1 in the photo) to centre that point; subject: a cut-out's head centred across, its top at the box's top."
5666
5758
  },
5667
5759
  "region": {
5668
5760
  "$ref": "#/$defs/value",
package/src/index.d.ts CHANGED
@@ -259,8 +259,8 @@ export interface ImageLayer extends BaseLayer {
259
259
  y?: Length | `${number}%`;
260
260
  width?: Length | `${number}%`;
261
261
  height?: Length | `${number}%`;
262
- /** Crop focus: an anchor, or (with sharp) `'attention'` / `'entropy'`. Default `'center'`. */
263
- focus?: Anchor | 'attention' | 'entropy';
262
+ /** Crop focus: an anchor, or (with sharp) `'attention'` / `'entropy'`; or (with sharp) a point centred in the box, `[x, y]` 0–1 in the photo, or `'subject'`, a cut-out's head centred across with its top at the box's top. Default `'center'`. */
263
+ focus?: Anchor | 'attention' | 'entropy' | 'subject' | [number, number];
264
264
  /** Default the whole canvas. */
265
265
  region?: Region;
266
266
  /** Map tones to two colours (shadows, highlights); `true` derives them from the palette. */
@@ -14,7 +14,7 @@ export const LAYER_OPTIONS = {
14
14
  radial: { colors: 'Centre → edge.', x: 'Centre x, 0–1.', y: 'Centre y, 0–1.', radius: 'Length. Default "80vmax".', ease: 'Default true.' },
15
15
  mesh: { colors: 'Blob colours.', hues: 'Distinct colours to use. Default 3.', hueSpan: 'Keep the colours within this many degrees of hue (OKLCH): neighbouring hues only.', count: 'Blobs. Default 4.', blur: 'Length. Default "11vmax".', spread: '0–1. Default 1 (sweep), 0.7 (ring).', size: '[min, max] radius, fractions of the longer side.', base: 'Fill under the fields: "auto" (a gradient across the sweep between the first two colours; default), "bg", a colour, or null.', arrangement: 'sweep (default: long fields along one direction) | ring', angle: 'Sweep direction in degrees. Default seeded.', streak: 'How much more the sweep blurs along than across. Default 1.5.', tone: '{ l: [min, max], c: [min, max] }: pull colours into an OKLCH band.', fold: 'true or { color, to, position, bend, softness, sheen }: a second field meeting the first along a soft curve.', streaks: 'Thin light motion streaks along the sweep. Default 0.', from: 'A photo (path or bytes) to take the colours from (needs sharp).', blobs: 'Explicit [{ x, y, r, squash, rotate, color, opacity }].' },
16
16
  stripes: { direction: 'vertical | horizontal | diagonal', angle: 'For diagonal.', count: 'Stripes. Default 8.', widths: 'even | random | taper | [weights]', taper: 'Ratio for widths: taper.', colors: 'Cycled.', gap: 'Length.', radius: 'Length.', region: '{ x, y, width, height } fractions.', regionRadius: 'Length.' },
17
- image: { x: 'Box left, px or "%" (with y, width, height: an explicit box instead of region).', y: 'Box top.', width: 'Box width.', height: 'Box height.', src: 'A local path, data: URI, URL (where allowed) or bytes ("{image}").', fit: 'cover | contain | stretch', focus: 'An anchor, attention or entropy.', region: 'Fractions.', duotone: 'true or [shadow, highlight].', grayscale: 'boolean', normalize: 'Stretch tones first.', tint: 'A colour or { color, amount, blend }.', blur: 'Length.' },
17
+ image: { x: 'Box left, px or "%" (with y, width, height: an explicit box instead of region).', y: 'Box top.', width: 'Box width.', height: 'Box height.', src: 'A local path, data: URI, URL (where allowed) or bytes ("{image}").', fit: 'cover | contain | stretch', focus: 'An anchor, attention or entropy; [x, y] (0–1 in the photo) to centre that point; subject: a cut-out\'s head centred across, its top at the box\'s top.', region: 'Fractions.', duotone: 'true or [shadow, highlight].', grayscale: 'boolean', normalize: 'Stretch tones first.', tint: 'A colour or { color, amount, blend }.', blur: 'Length.' },
18
18
  grain: { amount: '0–1. Default 0.08.', size: 'Grain px. Default 1.', monochrome: 'Default true.', vary: '"edges": heavier toward the edges (spray paint).' },
19
19
  pattern: { kind: 'grid | dots | lines | rings | waves', size: 'Cell. Length.', stroke: 'Length.', color: 'Default @text.', angle: 'For lines.', center: '{ x, y } fractions.', wavelength: 'Length.', amplitude: 'Length.', fade: 'none | radial | edges | top | bottom | left | right', dash: 'dotted | dashed | [on, off] lengths.', divisions: 'Grid/lines: N equal cells (inner lines only) instead of size; [cols, rows] for a design grid.', gaps: '[{ x, y, width, height }] fractions the lines break around.', region: 'Fractions.' },
20
20
  shapes: { kind: 'circle | square | pill | mixed', count: 'Default 6.', size: '[min, max] lengths.', colors: 'Cycled.', placement: 'scatter | row | column', items: '[{ kind, x, y, size, color, rotate, opacity }]', region: 'Fractions.', rotate: 'Tilt squares and pills.', radius: 'Square corner, fraction of size.', outline: 'true or a width.' },
@@ -0,0 +1,8 @@
1
+ // Placing a photo on a point of it: a focus [x, y], or a cut-out's head.
2
+
3
+ /** A cut-out's head (the alpha-weighted centre of the top 45% of its opaque rows), 0–1 in the photo; null with no transparency. */
4
+ export function subjectFocus(sharp: any, bytes: Uint8Array): Promise<[number, number] | null>;
5
+ /** The drawn box (relative to the box) that centres a point of a `sw` × `sh` photo; `cover` keeps the box filled except sideways when `free` (a cut-out). */
6
+ export function focalBox(box: { width: number; height: number }, sw: number, sh: number, fit: 'cover' | 'contain', point: [number, number], free?: boolean): { x: number; y: number; width: number; height: number };
7
+ /** A `W` × `H` raster with the photo drawn at a box (raster pixels), the rest clear, as a sharp pipeline. */
8
+ export function drawAt(sharp: any, bytes: Uint8Array, W: number, H: number, at: { x: number; y: number; width: number; height: number }): Promise<any>;
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Placing a photo on a point of it: a focus `[x, y]` (0–1 in the photo),
3
+ * or `'subject'`, a cut-out's head, found from its transparency. Used by
4
+ * the `image` layer when sharp is there to draw the pixels.
5
+ *
6
+ * @module layers/focal
7
+ */
8
+
9
+ /** The share of a cut-out's opaque height, from its top, taken as its head. */
10
+ const HEAD = 0.45;
11
+ /** Rows and columns the transparency is read at. */
12
+ const PROBE = 160;
13
+
14
+ /**
15
+ * Where a cut-out's head is: the alpha-weighted centre of the top 45% of
16
+ * its opaque rows, as `[x, y]` fractions of the photo. Null for a photo
17
+ * with no transparency to read (an ordinary photo: no subject to find).
18
+ *
19
+ * @param {any} sharp - The sharp module.
20
+ * @param {Uint8Array} bytes
21
+ * @returns {Promise<[number, number] | null>}
22
+ *
23
+ * @example
24
+ * await subjectFocus(sharp, cutoutPng); // [0.56, 0.21]
25
+ */
26
+ export async function subjectFocus(sharp, bytes) {
27
+ const meta = await sharp(bytes).metadata();
28
+ if (!meta.hasAlpha) return null;
29
+ const { data, info } = await sharp(bytes).rotate().ensureAlpha().resize(PROBE, PROBE, { fit: 'fill' }).raw().toBuffer({ resolveWithObject: true });
30
+ const { width: w, height: h, channels: c } = info;
31
+ const alpha = (/** @type {number} */ x, /** @type {number} */ y) => data[(y * w + x) * c + 3] / 255;
32
+ // The opaque rows: those with a few solid pixels.
33
+ const rows = [];
34
+ for (let y = 0; y < h; y++) {
35
+ let s = 0;
36
+ for (let x = 0; x < w; x++) s += alpha(x, y);
37
+ if (s >= 2) rows.push(y);
38
+ }
39
+ let clear = false;
40
+ for (let i = 3; i < data.length && !clear; i += c) clear = data[i] < 250;
41
+ if (!rows.length || !clear) return null;
42
+ const top = rows[0];
43
+ const bottom = rows[rows.length - 1];
44
+ const end = top + Math.max(1, Math.round((bottom - top + 1) * HEAD));
45
+ let sum = 0, sx = 0, sy = 0;
46
+ for (let y = top; y < end; y++) {
47
+ for (let x = 0; x < w; x++) {
48
+ const a = alpha(x, y);
49
+ sum += a; sx += a * (x + 0.5); sy += a * (y + 0.5);
50
+ }
51
+ }
52
+ return sum ? [sx / sum / w, sy / sum / h] : null;
53
+ }
54
+
55
+ /**
56
+ * Where a photo of `sw` × `sh` is drawn in a box so a point of it is
57
+ * centred: scaled to `fit` (cover or contain), then moved so the point
58
+ * sits on the box's centre. `cover` keeps the box filled (the move is
59
+ * held to the photo's overflow), except sideways for a cut-out
60
+ * (`free`), whose transparent sides show the ground; `contain` keeps it
61
+ * inside the box.
62
+ *
63
+ * @param {{ width: number, height: number }} box - Its size (any units).
64
+ * @param {number} sw @param {number} sh - The photo's size.
65
+ * @param {'cover' | 'contain'} fit
66
+ * @param {[number, number]} point - 0–1 in the photo.
67
+ * @param {boolean} [free=false] - Move freely sideways (a cut-out).
68
+ * @returns {{ x: number, y: number, width: number, height: number }} The photo's drawn box, relative to the box.
69
+ *
70
+ * @example
71
+ * focalBox({ width: 400, height: 500 }, 1000, 1000, 'cover', [0.3, 0]); // { x: 0, y: 0, width: 500, height: 500 }: held at the left edge
72
+ * focalBox({ width: 400, height: 500 }, 1000, 1000, 'cover', [0.3, 0], true); // { x: 50, … }: a cut-out, centred on 0.3
73
+ */
74
+ export function focalBox(box, sw, sh, fit, [fx, fy], free = false) {
75
+ const k = fit === 'contain' ? Math.min(box.width / sw, box.height / sh) : Math.max(box.width / sw, box.height / sh);
76
+ const width = sw * k;
77
+ const height = sh * k;
78
+ let x = box.width / 2 - fx * width;
79
+ let y = box.height / 2 - fy * height;
80
+ /** Held between a and b (in either order). */
81
+ const hold = (/** @type {number} */ v, /** @type {number} */ a, /** @type {number} */ b) => Math.min(Math.max(a, b), Math.max(Math.min(a, b), v));
82
+ if (!free || fit === 'contain') x = hold(x, 0, box.width - width);
83
+ y = hold(y, 0, box.height - height);
84
+ return { x, y, width, height };
85
+ }
86
+
87
+ /**
88
+ * Draw a photo into a `W` × `H` raster at a drawn box (raster pixels), the
89
+ * rest transparent: resized, the visible part cut out and laid on a clear
90
+ * canvas. Returns a sharp pipeline of the result.
91
+ *
92
+ * @param {any} sharp
93
+ * @param {Uint8Array} bytes
94
+ * @param {number} W @param {number} H
95
+ * @param {{ x: number, y: number, width: number, height: number }} at - Raster pixels.
96
+ * @returns {Promise<any>}
97
+ */
98
+ export async function drawAt(sharp, bytes, W, H, at) {
99
+ const rw = Math.max(1, Math.round(at.width));
100
+ const rh = Math.max(1, Math.round(at.height));
101
+ const ox = Math.round(at.x);
102
+ const oy = Math.round(at.y);
103
+ const left = Math.max(0, -ox), top = Math.max(0, -oy);
104
+ const width = Math.min(rw, W - ox) - left, height = Math.min(rh, H - oy) - top;
105
+ const canvas = sharp({ create: { width: W, height: H, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } } });
106
+ if (width <= 0 || height <= 0) return sharp(await canvas.png().toBuffer());
107
+ const part = await sharp(bytes).rotate().ensureAlpha().resize(rw, rh, { fit: 'fill' }).extract({ left, top, width, height }).png().toBuffer();
108
+ return sharp(await canvas.composite([{ input: part, left: Math.max(0, ox), top: Math.max(0, oy) }]).png().toBuffer());
109
+ }
package/src/layers.js CHANGED
@@ -30,6 +30,7 @@ import { ink, dither, contour, waveform, tiles, rule } from './print.js';
30
30
  import { typeset, wantsType } from './type/index.js';
31
31
  import { recordLayout } from './type/layout-ref.js';
32
32
  import { shape, svg, table, barcode, perforation, tear, texture, mosaic, marks, outline, construction } from './layers/index.js';
33
+ import { subjectFocus, focalBox, drawAt } from './layers/focal.js';
33
34
  import { pos, layerAttrs } from './layers/util.js';
34
35
  import { paint } from './paint/index.js';
35
36
  import { imageSize } from './lint/image-size.js';
@@ -423,7 +424,7 @@ export async function image(layer, ctx) {
423
424
  const focus = layer.focus ?? 'center';
424
425
  let bytes = typeof layer.src === 'string' || layer.src instanceof URL ? await readSource(layer.src) : await loadBytes(layer.src, 'image');
425
426
  let mime = imageMime(bytes);
426
- let preserve = fit === 'stretch' ? 'none' : `${alignFor(focus)} ${fit === 'contain' ? 'meet' : 'slice'}`;
427
+ let preserve = fit === 'stretch' ? 'none' : `${alignFor(typeof focus === 'string' ? (focus === 'subject' ? 'top' : focus) : 'center')} ${fit === 'contain' ? 'meet' : 'slice'}`;
427
428
  let sharpBlurred = false;
428
429
 
429
430
  /** Source pixels and what was embedded, for lint. */
@@ -501,8 +502,13 @@ async function resample(ctx, bytes, box, fit, focus, layer) {
501
502
  if (cap && fit === 'stretch') { w = Math.min(w, sw); hgt = Math.min(hgt, sh); }
502
503
  w = Math.max(1, Math.round(w));
503
504
  hgt = Math.max(1, Math.round(hgt));
505
+ // A focus point ([x, y], or a cut-out's head) is centred in the box.
506
+ const head = focus === 'subject' ? await subjectFocus(sharp, bytes) : null;
507
+ const point = Array.isArray(focus) ? focus : head ? [head[0], 0] : null;
504
508
  const position = typeof focus === 'string' && ['attention', 'entropy'].includes(focus) ? focus : (POSITIONS[focus] ?? 'centre');
505
- let img = sharp(bytes).rotate().resize(w, hgt, { fit: fit === 'stretch' ? 'fill' : fit, position, background: { r: 0, g: 0, b: 0, alpha: 0 } });
509
+ let img = point && fit !== 'stretch'
510
+ ? await drawAt(sharp, bytes, w, hgt, focalBox({ width: w, height: hgt }, sw, sh, fit, point, Boolean(meta.hasAlpha)))
511
+ : sharp(bytes).rotate().resize(w, hgt, { fit: fit === 'stretch' ? 'fill' : fit, position, background: { r: 0, g: 0, b: 0, alpha: 0 } });
506
512
  // Stretch the tones first, so a duotone spans both of its colours.
507
513
  if (layer.normalize ?? Boolean(layer.duotone || layer.grayscale)) img = img.normalise();
508
514
  // Blur here rather than in SVG: sharp clamps at the edges, where an SVG
package/src/version.js CHANGED
@@ -1,4 +1,4 @@
1
1
  // Generated by scripts/generate.mjs from package.json. Do not edit.
2
2
 
3
3
  /** The package version, part of every cache key. */
4
- export const VERSION = '1.5.1';
4
+ export const VERSION = '1.7.0';