@arraypress/covers 0.0.0-stage → 1.1.0
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 +126 -0
- package/LICENSE +29 -0
- package/README.md +846 -2
- package/fonts/OFL.txt +93 -0
- package/fonts/inter-latin-400.woff +0 -0
- package/fonts/inter-latin-500.woff +0 -0
- package/fonts/inter-latin-600.woff +0 -0
- package/fonts/inter-latin-700.woff +0 -0
- package/package.json +124 -4
- package/recipes/aurora.json +132 -0
- package/recipes/bauhaus.json +113 -0
- package/recipes/bigType.json +103 -0
- package/recipes/brand.json +1143 -0
- package/recipes/contour.json +109 -0
- package/recipes/cutout.json +191 -0
- package/recipes/diagram.json +92 -0
- package/recipes/dither.json +170 -0
- package/recipes/duotone.json +75 -0
- package/recipes/gridTitle.json +136 -0
- package/recipes/iconTile.json +214 -0
- package/recipes/issue.json +221 -0
- package/recipes/mark.json +170 -0
- package/recipes/photo.json +156 -0
- package/recipes/riso.json +387 -0
- package/recipes/stripes.json +206 -0
- package/recipes/waveform.json +154 -0
- package/schema/recipe.schema.json +8822 -0
- package/src/animate/index.d.ts +19 -0
- package/src/animate/index.js +116 -0
- package/src/astro.d.ts +31 -0
- package/src/astro.js +86 -0
- package/src/builtins.js +4 -0
- package/src/color.js +246 -0
- package/src/diagram.js +248 -0
- package/src/effects/ascii.d.ts +7 -0
- package/src/effects/ascii.js +93 -0
- package/src/effects/buffer.d.ts +60 -0
- package/src/effects/buffer.js +472 -0
- package/src/effects/detail.d.ts +12 -0
- package/src/effects/detail.js +124 -0
- package/src/effects/digital.d.ts +14 -0
- package/src/effects/digital.js +216 -0
- package/src/effects/distort.d.ts +20 -0
- package/src/effects/distort.js +314 -0
- package/src/effects/dither.d.ts +8 -0
- package/src/effects/dither.js +147 -0
- package/src/effects/drop-shadow.d.ts +4 -0
- package/src/effects/drop-shadow.js +45 -0
- package/src/effects/extrude.d.ts +5 -0
- package/src/effects/extrude.js +55 -0
- package/src/effects/film.d.ts +11 -0
- package/src/effects/film.js +204 -0
- package/src/effects/glow.d.ts +8 -0
- package/src/effects/glow.js +56 -0
- package/src/effects/grade.d.ts +30 -0
- package/src/effects/grade.js +274 -0
- package/src/effects/halftone.d.ts +8 -0
- package/src/effects/halftone.js +95 -0
- package/src/effects/index.d.ts +125 -0
- package/src/effects/index.js +277 -0
- package/src/effects/ink.d.ts +12 -0
- package/src/effects/ink.js +70 -0
- package/src/effects/lens.d.ts +14 -0
- package/src/effects/lens.js +277 -0
- package/src/effects/light.d.ts +10 -0
- package/src/effects/light.js +59 -0
- package/src/effects/looks.d.ts +9 -0
- package/src/effects/looks.js +74 -0
- package/src/effects/mask.d.ts +52 -0
- package/src/effects/mask.js +314 -0
- package/src/effects/motion.d.ts +6 -0
- package/src/effects/motion.js +60 -0
- package/src/effects/newsprint.d.ts +6 -0
- package/src/effects/newsprint.js +89 -0
- package/src/effects/optics.d.ts +10 -0
- package/src/effects/optics.js +213 -0
- package/src/effects/options.d.ts +6 -0
- package/src/effects/options.js +106 -0
- package/src/effects/paint-presets.d.ts +10 -0
- package/src/effects/paint-presets.js +47 -0
- package/src/effects/paint.d.ts +49 -0
- package/src/effects/paint.js +66 -0
- package/src/effects/pixels.d.ts +24 -0
- package/src/effects/pixels.js +138 -0
- package/src/effects/polar.d.ts +10 -0
- package/src/effects/polar.js +265 -0
- package/src/effects/region.d.ts +8 -0
- package/src/effects/region.js +136 -0
- package/src/effects/round-border.d.ts +5 -0
- package/src/effects/round-border.js +62 -0
- package/src/effects/screen.d.ts +14 -0
- package/src/effects/screen.js +212 -0
- package/src/effects/sort.d.ts +8 -0
- package/src/effects/sort.js +125 -0
- package/src/effects/symmetry.d.ts +6 -0
- package/src/effects/symmetry.js +74 -0
- package/src/effects/tile.d.ts +7 -0
- package/src/effects/tile.js +55 -0
- package/src/effects/tone.d.ts +16 -0
- package/src/effects/tone.js +117 -0
- package/src/effects/transform.d.ts +35 -0
- package/src/effects/transform.js +127 -0
- package/src/effects/util.d.ts +16 -0
- package/src/effects/util.js +85 -0
- package/src/extract.js +91 -0
- package/src/flatten.js +234 -0
- package/src/fonts.d.ts +55 -0
- package/src/fonts.js +393 -0
- 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 +187 -0
- package/src/grid.js +41 -0
- package/src/handmade.js +253 -0
- package/src/icons.js +191 -0
- package/src/index.d.ts +1083 -0
- package/src/index.js +27 -0
- package/src/inter-data.js +27 -0
- package/src/json-recipe.js +616 -0
- package/src/kit/index.d.ts +20 -0
- package/src/kit/index.js +58 -0
- package/src/layer-options.js +56 -0
- 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 +109 -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 +48 -0
- package/src/layers.js +1056 -0
- 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/palette.js +185 -0
- package/src/platform/bytes.d.ts +22 -0
- package/src/platform/bytes.js +103 -0
- package/src/platform/compress.d.ts +12 -0
- package/src/platform/compress.js +81 -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 +13 -0
- package/src/platform/load.d.ts +6 -0
- package/src/platform/load.js +72 -0
- package/src/platform/no-harfbuzz.d.ts +2 -0
- package/src/platform/no-harfbuzz.js +11 -0
- package/src/platform/node.d.ts +26 -0
- package/src/platform/node.js +86 -0
- package/src/platform/settings.d.ts +28 -0
- package/src/platform/settings.js +51 -0
- package/src/png.js +126 -0
- package/src/print.js +659 -0
- package/src/raster/index.d.ts +24 -0
- package/src/raster/index.js +171 -0
- package/src/recipes.js +79 -0
- package/src/render/backdrop.d.ts +6 -0
- package/src/render/backdrop.js +55 -0
- package/src/render/content.d.ts +5 -0
- package/src/render/content.js +58 -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 +91 -0
- package/src/render/report.d.ts +5 -0
- package/src/render/report.js +19 -0
- package/src/render/tiles.d.ts +6 -0
- package/src/render/tiles.js +77 -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 +199 -0
- package/src/render.js +553 -0
- package/src/rng.js +95 -0
- package/src/svg.js +69 -0
- package/src/text.d.ts +29 -0
- package/src/text.js +237 -0
- 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 +9 -0
- package/src/type/pixel.js +115 -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 +352 -0
- package/src/type/woff2.d.ts +6 -0
- package/src/type/woff2.js +301 -0
- package/src/version.js +4 -0
package/src/kit/index.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A brand kit: one brand, every format, one call.
|
|
3
|
+
*
|
|
4
|
+
* @module kit
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { renderSvg, renderPages, renderImage } from '../render.js';
|
|
8
|
+
|
|
9
|
+
/** The formats a kit renders by default. */
|
|
10
|
+
export const KIT_FORMATS = Object.freeze({
|
|
11
|
+
social: ['avatar', 'x-header', 'linkedin-banner', 'linkedin-company', 'youtube-banner', 'youtube-thumbnail', 'facebook-cover', 'instagram-post', 'instagram-square', 'instagram-story', 'instagram-reel', 'tiktok-cover', 'pinterest', 'twitch-banner', 'discord-banner', 'bluesky-banner', 'og'],
|
|
12
|
+
release: ['release', 'spotify-canvas', 'bandcamp-header'],
|
|
13
|
+
print: ['business-card', 'letterhead', 'compliment-slip', 'sticker', 'ticket', 'lanyard-pass'],
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
/** Formats printed on both sides by default. */
|
|
17
|
+
const TWO_SIDED = new Set(['business-card', 'business-card-us']);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Render a brand on many formats at once.
|
|
21
|
+
*
|
|
22
|
+
* `brand` is any cover options shared by every piece: `name`, `tagline`,
|
|
23
|
+
* `palette`, `mark` (path markup on a 512 grid), `photo`, `fonts`, `lines`
|
|
24
|
+
* (contact lines), `person` and `role`, … `recipe` is one recipe for all
|
|
25
|
+
* formats (the built-in `brand` by default) or a map from format to recipe
|
|
26
|
+
* (with `default`). Two-sided formats (business cards, or any in `pages`)
|
|
27
|
+
* come back as `{ front, back }`.
|
|
28
|
+
*
|
|
29
|
+
* @param {Record<string, any>} brand
|
|
30
|
+
* @param {{ formats?: string[], recipe?: any, pages?: Record<string, string[]>, render?: 'svg' | 'webp' | 'png' | 'jpeg' | 'avif', overlay?: boolean }} [options]
|
|
31
|
+
* @returns {Promise<Record<string, any>>} By format: an SVG string (or image bytes), or `{ front, back }`.
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* const files = await kit({ name: 'Hollow Fern', palette: { accent: '#2f6b4f' }, mark }, { formats: ['avatar', 'x-header', 'business-card'] });
|
|
35
|
+
* files['business-card'].back; // '<svg …'
|
|
36
|
+
*/
|
|
37
|
+
export async function kit(brand, options = {}) {
|
|
38
|
+
const formats = options.formats ?? [...KIT_FORMATS.social, ...KIT_FORMATS.release, ...KIT_FORMATS.print];
|
|
39
|
+
const pick = (/** @type {string} */ f) => {
|
|
40
|
+
const r = options.recipe;
|
|
41
|
+
if (r && typeof r === 'object' && !Array.isArray(r) && !r.layers) return r[f] ?? r.default ?? 'brand';
|
|
42
|
+
return r ?? 'brand';
|
|
43
|
+
};
|
|
44
|
+
/** @type {Record<string, any>} */
|
|
45
|
+
const out = {};
|
|
46
|
+
for (const format of formats) {
|
|
47
|
+
const input = { seed: brand.seed ?? brand.name ?? 'brand', ...brand, recipe: pick(format), format, overlay: options.overlay };
|
|
48
|
+
const pages = options.pages?.[format] ?? (TWO_SIDED.has(format) ? ['front', 'back'] : null);
|
|
49
|
+
const render = options.render ?? 'svg';
|
|
50
|
+
if (pages) {
|
|
51
|
+
if (render === 'svg') out[format] = await renderPages(input, { pages });
|
|
52
|
+
else out[format] = Object.fromEntries(await Promise.all(pages.map(async (page) => [page, await renderImage({ ...input, page }, { format: render })])));
|
|
53
|
+
} else {
|
|
54
|
+
out[format] = render === 'svg' ? await renderSvg(input) : await renderImage(input, { format: render });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every layer type's option names, with one-line descriptions. The JSON
|
|
3
|
+
* recipe validator rejects names not listed here, and
|
|
4
|
+
* `scripts/schema.mjs` builds the JSON Schema's autocomplete from it.
|
|
5
|
+
* Add an option to a layer, add it here.
|
|
6
|
+
*
|
|
7
|
+
* @module layer-options
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** @type {Record<string, Record<string, string>>} Layer options, by type: name → description. */
|
|
11
|
+
export const LAYER_OPTIONS = {
|
|
12
|
+
solid: { color: 'Fill. Default @bg.' },
|
|
13
|
+
linear: { colors: 'Two or more colours.', opacities: 'Per-stop opacity.', angle: 'CSS degrees (0 = up). Default seeded.', ease: 'Smoothstep stops. Default true.' },
|
|
14
|
+
radial: { colors: 'Centre → edge.', x: 'Centre x, 0–1.', y: 'Centre y, 0–1.', radius: 'Length. Default "80vmax".', ease: 'Default true.' },
|
|
15
|
+
mesh: { colors: 'Blob colours.', hues: 'Distinct colours to use. Default 3.', hueSpan: 'Keep the colours within this many degrees of hue (OKLCH): neighbouring hues only.', count: 'Blobs. Default 4.', blur: 'Length. Default "11vmax".', spread: '0–1. Default 1 (sweep), 0.7 (ring).', size: '[min, max] radius, fractions of the longer side.', base: 'Fill under the fields: "auto" (a gradient across the sweep between the first two colours; default), "bg", a colour, or null.', arrangement: 'sweep (default: long fields along one direction) | ring', angle: 'Sweep direction in degrees. Default seeded.', streak: 'How much more the sweep blurs along than across. Default 1.5.', tone: '{ l: [min, max], c: [min, max] }: pull colours into an OKLCH band.', fold: 'true or { color, to, position, bend, softness, sheen }: a second field meeting the first along a soft curve.', streaks: 'Thin light motion streaks along the sweep. Default 0.', from: 'A photo (path or bytes) to take the colours from (needs sharp).', blobs: 'Explicit [{ x, y, r, squash, rotate, color, opacity }].' },
|
|
16
|
+
stripes: { direction: 'vertical | horizontal | diagonal', angle: 'For diagonal.', count: 'Stripes. Default 8.', widths: 'even | random | taper | [weights]', taper: 'Ratio for widths: taper.', colors: 'Cycled.', gap: 'Length.', radius: 'Length.', region: '{ x, y, width, height } fractions.', regionRadius: 'Length.' },
|
|
17
|
+
image: { x: 'Box left, px or "%" (with y, width, height: an explicit box instead of region).', y: 'Box top.', width: 'Box width.', height: 'Box height.', src: 'A local path, data: URI, URL (where allowed) or bytes ("{image}").', fit: 'cover | contain | stretch', focus: 'An anchor, attention or entropy.', region: 'Fractions.', duotone: 'true or [shadow, highlight].', grayscale: 'boolean', normalize: 'Stretch tones first.', tint: 'A colour or { color, amount, blend }.', blur: 'Length.' },
|
|
18
|
+
grain: { amount: '0–1. Default 0.08.', size: 'Grain px. Default 1.', monochrome: 'Default true.', vary: '"edges": heavier toward the edges (spray paint).' },
|
|
19
|
+
pattern: { kind: 'grid | dots | lines | rings | waves', size: 'Cell. Length.', stroke: 'Length.', color: 'Default @text.', angle: 'For lines.', center: '{ x, y } fractions.', wavelength: 'Length.', amplitude: 'Length.', fade: 'none | radial | edges | top | bottom | left | right', dash: 'dotted | dashed | [on, off] lengths.', divisions: 'Grid/lines: N equal cells (inner lines only) instead of size.', gaps: '[{ x, y, width, height }] fractions the lines break around.', region: 'Fractions.' },
|
|
20
|
+
shapes: { kind: 'circle | square | pill | mixed', count: 'Default 6.', size: '[min, max] lengths.', colors: 'Cycled.', placement: 'scatter | row | column', items: '[{ kind, x, y, size, color, rotate, opacity }]', region: 'Fractions.', rotate: 'Tilt squares and pills.', radius: 'Square corner, fraction of size.', outline: 'true or a width.' },
|
|
21
|
+
icon: { fill: 'A gradient, tile or preset paint for the icon (instead of color).', markup: 'Inner SVG markup on a 512 grid (a mark\'s paths); viewBox overrides the grid.', x: 'Left, px (with y: placed by its top-left corner).', y: 'Top, px.', name: 'Iconify name ("lucide:feather") or raw SVG ("{icon}").', svg: 'Raw SVG.', path: 'A path d.', viewBox: '[x, y, w, h] for path.', size: 'Length. Default "24vmin".', color: 'Default the accent on a tile, else @text.', strokeWidth: 'Stroke icons.', anchor: 'One of nine anchors.', position: '{ x, y } fractions.', offset: '{ x, y } lengths.', margin: 'Length.', tile: 'true or { size, radius, color, shadow, border }.' },
|
|
22
|
+
text: { content: 'The text ("{title}").', font: 'Family. Default Inter.', weight: 'Default 600.', style: 'normal | italic', size: 'Length.', minSize: 'Shrink toward this before truncating.', lineHeight: 'Default 1.12.', letterSpacing: 'Em. Default -0.02.', color: 'Default @text.', align: 'left | center | right', anchor: 'Default bottom-left.', position: '{ x, y } fractions.', offset: '{ x, y } lengths.', margin: 'Length.', maxWidth: 'Length.', maxLines: 'Default 3.', ellipsis: "Default '…'.", balance: 'Default true.', transform: 'none | uppercase | lowercase', label: 'A string or { content, size, weight, color, letterSpacing, transform, gap }.', background: 'A colour or { color, radius, padding, opacity }.',
|
|
23
|
+
x: 'First baseline x, px or "%" (like SVG <text>; align is the text-anchor).', y: 'First baseline y.', width: 'Justify every line to this width (like textLength).', justify: 'Justify every line to maxWidth.', lengthAdjust: 'spacingAndGlyphs (default: scale) | spacing', fit: 'width (the block fills width) | lines (each line fills width) | words (a word a line, the widest filling width) | wrap (wrap at width, then fill it) | shrink', stretch: '[sx, sy] or sy: stretch about the first baseline.', axes: 'Variable-font axes: { wdth: 50, SOFT: 100, WONK: 1, opsz: 144 }.', stroke: 'Outline colour.', strokeWidth: 'Outline width.', strokes: '[{ color, width }]: keylines, widest first, under the fill.', fill: 'A colour, a gradient, or "none" (outline type).', path: 'Text on a path: { kind: circle | arc | ellipse | wave, cx, cy, r, start, end, direction } or { d }.', startOffset: 'Along the path: 0–1 or px. Default 0.5 (centred).', fitPath: 'Space the text to fill the path (a ring); a number leaves that gap.', dy: 'Shift off the path.', render: 'outline (default) | live (<text> with @font-face)', autoColors: '[light, dark] for color: "auto". Default ["#ffffff", "#0b0b0b"].', autoThreshold: 'color: "auto" takes the dark colour above this backdrop luma (0–1). Default 0.72.', lines: 'Reserved.',
|
|
24
|
+
spans: '[{ content, font, weight, style, axes, scale, size, color, letterSpacing, dy }]: mixed runs on the same lines.', glyphs: '{ scale, scaleX, scaleY, dx, dy, rotate, color, origin, advance }: per-glyph arrays (cycled; spaces skipped).', fallback: 'A family (or list) for characters the font lacks; then fonts registered with fallback: true, then the built-in symbols.', split: 'words: a word a line.', dash: 'Dashes on the stroke: [on, off] lengths, dashed or dotted.', extrude: 'A depth, or { depth, angle, color, to, steps }: block extrusion.', maxHeight: 'Shrink the block to this height.' },
|
|
25
|
+
diagram: { icon: 'One icon (Iconify name or raw SVG), drawn as white line-art.', nodes: '[{ icon, shape: square | circle | none, size, brackets }]', layout: 'single | row | triangle | square | stack | hub', arrows: 'true | false | loop | both | cycle', shape: 'For a single icon: none (default) | square | circle.', size: 'Node size. Length.', gap: 'Space between nodes. Length.', stroke: 'Line width. Default "0.3vmin".', color: 'Line colour. Default white.', radius: 'Tile corner radius.', iconScale: 'Icon size inside a tile, 0–1. Default 0.44.', position: '{ x, y } centre, fractions.', grid: 'true or { divisions, opacity, stroke, color, pad }: a dotted grid that breaks around the drawing.' },
|
|
26
|
+
paper: { count: 'Shapes in the collage. Default 4.', kind: 'mixed | rect | square | circle | blob | arch | pill', color: 'Default #faf9f5 (paper).', region: 'Where the collage sits, fractions.', items: '[{ kind, x, y, width, height, size, rotate, color }] placed in the region.', wobble: 'How rough the cut edges are, 0–1. Default 0.5.' },
|
|
27
|
+
scribble: { icon: 'An icon to re-draw by hand (Iconify name or raw SVG).', graph: 'true or { nodes, dot }: a hand-drawn node graph instead.', color: 'Default #faf9f5.', stroke: 'Line width. Default "1.3vmin".', size: 'Length. Default "46vmin".', position: '{ x, y } centre, fractions.', wobble: '0–1. Default 0.5.' },
|
|
28
|
+
ink: { color: 'The ink: a colour or riso:pink | blue | yellow | teal | red | green | orange | purple | navy | black.', shapes: '[{ kind: circle | ring | half | arch | band | rect | text, x, y, r, size, width, height, thickness, rotate, content, … }]', offset: '{ x, y }: misregistration. Default seeded, 0.3–1% of the short side.', texture: 'grain (default) | halftone | none', speckle: 'Grain: share of paper showing through. Default 0.09.', cell: 'Halftone cell. Length.', dot: 'Halftone dot radius, fraction of the cell.', angle: 'Halftone screen angle: 0 or 45 (default seeded).', density: '[min, max]: uneven ink across the sheet. Default [0.78, 1].' },
|
|
29
|
+
dither: { colors: '[ground, ink] or [c0, c1, c2] (two dithered bands).', source: 'field (default, noise) | linear | radial', shape: 'none | circle | sphere | arch', size: 'Shape size. Length.', center: '{ x, y } fractions.', matrix: 'bayer4 (default) | bayer8 | noise', cell: 'Pixel size. Length. Default "0.9vmin".', angle: 'For linear.', scale: 'Noise feature size.', gamma: 'Tone curve.', range: '[lo, hi] of the field.' },
|
|
30
|
+
contour: { levels: 'Isolines. Default 20.', color: 'Default @text.', stroke: 'Default "0.14vmin".', highlight: 'Level to draw in the accent (default seeded), or false.', highlightColor: 'Default @accent.', index: 'Every Nth line thicker. Default 5 (0: none).', indexWeight: 'Index line weight multiple.', scale: 'Noise feature size.', octaves: 'Noise detail.', resolution: 'Grid cells across the short side.' },
|
|
31
|
+
waveform: { peaks: 'Peak values (any scale).', audio: 'An audio file to decode at build (needs @arraypress/waveform-gen).', bars: 'Bars. Default 88.', style: 'mirrored (default) | bars | line | dots', color: 'Default @accent.', region: 'Fractions.', gap: 'Gap between bars, fraction. Default 0.4.', round: 'Rounded bars. Default true.', stroke: 'Line style width.', caption: 'true (the duration) or text under the wave.', duration: 'Seconds (for the caption).', captionSize: 'Length.', captionColor: 'Default @text.' },
|
|
32
|
+
tiles: { cols: 'Columns. Default from the series.', rows: 'Rows. Default to keep cells near square.', series: 'Seeds the grid rhythm shared by a series.', colors: 'Shape and cell colours.', ground: 'The page colour among them.', kinds: 'Subset of quarter, half, circle, square, triangle.', merge: 'A 2×2 merged cell. Default true.', region: 'Fractions.' },
|
|
33
|
+
rule: { y: 'Height, fraction. Default 0.15.', from: '{ x, y } fractions.', to: '{ x, y } fractions.', inset: 'Side inset. Length.', color: 'Default @text.', stroke: 'Default "0.12vmin".' },
|
|
34
|
+
frame: { inset: 'Length.', radius: 'Length.', stroke: 'Length.', color: 'Default @text.', fill: 'A colour (a card).' },
|
|
35
|
+
repeat: { layer: 'The layer to repeat (or layers, drawn as a group). {#…} expressions use i, n, row, col, rows, cols, x, y, angle, odd.', layers: 'Several layers to repeat as one.', rows: 'Grid rows.', cols: 'Grid columns.', count: 'Instances in a ring or a line.', step: '[dx, dy] between instances (a grid divides the canvas by default).', x: 'Where the first instance goes.', y: 'Where the first instance goes.', cx: 'Ring centre x.', cy: 'Ring centre y.', radius: 'Ring radius.', start: 'Ring start, degrees (0 = up, clockwise).', sweep: 'Ring sweep, degrees. Default 360.', turn: 'Turn ring instances to their angle. Default true.', odd: 'Overrides for odd instances (a checkerboard on a grid).', cycle: '[overrides] taken in turn.', skip: 'An expression ({#…}): leave the instance out when true.' },
|
|
36
|
+
defs: { layers: 'Layers with ids, for use layers to draw (nothing is drawn here).' },
|
|
37
|
+
use: { ref: 'The id of a layer in an earlier defs layer.', x: 'Where the copy goes.', y: 'Where the copy goes.', rotate: 'Degrees, about the copy\'s origin.', scale: 'A number or [sx, sy].', place: 'A transform instead of x, y, rotate and scale.', with: 'Options for this copy only.' },
|
|
38
|
+
group: { layers: 'The layers inside.', viewBox: '[x, y, w, h]: its own design grid (children use its units).', x: 'Box left, px.', y: 'Box top.', width: 'Box width.', height: 'Box height.', fit: 'contain (default) | cover | stretch | width | height', align: 'Where a fitted grid sits: center, top-left, …', clipToBox: 'Clip to the box.' },
|
|
39
|
+
shape: { kind: 'rect | circle | ellipse | line | polygon | polyline | star | burst | scallop | rays | ring | sector | arch | wave | path | cut (the print format\'s die-cut)', origin: 'rotate about [x, y] (px or "%") instead of the centre.', open: 'arch: the arc and sides only (stroked).', soft: 'A blur radius: soft orbs, drawn padded (no edge fade, no square edge).', dashOffset: 'Where the dash pattern starts.', outset: 'cut: grow (or shrink) the outline.', items: '[{ kind, … }]: several shapes in one layer.', x: 'px or "%".', y: 'px or "%".', width: 'px.', height: 'px.', cx: 'Centre x.', cy: 'Centre y.', r: 'Radius.', rx: 'Ellipse x radius.', ry: 'Ellipse y radius.', x1: 'Line/wave start x.', y1: 'Line start y.', x2: 'End x.', y2: 'End y.', points: 'Polygon [[x, y], …], or star/burst/scallop point count.', inner: 'Star/burst inner radius (≤1: fraction) or ring inner radius.', count: 'Rays.', start: 'Sector start, degrees (0 = up, clockwise).', end: 'Sector end.', depth: 'Scallop depth.', amplitude: 'Wave height.', wavelength: 'Wave length.', phase: 'Wave phase, radians.', band: 'Wave: fill a band this tall.', d: 'Path data.', viewBox: 'Path: its grid, drawn into x, y, width, height.', radius: 'Rect corner radius.', edge: 'Rect edges: deckle | torn | zigzag | wave | scallop, or { kind, size, step, sides }.', rotate: 'Degrees about its centre.', fill: 'A colour, a gradient { type, colors, stops, opacities, angle, spread, length, rx, ry, … }, a tile { type: "tile", kind: checker | stripes | dots | grid | custom, size, colors, ratio, angle, markup } or a preset { preset: "chrome" | "steel" | "gold" }.', stroke: 'A colour or a gradient.', strokeWidth: 'Length.', strokes: '[{ color, width, dash }]: keylines under the shape.', dash: 'dashed | dotted | [on, off].', linecap: 'butt | round | square', linejoin: 'miter | round | bevel', fillRule: 'nonzero | evenodd' },
|
|
40
|
+
svg: { markup: 'Raw SVG, drawn in the canvas (or from viewBox). Tokens: {{@accent}}, {{@colors.1}}, {{@width}}, {{id}}, {{seed}}, and in JSON recipes {{title}} or any option.', defs: 'Raw SVG for <defs>.', viewBox: '[x, y, w, h] of the markup.', x: 'Box left.', y: 'Box top.', width: 'Box width.', height: 'Box height.', fit: 'contain (default) | cover | stretch', align: 'xMidYMid etc.' },
|
|
41
|
+
table: { rows: 'Rows: arrays of cells, or objects read by column key ("{tracklist}"). A cell is text or { content, weight, size, color, align, … }.', columns: '[{ x, align, width, key, weight, size, color, font, letterSpacing, transform, opacity }]', x: 'Left, px.', y: 'First baseline, px.', size: 'Type size. Default "2.2vmin".', lineHeight: 'Line step inside a cell.', rowHeight: 'Fixed row step.', rowGap: 'Extra space between rows.', font: 'Family.', weight: 'Default 400.', style: 'normal | italic', axes: 'Variable axes.', color: 'Default @text.', letterSpacing: 'Em.', header: 'Style for the first row.', rules: 'true or { at: between | each | top, color, width, opacity, x1, x2, above }.', columnWidth: 'Column step when columns give no x.' },
|
|
42
|
+
barcode: { value: 'Text to encode (Code 128).', content: 'Same as value.', kind: 'code128 (default) | seeded (decorative)', x: 'px.', y: 'px.', width: 'px.', height: 'px.', color: 'Default @text.', text: 'true (the value) or text under the bars.', size: 'Text size.', font: 'Family.', weight: 'Text weight.', letterSpacing: 'Em.' },
|
|
43
|
+
perforation: { kind: 'holes (default) | dashes | notches', x: 'A vertical line at x.', x1: 'Start x.', y1: 'Start y.', x2: 'End x.', y2: 'End y.', size: 'Hole or notch radius.', gap: 'Spacing.', inset: 'Space before the first hole.', color: 'Default @text (or punch it with knockout).', strokeWidth: 'Dashes width.' },
|
|
44
|
+
tear: { from: '[x, y] where the tear starts (edge to edge).', to: '[x, y] where it ends.', side: 'Which side keeps the sheet: above (default) | below | left | right', color: 'The sheet (a colour, a gradient, or "none" for just the edge).', sheetOpacity: 'The sheet\'s opacity.', step: 'Spacing of the jagged points.', jitter: 'How jagged.', rip: 'false, or { color, width, opacity }: the torn white edge.', fibres: 'false, or { color, width, opacity, length: [min, max], spread }.', ripColor: 'Colour of the rip and fibres. Default #fff6fb.' },
|
|
45
|
+
texture: { kind: 'paper | creases | concrete | speck | wood | folds | dust', color: 'Fibre, aggregate or dust colour.', amount: 'Paper: fibre density.', frequency: 'Noise frequency (number or [x, y]).', octaves: 'Noise octaves.', depth: 'Creases: relief.', azimuth: 'Creases: light direction.', elevation: 'Creases: light height.', x: 'Folds: vertical fold positions, fractions.', y: 'Folds: horizontal fold positions, fractions.', width: 'Folds: band width.', strength: 'Folds: shading.', count: 'Dust: specks.', size: 'Dust: [min, max] radius.', region: 'Clip to { x, y, width, height } (px or "%").' },
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** @type {Record<string, string>} Options every layer takes (besides type, id and when). */
|
|
49
|
+
export const COMMON = {
|
|
50
|
+
opacity: '0–1.', blend: 'A CSS mix-blend-mode.', seed: "This layer's own random stream.",
|
|
51
|
+
transform: '{ translate, rotate, scale, skewX, skewY, mirror: x | y, origin } (on text, a case keyword is still the case).',
|
|
52
|
+
effects: '[{ kind, mask, colorSpace, … }]: filters, pixel effects and copies, in order (see the README\'s Effects table; kinds in EFFECTS).',
|
|
53
|
+
mask: 'Layers whose luminance masks this one (white shows).', knockout: 'Layers cut out of this one.', clip: 'Layers this one shows only inside.',
|
|
54
|
+
area: 'trim | safe | bleed, or { x, y, width, height } fractions: draw the layer in that box.',
|
|
55
|
+
};
|
|
56
|
+
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// The `barcode` layer.
|
|
2
|
+
import type { Color, Length, BaseLayer, Anchor } from '../index.js';
|
|
3
|
+
import type { RenderContext, Markup } from '../render/context.js';
|
|
4
|
+
|
|
5
|
+
export interface BarcodeLayer extends BaseLayer {
|
|
6
|
+
type: 'barcode';
|
|
7
|
+
/** Encoded as Code 128 (set B). */
|
|
8
|
+
value?: string;
|
|
9
|
+
content?: string;
|
|
10
|
+
kind?: 'code128' | 'seeded';
|
|
11
|
+
x?: Length | `${number}%`; y?: Length | `${number}%`; width?: Length | `${number}%`; height?: Length | `${number}%`;
|
|
12
|
+
color?: Color;
|
|
13
|
+
text?: boolean | string;
|
|
14
|
+
size?: Length; font?: string; weight?: number; letterSpacing?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
/** Draw a Code 128 barcode (or decorative bars). */
|
|
19
|
+
export function barcode(layer: BarcodeLayer, ctx: RenderContext): Promise<Markup>;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `barcode` layer.
|
|
3
|
+
*
|
|
4
|
+
* @module layers/barcode
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { attrs, num } from '../svg.js';
|
|
8
|
+
import { len } from '../geometry.js';
|
|
9
|
+
import { toHex } from '../color.js';
|
|
10
|
+
import { faceFor, setLine } from '../type/line.js';
|
|
11
|
+
import { pos, wrap, contentOf } from './util.js';
|
|
12
|
+
import { code128 } from './code128.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* `barcode` — a real Code 128 barcode of `value` (scannable at print
|
|
16
|
+
* sizes), or `kind: 'seeded'` decorative bars. `x`, `y`, `width`, `height`
|
|
17
|
+
* in px; `color`; `text: true` (the value under it) or any text; `quiet`
|
|
18
|
+
* zones are left to you.
|
|
19
|
+
*
|
|
20
|
+
* @param {any} layer
|
|
21
|
+
* @param {import('../layers.js').RenderContext} ctx
|
|
22
|
+
* @returns {Promise<import('../layers.js').Markup>}
|
|
23
|
+
*/
|
|
24
|
+
export async function barcode(layer, ctx) {
|
|
25
|
+
const x = pos(layer.x ?? '10%', 'x', ctx.canvas);
|
|
26
|
+
const y = pos(layer.y ?? '80%', 'y', ctx.canvas);
|
|
27
|
+
const w = layer.width != null ? pos(layer.width, 'x', ctx.canvas) : ctx.width * 0.3;
|
|
28
|
+
const h = layer.height != null ? pos(layer.height, 'y', ctx.canvas) : ctx.height * 0.08;
|
|
29
|
+
const color = layer.color ? toHex(layer.color) : ctx.palette.text;
|
|
30
|
+
/** @type {number[]} */
|
|
31
|
+
let widths;
|
|
32
|
+
if (layer.kind === 'seeded') {
|
|
33
|
+
widths = [];
|
|
34
|
+
let total = 0;
|
|
35
|
+
const target = 100;
|
|
36
|
+
while (total < target) {
|
|
37
|
+
const bar = ctx.rng.pick([0.7, 1.1, 1.6, 2.3]);
|
|
38
|
+
const gap = ctx.rng.pick([0.7, 1.1, 1.7]);
|
|
39
|
+
widths.push(bar, gap);
|
|
40
|
+
total += bar + gap;
|
|
41
|
+
}
|
|
42
|
+
widths.pop();
|
|
43
|
+
} else {
|
|
44
|
+
widths = code128(String(layer.value ?? contentOf(layer.content) ?? '0000'));
|
|
45
|
+
}
|
|
46
|
+
const total = widths.reduce((a, b) => a + b, 0);
|
|
47
|
+
const k = w / total;
|
|
48
|
+
let d = '';
|
|
49
|
+
let at = x;
|
|
50
|
+
widths.forEach((m, i) => {
|
|
51
|
+
if (i % 2 === 0) d += `M${num(at, 3)} ${num(y)}h${num(m * k, 3)}v${num(h)}h${num(-m * k, 3)}Z`;
|
|
52
|
+
at += m * k;
|
|
53
|
+
});
|
|
54
|
+
let body = `<path${attrs({ d, fill: color })}/>`;
|
|
55
|
+
if (layer.text) {
|
|
56
|
+
const label = layer.text === true ? String(layer.value ?? '') : String(layer.text);
|
|
57
|
+
const size = len(layer.size, ctx.canvas, h * 0.18);
|
|
58
|
+
const face = await faceFor(ctx, { font: layer.font, weight: layer.weight ?? 400 });
|
|
59
|
+
const line = setLine({ face, text: label, size, tracking: (layer.letterSpacing ?? 0.1) * size, x: x + w / 2, y: y + h + size * 1.1, align: 'center' });
|
|
60
|
+
body += `<path${attrs({ d: line.d, fill: color })}/>`;
|
|
61
|
+
}
|
|
62
|
+
return { defs: '', body: wrap(layer, body) };
|
|
63
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Code 128 (set B) encoding.
|
|
3
|
+
*
|
|
4
|
+
* @module layers/code128
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
/** Code 128 bar/space widths, values 0–106 (106 is the stop pattern). */
|
|
9
|
+
const CODE128 = '212222 222122 222221 121223 121322 131222 122213 122312 132212 221213 221312 231212 112232 122132 122231 113222 123122 123221 223211 221132 221231 213212 223112 312131 311222 321122 321221 312212 322112 322211 212123 212321 232121 111323 131123 131321 112313 132113 132311 211313 231113 231311 112133 112331 132131 113123 113321 133121 313121 211331 231131 213113 213311 213131 311123 311321 331121 312113 312311 332111 314111 221411 431111 111224 111422 121124 121421 141122 141221 112214 112412 122114 122411 142112 142211 241211 221114 413111 241112 134111 111242 121142 121241 114212 124112 124211 411212 421112 421211 212141 214121 412121 111143 111341 131141 114113 114311 411113 411311 113141 114131 311141 411131 211412 211214 211232 2331112'.split(' ');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Code 128 (set B) module widths for a string, start to stop with the
|
|
13
|
+
* check symbol: bars and spaces alternating, bar first.
|
|
14
|
+
*
|
|
15
|
+
* @param {string} value - Printable ASCII.
|
|
16
|
+
* @returns {number[]}
|
|
17
|
+
*/
|
|
18
|
+
export function code128(value) {
|
|
19
|
+
const codes = [104];
|
|
20
|
+
for (const ch of String(value)) {
|
|
21
|
+
const c = ch.charCodeAt(0);
|
|
22
|
+
if (c < 32 || c > 126) throw new TypeError(`Code 128 (set B) can't encode "${ch}"`);
|
|
23
|
+
codes.push(c - 32);
|
|
24
|
+
}
|
|
25
|
+
const check = codes.reduce((sum, v, i) => sum + v * (i || 1), 0) % 103;
|
|
26
|
+
codes.push(check, 106);
|
|
27
|
+
return codes.flatMap((v) => [...CODE128[v]].map(Number));
|
|
28
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// `{#…}` expressions for repeat instances.
|
|
2
|
+
|
|
3
|
+
/** Evaluate an expression over variables (no `eval`: a small parser). */
|
|
4
|
+
export function evaluate(src: string, vars: Record<string, number | string | boolean>): unknown;
|
|
5
|
+
/** A value with its `{#…}` expressions filled in (nested repeats left alone). */
|
|
6
|
+
export function fill<T>(v: T, vars: Record<string, number | string | boolean>): T;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-instance values for `repeat`: `{#…}` expressions over the instance's
|
|
3
|
+
* variables (`i`, `row`, `col`, `n`, `rows`, `cols`, `angle`, `x`, `y`,
|
|
4
|
+
* `odd`), with `+ - * / %`, parentheses, comparisons, `a ? b : c` and
|
|
5
|
+
* `min`, `max`, `abs`, `floor`, `round`, `sin`, `cos`, `pick(i, a, b, …)`.
|
|
6
|
+
* No other code runs: it's a small parser, not `eval`.
|
|
7
|
+
*
|
|
8
|
+
* @module layers/expr
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Functions an expression can call. */
|
|
12
|
+
const FNS = /** @type {Record<string, (...a: any[]) => any>} */ ({
|
|
13
|
+
min: Math.min, max: Math.max, abs: Math.abs, floor: Math.floor, ceil: Math.ceil, round: Math.round,
|
|
14
|
+
sin: (/** @type {number} */ d) => Math.sin((d * Math.PI) / 180), cos: (/** @type {number} */ d) => Math.cos((d * Math.PI) / 180),
|
|
15
|
+
pick: (/** @type {number} */ i, /** @type {any[]} */ ...xs) => xs[((Math.floor(i) % xs.length) + xs.length) % xs.length],
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
/** @param {string} src @returns {string[]} */
|
|
19
|
+
function tokens(src) {
|
|
20
|
+
const out = [];
|
|
21
|
+
const re = /\s*(?:(\d*\.?\d+(?:e[+-]?\d+)?)|([A-Za-z_]\w*)|('[^']*'|"[^"]*")|(<=|>=|==|!=|&&|\|\||[-+*/%()?:,<>!]))/gy;
|
|
22
|
+
let m;
|
|
23
|
+
let at = 0;
|
|
24
|
+
while (at < src.length && (m = re.exec(src))) {
|
|
25
|
+
out.push(m[0].trim());
|
|
26
|
+
at = re.lastIndex;
|
|
27
|
+
}
|
|
28
|
+
if (src.slice(at).trim()) throw new TypeError(`Can't read "${src.slice(at).trim()}" in {#${src}}`);
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Evaluate an expression.
|
|
34
|
+
*
|
|
35
|
+
* @param {string} src
|
|
36
|
+
* @param {Record<string, number | string | boolean>} vars
|
|
37
|
+
* @returns {any}
|
|
38
|
+
* @throws {TypeError} On an unknown name or bad syntax.
|
|
39
|
+
*/
|
|
40
|
+
export function evaluate(src, vars) {
|
|
41
|
+
const t = tokens(src);
|
|
42
|
+
let k = 0;
|
|
43
|
+
const peek = () => t[k];
|
|
44
|
+
const take = (/** @type {string} */ want) => { if (t[k] !== want) throw new TypeError(`Expected "${want}" in {#${src}}`); k++; };
|
|
45
|
+
/** @returns {any} */
|
|
46
|
+
const ternary = () => {
|
|
47
|
+
const c = or();
|
|
48
|
+
if (peek() !== '?') return c;
|
|
49
|
+
k++;
|
|
50
|
+
const a = ternary();
|
|
51
|
+
take(':');
|
|
52
|
+
const b = ternary();
|
|
53
|
+
return c ? a : b;
|
|
54
|
+
};
|
|
55
|
+
const or = () => { let v = and(); while (peek() === '||') { k++; const r = and(); v = v || r; } return v; };
|
|
56
|
+
const and = () => { let v = cmp(); while (peek() === '&&') { k++; const r = cmp(); v = v && r; } return v; };
|
|
57
|
+
const cmp = () => {
|
|
58
|
+
let v = add();
|
|
59
|
+
while (['<', '>', '<=', '>=', '==', '!='].includes(peek())) {
|
|
60
|
+
const op = t[k++];
|
|
61
|
+
const r = add();
|
|
62
|
+
v = op === '<' ? v < r : op === '>' ? v > r : op === '<=' ? v <= r : op === '>=' ? v >= r : op === '==' ? v === r : v !== r;
|
|
63
|
+
}
|
|
64
|
+
return v;
|
|
65
|
+
};
|
|
66
|
+
const add = () => { let v = mul(); while (peek() === '+' || peek() === '-') { const op = t[k++]; const r = mul(); v = op === '+' ? v + r : v - r; } return v; };
|
|
67
|
+
const mul = () => { let v = unary(); while (peek() === '*' || peek() === '/' || peek() === '%') { const op = t[k++]; const r = unary(); v = op === '*' ? v * r : op === '/' ? v / r : ((v % r) + r) % r; } return v; };
|
|
68
|
+
/** @returns {any} */
|
|
69
|
+
const unary = () => {
|
|
70
|
+
if (peek() === '-') { k++; return -unary(); }
|
|
71
|
+
if (peek() === '!') { k++; return !unary(); }
|
|
72
|
+
return atom();
|
|
73
|
+
};
|
|
74
|
+
const atom = () => {
|
|
75
|
+
const tok = t[k++];
|
|
76
|
+
if (tok === undefined) throw new TypeError(`Unexpected end of {#${src}}`);
|
|
77
|
+
if (tok === '(') { const v = ternary(); take(')'); return v; }
|
|
78
|
+
if (/^\d|^\./.test(tok)) return Number(tok);
|
|
79
|
+
if (/^['"]/.test(tok)) return tok.slice(1, -1);
|
|
80
|
+
if (peek() === '(') {
|
|
81
|
+
if (!Object.hasOwn(FNS, tok)) throw new TypeError(`Unknown function "${tok}" in {#${src}} (${Object.keys(FNS).join(', ')})`);
|
|
82
|
+
k++;
|
|
83
|
+
const args = [];
|
|
84
|
+
if (peek() !== ')') { args.push(ternary()); while (peek() === ',') { k++; args.push(ternary()); } }
|
|
85
|
+
take(')');
|
|
86
|
+
return FNS[tok](...args);
|
|
87
|
+
}
|
|
88
|
+
if (!Object.hasOwn(vars, tok)) throw new TypeError(`Unknown name "${tok}" in {#${src}} (${Object.keys(vars).join(', ')})`);
|
|
89
|
+
return vars[tok];
|
|
90
|
+
};
|
|
91
|
+
const v = ternary();
|
|
92
|
+
if (k < t.length) throw new TypeError(`Unexpected "${t[k]}" in {#${src}}`);
|
|
93
|
+
return v;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A whole-value expression, or one inside a string. */
|
|
97
|
+
const WHOLE = /^\{#([^{}]+)\}$/;
|
|
98
|
+
const INSIDE = /\{#([^{}]+)\}/g;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A value with its `{#…}` expressions evaluated (a whole-string expression
|
|
102
|
+
* keeps its type). Nested layers are left alone: an inner `repeat` fills
|
|
103
|
+
* its own.
|
|
104
|
+
*
|
|
105
|
+
* @param {any} v
|
|
106
|
+
* @param {Record<string, number | string | boolean>} vars
|
|
107
|
+
* @returns {any}
|
|
108
|
+
*/
|
|
109
|
+
export function fill(v, vars) {
|
|
110
|
+
if (typeof v === 'string') {
|
|
111
|
+
const whole = WHOLE.exec(v);
|
|
112
|
+
if (whole) return evaluate(whole[1], vars);
|
|
113
|
+
return v.includes('{#') ? v.replace(INSIDE, (_, e) => String(evaluate(e, vars))) : v;
|
|
114
|
+
}
|
|
115
|
+
if (Array.isArray(v)) return v.map((x) => fill(x, vars));
|
|
116
|
+
if (v && typeof v === 'object' && !(v instanceof Uint8Array)) {
|
|
117
|
+
if (v.type === 'repeat') return v;
|
|
118
|
+
return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, fill(x, vars)]));
|
|
119
|
+
}
|
|
120
|
+
return v;
|
|
121
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Shape geometry for the `shape` layer.
|
|
2
|
+
import type { ShapeSpec } from './shape.js';
|
|
3
|
+
import type { RenderContext } from '../render/context.js';
|
|
4
|
+
|
|
5
|
+
/** A shape's path data (or an opening element tag), bounds and transform. */
|
|
6
|
+
export function geometry(s: ShapeSpec, ctx: RenderContext): { d?: string; el?: string; transform?: string; box: { x: number; y: number; width: number; height: number } };
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shape geometry for the `shape` layer: path data (or an element) and
|
|
3
|
+
* bounds for each kind, and jittered edges for deckled, torn and zigzag
|
|
4
|
+
* paper.
|
|
5
|
+
*
|
|
6
|
+
* @module layers/geometry
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { attrs, num } from '../svg.js';
|
|
10
|
+
import { len } from '../geometry.js';
|
|
11
|
+
import { pos } from './util.js';
|
|
12
|
+
import { cutPath } from '../formats/cut.js';
|
|
13
|
+
import { invert, apply } from '../effects/transform.js';
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Seeded jitter along a polyline, for deckled, torn or zigzag edges.
|
|
18
|
+
*
|
|
19
|
+
* @param {[number, number]} a @param {[number, number]} b
|
|
20
|
+
* @param {{ kind: string, size: number, step: number }} e
|
|
21
|
+
* @param {import('../rng.js').Rng} rng
|
|
22
|
+
* @returns {[number, number][]} Points from a (exclusive) to b (inclusive).
|
|
23
|
+
*/
|
|
24
|
+
function edgePoints(a, b, e, rng) {
|
|
25
|
+
const L = Math.hypot(b[0] - a[0], b[1] - a[1]);
|
|
26
|
+
const n = Math.max(2, Math.round(L / e.step));
|
|
27
|
+
const nx = -(b[1] - a[1]) / L;
|
|
28
|
+
const ny = (b[0] - a[0]) / L;
|
|
29
|
+
const out = /** @type {[number, number][]} */ ([]);
|
|
30
|
+
for (let i = 1; i <= n; i++) {
|
|
31
|
+
const t = i / n;
|
|
32
|
+
let off = 0;
|
|
33
|
+
if (i < n) {
|
|
34
|
+
if (e.kind === 'zigzag') off = (i % 2 ? 1 : -1) * e.size / 2;
|
|
35
|
+
else if (e.kind === 'wave') off = Math.sin(t * n * Math.PI) * e.size / 2;
|
|
36
|
+
else if (e.kind === 'scallop') off = -Math.abs(Math.sin(t * n * Math.PI / 2)) * e.size;
|
|
37
|
+
else off = (rng.next() - 0.5) * e.size * (e.kind === 'torn' ? 2 : 1);
|
|
38
|
+
}
|
|
39
|
+
out.push([a[0] + (b[0] - a[0]) * t + nx * off, a[1] + (b[1] - a[1]) * t + ny * off]);
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* One shape's path data and bounds.
|
|
46
|
+
*
|
|
47
|
+
* @param {any} s - The shape.
|
|
48
|
+
* @param {import('../layers.js').RenderContext} ctx
|
|
49
|
+
* @returns {{ d?: string, el?: string, box: { x: number, y: number, width: number, height: number } }}
|
|
50
|
+
*/
|
|
51
|
+
export function geometry(s, ctx) {
|
|
52
|
+
const c = ctx.canvas;
|
|
53
|
+
const X = (/** @type {any} */ v, /** @type {any} */ f = 0) => pos(v ?? f, 'x', c);
|
|
54
|
+
const Y = (/** @type {any} */ v, /** @type {any} */ f = 0) => pos(v ?? f, 'y', c);
|
|
55
|
+
const L = (/** @type {any} */ v, /** @type {any} */ f = 0) => len(v, c, f);
|
|
56
|
+
const kind = s.kind ?? 'rect';
|
|
57
|
+
const P = (/** @type {number} */ x, /** @type {number} */ y) => `${num(x)} ${num(y)}`;
|
|
58
|
+
switch (kind) {
|
|
59
|
+
case 'rect': {
|
|
60
|
+
const x = X(s.x);
|
|
61
|
+
const y = Y(s.y);
|
|
62
|
+
const w = s.width != null ? X(s.width) : c.width - x;
|
|
63
|
+
const h = s.height != null ? Y(s.height) : c.height - y;
|
|
64
|
+
const box = { x, y, width: w, height: h };
|
|
65
|
+
if (s.edge) {
|
|
66
|
+
const e = typeof s.edge === 'string' ? { kind: s.edge } : s.edge;
|
|
67
|
+
const size = L(e.size, e.kind === 'deckle' ? '0.6vmin' : '1.2vmin');
|
|
68
|
+
const step = L(e.step, e.kind === 'zigzag' ? size : e.kind === 'deckle' ? '0.9vmin' : '2vmin');
|
|
69
|
+
const sides = new Set(e.sides ?? ['top', 'right', 'bottom', 'left']);
|
|
70
|
+
const corners = /** @type {[number, number][]} */ ([[x, y], [x + w, y], [x + w, y + h], [x, y + h]]);
|
|
71
|
+
const names = ['top', 'right', 'bottom', 'left'];
|
|
72
|
+
let d = `M${P(x, y)}`;
|
|
73
|
+
for (let k = 0; k < 4; k++) {
|
|
74
|
+
const a = corners[k];
|
|
75
|
+
const b = corners[(k + 1) % 4];
|
|
76
|
+
if (sides.has(names[k])) for (const q of edgePoints(a, b, { kind: e.kind, size, step }, ctx.rng)) d += `L${P(q[0], q[1])}`;
|
|
77
|
+
else d += `L${P(b[0], b[1])}`;
|
|
78
|
+
}
|
|
79
|
+
return { d: `${d}Z`, box };
|
|
80
|
+
}
|
|
81
|
+
return { el: `<rect${attrs({ x, y, width: w, height: h, rx: s.radius != null ? L(s.radius) : undefined })}`, box };
|
|
82
|
+
}
|
|
83
|
+
case 'circle': {
|
|
84
|
+
const cx = X(s.cx, '50%');
|
|
85
|
+
const cy = Y(s.cy, '50%');
|
|
86
|
+
const r = L(s.r, '20vmin');
|
|
87
|
+
return { el: `<circle${attrs({ cx, cy, r })}`, box: { x: cx - r, y: cy - r, width: 2 * r, height: 2 * r } };
|
|
88
|
+
}
|
|
89
|
+
case 'ellipse': {
|
|
90
|
+
const cx = X(s.cx, '50%');
|
|
91
|
+
const cy = Y(s.cy, '50%');
|
|
92
|
+
const rx = L(s.rx, '20vmin');
|
|
93
|
+
const ry = L(s.ry, '12vmin');
|
|
94
|
+
return { el: `<ellipse${attrs({ cx, cy, rx, ry })}`, box: { x: cx - rx, y: cy - ry, width: 2 * rx, height: 2 * ry } };
|
|
95
|
+
}
|
|
96
|
+
case 'line': {
|
|
97
|
+
const x1 = X(s.x1);
|
|
98
|
+
const y1 = Y(s.y1);
|
|
99
|
+
const x2 = X(s.x2, '100%');
|
|
100
|
+
const y2 = Y(s.y2, s.y1 ?? 0);
|
|
101
|
+
return { d: `M${P(x1, y1)}L${P(x2, y2)}`, box: { x: Math.min(x1, x2), y: Math.min(y1, y2), width: Math.abs(x2 - x1), height: Math.abs(y2 - y1) } };
|
|
102
|
+
}
|
|
103
|
+
case 'polygon':
|
|
104
|
+
case 'polyline': {
|
|
105
|
+
const pts = (s.points ?? []).map((/** @type {any} */ q) => [X(q[0]), Y(q[1])]);
|
|
106
|
+
const xs = pts.map((/** @type {number[]} */ q) => q[0]);
|
|
107
|
+
const ys = pts.map((/** @type {number[]} */ q) => q[1]);
|
|
108
|
+
return { d: pts.map((/** @type {number[]} */ q, /** @type {number} */ i) => `${i ? 'L' : 'M'}${P(q[0], q[1])}`).join('') + (kind === 'polygon' ? 'Z' : ''), box: { x: Math.min(...xs), y: Math.min(...ys), width: Math.max(...xs) - Math.min(...xs), height: Math.max(...ys) - Math.min(...ys) } };
|
|
109
|
+
}
|
|
110
|
+
case 'star':
|
|
111
|
+
case 'burst':
|
|
112
|
+
case 'scallop':
|
|
113
|
+
case 'rays':
|
|
114
|
+
case 'ring':
|
|
115
|
+
case 'sector': {
|
|
116
|
+
const cx = X(s.cx, '50%');
|
|
117
|
+
const cy = Y(s.cy, '50%');
|
|
118
|
+
const r = L(s.r, '20vmin');
|
|
119
|
+
const rot = ((s.rotate ?? 0) * Math.PI) / 180;
|
|
120
|
+
const box = { x: cx - r, y: cy - r, width: 2 * r, height: 2 * r };
|
|
121
|
+
const at = (/** @type {number} */ a, /** @type {number} */ rr) => P(cx + rr * Math.sin(a + rot), cy - rr * Math.cos(a + rot));
|
|
122
|
+
if (kind === 'ring') {
|
|
123
|
+
const r2 = L(s.inner, r * 0.6);
|
|
124
|
+
return { d: `M${P(cx - r, cy)}a${num(r)} ${num(r)} 0 1 0 ${num(2 * r)} 0a${num(r)} ${num(r)} 0 1 0 ${num(-2 * r)} 0ZM${P(cx - r2, cy)}a${num(r2)} ${num(r2)} 0 1 1 ${num(2 * r2)} 0a${num(r2)} ${num(r2)} 0 1 1 ${num(-2 * r2)} 0Z`, box };
|
|
125
|
+
}
|
|
126
|
+
if (kind === 'sector') {
|
|
127
|
+
// A pie slice, `start` to `end` degrees (0 at 12 o'clock, clockwise): quarter and half discs.
|
|
128
|
+
const a0 = ((s.start ?? 0) * Math.PI) / 180;
|
|
129
|
+
const a1 = ((s.end ?? 90) * Math.PI) / 180;
|
|
130
|
+
const large = Math.abs(a1 - a0) > Math.PI ? 1 : 0;
|
|
131
|
+
return { d: `M${P(cx, cy)}L${at(a0, r)}A${num(r)} ${num(r)} 0 ${large} 1 ${at(a1, r)}Z`, box };
|
|
132
|
+
}
|
|
133
|
+
if (kind === 'rays') {
|
|
134
|
+
const n = s.count ?? 24;
|
|
135
|
+
let d = '';
|
|
136
|
+
for (let i = 0; i < n; i += 2) {
|
|
137
|
+
const a0 = (i / n) * Math.PI * 2;
|
|
138
|
+
const a1 = ((i + 1) / n) * Math.PI * 2;
|
|
139
|
+
d += `M${P(cx, cy)}L${at(a0, r)}L${at(a1, r)}Z`;
|
|
140
|
+
}
|
|
141
|
+
return { d, box };
|
|
142
|
+
}
|
|
143
|
+
if (kind === 'scallop') {
|
|
144
|
+
const bumps = s.points ?? 12;
|
|
145
|
+
const depth = L(s.depth, r * 0.08);
|
|
146
|
+
let d = '';
|
|
147
|
+
for (let deg = 0; deg <= 360; deg += 2) {
|
|
148
|
+
const a = (deg * Math.PI) / 180;
|
|
149
|
+
d += `${deg ? 'L' : 'M'}${at(a, r - depth + depth * Math.cos(a * bumps))}`;
|
|
150
|
+
}
|
|
151
|
+
return { d: `${d}Z`, box };
|
|
152
|
+
}
|
|
153
|
+
const n = s.points ?? (kind === 'burst' ? 16 : 5);
|
|
154
|
+
const inner = s.inner != null ? (s.inner <= 1 ? r * s.inner : L(s.inner)) : r * (kind === 'burst' ? 0.8 : 0.42);
|
|
155
|
+
let d = '';
|
|
156
|
+
for (let i = 0; i < n * 2; i++) d += `${i ? 'L' : 'M'}${at((i / (n * 2)) * Math.PI * 2, i % 2 ? inner : r)}`;
|
|
157
|
+
return { d: `${d}Z`, box };
|
|
158
|
+
}
|
|
159
|
+
case 'arch': {
|
|
160
|
+
// A window: straight sides, a round top.
|
|
161
|
+
const x = X(s.x);
|
|
162
|
+
const y = Y(s.y);
|
|
163
|
+
const w = X(s.width, '40%');
|
|
164
|
+
const h = Y(s.height, '50%');
|
|
165
|
+
const r = w / 2;
|
|
166
|
+
// `open`: the arc and its sides only (a stroked arch).
|
|
167
|
+
return { d: `M${P(x, y + h)}V${num(y + r)}A${num(r)} ${num(r)} 0 0 1 ${P(x + w, y + r)}V${num(y + h)}${s.open ? '' : 'Z'}`, box: { x, y, width: w, height: h } };
|
|
168
|
+
}
|
|
169
|
+
case 'wave': {
|
|
170
|
+
const x1 = X(s.x1);
|
|
171
|
+
const x2 = X(s.x2, '100%');
|
|
172
|
+
const y = Y(s.y, '50%');
|
|
173
|
+
const amp = L(s.amplitude, '2vmin');
|
|
174
|
+
const wl = L(s.wavelength, '20vmin');
|
|
175
|
+
const ph = s.phase ?? 0;
|
|
176
|
+
let d = '';
|
|
177
|
+
for (let x = x1, i = 0; x <= x2 + 0.01; x += Math.max(1, wl / 32), i++) d += `${i ? 'L' : 'M'}${P(x, y + Math.sin(((x - x1) / wl) * Math.PI * 2 + ph) * amp)}`;
|
|
178
|
+
if (s.band) {
|
|
179
|
+
const bh = L(s.band);
|
|
180
|
+
let back = '';
|
|
181
|
+
for (let x = x2; x >= x1 - 0.01; x -= Math.max(1, wl / 32)) back += `L${P(x, y + bh + Math.sin(((x - x1) / wl) * Math.PI * 2 + ph) * amp)}`;
|
|
182
|
+
d += `${back}Z`;
|
|
183
|
+
}
|
|
184
|
+
return { d, box: { x: x1, y: y - amp, width: x2 - x1, height: 2 * amp } };
|
|
185
|
+
}
|
|
186
|
+
case 'path': {
|
|
187
|
+
// Path data, optionally drawn from a viewBox into a box (x, y, width, height).
|
|
188
|
+
const d = String(s.d ?? '');
|
|
189
|
+
if (s.viewBox) {
|
|
190
|
+
const [vx, vy, vw, vh] = s.viewBox;
|
|
191
|
+
const x = X(s.x);
|
|
192
|
+
const y = Y(s.y);
|
|
193
|
+
const w = s.width != null ? X(s.width) : vw;
|
|
194
|
+
const h = s.height != null ? Y(s.height) : (w / vw) * vh;
|
|
195
|
+
return { d, box: { x, y, width: w, height: h }, el: undefined, ...{ transform: `translate(${num(x)} ${num(y)}) scale(${num(w / vw, 5)} ${num(h / vh, 5)}) translate(${num(-vx)} ${num(-vy)})` } };
|
|
196
|
+
}
|
|
197
|
+
return { d, box: { x: 0, y: 0, width: c.width, height: c.height } };
|
|
198
|
+
}
|
|
199
|
+
case 'cut': {
|
|
200
|
+
// The print format's die-cut (the trim's corners or shape), in this layer's units.
|
|
201
|
+
return cutGeometry(/** @type {any} */ (ctx).cut, L(s.outset, 0), /** @type {any} */ (ctx).matrix, c);
|
|
202
|
+
}
|
|
203
|
+
default:
|
|
204
|
+
throw new TypeError(`Unknown shape kind "${kind}" (rect, circle, ellipse, line, polygon, polyline, star, burst, scallop, rays, ring, sector, arch, wave, path, cut)`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The cut outline, from canvas px into the layer's units (through the
|
|
210
|
+
* inverse of everything drawn above it). Without a print format, the canvas.
|
|
211
|
+
*
|
|
212
|
+
* @param {{ x: number, y: number, width: number, height: number, cut?: any, mm?: number } | undefined} cut - Trim box and cut, canvas px.
|
|
213
|
+
* @param {number} outset - Grow (or, negative, shrink) the outline, in the layer's units.
|
|
214
|
+
* @param {import('../effects/transform.js').Affine | undefined} matrix - Layer units to canvas px.
|
|
215
|
+
* @param {{ width: number, height: number }} canvas
|
|
216
|
+
* @returns {{ d: string, box: { x: number, y: number, width: number, height: number } }}
|
|
217
|
+
*/
|
|
218
|
+
function cutGeometry(cut, outset, matrix, canvas) {
|
|
219
|
+
const inv = matrix ? invert(matrix) : undefined;
|
|
220
|
+
const k = matrix ? Math.sqrt(Math.abs(matrix[0] * matrix[3] - matrix[1] * matrix[2])) : 1;
|
|
221
|
+
const trim = cut ?? { x: 0, y: 0, width: canvas.width * k, height: canvas.height * k };
|
|
222
|
+
const o = outset * k;
|
|
223
|
+
const box = { x: trim.x - o, y: trim.y - o, width: trim.width + 2 * o, height: trim.height + 2 * o };
|
|
224
|
+
const segs = cutPath(cut?.cut, box, (cut?.mm ?? 1) * 1, o);
|
|
225
|
+
const at = (/** @type {number} */ x, /** @type {number} */ y) => (inv ? apply(inv, x, y) : [x, y]);
|
|
226
|
+
let d = '';
|
|
227
|
+
const xs = [];
|
|
228
|
+
const ys = [];
|
|
229
|
+
for (const seg of segs) {
|
|
230
|
+
d += seg.cmd;
|
|
231
|
+
for (let i = 0; i < seg.pts.length; i += 2) {
|
|
232
|
+
const [X, Y] = at(seg.pts[i], seg.pts[i + 1]);
|
|
233
|
+
xs.push(X); ys.push(Y);
|
|
234
|
+
d += `${i ? ' ' : ''}${num(X)} ${num(Y)}`;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return { d, box: { x: Math.min(...xs), y: Math.min(...ys), width: Math.max(...xs) - Math.min(...xs), height: Math.max(...ys) - Math.min(...ys) } };
|
|
238
|
+
}
|