@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,155 @@
1
+ /**
2
+ * The `mosaic` layer.
3
+ *
4
+ * @module layers/mosaic
5
+ */
6
+
7
+ import { attrs, num } from '@logomarks/image-effects/svg';
8
+ import { toHex } from '@logomarks/image-effects/color';
9
+ import { pixelGrid, pixelCoverage, pixelCells } from '@logomarks/core/edit';
10
+ import { pos, wrap } from './util.js';
11
+ import { markGeometry } from './mark-geometry.js';
12
+
13
+ /**
14
+ * `mosaic` — a mark rastered into big square cells, each drawn on its own
15
+ * with a gap between, so whatever is under it shows through. The cells
16
+ * follow the mark's own shape (`@logomarks/core`'s pixelate: a cell is
17
+ * filled when the mark covers half of it, or a fifth where it would
18
+ * otherwise lose a thin line or a dot). `drop` leaves a share of the
19
+ * filled cells out, chosen by the layer's seed; `accent` puts a small
20
+ * asterisk or dot in one empty cell beside the mark.
21
+ *
22
+ * Options: `markup` (the mark's paths, 512 grid), `x`, `y`, `width`,
23
+ * `height` (the box it's fitted and centred in; default the canvas),
24
+ * `cells` (across the mark's longest side, 10), `gap` (a share of a cell,
25
+ * 0.12), `radius` (a share of a cell, 0), `drop` (0–1, 0.12), `color`
26
+ * (default the accent), `accent` (`'asterisk'` or `'dot'`), `accentColor`.
27
+ *
28
+ * @param {any} layer
29
+ * @param {import('../layers.js').RenderContext} ctx
30
+ * @returns {import('../layers.js').Markup}
31
+ *
32
+ * @example
33
+ * { type: 'mosaic', markup: mark, x: 40, y: 60, width: 320, height: 320, cells: 11, color: '#c8ff2e', drop: 0.15, accent: 'asterisk' }
34
+ */
35
+ export function mosaic(layer, ctx) {
36
+ const geo = markGeometry(layer.markup);
37
+ if (!geo) return { defs: '', body: '' };
38
+ const g = mosaicGrid(geo, layer.cells);
39
+ const on = pixelCells(pixelCoverage(geo.paths, g), g);
40
+
41
+ // Fit the grid into the box, centred.
42
+ const bx = pos(layer.x ?? 0, 'x', ctx.canvas);
43
+ const by = pos(layer.y ?? 0, 'y', ctx.canvas);
44
+ const bw = pos(layer.width ?? '100%', 'x', ctx.canvas);
45
+ const bh = pos(layer.height ?? '100%', 'y', ctx.canvas);
46
+ const k = Math.min(bw / (g.cols * g.cell), bh / (g.rows * g.cell));
47
+ const cell = g.cell * k;
48
+ const ox = bx + (bw - g.cols * cell) / 2;
49
+ const oy = by + (bh - g.rows * cell) / 2;
50
+
51
+ const kept = dropCells(on, layer.drop ?? 0.12, ctx.rng);
52
+ const gap = cell * clamp(layer.gap ?? 0.12, 0, 0.9);
53
+ const r = cell * clamp(layer.radius ?? 0, 0, 0.5);
54
+ const side = cell - gap;
55
+ let rects = '';
56
+ kept.forEach((v, i) => {
57
+ if (!v) return;
58
+ const x = ox + (i % g.cols) * cell + gap / 2;
59
+ const y = oy + Math.floor(i / g.cols) * cell + gap / 2;
60
+ rects += r ? `<rect${attrs({ x, y, width: side, height: side, rx: r })}/>` : `M${num(x)} ${num(y)}h${num(side)}v${num(side)}h${num(-side)}Z`;
61
+ });
62
+ const color = layer.color ? toHex(layer.color) : ctx.palette.accent;
63
+ let body = r ? `<g fill="${color}">${rects}</g>` : `<path${attrs({ d: rects, fill: color })}/>`;
64
+
65
+ if (layer.accent) {
66
+ const at = accentCell(on, g, ctx.rng);
67
+ if (at >= 0) {
68
+ const cx = ox + (at % g.cols + 0.5) * cell;
69
+ const cy = oy + (Math.floor(at / g.cols) + 0.5) * cell;
70
+ body += accentGlyph(layer.accent, cx, cy, side, layer.accentColor ? toHex(layer.accentColor) : color);
71
+ }
72
+ }
73
+ return { defs: '', body: wrap(layer, body) };
74
+ }
75
+
76
+ /**
77
+ * The mosaic's grid over a mark: `cells` across its longest side (4–48),
78
+ * centred on its bounds (pixelate's grid, at that coarseness).
79
+ *
80
+ * @param {import('./mark-geometry.js').MarkGeometry} geo
81
+ * @param {number} [cells=10]
82
+ * @returns {import('@logomarks/core/edit').PixelGrid}
83
+ */
84
+ export function mosaicGrid(geo, cells = 10) {
85
+ const n = clamp(Math.round(cells), 4, 48);
86
+ // pixelGrid sets 48 − 34 × amount cells across.
87
+ return pixelGrid((48 - n) / 34, geo.box);
88
+ }
89
+
90
+ /**
91
+ * The filled cells less a seeded share left out (at least one is kept).
92
+ *
93
+ * @param {Uint8Array} on
94
+ * @param {number} drop - 0–1.
95
+ * @param {import('../index.js').Rng} rng
96
+ * @returns {Uint8Array}
97
+ */
98
+ function dropCells(on, drop, rng) {
99
+ const out = on.slice();
100
+ const share = clamp(drop, 0, 0.9);
101
+ let left = 0;
102
+ out.forEach((v, i) => {
103
+ if (!v) return;
104
+ // One draw a filled cell, always, so the choice is stable whatever `drop` is.
105
+ if (rng.next() < share) out[i] = 0;
106
+ else left++;
107
+ });
108
+ if (!left) return on.slice();
109
+ return out;
110
+ }
111
+
112
+ /**
113
+ * A seeded empty cell beside the mark (one with a filled neighbour), or -1.
114
+ *
115
+ * @param {Uint8Array} on
116
+ * @param {{ cols: number, rows: number }} g
117
+ * @param {import('../index.js').Rng} rng
118
+ * @returns {number}
119
+ */
120
+ function accentCell(on, { cols, rows }, rng) {
121
+ const near = [];
122
+ for (let i = 0; i < on.length; i++) {
123
+ if (on[i]) continue;
124
+ const q = i % cols;
125
+ const r = Math.floor(i / cols);
126
+ const n = [[q - 1, r], [q + 1, r], [q, r - 1], [q, r + 1]].some(([a, b]) => a >= 0 && b >= 0 && a < cols && b < rows && on[b * cols + a]);
127
+ if (n) near.push(i);
128
+ }
129
+ if (!near.length) return -1;
130
+ return near[Math.floor(rng.next() * near.length)];
131
+ }
132
+
133
+ /**
134
+ * An asterisk (three bars) or a dot, centred in a cell of `side`.
135
+ *
136
+ * @param {'asterisk' | 'dot' | string} kind
137
+ * @param {number} cx @param {number} cy @param {number} side
138
+ * @param {string} color
139
+ * @returns {string}
140
+ */
141
+ function accentGlyph(kind, cx, cy, side, color) {
142
+ if (kind === 'dot') return `<circle${attrs({ cx, cy, r: side * 0.24, fill: color })}/>`;
143
+ const L = side * 0.36;
144
+ const t = side * 0.085;
145
+ const d = [0, 60, 120].map((deg) => {
146
+ const a = (deg * Math.PI) / 180;
147
+ const [ux, uy, vx, vy] = [Math.cos(a), Math.sin(a), -Math.sin(a), Math.cos(a)];
148
+ const pts = [[-L, -t], [L, -t], [L, t], [-L, t]].map(([u, v]) => `${num(cx + u * ux + v * vx)} ${num(cy + u * uy + v * vy)}`);
149
+ return `M${pts.join('L')}Z`;
150
+ }).join('');
151
+ return `<path${attrs({ d, fill: color })}/>`;
152
+ }
153
+
154
+ /** @param {number} v @param {number} lo @param {number} hi @returns {number} */
155
+ const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
@@ -0,0 +1,26 @@
1
+ // The `outline` layer.
2
+ import type { Color, Length, BaseLayer } from '../index.js';
3
+ import type { RenderContext, Markup } from '../render/context.js';
4
+
5
+ export interface OutlineLayer extends BaseLayer {
6
+ type: 'outline';
7
+ /** The mark's paths, on a 512 grid. */
8
+ markup: string;
9
+ /** The box the lines are fitted and centred in (default the canvas). */
10
+ x?: Length | `${number}%`; y?: Length | `${number}%`; width?: Length | `${number}%`; height?: Length | `${number}%`;
11
+ /** The share of the box's shorter side the whole set's longest side takes. Default 1. */
12
+ size?: number;
13
+ /** Contour lines, 0–8: the outline and more round it, `gap` apart. 0 draws the mark solid. Default 4. */
14
+ lines?: number;
15
+ /** Between lines, a share of the mark's longest side. Default 0.05. */
16
+ gap?: number;
17
+ /** Lines round the mark (`'out'`, the default) or inside it (`'in'`). */
18
+ direction?: 'out' | 'in';
19
+ /** Line width. Default `'0.4vmin'`. */
20
+ stroke?: Length;
21
+ /** Default the palette's text. */
22
+ color?: Color;
23
+ }
24
+
25
+ /** Draw a mark as contour lines (or solid), fitted by its real bounds. */
26
+ export function outline(layer: OutlineLayer, ctx: RenderContext): Markup;
@@ -0,0 +1,105 @@
1
+ /**
2
+ * The `outline` layer.
3
+ *
4
+ * @module layers/outline
5
+ */
6
+
7
+ import { attrs, num } from '@logomarks/image-effects/svg';
8
+ import { toHex } from '@logomarks/image-effects/color';
9
+ import { len } from '@logomarks/image-effects/length';
10
+ import { wrap } from './util.js';
11
+ import { markGeometry } from './mark-geometry.js';
12
+ import { layerBox, fitMark, markPaths } from './mark-draw.js';
13
+ import { distanceField, isolines, smoothLine } from './isolines.js';
14
+
15
+ /** Contour lines drawn by the field, by markup and spacing (a series of ten draws each mark once). @type {Map<string, number[][][][]>} */
16
+ const cache = new Map();
17
+
18
+ /**
19
+ * `outline` — a mark fitted to its box by its real bounds, drawn as
20
+ * contour lines: its own outline, then `lines − 1` more, each `gap`
21
+ * further out (or in), as a topographic map draws a hill. The offsets
22
+ * are isolines of the mark's signed distance field, so parts that grow
23
+ * into each other merge, as contours do, and outer corners round off.
24
+ * `lines: 0` draws the mark solid instead, in the same place.
25
+ *
26
+ * Options: `markup` (512 grid), `x`, `y`, `width`, `height` (the box; the
27
+ * whole set of lines is fitted and centred in it), `size` (the share of
28
+ * the box's shorter side the set's longest side takes, 1), `lines` (0–8,
29
+ * 4), `gap` (a share of the mark's longest side, 0.05), `direction`
30
+ * (`'out'`, `'in'`), `stroke` (px, `'0.4vmin'`), `color` (default `text`).
31
+ *
32
+ * @param {any} layer
33
+ * @param {import('../layers.js').RenderContext} ctx
34
+ * @returns {import('../layers.js').Markup}
35
+ *
36
+ * @example
37
+ * { type: 'outline', markup: mark, x: 140, y: 100, width: 240, height: 280, lines: 4, gap: 0.05, color: '#1b1b1b' }
38
+ */
39
+ export function outline(layer, ctx) {
40
+ const geo = markGeometry(layer.markup);
41
+ if (!geo) return { defs: '', body: '' };
42
+ const lines = Math.round(Math.min(8, Math.max(0, layer.lines ?? 4)));
43
+ const side = Math.max(geo.box.width, geo.box.height);
44
+ const gap = side * Math.max(0.005, layer.gap ?? 0.05);
45
+ const inward = layer.direction === 'in';
46
+ const reach = lines > 1 && !inward ? gap * (lines - 1) : 0;
47
+ const fit = fitMark(geo.box, layerBox(layer, ctx), layer.size ?? 1, reach);
48
+ const color = layer.color ? toHex(layer.color) : ctx.palette.text;
49
+ const own = markPaths(layer.markup, fit);
50
+
51
+ if (!lines) {
52
+ const body = own.map((p) => `<path${attrs({ d: p.d, 'fill-rule': p.evenodd ? 'evenodd' : undefined })}/>`).join('');
53
+ return { defs: '', body: wrap(layer, `<g fill="${color}">${body}</g>`) };
54
+ }
55
+ const sw = len(layer.stroke, ctx.canvas, '0.4vmin');
56
+ // The outline itself, exactly; the offsets from the field.
57
+ let d = own.map((p) => p.d).join('');
58
+ for (const ring of offsetLines(layer.markup, geo, gap, lines, inward)) {
59
+ d += smoothLine(ring.map(([x, y]) => [x * fit.k + fit.dx, y * fit.k + fit.dy]), 0.1);
60
+ }
61
+ const body = `<path${attrs({ d, fill: 'none', stroke: color, 'stroke-width': num(sw, 3), 'stroke-linejoin': 'round', 'stroke-linecap': 'round' })}/>`;
62
+ return { defs: '', body: wrap(layer, body) };
63
+ }
64
+
65
+ /**
66
+ * The offset lines (not the outline itself), mark units: one set of
67
+ * polylines for each of `lines − 1` distances, `gap` apart.
68
+ *
69
+ * @param {string} markup
70
+ * @param {import('./mark-geometry.js').MarkGeometry} geo
71
+ * @param {number} gap - Mark units.
72
+ * @param {number} lines
73
+ * @param {boolean} inward
74
+ * @returns {number[][][]}
75
+ */
76
+ function offsetLines(markup, geo, gap, lines, inward) {
77
+ const key = `${num(gap, 3)}|${lines}|${inward ? 'in' : 'out'}|${markup}`;
78
+ let sets = cache.get(key);
79
+ if (!sets) {
80
+ const levels = Array.from({ length: lines - 1 }, (_, i) => (inward ? -1 : 1) * gap * (i + 1));
81
+ const field = distanceField(geo, inward ? 0 : gap * (lines - 1), levels);
82
+ sets = [];
83
+ for (const level of levels) {
84
+ // Loops much smaller than the gap are where the field peaks at the level (between parts): left out.
85
+ sets.push(isolines(field.values, field.width, field.height, level)
86
+ .map((line) => line.map(([c, r]) => [field.x0 + c * field.step, field.y0 + r * field.step]))
87
+ .filter((line) => extent(line) >= gap * 0.6));
88
+ }
89
+ if (cache.size > 100) cache.clear();
90
+ cache.set(key, sets);
91
+ }
92
+ return sets.flat();
93
+ }
94
+
95
+ /**
96
+ * The longer side of a polyline's box.
97
+ *
98
+ * @param {number[][]} line
99
+ * @returns {number}
100
+ */
101
+ function extent(line) {
102
+ let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
103
+ for (const [x, y] of line) { x0 = Math.min(x0, x); y0 = Math.min(y0, y); x1 = Math.max(x1, x); y1 = Math.max(y1, y); }
104
+ return Math.max(x1 - x0, y1 - y0);
105
+ }
package/src/layers.js CHANGED
@@ -29,7 +29,7 @@ import { paper, scribble } from './handmade.js';
29
29
  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
