@arraypress/covers 1.0.0 → 1.3.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 +118 -0
- package/README.md +18 -524
- package/package.json +34 -5
- package/recipes/brand.json +1143 -0
- package/recipes/flyers/afterglow-motion.json +540 -0
- package/recipes/flyers/afterglow.json +70 -0
- package/recipes/flyers/arcfield-mirror-motion.json +397 -0
- package/recipes/flyers/arcfield-mirror.json +364 -0
- package/recipes/flyers/bauhaus-oktav.json +319 -0
- package/recipes/flyers/bauhaus-ritmo.json +735 -0
- package/recipes/flyers/bauhaus-volta.json +394 -0
- package/recipes/flyers/bloomfall.json +99 -0
- package/recipes/flyers/drag-solenne-motion.json +444 -0
- package/recipes/flyers/drag-solenne.json +426 -0
- package/recipes/flyers/fennick-patches.json +874 -0
- package/recipes/flyers/ferro-pass.json +398 -0
- package/recipes/flyers/ferrova.json +812 -0
- package/recipes/flyers/grid-rest-rhythm.json +125 -0
- package/recipes/flyers/groovy-honeycomb.json +816 -0
- package/recipes/flyers/groovy-marigold.json +479 -0
- package/recipes/flyers/groovy-peachfuzz.json +974 -0
- package/recipes/flyers/halden-orbit-motion.json +381 -0
- package/recipes/flyers/halden-orbit.json +356 -0
- package/recipes/flyers/halvard.json +120 -0
- package/recipes/flyers/kairo-weekender.json +696 -0
- package/recipes/flyers/linger-motion.json +518 -0
- package/recipes/flyers/linger.json +67 -0
- package/recipes/flyers/little-orchard.json +105 -0
- package/recipes/flyers/lofi-hexmoth.json +912 -0
- package/recipes/flyers/lofi-lowbit-motion.json +888 -0
- package/recipes/flyers/lofi-lowbit.json +880 -0
- package/recipes/flyers/lofi-nullpoint.json +3013 -0
- package/recipes/flyers/lumio-stack-motion.json +648 -0
- package/recipes/flyers/lumio-stack.json +438 -0
- package/recipes/flyers/magazine-cover.json +168 -0
- package/recipes/flyers/magazine-extended.json +269 -0
- package/recipes/flyers/magazine-knockout.json +40 -0
- package/recipes/flyers/northfold-varsity.json +618 -0
- package/recipes/flyers/open-studio.json +69 -0
- package/recipes/flyers/orrin-merch.json +715 -0
- package/recipes/flyers/oxyde.json +113 -0
- package/recipes/flyers/quiet-fields.json +610 -0
- package/recipes/flyers/scrapbook-halcyon.json +948 -0
- package/recipes/flyers/solenne-ticket.json +730 -0
- package/recipes/flyers/solvane-motion.json +576 -0
- package/recipes/flyers/solvane.json +98 -0
- package/recipes/flyers/subduct.json +99 -0
- package/recipes/flyers/sunvale-lineup.json +584 -0
- package/recipes/flyers/tidewell.json +91 -0
- package/recipes/flyers/tour-kairo.json +535 -0
- package/recipes/flyers/velora-stickers.json +885 -0
- package/recipes/flyers/wheatpaste-black.json +313 -0
- package/recipes/flyers/wheatpaste-mark.json +198 -0
- package/recipes/flyers/wheatpaste-torn.json +114 -0
- package/recipes/flyers/wheatpaste-wall.json +984 -0
- package/recipes/flyers/wheatpaste-words.json +233 -0
- package/recipes/release/aura.json +137 -0
- package/recipes/release/chrome.json +433 -0
- package/recipes/release/collage.json +372 -0
- package/recipes/release/duotone.json +113 -0
- package/recipes/release/film-still.json +102 -0
- package/recipes/release/headline.json +70 -0
- package/recipes/release/instant.json +170 -0
- package/recipes/release/label.json +364 -0
- package/recipes/release/liquid-glass.json +301 -0
- package/recipes/release/odd-tile-dark.json +688 -0
- package/recipes/release/odd-tile-dense.json +1056 -0
- package/recipes/release/odd-tile-shift.json +541 -0
- package/recipes/release/odd-tile-split.json +571 -0
- package/recipes/release/odd-tile.json +633 -0
- package/recipes/release/off-edge.json +96 -0
- package/recipes/release/plinth.json +794 -0
- package/recipes/release/riso.json +108 -0
- package/recipes/release/series/fluid-label.json +561 -0
- package/recipes/release/series/fluid-pixel.json +334 -0
- package/recipes/release/series/hairline.json +305 -0
- package/recipes/release/series/halo.json +282 -0
- package/recipes/release/series/mark-crop.json +135 -0
- package/recipes/release/series/mark-window.json +150 -0
- package/recipes/release/series/photo-label.json +475 -0
- package/recipes/release/series/smear-field.json +345 -0
- package/recipes/release/series/smear.json +202 -0
- package/recipes/release/series/spin.json +172 -0
- package/recipes/release/series/split-ghost.json +230 -0
- package/recipes/release/series/zoom.json +168 -0
- package/recipes/release/small-print.json +114 -0
- package/recipes/release/stack.json +156 -0
- package/recipes/release/swiss.json +117 -0
- package/schema/recipe.schema.json +7624 -827
- package/src/animate/index.d.ts +9 -0
- package/src/animate/index.js +11 -0
- package/src/animate/keyframes.d.ts +21 -0
- package/src/animate/keyframes.js +119 -0
- package/src/animate/marquee.d.ts +30 -0
- package/src/animate/marquee.js +93 -0
- package/src/animate/morph.d.ts +8 -0
- package/src/animate/morph.js +99 -0
- package/src/animate/motion.d.ts +59 -0
- package/src/animate/motion.js +63 -0
- package/src/animate/sheet.d.ts +19 -0
- package/src/animate/sheet.js +61 -0
- package/src/animate/timing.d.ts +28 -0
- package/src/animate/timing.js +146 -0
- package/src/animate/tracks.d.ts +18 -0
- package/src/animate/tracks.js +139 -0
- package/src/builtins.js +4 -0
- package/src/diagram.js +4 -3
- package/src/extract.js +6 -5
- package/src/fonts.d.ts +55 -0
- package/src/fonts.js +245 -53
- package/src/formats/cut.d.ts +10 -0
- package/src/formats/cut.js +77 -0
- package/src/formats/index.d.ts +6 -0
- package/src/formats/index.js +9 -0
- package/src/formats/presets.d.ts +41 -0
- package/src/formats/presets.js +89 -0
- package/src/formats/print.d.ts +25 -0
- package/src/formats/print.js +117 -0
- package/src/formats/zones.d.ts +10 -0
- package/src/formats/zones.js +64 -0
- package/src/geometry.js +5 -35
- package/src/grid.js +1 -1
- package/src/handmade.js +9 -7
- package/src/icons.js +42 -9
- package/src/index.d.ts +232 -55
- package/src/index.js +14 -6
- package/src/inter-data.js +27 -0
- package/src/json-recipe.js +201 -27
- package/src/kit/index.d.ts +20 -0
- package/src/kit/index.js +58 -0
- package/src/layer-options.js +43 -4
- package/src/layers/barcode.d.ts +19 -0
- package/src/layers/barcode.js +63 -0
- package/src/layers/code128.d.ts +4 -0
- package/src/layers/code128.js +28 -0
- package/src/layers/expr.d.ts +6 -0
- package/src/layers/expr.js +121 -0
- package/src/layers/geometry.d.ts +6 -0
- package/src/layers/geometry.js +238 -0
- package/src/layers/index.d.ts +18 -0
- package/src/layers/index.js +15 -0
- package/src/layers/perforation.d.ts +14 -0
- package/src/layers/perforation.js +47 -0
- package/src/layers/repeat.d.ts +29 -0
- package/src/layers/repeat.js +90 -0
- package/src/layers/shape.d.ts +46 -0
- package/src/layers/shape.js +115 -0
- package/src/layers/soft.d.ts +5 -0
- package/src/layers/soft.js +43 -0
- package/src/layers/svg.d.ts +23 -0
- package/src/layers/svg.js +108 -0
- package/src/layers/table.d.ts +25 -0
- package/src/layers/table.js +80 -0
- package/src/layers/tear.d.ts +21 -0
- package/src/layers/tear.js +74 -0
- package/src/layers/texture.d.ts +17 -0
- package/src/layers/texture.js +106 -0
- package/src/layers/util.d.ts +8 -0
- package/src/layers/util.js +61 -0
- package/src/layers.js +129 -61
- package/src/lint/image-size.d.ts +4 -0
- package/src/lint/image-size.js +34 -0
- package/src/lint/index.d.ts +16 -0
- package/src/lint/index.js +127 -0
- package/src/lint/rules.d.ts +5 -0
- package/src/lint/rules.js +19 -0
- package/src/node.d.ts +2 -0
- package/src/node.js +20 -0
- package/src/paint/index.d.ts +49 -0
- package/src/paint/index.js +66 -0
- package/src/paint/presets.d.ts +10 -0
- package/src/paint/presets.js +47 -0
- package/src/paint/tile.d.ts +7 -0
- package/src/paint/tile.js +55 -0
- package/src/palette.js +1 -1
- package/src/platform/files.d.ts +6 -0
- package/src/platform/files.js +41 -0
- package/src/platform/harfbuzz.d.ts +9 -0
- package/src/platform/harfbuzz.js +80 -0
- package/src/platform/index.d.ts +8 -0
- package/src/platform/index.js +15 -0
- package/src/platform/load.d.ts +6 -0
- package/src/platform/load.js +71 -0
- package/src/platform/no-harfbuzz.d.ts +2 -0
- package/src/platform/no-harfbuzz.js +11 -0
- package/src/platform/settings.d.ts +28 -0
- package/src/platform/settings.js +51 -0
- package/src/print.js +16 -13
- package/src/recipes.js +5 -4
- package/src/render/backdrop.d.ts +6 -0
- package/src/render/backdrop.js +55 -0
- package/src/render/context.d.ts +82 -0
- package/src/render/grain.d.ts +7 -0
- package/src/render/grain.js +113 -0
- package/src/render/group.d.ts +23 -0
- package/src/render/group.js +67 -0
- package/src/render/key.d.ts +5 -0
- package/src/render/key.js +90 -0
- package/src/render/report.d.ts +5 -0
- package/src/render/report.js +19 -0
- package/src/render/transform.d.ts +36 -0
- package/src/render/transform.js +139 -0
- package/src/render/use.d.ts +27 -0
- package/src/render/use.js +73 -0
- package/src/render/wrap.d.ts +14 -0
- package/src/render/wrap.js +144 -0
- package/src/render.js +343 -215
- package/src/text.d.ts +29 -0
- package/src/text.js +23 -16
- package/src/type/auto-color.d.ts +9 -0
- package/src/type/auto-color.js +44 -0
- package/src/type/fallback.d.ts +11 -0
- package/src/type/fallback.js +92 -0
- package/src/type/glyphs.d.ts +24 -0
- package/src/type/glyphs.js +87 -0
- package/src/type/index.d.ts +5 -0
- package/src/type/index.js +10 -0
- package/src/type/kern.d.ts +4 -0
- package/src/type/kern.js +144 -0
- package/src/type/layout-ref.d.ts +10 -0
- package/src/type/layout-ref.js +74 -0
- package/src/type/line.d.ts +11 -0
- package/src/type/line.js +105 -0
- package/src/type/live.d.ts +6 -0
- package/src/type/live.js +39 -0
- package/src/type/name.d.ts +4 -0
- package/src/type/name.js +32 -0
- package/src/type/path.d.ts +18 -0
- package/src/type/path.js +89 -0
- package/src/type/pixel.d.ts +7 -0
- package/src/type/pixel.js +85 -0
- package/src/type/sfnt.d.ts +20 -0
- package/src/type/sfnt.js +258 -0
- package/src/type/spans.d.ts +33 -0
- package/src/type/spans.js +138 -0
- package/src/type/symbols.d.ts +7 -0
- package/src/type/symbols.js +89 -0
- package/src/type/typeset.d.ts +10 -0
- package/src/type/typeset.js +363 -0
- package/src/type/woff2.d.ts +6 -0
- package/src/type/woff2.js +301 -0
- package/src/version.js +4 -0
- package/src/color.js +0 -246
- package/src/png.js +0 -77
- package/src/rng.js +0 -95
- package/src/svg.js +0 -69
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Mixed runs in one text layer.
|
|
2
|
+
import type { LoadedFont } from '../fonts.js';
|
|
3
|
+
import type { Shaped } from '../text.js';
|
|
4
|
+
import type { RenderContext } from '../render/context.js';
|
|
5
|
+
import type { Length, Color } from '../index.js';
|
|
6
|
+
|
|
7
|
+
/** A span as written in a text layer's `spans`. */
|
|
8
|
+
export interface TextSpan {
|
|
9
|
+
content: string;
|
|
10
|
+
font?: string; weight?: number; style?: 'normal' | 'italic'; axes?: Record<string, number>;
|
|
11
|
+
/** Size relative to the layer's (default 1). */
|
|
12
|
+
scale?: number;
|
|
13
|
+
/** Size outright. */
|
|
14
|
+
size?: Length;
|
|
15
|
+
color?: Color; fill?: Color;
|
|
16
|
+
/** Em. */
|
|
17
|
+
letterSpacing?: number;
|
|
18
|
+
/** Baseline shift (down is positive). */
|
|
19
|
+
dy?: Length;
|
|
20
|
+
transform?: 'uppercase' | 'lowercase' | 'none';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A resolved span. */
|
|
24
|
+
export interface Span { text: string; face: LoadedFont; scale: number; tracking: number; paint: string | undefined; dy: number }
|
|
25
|
+
|
|
26
|
+
/** 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[]>;
|
|
28
|
+
/** Spans as lines (split at `\n`, wrapped at `maxWidth`). */
|
|
29
|
+
export function spanLines(spans: Span[], size: number, maxWidth: number, fallbacks: LoadedFont[]): Span[][];
|
|
30
|
+
/** Shape a line of spans as one run. */
|
|
31
|
+
export function shapeSpans(line: Span[], size: number, extra: number, base: LoadedFont | undefined, fallbacks: LoadedFont[]): Shaped;
|
|
32
|
+
/** A line's text. */
|
|
33
|
+
export function spanText(line: Span[]): string;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mixed runs in one text layer: `spans`, each with its own font, weight,
|
|
3
|
+
* style, axes, size, colour, tracking and baseline shift, set one after
|
|
4
|
+
* another on the same lines (a `\n` in a span starts a new line).
|
|
5
|
+
*
|
|
6
|
+
* `spans: [{ content, font, weight, style, axes, scale, size, color, letterSpacing, dy }]`;
|
|
7
|
+
* anything a span leaves out comes from the layer. `scale` sizes a span
|
|
8
|
+
* relative to the layer (`size` gives it outright).
|
|
9
|
+
*
|
|
10
|
+
* @module type/spans
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { len } from '@logomarks/image-effects/length';
|
|
14
|
+
import { toHex } from '@logomarks/image-effects/color';
|
|
15
|
+
import { faceFor } from './line.js';
|
|
16
|
+
import { shapeText } from './fallback.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {object} Span
|
|
20
|
+
* @property {string} text
|
|
21
|
+
* @property {import('../fonts.js').LoadedFont} face
|
|
22
|
+
* @property {number} scale - Size relative to the layer's.
|
|
23
|
+
* @property {number} tracking - Em.
|
|
24
|
+
* @property {string | undefined} paint - A colour, or the layer's fill.
|
|
25
|
+
* @property {number} dy - Baseline shift, px at the layer's size (scales with it).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Resolve a layer's spans: faces loaded, sizes as multiples of the layer's.
|
|
30
|
+
*
|
|
31
|
+
* @param {any} layer
|
|
32
|
+
* @param {import('../layers.js').RenderContext} ctx
|
|
33
|
+
* @param {number} size - The layer's size, px.
|
|
34
|
+
* @returns {Promise<Span[]>}
|
|
35
|
+
*/
|
|
36
|
+
export async function loadSpans(layer, ctx, size) {
|
|
37
|
+
const out = [];
|
|
38
|
+
for (const given of layer.spans) {
|
|
39
|
+
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 } });
|
|
41
|
+
const kase = s.transform ?? layer.transform;
|
|
42
|
+
const raw = String(s.content ?? '').replace(/[\t\r ]+/g, ' ');
|
|
43
|
+
out.push({
|
|
44
|
+
text: kase === 'uppercase' ? raw.toUpperCase() : kase === 'lowercase' ? raw.toLowerCase() : raw,
|
|
45
|
+
face,
|
|
46
|
+
scale: s.size != null ? len(s.size, ctx.canvas) / size : s.scale ?? 1,
|
|
47
|
+
tracking: s.letterSpacing ?? layer.letterSpacing ?? -0.02,
|
|
48
|
+
paint: s.color ?? (typeof s.fill === 'string' ? s.fill : undefined) ? toHex(s.color ?? s.fill) : undefined,
|
|
49
|
+
dy: len(s.dy, ctx.canvas, 0) / size,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Spans as lines: split at `\n`, then (with a finite `maxWidth`) wrapped at
|
|
57
|
+
* spaces, greedily, at `size`.
|
|
58
|
+
*
|
|
59
|
+
* @param {Span[]} spans
|
|
60
|
+
* @param {number} size
|
|
61
|
+
* @param {number} maxWidth
|
|
62
|
+
* @param {import('../fonts.js').LoadedFont[]} fallbacks
|
|
63
|
+
* @returns {Span[][]}
|
|
64
|
+
*/
|
|
65
|
+
export function spanLines(spans, size, maxWidth, fallbacks) {
|
|
66
|
+
/** @type {Span[][]} */
|
|
67
|
+
const lines = [[]];
|
|
68
|
+
for (const s of spans) {
|
|
69
|
+
s.text.split('\n').forEach((part, i) => {
|
|
70
|
+
if (i) lines.push([]);
|
|
71
|
+
if (part) lines[lines.length - 1].push({ ...s, text: part });
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
if (!Number.isFinite(maxWidth)) return lines;
|
|
75
|
+
/** @type {Span[][]} */
|
|
76
|
+
const out = [];
|
|
77
|
+
for (const line of lines) {
|
|
78
|
+
/** @type {Span[]} */
|
|
79
|
+
let cur = [];
|
|
80
|
+
let width = 0;
|
|
81
|
+
for (const s of line) {
|
|
82
|
+
for (const word of s.text.split(/(?<= )/)) {
|
|
83
|
+
const piece = { ...s, text: word };
|
|
84
|
+
const w = shapeSpans([piece], size, 0, undefined, fallbacks).width;
|
|
85
|
+
if (cur.length && width + w - trailing(piece, size) > maxWidth) {
|
|
86
|
+
out.push(trim(cur));
|
|
87
|
+
cur = [];
|
|
88
|
+
width = 0;
|
|
89
|
+
}
|
|
90
|
+
cur.push(piece);
|
|
91
|
+
width += w;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
out.push(trim(cur));
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** @param {Span} s @param {number} size @returns {number} The width of a trailing space, px (it may hang past the line). */
|
|
100
|
+
const trailing = (s, size) => (s.text.endsWith(' ') ? size * s.scale * 0.25 : 0);
|
|
101
|
+
|
|
102
|
+
/** @param {Span[]} line @returns {Span[]} The line with its ends' spaces trimmed. */
|
|
103
|
+
function trim(line) {
|
|
104
|
+
if (!line.length) return line;
|
|
105
|
+
const out = line.map((s) => ({ ...s }));
|
|
106
|
+
out[0].text = out[0].text.replace(/^ +/, '');
|
|
107
|
+
out[out.length - 1].text = out[out.length - 1].text.replace(/ +$/, '');
|
|
108
|
+
return out.filter((s) => s.text);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Shape a line of spans as one run: each span in its face and size, its
|
|
113
|
+
* glyphs carrying them (and their colour as a paint key).
|
|
114
|
+
*
|
|
115
|
+
* @param {Span[]} line
|
|
116
|
+
* @param {number} size - The layer's size, px.
|
|
117
|
+
* @param {number} extra - Tracking added everywhere, px (justification).
|
|
118
|
+
* @param {import('../fonts.js').LoadedFont | undefined} base - The layer's face (its glyphs needn't carry it).
|
|
119
|
+
* @param {import('../fonts.js').LoadedFont[]} fallbacks
|
|
120
|
+
* @returns {import('../text.js').Shaped}
|
|
121
|
+
*/
|
|
122
|
+
export function shapeSpans(line, size, extra, base, fallbacks) {
|
|
123
|
+
/** @type {import('../text.js').Shaped['glyphs']} */
|
|
124
|
+
const glyphs = [];
|
|
125
|
+
let x = 0;
|
|
126
|
+
let at = 0;
|
|
127
|
+
line.forEach((s, i) => {
|
|
128
|
+
const sz = size * s.scale;
|
|
129
|
+
const run = shapeText(s.face, s.text, sz, s.tracking * sz + extra, fallbacks);
|
|
130
|
+
for (const g of run.glyphs) glyphs.push({ ...g, x: g.x + x, y: g.y - s.dy * size, face: g.face ?? s.face, size: sz, paint: s.paint, cluster: (g.cluster ?? 0) + at });
|
|
131
|
+
x += run.width + (i < line.length - 1 ? s.tracking * sz + extra : 0);
|
|
132
|
+
at += s.text.length;
|
|
133
|
+
});
|
|
134
|
+
return { glyphs, width: x };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** @param {Span[]} line @returns {string} The line's text. */
|
|
138
|
+
export const spanText = (line) => line.map((s) => s.text).join('');
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Built-in symbols, drawn as shapes (the last glyph fallback).
|
|
2
|
+
import type { LoadedFont } from '../fonts.js';
|
|
3
|
+
|
|
4
|
+
/** The symbol face: ✺ ✦ ✶ ★ ☆ ♥ ♡ ▶ ◀ ● ◆ ■ ♪. */
|
|
5
|
+
export function symbolFace(): LoadedFont;
|
|
6
|
+
/** The characters it draws. */
|
|
7
|
+
export const SYMBOLS: string;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
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
|
|
4
|
+
* text layer's font has no glyph for a character.
|
|
5
|
+
*
|
|
6
|
+
* @module type/symbols
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Advance, font units (1000 per em). */
|
|
10
|
+
const ADV = 820;
|
|
11
|
+
/** Centre of a symbol: half the advance across, about the middle of a capital up. */
|
|
12
|
+
const CX = ADV / 2;
|
|
13
|
+
const CY = 340;
|
|
14
|
+
const R = 360;
|
|
15
|
+
|
|
16
|
+
/** @param {[number, number][]} pts @returns {string} A closed polygon, font units (y up). */
|
|
17
|
+
const poly = (pts) => `M${pts.map(([x, y]) => `${x.toFixed(1)},${y.toFixed(1)}`).join('L')}Z`;
|
|
18
|
+
|
|
19
|
+
/** @param {number} n @param {number} inner @param {number} [turn=0] @returns {[number, number][]} A star's points (clockwise from the top). */
|
|
20
|
+
function star(n, inner, turn = 0) {
|
|
21
|
+
return Array.from({ length: n * 2 }, (_, i) => {
|
|
22
|
+
const a = (i / (n * 2)) * Math.PI * 2 + turn;
|
|
23
|
+
const r = i % 2 ? R * inner : R;
|
|
24
|
+
return /** @type {[number, number]} */ ([CX + r * Math.sin(a), CY + r * Math.cos(a)]);
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** @param {number} k - Size, 0–1. @returns {[number, number][]} A heart, clockwise. */
|
|
29
|
+
function heart(k) {
|
|
30
|
+
return Array.from({ length: 72 }, (_, i) => {
|
|
31
|
+
const t = (i / 72) * Math.PI * 2;
|
|
32
|
+
const x = 16 * Math.sin(t) ** 3;
|
|
33
|
+
const y = 13 * Math.cos(t) - 5 * Math.cos(2 * t) - 2 * Math.cos(3 * t) - Math.cos(4 * t);
|
|
34
|
+
return /** @type {[number, number]} */ ([CX + x * 22 * k, CY + 30 + y * 22 * k]);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** @param {number} r @param {number} [n=48] @returns {[number, number][]} */
|
|
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
|
+
|
|
41
|
+
/** Outlines by character. An inner contour runs the other way (a hole). */
|
|
42
|
+
const SHAPES = /** @type {Record<string, string>} */ ({
|
|
43
|
+
'✺': poly(star(16, 0.5)),
|
|
44
|
+
'✦': poly(star(4, 0.32)),
|
|
45
|
+
'✶': poly(star(6, 0.45)),
|
|
46
|
+
'★': poly(star(5, 0.4)),
|
|
47
|
+
'☆': poly(star(5, 0.4)) + poly(star(5, 0.4).map(([x, y]) => /** @type {[number, number]} */ ([CX + (x - CX) * 0.7, CY + (y - CY) * 0.7])).reverse()),
|
|
48
|
+
'♥': poly(heart(1)),
|
|
49
|
+
'♡': poly(heart(1)) + poly(heart(0.72).map(([x, y]) => /** @type {[number, number]} */ ([x, y + 28])).reverse()),
|
|
50
|
+
'▶': poly([[CX - R * 0.8, CY + R], [CX + R, CY], [CX - R * 0.8, CY - R]]),
|
|
51
|
+
'◀': poly([[CX + R * 0.8, CY + R], [CX + R * 0.8, CY - R], [CX - R, CY]]),
|
|
52
|
+
'●': poly(circle(R * 0.8)),
|
|
53
|
+
'◆': poly([[CX, CY + R], [CX + R * 0.8, CY], [CX, CY - R], [CX - R * 0.8, CY]]),
|
|
54
|
+
'■': 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
|
+
'♪': 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]))),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const CHARS = Object.keys(SHAPES);
|
|
59
|
+
|
|
60
|
+
/** @type {any} */
|
|
61
|
+
let face;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The symbol face (shaped and outlined in JavaScript).
|
|
65
|
+
*
|
|
66
|
+
* @returns {import('../fonts.js').LoadedFont}
|
|
67
|
+
*/
|
|
68
|
+
export function symbolFace() {
|
|
69
|
+
face ??= {
|
|
70
|
+
name: 'symbols',
|
|
71
|
+
weight: 400,
|
|
72
|
+
style: 'normal',
|
|
73
|
+
font: null,
|
|
74
|
+
upem: 1000,
|
|
75
|
+
ascender: 800,
|
|
76
|
+
descender: -200,
|
|
77
|
+
capHeight: 700,
|
|
78
|
+
paths: new Map(),
|
|
79
|
+
axes: {},
|
|
80
|
+
variations: {},
|
|
81
|
+
source: { name: 'symbols', src: 'covers:symbols', weight: 400 },
|
|
82
|
+
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 })),
|
|
84
|
+
};
|
|
85
|
+
return face;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The characters the symbol face draws. */
|
|
89
|
+
export const SYMBOLS = Object.freeze(CHARS.join(''));
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// The text layer with its 1.1 options.
|
|
2
|
+
import type { TextLayer } from '../index.js';
|
|
3
|
+
import type { RenderContext, Markup } from '../render/context.js';
|
|
4
|
+
|
|
5
|
+
/** The options that switch a text layer onto the poster typesetter. */
|
|
6
|
+
export const TYPE_OPTIONS: readonly (keyof TextLayer)[];
|
|
7
|
+
/** Whether a text layer uses any of them. */
|
|
8
|
+
export function wantsType(layer: TextLayer): boolean;
|
|
9
|
+
/** Draw a text layer with its 1.1 options. */
|
|
10
|
+
export function typeset(layer: TextLayer, ctx: RenderContext): Promise<Markup>;
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typesetting for posters and flyers: what the `text` layer does when it's
|
|
3
|
+
* given any of the 1.1 options (the 1.0 path is untouched, so 1.0 covers
|
|
4
|
+
* render byte-identically).
|
|
5
|
+
*
|
|
6
|
+
* - **Baseline placement**: `x`, `y` put the first baseline where SVG
|
|
7
|
+
* `<text x y>` would, with `align` acting as `text-anchor`.
|
|
8
|
+
* - **Justified to a width**: `width` sets each line to exactly that width,
|
|
9
|
+
* like `textLength` (`lengthAdjust: 'spacingAndGlyphs'` scales the glyphs,
|
|
10
|
+
* the default; `'spacing'` opens the letter-spacing instead).
|
|
11
|
+
* - **Fitting**: `fit: 'width'` sizes the block so its widest line fills
|
|
12
|
+
* `width` (or `maxWidth`); `fit: 'lines'` sizes every line on its own to
|
|
13
|
+
* fill it (the stacked full-width headline); `fit: 'shrink'` shrinks to
|
|
14
|
+
* `minSize` before wrapping further.
|
|
15
|
+
* - **Stretch and slant**: `stretch: [sx, sy]` about the first baseline.
|
|
16
|
+
* - **Variable fonts**: `axes: { wdth: 50, SOFT: 100, WONK: 1 }`.
|
|
17
|
+
* - **Strokes**: `stroke`, `strokeWidth`, `fill: 'none'` (outline type), or
|
|
18
|
+
* `strokes: [{ color, width }]`, drawn widest first under the fill, for
|
|
19
|
+
* keylines.
|
|
20
|
+
* - **Text on a path**: `path` (`circle`, `arc`, `ellipse`, `wave`, or any
|
|
21
|
+
* `d`), `startOffset`, `fitPath` to fill a ring, `dy`.
|
|
22
|
+
* - **Auto contrast**: `color: 'auto'` picks white or near-black (or your
|
|
23
|
+
* `autoColors`) by contrast against whatever is drawn underneath.
|
|
24
|
+
* - **Live text**: `render: 'live'` writes `<text>` with the font embedded
|
|
25
|
+
* by `@font-face` (or linked by URL), laid out the same way, for in-browser
|
|
26
|
+
* speed and selectable text.
|
|
27
|
+
* - **Mixed runs**: `spans`, each with its own font, weight, size, colour.
|
|
28
|
+
* - **Per-glyph control**: `glyphs: { scale, dx, dy, rotate, color }` arrays.
|
|
29
|
+
* - **Fallback**: characters the font lacks come from `fallback`, fonts
|
|
30
|
+
* registered with `fallback: true`, or the built-in symbols.
|
|
31
|
+
* - **Pixel type**: `font: 'pixel'`, a built-in 5×7 face.
|
|
32
|
+
* - **Layout from data**: `fit: 'words'` (a word a line), `fit: 'wrap'`
|
|
33
|
+
* (wrap, then fill the width), `split: 'words'`, and the block's box for
|
|
34
|
+
* later layers as `{layout.<id>.bottom}` and the like.
|
|
35
|
+
*
|
|
36
|
+
* @module type/typeset
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { attrs, esc, num } from '@logomarks/image-effects/svg';
|
|
40
|
+
import { len } from '@logomarks/image-effects/length';
|
|
41
|
+
import { place, anchorAlign, unit } from '../geometry.js';
|
|
42
|
+
import { toHex } from '@logomarks/image-effects/color';
|
|
43
|
+
import { layout, caseText } from '../text.js';
|
|
44
|
+
import { paint } from '../paint/index.js';
|
|
45
|
+
import { pos, wrap } from '../layers/util.js';
|
|
46
|
+
import { presetStrokes } from '../paint/presets.js';
|
|
47
|
+
import { extrude } from '@logomarks/image-effects/effects/extrude';
|
|
48
|
+
import { dashArray } from '../layers/shape.js';
|
|
49
|
+
import { faceFor, mappedPath, setLine } from './line.js';
|
|
50
|
+
import { textPathD, pathSampler } from './path.js';
|
|
51
|
+
import { fontFace } from './live.js';
|
|
52
|
+
import { autoColor } from './auto-color.js';
|
|
53
|
+
import { fallbackFaces, shapeText } from './fallback.js';
|
|
54
|
+
import { controlGlyphs, glyphCount } from './glyphs.js';
|
|
55
|
+
import { loadSpans, spanLines, shapeSpans, spanText } from './spans.js';
|
|
56
|
+
import { recordLayout } from './layout-ref.js';
|
|
57
|
+
import { marquee } from '../animate/marquee.js';
|
|
58
|
+
|
|
59
|
+
/** Options that switch the text layer onto this typesetter. */
|
|
60
|
+
export const TYPE_OPTIONS = ['x', 'y', 'width', 'justify', 'lengthAdjust', 'fit', 'stretch', 'axes', 'stroke', 'strokeWidth', 'strokes', 'fill', 'path', 'startOffset', 'fitPath', 'dy', 'render', 'autoColors', 'lines', 'spans', 'glyphs', 'fallback', 'split', 'dash', 'extrude', 'maxHeight', 'autoThreshold'];
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Whether a text layer needs this typesetter.
|
|
64
|
+
*
|
|
65
|
+
* @param {any} layer
|
|
66
|
+
* @returns {boolean}
|
|
67
|
+
*/
|
|
68
|
+
export function wantsType(layer) {
|
|
69
|
+
return TYPE_OPTIONS.some((k) => layer[k] !== undefined) || layer.color === 'auto' || Array.isArray(layer.content) || layer.font === 'pixel';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A line to set: its text, size, and (with `spans`) its runs.
|
|
74
|
+
*
|
|
75
|
+
* @typedef {{ text: string, size: number, spans?: import('./spans.js').Span[] }} Line
|
|
76
|
+
*/
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The `text` layer with 1.1 options.
|
|
80
|
+
*
|
|
81
|
+
* @param {any} layer
|
|
82
|
+
* @param {import('../layers.js').RenderContext} ctx
|
|
83
|
+
* @returns {Promise<import('../layers.js').Markup>}
|
|
84
|
+
*/
|
|
85
|
+
export async function typeset(layer, ctx) {
|
|
86
|
+
const rawContent = Array.isArray(layer.content) ? layer.content.join('\n') : layer.content && typeof layer.content === 'object' ? layer.content.content : layer.content;
|
|
87
|
+
const kase = layer.transform === 'uppercase' || layer.transform === 'lowercase' ? layer.transform : undefined;
|
|
88
|
+
let content = layer.spans ? '' : caseText(String(rawContent ?? ''), kase);
|
|
89
|
+
if (!layer.spans && (layer.split === 'words' || layer.fit === 'words')) content = content.split(/\s+/).filter(Boolean).join('\n');
|
|
90
|
+
const p = ctx.palette;
|
|
91
|
+
const u = unit(ctx.canvas);
|
|
92
|
+
const face = await faceFor(ctx, layer);
|
|
93
|
+
const fallbacks = await fallbackFaces(ctx, layer);
|
|
94
|
+
let size = len(layer.size, ctx.canvas, u * 0.06);
|
|
95
|
+
const ls = layer.letterSpacing ?? -0.02;
|
|
96
|
+
const lh = layer.lineHeight ?? 1.12;
|
|
97
|
+
const baseline = layer.x != null || layer.y != null;
|
|
98
|
+
const anchor = layer.anchor ?? 'bottom-left';
|
|
99
|
+
const align = layer.align ?? (baseline ? 'left' : anchorAlign(anchor));
|
|
100
|
+
const margin = len(layer.margin, ctx.canvas, /** @type {any} */ (ctx).inset ?? u * 0.07);
|
|
101
|
+
const target = layer.width != null ? len(layer.width, ctx.canvas) : undefined;
|
|
102
|
+
const maxWidth = layer.maxWidth != null ? len(layer.maxWidth, ctx.canvas) : baseline ? Infinity : ctx.width - 2 * margin;
|
|
103
|
+
const justifyTo = target ?? (layer.justify ? maxWidth : undefined);
|
|
104
|
+
const adjust = layer.lengthAdjust ?? 'spacingAndGlyphs';
|
|
105
|
+
const id = ctx.id;
|
|
106
|
+
const spans = layer.spans ? await loadSpans(layer, ctx, size) : undefined;
|
|
107
|
+
if (spans) content = spans.map((s) => s.text).join('');
|
|
108
|
+
if (!content.trim()) return { defs: '', body: '' };
|
|
109
|
+
let defs = '';
|
|
110
|
+
|
|
111
|
+
/** Shape a line at a tracking (px), through fallback, spans and per-glyph control. @type {(l: Line, tracking: number, start: number) => import('../text.js').Shaped} */
|
|
112
|
+
const shapeLine = (l, tracking, start) => {
|
|
113
|
+
const run = l.spans ? shapeSpans(l.spans, l.size, tracking - ls * l.size, face, fallbacks) : shapeText(face, l.text, l.size, tracking, fallbacks);
|
|
114
|
+
return controlGlyphs(run, layer.glyphs, { start, size: l.size, capHeight: (face.capHeight / face.upem) * l.size, canvas: ctx.canvas, text: l.text }).run;
|
|
115
|
+
};
|
|
116
|
+
/** @param {Line} l @returns {number} */
|
|
117
|
+
const natural = (l) => shapeLine(l, ls * l.size, 0).width;
|
|
118
|
+
|
|
119
|
+
// ---- Lines and sizes ----
|
|
120
|
+
/** @type {Line[]} */
|
|
121
|
+
let lines;
|
|
122
|
+
if (layer.path) {
|
|
123
|
+
lines = [{ text: content.replace(/\n/g, ' '), size, spans: spans ? spanLines(spans, size, Infinity, fallbacks).flat() : undefined }];
|
|
124
|
+
} else if (spans) {
|
|
125
|
+
const fitting = layer.fit === 'width' || layer.fit === 'lines' || layer.fit === 'words';
|
|
126
|
+
const rows = spanLines(spans, size, fitting ? Infinity : layer.fit === 'wrap' ? justifyTo ?? maxWidth : maxWidth, fallbacks);
|
|
127
|
+
lines = rows.map((r) => ({ text: spanText(r), size, spans: r }));
|
|
128
|
+
lines = fitLines(lines, layer.fit, justifyTo ?? maxWidth, natural);
|
|
129
|
+
} else if (layer.fit === 'width' || layer.fit === 'lines' || layer.fit === 'words') {
|
|
130
|
+
lines = fitLines(content.split('\n').map((t) => ({ text: t, size })), layer.fit, justifyTo ?? maxWidth, natural);
|
|
131
|
+
} else {
|
|
132
|
+
const fitTo = justifyTo ?? maxWidth;
|
|
133
|
+
const set = layout({
|
|
134
|
+
face,
|
|
135
|
+
text: content,
|
|
136
|
+
size,
|
|
137
|
+
minSize: layer.minSize != null ? len(layer.minSize, ctx.canvas) : layer.fit === 'shrink' ? size * 0.5 : undefined,
|
|
138
|
+
letterSpacing: ls,
|
|
139
|
+
// Only `fit: 'wrap'` wraps at the justification width; otherwise `width` justifies what `maxWidth` wrapped.
|
|
140
|
+
maxWidth: layer.fit === 'wrap' ? fitTo : maxWidth,
|
|
141
|
+
maxLines: layer.maxLines ?? (baseline || layer.fit === 'wrap' ? Infinity : 3),
|
|
142
|
+
ellipsis: layer.ellipsis,
|
|
143
|
+
balance: layer.balance ?? !baseline,
|
|
144
|
+
});
|
|
145
|
+
size = set.size;
|
|
146
|
+
lines = set.lines.map((l) => ({ text: l.text, size }));
|
|
147
|
+
// Wrap, then fill: every line at one size, the widest filling the width.
|
|
148
|
+
if (layer.fit === 'wrap') lines = fitLines(lines, 'width', fitTo, natural);
|
|
149
|
+
}
|
|
150
|
+
// A block taller than `maxHeight` shrinks to it.
|
|
151
|
+
if (layer.maxHeight != null && !layer.path) {
|
|
152
|
+
const cap = face.capHeight / face.upem;
|
|
153
|
+
const tall = lines[0].size * cap + lines.slice(1).reduce((h, l) => h + l.size * lh, 0);
|
|
154
|
+
const k = len(layer.maxHeight, ctx.canvas) / tall;
|
|
155
|
+
if (k < 1) lines = lines.map((l) => ({ ...l, size: l.size * k }));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ---- Paint ----
|
|
159
|
+
const strokes = layer.strokes ?? (layer.stroke ? [{ color: layer.stroke, width: layer.strokeWidth ?? '0.3vmin', dash: layer.dash }] : presetStrokes(layer.fill) ?? []);
|
|
160
|
+
const live = (layer.render ?? /** @type {any} */ (ctx).textRender) === 'live';
|
|
161
|
+
|
|
162
|
+
// ---- Text on a path ----
|
|
163
|
+
if (layer.path) {
|
|
164
|
+
const d = textPathD(layer.path, ctx.canvas);
|
|
165
|
+
const sampler = pathSampler(d);
|
|
166
|
+
const line = lines[0];
|
|
167
|
+
let run = shapeLine(line, ls * size, 0);
|
|
168
|
+
if (layer.fitPath) {
|
|
169
|
+
const gaps = Math.max(1, run.glyphs.length);
|
|
170
|
+
const gap = len(layer.fitPath === true ? 0 : layer.fitPath, ctx.canvas);
|
|
171
|
+
run = shapeLine(line, ls * size + (sampler.length - gap - run.width) / gaps, 0);
|
|
172
|
+
}
|
|
173
|
+
const so = layer.startOffset ?? (layer.fitPath ? 0 : 0.5);
|
|
174
|
+
const offset = typeof so === 'number' && so <= 1 && so >= 0 ? so * sampler.length : pos(so, 'x', ctx.canvas);
|
|
175
|
+
let start = layer.fitPath ? offset : align === 'center' || layer.align == null ? offset - run.width / 2 : align === 'right' ? offset - run.width : offset;
|
|
176
|
+
const dy = len(layer.dy, ctx.canvas, 0);
|
|
177
|
+
const adv = (/** @type {number} */ i) => (i + 1 < run.glyphs.length ? run.glyphs[i + 1].x : run.width) - run.glyphs[i].x;
|
|
178
|
+
const mids = run.glyphs.map((g, i) => g.x + adv(i) / 2);
|
|
179
|
+
const index = new Map(run.glyphs.map((g, i) => [g, i]));
|
|
180
|
+
const along = (/** @type {(g: any) => boolean} */ only) => mappedPath(face, run, size, (gx, gy, g) => {
|
|
181
|
+
const i = /** @type {number} */ (index.get(/** @type {any} */ (g)));
|
|
182
|
+
const at = sampler.at(start + mids[i]);
|
|
183
|
+
const a = gx - mids[i];
|
|
184
|
+
const across = gy + dy;
|
|
185
|
+
return [at.x + at.tx * a - at.ty * across, at.y + at.ty * a + at.tx * across];
|
|
186
|
+
}, only);
|
|
187
|
+
const whole = { x: 0, y: 0, width: ctx.width, height: ctx.height };
|
|
188
|
+
const fill = layer.fill === 'none' ? { defs: '', value: 'none' } : layer.color === 'auto' ? { defs: '', value: await autoColor(layer, ctx, whole) } : paint(layer.fill ?? layer.color, p.text, { id: `${id}f`, box: whole, canvas: ctx.canvas });
|
|
189
|
+
defs += fill.defs;
|
|
190
|
+
// A marquee (motion): the still moves the start; playing, each glyph is drawn once in its own frame and placed by copies.
|
|
191
|
+
const running = /** @type {any} */ (ctx).marquee && marquee(/** @type {any} */ (ctx).marquee, {
|
|
192
|
+
run, mids, start, sampler, id,
|
|
193
|
+
glyph: (i) => drawBuckets({ glyphs: [run.glyphs[i]] }, (only) => mappedPath(face, run, size, (gx, gy) => [gx - mids[i], gy + dy], (g) => g === run.glyphs[i] && only(g)), fill.value, strokes, ctx),
|
|
194
|
+
});
|
|
195
|
+
if (running) {
|
|
196
|
+
start += running.shift;
|
|
197
|
+
defs += running.defs;
|
|
198
|
+
}
|
|
199
|
+
report(ctx, layer, content, size, face, null, { capHeight: (face.capHeight / face.upem) * size });
|
|
200
|
+
const drawn = running?.body ?? drawBuckets(run, along, fill.value, strokes, ctx);
|
|
201
|
+
recordLayout(ctx, layer.id, whole, { x: 0, y: 0, baseline: 0, size, lines: 1 });
|
|
202
|
+
return { defs, body: wrap(layer, finish(drawn, layer, ctx)) };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ---- Measure, then place ----
|
|
206
|
+
// `width` justifies every line, except under a fit that sizes the block to
|
|
207
|
+
// its widest line (`width`, `words`, `wrap`): there the widest fills the
|
|
208
|
+
// width and the others keep their natural width.
|
|
209
|
+
const widestOnly = justifyTo != null && (layer.fit === 'width' || layer.fit === 'words' || layer.fit === 'wrap');
|
|
210
|
+
const naturals = widestOnly ? lines.map(natural) : [];
|
|
211
|
+
const widest = widestOnly ? naturals.indexOf(Math.max(...naturals)) : -1;
|
|
212
|
+
const runs = lines.map((l, i) => {
|
|
213
|
+
const fill = justifyTo != null && (!widestOnly || i === widest);
|
|
214
|
+
const w = fill ? /** @type {number} */ (justifyTo) : widestOnly ? naturals[i] : natural(l);
|
|
215
|
+
return { ...l, w, fill, cap: (face.capHeight / face.upem) * l.size };
|
|
216
|
+
});
|
|
217
|
+
const lineStep = (/** @type {number} */ i) => runs[i].size * lh;
|
|
218
|
+
let x0;
|
|
219
|
+
let y0;
|
|
220
|
+
const blockW = Math.max(...runs.map((r) => r.w));
|
|
221
|
+
if (baseline) {
|
|
222
|
+
x0 = pos(layer.x ?? 0, 'x', ctx.canvas);
|
|
223
|
+
y0 = pos(layer.y ?? 0, 'y', ctx.canvas);
|
|
224
|
+
} else {
|
|
225
|
+
let h = runs[0].cap;
|
|
226
|
+
for (let i = 1; i < runs.length; i++) h += lineStep(i);
|
|
227
|
+
const at = place({ width: blockW, height: h, canvas: ctx.canvas, anchor, margin, position: layer.position, offset: layer.offset });
|
|
228
|
+
x0 = align === 'center' ? at.x + blockW / 2 : align === 'right' ? at.x + blockW : at.x;
|
|
229
|
+
y0 = at.y + runs[0].cap;
|
|
230
|
+
}
|
|
231
|
+
const baselines = [y0];
|
|
232
|
+
for (let i = 1; i < runs.length; i++) baselines.push(baselines[i - 1] + lineStep(i));
|
|
233
|
+
const top = y0 - runs[0].cap;
|
|
234
|
+
const left = align === 'center' ? x0 - blockW / 2 : align === 'right' ? x0 - blockW : x0;
|
|
235
|
+
const box = { x: left, y: top, width: blockW, height: baselines[baselines.length - 1] - top };
|
|
236
|
+
const [sx, sy] = layer.stretch ? (Array.isArray(layer.stretch) ? layer.stretch : [1, layer.stretch]) : [1, 1];
|
|
237
|
+
// The block as drawn (stretched about the first baseline), for lint, `color: 'auto'` and later layers.
|
|
238
|
+
const drawnBox = { x: Math.min(x0 + (box.x - x0) * sx, x0 + (box.x + box.width - x0) * sx), y: Math.min(y0 + (box.y - y0) * sy, y0 + (box.y + box.height - y0) * sy), width: box.width * Math.abs(sx), height: box.height * Math.abs(sy) };
|
|
239
|
+
|
|
240
|
+
const color = layer.color === 'auto' ? await autoColor(layer, ctx, drawnBox) : undefined;
|
|
241
|
+
const fillPaint = layer.fill === 'none' ? { defs: '', value: 'none' } : paint(color ?? layer.fill ?? layer.color, p.text, { id: `${id}f`, box, canvas: ctx.canvas });
|
|
242
|
+
defs += fillPaint.defs;
|
|
243
|
+
recordLayout(ctx, layer.id, drawnBox, { x: x0, y: y0, baseline: y0 + (baselines[baselines.length - 1] - y0) * sy, size: runs[0].size, lines: runs.length });
|
|
244
|
+
|
|
245
|
+
let body;
|
|
246
|
+
/** Narrowest horizontal scale a line was set at (justification squeezes glyphs). */
|
|
247
|
+
let squeeze = 1;
|
|
248
|
+
if (live) {
|
|
249
|
+
const ff = await fontFace(face, ctx);
|
|
250
|
+
if (ff.css) defs += `<style>${ff.css}</style>`;
|
|
251
|
+
const fv = Object.entries(face.variations ?? {}).filter(([t]) => t !== 'wght').map(([t, v]) => `'${t}' ${num(v, 2)}`).join(',');
|
|
252
|
+
const anchorAttr = align === 'center' ? 'middle' : align === 'right' ? 'end' : undefined;
|
|
253
|
+
const strokeAttrs = strokes.length ? { stroke: toHex(strokes[0].color), 'stroke-width': len(strokes[0].width, ctx.canvas), 'paint-order': 'stroke', 'stroke-linejoin': 'round' } : {};
|
|
254
|
+
body = runs.map((r, i) => `<text${attrs({
|
|
255
|
+
x: x0, y: baselines[i], 'font-family': ff.family, 'font-size': r.size,
|
|
256
|
+
'font-weight': layer.weight ?? 600, 'font-style': layer.style && layer.style !== 'normal' ? layer.style : undefined,
|
|
257
|
+
'letter-spacing': ls ? num(ls * r.size, 3) : undefined,
|
|
258
|
+
'text-anchor': anchorAttr,
|
|
259
|
+
textLength: r.fill ? justifyTo : undefined, lengthAdjust: r.fill ? adjust : undefined,
|
|
260
|
+
style: fv ? `font-variation-settings:${fv}` : undefined,
|
|
261
|
+
fill: fillPaint.value, ...strokeAttrs,
|
|
262
|
+
})}>${esc(r.text)}</text>`).join('');
|
|
263
|
+
} else {
|
|
264
|
+
// Each line once; then its glyphs drawn per paint (spans and per-glyph colours).
|
|
265
|
+
let start = 0;
|
|
266
|
+
const set = runs.map((r, i) => {
|
|
267
|
+
const first = start;
|
|
268
|
+
start += glyphCount(r.text);
|
|
269
|
+
const line = setLine({ face, text: r.text, size: r.size, tracking: ls * r.size, x: x0, y: baselines[i], align, width: r.fill ? justifyTo : undefined, adjust, shape: (t) => shapeLine(r, t, first), only: () => false });
|
|
270
|
+
squeeze = Math.min(squeeze, line.scaleX);
|
|
271
|
+
return { ...line, baseline: baselines[i], size: r.size };
|
|
272
|
+
});
|
|
273
|
+
const draw = (/** @type {(g: any) => boolean} */ only) => set.map((l) => mappedPath(face, l.run, l.size, (gx, gy) => [l.left + gx * l.scaleX, l.baseline + gy], only)).join('');
|
|
274
|
+
body = finish(drawBuckets({ glyphs: set.flatMap((l) => l.run.glyphs) }, draw, fillPaint.value, strokes, ctx), layer, ctx);
|
|
275
|
+
}
|
|
276
|
+
// Mirrored (negative) stretch is still that tall.
|
|
277
|
+
report(ctx, layer, content, runs[0].size * Math.abs(sy), face, drawnBox, {
|
|
278
|
+
capHeight: Math.min(...runs.map((r) => r.cap)) * Math.abs(sy),
|
|
279
|
+
squeeze: squeeze * Math.abs(sx),
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
if (layer.stretch) body = `<g transform="translate(${num(x0)} ${num(y0)}) scale(${num(sx, 4)} ${num(sy, 4)}) translate(${num(-x0)} ${num(-y0)})">${body}</g>`;
|
|
283
|
+
// Opacity and blend (renderWrapped has already taken them off a wrapped layer).
|
|
284
|
+
return { defs, body: wrap(layer, body) };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Lines sized to a width: `'width'` (one size, the widest line fills it),
|
|
289
|
+
* `'lines'` and `'words'`… `'lines'` sizes each line on its own; `'words'`
|
|
290
|
+
* (a word a line) is sized like `'width'`.
|
|
291
|
+
*
|
|
292
|
+
* @param {Line[]} lines @param {string} mode @param {number} fitTo
|
|
293
|
+
* @param {(l: Line) => number} natural - A line's width at its size.
|
|
294
|
+
* @returns {Line[]}
|
|
295
|
+
*/
|
|
296
|
+
function fitLines(lines, mode, fitTo, natural) {
|
|
297
|
+
if (mode !== 'width' && mode !== 'lines' && mode !== 'words') return lines;
|
|
298
|
+
if (!Number.isFinite(fitTo)) throw new TypeError('fit needs a width (width or maxWidth)');
|
|
299
|
+
const nat = lines.map(natural);
|
|
300
|
+
if (mode === 'lines') return lines.map((l, i) => ({ ...l, size: (l.size * fitTo) / Math.max(nat[i], 1e-6) }));
|
|
301
|
+
const k = fitTo / Math.max(...nat, 1e-6);
|
|
302
|
+
return lines.map((l) => ({ ...l, size: l.size * k }));
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Draw glyphs by paint: strokes under everything (widest first), then the
|
|
307
|
+
* fill, then each colour bucket (spans, per-glyph colours) on its own path.
|
|
308
|
+
*
|
|
309
|
+
* @param {{ glyphs: any[] }} run - Every glyph drawn (for its paint keys).
|
|
310
|
+
* @param {(only: (g: any) => boolean) => string} draw - Path data for the glyphs a filter keeps.
|
|
311
|
+
* @param {string} fill
|
|
312
|
+
* @param {any[]} strokes
|
|
313
|
+
* @param {import('../layers.js').RenderContext} ctx
|
|
314
|
+
* @returns {string}
|
|
315
|
+
*/
|
|
316
|
+
function drawBuckets(run, draw, fill, strokes, ctx) {
|
|
317
|
+
const paints = [...new Set(run.glyphs.map((g) => g.paint).filter(Boolean))];
|
|
318
|
+
if (!paints.length) return drawPaths([draw(() => true)], fill, strokes, ctx);
|
|
319
|
+
let out = strokes.length ? drawPaths([draw(() => true)], 'none', strokes, ctx) : '';
|
|
320
|
+
const plain = draw((g) => !g.paint);
|
|
321
|
+
if (plain && fill !== 'none') out += `<path${attrs({ d: plain, fill })}/>`;
|
|
322
|
+
for (const c of paints) out += `<path${attrs({ d: draw((g) => g.paint === c), fill: c })}/>`;
|
|
323
|
+
return out;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* The text's extrusion (`extrude: { depth, angle, color, to, steps }` or a depth).
|
|
328
|
+
*
|
|
329
|
+
* @param {string} body @param {any} layer @param {import('../layers.js').RenderContext} ctx
|
|
330
|
+
* @returns {string}
|
|
331
|
+
*/
|
|
332
|
+
function finish(body, layer, ctx) {
|
|
333
|
+
if (!layer.extrude) return body;
|
|
334
|
+
const fx = typeof layer.extrude === 'object' ? layer.extrude : { depth: layer.extrude };
|
|
335
|
+
const out = extrude({ kind: 'extrude', ...fx }, { defs: '', body }, ctx, `${ctx.id}t`);
|
|
336
|
+
return `<defs>${out.defs}</defs>${out.body}`;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Stroke copies widest first, then the fill.
|
|
341
|
+
*
|
|
342
|
+
* @param {string[]} ds @param {string} fill @param {any[]} strokes @param {import('../layers.js').RenderContext} ctx
|
|
343
|
+
* @returns {string}
|
|
344
|
+
*/
|
|
345
|
+
function drawPaths(ds, fill, strokes, ctx) {
|
|
346
|
+
const d = ds.join('');
|
|
347
|
+
const sorted = [...strokes].sort((a, b) => len(b.width, ctx.canvas) - len(a.width, ctx.canvas));
|
|
348
|
+
let out = sorted.map((s) => `<path${attrs({ d, fill: s.fill ? toHex(s.fill) : 'none', stroke: toHex(s.color), 'stroke-width': len(s.width, ctx.canvas), 'stroke-linejoin': 'round', 'stroke-linecap': 'round', 'stroke-dasharray': s.dash ? dashArray(s.dash, len(s.width, ctx.canvas), ctx) : undefined })}/>`).join('');
|
|
349
|
+
if (fill !== 'none' || !strokes.length) out += `<path${attrs({ d, fill })}/>`;
|
|
350
|
+
return out;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Note a text block for `lint`: the whole block as drawn (every line,
|
|
355
|
+
* stretched), its smallest capitals, how far justification squeezed it.
|
|
356
|
+
*
|
|
357
|
+
* @param {import('../layers.js').RenderContext} ctx @param {any} layer @param {string} content @param {number} size
|
|
358
|
+
* @param {import('../fonts.js').LoadedFont} face @param {{ x: number, y: number, width: number, height: number } | null} box
|
|
359
|
+
* @param {{ capHeight: number, squeeze?: number }} drawn
|
|
360
|
+
*/
|
|
361
|
+
function report(ctx, layer, content, size, face, box, drawn) {
|
|
362
|
+
/** @type {any} */ (ctx).report?.({ kind: 'text', id: layer.id, content, size, capHeight: drawn.capHeight, box, squeeze: drawn.squeeze ?? 1 });
|
|
363
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// WOFF2 to sfnt.
|
|
2
|
+
|
|
3
|
+
/** A WOFF2 file's flavour, table directory and Brotli payload. */
|
|
4
|
+
export function woff2Directory(buf: Uint8Array): { flavor: number; tables: { tag: string; origLength: number; length: number; transformed: boolean; data: Uint8Array }[]; payload: Uint8Array };
|
|
5
|
+
/** WOFF2 bytes as sfnt, given its decompressed stream (glyf, loca and hmtx rebuilt). */
|
|
6
|
+
export function woff2ToSfnt(buf: Uint8Array, stream: Uint8Array | undefined): Uint8Array;
|