@arraypress/covers 1.3.1 → 1.5.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +1 -1
  3. package/package.json +3 -2
  4. package/recipes/flyers/construction.json +36 -0
  5. package/recipes/flyers/fluted-poster.json +95 -0
  6. package/recipes/flyers/grid-poster.json +91 -0
  7. package/recipes/flyers/logofolio.json +60 -0
  8. package/recipes/flyers/pixel-mosaic.json +58 -0
  9. package/recipes/flyers/series-card.json +81 -0
  10. package/schema/recipe.schema.json +768 -4
  11. package/src/index.d.ts +9 -7
  12. package/src/json-recipe.js +5 -0
  13. package/src/layer-options.js +7 -3
  14. package/src/layers/construction.d.ts +34 -0
  15. package/src/layers/construction.js +137 -0
  16. package/src/layers/guides.d.ts +38 -0
  17. package/src/layers/guides.js +212 -0
  18. package/src/layers/index.d.ts +12 -0
  19. package/src/layers/index.js +6 -0
  20. package/src/layers/isolines.d.ts +21 -0
  21. package/src/layers/isolines.js +271 -0
  22. package/src/layers/mark-draw.d.ts +21 -0
  23. package/src/layers/mark-draw.js +93 -0
  24. package/src/layers/mark-geometry.d.ts +15 -0
  25. package/src/layers/mark-geometry.js +46 -0
  26. package/src/layers/marks.d.ts +25 -0
  27. package/src/layers/marks.js +102 -0
  28. package/src/layers/mosaic.d.ts +32 -0
  29. package/src/layers/mosaic.js +155 -0
  30. package/src/layers/outline.d.ts +26 -0
  31. package/src/layers/outline.js +105 -0
  32. package/src/layers.js +7 -6
  33. package/src/type/fallback.js +1 -1
  34. package/src/type/spans.d.ts +5 -1
  35. package/src/type/spans.js +26 -3
  36. package/src/type/symbols.d.ts +1 -1
  37. package/src/type/symbols.js +20 -2
  38. package/src/type/typeset.js +6 -2
  39. package/src/version.js +1 -1