- import { shape, svg, table, barcode, perforation, tear, texture } from './layers/index.js';
32
+ import { shape, svg, table, barcode, perforation, tear, texture, mosaic, marks, outline, construction } from './layers/index.js';
33
33
  import { pos, layerAttrs } from './layers/util.js';
34
34
  import { paint } from './paint/index.js';
35
35
  import { imageSize } from './lint/image-size.js';
@@ -620,10 +620,11 @@ export function pattern(layer, ctx) {
620
620
  const d = [];
621
621
  if (angle === 0) {
622
622
  // Rows and columns: every `size` from the centre, or `divisions` equal
623
- // cells (inner lines only); broken wherever they cross a `gaps` region.
624
- const n = layer.divisions;
625
- const ys = n ? Array.from({ length: n - 1 }, (_, k) => ((k + 1) * h) / n) : range(cy % size, h, size);
626
- const xs = kind !== 'grid' ? [] : n ? Array.from({ length: n - 1 }, (_, k) => ((k + 1) * w) / n) : range(cx % size, w, size);
623
+ // cells (inner lines only; `[cols, rows]` for a design grid); broken
624
+ // wherever they cross a `gaps` region.
625
+ const [nx, ny] = Array.isArray(layer.divisions) ? layer.divisions.map((d) => Math.max(1, Math.round(d))) : [layer.divisions, layer.divisions];
626
+ const ys = ny ? Array.from({ length: ny - 1 }, (_, k) => ((k + 1) * h) / ny) : range(cy % size, h, size);
627
+ const xs = kind !== 'grid' ? [] : nx ? Array.from({ length: nx - 1 }, (_, k) => ((k + 1) * w) / nx) : range(cx % size, w, size);
627
628
  const gaps = (layer.gaps ?? []).map((g) => regionPx(g, ctx.canvas));
628
629
  d.push(gridPath(xs, ys, w, h, gaps));
629
630
  shapes = `<path${attrs({ d: d.join(''), fill: 'none', stroke: col, 'stroke-width': stroke, ...dashAttrs })}/>`;
@@ -1037,4 +1038,4 @@ function group() {
1037
1038
  throw new TypeError('A group layer is drawn by renderSvg()/renderImage(), not on its own');
1038
1039
  }
1039
1040
 
1040
- export const LAYERS = Object.freeze({ solid, linear, radial, mesh, stripes, image, grain, pattern, shapes, icon, text, frame, diagram, paper, scribble, ink, dither, contour, waveform, tiles, rule, group, shape, svg, table, barcode, perforation, tear, texture, repeat: group, defs: group, use: group });
1041
+ export const LAYERS = Object.freeze({ solid, linear, radial, mesh, stripes, image, grain, pattern, shapes, icon, text, frame, diagram, paper, scribble, ink, dither, contour, waveform, tiles, rule, group, shape, svg, table, barcode, perforation, tear, texture, mosaic, marks, outline, construction, repeat: group, defs: group, use: group });
@@ -2,7 +2,7 @@
2
2
  * Glyph fallback: characters the chosen font lacks are shaped in a
3
3
  * fallback font (the layer's `fallback`, then any font registered with
4
4
  * `fallback: true`), and failing those drawn from the built-in symbols
5
- * (✺ ✦ ✶ ★ ☆ ♥ ♡ ▶ ◀ ● ◆ ■ ♪). Text the font covers shapes exactly as
5
+ * (✺ ✦ ✶ ★ ☆ ♥ ♡ ▶ ◀ ● ◆ ■ ♪ ≠). Text the font covers shapes exactly as
6
6
  * before (one HarfBuzz run).
7
7
  *
8
8
  * @module type/fallback
@@ -8,6 +8,8 @@ import type { Length, Color } from '../index.js';
8
8
  export interface TextSpan {
9
9
  content: string;
10
10
  font?: string; weight?: number; style?: 'normal' | 'italic'; axes?: Record<string, number>;
11
+ /** Set at the layer's `boldWeight` (default 700) when `weight` isn't given. */
12
+ bold?: boolean;
11
13
  /** Size relative to the layer's (default 1). */
12
14
  scale?: number;
13
15
  /** Size outright. */
@@ -24,7 +26,9 @@ export interface TextSpan {
24
26
  export interface Span { text: string; face: LoadedFont; scale: number; tracking: number; paint: string | undefined; dy: number }
25
27
 
26
28
  /** Resolve a layer's spans (faces loaded, sizes relative to `size`). */
27
- export function loadSpans(layer: { spans: (TextSpan | string)[]; font?: string; weight?: number; style?: string; axes?: Record<string, number>; letterSpacing?: number; transform?: string }, ctx: RenderContext, size: number): Promise<Span[]>;
29
+ export function loadSpans(layer: { spans: (TextSpan | string)[]; font?: string; weight?: number; boldWeight?: number; style?: string; axes?: Record<string, number>; letterSpacing?: number; transform?: string }, ctx: RenderContext, size: number): Promise<Span[]>;
30
+ /** A `content` array as spans, one a line, when any item is a span object; else `undefined`. */
31
+ export function lineSpans(content: unknown): TextSpan[] | undefined;
28
32
  /** Spans as lines (split at `\n`, wrapped at `maxWidth`). */
29
33
  export function spanLines(spans: Span[], size: number, maxWidth: number, fallbacks: LoadedFont[]): Span[][];
30
34
  /** Shape a line of spans as one run. */
package/src/type/spans.js CHANGED
@@ -3,9 +3,11 @@
3
3
  * style, axes, size, colour, tracking and baseline shift, set one after
4
4
  * another on the same lines (a `\n` in a span starts a new line).
5
5
  *
6
- * `spans: [{ content, font, weight, style, axes, scale, size, color, letterSpacing, dy }]`;
6
+ * `spans: [{ content, font, weight, bold, style, axes, scale, size, color, letterSpacing, dy }]`;
7
7
  * anything a span leaves out comes from the layer. `scale` sizes a span
8
- * relative to the layer (`size` gives it outright).
8
+ * relative to the layer (`size` gives it outright); `bold: true` sets it at
9
+ * the layer's `boldWeight` (700). A `content` array with span objects in it
10
+ * is one span a line (`lineSpans`): a headline whose lines change weight.
9
11
  *
10
12
  * @module type/spans
11
13
  */
@@ -37,7 +39,8 @@ export async function loadSpans(layer, ctx, size) {
37
39
  const out = [];
38
40
  for (const given of layer.spans) {
39
41
  const s = typeof given === 'string' ? { content: given } : given;
40
- const face = await faceFor(ctx, { font: s.font ?? layer.font, weight: s.weight ?? layer.weight, style: s.style ?? layer.style, axes: { ...layer.axes, ...s.axes } });
42
+ const weight = s.weight ?? (s.bold ? layer.boldWeight ?? 700 : layer.weight);
43
+ const face = await faceFor(ctx, { font: s.font ?? layer.font, weight, style: s.style ?? layer.style, axes: { ...layer.axes, ...s.axes } });
41
44
  const kase = s.transform ?? layer.transform;
42
45
  const raw = String(s.content ?? '').replace(/[\t\r ]+/g, ' ');
43
46
  out.push({
@@ -52,6 +55,26 @@ export async function loadSpans(layer, ctx, size) {
52
55
  return out;
53
56
  }
54
57
 
58
+ /**
59
+ * A `content` array as spans, one a line, when any item is a span object
60
+ * (`{ content, bold, weight, … }`); otherwise `undefined` (plain lines stay
61
+ * content).
62
+ *
63
+ * @param {unknown} content - A text layer's `content`.
64
+ * @returns {import('./spans.js').TextSpan[] | undefined}
65
+ *
66
+ * @example
67
+ * lineSpans([{ content: 'Signal', bold: true }, 'over the']);
68
+ * // [{ content: 'Signal\n', bold: true }, { content: 'over the' }]
69
+ */
70
+ export function lineSpans(content) {
71
+ if (!Array.isArray(content) || !content.some((x) => x && typeof x === 'object')) return undefined;
72
+ return content.map((item, i) => {
73
+ const s = item && typeof item === 'object' ? item : { content: item };
74
+ return { ...s, content: `${s.content ?? ''}${i < content.length - 1 ? '\n' : ''}` };
75
+ });
76
+ }
77
+
55
78
  /**
56
79
  * Spans as lines: split at `\n`, then (with a finite `maxWidth`) wrapped at
57
80
  * spaces, greedily, at `size`.
@@ -1,7 +1,7 @@
1
1
  // Built-in symbols, drawn as shapes (the last glyph fallback).
2
2
  import type { LoadedFont } from '../fonts.js';
3
3
 
4
- /** The symbol face: ✺ ✦ ✶ ★ ☆ ♥ ♡ ▶ ◀ ● ◆ ■ ♪. */
4
+ /** The symbol face: ✺ ✦ ✶ ★ ☆ ♥ ♡ ▶ ◀ ● ◆ ■ ♪ ≠. */
5
5
  export function symbolFace(): LoadedFont;
6
6
  /** The characters it draws. */
7
7
  export const SYMBOLS: string;
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * A built-in face of a few symbols type often wants and fonts often lack
3
- * (✺ ✦ ✶ ★ ☆ ♥ ♡ ▶ ◀ ● ◆ ■ ♪), drawn as shapes. The last fallback when a
3
+ * (✺ ✦ ✶ ★ ☆ ♥ ♡ ▶ ◀ ● ◆ ■ ♪ ≠), drawn as shapes. The last fallback when a
4
4
  * text layer's font has no glyph for a character.
5
5
  *
6
6
  * @module type/symbols
@@ -38,6 +38,23 @@ function heart(k) {
38
38
  /** @param {number} r @param {number} [n=48] @returns {[number, number][]} */
39
39
  const circle = (r, n = 48) => Array.from({ length: n }, (_, i) => /** @type {[number, number]} */ ([CX + r * Math.sin((i / n) * Math.PI * 2), CY + r * Math.cos((i / n) * Math.PI * 2)]));
40
40
 
41
+ /**
42
+ * Not-equal, on a narrower advance (`NARROW`): two bars and a slash, one
43
+ * contour each, all wound alike so they fill as one.
44
+ *
45
+ * @returns {string}
46
+ */
47
+ function notEqual() {
48
+ const cx = NARROW / 2;
49
+ const [half, bar, gap, lean, stem] = [230, 62, 112, 150, 31];
50
+ const rect = (/** @type {number} */ y) => poly([[cx - half, y + bar / 2], [cx + half, y + bar / 2], [cx + half, y - bar / 2], [cx - half, y - bar / 2]]);
51
+ const slash = poly([[cx + lean - stem, CY + 300], [cx + lean + stem, CY + 300], [cx - lean + stem, CY - 300], [cx - lean - stem, CY - 300]]);
52
+ return rect(CY + gap) + rect(CY - gap) + slash;
53
+ }
54
+
55
+ /** Advance of the narrow symbols (≠), font units. */
56
+ const NARROW = 600;
57
+
41
58
  /** Outlines by character. An inner contour runs the other way (a hole). */
42
59
  const SHAPES = /** @type {Record<string, string>} */ ({
43
60
  '✺': poly(star(16, 0.5)),
@@ -53,6 +70,7 @@ const SHAPES = /** @type {Record<string, string>} */ ({
53
70
  '◆': poly([[CX, CY + R], [CX + R * 0.8, CY], [CX, CY - R], [CX - R * 0.8, CY]]),
54
71
  '■': poly([[CX - R * 0.75, CY + R * 0.75], [CX + R * 0.75, CY + R * 0.75], [CX + R * 0.75, CY - R * 0.75], [CX - R * 0.75, CY - R * 0.75]]),
55
72
  '♪': poly([[CX - 40, CY + R], [CX + 220, CY + R * 0.7], [CX + 220, CY + R * 0.45], [CX + 20, CY + R * 0.72], [CX + 20, CY - R * 0.55], [CX - 40, CY - R * 0.55]]) + poly(circle(110).map(([x, y]) => /** @type {[number, number]} */ ([x - 120, y - R * 0.6]))),
73
+ '≠': notEqual(),
56
74
  });
57
75
 
58
76
  const CHARS = Object.keys(SHAPES);
@@ -80,7 +98,7 @@ export function symbolFace() {
80
98
  variations: {},
81
99
  source: { name: 'symbols', src: 'covers:symbols', weight: 400 },
82
100
  glyphToPath: (/** @type {number} */ gid) => SHAPES[CHARS[gid - 1]] ?? '',
83
- shapeRun: (/** @type {string} */ text) => Array.from(text).map((ch, i) => ({ gid: CHARS.indexOf(ch) + 1, cluster: i, xAdvance: ADV, xOffset: 0, yOffset: 0 })),
101
+ shapeRun: (/** @type {string} */ text) => Array.from(text).map((ch, i) => ({ gid: CHARS.indexOf(ch) + 1, cluster: i, xAdvance: ch === '≠' ? NARROW : ADV, xOffset: 0, yOffset: 0 })),
84
102
  };
85
103
  return face;
86
104
  }
@@ -24,7 +24,8 @@
24
24
  * - **Live text**: `render: 'live'` writes `<text>` with the font embedded
25
25
  * by `@font-face` (or linked by URL), laid out the same way, for in-browser
26
26
  * speed and selectable text.
27
- * - **Mixed runs**: `spans`, each with its own font, weight, size, colour.
27
+ * - **Mixed runs**: `spans`, each with its own font, weight, size, colour;
28
+ * or `content` lines as span objects (`{ content, bold }`), one a line.
28
29
  * - **Per-glyph control**: `glyphs: { scale, dx, dy, rotate, color }` arrays.
29
30
  * - **Fallback**: characters the font lacks come from `fallback`, fonts
30
31
  * registered with `fallback: true`, or the built-in symbols.
@@ -52,7 +53,7 @@ import { fontFace } from './live.js';
52
53
  import { autoColor } from './auto-color.js';
53
54
  import { fallbackFaces, shapeText } from './fallback.js';
54
55
  import { controlGlyphs, glyphCount } from './glyphs.js';
55
- import { loadSpans, spanLines, shapeSpans, spanText } from './spans.js';
56
+ import { loadSpans, spanLines, shapeSpans, spanText, lineSpans } from './spans.js';
56
57
  import { recordLayout } from './layout-ref.js';
57
58
  import { marquee } from '../animate/marquee.js';
58
59
 
@@ -83,6 +84,9 @@ export function wantsType(layer) {
83
84
  * @returns {Promise<import('../layers.js').Markup>}
84
85
  */
85
86
  export async function typeset(layer, ctx) {
87
+ // Lines given as span objects (a headline whose lines change weight) are spans.
88
+ const perLine = lineSpans(layer.content);
89
+ if (perLine) layer = { ...layer, content: undefined, spans: perLine };
86
90
  const rawContent = Array.isArray(layer.content) ? layer.content.join('\n') : layer.content && typeof layer.content === 'object' ? layer.content.content : layer.content;
87
91
  const kase = layer.transform === 'uppercase' || layer.transform === 'lowercase' ? layer.transform : undefined;
88
92
  let content = layer.spans ? '' : caseText(String(rawContent ?? ''), kase);
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.3.1';
4
+ export const VERSION = '1.5.1';