@ideare/kit 0.0.0-stage → 0.1.0-next.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/LICENSE +21 -0
- package/README.md +148 -2
- package/THIRD_PARTY.md +52 -0
- package/dist/brand/schema.d.ts +265 -0
- package/dist/brand/schema.js +271 -0
- package/dist/check/copy.d.ts +27 -0
- package/dist/check/copy.js +139 -0
- package/dist/check/index.d.ts +54 -0
- package/dist/check/index.js +492 -0
- package/dist/color/contrast.d.ts +41 -0
- package/dist/color/contrast.js +84 -0
- package/dist/color/palette.d.ts +27 -0
- package/dist/color/palette.js +114 -0
- package/dist/color/wcag.d.ts +22 -0
- package/dist/color/wcag.js +50 -0
- package/dist/draft/draft.d.ts +61 -0
- package/dist/draft/draft.js +383 -0
- package/dist/editor/history.d.ts +78 -0
- package/dist/editor/history.js +221 -0
- package/dist/editor/operations.d.ts +112 -0
- package/dist/editor/operations.js +621 -0
- package/dist/files/docs.d.ts +49 -0
- package/dist/files/docs.js +392 -0
- package/dist/files/generate.d.ts +47 -0
- package/dist/files/generate.js +254 -0
- package/dist/files/png.d.ts +40 -0
- package/dist/files/png.js +168 -0
- package/dist/files/raster.d.ts +14 -0
- package/dist/files/raster.js +0 -0
- package/dist/files/templates.d.ts +162 -0
- package/dist/files/templates.js +390 -0
- package/dist/files/tokens.d.ts +22 -0
- package/dist/files/tokens.js +204 -0
- package/dist/fonts/catalog.d.ts +3 -0
- package/dist/fonts/catalog.js +345 -0
- package/dist/fonts/ofl.d.ts +5 -0
- package/dist/fonts/ofl.js +5 -0
- package/dist/fonts/pick.d.ts +33 -0
- package/dist/fonts/pick.js +73 -0
- package/dist/imports/folder.d.ts +80 -0
- package/dist/imports/folder.js +124 -0
- package/dist/imports/unzip.d.ts +32 -0
- package/dist/imports/unzip.js +128 -0
- package/dist/index.d.ts +59 -0
- package/dist/index.js +34 -0
- package/dist/marks/draw.d.ts +48 -0
- package/dist/marks/draw.js +149 -0
- package/dist/marks/icons.data.d.ts +5 -0
- package/dist/marks/icons.data.js +600 -0
- package/dist/marks/svg.d.ts +46 -0
- package/dist/marks/svg.js +341 -0
- package/dist/node/index.d.ts +13 -0
- package/dist/node/index.js +41 -0
- package/dist/posts/pix.d.ts +19 -0
- package/dist/posts/pix.js +49 -0
- package/dist/posts/templates.d.ts +45 -0
- package/dist/posts/templates.js +868 -0
- package/dist/resvg/index.d.ts +2 -0
- package/dist/resvg/index.js +22 -0
- package/dist/scene/brand-art.d.ts +49 -0
- package/dist/scene/brand-art.js +154 -0
- package/dist/scene/frame.d.ts +89 -0
- package/dist/scene/frame.js +213 -0
- package/dist/scene/grain.d.ts +7 -0
- package/dist/scene/grain.js +7 -0
- package/dist/scene/qr.d.ts +10 -0
- package/dist/scene/qr.js +359 -0
- package/dist/scene/render.d.ts +76 -0
- package/dist/scene/render.js +557 -0
- package/dist/scene/schema.d.ts +271 -0
- package/dist/scene/schema.js +257 -0
- package/dist/sign.d.ts +47 -0
- package/dist/sign.js +88 -0
- package/dist/text/engine.d.ts +128 -0
- package/dist/text/engine.js +245 -0
- package/dist/text/fonts.d.ts +56 -0
- package/dist/text/fonts.js +65 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +2 -0
- package/dist/wasm/index.d.ts +15 -0
- package/dist/wasm/index.js +46 -0
- package/package.json +78 -5
- package/schema/brand.v1.json +442 -0
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
import { isLight } from "../color/contrast.js";
|
|
2
|
+
import { contrast, formatRatio, over } from "../color/wcag.js";
|
|
3
|
+
import { iconBody } from "../marks/draw.js";
|
|
4
|
+
import { sha256Hex } from "../files/png.js";
|
|
5
|
+
import { TextEngine, fmt } from "../text/engine.js";
|
|
6
|
+
import { brandArt, fontFor, logoFragment } from "./brand-art.js";
|
|
7
|
+
import { frameTransform, pageFrame, round2 } from "./frame.js";
|
|
8
|
+
import { GRAIN_TILE_PNG_BASE64, GRAIN_TILE_SIZE } from "./grain.js";
|
|
9
|
+
import { qrMatrix, qrPath } from "./qr.js";
|
|
10
|
+
import { parseScene } from "./schema.js";
|
|
11
|
+
/**
|
|
12
|
+
* renderScene: one scene page and a brand in, one SVG string out. The studio shows the string
|
|
13
|
+
* inline and the server rasterizes the same string with resvg, so the editor, the derived files
|
|
14
|
+
* and live images match by construction. Only the features docs/spikes/render.md allows are
|
|
15
|
+
* written: glyph paths for text, linear and radial gradients (never `fr`), clipPaths, blend
|
|
16
|
+
* modes inside an isolated group, filters with userSpaceOnUse regions inside a budget, a PNG
|
|
17
|
+
* grain tile, and pre-sized images at whole pixels.
|
|
18
|
+
*
|
|
19
|
+
* Each layer is drawn in its own space inside `<g transform="translate(x y) rotate(r w/2 h/2)">`,
|
|
20
|
+
* so its position and turn live in that one attribute (docs/spikes/editor.md, rule E6). With a
|
|
21
|
+
* `cache`, a layer whose content did not change reuses its markup, and a drag frame rewrites
|
|
22
|
+
* one transform instead of every glyph path.
|
|
23
|
+
*/
|
|
24
|
+
/** Bump when the output for the same scene and brand changes, so revisions change too. */
|
|
25
|
+
export const RENDERER_VERSION = "2";
|
|
26
|
+
/** Filter region pixels allowed per page: about two large shadows on a share card. */
|
|
27
|
+
export const FILTER_BUDGET_PX = 500_000;
|
|
28
|
+
const engines = new WeakMap();
|
|
29
|
+
/** One text engine per font resolver, shared by every render. */
|
|
30
|
+
export function engineFor(fonts) {
|
|
31
|
+
let engine = engines.get(fonts);
|
|
32
|
+
if (!engine) {
|
|
33
|
+
engine = TextEngine.create(fonts);
|
|
34
|
+
engines.set(fonts, engine);
|
|
35
|
+
}
|
|
36
|
+
return engine;
|
|
37
|
+
}
|
|
38
|
+
const cacheStates = new WeakMap();
|
|
39
|
+
let cacheCount = 0;
|
|
40
|
+
/** ponytail: the cache empties itself past this many layers; an LRU would keep the hot ones. */
|
|
41
|
+
const CACHE_LIMIT = 2000;
|
|
42
|
+
/** A cache for one editor stage. Its prefix keeps its ids apart from other stages on the page. */
|
|
43
|
+
export function createRenderCache() {
|
|
44
|
+
cacheCount += 1;
|
|
45
|
+
const cache = { prefix: `e${cacheCount}` };
|
|
46
|
+
cacheStates.set(cache, { layers: new Map() });
|
|
47
|
+
return cache;
|
|
48
|
+
}
|
|
49
|
+
/** Sorted keys and no undefined values, so equal content always gives the same string. */
|
|
50
|
+
export function canonical(value) {
|
|
51
|
+
if (Array.isArray(value))
|
|
52
|
+
return `[${value.map(canonical).join(",")}]`;
|
|
53
|
+
if (value && typeof value === "object") {
|
|
54
|
+
const entries = Object.entries(value)
|
|
55
|
+
.filter(([, item]) => item !== undefined)
|
|
56
|
+
.sort(([a], [b]) => (a < b ? -1 : 1));
|
|
57
|
+
return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${canonical(item)}`).join(",")}}`;
|
|
58
|
+
}
|
|
59
|
+
return JSON.stringify(value);
|
|
60
|
+
}
|
|
61
|
+
/** sha256 of the canonical scene, the brand (its version when approved) and the renderer. */
|
|
62
|
+
export async function sceneRevision(scene, brand, params) {
|
|
63
|
+
const brandRef = brand.slug && brand.version
|
|
64
|
+
? `${brand.slug}@${brand.version}`
|
|
65
|
+
: await sha256Hex(canonical(brand));
|
|
66
|
+
return sha256Hex(canonical({ scene, brand: brandRef, renderer: RENDERER_VERSION, params: params ?? {} }));
|
|
67
|
+
}
|
|
68
|
+
function escapeAttr(value) {
|
|
69
|
+
return value.replaceAll("&", "&").replaceAll('"', """).replaceAll("<", "<");
|
|
70
|
+
}
|
|
71
|
+
/** What a layer's markup depends on: everything but where it sits and how it is composited. */
|
|
72
|
+
function contentOf(layer) {
|
|
73
|
+
return {
|
|
74
|
+
...layer,
|
|
75
|
+
frame: { w: layer.frame.w, h: layer.frame.h },
|
|
76
|
+
name: undefined,
|
|
77
|
+
lock: undefined,
|
|
78
|
+
opacity: undefined,
|
|
79
|
+
blend: undefined,
|
|
80
|
+
hidden: undefined,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
const LARGE_TEXT_PX = 24;
|
|
84
|
+
export async function renderScene(input, brand, ctx) {
|
|
85
|
+
// Scenes come from people (the editor, the API, live image templates), so every value that
|
|
86
|
+
// reaches the SVG (colors, path data, font ids) is checked against the schema first.
|
|
87
|
+
const parsed = parseScene(input);
|
|
88
|
+
if (!parsed.ok) {
|
|
89
|
+
const first = parsed.issues.slice(0, 3).map((issue) => `${issue.path} ${issue.message}`);
|
|
90
|
+
throw new TypeError(`The scene is not valid: ${first.join("; ")}`);
|
|
91
|
+
}
|
|
92
|
+
const scene = parsed.scene;
|
|
93
|
+
const revision = await sceneRevision(scene, brand, ctx.params);
|
|
94
|
+
const pageIndex = ctx.page ?? 0;
|
|
95
|
+
const page = scene.pages[pageIndex];
|
|
96
|
+
if (!page)
|
|
97
|
+
throw new RangeError(`The scene has no page ${pageIndex}.`);
|
|
98
|
+
const engine = await engineFor(ctx.fonts);
|
|
99
|
+
const art = await brandArt(brand, engine);
|
|
100
|
+
const palette = brand.colors[scene.theme];
|
|
101
|
+
const prefix = ctx.idPrefix ?? ctx.cache?.prefix ?? `i${revision.slice(0, 8)}`;
|
|
102
|
+
const { w: width, h: height } = scene.size;
|
|
103
|
+
const cache = ctx.cache ? cacheStates.get(ctx.cache) : undefined;
|
|
104
|
+
if (cache &&
|
|
105
|
+
(cache.brand !== brand ||
|
|
106
|
+
cache.fonts !== ctx.fonts ||
|
|
107
|
+
cache.assets !== ctx.assets ||
|
|
108
|
+
cache.layers.size > CACHE_LIMIT)) {
|
|
109
|
+
cache.layers.clear();
|
|
110
|
+
cache.brand = brand;
|
|
111
|
+
cache.fonts = ctx.fonts;
|
|
112
|
+
cache.assets = ctx.assets;
|
|
113
|
+
}
|
|
114
|
+
const defs = [];
|
|
115
|
+
const boxes = {};
|
|
116
|
+
const checks = [];
|
|
117
|
+
let filterPixels = 0;
|
|
118
|
+
const sinkFor = (path) => {
|
|
119
|
+
let next = 0;
|
|
120
|
+
return {
|
|
121
|
+
defs: [],
|
|
122
|
+
checks: [],
|
|
123
|
+
filterPixels: 0,
|
|
124
|
+
id: () => `${prefix}-${path}-${next++}`,
|
|
125
|
+
};
|
|
126
|
+
};
|
|
127
|
+
const solidHex = (sink, paint, layer) => {
|
|
128
|
+
if ("token" in paint)
|
|
129
|
+
return palette[paint.token];
|
|
130
|
+
if (layer && !layer.free) {
|
|
131
|
+
sink.checks.push({
|
|
132
|
+
rule: "colors.offPalette",
|
|
133
|
+
level: "warning",
|
|
134
|
+
layer: layer.id,
|
|
135
|
+
message: "This color is not one of the brand's. Pick a brand color, or turn on Use any color.",
|
|
136
|
+
details: { color: paint.hex.toLowerCase() },
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
return paint.hex.toLowerCase();
|
|
140
|
+
};
|
|
141
|
+
const solidAttrs = (sink, paint, attr, layer) => {
|
|
142
|
+
const alpha = paint.alpha ?? 1;
|
|
143
|
+
return ` ${attr}="${solidHex(sink, paint, layer)}"${alpha < 1 ? ` ${attr}-opacity="${fmt(alpha)}"` : ""}`;
|
|
144
|
+
};
|
|
145
|
+
const gradientStops = (sink, stops, layer) => stops
|
|
146
|
+
.map((stop) => {
|
|
147
|
+
const alpha = stop.paint.alpha ?? 1;
|
|
148
|
+
return `<stop offset="${fmt(stop.at)}" stop-color="${solidHex(sink, stop.paint, layer)}"${alpha < 1 ? ` stop-opacity="${fmt(alpha)}"` : ""}/>`;
|
|
149
|
+
})
|
|
150
|
+
.join("");
|
|
151
|
+
/** A fill attribute for any paint, over a box (gradients are userSpaceOnUse on the box). */
|
|
152
|
+
const paintAttr = (sink, paint, box, layer) => {
|
|
153
|
+
if ("token" in paint || "hex" in paint)
|
|
154
|
+
return solidAttrs(sink, paint, "fill", layer);
|
|
155
|
+
const gradientId = sink.id();
|
|
156
|
+
if ("linear" in paint) {
|
|
157
|
+
const angle = (paint.linear.angle * Math.PI) / 180;
|
|
158
|
+
const half = (Math.abs(box.w * Math.cos(angle)) + Math.abs(box.h * Math.sin(angle))) / 2;
|
|
159
|
+
const cx = box.x + box.w / 2;
|
|
160
|
+
const cy = box.y + box.h / 2;
|
|
161
|
+
const dx = Math.cos(angle) * half;
|
|
162
|
+
const dy = Math.sin(angle) * half;
|
|
163
|
+
sink.defs.push(`<linearGradient id="${gradientId}" gradientUnits="userSpaceOnUse" x1="${fmt(cx - dx)}" y1="${fmt(cy - dy)}" x2="${fmt(cx + dx)}" y2="${fmt(cy + dy)}">${gradientStops(sink, paint.linear.stops, layer)}</linearGradient>`);
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
const { cx, cy, r, stops } = paint.radial;
|
|
167
|
+
sink.defs.push(`<radialGradient id="${gradientId}" gradientUnits="userSpaceOnUse" cx="${fmt(box.x + cx * box.w)}" cy="${fmt(box.y + cy * box.h)}" r="${fmt(r * Math.max(box.w, box.h))}">${gradientStops(sink, stops, layer)}</radialGradient>`);
|
|
168
|
+
}
|
|
169
|
+
return ` fill="url(#${gradientId})"`;
|
|
170
|
+
};
|
|
171
|
+
/** The solid color of a paint, when it has one (gradients and images have none). */
|
|
172
|
+
const opaqueHex = (paint) => {
|
|
173
|
+
if (!paint || !("token" in paint || "hex" in paint))
|
|
174
|
+
return undefined;
|
|
175
|
+
const hex = "token" in paint ? palette[paint.token] : paint.hex.toLowerCase();
|
|
176
|
+
return (paint.alpha ?? 1) < 1 ? over(hex, paint.alpha ?? 1, palette.ground) : hex;
|
|
177
|
+
};
|
|
178
|
+
const pageGround = (background) => "image" in background ? undefined : opaqueHex(background);
|
|
179
|
+
/** What sits behind a box: the last earlier filled shape that covers it, or the page. */
|
|
180
|
+
const groundBehind = (layers, index, frame) => {
|
|
181
|
+
for (let i = index - 1; i >= 0; i--) {
|
|
182
|
+
const below = layers[i];
|
|
183
|
+
if (!below || below.hidden)
|
|
184
|
+
continue;
|
|
185
|
+
const covers = below.frame.x <= frame.x &&
|
|
186
|
+
below.frame.y <= frame.y &&
|
|
187
|
+
below.frame.x + below.frame.w >= frame.x + frame.w &&
|
|
188
|
+
below.frame.y + below.frame.h >= frame.y + frame.h;
|
|
189
|
+
if (!covers)
|
|
190
|
+
continue;
|
|
191
|
+
if (below.type === "shape" && (below.shape === "rect" || below.shape === "ellipse")) {
|
|
192
|
+
const hex = opaqueHex(below.fill);
|
|
193
|
+
if (hex)
|
|
194
|
+
return hex;
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
if (below.type === "image" || below.type === "art")
|
|
198
|
+
return undefined;
|
|
199
|
+
}
|
|
200
|
+
return pageGround(page.background);
|
|
201
|
+
};
|
|
202
|
+
const clipRect = (sink, box, radius, circle) => {
|
|
203
|
+
const clipId = sink.id();
|
|
204
|
+
const shape = circle
|
|
205
|
+
? `<ellipse cx="${fmt(box.x + box.w / 2)}" cy="${fmt(box.y + box.h / 2)}" rx="${fmt(box.w / 2)}" ry="${fmt(box.h / 2)}"/>`
|
|
206
|
+
: `<rect x="${fmt(box.x)}" y="${fmt(box.y)}" width="${fmt(box.w)}" height="${fmt(box.h)}"${radius ? ` rx="${fmt(radius)}"` : ""}/>`;
|
|
207
|
+
sink.defs.push(`<clipPath id="${clipId}">${shape}</clipPath>`);
|
|
208
|
+
return clipId;
|
|
209
|
+
};
|
|
210
|
+
/**
|
|
211
|
+
* An image over `box`, at whole page pixels (render.md rule 10). `origin` is where the box's
|
|
212
|
+
* space starts on the page; it is unknown inside a turned layer, which never lands on whole
|
|
213
|
+
* pixels anyway.
|
|
214
|
+
*/
|
|
215
|
+
const drawImage = async (sink, asset, box, fit, focus, radius, circle, layerId, origin) => {
|
|
216
|
+
const scale = fit === "cover"
|
|
217
|
+
? Math.max(box.w / asset.width, box.h / asset.height)
|
|
218
|
+
: Math.min(box.w / asset.width, box.h / asset.height);
|
|
219
|
+
const w = Math.round(asset.width * scale);
|
|
220
|
+
const h = Math.round(asset.height * scale);
|
|
221
|
+
const fx = focus?.x ?? 0.5;
|
|
222
|
+
const fy = focus?.y ?? 0.5;
|
|
223
|
+
const whole = (at, start = 0) => Math.round(start + at) - start;
|
|
224
|
+
// Whole pixels, and the crop centered on the focus point without showing an edge.
|
|
225
|
+
const x = whole(fit === "cover"
|
|
226
|
+
? box.x - Math.min(w - box.w, Math.max(0, w * fx - box.w / 2))
|
|
227
|
+
: box.x + (box.w - w) / 2, origin?.x);
|
|
228
|
+
const y = whole(fit === "cover"
|
|
229
|
+
? box.y - Math.min(h - box.h, Math.max(0, h * fy - box.h / 2))
|
|
230
|
+
: box.y + (box.h - h) / 2, origin?.y);
|
|
231
|
+
const resolved = ctx.assets ? await ctx.assets(asset, { width: w, height: h }) : undefined;
|
|
232
|
+
if (!resolved) {
|
|
233
|
+
sink.checks.push({
|
|
234
|
+
rule: "image.missing",
|
|
235
|
+
level: "error",
|
|
236
|
+
layer: layerId,
|
|
237
|
+
message: "A picture could not be loaded. Upload it again, or pick another one.",
|
|
238
|
+
details: { sha256: asset.sha256 },
|
|
239
|
+
});
|
|
240
|
+
return `<rect x="${fmt(box.x)}" y="${fmt(box.y)}" width="${fmt(box.w)}" height="${fmt(box.h)}" fill="${palette.surface}"/>`;
|
|
241
|
+
}
|
|
242
|
+
const needsClip = fit === "cover" || radius > 0 || circle;
|
|
243
|
+
const clip = needsClip ? ` clip-path="url(#${clipRect(sink, box, radius, circle)})"` : "";
|
|
244
|
+
return `<g${clip}><image href="${escapeAttr(resolved.href)}" x="${fmt(x)}" y="${fmt(y)}" width="${w}" height="${h}" preserveAspectRatio="none"/></g>`;
|
|
245
|
+
};
|
|
246
|
+
const slotText = (slot, fallback) => slot ? (ctx.params?.[`t.${slot}`] ?? ctx.params?.[slot] ?? fallback) : fallback;
|
|
247
|
+
/** One layer that is not a group, in its own space: (0, 0) is its frame's top-left. */
|
|
248
|
+
const drawLeaf = async (layer, siblings, index, path, origin) => {
|
|
249
|
+
const ground = layer.type === "text" || layer.type === "logo" || layer.type === "qr"
|
|
250
|
+
? groundBehind(siblings, index, layer.frame)
|
|
251
|
+
: undefined;
|
|
252
|
+
const text = layer.type === "text" ? slotText(layer.slot, layer.text) : undefined;
|
|
253
|
+
// Pictures land on whole page pixels, so they also depend on where their space starts.
|
|
254
|
+
const isPicture = layer.type === "image" || layer.type === "art";
|
|
255
|
+
const phase = origin ? [round2(origin.x % 1), round2(origin.y % 1)] : "turned";
|
|
256
|
+
const key = cache
|
|
257
|
+
? canonical([
|
|
258
|
+
prefix,
|
|
259
|
+
path,
|
|
260
|
+
scene.theme,
|
|
261
|
+
scene.locale,
|
|
262
|
+
ground,
|
|
263
|
+
text,
|
|
264
|
+
isPicture ? phase : undefined,
|
|
265
|
+
contentOf(layer),
|
|
266
|
+
])
|
|
267
|
+
: undefined;
|
|
268
|
+
const hit = key === undefined ? undefined : cache?.layers.get(key);
|
|
269
|
+
if (hit)
|
|
270
|
+
return hit;
|
|
271
|
+
const sink = sinkFor(path);
|
|
272
|
+
const box = { x: 0, y: 0, w: layer.frame.w, h: layer.frame.h };
|
|
273
|
+
let body = "";
|
|
274
|
+
switch (layer.type) {
|
|
275
|
+
case "text": {
|
|
276
|
+
const fontRef = "role" in layer.style.font
|
|
277
|
+
? fontFor(brand, layer.style.font.role, layer.style.font.weight)
|
|
278
|
+
: layer.style.font;
|
|
279
|
+
if (!("role" in layer.style.font) && !layer.free) {
|
|
280
|
+
sink.checks.push({
|
|
281
|
+
rule: "fonts.offBrand",
|
|
282
|
+
level: "warning",
|
|
283
|
+
layer: layer.id,
|
|
284
|
+
message: "This text uses a font outside the brand. Pick a brand font, or turn on Use any font.",
|
|
285
|
+
details: { font: `${fontRef.id} ${fontRef.weight}` },
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
const font = await engine.font(fontRef);
|
|
289
|
+
const words = text ?? layer.text;
|
|
290
|
+
const layout = engine.layout({
|
|
291
|
+
text: words,
|
|
292
|
+
font,
|
|
293
|
+
size: layer.style.size,
|
|
294
|
+
lineHeight: layer.style.lineHeight,
|
|
295
|
+
tracking: layer.style.tracking,
|
|
296
|
+
align: layer.style.align,
|
|
297
|
+
valign: layer.style.valign,
|
|
298
|
+
uppercase: layer.style.uppercase,
|
|
299
|
+
locale: scene.locale,
|
|
300
|
+
box,
|
|
301
|
+
maxLines: layer.fit?.maxLines,
|
|
302
|
+
fit: layer.fit ? { mode: layer.fit.mode, min: layer.fit.min } : undefined,
|
|
303
|
+
});
|
|
304
|
+
sink.lines = layout.lines;
|
|
305
|
+
sink.fontSize = layout.size;
|
|
306
|
+
if (layout.overflow) {
|
|
307
|
+
const { reason, needed, allowed } = layout.overflow;
|
|
308
|
+
sink.checks.push({
|
|
309
|
+
rule: "text.fit",
|
|
310
|
+
level: "error",
|
|
311
|
+
layer: layer.id,
|
|
312
|
+
message: reason === "width"
|
|
313
|
+
? "A word is too long for its box. Make the box wider, or use a shorter word."
|
|
314
|
+
: reason === "lines"
|
|
315
|
+
? `The text needs ${needed} lines and the box allows ${allowed}. Shorten it a little.`
|
|
316
|
+
: "The text is too tall for its box. Shorten it, or make the box taller.",
|
|
317
|
+
details: {
|
|
318
|
+
reason,
|
|
319
|
+
size: layout.size,
|
|
320
|
+
needed: Math.ceil(needed),
|
|
321
|
+
allowed: Math.floor(allowed),
|
|
322
|
+
},
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
if (layout.missing.length) {
|
|
326
|
+
sink.checks.push({
|
|
327
|
+
rule: "fonts.glyphs",
|
|
328
|
+
level: "error",
|
|
329
|
+
layer: layer.id,
|
|
330
|
+
message: `The font has no ${layout.missing.join(" ")} sign, so it would not show. Take it out or pick another font.`,
|
|
331
|
+
details: { missing: layout.missing.join(" "), font: fontRef.id },
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
const fill = solidHex(sink, layer.style.fill, layer);
|
|
335
|
+
if (ground && words.trim()) {
|
|
336
|
+
const ratio = contrast(over(fill, layer.style.fill.alpha ?? 1, ground), ground);
|
|
337
|
+
const min = layout.size >= LARGE_TEXT_PX ? 3 : 4.5;
|
|
338
|
+
if (ratio < min) {
|
|
339
|
+
sink.checks.push({
|
|
340
|
+
rule: "contrast.text",
|
|
341
|
+
level: "error",
|
|
342
|
+
layer: layer.id,
|
|
343
|
+
message: "This text is hard to read on what is behind it. Use a darker or lighter color.",
|
|
344
|
+
details: {
|
|
345
|
+
text: fill,
|
|
346
|
+
ground,
|
|
347
|
+
ratio: formatRatio(ratio),
|
|
348
|
+
needed: `${min}:1`,
|
|
349
|
+
},
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
if (layout.d)
|
|
354
|
+
body = `<path d="${layout.d}"${solidAttrs(sink, layer.style.fill, "fill", layer)}/>`;
|
|
355
|
+
break;
|
|
356
|
+
}
|
|
357
|
+
case "image":
|
|
358
|
+
body = await drawImage(sink, layer.asset, box, layer.fit, layer.focus, layer.radius ?? 0, layer.mask === "circle", layer.id, origin);
|
|
359
|
+
break;
|
|
360
|
+
case "art":
|
|
361
|
+
if (layer.asset) {
|
|
362
|
+
body = await drawImage(sink, layer.asset, box, "cover", layer.focus, layer.radius ?? 0, false, layer.id, origin);
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
365
|
+
sink.checks.push({
|
|
366
|
+
rule: "art.missing",
|
|
367
|
+
level: "warning",
|
|
368
|
+
layer: layer.id,
|
|
369
|
+
message: "This spot is waiting for a picture. Make one in the brand's style, or upload a photo.",
|
|
370
|
+
});
|
|
371
|
+
body = `<rect width="${fmt(box.w)}" height="${fmt(box.h)}"${layer.radius ? ` rx="${fmt(layer.radius)}"` : ""} fill="${palette.surface}"/>`;
|
|
372
|
+
}
|
|
373
|
+
break;
|
|
374
|
+
case "logo": {
|
|
375
|
+
const tone = layer.tone === "auto" ? "color" : layer.tone;
|
|
376
|
+
const fragment = logoFragment(art, brand, layer.variant, tone, ground ?? palette.ground);
|
|
377
|
+
const scale = Math.min(box.w / fragment.w, box.h / fragment.h);
|
|
378
|
+
const spare = box.w - fragment.w * scale;
|
|
379
|
+
const x = layer.align === "start" ? 0 : layer.align === "end" ? spare : spare / 2;
|
|
380
|
+
const y = (box.h - fragment.h * scale) / 2;
|
|
381
|
+
body = `<g transform="translate(${fmt(x)} ${fmt(y)}) scale(${fmt(scale)})">${fragment.body}</g>`;
|
|
382
|
+
break;
|
|
383
|
+
}
|
|
384
|
+
case "shape": {
|
|
385
|
+
const fill = layer.fill ? paintAttr(sink, layer.fill, box, layer) : ' fill="none"';
|
|
386
|
+
const stroke = layer.stroke
|
|
387
|
+
? `${solidAttrs(sink, layer.stroke.paint, "stroke", layer)} stroke-width="${fmt(layer.stroke.width)}"${layer.stroke.dash?.length ? ` stroke-dasharray="${layer.stroke.dash.map(fmt).join(" ")}"` : ""}`
|
|
388
|
+
: "";
|
|
389
|
+
let filter = "";
|
|
390
|
+
if (layer.shadow) {
|
|
391
|
+
const { x: sx, y: sy, blur } = layer.shadow;
|
|
392
|
+
const grow = 3 * blur;
|
|
393
|
+
const region = {
|
|
394
|
+
x: -grow + Math.min(0, sx),
|
|
395
|
+
y: -grow + Math.min(0, sy),
|
|
396
|
+
w: box.w + 2 * grow + Math.abs(sx),
|
|
397
|
+
h: box.h + 2 * grow + Math.abs(sy),
|
|
398
|
+
};
|
|
399
|
+
sink.filterPixels += region.w * region.h;
|
|
400
|
+
const filterId = sink.id();
|
|
401
|
+
const color = solidHex(sink, layer.shadow.paint, layer);
|
|
402
|
+
sink.defs.push(`<filter id="${filterId}" filterUnits="userSpaceOnUse" x="${fmt(region.x)}" y="${fmt(region.y)}" width="${fmt(region.w)}" height="${fmt(region.h)}" color-interpolation-filters="sRGB"><feDropShadow dx="${fmt(sx)}" dy="${fmt(sy)}" stdDeviation="${fmt(blur)}" flood-color="${color}" flood-opacity="${fmt(layer.shadow.paint.alpha ?? 1)}"/></filter>`);
|
|
403
|
+
filter = ` filter="url(#${filterId})"`;
|
|
404
|
+
}
|
|
405
|
+
const attrs = `${fill}${stroke}${filter}`;
|
|
406
|
+
if (layer.shape === "rect") {
|
|
407
|
+
body = `<rect width="${fmt(box.w)}" height="${fmt(box.h)}"${layer.radius ? ` rx="${fmt(Math.min(layer.radius, box.w / 2, box.h / 2))}"` : ""}${attrs}/>`;
|
|
408
|
+
}
|
|
409
|
+
else if (layer.shape === "ellipse") {
|
|
410
|
+
body = `<ellipse cx="${fmt(box.w / 2)}" cy="${fmt(box.h / 2)}" rx="${fmt(box.w / 2)}" ry="${fmt(box.h / 2)}"${attrs}/>`;
|
|
411
|
+
}
|
|
412
|
+
else if (layer.shape === "line") {
|
|
413
|
+
body = `<line x1="0" y1="0" x2="${fmt(box.w)}" y2="${fmt(box.h)}"${stroke || solidAttrs(sink, { token: "line" }, "stroke")}${filter}/>`;
|
|
414
|
+
}
|
|
415
|
+
else {
|
|
416
|
+
body = `<path d="${layer.d ?? ""}"${attrs}/>`;
|
|
417
|
+
}
|
|
418
|
+
break;
|
|
419
|
+
}
|
|
420
|
+
case "icon": {
|
|
421
|
+
const inner = iconBody(layer.icon);
|
|
422
|
+
if (!inner) {
|
|
423
|
+
sink.checks.push({
|
|
424
|
+
rule: "icon.unknown",
|
|
425
|
+
level: "error",
|
|
426
|
+
layer: layer.id,
|
|
427
|
+
message: "This icon is not in the set. Pick another one.",
|
|
428
|
+
details: { icon: layer.icon },
|
|
429
|
+
});
|
|
430
|
+
break;
|
|
431
|
+
}
|
|
432
|
+
const scale = Math.min(box.w, box.h) / 24;
|
|
433
|
+
const x = (box.w - 24 * scale) / 2;
|
|
434
|
+
const y = (box.h - 24 * scale) / 2;
|
|
435
|
+
body = `<g transform="translate(${fmt(x)} ${fmt(y)}) scale(${fmt(scale)})" fill="none"${solidAttrs(sink, layer.stroke, "stroke", layer)} stroke-width="${fmt(layer.strokeWidth ?? 2)}" stroke-linecap="round" stroke-linejoin="round">${inner}</g>`;
|
|
436
|
+
break;
|
|
437
|
+
}
|
|
438
|
+
case "qr": {
|
|
439
|
+
const modules = qrMatrix(layer.value, layer.ecc ?? "M");
|
|
440
|
+
const count = modules.length + 8; // 4 modules of quiet zone on each side
|
|
441
|
+
const size = Math.min(box.w, box.h);
|
|
442
|
+
const scale = size / count;
|
|
443
|
+
const x0 = (box.w - size) / 2;
|
|
444
|
+
const y0 = (box.h - size) / 2;
|
|
445
|
+
const back = layer.background ? solidHex(sink, layer.background, layer) : ground;
|
|
446
|
+
const fill = solidHex(sink, layer.fill, layer);
|
|
447
|
+
if (!back || contrast(fill, back) < 4.5 || isLight(fill)) {
|
|
448
|
+
sink.checks.push({
|
|
449
|
+
rule: "qr.contrast",
|
|
450
|
+
level: "error",
|
|
451
|
+
layer: layer.id,
|
|
452
|
+
message: "Phones may not read this QR code. Use dark squares on a light background.",
|
|
453
|
+
details: { squares: fill, background: back ?? "a picture" },
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
if (scale < 2) {
|
|
457
|
+
sink.checks.push({
|
|
458
|
+
rule: "qr.size",
|
|
459
|
+
level: "warning",
|
|
460
|
+
layer: layer.id,
|
|
461
|
+
message: "This QR code is very small. Make it bigger so phones can read it.",
|
|
462
|
+
details: { modulePx: fmt(scale) },
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
const backRect = layer.background
|
|
466
|
+
? `<rect x="${fmt(x0)}" y="${fmt(y0)}" width="${fmt(size)}" height="${fmt(size)}"${solidAttrs(sink, layer.background, "fill", layer)}/>`
|
|
467
|
+
: "";
|
|
468
|
+
body = `${backRect}<path d="${qrPath(modules, scale, x0 + 4 * scale, y0 + 4 * scale)}"${solidAttrs(sink, layer.fill, "fill", layer)}/>`;
|
|
469
|
+
break;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
const drawn = {
|
|
473
|
+
body,
|
|
474
|
+
defs: sink.defs,
|
|
475
|
+
checks: sink.checks,
|
|
476
|
+
filterPixels: sink.filterPixels,
|
|
477
|
+
lines: sink.lines,
|
|
478
|
+
fontSize: sink.fontSize,
|
|
479
|
+
};
|
|
480
|
+
if (key !== undefined)
|
|
481
|
+
cache?.layers.set(key, drawn);
|
|
482
|
+
return drawn;
|
|
483
|
+
};
|
|
484
|
+
/**
|
|
485
|
+
* A layer in its parent's space, wrapped in the `<g>` that places it. `groups` are the
|
|
486
|
+
* frames of the groups around it, outermost first; `origin` is where its parent's space
|
|
487
|
+
* starts on the page, unknown once anything around it turns.
|
|
488
|
+
*/
|
|
489
|
+
const drawLayer = async (layer, siblings, index, path, groups, origin) => {
|
|
490
|
+
if (layer.hidden)
|
|
491
|
+
return "";
|
|
492
|
+
const onPage = pageFrame(layer.frame, groups);
|
|
493
|
+
const inner = origin && !layer.frame.rotate
|
|
494
|
+
? { x: origin.x + round2(layer.frame.x), y: origin.y + round2(layer.frame.y) }
|
|
495
|
+
: undefined;
|
|
496
|
+
let body = "";
|
|
497
|
+
if (layer.type === "group") {
|
|
498
|
+
boxes[layer.id] = onPage;
|
|
499
|
+
const parts = [];
|
|
500
|
+
for (const [childIndex, child] of layer.layers.entries())
|
|
501
|
+
parts.push(await drawLayer(child, layer.layers, childIndex, `${path}_${childIndex}`, [...groups, layer.frame], inner));
|
|
502
|
+
body = parts.join("");
|
|
503
|
+
}
|
|
504
|
+
else {
|
|
505
|
+
const drawn = await drawLeaf(layer, siblings, index, path, inner);
|
|
506
|
+
defs.push(...drawn.defs);
|
|
507
|
+
checks.push(...drawn.checks);
|
|
508
|
+
filterPixels += drawn.filterPixels;
|
|
509
|
+
boxes[layer.id] = drawn.lines
|
|
510
|
+
? { ...onPage, lines: drawn.lines, fontSize: drawn.fontSize }
|
|
511
|
+
: onPage;
|
|
512
|
+
body = drawn.body;
|
|
513
|
+
}
|
|
514
|
+
const attrs = [`transform="${frameTransform(layer.frame)}"`];
|
|
515
|
+
if (layer.opacity !== undefined && layer.opacity < 1)
|
|
516
|
+
attrs.push(`opacity="${fmt(layer.opacity)}"`);
|
|
517
|
+
if (layer.blend && layer.blend !== "normal")
|
|
518
|
+
attrs.push(`style="mix-blend-mode:${layer.blend}"`);
|
|
519
|
+
return `<g ${attrs.join(" ")}>${body}</g>`;
|
|
520
|
+
};
|
|
521
|
+
const full = { x: 0, y: 0, w: width, h: height };
|
|
522
|
+
const pageOrigin = { x: 0, y: 0 };
|
|
523
|
+
const invisible = ("token" in page.background || "hex" in page.background) && page.background.alpha === 0;
|
|
524
|
+
const pageSink = sinkFor(`p${pageIndex}`);
|
|
525
|
+
const background = invisible
|
|
526
|
+
? ""
|
|
527
|
+
: "image" in page.background
|
|
528
|
+
? await drawImage(pageSink, page.background.image, full, page.background.fit, page.background.focus, 0, false, `${page.id}:background`, pageOrigin)
|
|
529
|
+
: `<rect width="${width}" height="${height}"${paintAttr(pageSink, page.background, full)}/>`;
|
|
530
|
+
const layers = [];
|
|
531
|
+
for (const [index, layer] of page.layers.entries())
|
|
532
|
+
layers.push(await drawLayer(layer, page.layers, index, `p${pageIndex}_${index}`, [], pageOrigin));
|
|
533
|
+
let grain = "";
|
|
534
|
+
if (page.grain) {
|
|
535
|
+
const patternId = pageSink.id();
|
|
536
|
+
pageSink.defs.push(`<pattern id="${patternId}" patternUnits="userSpaceOnUse" width="${GRAIN_TILE_SIZE}" height="${GRAIN_TILE_SIZE}"><image href="data:image/png;base64,${GRAIN_TILE_PNG_BASE64}" width="${GRAIN_TILE_SIZE}" height="${GRAIN_TILE_SIZE}"/></pattern>`);
|
|
537
|
+
grain = `<rect width="${width}" height="${height}" fill="url(#${patternId})" opacity="${fmt(page.grain)}"/>`;
|
|
538
|
+
}
|
|
539
|
+
defs.unshift(...pageSink.defs);
|
|
540
|
+
checks.unshift(...pageSink.checks);
|
|
541
|
+
if (filterPixels > FILTER_BUDGET_PX) {
|
|
542
|
+
checks.push({
|
|
543
|
+
rule: "filters.budget",
|
|
544
|
+
level: "error",
|
|
545
|
+
message: "This design has too many shadows to draw quickly. Remove a shadow or make one smaller.",
|
|
546
|
+
details: { filterPixels: Math.round(filterPixels), budget: FILTER_BUDGET_PX },
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
const usesBlend = (layers) => layers.some((layer) => (layer.blend && layer.blend !== "normal") ||
|
|
550
|
+
(layer.type === "group" && usesBlend(layer.layers)));
|
|
551
|
+
const content = `${background}${layers.join("")}${grain}`;
|
|
552
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` +
|
|
553
|
+
(defs.length ? `<defs>${defs.join("")}</defs>` : "") +
|
|
554
|
+
(usesBlend(page.layers) ? `<g style="isolation:isolate">${content}</g>` : content) +
|
|
555
|
+
`</svg>`;
|
|
556
|
+
return { svg, boxes, checks, revision };
|
|
557
|
+
}
|