@idirdev/fontsubset 1.0.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2020-2026 idirdev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,106 @@
1
+ # fontsubset
2
+
3
+ > **[EN]** Audit font files in your project — detect formats, report file sizes, validate TTF/OTF magic bytes, extract unicode ranges from CSS, and suggest WOFF2 conversion savings.
4
+ > **[FR]** Auditez les polices de votre projet — détectez les formats, rapportez les tailles, validez les magic bytes TTF/OTF, extrayez les plages unicode depuis le CSS et suggérez les économies de conversion en WOFF2.
5
+
6
+ ---
7
+
8
+ ## Features / Fonctionnalités
9
+
10
+ **[EN]**
11
+ - Recursively finds all font files: `.woff2`, `.woff`, `.ttf`, `.otf`, `.eot`
12
+ - Reports format name, file size (human-readable), and basic validity check
13
+ - Validates TTF/OTF files by reading the magic bytes header (`0x00010000` / `0x4F54544F`)
14
+ - Extracts `unicode-range` declarations from CSS content to map used character sets
15
+ - Suggests conversion to WOFF2 and estimates potential file size savings (~30%)
16
+ - Outputs human-readable table or full JSON with `--json`
17
+ - Zero external dependencies — pure Node.js fs reads
18
+
19
+ **[FR]**
20
+ - Trouve récursivement tous les fichiers de polices : `.woff2`, `.woff`, `.ttf`, `.otf`, `.eot`
21
+ - Rapporte le nom du format, la taille du fichier (lisible) et une vérification de base
22
+ - Valide les fichiers TTF/OTF en lisant l'en-tête des magic bytes (`0x00010000` / `0x4F54544F`)
23
+ - Extrait les déclarations `unicode-range` du contenu CSS pour cartographier les jeux de caractères utilisés
24
+ - Suggère la conversion en WOFF2 et estime les économies potentielles de taille (~30%)
25
+ - Sortie lisible ou JSON complet avec `--json`
26
+ - Aucune dépendance externe — lectures fs Node.js pures
27
+
28
+ ---
29
+
30
+ ## Installation
31
+
32
+ ```bash
33
+ npm install -g @idirdev/fontsubset
34
+ ```
35
+
36
+ ---
37
+
38
+ ## CLI Usage / Utilisation CLI
39
+
40
+ ```bash
41
+ # Scan current directory for fonts (scanner le répertoire courant pour les polices)
42
+ fontsubset
43
+
44
+ # Scan a specific directory (scanner un répertoire spécifique)
45
+ fontsubset ./public/fonts
46
+
47
+ # Output full JSON report (sortie JSON complète)
48
+ fontsubset ./assets --json
49
+
50
+ # Show help (afficher l'aide)
51
+ fontsubset --help
52
+ ```
53
+
54
+ ### Example Output / Exemple de sortie
55
+
56
+ ```
57
+ $ fontsubset ./public/fonts
58
+ 5 font(s) found:
59
+ Inter-Regular.ttf (TrueType, 312.4KB)
60
+ Inter-Bold.ttf (TrueType, 318.1KB)
61
+ Inter-Regular.woff2 (WOFF2, 89.2KB)
62
+ icons.woff (WOFF, 42.8KB)
63
+ legacy.eot (EOT, 156.0KB)
64
+
65
+ Potential savings: 157.8KB
66
+ ```
67
+
68
+ ---
69
+
70
+ ## API (Programmatic) / API (Programmation)
71
+
72
+ ```js
73
+ const { analyzeFontFile, findFonts, extractUsedChars, suggestSubset, formatSize } = require('@idirdev/fontsubset');
74
+
75
+ // Analyze a single font file (analyser un fichier de police unique)
76
+ const info = analyzeFontFile('./public/fonts/Inter-Regular.ttf');
77
+ console.log(info.format); // 'TrueType'
78
+ console.log(info.sizeStr); // '312.4KB'
79
+ console.log(info.valid); // true (magic bytes OK)
80
+
81
+ // Find all fonts in a directory tree (trouver toutes les polices dans un arbre de répertoires)
82
+ const fonts = findFonts('./public');
83
+ fonts.forEach(f => console.log(f.file, f.format, f.sizeStr));
84
+
85
+ // Extract used unicode characters from a CSS file (extraire les caractères unicode utilisés depuis un CSS)
86
+ const css = require('fs').readFileSync('./src/fonts.css', 'utf8');
87
+ const usedChars = extractUsedChars(css);
88
+ console.log(usedChars.size); // number of codepoints used
89
+
90
+ // Get WOFF2 conversion suggestions with savings estimates (suggestions de conversion WOFF2 avec économies)
91
+ const suggestions = suggestSubset(fonts);
92
+ suggestions.forEach(s => {
93
+ if (s.potentialSavings > 0) {
94
+ console.log(`Convert ${s.file} to WOFF2 → save ~${s.savingsStr}`);
95
+ }
96
+ });
97
+
98
+ // Human-readable size formatting (formatage lisible des tailles)
99
+ console.log(formatSize(328172)); // '320.5KB'
100
+ ```
101
+
102
+ ---
103
+
104
+ ## License
105
+
106
+ MIT © idirdev
package/bin/cli.js ADDED
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * @fileoverview CLI for fontsubset — analyze web fonts and suggest optimization.
6
+ * @author idirdev
7
+ * @usage fontsubset <dir> [--css file.css] [--json] [--suggest]
8
+ */
9
+
10
+ const fs = require('fs');
11
+ const m = require('../src/index');
12
+
13
+ const args = process.argv.slice(2);
14
+
15
+ if (args.includes('--help') || args.length === 0) {
16
+ console.log([
17
+ 'Usage: fontsubset <dir> [options]',
18
+ '',
19
+ 'Options:',
20
+ ' --css <file> CSS file to extract unicode-range declarations from',
21
+ ' --suggest Show WOFF2 conversion suggestions',
22
+ ' --json Output results as JSON',
23
+ ' --help Show this help message',
24
+ ].join('\n'));
25
+ process.exit(0);
26
+ }
27
+
28
+ const target = args.find(a => !a.startsWith('-')) || '.';
29
+ const showJson = args.includes('--json');
30
+ const showSuggest = args.includes('--suggest');
31
+
32
+ const cssIdx = args.indexOf('--css');
33
+ const cssFile = cssIdx >= 0 ? args[cssIdx + 1] : null;
34
+
35
+ let stat;
36
+ try { stat = fs.statSync(target); } catch {
37
+ console.error('Error: cannot access ' + target);
38
+ process.exit(1);
39
+ }
40
+
41
+ const fonts = m.findFonts(target);
42
+
43
+ if (!fonts.length) {
44
+ console.log('No font files found in: ' + target);
45
+ process.exit(0);
46
+ }
47
+
48
+ if (cssFile) {
49
+ try {
50
+ const css = fs.readFileSync(cssFile, 'utf8');
51
+ const chars = m.extractUsedChars(css);
52
+ if (!showJson) {
53
+ console.log(`Unicode ranges cover ${chars.size} code point(s).`);
54
+ }
55
+ } catch {
56
+ console.error('Warning: could not read CSS file: ' + cssFile);
57
+ }
58
+ }
59
+
60
+ const results = showSuggest ? m.suggestSubset(fonts) : fonts;
61
+
62
+ if (showJson) {
63
+ console.log(JSON.stringify(results, null, 2));
64
+ } else {
65
+ console.log(m.formatReport(fonts));
66
+ if (showSuggest) {
67
+ const totalSavings = results.reduce((acc, f) => acc + (f.potentialSavings || 0), 0);
68
+ console.log('\nWOFF2 conversion savings estimate: ' + m.formatSize(totalSavings));
69
+ }
70
+ console.log('\n' + m.summary(fonts));
71
+ }
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@idirdev/fontsubset",
3
+ "version": "1.0.0",
4
+ "description": "Analyze web fonts and suggest optimization strategies",
5
+ "main": "src/index.js",
6
+ "author": "idirdev",
7
+ "license": "MIT",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/idirdev/fontsubset.git"
11
+ },
12
+ "keywords": [],
13
+ "engines": {
14
+ "node": ">=16.0.0"
15
+ },
16
+ "scripts": {
17
+ "test": "node --test tests/fontsubset.test.js"
18
+ },
19
+ "bin": {
20
+ "fontsubset": "bin/cli.js"
21
+ },
22
+ "homepage": "https://github.com/idirdev/fontsubset",
23
+ "bugs": {
24
+ "url": "https://github.com/idirdev/fontsubset/issues"
25
+ }
26
+ }
package/src/index.js ADDED
@@ -0,0 +1,207 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @fileoverview Web font analyzer — detect formats, extract unicode ranges, suggest optimizations.
5
+ * @module fontsubset
6
+ * @author idirdev
7
+ */
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+
12
+ /** @type {Object.<string, string>} Map file extension to format name. */
13
+ const EXT_FORMAT = {
14
+ '.woff2': 'WOFF2',
15
+ '.woff': 'WOFF',
16
+ '.ttf': 'TrueType',
17
+ '.otf': 'OpenType',
18
+ '.eot': 'EOT',
19
+ };
20
+
21
+ /** Font file extensions this tool recognizes. */
22
+ const FONT_EXTS = new Set(Object.keys(EXT_FORMAT));
23
+
24
+ /**
25
+ * Validate a font buffer by inspecting its magic bytes.
26
+ *
27
+ * @param {Buffer} buf - File contents.
28
+ * @param {string} ext - Lowercase file extension (e.g. '.ttf').
29
+ * @returns {boolean} Whether the magic bytes match the expected format.
30
+ */
31
+ function validateMagic(buf, ext) {
32
+ if (buf.length < 4) return false;
33
+ switch (ext) {
34
+ case '.ttf': return buf.readUInt32BE(0) === 0x00010000;
35
+ case '.otf': return buf.readUInt32BE(0) === 0x4F54544F; // 'OTTO'
36
+ case '.woff': {
37
+ const sig = buf.slice(0, 4).toString('ascii');
38
+ return sig === 'wOFF';
39
+ }
40
+ case '.woff2': {
41
+ const sig = buf.slice(0, 4).toString('ascii');
42
+ return sig === 'wOF2';
43
+ }
44
+ case '.eot': return true; // EOT has no consistent magic
45
+ default: return false;
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Analyze a single font file.
51
+ *
52
+ * @param {string} filePath - Absolute or relative path to the font file.
53
+ * @returns {{ file: string, path: string, format: string, size: number, sizeStr: string, valid: boolean }}
54
+ */
55
+ function analyzeFontFile(filePath) {
56
+ const ext = path.extname(filePath).toLowerCase();
57
+ const format = EXT_FORMAT[ext] || 'Unknown';
58
+ const stat = fs.statSync(filePath);
59
+ const buf = fs.readFileSync(filePath);
60
+ const valid = validateMagic(buf, ext);
61
+
62
+ return {
63
+ file: path.basename(filePath),
64
+ path: filePath,
65
+ format,
66
+ size: stat.size,
67
+ sizeStr: formatSize(stat.size),
68
+ valid,
69
+ };
70
+ }
71
+
72
+ /**
73
+ * Recursively find all font files in a directory.
74
+ *
75
+ * @param {string} dir - Directory to walk.
76
+ * @returns {object[]} Array of objects from {@link analyzeFontFile}.
77
+ */
78
+ function findFonts(dir) {
79
+ const results = [];
80
+
81
+ function walk(d) {
82
+ let entries;
83
+ try { entries = fs.readdirSync(d); } catch { return; }
84
+ for (const entry of entries) {
85
+ if (entry === 'node_modules' || entry === '.git') continue;
86
+ const fp = path.join(d, entry);
87
+ let st;
88
+ try { st = fs.statSync(fp); } catch { continue; }
89
+ if (st.isDirectory()) {
90
+ walk(fp);
91
+ } else if (FONT_EXTS.has(path.extname(entry).toLowerCase())) {
92
+ try { results.push(analyzeFontFile(fp)); } catch { /* skip unreadable */ }
93
+ }
94
+ }
95
+ }
96
+
97
+ walk(dir);
98
+ return results;
99
+ }
100
+
101
+ /**
102
+ * Parse unicode-range declarations from CSS and return the set of code points referenced.
103
+ *
104
+ * @param {string} cssContent - Raw CSS text (may contain @font-face blocks).
105
+ * @returns {Set<number>} Set of Unicode code points.
106
+ */
107
+ function extractUsedChars(cssContent) {
108
+ if (typeof cssContent !== 'string') throw new TypeError('cssContent must be a string');
109
+ const codePoints = new Set();
110
+ const rangeDecls = cssContent.match(/unicode-range\s*:\s*([^;]+)/gi) || [];
111
+
112
+ for (const decl of rangeDecls) {
113
+ const value = decl.replace(/unicode-range\s*:\s*/i, '').trim();
114
+ for (const segment of value.split(',')) {
115
+ const s = segment.trim();
116
+ // Match U+XXXX or U+XXXX-XXXX
117
+ const m = s.match(/U\+([0-9A-Fa-f]+)(?:-([0-9A-Fa-f]+))?/i);
118
+ if (!m) continue;
119
+ const start = parseInt(m[1], 16);
120
+ const end = m[2] ? parseInt(m[2], 16) : start;
121
+ for (let cp = start; cp <= end && cp <= 0x10FFFF; cp++) {
122
+ codePoints.add(cp);
123
+ }
124
+ }
125
+ }
126
+
127
+ return codePoints;
128
+ }
129
+
130
+ /**
131
+ * Estimate WOFF2 conversion savings for a list of analyzed fonts.
132
+ *
133
+ * Fonts already in WOFF2 format are assumed to have no savings.
134
+ * Other formats are estimated to save ~30 % after conversion.
135
+ *
136
+ * @param {object[]} fonts - Array from {@link findFonts} or {@link analyzeFontFile}.
137
+ * @returns {object[]} Same array with added suggestedFormat, potentialSavings, savingsStr.
138
+ */
139
+ function suggestSubset(fonts) {
140
+ return fonts.map(f => {
141
+ const savings = f.format === 'WOFF2' ? 0 : Math.round(f.size * 0.3);
142
+ return {
143
+ ...f,
144
+ suggestedFormat: 'WOFF2',
145
+ potentialSavings: savings,
146
+ savingsStr: formatSize(savings),
147
+ };
148
+ });
149
+ }
150
+
151
+ /**
152
+ * Format a byte count as a human-readable string.
153
+ *
154
+ * @param {number} bytes - Number of bytes.
155
+ * @returns {string} Human-readable size string.
156
+ */
157
+ function formatSize(bytes) {
158
+ if (bytes < 1024) return bytes + 'B';
159
+ if (bytes < 1048576) return (bytes / 1024).toFixed(1) + 'KB';
160
+ return (bytes / 1048576).toFixed(2) + 'MB';
161
+ }
162
+
163
+ /**
164
+ * Format a detailed report for a list of analyzed font files.
165
+ *
166
+ * @param {object[]} fonts - Array from {@link findFonts}.
167
+ * @returns {string} Human-readable report.
168
+ */
169
+ function formatReport(fonts) {
170
+ if (!fonts.length) return 'No font files found.';
171
+ const lines = [
172
+ `Font Analysis Report`,
173
+ `====================`,
174
+ `Found ${fonts.length} font file(s):`,
175
+ '',
176
+ ];
177
+ for (const f of fonts) {
178
+ lines.push(` ${f.file}`);
179
+ lines.push(` Format : ${f.format}`);
180
+ lines.push(` Size : ${f.sizeStr}`);
181
+ lines.push(` Valid : ${f.valid ? 'yes' : 'no'}`);
182
+ }
183
+ return lines.join('\n');
184
+ }
185
+
186
+ /**
187
+ * Produce a one-line summary of the font list.
188
+ *
189
+ * @param {object[]} fonts - Array from {@link findFonts}.
190
+ * @returns {string} Single-line summary.
191
+ */
192
+ function summary(fonts) {
193
+ if (!fonts.length) return 'No fonts found.';
194
+ const totalSize = fonts.reduce((acc, f) => acc + f.size, 0);
195
+ const formats = [...new Set(fonts.map(f => f.format))].join(', ');
196
+ return `${fonts.length} font(s), ${formatSize(totalSize)} total, formats: ${formats}`;
197
+ }
198
+
199
+ module.exports = {
200
+ analyzeFontFile,
201
+ findFonts,
202
+ extractUsedChars,
203
+ suggestSubset,
204
+ formatSize,
205
+ formatReport,
206
+ summary,
207
+ };
@@ -0,0 +1,154 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @fileoverview Tests for fontsubset.
5
+ * @author idirdev
6
+ */
7
+
8
+ const { describe, it } = require('node:test');
9
+ const assert = require('node:assert/strict');
10
+ const fs = require('fs');
11
+ const os = require('os');
12
+ const path = require('path');
13
+ const {
14
+ formatSize,
15
+ extractUsedChars,
16
+ suggestSubset,
17
+ findFonts,
18
+ formatReport,
19
+ summary,
20
+ } = require('../src/index');
21
+
22
+ // ── formatSize ────────────────────────────────────────────────────────────────
23
+
24
+ describe('formatSize', () => {
25
+ it('formats byte values under 1 KB', () => {
26
+ assert.equal(formatSize(500), '500B');
27
+ });
28
+
29
+ it('formats kilobyte values', () => {
30
+ assert.ok(formatSize(2048).includes('KB'));
31
+ });
32
+
33
+ it('formats megabyte values', () => {
34
+ assert.ok(formatSize(2 * 1048576).includes('MB'));
35
+ });
36
+
37
+ it('handles zero bytes', () => {
38
+ assert.equal(formatSize(0), '0B');
39
+ });
40
+ });
41
+
42
+ // ── extractUsedChars ──────────────────────────────────────────────────────────
43
+
44
+ describe('extractUsedChars', () => {
45
+ it('parses a basic unicode-range declaration', () => {
46
+ const chars = extractUsedChars('@font-face { unicode-range: U+0020-007F; }');
47
+ assert.ok(chars.size > 0);
48
+ // U+0041 = 'A', must be in the basic Latin range
49
+ assert.ok(chars.has(0x41));
50
+ });
51
+
52
+ it('returns empty set when no unicode-range is present', () => {
53
+ const chars = extractUsedChars('body { font-family: Arial; }');
54
+ assert.equal(chars.size, 0);
55
+ });
56
+
57
+ it('handles a single code point (no range)', () => {
58
+ const chars = extractUsedChars('unicode-range: U+0041;');
59
+ assert.ok(chars.has(0x41));
60
+ assert.equal(chars.size, 1);
61
+ });
62
+
63
+ it('handles multiple comma-separated ranges', () => {
64
+ const chars = extractUsedChars('unicode-range: U+0041, U+0042;');
65
+ assert.ok(chars.has(0x41));
66
+ assert.ok(chars.has(0x42));
67
+ assert.equal(chars.size, 2);
68
+ });
69
+ });
70
+
71
+ // ── suggestSubset ─────────────────────────────────────────────────────────────
72
+
73
+ describe('suggestSubset', () => {
74
+ it('suggests WOFF2 for TrueType fonts', () => {
75
+ const r = suggestSubset([{ file: 'a.ttf', format: 'TrueType', size: 50000 }]);
76
+ assert.equal(r[0].suggestedFormat, 'WOFF2');
77
+ assert.ok(r[0].potentialSavings > 0);
78
+ });
79
+
80
+ it('reports zero savings for fonts already in WOFF2', () => {
81
+ const r = suggestSubset([{ file: 'b.woff2', format: 'WOFF2', size: 30000 }]);
82
+ assert.equal(r[0].potentialSavings, 0);
83
+ });
84
+
85
+ it('preserves original font properties', () => {
86
+ const input = [{ file: 'c.woff', format: 'WOFF', size: 20000, sizeStr: '19.5KB' }];
87
+ const r = suggestSubset(input);
88
+ assert.equal(r[0].file, 'c.woff');
89
+ assert.equal(r[0].sizeStr, '19.5KB');
90
+ });
91
+
92
+ it('estimates ~30% savings for non-WOFF2 fonts', () => {
93
+ const size = 100000;
94
+ const r = suggestSubset([{ file: 'x.ttf', format: 'TrueType', size }]);
95
+ assert.equal(r[0].potentialSavings, Math.round(size * 0.3));
96
+ });
97
+ });
98
+
99
+ // ── findFonts ─────────────────────────────────────────────────────────────────
100
+
101
+ describe('findFonts', () => {
102
+ it('returns an array (possibly empty) for a temp directory', () => {
103
+ const r = findFonts(os.tmpdir());
104
+ assert.ok(Array.isArray(r));
105
+ });
106
+
107
+ it('finds a fake .woff2 file written to a temp dir', () => {
108
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fontsubset-test-'));
109
+ const fakeFont = path.join(tmpDir, 'test.woff2');
110
+ // Write wOF2 magic bytes followed by padding
111
+ const buf = Buffer.alloc(16);
112
+ buf.write('wOF2', 0, 'ascii');
113
+ fs.writeFileSync(fakeFont, buf);
114
+ try {
115
+ const r = findFonts(tmpDir);
116
+ assert.ok(r.length >= 1);
117
+ assert.equal(r[0].format, 'WOFF2');
118
+ } finally {
119
+ fs.unlinkSync(fakeFont);
120
+ fs.rmdirSync(tmpDir);
121
+ }
122
+ });
123
+ });
124
+
125
+ // ── formatReport ──────────────────────────────────────────────────────────────
126
+
127
+ describe('formatReport', () => {
128
+ it('returns a no-fonts message for empty array', () => {
129
+ assert.ok(formatReport([]).includes('No font files'));
130
+ });
131
+
132
+ it('includes file name and format in report', () => {
133
+ const report = formatReport([{ file: 'font.ttf', format: 'TrueType', sizeStr: '48.8KB', valid: true }]);
134
+ assert.ok(report.includes('font.ttf'));
135
+ assert.ok(report.includes('TrueType'));
136
+ });
137
+ });
138
+
139
+ // ── summary ───────────────────────────────────────────────────────────────────
140
+
141
+ describe('summary', () => {
142
+ it('returns a message for empty font list', () => {
143
+ assert.ok(summary([]).includes('No fonts'));
144
+ });
145
+
146
+ it('reports correct file count', () => {
147
+ const fonts = [
148
+ { file: 'a.ttf', format: 'TrueType', size: 40000 },
149
+ { file: 'b.woff2', format: 'WOFF2', size: 20000 },
150
+ ];
151
+ const s = summary(fonts);
152
+ assert.ok(s.includes('2 font'));
153
+ });
154
+ });