@pantoken/icon-font 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,64 @@
1
+ # @pantoken/icon-font
2
+
3
+ Build an installable Instructure icon font from the Canvas by Instructure icon set. You get a WOFF2 file, a TTF file, a stylesheet, and a codepoint map. Install the font on a device and use the glyphs in design apps, documents, and offline contexts where the web tooling isn't reachable.
4
+
5
+ ## Why a font
6
+
7
+ The other pantoken icon formats emit inline SVG or per-framework components. Those need a build step and a browser. A font doesn't. Once it's installed, every app that picks a font can use the icons: Figma, Sketch, Google Slides, Keynote, Word, and any native text field.
8
+
9
+ ## What it does
10
+
11
+ Instructure ships two kinds of glyphs:
12
+
13
+ - Fill glyphs (the custom Instructure set) already describe filled regions, so they pass straight through.
14
+ - Stroke glyphs (the Lucide set) describe a centerline with a stroke width. A font glyph has no stroke, so this package outlines each stroke into a filled shape. It flattens every subpath to points, then offsets that polyline by half the stroke width with round joins and caps.
15
+
16
+ The result is one filled path per icon, streamed through `svgicons2svgfont`, `svg2ttf`, and `ttf2woff2`.
17
+
18
+ ## Install
19
+
20
+ ```sh
21
+ npm i -D @pantoken/icon-font
22
+ ```
23
+
24
+ This package isn't part of the unscoped `pantoken` meta package. The font toolchain is heavy, so `npm i pantoken` stays lean. Install this one directly when you need a font.
25
+
26
+ ## CLI
27
+
28
+ ```sh
29
+ pantoken generate icon-font --out ./fonts
30
+ pantoken generate icon-font --out ./fonts --class Instructure --icons arrow-left,check-mark
31
+ ```
32
+
33
+ Flags:
34
+
35
+ - `--out` is the output directory (default `./pantoken-out`).
36
+ - `--class` is the font family name (default `PanTokens`).
37
+ - `--icons` is a comma-separated subset (default: every icon).
38
+ - `--theme` is the source theme (default `rebrand`).
39
+
40
+ The command writes four files: `<class>.woff2`, `<class>.ttf`, `icons.css`, and `codepoints.json`.
41
+
42
+ ## API
43
+
44
+ ```ts
45
+ import { buildIconFont } from "@pantoken/icon-font";
46
+
47
+ const font = await buildIconFont({ fontName: "PanTokens", icons: ["arrow-left"] });
48
+ // font.woff2 → Uint8Array
49
+ // font.ttf → Uint8Array
50
+ // font.css → string (@font-face + .instui-icon-<name> rules)
51
+ // font.codepoints → { "arrow-left": "e000", ... }
52
+ ```
53
+
54
+ ## Use the font on the web
55
+
56
+ ```html
57
+ <link rel="stylesheet" href="./icons.css" /> <i class="instui-icon instui-icon-arrow-left"></i>
58
+ ```
59
+
60
+ Each glyph lives in the Unicode Private Use Area, starting at U+E000. The `codepoints.json` map records the assignment for every icon, so you can also type a glyph directly once the font is installed.
61
+
62
+ ## License
63
+
64
+ MIT
@@ -0,0 +1,79 @@
1
+ import { Theme } from "@pantoken/model";
2
+
3
+ //#region src/build.d.ts
4
+ /** The generated font artifacts. */
5
+ interface IconFontResult {
6
+ /** The TrueType font bytes. */
7
+ ttf: Uint8Array;
8
+ /** The WOFF2 font bytes. */
9
+ woff2: Uint8Array;
10
+ /** The `@font-face` + `.instui-icon-<name>` stylesheet. */
11
+ css: string;
12
+ /** Icon name → hex codepoint (e.g. `"e001"`). */
13
+ codepoints: Record<string, string>;
14
+ }
15
+ /** Options for {@link buildIconFont}. */
16
+ interface BuildIconFontOptions {
17
+ /** Icon names to include (default: the whole set). */
18
+ icons?: string[];
19
+ /** The font family name (default `"PanTokens"`). */
20
+ fontName?: string;
21
+ /** The theme to source glyphs from (default `"rebrand"`). */
22
+ theme?: Theme;
23
+ }
24
+ /**
25
+ * Build the icon font.
26
+ *
27
+ * @param options - {@link BuildIconFontOptions}.
28
+ * @returns The {@link IconFontResult}.
29
+ *
30
+ * @example Build the full font and write the artifacts
31
+ * ```ts
32
+ * import { buildIconFont } from "@pantoken/icon-font";
33
+ * import { writeFileSync } from "node:fs";
34
+ *
35
+ * const font = await buildIconFont();
36
+ * writeFileSync("PanTokens.woff2", font.woff2);
37
+ * writeFileSync("PanTokens.ttf", font.ttf);
38
+ * writeFileSync("icons.css", font.css);
39
+ * ```
40
+ *
41
+ * @example A custom font name and a subset of icons from another theme
42
+ * ```ts
43
+ * import { buildIconFont } from "@pantoken/icon-font";
44
+ *
45
+ * const font = await buildIconFont({
46
+ * fontName: "Instructure",
47
+ * icons: ["arrow-left", "check-mark"],
48
+ * theme: "canvas",
49
+ * });
50
+ * font.codepoints; // { "arrow-left": "e000", "check-mark": "e001" }
51
+ * ```
52
+ */
53
+ declare function buildIconFont(options?: BuildIconFontOptions): Promise<IconFontResult>;
54
+ //#endregion
55
+ //#region src/outline.d.ts
56
+ /** A filled glyph path plus the SVG viewBox size to wrap it in. */
57
+ interface GlyphPath {
58
+ d: string;
59
+ width: number;
60
+ height: number;
61
+ }
62
+ /**
63
+ * Produce a filled glyph path for an icon SVG.
64
+ *
65
+ * @param svg - Inline SVG markup.
66
+ * @returns The filled path `d` (in viewBox coordinates) and the viewBox size.
67
+ *
68
+ * @example Outline a stroke-based (Lucide) icon and a fill-based icon
69
+ * ```ts
70
+ * import { svgToGlyphPath } from "@pantoken/icon-font";
71
+ * import { getIcon } from "@pantoken/icons";
72
+ *
73
+ * const { d, width, height } = svgToGlyphPath(getIcon("arrow-left")!.svg);
74
+ * // d: a single filled path; width/height: the viewBox size (e.g. 24, 24)
75
+ * ```
76
+ */
77
+ declare function svgToGlyphPath(svg: string): GlyphPath;
78
+ //#endregion
79
+ export { type BuildIconFontOptions, type GlyphPath, type IconFontResult, buildIconFont, svgToGlyphPath };
package/dist/index.mjs ADDED
@@ -0,0 +1,229 @@
1
+ import { Readable } from "node:stream";
2
+ import { getIconSvgs } from "@pantoken/core";
3
+ import { byTheme } from "@pantoken/tokens";
4
+ import { SVGIcons2SVGFontStream } from "svgicons2svgfont";
5
+ import svg2ttf from "svg2ttf";
6
+ import ttf2woff2 from "ttf2woff2";
7
+ import ClipperLib from "clipper-lib";
8
+ import { pointsOnPath } from "points-on-path";
9
+ //#region src/outline.ts
10
+ /**
11
+ * Turn an icon SVG into a single **filled** glyph path (what a font glyph needs). Fill-based icons
12
+ * (Instructure custom glyphs) pass through; stroke-based icons (Lucide) are converted from a
13
+ * centerline stroke to a filled outline by flattening each subpath to points and offsetting it by
14
+ * half the stroke width with round joins/caps (`points-on-path` + `clipper-lib`, pure JS).
15
+ *
16
+ * @module
17
+ */
18
+ const SCALE = 100;
19
+ function attr(tag, name) {
20
+ return new RegExp(`\\b${name}=["']([^"']*)["']`).exec(tag)?.[1];
21
+ }
22
+ const STROKED_RE = /\bstroke=["'](?!none)[^"']+["']/;
23
+ function nums(value) {
24
+ return (value ?? "").split(/[\s,]+/).filter(Boolean).map(Number);
25
+ }
26
+ /** Convert an SVG primitive element to a subpath `d` + closure. */
27
+ function primitiveToSubpath(tag, el) {
28
+ switch (tag) {
29
+ case "line": {
30
+ const [x1, y1, x2, y2] = [
31
+ attr(el, "x1"),
32
+ attr(el, "y1"),
33
+ attr(el, "x2"),
34
+ attr(el, "y2")
35
+ ].map(Number);
36
+ return {
37
+ d: `M${x1},${y1} L${x2},${y2}`,
38
+ closed: false
39
+ };
40
+ }
41
+ case "circle": {
42
+ const cx = Number(attr(el, "cx"));
43
+ const cy = Number(attr(el, "cy"));
44
+ const r = Number(attr(el, "r"));
45
+ return {
46
+ d: `M${cx - r},${cy} a${r},${r} 0 1,0 ${r * 2},0 a${r},${r} 0 1,0 ${-r * 2},0`,
47
+ closed: true
48
+ };
49
+ }
50
+ case "rect": {
51
+ const x = Number(attr(el, "x") ?? "0");
52
+ const y = Number(attr(el, "y") ?? "0");
53
+ const w = Number(attr(el, "width"));
54
+ return {
55
+ d: `M${x},${y} h${w} v${Number(attr(el, "height"))} h${-w} z`,
56
+ closed: true
57
+ };
58
+ }
59
+ case "polyline":
60
+ case "polygon": {
61
+ const pts = nums(attr(el, "points"));
62
+ if (pts.length < 4) return void 0;
63
+ const parts = [`M${pts[0]},${pts[1]}`];
64
+ for (let i = 2; i < pts.length; i += 2) parts.push(`L${pts[i]},${pts[i + 1]}`);
65
+ if (tag === "polygon") parts.push("z");
66
+ return {
67
+ d: parts.join(" "),
68
+ closed: tag === "polygon"
69
+ };
70
+ }
71
+ default: return;
72
+ }
73
+ }
74
+ /** Split a path `d` into its subpaths (at each M/m), marking each closed if it contains z/Z. */
75
+ function splitSubpaths(d) {
76
+ return (d.match(/[Mm][^Mm]*/g) ?? []).map((chunk) => ({
77
+ d: chunk.trim(),
78
+ closed: /[Zz]/.test(chunk)
79
+ }));
80
+ }
81
+ /**
82
+ * Produce a filled glyph path for an icon SVG.
83
+ *
84
+ * @param svg - Inline SVG markup.
85
+ * @returns The filled path `d` (in viewBox coordinates) and the viewBox size.
86
+ *
87
+ * @example Outline a stroke-based (Lucide) icon and a fill-based icon
88
+ * ```ts
89
+ * import { svgToGlyphPath } from "@pantoken/icon-font";
90
+ * import { getIcon } from "@pantoken/icons";
91
+ *
92
+ * const { d, width, height } = svgToGlyphPath(getIcon("arrow-left")!.svg);
93
+ * // d: a single filled path; width/height: the viewBox size (e.g. 24, 24)
94
+ * ```
95
+ */
96
+ function svgToGlyphPath(svg) {
97
+ const vb = nums(/viewBox=["']([^"']+)["']/.exec(svg)?.[1]);
98
+ const [width, height] = vb.length === 4 ? [vb[2], vb[3]] : [24, 24];
99
+ const rootTag = /<svg\b[^>]*>/.exec(svg)?.[0] ?? "";
100
+ const strokeWidth = Number(attr(rootTag, "stroke-width") ?? "2");
101
+ const stroked = STROKED_RE.test(rootTag);
102
+ const subpaths = [];
103
+ for (const [, tag, el] of svg.matchAll(/<(path|line|circle|rect|polyline|polygon)\b([^>]*)>/g)) if (tag === "path") {
104
+ const d = attr(el, "d");
105
+ if (d) subpaths.push(...splitSubpaths(d));
106
+ } else {
107
+ const sub = primitiveToSubpath(tag, el);
108
+ if (sub) subpaths.push(sub);
109
+ }
110
+ if (!stroked) return {
111
+ d: subpaths.map((s) => s.d).join(" "),
112
+ width,
113
+ height
114
+ };
115
+ const offset = new ClipperLib.ClipperOffset(2, .25);
116
+ for (const sub of subpaths) for (const poly of pointsOnPath(sub.d, .2, .2)) {
117
+ if (poly.length < 2) continue;
118
+ const path = poly.map(([x, y]) => ({
119
+ X: Math.round(x * SCALE),
120
+ Y: Math.round(y * SCALE)
121
+ }));
122
+ offset.AddPath(path, ClipperLib.JoinType.jtRound, sub.closed ? ClipperLib.EndType.etClosedLine : ClipperLib.EndType.etOpenRound);
123
+ }
124
+ const solution = new ClipperLib.Paths();
125
+ offset.Execute(solution, strokeWidth / 2 * SCALE);
126
+ return {
127
+ d: solution.map((poly) => `M${poly.map((pt) => `${pt.X / SCALE},${pt.Y / SCALE}`).join(" L")} Z`).join(" "),
128
+ width,
129
+ height
130
+ };
131
+ }
132
+ //#endregion
133
+ //#region src/build.ts
134
+ /**
135
+ * Build a full Instructure icon font. Stroke glyphs are outlined (see {@link svgToGlyphPath}), then
136
+ * the glyph set is streamed through `svgicons2svgfont` → `svg2ttf` → `ttf2woff2`, with a matching
137
+ * `icons.css` and `codepoints.json`. Codepoints start at the Unicode Private Use Area (U+E000).
138
+ *
139
+ * @module
140
+ */
141
+ function css(fontName, codepoints) {
142
+ return `${[
143
+ [
144
+ "@font-face {",
145
+ ` font-family: "${fontName}";`,
146
+ ` src: url("./${fontName}.woff2") format("woff2"), url("./${fontName}.ttf") format("truetype");`,
147
+ " font-weight: normal;",
148
+ " font-style: normal;",
149
+ " font-display: block;",
150
+ "}"
151
+ ].join("\n"),
152
+ `.instui-icon { font-family: "${fontName}"; font-style: normal; font-weight: normal; line-height: 1; -webkit-font-smoothing: antialiased; }`,
153
+ Object.entries(codepoints).map(([name, hex]) => `.instui-icon-${name}::before { content: "\\${hex}"; }`).join("\n")
154
+ ].join("\n\n")}\n`;
155
+ }
156
+ /**
157
+ * Build the icon font.
158
+ *
159
+ * @param options - {@link BuildIconFontOptions}.
160
+ * @returns The {@link IconFontResult}.
161
+ *
162
+ * @example Build the full font and write the artifacts
163
+ * ```ts
164
+ * import { buildIconFont } from "@pantoken/icon-font";
165
+ * import { writeFileSync } from "node:fs";
166
+ *
167
+ * const font = await buildIconFont();
168
+ * writeFileSync("PanTokens.woff2", font.woff2);
169
+ * writeFileSync("PanTokens.ttf", font.ttf);
170
+ * writeFileSync("icons.css", font.css);
171
+ * ```
172
+ *
173
+ * @example A custom font name and a subset of icons from another theme
174
+ * ```ts
175
+ * import { buildIconFont } from "@pantoken/icon-font";
176
+ *
177
+ * const font = await buildIconFont({
178
+ * fontName: "Instructure",
179
+ * icons: ["arrow-left", "check-mark"],
180
+ * theme: "canvas",
181
+ * });
182
+ * font.codepoints; // { "arrow-left": "e000", "check-mark": "e001" }
183
+ * ```
184
+ */
185
+ async function buildIconFont(options = {}) {
186
+ const fontName = options.fontName ?? "PanTokens";
187
+ const svgs = getIconSvgs(byTheme(options.theme ?? "rebrand"));
188
+ const names = (options.icons?.length ? options.icons : [...svgs.keys()]).filter((n) => svgs.has(n)).sort();
189
+ const stream = new SVGIcons2SVGFontStream({
190
+ fontName,
191
+ fontHeight: 1e3,
192
+ normalize: true
193
+ });
194
+ let svgFont = "";
195
+ stream.on("data", (chunk) => {
196
+ svgFont += chunk.toString();
197
+ });
198
+ const done = new Promise((resolve, reject) => {
199
+ stream.on("end", resolve);
200
+ stream.on("error", reject);
201
+ });
202
+ const codepoints = {};
203
+ let codepoint = 57344;
204
+ for (const name of names) {
205
+ const svg = svgs.get(name);
206
+ if (!svg) continue;
207
+ const { d, width, height } = svgToGlyphPath(svg);
208
+ if (!d) continue;
209
+ const code = codepoint++;
210
+ codepoints[name] = code.toString(16);
211
+ const glyph = Readable.from([`<svg viewBox="0 0 ${width} ${height}"><path d="${d}"/></svg>`]);
212
+ glyph.metadata = {
213
+ unicode: [String.fromCodePoint(code)],
214
+ name
215
+ };
216
+ stream.write(glyph);
217
+ }
218
+ stream.end();
219
+ await done;
220
+ const ttf = new Uint8Array(svg2ttf(svgFont, { description: "Instructure icons (pantoken)" }).buffer);
221
+ return {
222
+ ttf,
223
+ woff2: new Uint8Array(ttf2woff2(ttf)),
224
+ css: css(fontName, codepoints),
225
+ codepoints
226
+ };
227
+ }
228
+ //#endregion
229
+ export { buildIconFont, svgToGlyphPath };
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@pantoken/icon-font",
3
+ "version": "0.1.0",
4
+ "description": "Build an installable Instructure icon font (WOFF2 + TTF) with CSS and codepoints — stroke glyphs outlined to fills.",
5
+ "license": "MIT",
6
+ "files": [
7
+ "dist"
8
+ ],
9
+ "type": "module",
10
+ "exports": {
11
+ ".": "./dist/index.mjs",
12
+ "./package.json": "./package.json"
13
+ },
14
+ "publishConfig": {
15
+ "access": "public"
16
+ },
17
+ "dependencies": {
18
+ "clipper-lib": "^6.4.2",
19
+ "points-on-path": "^0.2.1",
20
+ "svg2ttf": "^6.1.0",
21
+ "svgicons2svgfont": "^16.0.0",
22
+ "ttf2woff2": "^8.0.1",
23
+ "@pantoken/core": "0.1.0",
24
+ "@pantoken/model": "0.1.0",
25
+ "@pantoken/tokens": "0.1.0"
26
+ },
27
+ "devDependencies": {
28
+ "@types/node": "^24.13.3",
29
+ "typescript": "^6.0.3",
30
+ "vite": "npm:@voidzero-dev/vite-plus-core@0.2.4",
31
+ "vite-plus": "0.2.4"
32
+ },
33
+ "scripts": {
34
+ "build": "vp pack",
35
+ "dev": "vp pack --watch",
36
+ "test": "vp test",
37
+ "check": "vp check"
38
+ }
39
+ }