@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,128 @@
|
|
|
1
|
+
import type { FontKey, FontResolver } from "./fonts.ts";
|
|
2
|
+
/**
|
|
3
|
+
* The text engine: HarfBuzz shapes text into glyph paths, so line breaks, fit and pixels come
|
|
4
|
+
* from one computation in the browser and on the server (docs/spikes/text.md: byte-identical
|
|
5
|
+
* SVG in Bun and Chrome). resvg never sees a <text> element.
|
|
6
|
+
*/
|
|
7
|
+
type Harfbuzz = typeof import("harfbuzzjs");
|
|
8
|
+
type HbFont = InstanceType<Harfbuzz["Font"]>;
|
|
9
|
+
/** harfbuzzjs loads 444 kB of wasm at import, so it is imported on first use only. */
|
|
10
|
+
export declare function loadHarfbuzz(): Promise<Harfbuzz>;
|
|
11
|
+
type PathCommand = {
|
|
12
|
+
type: string;
|
|
13
|
+
values: number[];
|
|
14
|
+
};
|
|
15
|
+
/** A font ready to shape: the HarfBuzz objects plus the numbers layout needs. */
|
|
16
|
+
export type LoadedFont = {
|
|
17
|
+
key: FontKey;
|
|
18
|
+
upem: number;
|
|
19
|
+
ascender: number;
|
|
20
|
+
descender: number;
|
|
21
|
+
/** The code points the file has glyphs for. */
|
|
22
|
+
coverage: Set<number>;
|
|
23
|
+
/** The copyright and license strings from the name table (ids 0 and 13). */
|
|
24
|
+
copyright: string;
|
|
25
|
+
license: string;
|
|
26
|
+
/** The file itself, for hashing and for fonts.css. */
|
|
27
|
+
bytes: Uint8Array;
|
|
28
|
+
hb: HbFont;
|
|
29
|
+
paths: Map<number, PathCommand[]>;
|
|
30
|
+
};
|
|
31
|
+
type ShapedGlyph = {
|
|
32
|
+
gid: number;
|
|
33
|
+
x: number;
|
|
34
|
+
dx: number;
|
|
35
|
+
dy: number;
|
|
36
|
+
cluster: number;
|
|
37
|
+
};
|
|
38
|
+
/** One shaped run at a size: glyph positions in px from the run's start, and its width. */
|
|
39
|
+
export type Run = {
|
|
40
|
+
glyphs: ShapedGlyph[];
|
|
41
|
+
width: number;
|
|
42
|
+
missing: string[];
|
|
43
|
+
};
|
|
44
|
+
export type TextAlign = "start" | "center" | "end";
|
|
45
|
+
export type TextVAlign = "top" | "middle" | "bottom";
|
|
46
|
+
export type TextBlock = {
|
|
47
|
+
text: string;
|
|
48
|
+
font: LoadedFont;
|
|
49
|
+
/** px */
|
|
50
|
+
size: number;
|
|
51
|
+
/** Line height as a multiple of the size. */
|
|
52
|
+
lineHeight: number;
|
|
53
|
+
/** Letter spacing in em. */
|
|
54
|
+
tracking?: number;
|
|
55
|
+
align?: TextAlign;
|
|
56
|
+
valign?: TextVAlign;
|
|
57
|
+
/** The box the text must stay inside, in px. */
|
|
58
|
+
box: {
|
|
59
|
+
x: number;
|
|
60
|
+
y: number;
|
|
61
|
+
w: number;
|
|
62
|
+
h: number;
|
|
63
|
+
};
|
|
64
|
+
maxLines?: number;
|
|
65
|
+
/** shrink: the largest size from `min` to `size` that fits. none: `size` or an overflow. */
|
|
66
|
+
fit?: {
|
|
67
|
+
mode: "none" | "shrink";
|
|
68
|
+
min: number;
|
|
69
|
+
};
|
|
70
|
+
locale?: string;
|
|
71
|
+
uppercase?: boolean;
|
|
72
|
+
};
|
|
73
|
+
export type TextOverflow = {
|
|
74
|
+
/** width: a word is wider than the box. lines: more lines than allowed. height: too tall. */
|
|
75
|
+
reason: "width" | "lines" | "height";
|
|
76
|
+
/** The size the text was laid out at, after shrinking. */
|
|
77
|
+
size: number;
|
|
78
|
+
/** What would be needed: the widest word's width, the line count or the block height. */
|
|
79
|
+
needed: number;
|
|
80
|
+
/** What the box allows. */
|
|
81
|
+
allowed: number;
|
|
82
|
+
};
|
|
83
|
+
export type TextLayout = {
|
|
84
|
+
/** One `d` for the whole block, in the scene's px. Empty when there is no visible glyph. */
|
|
85
|
+
d: string;
|
|
86
|
+
size: number;
|
|
87
|
+
lines: {
|
|
88
|
+
text: string;
|
|
89
|
+
width: number;
|
|
90
|
+
x: number;
|
|
91
|
+
baseline: number;
|
|
92
|
+
}[];
|
|
93
|
+
/** The block's box: the lines' extent, not the frame. */
|
|
94
|
+
bounds: {
|
|
95
|
+
x: number;
|
|
96
|
+
y: number;
|
|
97
|
+
w: number;
|
|
98
|
+
h: number;
|
|
99
|
+
};
|
|
100
|
+
overflow?: TextOverflow;
|
|
101
|
+
/** Characters the font has no glyph for. They are never drawn as a box. */
|
|
102
|
+
missing: string[];
|
|
103
|
+
};
|
|
104
|
+
/** Round for output: 2 decimals keeps paths small and identical on every platform. */
|
|
105
|
+
export declare function fmt(value: number): string;
|
|
106
|
+
export declare class TextEngine {
|
|
107
|
+
private readonly fonts;
|
|
108
|
+
private buffer;
|
|
109
|
+
private readonly runs;
|
|
110
|
+
private readonly hb;
|
|
111
|
+
private readonly resolver;
|
|
112
|
+
private constructor();
|
|
113
|
+
static create(resolver: FontResolver): Promise<TextEngine>;
|
|
114
|
+
/** Load (once) and prepare a font. */
|
|
115
|
+
font(key: FontKey): Promise<LoadedFont>;
|
|
116
|
+
/** Wrap bytes you already hold, for tests and for fonts outside any resolver. */
|
|
117
|
+
fromBytes(key: FontKey, bytes: Uint8Array): LoadedFont;
|
|
118
|
+
private prepare;
|
|
119
|
+
/** Shape one line of text at a size. Cached, since line breaking measures the same runs. */
|
|
120
|
+
shape(font: LoadedFont, text: string, size: number, tracking?: number): Run;
|
|
121
|
+
/** The outline of a shaped run as one path, starting at (x, baseline). */
|
|
122
|
+
runPath(font: LoadedFont, run: Run, size: number, x: number, baseline: number): string;
|
|
123
|
+
/** Break, fit and outline a block of text. Overflow is reported, never cut or ellipsized. */
|
|
124
|
+
layout(block: TextBlock): TextLayout;
|
|
125
|
+
private breakLines;
|
|
126
|
+
private place;
|
|
127
|
+
}
|
|
128
|
+
export {};
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
let harfbuzz;
|
|
2
|
+
/** harfbuzzjs loads 444 kB of wasm at import, so it is imported on first use only. */
|
|
3
|
+
export function loadHarfbuzz() {
|
|
4
|
+
harfbuzz ??= import("harfbuzzjs");
|
|
5
|
+
return harfbuzz;
|
|
6
|
+
}
|
|
7
|
+
/** Round for output: 2 decimals keeps paths small and identical on every platform. */
|
|
8
|
+
export function fmt(value) {
|
|
9
|
+
const rounded = Math.round(value * 100) / 100;
|
|
10
|
+
return Object.is(rounded, -0) ? "0" : String(rounded);
|
|
11
|
+
}
|
|
12
|
+
export class TextEngine {
|
|
13
|
+
fonts = new Map();
|
|
14
|
+
buffer;
|
|
15
|
+
runs = new Map();
|
|
16
|
+
hb;
|
|
17
|
+
resolver;
|
|
18
|
+
constructor(hb, resolver) {
|
|
19
|
+
this.hb = hb;
|
|
20
|
+
this.resolver = resolver;
|
|
21
|
+
}
|
|
22
|
+
static async create(resolver) {
|
|
23
|
+
return new TextEngine(await loadHarfbuzz(), resolver);
|
|
24
|
+
}
|
|
25
|
+
/** Load (once) and prepare a font. */
|
|
26
|
+
font(key) {
|
|
27
|
+
const id = `${key.id}:${key.weight}:${key.style ?? "normal"}`;
|
|
28
|
+
let pending = this.fonts.get(id);
|
|
29
|
+
if (!pending) {
|
|
30
|
+
pending = this.resolver
|
|
31
|
+
.load({ ...key, format: "ttf" })
|
|
32
|
+
.then((bytes) => this.prepare(key, bytes));
|
|
33
|
+
pending.catch(() => this.fonts.delete(id));
|
|
34
|
+
this.fonts.set(id, pending);
|
|
35
|
+
}
|
|
36
|
+
return pending;
|
|
37
|
+
}
|
|
38
|
+
/** Wrap bytes you already hold, for tests and for fonts outside any resolver. */
|
|
39
|
+
fromBytes(key, bytes) {
|
|
40
|
+
return this.prepare(key, bytes);
|
|
41
|
+
}
|
|
42
|
+
prepare(key, bytes) {
|
|
43
|
+
const { hb } = this;
|
|
44
|
+
const face = new hb.Face(new hb.Blob(bytes), 0);
|
|
45
|
+
const font = new hb.Font(face);
|
|
46
|
+
const extents = font.hExtents();
|
|
47
|
+
const name = (id) => {
|
|
48
|
+
try {
|
|
49
|
+
return face.getName(id, "en") ?? "";
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return "";
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
return {
|
|
56
|
+
key,
|
|
57
|
+
upem: face.upem,
|
|
58
|
+
ascender: extents.ascender,
|
|
59
|
+
descender: extents.descender,
|
|
60
|
+
coverage: new Set(face.collectUnicodes()),
|
|
61
|
+
copyright: name(0),
|
|
62
|
+
license: name(13),
|
|
63
|
+
bytes,
|
|
64
|
+
hb: font,
|
|
65
|
+
paths: new Map(),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/** Shape one line of text at a size. Cached, since line breaking measures the same runs. */
|
|
69
|
+
shape(font, text, size, tracking = 0) {
|
|
70
|
+
const cacheKey = `${font.key.id}:${font.key.weight}:${size}:${tracking}:${text}`;
|
|
71
|
+
const cached = this.runs.get(cacheKey);
|
|
72
|
+
if (cached)
|
|
73
|
+
return cached;
|
|
74
|
+
const { hb } = this;
|
|
75
|
+
this.buffer ??= new hb.Buffer();
|
|
76
|
+
const buffer = this.buffer;
|
|
77
|
+
buffer.reset();
|
|
78
|
+
buffer.addText(text);
|
|
79
|
+
buffer.guessSegmentProperties();
|
|
80
|
+
hb.shape(font.hb, buffer);
|
|
81
|
+
const scale = size / font.upem;
|
|
82
|
+
const extra = tracking * size;
|
|
83
|
+
const glyphs = [];
|
|
84
|
+
const missing = new Set();
|
|
85
|
+
let x = 0;
|
|
86
|
+
const infos = buffer.getGlyphInfosAndPositions();
|
|
87
|
+
infos.forEach((info, index) => {
|
|
88
|
+
if (info.codepoint === 0) {
|
|
89
|
+
const char = String.fromCodePoint(text.codePointAt(info.cluster) ?? 0xfffd);
|
|
90
|
+
if (char.trim())
|
|
91
|
+
missing.add(char);
|
|
92
|
+
}
|
|
93
|
+
glyphs.push({
|
|
94
|
+
gid: info.codepoint,
|
|
95
|
+
x,
|
|
96
|
+
dx: (info.xOffset ?? 0) * scale,
|
|
97
|
+
dy: (info.yOffset ?? 0) * scale,
|
|
98
|
+
cluster: info.cluster,
|
|
99
|
+
});
|
|
100
|
+
x += (info.xAdvance ?? 0) * scale + (index < infos.length - 1 ? extra : 0);
|
|
101
|
+
});
|
|
102
|
+
const run = { glyphs, width: x, missing: [...missing] };
|
|
103
|
+
if (this.runs.size > 5000)
|
|
104
|
+
this.runs.clear();
|
|
105
|
+
this.runs.set(cacheKey, run);
|
|
106
|
+
return run;
|
|
107
|
+
}
|
|
108
|
+
/** The outline of a shaped run as one path, starting at (x, baseline). */
|
|
109
|
+
runPath(font, run, size, x, baseline) {
|
|
110
|
+
const scale = size / font.upem;
|
|
111
|
+
const parts = [];
|
|
112
|
+
for (const glyph of run.glyphs) {
|
|
113
|
+
if (glyph.gid === 0)
|
|
114
|
+
continue;
|
|
115
|
+
let commands = font.paths.get(glyph.gid);
|
|
116
|
+
if (!commands) {
|
|
117
|
+
commands = font.hb.glyphToJson(glyph.gid);
|
|
118
|
+
font.paths.set(glyph.gid, commands);
|
|
119
|
+
}
|
|
120
|
+
const ox = x + glyph.x + glyph.dx;
|
|
121
|
+
const oy = baseline - glyph.dy;
|
|
122
|
+
for (const command of commands) {
|
|
123
|
+
const values = command.values;
|
|
124
|
+
const points = [];
|
|
125
|
+
for (let i = 0; i + 1 < values.length; i += 2) {
|
|
126
|
+
points.push(`${fmt(ox + (values[i] ?? 0) * scale)} ${fmt(oy - (values[i + 1] ?? 0) * scale)}`);
|
|
127
|
+
}
|
|
128
|
+
parts.push(`${command.type}${points.join(" ")}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return parts.join("");
|
|
132
|
+
}
|
|
133
|
+
/** Break, fit and outline a block of text. Overflow is reported, never cut or ellipsized. */
|
|
134
|
+
layout(block) {
|
|
135
|
+
const text = block.uppercase ? block.text.toLocaleUpperCase(block.locale) : block.text;
|
|
136
|
+
const at = (size) => this.breakLines(block, text, size);
|
|
137
|
+
let size = block.size;
|
|
138
|
+
let attempt = at(size);
|
|
139
|
+
if (attempt.overflow && block.fit?.mode === "shrink") {
|
|
140
|
+
// The largest size on a half-pixel grid from `min` up that fits. When even `min`
|
|
141
|
+
// overflows, lay out at `min` and report it.
|
|
142
|
+
const min = Math.min(block.fit.min, block.size);
|
|
143
|
+
const steps = Math.floor((block.size - min) / 0.5);
|
|
144
|
+
const sizeOf = (step) => min + step * 0.5;
|
|
145
|
+
const smallest = at(min);
|
|
146
|
+
size = min;
|
|
147
|
+
attempt = smallest;
|
|
148
|
+
if (!smallest.overflow) {
|
|
149
|
+
let low = 0;
|
|
150
|
+
let high = steps;
|
|
151
|
+
while (low < high) {
|
|
152
|
+
const mid = Math.ceil((low + high) / 2);
|
|
153
|
+
if (at(sizeOf(mid)).overflow)
|
|
154
|
+
high = mid - 1;
|
|
155
|
+
else
|
|
156
|
+
low = mid;
|
|
157
|
+
}
|
|
158
|
+
size = sizeOf(low);
|
|
159
|
+
attempt = at(size);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return this.place(block, attempt.lines, size, attempt.overflow, attempt.missing);
|
|
163
|
+
}
|
|
164
|
+
breakLines(block, text, size) {
|
|
165
|
+
const tracking = block.tracking ?? 0;
|
|
166
|
+
const width = (line) => this.shape(block.font, line, size, tracking).width;
|
|
167
|
+
const maxWidth = block.box.w;
|
|
168
|
+
const lines = [];
|
|
169
|
+
let overflow;
|
|
170
|
+
const missing = new Set();
|
|
171
|
+
for (const paragraph of text.split("\n")) {
|
|
172
|
+
const words = paragraph.split(/[ \t]+/).filter(Boolean);
|
|
173
|
+
if (words.length === 0) {
|
|
174
|
+
lines.push({ text: "", width: 0 });
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
let current = "";
|
|
178
|
+
for (const word of words) {
|
|
179
|
+
const candidate = current ? `${current} ${word}` : word;
|
|
180
|
+
const candidateWidth = width(candidate);
|
|
181
|
+
if (!current || candidateWidth <= maxWidth) {
|
|
182
|
+
current = candidate;
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
lines.push({ text: current, width: width(current) });
|
|
186
|
+
current = word;
|
|
187
|
+
}
|
|
188
|
+
lines.push({ text: current, width: width(current) });
|
|
189
|
+
}
|
|
190
|
+
for (const line of lines) {
|
|
191
|
+
for (const char of this.shape(block.font, line.text, size, tracking).missing)
|
|
192
|
+
missing.add(char);
|
|
193
|
+
if (!overflow && line.width > maxWidth + 0.01) {
|
|
194
|
+
overflow = { reason: "width", size, needed: line.width, allowed: maxWidth };
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
const height = lines.length * size * block.lineHeight;
|
|
198
|
+
if (!overflow && block.maxLines !== undefined && lines.length > block.maxLines) {
|
|
199
|
+
overflow = { reason: "lines", size, needed: lines.length, allowed: block.maxLines };
|
|
200
|
+
}
|
|
201
|
+
if (!overflow && height > block.box.h + 0.01) {
|
|
202
|
+
overflow = { reason: "height", size, needed: height, allowed: block.box.h };
|
|
203
|
+
}
|
|
204
|
+
return { lines, overflow, missing: [...missing] };
|
|
205
|
+
}
|
|
206
|
+
place(block, lines, size, overflow, missing) {
|
|
207
|
+
const { font, box } = block;
|
|
208
|
+
const tracking = block.tracking ?? 0;
|
|
209
|
+
const scale = size / font.upem;
|
|
210
|
+
const gap = size * block.lineHeight;
|
|
211
|
+
const height = lines.length * gap;
|
|
212
|
+
const valign = block.valign ?? "top";
|
|
213
|
+
const top = valign === "top"
|
|
214
|
+
? box.y
|
|
215
|
+
: valign === "middle"
|
|
216
|
+
? box.y + (box.h - height) / 2
|
|
217
|
+
: box.y + box.h - height;
|
|
218
|
+
// Center the font's ascent-to-descent span inside each line's gap.
|
|
219
|
+
const glyphSpan = (font.ascender - font.descender) * scale;
|
|
220
|
+
const firstBaseline = top + (gap - glyphSpan) / 2 + font.ascender * scale;
|
|
221
|
+
const align = block.align ?? "start";
|
|
222
|
+
const placed = [];
|
|
223
|
+
const parts = [];
|
|
224
|
+
let minX = Number.POSITIVE_INFINITY;
|
|
225
|
+
let maxX = Number.NEGATIVE_INFINITY;
|
|
226
|
+
lines.forEach((line, index) => {
|
|
227
|
+
const x = align === "start"
|
|
228
|
+
? box.x
|
|
229
|
+
: align === "center"
|
|
230
|
+
? box.x + (box.w - line.width) / 2
|
|
231
|
+
: box.x + box.w - line.width;
|
|
232
|
+
const baseline = firstBaseline + index * gap;
|
|
233
|
+
placed.push({ text: line.text, width: line.width, x, baseline });
|
|
234
|
+
if (line.text) {
|
|
235
|
+
parts.push(this.runPath(font, this.shape(font, line.text, size, tracking), size, x, baseline));
|
|
236
|
+
minX = Math.min(minX, x);
|
|
237
|
+
maxX = Math.max(maxX, x + line.width);
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
const bounds = minX <= maxX
|
|
241
|
+
? { x: minX, y: top, w: maxX - minX, h: height }
|
|
242
|
+
: { x: box.x, y: top, w: 0, h: height };
|
|
243
|
+
return { d: parts.join(""), size, lines: placed, bounds, overflow, missing };
|
|
244
|
+
}
|
|
245
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where font bytes come from. The kit never bundles a font: a resolver hands it the static TTF
|
|
3
|
+
* (one file per weight) for a Fontsource id. The default reads Fontsource's CDN with fetch and
|
|
4
|
+
* keeps the bytes in memory; `@ideare/kit/node` adds a disk cache, and the server can point a
|
|
5
|
+
* resolver at its R2 mirror (`fonts/<sha256>.ttf`).
|
|
6
|
+
*/
|
|
7
|
+
export type FontStyle = "normal" | "italic";
|
|
8
|
+
export type FontFormat = "ttf" | "woff2";
|
|
9
|
+
export type FontKey = {
|
|
10
|
+
id: string;
|
|
11
|
+
weight: number;
|
|
12
|
+
style?: FontStyle;
|
|
13
|
+
};
|
|
14
|
+
export interface FontResolver {
|
|
15
|
+
load(font: FontKey & {
|
|
16
|
+
format?: FontFormat;
|
|
17
|
+
}): Promise<Uint8Array>;
|
|
18
|
+
}
|
|
19
|
+
/** Fontsource's CDN. Every family there ships per-weight static files for each subset. */
|
|
20
|
+
export declare const FONTSOURCE_CDN = "https://cdn.jsdelivr.net/fontsource/fonts";
|
|
21
|
+
/**
|
|
22
|
+
* The CDN address of one static file. The latin subset covers pt-BR and es (Latin-1 plus the
|
|
23
|
+
* euro sign and typographic quotes). `version` pins the bytes; "latest" follows Fontsource.
|
|
24
|
+
*/
|
|
25
|
+
export declare function fontsourceUrl(font: FontKey & {
|
|
26
|
+
format?: FontFormat;
|
|
27
|
+
}, version?: string, subset?: string): string;
|
|
28
|
+
/** The cache key for one file, shared by every resolver. */
|
|
29
|
+
export declare function fontFileName(font: FontKey & {
|
|
30
|
+
format?: FontFormat;
|
|
31
|
+
}): string;
|
|
32
|
+
export declare class FontLoadError extends Error {
|
|
33
|
+
readonly font: FontKey;
|
|
34
|
+
readonly status: number | undefined;
|
|
35
|
+
constructor(font: FontKey, status: number | undefined, message: string);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Fetch from Fontsource, keeping each file in memory once loaded. `versions` pins a family to a
|
|
39
|
+
* Fontsource release (the curated catalog does this), so the same brand always draws with the
|
|
40
|
+
* same bytes.
|
|
41
|
+
*/
|
|
42
|
+
export declare function fetchFontResolver(options?: {
|
|
43
|
+
fetch?: typeof fetch;
|
|
44
|
+
versions?: Record<string, string>;
|
|
45
|
+
/**
|
|
46
|
+
* Where to fetch each file, when not from Fontsource: a page whose CSP allows only its
|
|
47
|
+
* own origin and cdn.ideare.app points this at its own copies.
|
|
48
|
+
*/
|
|
49
|
+
url?: (font: FontKey & {
|
|
50
|
+
format?: FontFormat;
|
|
51
|
+
}) => string;
|
|
52
|
+
}): FontResolver;
|
|
53
|
+
/** ideare's API serves the catalog's TTFs at this path, for pages whose CSP allows no CDN. */
|
|
54
|
+
export declare function ideareFontUrl(apiOrigin: string, font: FontKey & {
|
|
55
|
+
format?: FontFormat;
|
|
56
|
+
}, version: string): string;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where font bytes come from. The kit never bundles a font: a resolver hands it the static TTF
|
|
3
|
+
* (one file per weight) for a Fontsource id. The default reads Fontsource's CDN with fetch and
|
|
4
|
+
* keeps the bytes in memory; `@ideare/kit/node` adds a disk cache, and the server can point a
|
|
5
|
+
* resolver at its R2 mirror (`fonts/<sha256>.ttf`).
|
|
6
|
+
*/
|
|
7
|
+
/** Fontsource's CDN. Every family there ships per-weight static files for each subset. */
|
|
8
|
+
export const FONTSOURCE_CDN = "https://cdn.jsdelivr.net/fontsource/fonts";
|
|
9
|
+
/**
|
|
10
|
+
* The CDN address of one static file. The latin subset covers pt-BR and es (Latin-1 plus the
|
|
11
|
+
* euro sign and typographic quotes). `version` pins the bytes; "latest" follows Fontsource.
|
|
12
|
+
*/
|
|
13
|
+
export function fontsourceUrl(font, version = "latest", subset = "latin") {
|
|
14
|
+
const style = font.style ?? "normal";
|
|
15
|
+
return `${FONTSOURCE_CDN}/${font.id}@${version}/${subset}-${font.weight}-${style}.${font.format ?? "ttf"}`;
|
|
16
|
+
}
|
|
17
|
+
/** The cache key for one file, shared by every resolver. */
|
|
18
|
+
export function fontFileName(font) {
|
|
19
|
+
return `${font.id}-${font.weight}-${font.style ?? "normal"}.${font.format ?? "ttf"}`;
|
|
20
|
+
}
|
|
21
|
+
export class FontLoadError extends Error {
|
|
22
|
+
font;
|
|
23
|
+
status;
|
|
24
|
+
constructor(font, status, message) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = "FontLoadError";
|
|
27
|
+
this.font = font;
|
|
28
|
+
this.status = status;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Fetch from Fontsource, keeping each file in memory once loaded. `versions` pins a family to a
|
|
33
|
+
* Fontsource release (the curated catalog does this), so the same brand always draws with the
|
|
34
|
+
* same bytes.
|
|
35
|
+
*/
|
|
36
|
+
export function fetchFontResolver(options = {}) {
|
|
37
|
+
const cache = new Map();
|
|
38
|
+
const doFetch = options.fetch ?? fetch;
|
|
39
|
+
return {
|
|
40
|
+
load(font) {
|
|
41
|
+
const key = fontFileName(font);
|
|
42
|
+
let pending = cache.get(key);
|
|
43
|
+
if (!pending) {
|
|
44
|
+
pending = (async () => {
|
|
45
|
+
const url = options.url?.(font) ??
|
|
46
|
+
fontsourceUrl(font, options.versions?.[font.id] ?? "latest");
|
|
47
|
+
const response = await doFetch(url);
|
|
48
|
+
if (!response.ok) {
|
|
49
|
+
throw new FontLoadError(font, response.status, `Could not download ${font.id} ${font.weight} (${response.status} from ${url}). Check the font id and weight, then try again.`);
|
|
50
|
+
}
|
|
51
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
52
|
+
})();
|
|
53
|
+
// A failed download is not remembered, so the next call can retry.
|
|
54
|
+
pending.catch(() => cache.delete(key));
|
|
55
|
+
cache.set(key, pending);
|
|
56
|
+
}
|
|
57
|
+
return pending;
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/** ideare's API serves the catalog's TTFs at this path, for pages whose CSP allows no CDN. */
|
|
62
|
+
export function ideareFontUrl(apiOrigin, font, version) {
|
|
63
|
+
const style = font.style && font.style !== "normal" ? `-${font.style}` : "";
|
|
64
|
+
return `${apiOrigin.replace(/\/$/, "")}/public/fonts/${font.id}/${version}/${font.weight}${style}.${font.format ?? "ttf"}`;
|
|
65
|
+
}
|
package/dist/version.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rasterizer for browsers and for the CLI: @resvg/resvg-wasm 2.6.2, the same resvg 0.34 as
|
|
3
|
+
* `@ideare/kit/resvg`, built to wasm, so `npx ideare` installs nothing native. The kit only ever
|
|
4
|
+
* hands it glyph paths, so it never needs a font.
|
|
5
|
+
*
|
|
6
|
+
* The caller says where the wasm comes from, because only the caller knows: a browser passes a
|
|
7
|
+
* URL its bundler made (`import wasm from "@resvg/resvg-wasm/index_bg.wasm?url"` in Vite), and
|
|
8
|
+
* Node passes the file's bytes.
|
|
9
|
+
*/
|
|
10
|
+
import { type InitInput } from "@resvg/resvg-wasm";
|
|
11
|
+
import type { Rasterizer } from "../files/raster.ts";
|
|
12
|
+
export type WasmSource = InitInput | Promise<InitInput>;
|
|
13
|
+
export declare function wasmRasterizer(options: {
|
|
14
|
+
wasm: WasmSource;
|
|
15
|
+
}): Rasterizer;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rasterizer for browsers and for the CLI: @resvg/resvg-wasm 2.6.2, the same resvg 0.34 as
|
|
3
|
+
* `@ideare/kit/resvg`, built to wasm, so `npx ideare` installs nothing native. The kit only ever
|
|
4
|
+
* hands it glyph paths, so it never needs a font.
|
|
5
|
+
*
|
|
6
|
+
* The caller says where the wasm comes from, because only the caller knows: a browser passes a
|
|
7
|
+
* URL its bundler made (`import wasm from "@resvg/resvg-wasm/index_bg.wasm?url"` in Vite), and
|
|
8
|
+
* Node passes the file's bytes.
|
|
9
|
+
*/
|
|
10
|
+
import { Resvg, initWasm } from "@resvg/resvg-wasm";
|
|
11
|
+
// resvg-wasm may be initialized once per page or process, so every rasterizer shares this.
|
|
12
|
+
let ready;
|
|
13
|
+
function init(wasm) {
|
|
14
|
+
ready ??= initWasm(wasm).catch((error) => {
|
|
15
|
+
ready = undefined; // a failed download may work next time
|
|
16
|
+
throw error;
|
|
17
|
+
});
|
|
18
|
+
return ready;
|
|
19
|
+
}
|
|
20
|
+
export function wasmRasterizer(options) {
|
|
21
|
+
return async (svg, { width }) => {
|
|
22
|
+
await init(options.wasm);
|
|
23
|
+
const resvg = new Resvg(svg, {
|
|
24
|
+
fitTo: { mode: "width", value: Math.round(width) },
|
|
25
|
+
font: { loadSystemFonts: false },
|
|
26
|
+
});
|
|
27
|
+
// Each object holds wasm memory that JavaScript's collector never frees.
|
|
28
|
+
try {
|
|
29
|
+
const image = resvg.render();
|
|
30
|
+
try {
|
|
31
|
+
return {
|
|
32
|
+
png: image.asPng(),
|
|
33
|
+
pixels: image.pixels,
|
|
34
|
+
width: image.width,
|
|
35
|
+
height: image.height,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
image.free();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
resvg.free();
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,79 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "@ideare/kit",
|
|
3
|
+
"version": "0.1.0-next.0",
|
|
4
|
+
"description": "The brand kit engine behind ideare: brand.json, file generation, checks and the scene renderer.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"homepage": "https://ideare.app",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"bun": "./src/index.ts",
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"default": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./resvg": {
|
|
15
|
+
"bun": "./src/resvg/index.ts",
|
|
16
|
+
"types": "./dist/resvg/index.d.ts",
|
|
17
|
+
"default": "./dist/resvg/index.js"
|
|
18
|
+
},
|
|
19
|
+
"./wasm": {
|
|
20
|
+
"bun": "./src/wasm/index.ts",
|
|
21
|
+
"types": "./dist/wasm/index.d.ts",
|
|
22
|
+
"default": "./dist/wasm/index.js"
|
|
23
|
+
},
|
|
24
|
+
"./node": {
|
|
25
|
+
"bun": "./src/node/index.ts",
|
|
26
|
+
"types": "./dist/node/index.d.ts",
|
|
27
|
+
"default": "./dist/node/index.js"
|
|
28
|
+
},
|
|
29
|
+
"./schema/brand.v1.json": "./schema/brand.v1.json",
|
|
30
|
+
"./package.json": "./package.json"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"dist",
|
|
34
|
+
"schema",
|
|
35
|
+
"LICENSE",
|
|
36
|
+
"README.md",
|
|
37
|
+
"THIRD_PARTY.md"
|
|
38
|
+
],
|
|
39
|
+
"sideEffects": false,
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
},
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": ">=20.3"
|
|
45
|
+
},
|
|
46
|
+
"scripts": {
|
|
47
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
48
|
+
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.node.json",
|
|
49
|
+
"lint": "eslint .",
|
|
50
|
+
"test": "vitest --run --passWithNoTests",
|
|
51
|
+
"schema": "bun run scripts/write-json-schema.ts"
|
|
52
|
+
},
|
|
53
|
+
"dependencies": {
|
|
54
|
+
"culori": "4.0.2",
|
|
55
|
+
"fflate": "^0.8.3",
|
|
56
|
+
"harfbuzzjs": "1.6.4",
|
|
57
|
+
"zod": "^4.4.3"
|
|
58
|
+
},
|
|
59
|
+
"peerDependencies": {
|
|
60
|
+
"@resvg/resvg-js": "2.6.2",
|
|
61
|
+
"@resvg/resvg-wasm": "2.6.2"
|
|
62
|
+
},
|
|
63
|
+
"peerDependenciesMeta": {
|
|
64
|
+
"@resvg/resvg-js": {
|
|
65
|
+
"optional": true
|
|
66
|
+
},
|
|
67
|
+
"@resvg/resvg-wasm": {
|
|
68
|
+
"optional": true
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
"devDependencies": {
|
|
72
|
+
"@resvg/resvg-js": "2.6.2",
|
|
73
|
+
"@resvg/resvg-wasm": "2.6.2",
|
|
74
|
+
"@types/bun": "^1.3.14",
|
|
75
|
+
"@types/culori": "^4.0.1",
|
|
76
|
+
"typescript": "^5.9.3",
|
|
77
|
+
"vitest": "^4.1.2"
|
|
78
|
+
}
|
|
79
|
+
}
|