mimeforge 0.0.0-stage → 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/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +291 -2
- package/THIRD_PARTY.md +10 -0
- package/assets/bodies/archive.svg +13 -0
- package/assets/bodies/audio.svg +14 -0
- package/assets/bodies/code.svg +14 -0
- package/assets/bodies/data.svg +13 -0
- package/assets/bodies/database.svg +14 -0
- package/assets/bodies/design.svg +15 -0
- package/assets/bodies/disk.svg +16 -0
- package/assets/bodies/ebook.svg +13 -0
- package/assets/bodies/executable.svg +13 -0
- package/assets/bodies/font.svg +13 -0
- package/assets/bodies/game.svg +17 -0
- package/assets/bodies/generic.svg +13 -0
- package/assets/bodies/geo.svg +13 -0
- package/assets/bodies/image.svg +14 -0
- package/assets/bodies/mail.svg +13 -0
- package/assets/bodies/model3d.svg +14 -0
- package/assets/bodies/pdf.svg +13 -0
- package/assets/bodies/science.svg +13 -0
- package/assets/bodies/security.svg +14 -0
- package/assets/bodies/sheet.svg +13 -0
- package/assets/bodies/slides.svg +13 -0
- package/assets/bodies/text.svg +14 -0
- package/assets/bodies/video.svg +13 -0
- package/assets/bodies/word.svg +12 -0
- package/assets/fonts/monogram.json +106 -0
- package/dist/body.d.ts +24 -0
- package/dist/body.js +70 -0
- package/dist/categories.d.ts +10 -0
- package/dist/categories.js +122 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +127 -0
- package/dist/color.d.ts +12 -0
- package/dist/color.js +34 -0
- package/dist/config.d.ts +22 -0
- package/dist/config.js +30 -0
- package/dist/font.d.ts +47 -0
- package/dist/font.js +102 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/label.d.ts +18 -0
- package/dist/label.js +36 -0
- package/dist/mime.d.ts +6 -0
- package/dist/mime.js +28 -0
- package/dist/palette.d.ts +13 -0
- package/dist/palette.js +32 -0
- package/dist/paths.d.ts +3 -0
- package/dist/paths.js +5 -0
- package/dist/png.d.ts +2 -0
- package/dist/png.js +11 -0
- package/dist/render.d.ts +26 -0
- package/dist/render.js +23 -0
- package/dist/testrun.d.ts +12 -0
- package/dist/testrun.js +70 -0
- package/docs/bodies.md +86 -0
- package/docs/categories.md +42 -0
- package/docs/fonts.md +63 -0
- package/docs/preview.svg +1 -0
- package/package.json +76 -4
- package/third_party/MIME-DB-LICENSE +23 -0
- package/third_party/monogram/CREDITS.txt +15 -0
- package/third_party/monogram/monogram.ttf +0 -0
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface Config {
|
|
2
|
+
out?: string;
|
|
3
|
+
font?: string;
|
|
4
|
+
body?: string;
|
|
5
|
+
/** Accent per category (`pdf`) or extension (`.docx`). */
|
|
6
|
+
colors?: Record<string, string>;
|
|
7
|
+
accent?: string;
|
|
8
|
+
labelCase?: 'upper' | 'lower' | 'none';
|
|
9
|
+
/** false = bodies only. */
|
|
10
|
+
label?: boolean;
|
|
11
|
+
/** Raster sizes for `--png`. */
|
|
12
|
+
png?: number[];
|
|
13
|
+
}
|
|
14
|
+
export declare const CONFIG_FILE = "mimeforge.config.json";
|
|
15
|
+
/**
|
|
16
|
+
* Load a config file (`path`, or `./mimeforge.config.json` when it exists). Relative `out`, `font` and `body`
|
|
17
|
+
* paths are resolved against the config file's directory.
|
|
18
|
+
*/
|
|
19
|
+
export declare function loadConfig(path?: string): {
|
|
20
|
+
file: string;
|
|
21
|
+
config: Config;
|
|
22
|
+
} | undefined;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, resolve } from 'node:path';
|
|
3
|
+
export const CONFIG_FILE = 'mimeforge.config.json';
|
|
4
|
+
/**
|
|
5
|
+
* Load a config file (`path`, or `./mimeforge.config.json` when it exists). Relative `out`, `font` and `body`
|
|
6
|
+
* paths are resolved against the config file's directory.
|
|
7
|
+
*/
|
|
8
|
+
export function loadConfig(path) {
|
|
9
|
+
const file = path ? resolve(path) : resolve(CONFIG_FILE);
|
|
10
|
+
if (!existsSync(file)) {
|
|
11
|
+
if (path)
|
|
12
|
+
throw new Error(`Config file not found: ${path}`);
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
let config;
|
|
16
|
+
try {
|
|
17
|
+
config = JSON.parse(readFileSync(file, 'utf8'));
|
|
18
|
+
}
|
|
19
|
+
catch (e) {
|
|
20
|
+
throw new Error(`Invalid JSON in ${file}: ${e.message}`);
|
|
21
|
+
}
|
|
22
|
+
const dir = dirname(file);
|
|
23
|
+
for (const k of ['out', 'font', 'body']) {
|
|
24
|
+
const v = config[k];
|
|
25
|
+
// "monogram" is the built-in font name, not a path
|
|
26
|
+
if (typeof v === 'string' && !(k === 'font' && v === 'monogram'))
|
|
27
|
+
config[k] = resolve(dir, v);
|
|
28
|
+
}
|
|
29
|
+
return { file, config };
|
|
30
|
+
}
|
package/dist/font.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** Design grid of the label, in SVG units (one "pixel" of a 48 px render of the 32-unit canvas). */
|
|
2
|
+
export declare const GRID: number;
|
|
3
|
+
/** Natural cap height of label text, in SVG units (9 grid cells). */
|
|
4
|
+
export declare const CAP_HEIGHT: number;
|
|
5
|
+
export interface LabelLayout {
|
|
6
|
+
/** Path data. Coordinates are multiplied by `unitScale` to get SVG units. Origin = top-left of the cap box. */
|
|
7
|
+
d: string;
|
|
8
|
+
unitScale: number;
|
|
9
|
+
/** Size of the text box in SVG units (after `unitScale`). */
|
|
10
|
+
width: number;
|
|
11
|
+
height: number;
|
|
12
|
+
/** If set, the label should be positioned on multiples of this value (pixel fonts). */
|
|
13
|
+
grid?: number;
|
|
14
|
+
}
|
|
15
|
+
export interface LabelFont {
|
|
16
|
+
name: string;
|
|
17
|
+
/** Layout `text` at `scale` (1 = natural size). */
|
|
18
|
+
layout(text: string, scale: number): LabelLayout;
|
|
19
|
+
/** Can this font draw the character? */
|
|
20
|
+
has(ch: string): boolean;
|
|
21
|
+
}
|
|
22
|
+
export interface BitmapFontData {
|
|
23
|
+
name: string;
|
|
24
|
+
cols: number;
|
|
25
|
+
rows: number;
|
|
26
|
+
/** Extra rows below the baseline (descenders). Default 0. */
|
|
27
|
+
descender?: number;
|
|
28
|
+
/** Advance in cells (glyph width + spacing). */
|
|
29
|
+
advance: number;
|
|
30
|
+
/** char → rows of '#'/'.'; `rows + descender` rows each, `cols` characters wide. */
|
|
31
|
+
glyphs: Record<string, string[]>;
|
|
32
|
+
author?: string;
|
|
33
|
+
license?: string;
|
|
34
|
+
source?: string;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Pixel font drawn as rectangles. The `rows` cap rows are stretched to `capCells` rows (whole cells, so the
|
|
38
|
+
* stretch doubles a few rows) while columns stay 1 cell wide.
|
|
39
|
+
*/
|
|
40
|
+
export declare function bitmapFont(data: BitmapFontData, capCells?: number): LabelFont;
|
|
41
|
+
/** Any vector font: glyph outlines become SVG paths, scaled so the cap height equals `CAP_HEIGHT`. */
|
|
42
|
+
export declare function outlineFont(buffer: Buffer, name: string): LabelFont;
|
|
43
|
+
export declare const BUILTIN_FONT = "monogram";
|
|
44
|
+
/**
|
|
45
|
+
* `spec`: undefined | "monogram" (built in) | path to a bitmap-font `.json` | path to a `.ttf` / `.otf` / `.woff`.
|
|
46
|
+
*/
|
|
47
|
+
export declare function loadFont(spec?: string): LabelFont;
|
package/dist/font.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { extname } from 'node:path';
|
|
3
|
+
import opentype from 'opentype.js';
|
|
4
|
+
import { assetPath } from './paths.js';
|
|
5
|
+
/** Design grid of the label, in SVG units (one "pixel" of a 48 px render of the 32-unit canvas). */
|
|
6
|
+
export const GRID = 2 / 3;
|
|
7
|
+
/** Natural cap height of label text, in SVG units (9 grid cells). */
|
|
8
|
+
export const CAP_HEIGHT = 9 * GRID;
|
|
9
|
+
const num = (v) => String(+v.toFixed(3));
|
|
10
|
+
/**
|
|
11
|
+
* Pixel font drawn as rectangles. The `rows` cap rows are stretched to `capCells` rows (whole cells, so the
|
|
12
|
+
* stretch doubles a few rows) while columns stay 1 cell wide.
|
|
13
|
+
*/
|
|
14
|
+
export function bitmapFont(data, capCells = 9) {
|
|
15
|
+
const desc = data.descender ?? 0;
|
|
16
|
+
const rowStart = (r) => (r <= data.rows ? Math.round((r * capCells) / data.rows) : capCells + (r - data.rows));
|
|
17
|
+
const glyphPath = (rows, x0) => {
|
|
18
|
+
let d = '';
|
|
19
|
+
for (let r = 0; r < data.rows + desc; r++) {
|
|
20
|
+
const line = rows[r] ?? '';
|
|
21
|
+
const y = rowStart(r), h = rowStart(r + 1) - y;
|
|
22
|
+
for (let c = 0; c < data.cols;) {
|
|
23
|
+
if (line[c] !== '#') {
|
|
24
|
+
c++;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
let e = c;
|
|
28
|
+
while (e < data.cols && line[e] === '#')
|
|
29
|
+
e++;
|
|
30
|
+
d += `M${x0 + c} ${y}h${e - c}v${h}h${c - e}z`;
|
|
31
|
+
c = e;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return d;
|
|
35
|
+
};
|
|
36
|
+
return {
|
|
37
|
+
name: data.name,
|
|
38
|
+
has: (ch) => ch === ' ' || ch in data.glyphs,
|
|
39
|
+
layout(text, scale) {
|
|
40
|
+
const cell = GRID * scale;
|
|
41
|
+
let d = '';
|
|
42
|
+
[...text].forEach((ch, i) => { const g = data.glyphs[ch]; if (g)
|
|
43
|
+
d += glyphPath(g, i * data.advance); });
|
|
44
|
+
const n = [...text].length;
|
|
45
|
+
return {
|
|
46
|
+
d, unitScale: cell, grid: cell,
|
|
47
|
+
width: (data.advance * (n - 1) + data.cols) * cell, height: capCells * cell,
|
|
48
|
+
};
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
// ---------------------------------------------------------------- outline fonts (TTF / OTF / WOFF)
|
|
53
|
+
/** Serialize an opentype.js path ourselves (its `toPathData` can emit NaN for some coordinates). */
|
|
54
|
+
function pathData(p) {
|
|
55
|
+
return p.commands.map((c) => {
|
|
56
|
+
switch (c.type) {
|
|
57
|
+
case 'M':
|
|
58
|
+
case 'L': return `${c.type}${num(c.x)} ${num(c.y)}`;
|
|
59
|
+
case 'Q': return `Q${num(c.x1)} ${num(c.y1)} ${num(c.x)} ${num(c.y)}`;
|
|
60
|
+
case 'C': return `C${num(c.x1)} ${num(c.y1)} ${num(c.x2)} ${num(c.y2)} ${num(c.x)} ${num(c.y)}`;
|
|
61
|
+
default: return 'Z';
|
|
62
|
+
}
|
|
63
|
+
}).join('');
|
|
64
|
+
}
|
|
65
|
+
/** Any vector font: glyph outlines become SVG paths, scaled so the cap height equals `CAP_HEIGHT`. */
|
|
66
|
+
export function outlineFont(buffer, name) {
|
|
67
|
+
const font = opentype.parse(buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength));
|
|
68
|
+
const capUnits = font.charToGlyph('H').getBoundingBox().y2 || font.unitsPerEm * 0.7;
|
|
69
|
+
const k = CAP_HEIGHT / capUnits; // svg units per font unit
|
|
70
|
+
return {
|
|
71
|
+
name,
|
|
72
|
+
has: (ch) => font.charToGlyph(ch).index !== 0,
|
|
73
|
+
layout(text, scale) {
|
|
74
|
+
const glyphs = [...text].map((c) => font.charToGlyph(c));
|
|
75
|
+
let x = 0;
|
|
76
|
+
const placed = glyphs.map((g) => { const px = x; x += g.advanceWidth * k; return px; });
|
|
77
|
+
const first = glyphs[0]?.getBoundingBox().x1 ?? 0;
|
|
78
|
+
const last = glyphs[glyphs.length - 1];
|
|
79
|
+
const right = last ? placed[placed.length - 1] + last.getBoundingBox().x2 * k : 0;
|
|
80
|
+
const left = first * k;
|
|
81
|
+
let d = '';
|
|
82
|
+
glyphs.forEach((g, i) => { d += pathData(g.getPath(placed[i] - left, CAP_HEIGHT, k * font.unitsPerEm)); });
|
|
83
|
+
return { d, unitScale: scale, width: (right - left) * scale, height: CAP_HEIGHT * scale };
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
// ---------------------------------------------------------------- loading
|
|
88
|
+
export const BUILTIN_FONT = 'monogram';
|
|
89
|
+
/**
|
|
90
|
+
* `spec`: undefined | "monogram" (built in) | path to a bitmap-font `.json` | path to a `.ttf` / `.otf` / `.woff`.
|
|
91
|
+
*/
|
|
92
|
+
export function loadFont(spec) {
|
|
93
|
+
if (!spec || spec === BUILTIN_FONT) {
|
|
94
|
+
return bitmapFont(JSON.parse(readFileSync(assetPath('fonts', 'monogram.json'), 'utf8')));
|
|
95
|
+
}
|
|
96
|
+
const ext = extname(spec).toLowerCase();
|
|
97
|
+
if (ext === '.json')
|
|
98
|
+
return bitmapFont(JSON.parse(readFileSync(spec, 'utf8')));
|
|
99
|
+
if (['.ttf', '.otf', '.woff'].includes(ext))
|
|
100
|
+
return outlineFont(readFileSync(spec), spec.split('/').pop() ?? spec);
|
|
101
|
+
throw new Error(`Unsupported font "${spec}". Use "monogram", a bitmap-font .json, or a .ttf/.otf/.woff file.`);
|
|
102
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { renderIcon, type RenderOptions, type RenderResult } from './render.js';
|
|
2
|
+
export { CATEGORIES, classify, classifyMime, isCategory, type Category } from './categories.js';
|
|
3
|
+
export { allExtensions, categoryFor, extToMime } from './mime.js';
|
|
4
|
+
export { DEFAULT_PALETTE, createPalette, accentFor, type Palette } from './palette.js';
|
|
5
|
+
export { loadBodies, validateBody, recolor, DEFAULT_TOKENS, type BodySet, type BodyTokens } from './body.js';
|
|
6
|
+
export { loadFont, bitmapFont, outlineFont, GRID, CAP_HEIGHT, type LabelFont, type LabelLayout, type BitmapFontData } from './font.js';
|
|
7
|
+
export { renderLabel, DEFAULT_LABEL, type LabelConfig } from './label.js';
|
|
8
|
+
export { contrast, tint, labelTextColor, normalizeHex } from './color.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { renderIcon } from './render.js';
|
|
2
|
+
export { CATEGORIES, classify, classifyMime, isCategory } from './categories.js';
|
|
3
|
+
export { allExtensions, categoryFor, extToMime } from './mime.js';
|
|
4
|
+
export { DEFAULT_PALETTE, createPalette, accentFor } from './palette.js';
|
|
5
|
+
export { loadBodies, validateBody, recolor, DEFAULT_TOKENS } from './body.js';
|
|
6
|
+
export { loadFont, bitmapFont, outlineFont, GRID, CAP_HEIGHT } from './font.js';
|
|
7
|
+
export { renderLabel, DEFAULT_LABEL } from './label.js';
|
|
8
|
+
export { contrast, tint, labelTextColor, normalizeHex } from './color.js';
|
package/dist/label.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type LabelFont } from './font.js';
|
|
2
|
+
export interface LabelConfig {
|
|
3
|
+
/** Label band: horizontal center is x=16; `width` and bottom edge in SVG units. */
|
|
4
|
+
bandWidth: number;
|
|
5
|
+
bandBottom: number;
|
|
6
|
+
/** Vertical padding between text and band edge, in grid cells. */
|
|
7
|
+
padCells: number;
|
|
8
|
+
/** Maximum text width in SVG units; longer text is scaled down. */
|
|
9
|
+
innerWidth: number;
|
|
10
|
+
/** Smallest scale before letters are dropped. */
|
|
11
|
+
minScale: number;
|
|
12
|
+
cornerRadius: number;
|
|
13
|
+
textLight: string;
|
|
14
|
+
textDark: string;
|
|
15
|
+
}
|
|
16
|
+
export declare const DEFAULT_LABEL: LabelConfig;
|
|
17
|
+
/** SVG fragment (band + text) for `text` in the 32×32 canvas. */
|
|
18
|
+
export declare function renderLabel(text: string, font: LabelFont, accent: string, cfg?: LabelConfig): string;
|
package/dist/label.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { labelTextColor } from './color.js';
|
|
2
|
+
import { CAP_HEIGHT, GRID } from './font.js';
|
|
3
|
+
export const DEFAULT_LABEL = {
|
|
4
|
+
bandWidth: 26, bandBottom: 28, padCells: 2, innerWidth: 24, minScale: 0.35, cornerRadius: 1,
|
|
5
|
+
textLight: '#FFFFFF', textDark: '#1B1F24',
|
|
6
|
+
};
|
|
7
|
+
const num = (v) => String(+v.toFixed(3));
|
|
8
|
+
const snap = (v, grid) => (grid ? Math.round(v / grid) * grid : v);
|
|
9
|
+
/** SVG fragment (band + text) for `text` in the 32×32 canvas. */
|
|
10
|
+
export function renderLabel(text, font, accent, cfg = DEFAULT_LABEL) {
|
|
11
|
+
let chars = [...text].filter((c) => font.has(c));
|
|
12
|
+
if (!chars.length)
|
|
13
|
+
return '';
|
|
14
|
+
let scale = 1;
|
|
15
|
+
let layout = font.layout(chars.join(''), 1);
|
|
16
|
+
if (layout.width > cfg.innerWidth) {
|
|
17
|
+
scale = cfg.innerWidth / layout.width;
|
|
18
|
+
// below the minimum scale we drop trailing letters instead of shrinking further
|
|
19
|
+
while (scale < cfg.minScale && chars.length > 1) {
|
|
20
|
+
chars = chars.slice(0, -1);
|
|
21
|
+
const l = font.layout(chars.join(''), 1);
|
|
22
|
+
scale = Math.min(1, cfg.innerWidth / l.width);
|
|
23
|
+
}
|
|
24
|
+
scale = Math.max(scale, cfg.minScale);
|
|
25
|
+
layout = font.layout(chars.join(''), scale);
|
|
26
|
+
}
|
|
27
|
+
const pad = cfg.padCells * GRID;
|
|
28
|
+
const bandH = CAP_HEIGHT + 2 * pad;
|
|
29
|
+
const bandTop = cfg.bandBottom - bandH;
|
|
30
|
+
const tx = snap(16 - layout.width / 2, layout.grid);
|
|
31
|
+
const ty = snap(bandTop + (bandH - layout.height) / 2, layout.grid);
|
|
32
|
+
const fill = labelTextColor(accent, cfg.textLight, cfg.textDark);
|
|
33
|
+
const band = `<rect x="${num(16 - cfg.bandWidth / 2)}" y="${num(bandTop)}" width="${cfg.bandWidth}" height="${num(bandH)}" rx="${cfg.cornerRadius}" fill="${accent}"/>`;
|
|
34
|
+
const txt = `<path transform="translate(${num(tx)} ${num(ty)}) scale(${num(layout.unitScale)})" d="${layout.d}" fill="${fill}"/>`;
|
|
35
|
+
return `<g id="label">${band}${txt}</g>`;
|
|
36
|
+
}
|
package/dist/mime.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type Category } from './categories.js';
|
|
2
|
+
/** extension → all registered mime types, built from the `mime-db` package (MIT). */
|
|
3
|
+
export declare function extToMime(): Map<string, string[]>;
|
|
4
|
+
export declare function categoryFor(ext: string): Category;
|
|
5
|
+
/** Every extension we know about (mime-db + our own overrides), sorted. */
|
|
6
|
+
export declare function allExtensions(): string[];
|
package/dist/mime.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { classify, EXT_OVERRIDES } from './categories.js';
|
|
3
|
+
const require = createRequire(import.meta.url);
|
|
4
|
+
let cache;
|
|
5
|
+
/** extension → all registered mime types, built from the `mime-db` package (MIT). */
|
|
6
|
+
export function extToMime() {
|
|
7
|
+
if (cache)
|
|
8
|
+
return cache;
|
|
9
|
+
const db = require('mime-db');
|
|
10
|
+
const map = new Map();
|
|
11
|
+
for (const [mime, info] of Object.entries(db))
|
|
12
|
+
for (const ext of info.extensions ?? []) {
|
|
13
|
+
const list = map.get(ext);
|
|
14
|
+
if (list)
|
|
15
|
+
list.push(mime);
|
|
16
|
+
else
|
|
17
|
+
map.set(ext, [mime]);
|
|
18
|
+
}
|
|
19
|
+
return (cache = map);
|
|
20
|
+
}
|
|
21
|
+
export function categoryFor(ext) {
|
|
22
|
+
const e = ext.toLowerCase().replace(/^\./, '');
|
|
23
|
+
return classify(e, extToMime().get(e));
|
|
24
|
+
}
|
|
25
|
+
/** Every extension we know about (mime-db + our own overrides), sorted. */
|
|
26
|
+
export function allExtensions() {
|
|
27
|
+
return [...new Set([...extToMime().keys(), ...EXT_OVERRIDES.keys()])].sort();
|
|
28
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type Category } from './categories.js';
|
|
2
|
+
/** Default accent per category. Everything here can be overridden (per category or per extension). */
|
|
3
|
+
export declare const DEFAULT_PALETTE: Readonly<Record<Category, string>>;
|
|
4
|
+
export interface Palette {
|
|
5
|
+
categories: Record<Category, string>;
|
|
6
|
+
/** Per-extension overrides (lowercase, no dot). */
|
|
7
|
+
extensions: Record<string, string>;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Build a palette from overrides. Keys are category names (`pdf`) or extensions with a leading dot (`.docx`).
|
|
11
|
+
*/
|
|
12
|
+
export declare function createPalette(overrides?: Record<string, string>): Palette;
|
|
13
|
+
export declare function accentFor(palette: Palette, ext: string, category: Category): string;
|
package/dist/palette.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { CATEGORIES, isCategory } from './categories.js';
|
|
2
|
+
import { normalizeHex } from './color.js';
|
|
3
|
+
/** Default accent per category. Everything here can be overridden (per category or per extension). */
|
|
4
|
+
export const DEFAULT_PALETTE = {
|
|
5
|
+
generic: '#990000', text: '#999999', code: '#2F7F8F', data: '#336699', pdf: '#D64545', word: '#2B6CB0',
|
|
6
|
+
sheet: '#217346', slides: '#D24726', image: '#999900', audio: '#1E9E5A', video: '#8E44AD', archive: '#996600',
|
|
7
|
+
font: '#7B4B94', ebook: '#B5651D', database: '#5B6770', executable: '#444B57', security: '#B8860B',
|
|
8
|
+
model3d: '#4B6CB7', geo: '#99CC00', mail: '#2E86AB', science: '#009999',
|
|
9
|
+
disk: '#00796B', design: '#C2185B', game: '#E08A00',
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Build a palette from overrides. Keys are category names (`pdf`) or extensions with a leading dot (`.docx`).
|
|
13
|
+
*/
|
|
14
|
+
export function createPalette(overrides = {}) {
|
|
15
|
+
const categories = { ...DEFAULT_PALETTE };
|
|
16
|
+
const extensions = {};
|
|
17
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
18
|
+
const hex = normalizeHex(value);
|
|
19
|
+
if (key.startsWith('.'))
|
|
20
|
+
extensions[key.slice(1).toLowerCase()] = hex;
|
|
21
|
+
else if (isCategory(key))
|
|
22
|
+
categories[key] = hex;
|
|
23
|
+
else
|
|
24
|
+
throw new Error(`Unknown color key "${key}". Use a category (${CATEGORIES.join(', ')}) or ".ext".`);
|
|
25
|
+
}
|
|
26
|
+
for (const c of CATEGORIES)
|
|
27
|
+
categories[c] = normalizeHex(categories[c]);
|
|
28
|
+
return { categories, extensions };
|
|
29
|
+
}
|
|
30
|
+
export function accentFor(palette, ext, category) {
|
|
31
|
+
return palette.extensions[ext.toLowerCase().replace(/^\./, '')] ?? palette.categories[category];
|
|
32
|
+
}
|
package/dist/paths.d.ts
ADDED
package/dist/paths.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
/** Package root. Works from both `src/` and `dist/` (both sit one level below the root). */
|
|
4
|
+
export const ROOT = fileURLToPath(new URL('..', import.meta.url));
|
|
5
|
+
export const assetPath = (...parts) => join(ROOT, 'assets', ...parts);
|
package/dist/png.d.ts
ADDED
package/dist/png.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Rasterize an SVG string. Needs the optional dependency `@resvg/resvg-js`. */
|
|
2
|
+
export async function renderPng(svg, size) {
|
|
3
|
+
let mod;
|
|
4
|
+
try {
|
|
5
|
+
mod = (await import('@resvg/resvg-js'));
|
|
6
|
+
}
|
|
7
|
+
catch {
|
|
8
|
+
throw new Error('PNG export needs the optional dependency @resvg/resvg-js (npm i @resvg/resvg-js).');
|
|
9
|
+
}
|
|
10
|
+
return new mod.Resvg(svg, { fitTo: { mode: 'width', value: size } }).render().asPng();
|
|
11
|
+
}
|
package/dist/render.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type BodySet, type BodyTokens } from './body.js';
|
|
2
|
+
import type { Category } from './categories.js';
|
|
3
|
+
import { type LabelFont } from './font.js';
|
|
4
|
+
import { type LabelConfig } from './label.js';
|
|
5
|
+
import { type Palette } from './palette.js';
|
|
6
|
+
export interface RenderOptions {
|
|
7
|
+
font?: LabelFont;
|
|
8
|
+
bodies?: BodySet;
|
|
9
|
+
palette?: Palette;
|
|
10
|
+
/** Force this accent for the icon (wins over the palette). */
|
|
11
|
+
accent?: string;
|
|
12
|
+
/** Force a category (skip mime lookup). */
|
|
13
|
+
category?: Category;
|
|
14
|
+
/** Text on the label: default is the extension; `false` draws no label. */
|
|
15
|
+
label?: string | false;
|
|
16
|
+
labelCase?: 'upper' | 'lower' | 'none';
|
|
17
|
+
labelConfig?: Partial<LabelConfig>;
|
|
18
|
+
tokens?: BodyTokens;
|
|
19
|
+
}
|
|
20
|
+
export interface RenderResult {
|
|
21
|
+
svg: string;
|
|
22
|
+
ext: string;
|
|
23
|
+
category: Category;
|
|
24
|
+
accent: string;
|
|
25
|
+
}
|
|
26
|
+
export declare function renderIcon(extension: string, opts?: RenderOptions): RenderResult;
|
package/dist/render.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { loadBodies, recolor, DEFAULT_TOKENS } from './body.js';
|
|
2
|
+
import { normalizeHex } from './color.js';
|
|
3
|
+
import { loadFont } from './font.js';
|
|
4
|
+
import { DEFAULT_LABEL, renderLabel } from './label.js';
|
|
5
|
+
import { categoryFor } from './mime.js';
|
|
6
|
+
import { accentFor, createPalette } from './palette.js';
|
|
7
|
+
let defaults;
|
|
8
|
+
const getDefaults = () => (defaults ??= { font: loadFont(), bodies: loadBodies(), palette: createPalette() });
|
|
9
|
+
export function renderIcon(extension, opts = {}) {
|
|
10
|
+
const d = getDefaults();
|
|
11
|
+
const ext = extension.toLowerCase().replace(/^\./, '');
|
|
12
|
+
const category = opts.category ?? categoryFor(ext);
|
|
13
|
+
const accent = opts.accent ? normalizeHex(opts.accent) : accentFor(opts.palette ?? d.palette, ext, category);
|
|
14
|
+
const tokens = opts.tokens ?? DEFAULT_TOKENS;
|
|
15
|
+
let svg = recolor((opts.bodies ?? d.bodies).svg(category), accent, tokens);
|
|
16
|
+
if (opts.label !== false) {
|
|
17
|
+
const raw = opts.label ?? ext;
|
|
18
|
+
const text = opts.labelCase === 'lower' ? raw.toLowerCase() : opts.labelCase === 'none' ? raw : raw.toUpperCase();
|
|
19
|
+
const frag = renderLabel(text, opts.font ?? d.font, accent, { ...DEFAULT_LABEL, ...opts.labelConfig });
|
|
20
|
+
svg = svg.replace(/<\/svg>\s*$/, `${frag}</svg>`);
|
|
21
|
+
}
|
|
22
|
+
return { svg: svg.replace(/>\s*\n\s*</g, '><').trim() + '\n', ext, category, accent };
|
|
23
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type Category } from './categories.js';
|
|
2
|
+
import { type RenderOptions } from './render.js';
|
|
3
|
+
/** One representative extension per body type. */
|
|
4
|
+
export declare const SAMPLES: Record<Category, string>;
|
|
5
|
+
export interface TestRunInfo {
|
|
6
|
+
font: string;
|
|
7
|
+
body: string;
|
|
8
|
+
warnings: string[];
|
|
9
|
+
missing?: string[];
|
|
10
|
+
}
|
|
11
|
+
/** Writes `<out>/svg/*.svg` and `<out>/index.html`. Returns the path of the HTML file. */
|
|
12
|
+
export declare function testRun(outDir: string, opts: RenderOptions, info: TestRunInfo): string;
|
package/dist/testrun.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { CATEGORIES } from './categories.js';
|
|
4
|
+
import { renderIcon } from './render.js';
|
|
5
|
+
import { accentFor, createPalette } from './palette.js';
|
|
6
|
+
/** One representative extension per body type. */
|
|
7
|
+
export const SAMPLES = {
|
|
8
|
+
generic: 'dat', text: 'txt', code: 'py', data: 'json', pdf: 'pdf', word: 'docx', sheet: 'xlsx', slides: 'pptx',
|
|
9
|
+
image: 'png', audio: 'mp3', video: 'mp4', archive: 'zip', font: 'ttf', ebook: 'epub', database: 'sqlite',
|
|
10
|
+
executable: 'exe', security: 'pem', model3d: 'glb', geo: 'kml', mail: 'eml', science: 'cif',
|
|
11
|
+
disk: 'iso', design: 'psd', game: 'rom',
|
|
12
|
+
};
|
|
13
|
+
/** Label stress test: different lengths, widest letters, digits. */
|
|
14
|
+
const STRESS = ['go', 'pdf', 'docx', 'webm', 'xhtml', 'mmmm', 'json5', 'svgz', 'webmanifest', 'appinstaller'];
|
|
15
|
+
const SIZES = [16, 48, 128];
|
|
16
|
+
/** Writes `<out>/svg/*.svg` and `<out>/index.html`. Returns the path of the HTML file. */
|
|
17
|
+
export function testRun(outDir, opts, info) {
|
|
18
|
+
const svgDir = join(outDir, 'svg');
|
|
19
|
+
mkdirSync(svgDir, { recursive: true });
|
|
20
|
+
const palette = opts.palette ?? createPalette();
|
|
21
|
+
const write = (ext) => {
|
|
22
|
+
const r = renderIcon(ext, opts);
|
|
23
|
+
writeFileSync(join(svgDir, `${ext}.svg`), r.svg);
|
|
24
|
+
return { file: `svg/${ext}.svg`, accent: r.accent, category: r.category };
|
|
25
|
+
};
|
|
26
|
+
const imgs = (file, alt) => SIZES.map((s) => `<figure><img src="${file}" width="${s}" height="${s}" alt="${alt}"><figcaption>${s}px</figcaption></figure>`).join('');
|
|
27
|
+
const bodyCards = CATEGORIES.map((cat) => {
|
|
28
|
+
const ext = SAMPLES[cat];
|
|
29
|
+
const r = write(ext);
|
|
30
|
+
const accent = opts.accent ?? accentFor(palette, ext, cat);
|
|
31
|
+
return `<section class="card"><h3>${cat}<small>.${ext}</small></h3><div class="row">${imgs(r.file, cat)}</div>` +
|
|
32
|
+
`<p class="sw"><i style="background:${accent}"></i>${accent}</p></section>`;
|
|
33
|
+
}).join('\n');
|
|
34
|
+
const stress = STRESS.map((ext) => {
|
|
35
|
+
const r = write(ext);
|
|
36
|
+
return `<section class="card"><h3>.${ext}<small>${r.category}</small></h3><div class="row">${imgs(r.file, ext)}</div></section>`;
|
|
37
|
+
}).join('\n');
|
|
38
|
+
const missingNote = info.missing?.length ? `<div class="warn"><b>No own body yet</b> (drawn with the generic body): ${info.missing.join(', ')}</div>` : '';
|
|
39
|
+
const warn = missingNote + (info.warnings.length ? `<div class="warn"><b>Body warnings</b><ul>${info.warnings.map((w) => `<li>${w}</li>`).join('')}</ul></div>` : '');
|
|
40
|
+
const html = `<!doctype html>
|
|
41
|
+
<html lang="en"><meta charset="utf-8"><title>MimeForge test run</title>
|
|
42
|
+
<style>
|
|
43
|
+
:root{--bg:#fff;--fg:#1b1f24;--card:#f6f7f9;--line:#e2e5e9}
|
|
44
|
+
body.dark{--bg:#1e2227;--fg:#e8eaed;--card:#272c33;--line:#363c45}
|
|
45
|
+
body{font:14px/1.4 system-ui,sans-serif;margin:24px;background:var(--bg);color:var(--fg)}
|
|
46
|
+
h1{margin:0 0 4px;font-size:20px}h2{margin:28px 0 10px;font-size:16px}
|
|
47
|
+
.meta{color:#8a919a;margin-bottom:12px}.meta b{color:var(--fg)}
|
|
48
|
+
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:12px}
|
|
49
|
+
.card{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:12px}
|
|
50
|
+
.card h3{margin:0 0 8px;font-size:13px}.card h3 small{color:#8a919a;margin-left:6px;font-weight:400}
|
|
51
|
+
.row{display:flex;align-items:flex-end;gap:16px}
|
|
52
|
+
figure{margin:0;text-align:center}figcaption{font-size:11px;color:#8a919a;margin-top:2px}
|
|
53
|
+
.sw{margin:8px 0 0;font:12px ui-monospace,monospace;color:#8a919a}.sw i{display:inline-block;width:12px;height:12px;border-radius:3px;margin-right:6px;vertical-align:-2px}
|
|
54
|
+
button{font:inherit;padding:4px 10px;border-radius:6px;border:1px solid var(--line);background:var(--card);color:var(--fg);cursor:pointer}
|
|
55
|
+
.warn{background:#fff3cd;color:#664d03;padding:8px 12px;border-radius:8px;margin:12px 0}
|
|
56
|
+
</style>
|
|
57
|
+
<body>
|
|
58
|
+
<h1>MimeForge · test run</h1>
|
|
59
|
+
<div class="meta">font: <b>${info.font}</b> · body: <b>${info.body}</b> · icons are plain <img src="svg/…"> at 16 / 48 / 128 px <button onclick="document.body.classList.toggle('dark')">toggle dark</button></div>
|
|
60
|
+
${warn}
|
|
61
|
+
<h2>Body types (${CATEGORIES.length})</h2>
|
|
62
|
+
<div class="grid">${bodyCards}</div>
|
|
63
|
+
<h2>Label length stress test</h2>
|
|
64
|
+
<div class="grid">${stress}</div>
|
|
65
|
+
</body></html>
|
|
66
|
+
`;
|
|
67
|
+
const file = join(outDir, 'index.html');
|
|
68
|
+
writeFileSync(file, html);
|
|
69
|
+
return file;
|
|
70
|
+
}
|
package/docs/bodies.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# Bodies
|
|
2
|
+
|
|
3
|
+
A **body** is the SVG that holds the page shape and the type emblem (the PDF flame, the music note, …). MimeForge adds
|
|
4
|
+
the colored extension **label** on top and recolors the body with the icon's accent color.
|
|
5
|
+
|
|
6
|
+
MimeForge ships 24 bodies in `assets/bodies/`, one per [category](categories.md). You can replace all of them or just
|
|
7
|
+
some, with `--body`.
|
|
8
|
+
|
|
9
|
+
## Using your own bodies
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
mimeforge --test-run --body ./my-bodies # a directory of <category>.svg
|
|
13
|
+
mimeforge --all --body ./one-shape.svg # one SVG for every category
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
- **Directory:** file names are category names (`pdf.svg`, `code.svg`, …, see the list in [categories.md](categories.md)).
|
|
17
|
+
A category without a file falls back to your `generic.svg`, then to the bundled body. Other file names are ignored.
|
|
18
|
+
- **Single file:** used for every category.
|
|
19
|
+
|
|
20
|
+
`mimeforge --test-run --body …` shows every category with your bodies, so you can check them at 16, 48 and 128 px.
|
|
21
|
+
|
|
22
|
+
## The contract
|
|
23
|
+
|
|
24
|
+
| Rule | Why |
|
|
25
|
+
|---|---|
|
|
26
|
+
| The root is `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">` | the label geometry uses the same 32×32 space |
|
|
27
|
+
| The accent color appears literally as **`#FF00FF`** everywhere the category color should be used | it is replaced by the icon's accent |
|
|
28
|
+
| The lighter tint appears literally as **`#FF99FF`** (optional) | it is replaced by the accent mixed 55 % towards white |
|
|
29
|
+
| Leave the **label zone** empty: x 3 to 29, y 19.3 to 28 | the label band is drawn there, on top of the body |
|
|
30
|
+
| Use plain shapes with solid fills. No gradients, filters, masks, clip paths, `<style>`, `<text>` | identical rendering everywhere, tiny files |
|
|
31
|
+
| The file ends with `</svg>` | the label is inserted just before it |
|
|
32
|
+
|
|
33
|
+
MimeForge warns on stderr when a body breaks the first, second or last rule.
|
|
34
|
+
|
|
35
|
+
The placeholder colors can be changed in the library API: `loadBodies(path, { accent: '#123456', tint: '#abcdef' })`.
|
|
36
|
+
|
|
37
|
+
## Skeleton of a bundled body
|
|
38
|
+
|
|
39
|
+
```svg
|
|
40
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
|
41
|
+
<g id="page">
|
|
42
|
+
<path d="M7.5 2.5H19.5L26.5 9.5V27.5A2 2 0 0 1 24.5 29.5H7.5A2 2 0 0 1 5.5 27.5V4.5A2 2 0 0 1 7.5 2.5Z" fill="#FFFFFF" stroke="#B8BFC7" stroke-linejoin="round"/>
|
|
43
|
+
</g>
|
|
44
|
+
<g id="emblem">
|
|
45
|
+
<!-- emblem, drawn in accent #FF00FF and tint #FF99FF -->
|
|
46
|
+
</g>
|
|
47
|
+
<g id="fold">
|
|
48
|
+
<path d="M19.5 2.5V7.5Q19.5 9.5 21.5 9.5H26.5Z" fill="#E3E7EB"/>
|
|
49
|
+
<path d="M19.5 2.5V7.5Q19.5 9.5 21.5 9.5H26.5Z" fill="none" stroke="#B8BFC7" stroke-linejoin="round"/>
|
|
50
|
+
</g>
|
|
51
|
+
</svg>
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The bundled set uses these conventions (you do not have to):
|
|
55
|
+
|
|
56
|
+
- page x 5 to 27, y 2 to 30 (22 × 28 units), 1 unit outline `#B8BFC7`, rounded corners of radius 2
|
|
57
|
+
- the folded corner is its own group, drawn last, so it covers parts of the emblem that reach the corner
|
|
58
|
+
- emblem inside x 9 to 23, y 6 to 18, bold shapes (at least 2 units wide), at most about three shapes, readable at 16 px
|
|
59
|
+
- neutrals `#FFFFFF`, `#E3E7EB`, `#B8BFC7`; a shape that should look cut out of an accent shape is painted `#FFFFFF`
|
|
60
|
+
|
|
61
|
+
## Generating new bodies with an AI design tool
|
|
62
|
+
|
|
63
|
+
The bundled bodies were designed with an AI design tool. A prompt template that works well, with the skeleton
|
|
64
|
+
attached as a reference (replace the bracketed parts):
|
|
65
|
+
|
|
66
|
+
```text
|
|
67
|
+
I have a family of file-type icon bodies in one flat style (reference SVGs attached). I need [N] more in exactly the
|
|
68
|
+
same style: [names]. Each is the same document-page silhouette with a category emblem. The extension label is added
|
|
69
|
+
later by code, so draw no text or letters.
|
|
70
|
+
|
|
71
|
+
Rules: solid fills only (no gradients, shadows, filters, masks, clip-paths, styles, classes, opacity tricks); bold
|
|
72
|
+
shapes with round caps and joins, minimum feature and gap size 2 units, at most about 3 shapes, readable at 16x16 px.
|
|
73
|
+
viewBox 0 0 32 32. The accent color is the literal #FF00FF and its lighter tint the literal #FF99FF; besides those only
|
|
74
|
+
#FFFFFF, #E3E7EB, #B8BFC7, #5B6470. Use the attached skeleton verbatim for the "page" and "fold" groups and design only
|
|
75
|
+
the contents of "emblem". Keep the emblem inside x 9-23, y 6-18 and nothing below y = 18 (a label band covers y 19.3-28).
|
|
76
|
+
The folded corner is drawn last and covers whatever reaches it.
|
|
77
|
+
|
|
78
|
+
Emblems:
|
|
79
|
+
1. [name]: [what to draw]
|
|
80
|
+
...
|
|
81
|
+
|
|
82
|
+
First deliver one preview sheet only: the new icons at 32 px and 16 px on white and on dark gray, next to three of the
|
|
83
|
+
attached icons so I can judge consistency. I will ask for the SVG files after reviewing it.
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Then run `mimeforge --test-run --body ./new-bodies` and check the colors, the 16 px rendering and the warnings.
|