@arraypress/covers 1.5.1 → 1.6.1
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 +28 -0
- package/README.md +1 -1
- package/package.json +2 -2
- package/recipes/flyers/dissolve-poster.json +72 -0
- package/recipes/flyers/fluted-poster.json +14 -4
- package/schema/recipe.schema.json +93 -1
- package/src/index.d.ts +2 -2
- package/src/layer-options.js +1 -1
- package/src/layers/focal.d.ts +8 -0
- package/src/layers/focal.js +109 -0
- package/src/layers.js +8 -2
- package/src/version.js +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,34 @@ 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.6.1] — 2026-11-06
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **`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'`.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- **`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.
|
|
17
|
+
|
|
18
|
+
## [1.6.0] — 2026-11-06
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- **`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`.
|
|
23
|
+
- The `halftoneFade` effect from `@logomarks/image-effects` 0.4.0 on any layer, and in the schema.
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
- Depends on `@logomarks/image-effects` `^0.4.0`.
|
|
28
|
+
|
|
29
|
+
## [1.5.2] — 2026-11-06
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
|
|
33
|
+
- **`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.
|
|
34
|
+
- The gentle convex glass 1.5.1 gave the name is gone: every subject uses the `fluted` preset unless `preset` names another.
|
|
35
|
+
|
|
8
36
|
## [1.5.1] — 2026-11-06
|
|
9
37
|
|
|
10
38
|
### 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,
|
|
39
|
+
- [Recipes](https://github.com/arraypress/covers/blob/main/docs/recipes.md): seventeen built-ins, 91 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.
|
|
3
|
+
"version": "1.6.1",
|
|
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.
|
|
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:
|
|
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" }
|
|
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", { "
|
|
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": { "
|
|
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}" }
|
|
@@ -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'
|
|
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. */
|
package/src/layer-options.js
CHANGED
|
@@ -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 =
|
|
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