@ideare/kit 0.1.0-next.0 → 0.1.0-next.2
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/dist/check/index.js +84 -8
- package/dist/draft/draft.d.ts +6 -2
- package/dist/draft/draft.js +119 -10
- package/dist/files/docs.d.ts +2 -0
- package/dist/files/docs.js +1 -1
- package/dist/marks/one-color.d.ts +45 -0
- package/dist/marks/one-color.js +304 -0
- package/dist/marks/svg.d.ts +7 -0
- package/dist/marks/svg.js +57 -5
- package/dist/scene/brand-art.d.ts +14 -2
- package/dist/scene/brand-art.js +73 -10
- package/dist/scene/render.d.ts +1 -1
- package/dist/scene/render.js +2 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/check/index.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { ensureContrast, oklabDistance } from "../color/contrast.js";
|
|
2
2
|
import { CONTRAST_PAIRS } from "../color/palette.js";
|
|
3
3
|
import { contrast, formatRatio } from "../color/wcag.js";
|
|
4
|
-
import { BANNERS, avatarScene, bannerScene, cardBackScene, catalogScene, ogScene, tileScene, } from "../files/templates.js";
|
|
4
|
+
import { BANNERS, avatarScene, bannerScene, cardBackScene, catalogScene, logoScene, ogScene, tileScene, } from "../files/templates.js";
|
|
5
5
|
import { parseMark } from "../marks/svg.js";
|
|
6
6
|
import { engineFor, renderScene } from "../scene/render.js";
|
|
7
|
-
import { fontFor } from "../scene/brand-art.js";
|
|
7
|
+
import { fontFor, linePxAt16, smallMark } from "../scene/brand-art.js";
|
|
8
8
|
import { checkCopy } from "./copy.js";
|
|
9
9
|
const GROUND_WORDS = {
|
|
10
10
|
ground: "the page",
|
|
@@ -166,10 +166,9 @@ function markChecks(brand) {
|
|
|
166
166
|
});
|
|
167
167
|
}
|
|
168
168
|
}
|
|
169
|
-
// Thin lines vanish at 16 px: the thinnest stroke must stay at 1 px or
|
|
170
|
-
|
|
171
|
-
const
|
|
172
|
-
const thinnest = (small.thinnestStroke * drawn) / Math.max(small.viewBox.w, small.viewBox.h);
|
|
169
|
+
// Thin lines vanish at 16 px: the thinnest stroke of the small drawing must stay at 1 px or
|
|
170
|
+
// more. Measured on what the kit draws there, a curated icon's heavier lines included.
|
|
171
|
+
const thinnest = Math.round(linePxAt16(smallMark(brand), brand.mark.tile.padding) * 100) / 100;
|
|
173
172
|
if (Number.isFinite(thinnest)) {
|
|
174
173
|
results.push(thinnest >= 1
|
|
175
174
|
? {
|
|
@@ -177,7 +176,7 @@ function markChecks(brand) {
|
|
|
177
176
|
area: "logo",
|
|
178
177
|
level: "pass",
|
|
179
178
|
title: "The logo reads at 16 px",
|
|
180
|
-
details: { thinnestLinePx:
|
|
179
|
+
details: { thinnestLinePx: thinnest },
|
|
181
180
|
}
|
|
182
181
|
: {
|
|
183
182
|
id: "logo.legibility.16",
|
|
@@ -185,7 +184,7 @@ function markChecks(brand) {
|
|
|
185
184
|
level: "warning",
|
|
186
185
|
title: "Thin lines in the logo fade out at 16 px. Add a simpler small version",
|
|
187
186
|
message: "Browser tabs show the logo at 16 px.",
|
|
188
|
-
details: { thinnestLinePx:
|
|
187
|
+
details: { thinnestLinePx: thinnest, needed: 1 },
|
|
189
188
|
});
|
|
190
189
|
}
|
|
191
190
|
// Colors in the mark that are not in either palette.
|
|
@@ -435,9 +434,86 @@ async function textChecks(brand, ctx) {
|
|
|
435
434
|
: "The iPhone icon has see-through pixels, which iOS turns black",
|
|
436
435
|
details: { transparentPixels: transparent },
|
|
437
436
|
});
|
|
437
|
+
const kept = await oneColorKept(brand, ctx.fonts, ctx.rasterize);
|
|
438
|
+
results.push(kept >= ONE_COLOR_KEPT
|
|
439
|
+
? {
|
|
440
|
+
id: "logo.oneColor",
|
|
441
|
+
area: "logo",
|
|
442
|
+
level: "pass",
|
|
443
|
+
title: "The one-color logo keeps every part",
|
|
444
|
+
details: { edgesKept: formatShare(kept) },
|
|
445
|
+
}
|
|
446
|
+
: {
|
|
447
|
+
id: "logo.oneColor",
|
|
448
|
+
area: "logo",
|
|
449
|
+
level: "warning",
|
|
450
|
+
title: "Parts of the logo merge in the one-color version",
|
|
451
|
+
message: "Stamps and embroidery print the logo in one color. Parts in different colors that touch become one shape. Leave a small gap between them.",
|
|
452
|
+
details: {
|
|
453
|
+
edgesKept: formatShare(kept),
|
|
454
|
+
needed: formatShare(ONE_COLOR_KEPT),
|
|
455
|
+
},
|
|
456
|
+
});
|
|
438
457
|
}
|
|
439
458
|
return results;
|
|
440
459
|
}
|
|
460
|
+
/** The share of the color logo's edges the one-color logo must keep. */
|
|
461
|
+
const ONE_COLOR_KEPT = 0.8;
|
|
462
|
+
function formatShare(share) {
|
|
463
|
+
return `${Math.round(share * 100)}%`;
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* The share of the color mark's edges that its one-color (ink) version keeps, measured at 64 px.
|
|
467
|
+
* A glyph that merges into its tile keeps only the tile's outline: about half.
|
|
468
|
+
*/
|
|
469
|
+
async function oneColorKept(brand, fonts, rasterize) {
|
|
470
|
+
const size = 64;
|
|
471
|
+
const draw = async (tone) => {
|
|
472
|
+
const { svg } = await renderScene(logoScene("mark", tone, size, size), brand, { fonts });
|
|
473
|
+
const raster = await rasterize(svg, { width: size });
|
|
474
|
+
return { pixels: raster.pixels, width: raster.width, height: raster.height };
|
|
475
|
+
};
|
|
476
|
+
const color = await draw("color");
|
|
477
|
+
const ink = await draw("ink");
|
|
478
|
+
const { width, height } = color;
|
|
479
|
+
const at = (x, y) => (y * width + x) * 4;
|
|
480
|
+
// The color mark on white, so a see-through spot reads as the ground.
|
|
481
|
+
const shade = (i, channel) => {
|
|
482
|
+
const alpha = (color.pixels[i + 3] ?? 0) / 255;
|
|
483
|
+
return (color.pixels[i + channel] ?? 0) * alpha + 255 * (1 - alpha);
|
|
484
|
+
};
|
|
485
|
+
const colorEdge = (i, j) => Math.abs(shade(i, 0) - shade(j, 0)) +
|
|
486
|
+
Math.abs(shade(i, 1) - shade(j, 1)) +
|
|
487
|
+
Math.abs(shade(i, 2) - shade(j, 2)) >
|
|
488
|
+
96;
|
|
489
|
+
const alpha = (x, y) => ink.pixels[at(x, y) + 3] ?? 0;
|
|
490
|
+
const inkEdgeNear = (x, y) => {
|
|
491
|
+
for (let dy = -1; dy <= 1; dy++)
|
|
492
|
+
for (let dx = -1; dx <= 1; dx++) {
|
|
493
|
+
const px = x + dx;
|
|
494
|
+
const py = y + dy;
|
|
495
|
+
if (px < 0 || py < 0 || px + 1 >= ink.width || py + 1 >= ink.height)
|
|
496
|
+
continue;
|
|
497
|
+
const here = alpha(px, py);
|
|
498
|
+
if (Math.abs(here - alpha(px + 1, py)) > 64 ||
|
|
499
|
+
Math.abs(here - alpha(px, py + 1)) > 64)
|
|
500
|
+
return true;
|
|
501
|
+
}
|
|
502
|
+
return false;
|
|
503
|
+
};
|
|
504
|
+
let edges = 0;
|
|
505
|
+
let kept = 0;
|
|
506
|
+
for (let y = 0; y + 1 < height; y++)
|
|
507
|
+
for (let x = 0; x + 1 < width; x++) {
|
|
508
|
+
const i = at(x, y);
|
|
509
|
+
if (!colorEdge(i, at(x + 1, y)) && !colorEdge(i, at(x, y + 1)))
|
|
510
|
+
continue;
|
|
511
|
+
edges++;
|
|
512
|
+
if (inkEdgeNear(x, y))
|
|
513
|
+
kept++;
|
|
514
|
+
}
|
|
515
|
+
return edges ? kept / edges : 1;
|
|
516
|
+
}
|
|
441
517
|
/** Upload limits per network, in bytes. */
|
|
442
518
|
const SIZE_LIMITS = [
|
|
443
519
|
{ match: /^og\//, max: 5 * 1024 * 1024, where: "X share cards (5 MB)" },
|
package/dist/draft/draft.d.ts
CHANGED
|
@@ -20,12 +20,15 @@ export type DraftInput = {
|
|
|
20
20
|
name: string;
|
|
21
21
|
/** What the business does, in the owner's words. It becomes the tagline. */
|
|
22
22
|
idea: string;
|
|
23
|
+
/** A tagline the owner picked (an AI suggestion). It replaces the idea as the tagline only:
|
|
24
|
+
* the idea still says what the business does, and the look never moves with it. */
|
|
25
|
+
tagline?: string;
|
|
23
26
|
locale: Locale;
|
|
24
27
|
seed: number;
|
|
25
28
|
picks: DraftPicks;
|
|
26
29
|
};
|
|
27
30
|
/** The longest name (the wordmark's limit) and line (the copy check's tagline limit) a draft
|
|
28
|
-
* takes: every file the kit makes has room for them. */
|
|
31
|
+
* takes: every file the kit makes has room for them. A picked tagline has the line's limit. */
|
|
29
32
|
export declare const DRAFT_LIMITS: {
|
|
30
33
|
readonly name: 40;
|
|
31
34
|
readonly idea: 60;
|
|
@@ -55,7 +58,8 @@ export declare function newDraftSeed(): number;
|
|
|
55
58
|
export declare function draftBrand(input: DraftInput, ctx: {
|
|
56
59
|
fonts: FontResolver;
|
|
57
60
|
}): Promise<Brand>;
|
|
58
|
-
/** The draft as link parameters: name, idea, locale, seed, colors, fonts and
|
|
61
|
+
/** The draft as link parameters: name, idea, a picked tagline, locale, seed, colors, fonts and
|
|
62
|
+
* logo. */
|
|
59
63
|
export declare function draftParams(input: DraftInput): URLSearchParams;
|
|
60
64
|
/** A draft read back from link parameters, or null when any part is missing or out of range. */
|
|
61
65
|
export declare function readDraftParams(params: URLSearchParams): DraftInput | null;
|
package/dist/draft/draft.js
CHANGED
|
@@ -4,7 +4,7 @@ import { catalogFont } from "../fonts/pick.js";
|
|
|
4
4
|
import { iconMark, initials, matchIcons, monogramMark } from "../marks/draw.js";
|
|
5
5
|
import { engineFor } from "../scene/render.js";
|
|
6
6
|
/** The longest name (the wordmark's limit) and line (the copy check's tagline limit) a draft
|
|
7
|
-
* takes: every file the kit makes has room for them. */
|
|
7
|
+
* takes: every file the kit makes has room for them. A picked tagline has the line's limit. */
|
|
8
8
|
export const DRAFT_LIMITS = { name: 40, idea: 60 };
|
|
9
9
|
/**
|
|
10
10
|
* The font pairs a draft picks from. Few on purpose: a browser carries every file
|
|
@@ -199,6 +199,106 @@ function lineMood(idea) {
|
|
|
199
199
|
const haystack = plain(idea);
|
|
200
200
|
return MOOD_WORDS.find((row) => row.words.some((word) => haystack.includes(` ${word}`)));
|
|
201
201
|
}
|
|
202
|
+
/**
|
|
203
|
+
* Colors a name or an idea can say, in pt-BR, en and es, as OKLCH hue ranges: "Salão Ametista"
|
|
204
|
+
* starts purple, "Loja Rubi" red. A trailing space means the whole word. Short words that are also
|
|
205
|
+
* everyday words (mar, sol, céu) are left out, so colors don't jump while a name is typed.
|
|
206
|
+
*/
|
|
207
|
+
const COLOR_WORDS = [
|
|
208
|
+
{
|
|
209
|
+
hues: [18, 34],
|
|
210
|
+
words: [
|
|
211
|
+
"vermelh",
|
|
212
|
+
"rubi",
|
|
213
|
+
"ruby",
|
|
214
|
+
"rojo ",
|
|
215
|
+
"roja ",
|
|
216
|
+
"carmim",
|
|
217
|
+
"carmin",
|
|
218
|
+
"cereja",
|
|
219
|
+
"cherry",
|
|
220
|
+
],
|
|
221
|
+
},
|
|
222
|
+
{ hues: [24, 40], words: ["coral", "terracota", "terracotta"] },
|
|
223
|
+
{ hues: [48, 64], words: ["laranj", "orange", "naranj", "tangerin", "damasco", "apricot"] },
|
|
224
|
+
{
|
|
225
|
+
hues: [50, 66],
|
|
226
|
+
words: ["marrom", "brown", "chocolat", "cacau", "cacao", "canela", "cinnamon"],
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
hues: [86, 104],
|
|
230
|
+
words: [
|
|
231
|
+
"amarel",
|
|
232
|
+
"yellow",
|
|
233
|
+
"amarill",
|
|
234
|
+
"dourad",
|
|
235
|
+
"dorad",
|
|
236
|
+
"golden",
|
|
237
|
+
"ouro ",
|
|
238
|
+
"girassol",
|
|
239
|
+
"sunflower",
|
|
240
|
+
"girasol",
|
|
241
|
+
],
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
hues: [132, 156],
|
|
245
|
+
words: [
|
|
246
|
+
"verde",
|
|
247
|
+
"green",
|
|
248
|
+
"esmerald",
|
|
249
|
+
"emerald",
|
|
250
|
+
"menta",
|
|
251
|
+
"mint ",
|
|
252
|
+
"oliva",
|
|
253
|
+
"olive",
|
|
254
|
+
"jade",
|
|
255
|
+
"salvia",
|
|
256
|
+
"sage ",
|
|
257
|
+
],
|
|
258
|
+
},
|
|
259
|
+
{ hues: [180, 200], words: ["turques", "turquoise", "turquesa", "aqua "] },
|
|
260
|
+
{
|
|
261
|
+
hues: [244, 266],
|
|
262
|
+
words: [
|
|
263
|
+
"azul",
|
|
264
|
+
"blue",
|
|
265
|
+
"safira",
|
|
266
|
+
"sapphire",
|
|
267
|
+
"zafiro",
|
|
268
|
+
"celeste",
|
|
269
|
+
"anil",
|
|
270
|
+
"indigo",
|
|
271
|
+
"marinho",
|
|
272
|
+
"navy",
|
|
273
|
+
],
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
hues: [296, 316],
|
|
277
|
+
words: [
|
|
278
|
+
"roxo",
|
|
279
|
+
"roxa",
|
|
280
|
+
"purple",
|
|
281
|
+
"morad",
|
|
282
|
+
"lilas",
|
|
283
|
+
"lilac",
|
|
284
|
+
"lila ",
|
|
285
|
+
"violet",
|
|
286
|
+
"ametist",
|
|
287
|
+
"amethyst",
|
|
288
|
+
"amatist",
|
|
289
|
+
"lavand",
|
|
290
|
+
"lavender",
|
|
291
|
+
],
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
hues: [338, 356],
|
|
295
|
+
words: ["rosa ", "pink", "magenta", "fucsia", "fuchsia", "rubor", "blush"],
|
|
296
|
+
},
|
|
297
|
+
];
|
|
298
|
+
function colorHues(text) {
|
|
299
|
+
const haystack = plain(text);
|
|
300
|
+
return COLOR_WORDS.find((row) => row.words.some((word) => haystack.includes(` ${word}`)))?.hues;
|
|
301
|
+
}
|
|
202
302
|
/** FNV-1a, 32 bits: a stable number for a string. */
|
|
203
303
|
function hash(text) {
|
|
204
304
|
let h = 0x811c9dc5;
|
|
@@ -229,7 +329,8 @@ function draftColors(input) {
|
|
|
229
329
|
const fromLine = lineMood(`${input.name} ${input.idea}`);
|
|
230
330
|
const base = fromLine?.mood ?? MOODS[Math.floor(roll(input.seed, "mood") * MOODS.length)];
|
|
231
331
|
const mood = base ?? "calm";
|
|
232
|
-
|
|
332
|
+
// A color the name says comes first, then one the idea says, then the business's own range.
|
|
333
|
+
const [from, to] = colorHues(input.name) ?? colorHues(input.idea) ?? fromLine?.hues ?? [0, 360];
|
|
233
334
|
const firstHue = from + roll(input.seed, "hue") * (to - from);
|
|
234
335
|
const k = input.picks.colors;
|
|
235
336
|
// The business's own mood holds for two presses, then the moods take turns.
|
|
@@ -307,7 +408,9 @@ export async function draftBrand(input, ctx) {
|
|
|
307
408
|
ink: light.onPrimary,
|
|
308
409
|
});
|
|
309
410
|
}
|
|
310
|
-
const
|
|
411
|
+
const picked = (input.tagline ?? "").trim().slice(0, DRAFT_LIMITS.idea);
|
|
412
|
+
const tagline = picked || idea || name;
|
|
413
|
+
const subtitle = picked || idea;
|
|
311
414
|
const draft = {
|
|
312
415
|
$schema: BRAND_SCHEMA_URL,
|
|
313
416
|
schemaVersion: 1,
|
|
@@ -318,8 +421,9 @@ export async function draftBrand(input, ctx) {
|
|
|
318
421
|
copy: {
|
|
319
422
|
[input.locale]: {
|
|
320
423
|
tagline,
|
|
321
|
-
|
|
322
|
-
|
|
424
|
+
// What the business does, which a picked tagline doesn't replace.
|
|
425
|
+
description: idea || tagline,
|
|
426
|
+
card: { title: name, ...(subtitle ? { subtitle } : {}) },
|
|
323
427
|
},
|
|
324
428
|
},
|
|
325
429
|
mark: {
|
|
@@ -344,14 +448,18 @@ export async function draftBrand(input, ctx) {
|
|
|
344
448
|
return parsed.brand;
|
|
345
449
|
}
|
|
346
450
|
const PICK_KEYS = ["colors", "fonts", "logo"];
|
|
347
|
-
/** The draft as link parameters: name, idea, locale, seed, colors, fonts and
|
|
451
|
+
/** The draft as link parameters: name, idea, a picked tagline, locale, seed, colors, fonts and
|
|
452
|
+
* logo. */
|
|
348
453
|
export function draftParams(input) {
|
|
349
454
|
const params = new URLSearchParams({
|
|
350
455
|
name: input.name.trim().slice(0, DRAFT_LIMITS.name),
|
|
351
456
|
idea: input.idea.trim().slice(0, DRAFT_LIMITS.idea),
|
|
352
|
-
locale: input.locale,
|
|
353
|
-
seed: String(input.seed),
|
|
354
457
|
});
|
|
458
|
+
const tagline = input.tagline?.trim().slice(0, DRAFT_LIMITS.idea);
|
|
459
|
+
if (tagline)
|
|
460
|
+
params.set("tagline", tagline);
|
|
461
|
+
params.set("locale", input.locale);
|
|
462
|
+
params.set("seed", String(input.seed));
|
|
355
463
|
for (const key of PICK_KEYS)
|
|
356
464
|
params.set(key, String(input.picks[key]));
|
|
357
465
|
return params;
|
|
@@ -366,11 +474,12 @@ function count(value, max) {
|
|
|
366
474
|
export function readDraftParams(params) {
|
|
367
475
|
const name = params.get("name")?.trim() ?? "";
|
|
368
476
|
const idea = params.get("idea")?.trim() ?? "";
|
|
477
|
+
const tagline = params.get("tagline")?.trim() ?? "";
|
|
369
478
|
const locale = LOCALES.find((code) => code === params.get("locale"));
|
|
370
479
|
const seed = count(params.get("seed"), 2147483647);
|
|
371
480
|
if (!name || name.length > DRAFT_LIMITS.name || idea.length > DRAFT_LIMITS.idea)
|
|
372
481
|
return null;
|
|
373
|
-
if (!locale || !seed)
|
|
482
|
+
if (tagline.length > DRAFT_LIMITS.idea || !locale || !seed)
|
|
374
483
|
return null;
|
|
375
484
|
const picks = { colors: 0, fonts: 0, logo: 0 };
|
|
376
485
|
for (const key of PICK_KEYS) {
|
|
@@ -379,5 +488,5 @@ export function readDraftParams(params) {
|
|
|
379
488
|
return null;
|
|
380
489
|
picks[key] = value;
|
|
381
490
|
}
|
|
382
|
-
return { name, idea, locale, seed, picks };
|
|
491
|
+
return { name, idea, ...(tagline ? { tagline } : {}), locale, seed, picks };
|
|
383
492
|
}
|
package/dist/files/docs.d.ts
CHANGED
|
@@ -23,6 +23,8 @@ export type HeadOptions = {
|
|
|
23
23
|
base?: string;
|
|
24
24
|
/** The absolute URL of the share card, like https://acme.com/og.png. */
|
|
25
25
|
ogImage?: string;
|
|
26
|
+
/** The card's alt text when the card is a page's own (generate's `pages`), not the brand's. */
|
|
27
|
+
imageAlt?: string;
|
|
26
28
|
locale?: Locale;
|
|
27
29
|
/** The page title, when it is not the brand's name. */
|
|
28
30
|
title?: string;
|
package/dist/files/docs.js
CHANGED
|
@@ -247,7 +247,7 @@ export function headTags(brand, options = {}) {
|
|
|
247
247
|
`<meta property="og:description" content="${escapeHtml(copy.description)}">`,
|
|
248
248
|
];
|
|
249
249
|
if (options.ogImage) {
|
|
250
|
-
tags.push(`<meta property="og:image" content="${escapeHtml(options.ogImage)}">`, '<meta property="og:image:width" content="1200">', '<meta property="og:image:height" content="630">', `<meta property="og:image:alt" content="${escapeHtml(ogAlt(brand, options.locale))}">`, '<meta name="twitter:card" content="summary_large_image">');
|
|
250
|
+
tags.push(`<meta property="og:image" content="${escapeHtml(options.ogImage)}">`, '<meta property="og:image:width" content="1200">', '<meta property="og:image:height" content="630">', `<meta property="og:image:alt" content="${escapeHtml(options.imageAlt ?? ogAlt(brand, options.locale))}">`, '<meta name="twitter:card" content="summary_large_image">');
|
|
251
251
|
}
|
|
252
252
|
return tags;
|
|
253
253
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { type MarkElement, type ParsedMark } from "./svg.ts";
|
|
2
|
+
/**
|
|
3
|
+
* One-color versions (stamps, embroidery, a logo over a photo) keep the symbol readable the way a
|
|
4
|
+
* printer would: a glyph on a tile is cut out of it. The tile prints in the one color and the
|
|
5
|
+
* glyph shows the ground through it, so the version works on any ground. Painting every part in
|
|
6
|
+
* the one color leaves a plain disc.
|
|
7
|
+
*/
|
|
8
|
+
/** A box in the mark's own units. */
|
|
9
|
+
type Box = {
|
|
10
|
+
x0: number;
|
|
11
|
+
y0: number;
|
|
12
|
+
x1: number;
|
|
13
|
+
y1: number;
|
|
14
|
+
};
|
|
15
|
+
/** A drawn shape with the paints it inherits ("none", "currentColor" or a hex) and its box. */
|
|
16
|
+
type MarkLeaf = {
|
|
17
|
+
element: MarkElement;
|
|
18
|
+
fill: string;
|
|
19
|
+
stroke: string;
|
|
20
|
+
box: Box;
|
|
21
|
+
};
|
|
22
|
+
/** A shape in a one-color version: whether its fill and its stroke are cut out. */
|
|
23
|
+
type OneColorPart = {
|
|
24
|
+
leaf: MarkLeaf;
|
|
25
|
+
fillHole: boolean;
|
|
26
|
+
strokeHole: boolean;
|
|
27
|
+
};
|
|
28
|
+
/** Every point a path reaches, control points included, in its own units. Relative commands,
|
|
29
|
+
* H and V, and arcs with packed flags (svgo writes "a1 1 0 011 1") all read. */
|
|
30
|
+
export declare function pathPoints(d: string): [number, number][];
|
|
31
|
+
/**
|
|
32
|
+
* Which paints become holes in a one-color version. Each part is read against what it sits on
|
|
33
|
+
* (the topmost filled part before it whose box holds it): in another color it takes the other
|
|
34
|
+
* state, so a glyph on a tile is cut out of it and a dot on that glyph prints again; in the same
|
|
35
|
+
* color it takes the same state. A part on the ground prints, unless it is white or close to it,
|
|
36
|
+
* which reads as the paper. A drawing that is all white (made for dark grounds) keeps every
|
|
37
|
+
* shape, since there is nothing to cut it from.
|
|
38
|
+
*/
|
|
39
|
+
export declare function oneColorParts(mark: ParsedMark): OneColorPart[];
|
|
40
|
+
/**
|
|
41
|
+
* The mark as a mask for its one-color version: white where the one color prints, black where a
|
|
42
|
+
* part is cut out. Undefined when nothing is cut out, so the mark can simply be recolored.
|
|
43
|
+
*/
|
|
44
|
+
export declare function knockoutMask(mark: ParsedMark): ParsedMark | undefined;
|
|
45
|
+
export {};
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { contrast } from "../color/wcag.js";
|
|
2
|
+
import { IDENTITY, apply, withTransform, } from "./svg.js";
|
|
3
|
+
/** Under this contrast with white, a color reads as the paper: a hole or a highlight. */
|
|
4
|
+
const PAPER_CONTRAST = 1.3;
|
|
5
|
+
/** How far a part may reach past the tile's box and still sit on it, as a share of the mark. */
|
|
6
|
+
const SLACK = 0.02;
|
|
7
|
+
const NUMBER = /[-+]?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?/iy;
|
|
8
|
+
const COMMAND = /[MmLlHhVvCcSsQqTtAaZz]/;
|
|
9
|
+
/** Points along an arc (SVG's endpoint form), sampled from its center form. */
|
|
10
|
+
function arcPoints(x0, y0, radiusX, radiusY, degrees, large, sweep, x, y) {
|
|
11
|
+
let rx = Math.abs(radiusX);
|
|
12
|
+
let ry = Math.abs(radiusY);
|
|
13
|
+
if (!rx || !ry || (x0 === x && y0 === y))
|
|
14
|
+
return [[x, y]];
|
|
15
|
+
const phi = (degrees * Math.PI) / 180;
|
|
16
|
+
const cos = Math.cos(phi);
|
|
17
|
+
const sin = Math.sin(phi);
|
|
18
|
+
const dx = (x0 - x) / 2;
|
|
19
|
+
const dy = (y0 - y) / 2;
|
|
20
|
+
const x1 = cos * dx + sin * dy;
|
|
21
|
+
const y1 = -sin * dx + cos * dy;
|
|
22
|
+
const lambda = (x1 * x1) / (rx * rx) + (y1 * y1) / (ry * ry);
|
|
23
|
+
if (lambda > 1) {
|
|
24
|
+
rx *= Math.sqrt(lambda);
|
|
25
|
+
ry *= Math.sqrt(lambda);
|
|
26
|
+
}
|
|
27
|
+
const num = rx * rx * ry * ry - rx * rx * y1 * y1 - ry * ry * x1 * x1;
|
|
28
|
+
const den = rx * rx * y1 * y1 + ry * ry * x1 * x1;
|
|
29
|
+
const coef = (large === sweep ? -1 : 1) * Math.sqrt(Math.max(0, num / den));
|
|
30
|
+
const cxp = (coef * rx * y1) / ry;
|
|
31
|
+
const cyp = (-coef * ry * x1) / rx;
|
|
32
|
+
const cx = cos * cxp - sin * cyp + (x0 + x) / 2;
|
|
33
|
+
const cy = sin * cxp + cos * cyp + (y0 + y) / 2;
|
|
34
|
+
const start = Math.atan2((y1 - cyp) / ry, (x1 - cxp) / rx);
|
|
35
|
+
let delta = Math.atan2((-y1 - cyp) / ry, (-x1 - cxp) / rx) - start;
|
|
36
|
+
if (sweep && delta < 0)
|
|
37
|
+
delta += 2 * Math.PI;
|
|
38
|
+
else if (!sweep && delta > 0)
|
|
39
|
+
delta -= 2 * Math.PI;
|
|
40
|
+
const points = [];
|
|
41
|
+
for (let step = 1; step <= 16; step++) {
|
|
42
|
+
const t = start + (delta * step) / 16;
|
|
43
|
+
const ex = rx * Math.cos(t);
|
|
44
|
+
const ey = ry * Math.sin(t);
|
|
45
|
+
points.push([cos * ex - sin * ey + cx, sin * ex + cos * ey + cy]);
|
|
46
|
+
}
|
|
47
|
+
return points;
|
|
48
|
+
}
|
|
49
|
+
/** Every point a path reaches, control points included, in its own units. Relative commands,
|
|
50
|
+
* H and V, and arcs with packed flags (svgo writes "a1 1 0 011 1") all read. */
|
|
51
|
+
export function pathPoints(d) {
|
|
52
|
+
const points = [];
|
|
53
|
+
let i = 0;
|
|
54
|
+
const skip = () => {
|
|
55
|
+
while (i < d.length && /[\s,]/.test(d[i] ?? ""))
|
|
56
|
+
i++;
|
|
57
|
+
};
|
|
58
|
+
const number = () => {
|
|
59
|
+
skip();
|
|
60
|
+
NUMBER.lastIndex = i;
|
|
61
|
+
const match = NUMBER.exec(d);
|
|
62
|
+
if (!match)
|
|
63
|
+
return undefined;
|
|
64
|
+
i += match[0].length;
|
|
65
|
+
return Number(match[0]);
|
|
66
|
+
};
|
|
67
|
+
const flag = () => {
|
|
68
|
+
skip();
|
|
69
|
+
const char = d[i];
|
|
70
|
+
if (char !== "0" && char !== "1")
|
|
71
|
+
return undefined;
|
|
72
|
+
i++;
|
|
73
|
+
return Number(char);
|
|
74
|
+
};
|
|
75
|
+
let x = 0;
|
|
76
|
+
let y = 0;
|
|
77
|
+
let startX = 0;
|
|
78
|
+
let startY = 0;
|
|
79
|
+
let command = "";
|
|
80
|
+
for (;;) {
|
|
81
|
+
skip();
|
|
82
|
+
if (i >= d.length)
|
|
83
|
+
return points;
|
|
84
|
+
const char = d[i] ?? "";
|
|
85
|
+
if (COMMAND.test(char)) {
|
|
86
|
+
command = char;
|
|
87
|
+
i++;
|
|
88
|
+
if (command === "Z" || command === "z") {
|
|
89
|
+
x = startX;
|
|
90
|
+
y = startY;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const relative = command === command.toLowerCase();
|
|
95
|
+
const ox = relative ? x : 0;
|
|
96
|
+
const oy = relative ? y : 0;
|
|
97
|
+
const pairs = (count) => {
|
|
98
|
+
const out = [];
|
|
99
|
+
for (let n = 0; n < count; n++) {
|
|
100
|
+
const px = number();
|
|
101
|
+
const py = number();
|
|
102
|
+
if (px === undefined || py === undefined)
|
|
103
|
+
return undefined;
|
|
104
|
+
out.push([ox + px, oy + py]);
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
};
|
|
108
|
+
let drawn;
|
|
109
|
+
switch (command.toLowerCase()) {
|
|
110
|
+
case "m":
|
|
111
|
+
case "l":
|
|
112
|
+
case "t":
|
|
113
|
+
drawn = pairs(1);
|
|
114
|
+
break;
|
|
115
|
+
case "s":
|
|
116
|
+
case "q":
|
|
117
|
+
drawn = pairs(2);
|
|
118
|
+
break;
|
|
119
|
+
case "c":
|
|
120
|
+
drawn = pairs(3);
|
|
121
|
+
break;
|
|
122
|
+
case "h": {
|
|
123
|
+
const value = number();
|
|
124
|
+
drawn = value === undefined ? undefined : [[ox + value, y]];
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
case "v": {
|
|
128
|
+
const value = number();
|
|
129
|
+
drawn = value === undefined ? undefined : [[x, oy + value]];
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
case "a": {
|
|
133
|
+
const [rx, ry, rotation] = [number(), number(), number()];
|
|
134
|
+
const [large, sweep] = [flag(), flag()];
|
|
135
|
+
const end = pairs(1)?.[0];
|
|
136
|
+
drawn =
|
|
137
|
+
rx === undefined ||
|
|
138
|
+
ry === undefined ||
|
|
139
|
+
rotation === undefined ||
|
|
140
|
+
large === undefined ||
|
|
141
|
+
sweep === undefined ||
|
|
142
|
+
!end
|
|
143
|
+
? undefined
|
|
144
|
+
: arcPoints(x, y, rx, ry, rotation, large, sweep, end[0], end[1]);
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
default:
|
|
148
|
+
// A number with no command before it, or after a close: nothing more reads.
|
|
149
|
+
return points;
|
|
150
|
+
}
|
|
151
|
+
const last = drawn?.[drawn.length - 1];
|
|
152
|
+
if (!drawn || !last)
|
|
153
|
+
return points;
|
|
154
|
+
points.push(...drawn);
|
|
155
|
+
if (command === "M" || command === "m") {
|
|
156
|
+
startX = last[0];
|
|
157
|
+
startY = last[1];
|
|
158
|
+
// More pairs after a moveto are lines.
|
|
159
|
+
command = command === "M" ? "L" : "l";
|
|
160
|
+
}
|
|
161
|
+
[x, y] = last;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
function corners(x0, y0, x1, y1) {
|
|
165
|
+
return [
|
|
166
|
+
[x0, y0],
|
|
167
|
+
[x1, y0],
|
|
168
|
+
[x0, y1],
|
|
169
|
+
[x1, y1],
|
|
170
|
+
];
|
|
171
|
+
}
|
|
172
|
+
function shapePoints(element) {
|
|
173
|
+
const attrs = element.attrs;
|
|
174
|
+
const n = (name) => Number(attrs[name] ?? 0) || 0;
|
|
175
|
+
switch (element.tag) {
|
|
176
|
+
case "circle":
|
|
177
|
+
return corners(n("cx") - n("r"), n("cy") - n("r"), n("cx") + n("r"), n("cy") + n("r"));
|
|
178
|
+
case "ellipse":
|
|
179
|
+
return corners(n("cx") - n("rx"), n("cy") - n("ry"), n("cx") + n("rx"), n("cy") + n("ry"));
|
|
180
|
+
case "rect":
|
|
181
|
+
return corners(n("x"), n("y"), n("x") + n("width"), n("y") + n("height"));
|
|
182
|
+
case "line":
|
|
183
|
+
return [
|
|
184
|
+
[n("x1"), n("y1")],
|
|
185
|
+
[n("x2"), n("y2")],
|
|
186
|
+
];
|
|
187
|
+
case "polyline":
|
|
188
|
+
case "polygon": {
|
|
189
|
+
const values = (attrs.points ?? "")
|
|
190
|
+
.trim()
|
|
191
|
+
.split(/[\s,]+/)
|
|
192
|
+
.map(Number);
|
|
193
|
+
const points = [];
|
|
194
|
+
for (let k = 0; k + 1 < values.length; k += 2)
|
|
195
|
+
points.push([values[k] ?? 0, values[k + 1] ?? 0]);
|
|
196
|
+
return points;
|
|
197
|
+
}
|
|
198
|
+
case "path":
|
|
199
|
+
return pathPoints(attrs.d ?? "");
|
|
200
|
+
case "g":
|
|
201
|
+
return [];
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/** Every drawn shape of a mark, bottom first, with its paints and its box in the mark's units. */
|
|
205
|
+
function markLeaves(mark) {
|
|
206
|
+
const leaves = [];
|
|
207
|
+
const walk = (element, fill, stroke, matrix) => {
|
|
208
|
+
const nextFill = element.attrs.fill ?? fill;
|
|
209
|
+
const nextStroke = element.attrs.stroke ?? stroke;
|
|
210
|
+
const nextMatrix = withTransform(matrix, element.attrs.transform);
|
|
211
|
+
for (const child of element.children)
|
|
212
|
+
walk(child, nextFill, nextStroke, nextMatrix);
|
|
213
|
+
if (element.tag === "g")
|
|
214
|
+
return;
|
|
215
|
+
const points = shapePoints(element).map(([x, y]) => apply(nextMatrix, x, y));
|
|
216
|
+
if (!points.length)
|
|
217
|
+
return;
|
|
218
|
+
const xs = points.map(([x]) => x);
|
|
219
|
+
const ys = points.map(([, y]) => y);
|
|
220
|
+
leaves.push({
|
|
221
|
+
element,
|
|
222
|
+
fill: nextFill,
|
|
223
|
+
stroke: nextStroke,
|
|
224
|
+
box: {
|
|
225
|
+
x0: Math.min(...xs),
|
|
226
|
+
y0: Math.min(...ys),
|
|
227
|
+
x1: Math.max(...xs),
|
|
228
|
+
y1: Math.max(...ys),
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
};
|
|
232
|
+
// An unset fill on the root paints in currentColor (markBody writes it so).
|
|
233
|
+
walk(mark.root, "currentColor", "none", IDENTITY);
|
|
234
|
+
return leaves;
|
|
235
|
+
}
|
|
236
|
+
function inside(inner, outer, slack) {
|
|
237
|
+
return (inner.x0 >= outer.x0 - slack &&
|
|
238
|
+
inner.y0 >= outer.y0 - slack &&
|
|
239
|
+
inner.x1 <= outer.x1 + slack &&
|
|
240
|
+
inner.y1 <= outer.y1 + slack);
|
|
241
|
+
}
|
|
242
|
+
/** Whether the paint reads as the paper: white, or close to it. */
|
|
243
|
+
function isPaper(paint) {
|
|
244
|
+
return paint.startsWith("#") && contrast(paint, "#ffffff") < PAPER_CONTRAST;
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Which paints become holes in a one-color version. Each part is read against what it sits on
|
|
248
|
+
* (the topmost filled part before it whose box holds it): in another color it takes the other
|
|
249
|
+
* state, so a glyph on a tile is cut out of it and a dot on that glyph prints again; in the same
|
|
250
|
+
* color it takes the same state. A part on the ground prints, unless it is white or close to it,
|
|
251
|
+
* which reads as the paper. A drawing that is all white (made for dark grounds) keeps every
|
|
252
|
+
* shape, since there is nothing to cut it from.
|
|
253
|
+
*/
|
|
254
|
+
export function oneColorParts(mark) {
|
|
255
|
+
const slack = Math.max(mark.viewBox.w, mark.viewBox.h) * SLACK;
|
|
256
|
+
const parts = [];
|
|
257
|
+
for (const leaf of markLeaves(mark)) {
|
|
258
|
+
let below;
|
|
259
|
+
for (let k = parts.length - 1; k >= 0 && !below; k--) {
|
|
260
|
+
const part = parts[k];
|
|
261
|
+
if (part && part.leaf.fill !== "none" && inside(leaf.box, part.leaf.box, slack))
|
|
262
|
+
below = part;
|
|
263
|
+
}
|
|
264
|
+
const isHole = (paint) => paint !== "none" &&
|
|
265
|
+
(below
|
|
266
|
+
? paint === below.leaf.fill
|
|
267
|
+
? below.fillHole
|
|
268
|
+
: !below.fillHole
|
|
269
|
+
: isPaper(paint));
|
|
270
|
+
parts.push({ leaf, fillHole: isHole(leaf.fill), strokeHole: isHole(leaf.stroke) });
|
|
271
|
+
}
|
|
272
|
+
const keepsInk = parts.some((part) => (part.leaf.fill !== "none" && !part.fillHole) ||
|
|
273
|
+
(part.leaf.stroke !== "none" && !part.strokeHole));
|
|
274
|
+
return keepsInk
|
|
275
|
+
? parts
|
|
276
|
+
: parts.map((part) => ({ ...part, fillHole: false, strokeHole: false }));
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* The mark as a mask for its one-color version: white where the one color prints, black where a
|
|
280
|
+
* part is cut out. Undefined when nothing is cut out, so the mark can simply be recolored.
|
|
281
|
+
*/
|
|
282
|
+
export function knockoutMask(mark) {
|
|
283
|
+
const parts = oneColorParts(mark);
|
|
284
|
+
if (!parts.some((part) => part.fillHole || part.strokeHole))
|
|
285
|
+
return undefined;
|
|
286
|
+
const byElement = new Map(parts.map((part) => [part.leaf.element, part]));
|
|
287
|
+
const paint = (value, hole) => value === "none" ? "none" : hole ? "#000000" : "#ffffff";
|
|
288
|
+
const copy = (element) => {
|
|
289
|
+
const { fill: _fill, stroke: _stroke, ...attrs } = element.attrs;
|
|
290
|
+
const part = byElement.get(element);
|
|
291
|
+
return {
|
|
292
|
+
...element,
|
|
293
|
+
attrs: part
|
|
294
|
+
? {
|
|
295
|
+
...attrs,
|
|
296
|
+
fill: paint(part.leaf.fill, part.fillHole),
|
|
297
|
+
stroke: paint(part.leaf.stroke, part.strokeHole),
|
|
298
|
+
}
|
|
299
|
+
: attrs,
|
|
300
|
+
children: element.children.map(copy),
|
|
301
|
+
};
|
|
302
|
+
};
|
|
303
|
+
return { ...mark, root: copy(mark.root), colors: ["#000000", "#ffffff"] };
|
|
304
|
+
}
|
package/dist/marks/svg.d.ts
CHANGED
|
@@ -25,6 +25,13 @@ export type ParsedMark = {
|
|
|
25
25
|
/** What was left out, in plain words. Empty for a clean mark. */
|
|
26
26
|
unsupported: string[];
|
|
27
27
|
};
|
|
28
|
+
/** An affine transform as SVG's matrix(a b c d e f). */
|
|
29
|
+
export type Matrix = readonly [number, number, number, number, number, number];
|
|
30
|
+
export declare const IDENTITY: Matrix;
|
|
31
|
+
/** `matrix` followed by a transform attribute parseMark kept (so it is well formed). */
|
|
32
|
+
export declare function withTransform(matrix: Matrix, transform: string | undefined): Matrix;
|
|
33
|
+
/** A point through the matrix. */
|
|
34
|
+
export declare function apply([a, b, c, d, e, f]: Matrix, x: number, y: number): [number, number];
|
|
28
35
|
/** Read a mark master into the kit's subset. Throws only when there is no usable <svg> root. */
|
|
29
36
|
export declare function parseMark(svg: string): ParsedMark;
|
|
30
37
|
export type MarkPaint = {
|
package/dist/marks/svg.js
CHANGED
|
@@ -79,6 +79,56 @@ const NAMED = {
|
|
|
79
79
|
};
|
|
80
80
|
const NUMBER = /^-?(\d+\.?\d*|\.\d+)(e-?\d+)?$/i;
|
|
81
81
|
const TRANSFORM = /^(\s*(matrix|translate|scale|rotate|skewX|skewY)\s*\([-\d.,\se]*\)\s*)+$/i;
|
|
82
|
+
export const IDENTITY = [1, 0, 0, 1, 0, 0];
|
|
83
|
+
function multiply(m, n) {
|
|
84
|
+
return [
|
|
85
|
+
m[0] * n[0] + m[2] * n[1],
|
|
86
|
+
m[1] * n[0] + m[3] * n[1],
|
|
87
|
+
m[0] * n[2] + m[2] * n[3],
|
|
88
|
+
m[1] * n[2] + m[3] * n[3],
|
|
89
|
+
m[0] * n[4] + m[2] * n[5] + m[4],
|
|
90
|
+
m[1] * n[4] + m[3] * n[5] + m[5],
|
|
91
|
+
];
|
|
92
|
+
}
|
|
93
|
+
/** `matrix` followed by a transform attribute parseMark kept (so it is well formed). */
|
|
94
|
+
export function withTransform(matrix, transform) {
|
|
95
|
+
let out = matrix;
|
|
96
|
+
for (const [, name = "", args = ""] of (transform ?? "").matchAll(/(matrix|translate|scale|rotate|skewX|skewY)\s*\(([^)]*)\)/gi)) {
|
|
97
|
+
const n = args
|
|
98
|
+
.trim()
|
|
99
|
+
.split(/[\s,]+/)
|
|
100
|
+
.filter(Boolean)
|
|
101
|
+
.map(Number);
|
|
102
|
+
const radians = ((n[0] ?? 0) * Math.PI) / 180;
|
|
103
|
+
const cos = Math.cos(radians);
|
|
104
|
+
const sin = Math.sin(radians);
|
|
105
|
+
const [cx = 0, cy = 0] = n.slice(1);
|
|
106
|
+
const kind = name.toLowerCase();
|
|
107
|
+
const step = kind === "matrix"
|
|
108
|
+
? [n[0] ?? 1, n[1] ?? 0, n[2] ?? 0, n[3] ?? 1, n[4] ?? 0, n[5] ?? 0]
|
|
109
|
+
: kind === "translate"
|
|
110
|
+
? [1, 0, 0, 1, n[0] ?? 0, n[1] ?? 0]
|
|
111
|
+
: kind === "scale"
|
|
112
|
+
? [n[0] ?? 1, 0, 0, n[1] ?? n[0] ?? 1, 0, 0]
|
|
113
|
+
: kind === "rotate"
|
|
114
|
+
? [cos, sin, -sin, cos, cx - cos * cx + sin * cy, cy - sin * cx - cos * cy]
|
|
115
|
+
: kind === "skewx"
|
|
116
|
+
? [1, 0, Math.tan(radians), 1, 0, 0]
|
|
117
|
+
: [1, Math.tan(radians), 0, 1, 0, 0];
|
|
118
|
+
out = multiply(out, step);
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
/** A point through the matrix. */
|
|
123
|
+
export function apply([a, b, c, d, e, f], x, y) {
|
|
124
|
+
return [a * x + c * y + e, b * x + d * y + f];
|
|
125
|
+
}
|
|
126
|
+
/** How much the matrix scales a line at its thinnest: its smaller singular value. */
|
|
127
|
+
function thinnestScale([a, b, c, d]) {
|
|
128
|
+
const sum = a * a + b * b + c * c + d * d;
|
|
129
|
+
const det = a * d - b * c;
|
|
130
|
+
return Math.sqrt(Math.max(0, (sum - Math.sqrt(Math.max(0, sum * sum - 4 * det * det))) / 2));
|
|
131
|
+
}
|
|
82
132
|
/** A paint value as the kit stores it: lowercase hex, "none" or "currentColor". */
|
|
83
133
|
function normalizePaint(value) {
|
|
84
134
|
const trimmed = value.trim().toLowerCase();
|
|
@@ -266,19 +316,21 @@ export function parseMark(svg) {
|
|
|
266
316
|
}
|
|
267
317
|
if (!root || !viewBox)
|
|
268
318
|
throw new TypeError("The mark has no <svg> root with a viewBox or a size.");
|
|
269
|
-
// Stroke widths, inherited down the tree:
|
|
270
|
-
|
|
319
|
+
// Stroke widths, inherited down the tree, as drawn: a 2-unit line inside scale(2) is 4 units
|
|
320
|
+
// wide in the viewBox. The legibility check needs the thinnest one.
|
|
321
|
+
const walk = (element, stroke, width, matrix) => {
|
|
271
322
|
const nextStroke = element.attrs.stroke ?? stroke;
|
|
272
323
|
const nextWidth = element.attrs["stroke-width"]
|
|
273
324
|
? Number(element.attrs["stroke-width"])
|
|
274
325
|
: width;
|
|
326
|
+
const nextMatrix = withTransform(matrix, element.attrs.transform);
|
|
275
327
|
if (element.tag !== "g" && nextStroke && nextStroke !== "none") {
|
|
276
|
-
thinnestStroke = Math.min(thinnestStroke, nextWidth);
|
|
328
|
+
thinnestStroke = Math.min(thinnestStroke, nextWidth * thinnestScale(nextMatrix));
|
|
277
329
|
}
|
|
278
330
|
for (const child of element.children)
|
|
279
|
-
walk(child, nextStroke, nextWidth);
|
|
331
|
+
walk(child, nextStroke, nextWidth, nextMatrix);
|
|
280
332
|
};
|
|
281
|
-
walk(root, undefined, 1);
|
|
333
|
+
walk(root, undefined, 1, IDENTITY);
|
|
282
334
|
return {
|
|
283
335
|
viewBox,
|
|
284
336
|
root,
|
|
@@ -4,6 +4,16 @@ import type { LoadedFont, TextEngine } from "../text/engine.ts";
|
|
|
4
4
|
/** The versions an owner may bring as their own drawing (brand.mark.lockups). */
|
|
5
5
|
declare const LOCKUPS: readonly ["horizontal", "stacked", "wordmark"];
|
|
6
6
|
type Lockup = (typeof LOCKUPS)[number];
|
|
7
|
+
/** The thinnest line of a mark in a 16 px favicon, in pixels: the favicon draws the mark inside
|
|
8
|
+
* the tile's padding, of at most 12%. */
|
|
9
|
+
export declare function linePxAt16(mark: ParsedMark, padding: number): number;
|
|
10
|
+
/**
|
|
11
|
+
* The drawing for 16 to 32 px (favicons, small.svg, small avatars): the owner's own small version
|
|
12
|
+
* when there is one. A curated icon (`source.kind: "icon"`) is drawn with heavier lines, so its
|
|
13
|
+
* thinnest line holds a whole pixel at 16 px, where its 2-unit lines fade, up to twice its weight.
|
|
14
|
+
* Any other mark is drawn as it is, and check() asks for a simpler small version when it needs one.
|
|
15
|
+
*/
|
|
16
|
+
export declare function smallMark(brand: Brand, mark?: ParsedMark): ParsedMark;
|
|
7
17
|
/**
|
|
8
18
|
* Everything the brand contributes to a drawing, prepared once per brand: the parsed marks, the
|
|
9
19
|
* fonts for both roles and the outlined wordmark. Lockups are composed from these, in natural
|
|
@@ -43,7 +53,9 @@ export type LogoFragment = {
|
|
|
43
53
|
};
|
|
44
54
|
/**
|
|
45
55
|
* One logo version. `ground` is what it sits on (it picks the dark mark and the wordmark color
|
|
46
|
-
* for "color"); ink and white are one-color versions for stamps, embroidery and photos.
|
|
56
|
+
* for "color"); ink and white are one-color versions for stamps, embroidery and photos. `id`
|
|
57
|
+
* names the masks a one-color version cuts with: pass the page's own when several drawings share
|
|
58
|
+
* one document. Without it, the fragment numbers its own.
|
|
47
59
|
*/
|
|
48
|
-
export declare function logoFragment(art: BrandArt, brand: Brand, variant: LogoVariant, tone: LogoTone, ground: string): LogoFragment;
|
|
60
|
+
export declare function logoFragment(art: BrandArt, brand: Brand, variant: LogoVariant, tone: LogoTone, ground: string, id?: () => string): LogoFragment;
|
|
49
61
|
export {};
|
package/dist/scene/brand-art.js
CHANGED
|
@@ -1,11 +1,53 @@
|
|
|
1
1
|
import { contrast } from "../color/wcag.js";
|
|
2
2
|
import { isLight } from "../color/contrast.js";
|
|
3
|
+
import { knockoutMask } from "../marks/one-color.js";
|
|
3
4
|
import { markBody, parseMark } from "../marks/svg.js";
|
|
4
5
|
import { pathBounds } from "../marks/draw.js";
|
|
5
6
|
import { nearestWeight } from "../fonts/pick.js";
|
|
6
7
|
import { fmt } from "../text/engine.js";
|
|
7
8
|
/** The versions an owner may bring as their own drawing (brand.mark.lockups). */
|
|
8
9
|
const LOCKUPS = ["horizontal", "stacked", "wordmark"];
|
|
10
|
+
/** The heaviest a curated icon's lines get in the small version: past twice, details clog. */
|
|
11
|
+
const MAX_SMALL_WEIGHT = 2;
|
|
12
|
+
/** The thinnest line of a mark in a 16 px favicon, in pixels: the favicon draws the mark inside
|
|
13
|
+
* the tile's padding, of at most 12%. */
|
|
14
|
+
export function linePxAt16(mark, padding) {
|
|
15
|
+
const drawn = 16 * (1 - Math.min(padding, 0.12));
|
|
16
|
+
return (mark.thinnestStroke * drawn) / Math.max(mark.viewBox.w, mark.viewBox.h);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* The drawing for 16 to 32 px (favicons, small.svg, small avatars): the owner's own small version
|
|
20
|
+
* when there is one. A curated icon (`source.kind: "icon"`) is drawn with heavier lines, so its
|
|
21
|
+
* thinnest line holds a whole pixel at 16 px, where its 2-unit lines fade, up to twice its weight.
|
|
22
|
+
* Any other mark is drawn as it is, and check() asks for a simpler small version when it needs one.
|
|
23
|
+
*/
|
|
24
|
+
export function smallMark(brand, mark = parseMark(brand.mark.svg)) {
|
|
25
|
+
if (brand.mark.small)
|
|
26
|
+
return parseMark(brand.mark.small);
|
|
27
|
+
if (brand.mark.source?.kind !== "icon")
|
|
28
|
+
return mark;
|
|
29
|
+
const px = linePxAt16(mark, brand.mark.tile.padding);
|
|
30
|
+
if (!Number.isFinite(px) || px <= 0 || px >= 1)
|
|
31
|
+
return mark;
|
|
32
|
+
const weight = Math.min(MAX_SMALL_WEIGHT, Math.ceil(100 / px) / 100);
|
|
33
|
+
const heavier = (element, isRoot) => {
|
|
34
|
+
const width = element.attrs["stroke-width"];
|
|
35
|
+
// An unset width is 1, inherited from the root.
|
|
36
|
+
const scaled = width === undefined ? (isRoot ? weight : undefined) : Number(width) * weight;
|
|
37
|
+
return {
|
|
38
|
+
...element,
|
|
39
|
+
attrs: scaled === undefined
|
|
40
|
+
? element.attrs
|
|
41
|
+
: { ...element.attrs, "stroke-width": fmt(scaled) },
|
|
42
|
+
children: element.children.map((child) => heavier(child, false)),
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
return {
|
|
46
|
+
...mark,
|
|
47
|
+
root: heavier(mark.root, true),
|
|
48
|
+
thinnestStroke: mark.thinnestStroke * weight,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
9
51
|
const cache = new WeakMap();
|
|
10
52
|
/** The font file a role draws with: the brand's family at the nearest weight it has. */
|
|
11
53
|
export function fontFor(brand, role, weight) {
|
|
@@ -38,7 +80,7 @@ export function brandArt(brand, engine) {
|
|
|
38
80
|
}
|
|
39
81
|
return {
|
|
40
82
|
mark,
|
|
41
|
-
small: brand
|
|
83
|
+
small: smallMark(brand, mark),
|
|
42
84
|
dark: brand.mark.dark ? parseMark(brand.mark.dark) : undefined,
|
|
43
85
|
lockups,
|
|
44
86
|
display,
|
|
@@ -81,15 +123,30 @@ function lockupColorsOn(brand, ground) {
|
|
|
81
123
|
const light = brand.colors.dark.ink;
|
|
82
124
|
return (hex) => (contrast(hex, ground) < 3 ? light : hex);
|
|
83
125
|
}
|
|
84
|
-
function placeMark(mark,
|
|
126
|
+
function placeMark(mark, body, height, x, y) {
|
|
85
127
|
const { viewBox } = mark;
|
|
86
128
|
const scale = height / viewBox.h;
|
|
87
|
-
const body = markBody(mark, { color, current });
|
|
88
129
|
return {
|
|
89
130
|
body: `<g transform="translate(${fmt(x - viewBox.x * scale)} ${fmt(y - viewBox.y * scale)}) scale(${fmt(scale)})">${body}</g>`,
|
|
90
131
|
w: viewBox.w * scale,
|
|
91
132
|
};
|
|
92
133
|
}
|
|
134
|
+
/**
|
|
135
|
+
* A drawing in one color. What has to stay visible inside it (a glyph on its tile) is cut out
|
|
136
|
+
* with a mask instead of painted, so the version works on any ground. A drawing with nothing to
|
|
137
|
+
* cut out is simply recolored.
|
|
138
|
+
*/
|
|
139
|
+
function oneColorBody(mark, one, id) {
|
|
140
|
+
const mask = knockoutMask(mark);
|
|
141
|
+
if (!mask)
|
|
142
|
+
return markBody(mark, { color: () => one, current: one });
|
|
143
|
+
const { x, y, w, h } = mark.viewBox;
|
|
144
|
+
// Strokes and a viewBox that hugs the drawing can reach past the box.
|
|
145
|
+
const pad = Math.max(w, h) / 4;
|
|
146
|
+
const area = `x="${fmt(x - pad)}" y="${fmt(y - pad)}" width="${fmt(w + 2 * pad)}" height="${fmt(h + 2 * pad)}"`;
|
|
147
|
+
const maskId = id();
|
|
148
|
+
return `<mask id="${maskId}" maskUnits="userSpaceOnUse" ${area}>${markBody(mask)}</mask><rect ${area} fill="${one}" mask="url(#${maskId})"/>`;
|
|
149
|
+
}
|
|
93
150
|
function placeWordmark(art, fill, height, x, y) {
|
|
94
151
|
const { box, d } = art.wordmark;
|
|
95
152
|
const scale = box.h > 0 ? height / box.h : 1;
|
|
@@ -100,25 +157,31 @@ function placeWordmark(art, fill, height, x, y) {
|
|
|
100
157
|
}
|
|
101
158
|
/**
|
|
102
159
|
* One logo version. `ground` is what it sits on (it picks the dark mark and the wordmark color
|
|
103
|
-
* for "color"); ink and white are one-color versions for stamps, embroidery and photos.
|
|
160
|
+
* for "color"); ink and white are one-color versions for stamps, embroidery and photos. `id`
|
|
161
|
+
* names the masks a one-color version cuts with: pass the page's own when several drawings share
|
|
162
|
+
* one document. Without it, the fragment numbers its own.
|
|
104
163
|
*/
|
|
105
|
-
export function logoFragment(art, brand, variant, tone, ground) {
|
|
164
|
+
export function logoFragment(art, brand, variant, tone, ground, id) {
|
|
106
165
|
const palette = isLight(ground) ? brand.colors.light : brand.colors.dark;
|
|
107
166
|
const one = tone === "ink" ? brand.colors.light.ink : tone === "white" ? "#ffffff" : undefined;
|
|
108
167
|
const wordFill = one ?? palette.ink;
|
|
168
|
+
let next = 0;
|
|
169
|
+
const nextId = id ?? (() => `ideare-cut-${next++}`);
|
|
109
170
|
const mark = (which, height, x, y) => {
|
|
110
171
|
if (one) {
|
|
111
172
|
const base = which === "small" ? art.small : art.mark;
|
|
112
|
-
return placeMark(base, (
|
|
173
|
+
return placeMark(base, oneColorBody(base, one, nextId), height, x, y);
|
|
113
174
|
}
|
|
114
175
|
const chosen = markOn(art, brand, ground, which);
|
|
115
|
-
|
|
176
|
+
const body = markBody(chosen.mark, { color: chosen.color, current: palette.ink });
|
|
177
|
+
return placeMark(chosen.mark, body, height, x, y);
|
|
116
178
|
};
|
|
117
179
|
const own = variant === "mark" || variant === "small" ? undefined : art.lockups[variant];
|
|
118
180
|
if (own) {
|
|
119
|
-
const
|
|
120
|
-
?
|
|
121
|
-
:
|
|
181
|
+
const body = one
|
|
182
|
+
? oneColorBody(own, one, nextId)
|
|
183
|
+
: markBody(own, { color: lockupColorsOn(brand, ground), current: palette.ink });
|
|
184
|
+
const placed = placeMark(own, body, 100, 0, 0);
|
|
122
185
|
return { body: placed.body, w: placed.w, h: 100 };
|
|
123
186
|
}
|
|
124
187
|
switch (variant) {
|
package/dist/scene/render.d.ts
CHANGED
|
@@ -16,7 +16,7 @@ import type { AssetRef, Frame, Scene } from "./schema.ts";
|
|
|
16
16
|
* one transform instead of every glyph path.
|
|
17
17
|
*/
|
|
18
18
|
/** Bump when the output for the same scene and brand changes, so revisions change too. */
|
|
19
|
-
export declare const RENDERER_VERSION = "
|
|
19
|
+
export declare const RENDERER_VERSION = "3";
|
|
20
20
|
/** Filter region pixels allowed per page: about two large shadows on a share card. */
|
|
21
21
|
export declare const FILTER_BUDGET_PX = 500000;
|
|
22
22
|
export type AssetResolver = (asset: AssetRef, drawn: {
|
package/dist/scene/render.js
CHANGED
|
@@ -22,7 +22,7 @@ import { parseScene } from "./schema.js";
|
|
|
22
22
|
* one transform instead of every glyph path.
|
|
23
23
|
*/
|
|
24
24
|
/** Bump when the output for the same scene and brand changes, so revisions change too. */
|
|
25
|
-
export const RENDERER_VERSION = "
|
|
25
|
+
export const RENDERER_VERSION = "3";
|
|
26
26
|
/** Filter region pixels allowed per page: about two large shadows on a share card. */
|
|
27
27
|
export const FILTER_BUDGET_PX = 500_000;
|
|
28
28
|
const engines = new WeakMap();
|
|
@@ -373,7 +373,7 @@ export async function renderScene(input, brand, ctx) {
|
|
|
373
373
|
break;
|
|
374
374
|
case "logo": {
|
|
375
375
|
const tone = layer.tone === "auto" ? "color" : layer.tone;
|
|
376
|
-
const fragment = logoFragment(art, brand, layer.variant, tone, ground ?? palette.ground);
|
|
376
|
+
const fragment = logoFragment(art, brand, layer.variant, tone, ground ?? palette.ground, sink.id);
|
|
377
377
|
const scale = Math.min(box.w / fragment.w, box.h / fragment.h);
|
|
378
378
|
const spare = box.w - fragment.w * scale;
|
|
379
379
|
const x = layer.align === "start" ? 0 : layer.align === "end" ? spare : spare / 2;
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The kit's version, kept equal to package.json's (src/brand/schema.test.ts checks it). */
|
|
2
|
-
export declare const VERSION = "0.1.0-next.
|
|
2
|
+
export declare const VERSION = "0.1.0-next.2";
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The kit's version, kept equal to package.json's (src/brand/schema.test.ts checks it). */
|
|
2
|
-
export const VERSION = "0.1.0-next.
|
|
2
|
+
export const VERSION = "0.1.0-next.2";
|
package/package.json
CHANGED