@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.
- package/CHANGELOG.md +39 -0
- package/README.md +1 -1
- package/package.json +3 -2
- package/recipes/flyers/construction.json +36 -0
- package/recipes/flyers/fluted-poster.json +95 -0
- package/recipes/flyers/grid-poster.json +91 -0
- package/recipes/flyers/logofolio.json +60 -0
- package/recipes/flyers/pixel-mosaic.json +58 -0
- package/recipes/flyers/series-card.json +81 -0
- package/schema/recipe.schema.json +768 -4
- package/src/index.d.ts +9 -7
- package/src/json-recipe.js +5 -0
- package/src/layer-options.js +7 -3
- package/src/layers/construction.d.ts +34 -0
- package/src/layers/construction.js +137 -0
- package/src/layers/guides.d.ts +38 -0
- package/src/layers/guides.js +212 -0
- package/src/layers/index.d.ts +12 -0
- package/src/layers/index.js +6 -0
- package/src/layers/isolines.d.ts +21 -0
- package/src/layers/isolines.js +271 -0
- package/src/layers/mark-draw.d.ts +21 -0
- package/src/layers/mark-draw.js +93 -0
- package/src/layers/mark-geometry.d.ts +15 -0
- package/src/layers/mark-geometry.js +46 -0
- package/src/layers/marks.d.ts +25 -0
- package/src/layers/marks.js +102 -0
- package/src/layers/mosaic.d.ts +32 -0
- package/src/layers/mosaic.js +155 -0
- package/src/layers/outline.d.ts +26 -0
- package/src/layers/outline.js +105 -0
- package/src/layers.js +7 -6
- package/src/type/fallback.js +1 -1
- package/src/type/spans.d.ts +5 -1
- package/src/type/spans.js +26 -3
- package/src/type/symbols.d.ts +1 -1
- package/src/type/symbols.js +20 -2
- package/src/type/typeset.js +6 -2
- 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
|
|
624
|
-
|
|
625
|
-
const
|
|
626
|
-
const
|
|
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 });
|
package/src/type/fallback.js
CHANGED
|
@@ -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
|
package/src/type/spans.d.ts
CHANGED
|
@@ -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
|
|
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`.
|
package/src/type/symbols.d.ts
CHANGED
|
@@ -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;
|
package/src/type/symbols.js
CHANGED
|
@@ -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
|
}
|
package/src/type/typeset.js
CHANGED
|
@@ -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