@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,254 @@
|
|
|
1
|
+
import { OFL_TEXT } from "../fonts/ofl.js";
|
|
2
|
+
import { VERSION } from "../version.js";
|
|
3
|
+
import { brandArt, logoFragment } from "../scene/brand-art.js";
|
|
4
|
+
import { RENDERER_VERSION, engineFor, renderScene } from "../scene/render.js";
|
|
5
|
+
import { brandMd, headTags, ogAlt, promptMd, publicBrand, signatureHtml, slugOf, webManifest, } from "./docs.js";
|
|
6
|
+
import { dpiChunk, packIco, sha256Hex, textChunk, withChunks } from "./png.js";
|
|
7
|
+
import { AVATARS, BANNERS, CARD_SIZES, HIGHLIGHTS, avatarScene, backgroundScene, bannerScene, cardBackScene, cardFrontScene, catalogScene, emailLogoScene, highlightScene, logoScene, ogScene, tileScene, } from "./templates.js";
|
|
8
|
+
import { dtcgTokens, fontsCss, registryTheme, tailwindCss, tokensCss } from "./tokens.js";
|
|
9
|
+
/** The groups of files `generate()` can make. Each file's path starts with its group. */
|
|
10
|
+
export const TARGETS = [
|
|
11
|
+
"web",
|
|
12
|
+
"logo",
|
|
13
|
+
"og",
|
|
14
|
+
"social",
|
|
15
|
+
"tokens",
|
|
16
|
+
"registry",
|
|
17
|
+
"docs",
|
|
18
|
+
"email",
|
|
19
|
+
"print",
|
|
20
|
+
];
|
|
21
|
+
/** A file that would cut or overflow text, or drop a character, is refused rather than made. */
|
|
22
|
+
export class GenerateRefusal extends Error {
|
|
23
|
+
problems;
|
|
24
|
+
constructor(problems) {
|
|
25
|
+
super(`ideare did not make ${problems.length === 1 ? "1 file" : `${problems.length} files`}, because the text would not fit or show: ${problems
|
|
26
|
+
.slice(0, 3)
|
|
27
|
+
.map((problem) => `${problem.path} (${problem.check.message})`)
|
|
28
|
+
.join("; ")}. Run check() for the fixes.`);
|
|
29
|
+
this.name = "GenerateRefusal";
|
|
30
|
+
this.problems = problems;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const REFUSED_RULES = new Set(["text.fit", "fonts.glyphs", "filters.budget", "icon.unknown"]);
|
|
34
|
+
const encoder = new TextEncoder();
|
|
35
|
+
/**
|
|
36
|
+
* Every derived file of a brand, by path. Pure: nothing is written; the caller (the CLI, the
|
|
37
|
+
* worker, the browser zip) decides where bytes go. PNGs carry a tEXt provenance chunk; print
|
|
38
|
+
* files also say 300 dpi.
|
|
39
|
+
*/
|
|
40
|
+
export async function generate(brand, options) {
|
|
41
|
+
const targets = new Set(options.targets ?? TARGETS);
|
|
42
|
+
const files = new Map();
|
|
43
|
+
const refusals = [];
|
|
44
|
+
const engine = await engineFor(options.fonts);
|
|
45
|
+
const art = await brandArt(brand, engine);
|
|
46
|
+
const provenance = `${slugOf(brand)}${brand.version ? `@v${brand.version}` : " draft"}`;
|
|
47
|
+
const add = async (path, bytes, mime, size) => {
|
|
48
|
+
files.set(path, { bytes, mime, sha256: await sha256Hex(bytes), ...size });
|
|
49
|
+
};
|
|
50
|
+
const addText = (path, text, mime) => add(path, encoder.encode(text), mime);
|
|
51
|
+
const svgOf = async (path, scene) => {
|
|
52
|
+
const result = await renderScene(scene, brand, {
|
|
53
|
+
fonts: options.fonts,
|
|
54
|
+
idPrefix: path.replace(/[^a-z0-9]+/gi, "-"),
|
|
55
|
+
});
|
|
56
|
+
for (const check of result.checks)
|
|
57
|
+
if (REFUSED_RULES.has(check.rule))
|
|
58
|
+
refusals.push({ path, check });
|
|
59
|
+
return result.svg;
|
|
60
|
+
};
|
|
61
|
+
const png = async (path, scene, extra = []) => {
|
|
62
|
+
const svg = await svgOf(path, scene);
|
|
63
|
+
const raster = await options.rasterize(svg, { width: scene.size.w });
|
|
64
|
+
const bytes = withChunks(raster.png, [
|
|
65
|
+
textChunk("Software", `ideare kit ${VERSION}, renderer ${RENDERER_VERSION}`),
|
|
66
|
+
textChunk("Source", `ideare brand ${provenance}, file ${path}`),
|
|
67
|
+
...extra,
|
|
68
|
+
]);
|
|
69
|
+
await add(path, bytes, "image/png", { width: raster.width, height: raster.height });
|
|
70
|
+
return bytes;
|
|
71
|
+
};
|
|
72
|
+
const svgFile = async (path, scene) => {
|
|
73
|
+
await add(path, encoder.encode(await svgOf(path, scene)), "image/svg+xml", {
|
|
74
|
+
width: scene.size.w,
|
|
75
|
+
height: scene.size.h,
|
|
76
|
+
});
|
|
77
|
+
};
|
|
78
|
+
const tile = brand.mark.tile;
|
|
79
|
+
if (targets.has("web")) {
|
|
80
|
+
const small = (size) => tileScene(brand, {
|
|
81
|
+
size,
|
|
82
|
+
padding: Math.min(tile.padding, 0.12) / 2,
|
|
83
|
+
opaque: false,
|
|
84
|
+
variant: "small",
|
|
85
|
+
});
|
|
86
|
+
const ico = [];
|
|
87
|
+
for (const size of [16, 32, 48]) {
|
|
88
|
+
const svg = await svgOf(`web/favicon.ico#${size}`, small(size));
|
|
89
|
+
ico.push((await options.rasterize(svg, { width: size })).png);
|
|
90
|
+
}
|
|
91
|
+
await add("web/favicon.ico", packIco(ico), "image/x-icon", { width: 48, height: 48 });
|
|
92
|
+
// icon.svg follows the system theme inside the file: browsers read its media query.
|
|
93
|
+
const light = await svgOf("web/icon.svg", tileScene(brand, {
|
|
94
|
+
size: 64,
|
|
95
|
+
padding: tile.padding / 2,
|
|
96
|
+
opaque: false,
|
|
97
|
+
variant: "small",
|
|
98
|
+
}));
|
|
99
|
+
const dark = await svgOf("web/icon.svg#dark", tileScene(brand, {
|
|
100
|
+
size: 64,
|
|
101
|
+
padding: tile.padding / 2,
|
|
102
|
+
opaque: false,
|
|
103
|
+
variant: "small",
|
|
104
|
+
theme: "dark",
|
|
105
|
+
}));
|
|
106
|
+
const inner = (svg) => svg.slice(svg.indexOf(">") + 1, svg.lastIndexOf("</svg>"));
|
|
107
|
+
await addText("web/icon.svg", `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><style>.d{display:none}@media (prefers-color-scheme:dark){.l{display:none}.d{display:inline}}</style><g class="l">${inner(light)}</g><g class="d">${inner(dark)}</g></svg>`, "image/svg+xml");
|
|
108
|
+
await png("web/favicon-96x96.png", tileScene(brand, {
|
|
109
|
+
size: 96,
|
|
110
|
+
padding: tile.padding / 2,
|
|
111
|
+
opaque: false,
|
|
112
|
+
variant: "small",
|
|
113
|
+
}));
|
|
114
|
+
await png("web/apple-touch-icon.png", tileScene(brand, {
|
|
115
|
+
size: 180,
|
|
116
|
+
padding: Math.max(tile.padding * 0.8, 0.1),
|
|
117
|
+
opaque: true,
|
|
118
|
+
variant: "mark",
|
|
119
|
+
}));
|
|
120
|
+
await png("web/web-app-manifest-192x192.png", tileScene(brand, { size: 192, padding: tile.padding, opaque: false, variant: "mark" }));
|
|
121
|
+
await png("web/web-app-manifest-512x512.png", tileScene(brand, { size: 512, padding: tile.padding, opaque: false, variant: "mark" }));
|
|
122
|
+
await png("web/web-app-manifest-maskable-512x512.png", tileScene(brand, {
|
|
123
|
+
size: 512,
|
|
124
|
+
padding: Math.max(tile.padding, 0.22),
|
|
125
|
+
opaque: true,
|
|
126
|
+
variant: "mark",
|
|
127
|
+
}));
|
|
128
|
+
await addText("web/site.webmanifest", `${JSON.stringify(webManifest(brand), null, 2)}\n`, "application/manifest+json");
|
|
129
|
+
}
|
|
130
|
+
if (targets.has("logo")) {
|
|
131
|
+
const natural = (variant) => logoFragment(art, brand, variant, "color", brand.colors.light.ground);
|
|
132
|
+
const layouts = {
|
|
133
|
+
horizontal: "horizontal",
|
|
134
|
+
stacked: "stacked",
|
|
135
|
+
symbol: "mark",
|
|
136
|
+
name: "wordmark",
|
|
137
|
+
};
|
|
138
|
+
for (const [name, variant] of Object.entries(layouts)) {
|
|
139
|
+
const fragment = natural(variant);
|
|
140
|
+
const height = variant === "horizontal" || variant === "wordmark" ? 128 : 256;
|
|
141
|
+
const width = Math.round((height * fragment.w) / fragment.h);
|
|
142
|
+
// The big PNG is 4x, or as large as a scene may be (8192 px, with room for the
|
|
143
|
+
// brand-color padding) when a long name makes the lockup very wide.
|
|
144
|
+
const big = Math.min(4, 7680 / width);
|
|
145
|
+
for (const tone of ["color", "ink", "white", "brand"]) {
|
|
146
|
+
const base = `logo/${name}-${tone === "brand" ? "on-brand" : tone}`;
|
|
147
|
+
await svgFile(`${base}.svg`, logoScene(variant, tone, width, height));
|
|
148
|
+
await png(`${base}.png`, logoScene(variant, tone, width, height));
|
|
149
|
+
await png(`${base}@4x.png`, logoScene(variant, tone, Math.round(width * big), Math.round(height * big)));
|
|
150
|
+
}
|
|
151
|
+
if (name === "horizontal") {
|
|
152
|
+
await svgFile(`logo/${name}-color-dark.svg`, logoScene(variant, "color", width, height, "dark"));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const mark = natural("mark");
|
|
156
|
+
const markWidth = Math.round((256 * mark.w) / mark.h);
|
|
157
|
+
await svgFile("logo/mark.svg", logoScene("mark", "color", markWidth, 256));
|
|
158
|
+
await svgFile("logo/mark-dark.svg", logoScene("mark", "color", markWidth, 256, "dark"));
|
|
159
|
+
const horizontal = natural("horizontal");
|
|
160
|
+
const logoWidth = Math.round((128 * horizontal.w) / horizontal.h);
|
|
161
|
+
await svgFile("logo/logo.svg", logoScene("horizontal", "color", logoWidth, 128));
|
|
162
|
+
await svgFile("logo/logo-dark.svg", logoScene("horizontal", "color", logoWidth, 128, "dark"));
|
|
163
|
+
await svgFile("logo/small.svg", logoScene("small", "color", 32, 32));
|
|
164
|
+
await png("logo/small-16.png", logoScene("small", "color", 16, 16));
|
|
165
|
+
await png("logo/small-32.png", logoScene("small", "color", 32, 32));
|
|
166
|
+
await png("logo/icon-1024.png", tileScene(brand, { size: 1024, padding: tile.padding, opaque: true, variant: "mark" }));
|
|
167
|
+
}
|
|
168
|
+
if (targets.has("og")) {
|
|
169
|
+
// og.png is the default language's card (the fleet's name); each language also has its own.
|
|
170
|
+
const cards = [
|
|
171
|
+
["og/og", brand.defaultLocale],
|
|
172
|
+
...brand.locales.map((locale) => [`og/og.${locale}`, locale]),
|
|
173
|
+
];
|
|
174
|
+
for (const [base, locale] of cards) {
|
|
175
|
+
await png(`${base}.png`, ogScene(brand, locale));
|
|
176
|
+
await addText(`${base}.alt.txt`, `${ogAlt(brand, locale)}\n`, "text/plain");
|
|
177
|
+
}
|
|
178
|
+
for (const page of options.pages ?? []) {
|
|
179
|
+
for (const locale of brand.locales) {
|
|
180
|
+
const copy = page.copy[locale];
|
|
181
|
+
if (!copy)
|
|
182
|
+
continue;
|
|
183
|
+
const path = `og/${page.slug}.${locale}.png`;
|
|
184
|
+
await png(path, ogScene(brand, locale, copy));
|
|
185
|
+
await addText(path.replace(/\.png$/, ".alt.txt"), `${ogAlt(brand, locale, copy.title)}\n`, "text/plain");
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (targets.has("social")) {
|
|
190
|
+
const locale = brand.defaultLocale;
|
|
191
|
+
for (const [path, size] of Object.entries(AVATARS))
|
|
192
|
+
await png(`social/${path}`, avatarScene(brand, size));
|
|
193
|
+
for (const [path, banner] of Object.entries(BANNERS))
|
|
194
|
+
await png(`social/${path}`, bannerScene(brand, locale, banner.w, banner.h, banner.safe));
|
|
195
|
+
for (const [name, icon] of Object.entries(HIGHLIGHTS))
|
|
196
|
+
await png(`social/instagram/highlight-${name}.png`, highlightScene(icon));
|
|
197
|
+
await png("social/instagram/post-background.png", backgroundScene(1080, 1350, 80));
|
|
198
|
+
await png("social/instagram/story-background.png", backgroundScene(1080, 1920, 340));
|
|
199
|
+
await png("social/whatsapp/status-background.png", backgroundScene(1080, 1920, 340));
|
|
200
|
+
await png("social/whatsapp/catalog.png", catalogScene(brand, locale));
|
|
201
|
+
}
|
|
202
|
+
if (targets.has("tokens")) {
|
|
203
|
+
await addText("tokens/tokens.css", tokensCss(brand, { dark: options.dark }), "text/css");
|
|
204
|
+
await addText("tokens/tailwind.css", tailwindCss(brand), "text/css");
|
|
205
|
+
await addText("tokens/brand.tokens.json", `${JSON.stringify(dtcgTokens(brand), null, 2)}\n`, "application/design-tokens+json");
|
|
206
|
+
await addText("tokens/fonts.css", fontsCss(brand), "text/css");
|
|
207
|
+
const notices = new Set();
|
|
208
|
+
for (const role of ["display", "text"]) {
|
|
209
|
+
const font = brand.fonts[role];
|
|
210
|
+
for (const weight of font.weights) {
|
|
211
|
+
const path = `tokens/fonts/${font.id}-${weight}.woff2`;
|
|
212
|
+
if (files.has(path))
|
|
213
|
+
continue;
|
|
214
|
+
await add(path, await options.fonts.load({ id: font.id, weight, format: "woff2" }), "font/woff2");
|
|
215
|
+
const loaded = await engine.font({ id: font.id, weight });
|
|
216
|
+
notices.add(`${font.family}: ${loaded.copyright || `the ${font.family} authors`}`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const licenseText = brand.fonts.display.license === "Apache-2.0" ||
|
|
220
|
+
brand.fonts.text.license === "Apache-2.0"
|
|
221
|
+
? `${OFL_TEXT}\nFonts marked Apache-2.0 are under the Apache License, Version 2.0: https://www.apache.org/licenses/LICENSE-2.0\n`
|
|
222
|
+
: OFL_TEXT;
|
|
223
|
+
await addText("tokens/OFL.txt", `${[...notices].join("\n")}\n\n${licenseText}`, "text/plain");
|
|
224
|
+
}
|
|
225
|
+
if (targets.has("registry")) {
|
|
226
|
+
await addText("registry/theme.json", `${JSON.stringify(registryTheme(brand), null, 2)}\n`, "application/json");
|
|
227
|
+
}
|
|
228
|
+
if (targets.has("docs")) {
|
|
229
|
+
await addText("docs/brand.md", brandMd(brand), "text/markdown");
|
|
230
|
+
await addText("docs/brand.json", `${JSON.stringify(publicBrand(brand), null, 4)}\n`, "application/json");
|
|
231
|
+
const ogImage = options.filesUrl
|
|
232
|
+
? `${options.filesUrl.replace(/\/?$/, "/")}og/og.png`
|
|
233
|
+
: undefined;
|
|
234
|
+
await addText("docs/head.html", `${headTags(brand, { ogImage, dark: options.dark }).join("\n")}\n`, "text/html");
|
|
235
|
+
await addText("docs/prompt.md", promptMd(brand, { filesUrl: options.filesUrl }), "text/markdown");
|
|
236
|
+
}
|
|
237
|
+
if (targets.has("email")) {
|
|
238
|
+
await png("email/email-logo@2x.png", emailLogoScene());
|
|
239
|
+
const logoUrl = options.filesUrl
|
|
240
|
+
? `${options.filesUrl.replace(/\/?$/, "/")}email/email-logo@2x.png`
|
|
241
|
+
: "email-logo@2x.png";
|
|
242
|
+
await addText("email/signature.html", signatureHtml(brand, { logoUrl }), "text/html");
|
|
243
|
+
}
|
|
244
|
+
if (targets.has("print")) {
|
|
245
|
+
const dpi = dpiChunk(300);
|
|
246
|
+
for (const size of Object.keys(CARD_SIZES)) {
|
|
247
|
+
await png(`print/business-card-${size}-front.png`, cardFrontScene(size), [dpi]);
|
|
248
|
+
await png(`print/business-card-${size}-back.png`, cardBackScene(brand, size, brand.defaultLocale), [dpi]);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (refusals.length)
|
|
252
|
+
throw new GenerateRefusal(refusals);
|
|
253
|
+
return files;
|
|
254
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PNG and ICO bytes in pure JS: read a PNG's size, add provenance chunks, pack an .ico. No
|
|
3
|
+
* Buffer, so it runs in the browser too.
|
|
4
|
+
*/
|
|
5
|
+
export declare function isPng(bytes: Uint8Array): boolean;
|
|
6
|
+
/** Width and height from the IHDR chunk. */
|
|
7
|
+
export declare function pngSize(bytes: Uint8Array): {
|
|
8
|
+
width: number;
|
|
9
|
+
height: number;
|
|
10
|
+
};
|
|
11
|
+
/** One PNG chunk: length, type, data and CRC. */
|
|
12
|
+
export declare function pngChunk(type: string, data: Uint8Array): Uint8Array;
|
|
13
|
+
/** A tEXt chunk: a Latin-1 keyword and text (PNG spec 11.3.4.3). */
|
|
14
|
+
export declare function textChunk(keyword: string, text: string): Uint8Array;
|
|
15
|
+
/** An uncompressed iTXt chunk with UTF-8 text, the home of XMP (`XML:com.adobe.xmp`). */
|
|
16
|
+
export declare function itxtChunk(keyword: string, text: string): Uint8Array;
|
|
17
|
+
/** A pHYs chunk: the pixel density, so print shops read 300 dpi instead of guessing. */
|
|
18
|
+
export declare function dpiChunk(dpi: number): Uint8Array;
|
|
19
|
+
/** Insert chunks right after IHDR, so they come before the image data. */
|
|
20
|
+
export declare function withChunks(png: Uint8Array, chunks: Uint8Array[]): Uint8Array;
|
|
21
|
+
/**
|
|
22
|
+
* The XMP packet that marks a picture as made by a generative model, with the IPTC digital
|
|
23
|
+
* source type the EU AI Act's Art. 50 labelling points at.
|
|
24
|
+
*/
|
|
25
|
+
export declare function aiProvenanceXmp(tool: string): string;
|
|
26
|
+
/**
|
|
27
|
+
* Pack PNGs (16, 32 and 48 px, say) into one .ico. Each entry holds the PNG as it is, which every
|
|
28
|
+
* browser since IE9 reads: 2 KB where a BMP-entry packer writes 15 KB. Ported from enarro's
|
|
29
|
+
* gen-brand-assets.ts.
|
|
30
|
+
*/
|
|
31
|
+
export declare function packIco(pngs: readonly Uint8Array[]): Uint8Array;
|
|
32
|
+
/** Read an .ico back into its entries (for checks and tests). */
|
|
33
|
+
export declare function readIco(bytes: Uint8Array): {
|
|
34
|
+
width: number;
|
|
35
|
+
height: number;
|
|
36
|
+
png: Uint8Array;
|
|
37
|
+
}[];
|
|
38
|
+
/** sha256 of some bytes, as lowercase hex. */
|
|
39
|
+
export declare function sha256Hex(bytes: Uint8Array | string): Promise<string>;
|
|
40
|
+
export declare function base64(bytes: Uint8Array): string;
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PNG and ICO bytes in pure JS: read a PNG's size, add provenance chunks, pack an .ico. No
|
|
3
|
+
* Buffer, so it runs in the browser too.
|
|
4
|
+
*/
|
|
5
|
+
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
6
|
+
const CRC_TABLE = (() => {
|
|
7
|
+
const table = new Uint32Array(256);
|
|
8
|
+
for (let n = 0; n < 256; n++) {
|
|
9
|
+
let c = n;
|
|
10
|
+
for (let k = 0; k < 8; k++)
|
|
11
|
+
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
12
|
+
table[n] = c >>> 0;
|
|
13
|
+
}
|
|
14
|
+
return table;
|
|
15
|
+
})();
|
|
16
|
+
function crc32(bytes) {
|
|
17
|
+
let crc = 0xffffffff;
|
|
18
|
+
for (const byte of bytes)
|
|
19
|
+
crc = (CRC_TABLE[(crc ^ byte) & 0xff] ?? 0) ^ (crc >>> 8);
|
|
20
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
21
|
+
}
|
|
22
|
+
export function isPng(bytes) {
|
|
23
|
+
return bytes.length >= 24 && PNG_SIGNATURE.every((value, index) => bytes[index] === value);
|
|
24
|
+
}
|
|
25
|
+
/** Width and height from the IHDR chunk. */
|
|
26
|
+
export function pngSize(bytes) {
|
|
27
|
+
if (!isPng(bytes))
|
|
28
|
+
throw new TypeError("Not a PNG: the signature is missing");
|
|
29
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
30
|
+
return { width: view.getUint32(16), height: view.getUint32(20) };
|
|
31
|
+
}
|
|
32
|
+
/** One PNG chunk: length, type, data and CRC. */
|
|
33
|
+
export function pngChunk(type, data) {
|
|
34
|
+
const out = new Uint8Array(12 + data.length);
|
|
35
|
+
const view = new DataView(out.buffer);
|
|
36
|
+
view.setUint32(0, data.length);
|
|
37
|
+
for (let i = 0; i < 4; i++)
|
|
38
|
+
out[4 + i] = type.charCodeAt(i);
|
|
39
|
+
out.set(data, 8);
|
|
40
|
+
view.setUint32(8 + data.length, crc32(out.subarray(4, 8 + data.length)));
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
function latin1(text) {
|
|
44
|
+
return Uint8Array.from(text, (char) => {
|
|
45
|
+
const code = char.charCodeAt(0);
|
|
46
|
+
return code < 256 ? code : 0x3f;
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
/** A tEXt chunk: a Latin-1 keyword and text (PNG spec 11.3.4.3). */
|
|
50
|
+
export function textChunk(keyword, text) {
|
|
51
|
+
const key = latin1(keyword.slice(0, 79));
|
|
52
|
+
const value = latin1(text);
|
|
53
|
+
const data = new Uint8Array(key.length + 1 + value.length);
|
|
54
|
+
data.set(key, 0);
|
|
55
|
+
data.set(value, key.length + 1);
|
|
56
|
+
return pngChunk("tEXt", data);
|
|
57
|
+
}
|
|
58
|
+
/** An uncompressed iTXt chunk with UTF-8 text, the home of XMP (`XML:com.adobe.xmp`). */
|
|
59
|
+
export function itxtChunk(keyword, text) {
|
|
60
|
+
const key = latin1(keyword);
|
|
61
|
+
const value = new TextEncoder().encode(text);
|
|
62
|
+
// keyword \0, compression flag 0, method 0, empty language \0, empty translated keyword \0.
|
|
63
|
+
const data = new Uint8Array(key.length + 5 + value.length);
|
|
64
|
+
data.set(key, 0);
|
|
65
|
+
data.set(value, key.length + 5);
|
|
66
|
+
return pngChunk("iTXt", data);
|
|
67
|
+
}
|
|
68
|
+
/** A pHYs chunk: the pixel density, so print shops read 300 dpi instead of guessing. */
|
|
69
|
+
export function dpiChunk(dpi) {
|
|
70
|
+
const data = new Uint8Array(9);
|
|
71
|
+
const view = new DataView(data.buffer);
|
|
72
|
+
const perMeter = Math.round(dpi / 0.0254);
|
|
73
|
+
view.setUint32(0, perMeter);
|
|
74
|
+
view.setUint32(4, perMeter);
|
|
75
|
+
data[8] = 1; // the unit is the meter
|
|
76
|
+
return pngChunk("pHYs", data);
|
|
77
|
+
}
|
|
78
|
+
/** Insert chunks right after IHDR, so they come before the image data. */
|
|
79
|
+
export function withChunks(png, chunks) {
|
|
80
|
+
if (!isPng(png))
|
|
81
|
+
throw new TypeError("Not a PNG: the signature is missing");
|
|
82
|
+
const afterHeader = 8 + 25; // signature + IHDR (4 length + 4 type + 13 data + 4 crc)
|
|
83
|
+
const extra = chunks.reduce((sum, item) => sum + item.length, 0);
|
|
84
|
+
const out = new Uint8Array(png.length + extra);
|
|
85
|
+
out.set(png.subarray(0, afterHeader), 0);
|
|
86
|
+
let offset = afterHeader;
|
|
87
|
+
for (const item of chunks) {
|
|
88
|
+
out.set(item, offset);
|
|
89
|
+
offset += item.length;
|
|
90
|
+
}
|
|
91
|
+
out.set(png.subarray(afterHeader), offset);
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* The XMP packet that marks a picture as made by a generative model, with the IPTC digital
|
|
96
|
+
* source type the EU AI Act's Art. 50 labelling points at.
|
|
97
|
+
*/
|
|
98
|
+
export function aiProvenanceXmp(tool) {
|
|
99
|
+
return (`<?xpacket begin="\uFEFF" id="W5M0MpCehiHzreSzNTczkc9d"?>` +
|
|
100
|
+
`<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">` +
|
|
101
|
+
`<rdf:Description rdf:about="" xmlns:Iptc4xmpExt="http://iptc.org/std/Iptc4xmpExt/2008-02-29/" xmlns:xmp="http://ns.adobe.com/xap/1.0/" ` +
|
|
102
|
+
`Iptc4xmpExt:DigitalSourceType="http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia" xmp:CreatorTool="${tool.replace(/[<>&"]/g, "")}"/>` +
|
|
103
|
+
`</rdf:RDF></x:xmpmeta><?xpacket end="r"?>`);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Pack PNGs (16, 32 and 48 px, say) into one .ico. Each entry holds the PNG as it is, which every
|
|
107
|
+
* browser since IE9 reads: 2 KB where a BMP-entry packer writes 15 KB. Ported from enarro's
|
|
108
|
+
* gen-brand-assets.ts.
|
|
109
|
+
*/
|
|
110
|
+
export function packIco(pngs) {
|
|
111
|
+
if (pngs.length === 0 || pngs.length > 255)
|
|
112
|
+
throw new RangeError("packIco takes 1 to 255 PNGs");
|
|
113
|
+
const headerSize = 6 + pngs.length * 16;
|
|
114
|
+
const total = headerSize + pngs.reduce((sum, png) => sum + png.byteLength, 0);
|
|
115
|
+
const out = new Uint8Array(total);
|
|
116
|
+
const view = new DataView(out.buffer);
|
|
117
|
+
view.setUint16(0, 0, true); // reserved
|
|
118
|
+
view.setUint16(2, 1, true); // type: icon
|
|
119
|
+
view.setUint16(4, pngs.length, true);
|
|
120
|
+
let dataOffset = headerSize;
|
|
121
|
+
pngs.forEach((png, index) => {
|
|
122
|
+
const { width, height } = pngSize(png);
|
|
123
|
+
if (width < 1 || height < 1 || width > 256 || height > 256) {
|
|
124
|
+
throw new RangeError(`An .ico entry is 1 to 256 px wide, not ${width}x${height}`);
|
|
125
|
+
}
|
|
126
|
+
const entry = 6 + index * 16;
|
|
127
|
+
out[entry] = width >= 256 ? 0 : width;
|
|
128
|
+
out[entry + 1] = height >= 256 ? 0 : height;
|
|
129
|
+
view.setUint16(entry + 4, 1, true); // color planes
|
|
130
|
+
view.setUint16(entry + 6, 32, true); // bits per pixel
|
|
131
|
+
view.setUint32(entry + 8, png.byteLength, true);
|
|
132
|
+
view.setUint32(entry + 12, dataOffset, true);
|
|
133
|
+
out.set(png, dataOffset);
|
|
134
|
+
dataOffset += png.byteLength;
|
|
135
|
+
});
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
/** Read an .ico back into its entries (for checks and tests). */
|
|
139
|
+
export function readIco(bytes) {
|
|
140
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
141
|
+
if (view.getUint16(0, true) !== 0 || view.getUint16(2, true) !== 1) {
|
|
142
|
+
throw new TypeError("Not an .ico file");
|
|
143
|
+
}
|
|
144
|
+
const count = view.getUint16(4, true);
|
|
145
|
+
return Array.from({ length: count }, (_, index) => {
|
|
146
|
+
const entry = 6 + index * 16;
|
|
147
|
+
const size = view.getUint32(entry + 8, true);
|
|
148
|
+
const offset = view.getUint32(entry + 12, true);
|
|
149
|
+
return {
|
|
150
|
+
width: bytes[entry] || 256,
|
|
151
|
+
height: bytes[entry + 1] || 256,
|
|
152
|
+
png: bytes.subarray(offset, offset + size),
|
|
153
|
+
};
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
/** sha256 of some bytes, as lowercase hex. */
|
|
157
|
+
export async function sha256Hex(bytes) {
|
|
158
|
+
const data = typeof bytes === "string" ? new TextEncoder().encode(bytes) : new Uint8Array(bytes);
|
|
159
|
+
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", data));
|
|
160
|
+
return Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
161
|
+
}
|
|
162
|
+
export function base64(bytes) {
|
|
163
|
+
let binary = "";
|
|
164
|
+
for (let i = 0; i < bytes.length; i += 0x8000) {
|
|
165
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
|
166
|
+
}
|
|
167
|
+
return btoa(binary);
|
|
168
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turning SVG into pixels is a plug-in, so the core stays pure: `@ideare/kit/resvg` on Node and
|
|
3
|
+
* Bun (native resvg-js), and `@ideare/kit/wasm` (resvg-wasm) in the browser and the CLI.
|
|
4
|
+
*/
|
|
5
|
+
export type Raster = {
|
|
6
|
+
png: Uint8Array;
|
|
7
|
+
/** Straight RGBA, row by row, for checks that read pixels. */
|
|
8
|
+
pixels: Uint8Array;
|
|
9
|
+
width: number;
|
|
10
|
+
height: number;
|
|
11
|
+
};
|
|
12
|
+
export type Rasterizer = (svg: string, size: {
|
|
13
|
+
width: number;
|
|
14
|
+
}) => Promise<Raster>;
|
|
File without changes
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import type { Brand, Locale } from "../brand/schema.ts";
|
|
2
|
+
import type { Scene, SolidPaint } from "../scene/schema.ts";
|
|
3
|
+
/**
|
|
4
|
+
* The scenes behind every derived file. Each one is plain data, so the studio can show the same
|
|
5
|
+
* scene in the kit grid and the editor can open any of them as a starting design.
|
|
6
|
+
*/
|
|
7
|
+
export declare const TRANSPARENT: SolidPaint;
|
|
8
|
+
type Theme = "light" | "dark";
|
|
9
|
+
export type TileSpec = {
|
|
10
|
+
size: number;
|
|
11
|
+
/** Space around the mark, as a fraction of the side. */
|
|
12
|
+
padding: number;
|
|
13
|
+
/** Full-bleed and opaque (apple, maskable) or the brand's tile shape on transparent. */
|
|
14
|
+
opaque: boolean;
|
|
15
|
+
variant: "mark" | "small";
|
|
16
|
+
theme?: Theme;
|
|
17
|
+
};
|
|
18
|
+
/** The mark on the brand's tile, for favicons and app icons. */
|
|
19
|
+
export declare function tileScene(brand: Brand, spec: TileSpec): Scene;
|
|
20
|
+
/** A profile picture: the mark inside the circle every network crops to. */
|
|
21
|
+
export declare function avatarScene(brand: Brand, size: number): Scene;
|
|
22
|
+
/** A logo version on its own: transparent, or on the brand color. */
|
|
23
|
+
export declare function logoScene(variant: "mark" | "small" | "wordmark" | "horizontal" | "stacked", tone: "color" | "ink" | "white" | "brand", w: number, h: number, theme?: Theme): Scene;
|
|
24
|
+
export type CardCopy = {
|
|
25
|
+
title: string;
|
|
26
|
+
subtitle?: string;
|
|
27
|
+
};
|
|
28
|
+
/** The 1200 by 630 share card, in one language. */
|
|
29
|
+
export declare function ogScene(brand: Brand, locale: Locale, copy?: CardCopy): Scene;
|
|
30
|
+
/** A cover or banner: the logo and the tagline centered inside the network's safe area. */
|
|
31
|
+
export declare function bannerScene(brand: Brand, locale: Locale, w: number, h: number, safe: {
|
|
32
|
+
x: number;
|
|
33
|
+
y: number;
|
|
34
|
+
w: number;
|
|
35
|
+
h: number;
|
|
36
|
+
}): Scene;
|
|
37
|
+
/** A soft branded background for posts, stories and status, with the mark in a corner. */
|
|
38
|
+
export declare function backgroundScene(w: number, h: number, bottomSafe: number): Scene;
|
|
39
|
+
/** An Instagram highlight cover: one icon in the circle Instagram shows. */
|
|
40
|
+
export declare function highlightScene(icon: string): Scene;
|
|
41
|
+
/** A square catalog image: the stacked logo and the tagline. */
|
|
42
|
+
export declare function catalogScene(brand: Brand, locale: Locale): Scene;
|
|
43
|
+
/** The email logo: an opaque bar at 2x, so Gmail's dark mode cannot invert a transparent logo. */
|
|
44
|
+
export declare function emailLogoScene(): Scene;
|
|
45
|
+
export declare const CARD_SIZES: {
|
|
46
|
+
readonly br: {
|
|
47
|
+
readonly w: 90;
|
|
48
|
+
readonly h: 50;
|
|
49
|
+
readonly label: "Brazil, 90 by 50 mm";
|
|
50
|
+
};
|
|
51
|
+
readonly us: {
|
|
52
|
+
readonly w: 88.9;
|
|
53
|
+
readonly h: 50.8;
|
|
54
|
+
readonly label: "US, 3.5 by 2 in";
|
|
55
|
+
};
|
|
56
|
+
readonly eu: {
|
|
57
|
+
readonly w: 85;
|
|
58
|
+
readonly h: 55;
|
|
59
|
+
readonly label: "Europe, 85 by 55 mm";
|
|
60
|
+
};
|
|
61
|
+
};
|
|
62
|
+
export type CardSize = keyof typeof CARD_SIZES;
|
|
63
|
+
export declare const BLEED_MM = 3;
|
|
64
|
+
/** The canvas of a business card at 300 dpi, bleed included, and its safe box. */
|
|
65
|
+
export declare function cardCanvas(size: CardSize): {
|
|
66
|
+
w: number;
|
|
67
|
+
h: number;
|
|
68
|
+
safe: {
|
|
69
|
+
x: number;
|
|
70
|
+
y: number;
|
|
71
|
+
w: number;
|
|
72
|
+
h: number;
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
/** The front: the stacked logo on the brand color, to the bleed. */
|
|
76
|
+
export declare function cardFrontScene(size: CardSize): Scene;
|
|
77
|
+
/** The back: the name, the tagline and the public contacts. */
|
|
78
|
+
export declare function cardBackScene(brand: Brand, size: CardSize, locale: Locale): Scene;
|
|
79
|
+
/** Safe areas per network, in px of each canvas (research: brandgen, section 5). */
|
|
80
|
+
export declare const BANNERS: {
|
|
81
|
+
readonly "x/header-1500x500.png": {
|
|
82
|
+
readonly w: 1500;
|
|
83
|
+
readonly h: 500;
|
|
84
|
+
readonly safe: {
|
|
85
|
+
readonly x: 420;
|
|
86
|
+
readonly y: 70;
|
|
87
|
+
readonly w: 1020;
|
|
88
|
+
readonly h: 360;
|
|
89
|
+
};
|
|
90
|
+
};
|
|
91
|
+
readonly "facebook/cover-851x315.png": {
|
|
92
|
+
readonly w: 851;
|
|
93
|
+
readonly h: 315;
|
|
94
|
+
readonly safe: {
|
|
95
|
+
readonly x: 146;
|
|
96
|
+
readonly y: 40;
|
|
97
|
+
readonly w: 560;
|
|
98
|
+
readonly h: 235;
|
|
99
|
+
};
|
|
100
|
+
};
|
|
101
|
+
readonly "linkedin/cover-1512x256.png": {
|
|
102
|
+
readonly w: 1512;
|
|
103
|
+
readonly h: 256;
|
|
104
|
+
readonly safe: {
|
|
105
|
+
readonly x: 360;
|
|
106
|
+
readonly y: 40;
|
|
107
|
+
readonly w: 1092;
|
|
108
|
+
readonly h: 176;
|
|
109
|
+
};
|
|
110
|
+
};
|
|
111
|
+
readonly "linkedin/personal-1584x396.png": {
|
|
112
|
+
readonly w: 1584;
|
|
113
|
+
readonly h: 396;
|
|
114
|
+
readonly safe: {
|
|
115
|
+
readonly x: 420;
|
|
116
|
+
readonly y: 60;
|
|
117
|
+
readonly w: 1104;
|
|
118
|
+
readonly h: 276;
|
|
119
|
+
};
|
|
120
|
+
};
|
|
121
|
+
readonly "youtube/banner-2560x1440.png": {
|
|
122
|
+
readonly w: 2560;
|
|
123
|
+
readonly h: 1440;
|
|
124
|
+
readonly safe: {
|
|
125
|
+
readonly x: 507;
|
|
126
|
+
readonly y: 509;
|
|
127
|
+
readonly w: 1546;
|
|
128
|
+
readonly h: 423;
|
|
129
|
+
};
|
|
130
|
+
};
|
|
131
|
+
readonly "google/cover-1024x576.png": {
|
|
132
|
+
readonly w: 1024;
|
|
133
|
+
readonly h: 576;
|
|
134
|
+
readonly safe: {
|
|
135
|
+
readonly x: 102;
|
|
136
|
+
readonly y: 58;
|
|
137
|
+
readonly w: 820;
|
|
138
|
+
readonly h: 460;
|
|
139
|
+
};
|
|
140
|
+
};
|
|
141
|
+
};
|
|
142
|
+
export declare const AVATARS: {
|
|
143
|
+
readonly "avatar-1080.png": 1080;
|
|
144
|
+
readonly "instagram/avatar-320.png": 320;
|
|
145
|
+
readonly "whatsapp/avatar-640.png": 640;
|
|
146
|
+
readonly "google/logo-720.png": 720;
|
|
147
|
+
readonly "x/avatar-400.png": 400;
|
|
148
|
+
readonly "facebook/profile-320.png": 320;
|
|
149
|
+
readonly "linkedin/logo-400.png": 400;
|
|
150
|
+
readonly "youtube/avatar-800.png": 800;
|
|
151
|
+
readonly "tiktok/avatar-200.png": 200;
|
|
152
|
+
};
|
|
153
|
+
/** Instagram highlight covers: file name, icon. */
|
|
154
|
+
export declare const HIGHLIGHTS: {
|
|
155
|
+
readonly about: "info";
|
|
156
|
+
readonly hours: "clock";
|
|
157
|
+
readonly location: "map-pin";
|
|
158
|
+
readonly offers: "tag";
|
|
159
|
+
readonly contact: "message-circle";
|
|
160
|
+
readonly reviews: "star";
|
|
161
|
+
};
|
|
162
|
+
export {};
|