@@ -0,0 +1,271 @@
1
+ /**
2
+ * Isolines of a sampled field (marching squares) and a mark's signed
3
+ * distance field, for contour lines drawn round a mark.
4
+ *
5
+ * @module layers/isolines
6
+ */
7
+
8
+ import { fill, distance } from '@logomarks/core/raster';
9
+
10
+ /**
11
+ * @typedef {object} DistanceField
12
+ * @property {Float32Array} values - Signed distance in mark units, row by row: positive outside the mark, negative inside.
13
+ * @property {number} width - Samples across.
14
+ * @property {number} height - Samples down.
15
+ * @property {number} x0 - The mark x of the first column's centre.
16
+ * @property {number} y0 - The mark y of the first row's centre.
17
+ * @property {number} step - Mark units between samples.
18
+ */
19
+
20
+ /**
21
+ * A mark's signed distance field over its box plus `reach` round it,
22
+ * `samples` across its longest side. Its rings are filled and measured by
23
+ * `@logomarks/core`'s scanline fill and Euclidean distance transform; that
24
+ * measures to pixel centres, so where a line will be traced (samples near
25
+ * one of `levels`) the distance is then measured exactly, to the rings'
26
+ * edges, and the lines come out smooth.
27
+ *
28
+ * @param {import('./mark-geometry.js').MarkGeometry} geo
29
+ * @param {number} reach - Mark units round the box the field must cover.
30
+ * @param {number[]} [levels] - The distances lines will be traced at (mark units).
31
+ * @param {number} [samples=500]
32
+ * @returns {DistanceField}
33
+ */
34
+ export function distanceField(geo, reach, levels = [], samples = 500) {
35
+ const { box } = geo;
36
+ const scale = samples / Math.max(box.width, box.height);
37
+ const pad = reach + 4 / scale;
38
+ const frame = {
39
+ x0: box.x - pad,
40
+ y0: box.y - pad,
41
+ scale,
42
+ width: Math.ceil((box.width + 2 * pad) * scale),
43
+ height: Math.ceil((box.height + 2 * pad) * scale),
44
+ };
45
+ const bm = fill(geo.paths, frame);
46
+ const out = distance(bm, 0);
47
+ const inside = distance(bm, 1);
48
+ const values = new Float32Array(out.length);
49
+ // Pixel centres: half a pixel either side of the edge between ink and empty.
50
+ for (let i = 0; i < values.length; i++) values[i] = (bm.data[i] ? 0.5 - inside[i] : out[i] - 0.5) / scale;
51
+ const x0 = frame.x0 + 0.5 / scale;
52
+ const y0 = frame.y0 + 0.5 / scale;
53
+ if (levels.length) exactNear(values, frame.width, { x0, y0, step: 1 / scale }, geo, levels);
54
+ return { values, width: frame.width, height: frame.height, x0, y0, step: 1 / scale };
55
+ }
56
+
57
+ /**
58
+ * Measure the samples within two samples of a level exactly: the distance
59
+ * to the nearest edge of the mark's rings, its sign kept. Edges are binned
60
+ * so each sample looks only at those within its rough distance.
61
+ *
62
+ * @param {Float32Array} values - Rough signed distances (changed).
63
+ * @param {number} w - Samples across.
64
+ * @param {{ x0: number, y0: number, step: number }} at - Where the samples are.
65
+ * @param {import('./mark-geometry.js').MarkGeometry} geo
66
+ * @param {number[]} levels
67
+ */
68
+ function exactNear(values, w, { x0, y0, step }, geo, levels) {
69
+ /** @type {number[]} x1, y1, x2, y2 per edge */
70
+ const edges = [];
71
+ for (const p of geo.paths) {
72
+ for (const ring of p.rings) {
73
+ for (let i = 0; i < ring.length; i++) {
74
+ const a = ring[i], b = ring[(i + 1) % ring.length];
75
+ if (a[0] !== b[0] || a[1] !== b[1]) edges.push(a[0], a[1], b[0], b[1]);
76
+ }
77
+ }
78
+ }
79
+ const { box } = geo;
80
+ const size = Math.max(box.width, box.height);
81
+ const cell = size / 32;
82
+ const bx = box.x - size, by = box.y - size;
83
+ const cols = Math.ceil((box.width + 2 * size) / cell);
84
+ const rows = Math.ceil((box.height + 2 * size) / cell);
85
+ /** @type {number[][]} */
86
+ const bins = Array.from({ length: cols * rows }, () => []);
87
+ for (let e = 0; e < edges.length; e += 4) {
88
+ const c0 = Math.floor((Math.min(edges[e], edges[e + 2]) - bx) / cell), c1 = Math.floor((Math.max(edges[e], edges[e + 2]) - bx) / cell);
89
+ const r0 = Math.floor((Math.min(edges[e + 1], edges[e + 3]) - by) / cell), r1 = Math.floor((Math.max(edges[e + 1], edges[e + 3]) - by) / cell);
90
+ for (let r = Math.max(0, r0); r <= Math.min(rows - 1, r1); r++) for (let c = Math.max(0, c0); c <= Math.min(cols - 1, c1); c++) bins[r * cols + c].push(e);
91
+ }
92
+ const band = 2 * step;
93
+ for (let i = 0; i < values.length; i++) {
94
+ const v = values[i];
95
+ if (!levels.some((l) => Math.abs(v - l) <= band)) continue;
96
+ const x = x0 + (i % w) * step, y = y0 + Math.floor(i / w) * step;
97
+ // The nearest edge is no further than the rough distance and a sample or two.
98
+ const R = Math.abs(v) + band;
99
+ let best = R * R;
100
+ const c0 = Math.max(0, Math.floor((x - R - bx) / cell)), c1 = Math.min(cols - 1, Math.floor((x + R - bx) / cell));
101
+ const r0 = Math.max(0, Math.floor((y - R - by) / cell)), r1 = Math.min(rows - 1, Math.floor((y + R - by) / cell));
102
+ for (let r = r0; r <= r1; r++) {
103
+ for (let c = c0; c <= c1; c++) {
104
+ for (const e of bins[r * cols + c]) {
105
+ const ax = edges[e], ay = edges[e + 1], dx = edges[e + 2] - ax, dy = edges[e + 3] - ay;
106
+ const t = Math.max(0, Math.min(1, ((x - ax) * dx + (y - ay) * dy) / (dx * dx + dy * dy)));
107
+ const ex = ax + t * dx - x, ey = ay + t * dy - y;
108
+ const d2 = ex * ex + ey * ey;
109
+ if (d2 < best) best = d2;
110
+ }
111
+ }
112
+ }
113
+ values[i] = Math.sign(v || 1) * Math.sqrt(best);
114
+ }
115
+ }
116
+
117
+ /**
118
+ * The lines where a field crosses `level`, as polylines of sample
119
+ * coordinates (`[column, row]`), closed ones ending on their first point.
120
+ * Marching squares, saddles split by the cell's mean; crossings are
121
+ * chained by the grid edge they sit on, so the joins are exact.
122
+ *
123
+ * @param {ArrayLike<number>} f - Values row by row.
124
+ * @param {number} w - Columns.
125
+ * @param {number} h - Rows.
126
+ * @param {number} level
127
+ * @returns {number[][][]}
128
+ *
129
+ * @example
130
+ * isolines([0, 0, 0, 0, 1, 0, 0, 0, 0], 3, 3, 0.5); // one closed diamond round the middle sample
131
+ */
132
+ export function isolines(f, w, h, level) {
133
+ // Edge ids: horizontal (i, j)–(i + 1, j) is j·w + i; vertical (i, j)–(i, j + 1) is w·h + j·w + i.
134
+ const V = w * h;
135
+ /** @type {Map<number, number[]>} */
136
+ const at = new Map();
137
+ /** @type {number[][]} */
138
+ const segs = [];
139
+ const link = (/** @type {number} */ a, /** @type {number} */ b) => {
140
+ const s = segs.push([a, b]) - 1;
141
+ for (const e of [a, b]) {
142
+ const list = at.get(e);
143
+ if (list) list.push(s); else at.set(e, [s]);
144
+ }
145
+ };
146
+ for (let j = 0; j < h - 1; j++) {
147
+ for (let i = 0; i < w - 1; i++) {
148
+ const a = f[j * w + i] - level;
149
+ const b = f[j * w + i + 1] - level;
150
+ const c = f[(j + 1) * w + i + 1] - level;
151
+ const d = f[(j + 1) * w + i] - level;
152
+ const code = (a > 0 ? 8 : 0) | (b > 0 ? 4 : 0) | (c > 0 ? 2 : 0) | (d > 0 ? 1 : 0);
153
+ if (code === 0 || code === 15) continue;
154
+ const top = j * w + i;
155
+ const bottom = (j + 1) * w + i;
156
+ const left = V + j * w + i;
157
+ const right = V + j * w + i + 1;
158
+ const mid = a + b + c + d > 0;
159
+ switch (code) {
160
+ case 1: case 14: link(left, bottom); break;
161
+ case 2: case 13: link(bottom, right); break;
162
+ case 3: case 12: link(left, right); break;
163
+ case 4: case 11: link(top, right); break;
164
+ case 6: case 9: link(top, bottom); break;
165
+ case 7: case 8: link(left, top); break;
166
+ case 5: if (mid) { link(left, top); link(bottom, right); } else { link(left, bottom); link(top, right); } break;
167
+ case 10: if (mid) { link(left, bottom); link(top, right); } else { link(left, top); link(bottom, right); } break;
168
+ }
169
+ }
170
+ }
171
+ const point = (/** @type {number} */ e) => {
172
+ if (e < V) {
173
+ const i = e % w, j = Math.floor(e / w);
174
+ const p = f[e] - level, q = f[e + 1] - level;
175
+ return [i + p / (p - q), j];
176
+ }
177
+ const k = e - V, i = k % w, j = Math.floor(k / w);
178
+ const p = f[k] - level, q = f[k + w] - level;
179
+ return [i, j + p / (p - q)];
180
+ };
181
+ const used = new Uint8Array(segs.length);
182
+ /** @type {number[][][]} */
183
+ const out = [];
184
+ for (let s = 0; s < segs.length; s++) {
185
+ if (used[s]) continue;
186
+ used[s] = 1;
187
+ const edges = [segs[s][0], segs[s][1]];
188
+ for (const forward of [true, false]) {
189
+ for (;;) {
190
+ const tip = forward ? edges[edges.length - 1] : edges[0];
191
+ const next = (at.get(tip) ?? []).find((n) => !used[n]);
192
+ if (next === undefined) break;
193
+ used[next] = 1;
194
+ const other = segs[next][0] === tip ? segs[next][1] : segs[next][0];
195
+ if (forward) edges.push(other); else edges.unshift(other);
196
+ }
197
+ }
198
+ if (edges.length > 2) out.push(edges.map(point));
199
+ }
200
+ return out;
201
+ }
202
+
203
+ /**
204
+ * A polyline simplified within `tol` (Douglas-Peucker; a closed one split
205
+ * at its farthest point so both halves keep their ends), then smoothed by
206
+ * two rounds of Chaikin (corners rounded by at most `round`), as path data.
207
+ *
208
+ * @param {number[][]} pts - Canvas units; closed when the last is the first.
209
+ * @param {number} [tol=0.25]
210
+ * @param {number} [round=0.6]
211
+ * @returns {string}
212
+ */
213
+ export function smoothLine(pts, tol = 0.25, round = 0.6) {
214
+ const first = pts[0];
215
+ const last = pts[pts.length - 1];
216
+ const closed = pts.length > 3 && Math.hypot(first[0] - last[0], first[1] - last[1]) < 1e-9;
217
+ /** @type {number[][]} */
218
+ let p;
219
+ if (closed) {
220
+ let far = 0, fd = -1;
221
+ pts.forEach((q, i) => { const d = Math.hypot(q[0] - first[0], q[1] - first[1]); if (d > fd) { fd = d; far = i; } });
222
+ p = [...simplify(pts.slice(0, far + 1), tol).slice(0, -1), ...simplify(pts.slice(far), tol).slice(0, -1)];
223
+ if (p.length < 3) return '';
224
+ } else {
225
+ p = simplify(pts, tol);
226
+ }
227
+ // Chaikin, twice: each edge cut a quarter of the way in from both ends (a
228
+ // closed ring wraps round), but never more than `round` from a corner, so a
229
+ // long straight run meeting a sharp cusp keeps its point instead of a bevel.
230
+ let q = p;
231
+ for (const r of [round, round / 2]) {
232
+ const n = q.length;
233
+ const next = closed ? [] : [q[0]];
234
+ for (let i = 0; i < (closed ? n : n - 1); i++) {
235
+ const a = q[i], b = q[(i + 1) % n];
236
+ const t = Math.min(0.25, r / (Math.hypot(b[0] - a[0], b[1] - a[1]) || 1));
237
+ next.push([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t], [b[0] + (a[0] - b[0]) * t, b[1] + (a[1] - b[1]) * t]);
238
+ }
239
+ if (!closed) next.push(q[n - 1]);
240
+ q = next;
241
+ }
242
+ const f = (/** @type {number} */ v) => (Math.round(v * 100) / 100).toString();
243
+ return `M${q.map((pt) => `${f(pt[0])} ${f(pt[1])}`).join('L')}${closed ? 'Z' : ''}`;
244
+ }
245
+
246
+ /**
247
+ * Douglas-Peucker on an open run: points within `tol` of the line between
248
+ * the kept ones go; the ends stay.
249
+ *
250
+ * @param {number[][]} pts
251
+ * @param {number} tol
252
+ * @returns {number[][]}
253
+ */
254
+ function simplify(pts, tol) {
255
+ if (pts.length < 3) return pts.slice();
256
+ const keep = new Uint8Array(pts.length);
257
+ keep[0] = keep[pts.length - 1] = 1;
258
+ const stack = [[0, pts.length - 1]];
259
+ while (stack.length) {
260
+ const [i, j] = /** @type {number[]} */ (stack.pop());
261
+ const [ax, ay] = pts[i], [bx, by] = pts[j];
262
+ const L = Math.hypot(bx - ax, by - ay) || 1e-12;
263
+ let far = -1, fd = tol;
264
+ for (let k = i + 1; k < j; k++) {
265
+ const d = L > 1e-9 ? Math.abs((bx - ax) * (ay - pts[k][1]) - (ax - pts[k][0]) * (by - ay)) / L : Math.hypot(pts[k][0] - ax, pts[k][1] - ay);
266
+ if (d > fd) { fd = d; far = k; }
267
+ }
268
+ if (far >= 0) { keep[far] = 1; stack.push([i, far], [far, j]); }
269
+ }
270
+ return pts.filter((_, k) => keep[k]);
271
+ }
@@ -0,0 +1,21 @@
1
+ // Placing a mark by its real bounds and writing its outline in canvas units.
2
+ import type { RenderContext } from '../render/context.js';
3
+
4
+ /** A box, canvas or mark units. */
5
+ export interface Box { x: number; y: number; width: number; height: number }
6
+
7
+ /** A mark placed in an area: canvas x = mark x × k + dx. */
8
+ export interface MarkFit {
9
+ k: number;
10
+ dx: number;
11
+ dy: number;
12
+ /** The area it was fitted in, canvas units. */
13
+ area: Box;
14
+ }
15
+
16
+ /** The layer's box (`x`, `y`, `width`, `height`; default the canvas), canvas units. */
17
+ export function layerBox(layer: { x?: unknown; y?: unknown; width?: unknown; height?: unknown }, ctx: Pick<RenderContext, 'canvas'>): Box;
18
+ /** Fit a mark's bounds (plus `reach` round them) into an area, centred: the longest side takes `share` of the area's shorter side, never past the area. */
19
+ export function fitMark(box: Box, area: Box, share?: number, reach?: number): MarkFit;
20
+ /** A mark's filled shapes as path data in canvas units, each with its fill rule. */
21
+ export function markPaths(markup: string, fit: MarkFit): { d: string; evenodd: boolean }[];
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Placing a mark by its real bounds and writing its outline in canvas
3
+ * units, so strokes drawn with it (keylines, contour lines) keep their
4
+ * width whatever the mark's scale. Shared by the `outline` and
5
+ * `construction` layers.
6
+ *
7
+ * @module layers/mark-draw
8
+ */
9
+
10
+ import { num } from '@logomarks/image-effects/svg';
11
+ import { shapes } from '@logomarks/core/svg';
12
+ import { pos } from './util.js';
13
+
14
+ /**
15
+ * @typedef {object} MarkFit
16
+ * @property {number} k - Canvas units a mark unit.
17
+ * @property {number} dx - Added to a mark x times k.
18
+ * @property {number} dy - Added to a mark y times k.
19
+ * @property {{ x: number, y: number, width: number, height: number }} area - The layer's box, canvas units.
20
+ */
21
+
22
+ /**
23
+ * The layer's box (`x`, `y`, `width`, `height`; default the canvas), in
24
+ * canvas units.
25
+ *
26
+ * @param {any} layer
27
+ * @param {{ canvas: { width: number, height: number } }} ctx
28
+ * @returns {{ x: number, y: number, width: number, height: number }}
29
+ */
30
+ export function layerBox(layer, ctx) {
31
+ return {
32
+ x: pos(layer.x ?? 0, 'x', ctx.canvas),
33
+ y: pos(layer.y ?? 0, 'y', ctx.canvas),
34
+ width: pos(layer.width ?? '100%', 'x', ctx.canvas),
35
+ height: pos(layer.height ?? '100%', 'y', ctx.canvas),
36
+ };
37
+ }
38
+
39
+ /**
40
+ * Fit a box of mark units (its real bounds, plus `reach` round it) into an
41
+ * area, centred: the longest side takes `share` of the area's shorter
42
+ * side, and the whole never passes the area.
43
+ *
44
+ * @param {{ x: number, y: number, width: number, height: number }} box - The mark's bounds, mark units.
45
+ * @param {{ x: number, y: number, width: number, height: number }} area - Canvas units.
46
+ * @param {number} [share=1] - 0–1.
47
+ * @param {number} [reach=0] - Mark units drawn round the bounds (contour lines), kept inside too.
48
+ * @returns {MarkFit}
49
+ *
50
+ * @example
51
+ * fitMark({ x: 96, y: 96, width: 320, height: 320 }, { x: 0, y: 0, width: 400, height: 400 }, 0.5).k; // 0.625
52
+ */
53
+ export function fitMark(box, area, share = 1, reach = 0) {
54
+ const w = box.width + 2 * reach;
55
+ const h = box.height + 2 * reach;
56
+ const k = Math.min((Math.min(area.width, area.height) * share) / Math.max(w, h), area.width / w, area.height / h);
57
+ const cx = box.x + box.width / 2;
58
+ const cy = box.y + box.height / 2;
59
+ return { k, dx: area.x + area.width / 2 - cx * k, dy: area.y + area.height / 2 - cy * k, area };
60
+ }
61
+
62
+ /**
63
+ * A mark's filled shapes as path data in canvas units (each with its fill
64
+ * rule), through a fit.
65
+ *
66
+ * @param {string} markup - The mark's paths (512 grid).
67
+ * @param {MarkFit} fit
68
+ * @returns {{ d: string, evenodd: boolean }[]}
69
+ */
70
+ export function markPaths(markup, { k, dx, dy }) {
71
+ return shapes(String(markup ?? '')).map((s) => ({ d: segsPath(s.segs, k, dx, dy), evenodd: s.evenodd }));
72
+ }
73
+
74
+ /**
75
+ * Absolute segments scaled by `k` and moved by `dx`, `dy`, as path data.
76
+ *
77
+ * @param {import('@logomarks/core/svg').Segment[]} segs
78
+ * @param {number} k @param {number} dx @param {number} dy
79
+ * @returns {string}
80
+ */
81
+ function segsPath(segs, k, dx, dy) {
82
+ const X = (/** @type {number} */ v) => num(v * k + dx, 2);
83
+ const Y = (/** @type {number} */ v) => num(v * k + dy, 2);
84
+ return segs.map((s) => {
85
+ switch (s[0]) {
86
+ case 'M': case 'L': return `${s[0]}${X(s[1])} ${Y(s[2])}`;
87
+ case 'C': return `C${X(s[1])} ${Y(s[2])} ${X(s[3])} ${Y(s[4])} ${X(s[5])} ${Y(s[6])}`;
88
+ case 'Q': return `Q${X(s[1])} ${Y(s[2])} ${X(s[3])} ${Y(s[4])}`;
89
+ case 'A': return `A${num(s[1] * k, 2)} ${num(s[2] * k, 2)} ${num(s[3])} ${s[4] ? 1 : 0} ${s[5] ? 1 : 0} ${X(s[6])} ${Y(s[7])}`;
90
+ default: return 'Z';
91
+ }
92
+ }).join('');
93
+ }
@@ -0,0 +1,15 @@
1
+ // A mark's geometry from its markup, read with @logomarks/core.
2
+ import type { FillPath } from '@logomarks/core/raster';
3
+
4
+ /** A mark read from its paths. */
5
+ export interface MarkGeometry {
6
+ /** Its filled paths as rings, with their fill rules. */
7
+ paths: FillPath[];
8
+ /** Its real bounds, grid units. */
9
+ box: { x: number; y: number; width: number; height: number };
10
+ /** The share of its box it inks, 0–1. */
11
+ density: number;
12
+ }
13
+
14
+ /** Read a mark (paths on a 512 grid); null when it draws nothing. */
15
+ export function markGeometry(markup: string): MarkGeometry | null;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * A mark's geometry from its markup (paths on a 512 grid), read with
3
+ * `@logomarks/core`: its filled paths as rings, its real bounds, and how
4
+ * much of that box it inks. Shared by the `mosaic` and `marks` layers.
5
+ *
6
+ * @module layers/mark-geometry
7
+ */
8
+
9
+ import { shapes, rings, bounds } from '@logomarks/core/svg';
10
+ import { pixelGrid, pixelCoverage } from '@logomarks/core/edit';
11
+
12
+ /** Read marks, by markup (a sheet of seventy reads each once). @type {Map<string, MarkGeometry>} */
13
+ const cache = new Map();
14
+
15
+ /**
16
+ * @typedef {object} MarkGeometry
17
+ * @property {import('@logomarks/core/raster').FillPath[]} paths - Its filled paths as rings, with their fill rules.
18
+ * @property {{ x: number, y: number, width: number, height: number }} box - Its real bounds, grid units.
19
+ * @property {number} density - The share of its box it inks, 0–1.
20
+ */
21
+
22
+ /**
23
+ * Read a mark.
24
+ *
25
+ * @param {string} markup - The mark's paths (`<path d="…"/>`, on a 512 grid).
26
+ * @returns {MarkGeometry | null} Null when it draws nothing.
27
+ *
28
+ * @example
29
+ * markGeometry('<path d="M96 96h320v320H96z"/>').box; // { x: 96, y: 96, width: 320, height: 320 }
30
+ */
31
+ export function markGeometry(markup) {
32
+ if (cache.has(markup)) return /** @type {MarkGeometry} */ (cache.get(markup));
33
+ const filled = shapes(String(markup ?? ''));
34
+ const paths = filled.map((s) => ({ rings: rings(s.segs), evenodd: s.evenodd })).filter((p) => p.rings.length);
35
+ if (!paths.length) return null;
36
+ const b = bounds(filled);
37
+ const box = { x: b.x, y: b.y, width: Math.max(b.w, 1e-6), height: Math.max(b.h, 1e-6) };
38
+ // Ink measured on a 48-cell grid over the box: plenty for a density.
39
+ const grid = pixelGrid(0, box);
40
+ const cov = pixelCoverage(paths, grid);
41
+ const density = cov.reduce((a, v) => a + v, 0) / Math.max(1, cov.length);
42
+ const geo = { paths, box, density };
43
+ if (cache.size > 500) cache.clear();
44
+ cache.set(markup, geo);
45
+ return geo;
46
+ }
@@ -0,0 +1,25 @@
1
+ // The `marks` layer.
2
+ import type { Color, Length, BaseLayer } from '../index.js';
3
+ import type { RenderContext, Markup } from '../render/context.js';
4
+
5
+ export interface MarksLayer extends BaseLayer {
6
+ type: 'marks';
7
+ /** The marks: their paths on a 512 grid (or `{ markup }`). */
8
+ marks: (string | { markup: string })[];
9
+ /** Default from the count and the box: 3 × 3 up to 10 × 7. */
10
+ cols?: number;
11
+ rows?: number;
12
+ /** The box the grid is centred in (default the canvas). */
13
+ x?: Length | `${number}%`; y?: Length | `${number}%`; width?: Length | `${number}%`; height?: Length | `${number}%`;
14
+ /** The share of a cell a mark's area takes. Default 0.5. */
15
+ size?: number;
16
+ /** `false`: each mark's bounds fitted to the same box, not optically sized. Default true. */
17
+ optical?: boolean;
18
+ /** Default the palette's text. */
19
+ color?: Color;
20
+ }
21
+
22
+ /** Draw a set of marks on an even grid, optically sized the same. */
23
+ export function marks(layer: MarksLayer, ctx: RenderContext): Markup;
24
+ /** Columns and rows for `n` marks in a box of `aspect` (3 × 3 to 10 × 7; given ones kept). */
25
+ export function sheetGrid(n: number, aspect: number, cols?: number, rows?: number): { cols: number; rows: number };
@@ -0,0 +1,102 @@
1
+ /**
2
+ * The `marks` layer.
3
+ *
4
+ * @module layers/marks
5
+ */
6
+
7
+ import { attrs, num } from '@logomarks/image-effects/svg';
8
+ import { toHex } from '@logomarks/image-effects/color';
9
+ import { pos, wrap } from './util.js';
10
+ import { shapesOnly } from './svg.js';
11
+ import { markGeometry } from './mark-geometry.js';
12
+
13
+ /** The smallest and largest sheet: 3 × 3 to 10 × 7. */
14
+ const MIN = 3;
15
+ const MAX_COLS = 10;
16
+ const MAX_ROWS = 7;
17
+
18
+ /**
19
+ * `marks` — a set of marks on an even grid of square cells, centred in its
20
+ * box so the margins match, each mark optically sized the same: scaled by
21
+ * its real bounds (not its 512 grid), to the same area, nudged up a little
22
+ * where it inks less of that area (an outline looks smaller than a solid
23
+ * of the same size), and never past its cell. The logofolio sheet.
24
+ *
25
+ * Options: `marks` (a list of markup strings, or `{ markup }`), `cols` and
26
+ * `rows` (default from the count and the box: 3 × 3 up to 10 × 7), `x`,
27
+ * `y`, `width`, `height` (the box; default the canvas), `size` (the share
28
+ * of a cell a mark's area takes, 0.5), `optical` (`false` fits each
29
+ * mark's bounds to the same box instead), `color` (default `text`).
30
+ *
31
+ * @param {any} layer
32
+ * @param {import('../layers.js').RenderContext} ctx
33
+ * @returns {import('../layers.js').Markup}
34
+ *
35
+ * @example
36
+ * { type: 'marks', marks: ['<path d="…"/>', '<path d="…"/>'], x: 24, y: 120, width: 352, height: 340, color: '#f2efe8' }
37
+ */
38
+ export function marks(layer, ctx) {
39
+ const list = (layer.marks ?? []).map((m) => (m && typeof m === 'object' ? m.markup : m)).filter(Boolean);
40
+ if (!list.length) return { defs: '', body: '' };
41
+ const bx = pos(layer.x ?? 0, 'x', ctx.canvas);
42
+ const by = pos(layer.y ?? 0, 'y', ctx.canvas);
43
+ const bw = pos(layer.width ?? '100%', 'x', ctx.canvas);
44
+ const bh = pos(layer.height ?? '100%', 'y', ctx.canvas);
45
+ const { cols, rows } = sheetGrid(list.length, bw / bh, layer.cols, layer.rows);
46
+ const cell = Math.min(bw / cols, bh / rows);
47
+ const ox = bx + (bw - cols * cell) / 2;
48
+ const oy = by + (bh - rows * cell) / 2;
49
+ const share = layer.size ?? 0.5;
50
+ const color = layer.color ? toHex(layer.color) : ctx.palette.text;
51
+
52
+ let body = '';
53
+ list.slice(0, cols * rows).forEach((markup, i) => {
54
+ const geo = markGeometry(markup);
55
+ if (!geo) return;
56
+ const { x, y, width, height } = geo.box;
57
+ // Optical: the same area (density-weighted, gently), capped at the cell; else the same longest side.
58
+ const fit = (cell * 0.86) / Math.max(width, height);
59
+ const k = layer.optical === false ? (cell * Math.sqrt(share)) / Math.max(width, height)
60
+ : Math.min(fit, (cell * Math.sqrt(share)) / (Math.sqrt(width * height) * (geo.density / 0.5) ** 0.15));
61
+ const cx = ox + (i % cols + 0.5) * cell;
62
+ const cy = oy + (Math.floor(i / cols) + 0.5) * cell;
63
+ body += `<svg${attrs({ x: cx - (width * k) / 2, y: cy - (height * k) / 2, width: width * k, height: height * k, viewBox: `${num(x)} ${num(y)} ${num(width)} ${num(height)}`, overflow: 'visible' })}>${shapesOnly(markup)}</svg>`;
64
+ });
65
+ return { defs: '', body: wrap(layer, `<g fill="${color}">${body}</g>`) };
66
+ }
67
+
68
+ /**
69
+ * Columns and rows for `n` marks in a box of `aspect` (width / height):
70
+ * the biggest cells the box allows, less a cost for empty cells (so 25
71
+ * is 5 × 5, not 6 × 5 with a ragged row), 3 × 3 at the least, 10 × 7 at
72
+ * the most (marks past 70 are left out). Given ones are kept.
73
+ *
74
+ * @param {number} n
75
+ * @param {number} aspect
76
+ * @param {number} [cols]
77
+ * @param {number} [rows]
78
+ * @returns {{ cols: number, rows: number }}
79
+ *
80
+ * @example
81
+ * sheetGrid(70, 0.8); // { cols: 10, rows: 7 }
82
+ * sheetGrid(25, 1.6); // { cols: 5, rows: 5 }
83
+ */
84
+ export function sheetGrid(n, aspect, cols, rows) {
85
+ if (cols && rows) return { cols: Math.round(cols), rows: Math.round(rows) };
86
+ if (cols) return { cols: Math.round(cols), rows: Math.max(1, Math.ceil(n / cols)) };
87
+ if (rows) return { cols: Math.max(1, Math.ceil(n / rows)), rows: Math.round(rows) };
88
+ // The biggest cells, less a cost for empty ones (a ragged last row).
89
+ const count = Math.min(n, MAX_COLS * MAX_ROWS);
90
+ let best = { cols: MAX_COLS, rows: MAX_ROWS };
91
+ let score = -Infinity;
92
+ for (let c = MIN; c <= MAX_COLS; c++) {
93
+ const r = clamp(Math.ceil(count / c), MIN, MAX_ROWS);
94
+ if (c * r < count) continue;
95
+ const s = Math.min(aspect / c, 1 / r) * (1 - (1.5 * (c * r - count)) / (c * r));
96
+ if (s > score + 1e-9) [best, score] = [{ cols: c, rows: r }, s];
97
+ }
98
+ return best;
99
+ }
100
+
101
+ /** @param {number} v @param {number} lo @param {number} hi @returns {number} */
102
+ const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
@@ -0,0 +1,32 @@
1
+ // The `mosaic` layer.
2
+ import type { Color, Length, BaseLayer } from '../index.js';
3
+ import type { RenderContext, Markup } from '../render/context.js';
4
+ import type { PixelGrid } from '@logomarks/core/edit';
5
+ import type { MarkGeometry } from './mark-geometry.js';
6
+
7
+ export interface MosaicLayer extends BaseLayer {
8
+ type: 'mosaic';
9
+ /** The mark's paths, on a 512 grid. */
10
+ markup: string;
11
+ /** The box the mosaic is fitted and centred in (default the canvas). */
12
+ x?: Length | `${number}%`; y?: Length | `${number}%`; width?: Length | `${number}%`; height?: Length | `${number}%`;
13
+ /** Cells across the mark's longest side, 4–48. Default 10. */
14
+ cells?: number;
15
+ /** The gap between cells, a share of a cell. Default 0.12. */
16
+ gap?: number;
17
+ /** Cell corner radius, a share of a cell. Default 0. */
18
+ radius?: number;
19
+ /** The share of filled cells left out, by the layer's seed (0–0.9). Default 0.12. */
20
+ drop?: number;
21
+ /** Default the palette's accent. */
22
+ color?: Color;
23
+ /** A small asterisk or dot in one empty cell beside the mark. */
24
+ accent?: 'asterisk' | 'dot' | false;
25
+ /** Default `color`. */
26
+ accentColor?: Color;
27
+ }
28
+
29
+ /** Draw a mark as a mosaic of square cells. */
30
+ export function mosaic(layer: MosaicLayer, ctx: RenderContext): Markup;
31
+ /** The mosaic's grid over a mark: `cells` across its longest side, centred on its bounds. */
32
+ export function mosaicGrid(geo: MarkGeometry, cells?: number): PixelGrid;