@emasoft/svg-matrix 1.3.0 → 1.3.2

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 CHANGED
@@ -840,15 +840,28 @@ svg-matrix vs standard JavaScript (float64):
840
840
  | Arc length | `2.8e-13` | `< 1e-50` | 37+ digits |
841
841
  | Bounding box | `1.1e-13` | `0` (exact) | 13+ digits |
842
842
  | Self-intersection | Boolean only | `1.4e-58` | 58+ digits |
843
+ | Bezier-Bezier intersection | `4e-7` | `2.3e-10` | svgpathtools compatible |
843
844
 
844
845
  ---
845
846
 
846
847
  ## Installation
847
848
 
848
- **Requires Node.js 24+** (released 2025)
849
+ **Requires Node.js 24+** or **Bun 1.0+**
850
+
851
+ ### Package Managers
849
852
 
850
853
  ```bash
854
+ # Bun (recommended - fastest)
855
+ bun add @emasoft/svg-matrix
856
+
857
+ # npm
851
858
  npm install @emasoft/svg-matrix
859
+
860
+ # pnpm
861
+ pnpm add @emasoft/svg-matrix
862
+
863
+ # yarn
864
+ yarn add @emasoft/svg-matrix
852
865
  ```
853
866
 
854
867
  ### In JavaScript/TypeScript
package/bin/svgfonts.js CHANGED
@@ -21,7 +21,7 @@ import {
21
21
  statSync,
22
22
  realpathSync,
23
23
  } from "fs";
24
- import { join, dirname, basename, extname, resolve, isAbsolute } from "path";
24
+ import { join, basename, extname, resolve, isAbsolute } from "path";
25
25
  import { createInterface } from "readline";
26
26
 
27
27
  // Import library modules
@@ -84,8 +84,11 @@ const DEFAULT_CONFIG = {
84
84
  // Embed options
85
85
  subset: true,
86
86
  full: false,
87
+ woff2: false,
87
88
  source: null, // 'google' | 'local' | 'fontget' | 'fnt'
88
89
  timeout: CONSTANTS.DEFAULT_TIMEOUT,
90
+ useCache: true,
91
+ searchAlternatives: false,
89
92
  // Replace options
90
93
  mapFile: null,
91
94
  autoDownload: true,
@@ -94,6 +97,13 @@ const DEFAULT_CONFIG = {
94
97
  restoreLinks: false,
95
98
  // Template options
96
99
  templateOutput: null,
100
+ // Cache options
101
+ cacheAction: "stats", // 'stats' | 'clean' | 'list'
102
+ maxAge: 30, // days
103
+ // Search options
104
+ query: null,
105
+ limit: 10,
106
+ threshold: 0.3,
97
107
  };
98
108
 
99
109
  let config = { ...DEFAULT_CONFIG };
@@ -139,6 +149,9 @@ ${colors.cyan}COMMANDS:${colors.reset}
139
149
  list List fonts in SVG
140
150
  interactive Interactive font management mode
141
151
  template Generate replacement map template
152
+ cache Manage font cache (stats, clean)
153
+ search Search for fonts by name with similarity matching
154
+ dedupe Detect and merge duplicate @font-face rules
142
155
 
143
156
  ${colors.cyan}GLOBAL OPTIONS:${colors.reset}
144
157
  -i, --input <file> Input SVG file(s)
@@ -156,8 +169,11 @@ ${colors.cyan}GLOBAL OPTIONS:${colors.reset}
156
169
  ${colors.cyan}EMBED OPTIONS:${colors.reset}
157
170
  --subset Only embed glyphs used in SVG (default)
158
171
  --full Embed complete font files
172
+ --woff2 Convert fonts to WOFF2 format (~30% smaller)
159
173
  --source <name> Preferred font source (google|local|fontget|fnt)
160
174
  --timeout <ms> Download timeout (default: 30000)
175
+ --no-cache Disable font caching (enabled by default)
176
+ --search-alternatives If font unavailable, search for alternatives
161
177
 
162
178
  ${colors.cyan}REPLACE OPTIONS:${colors.reset}
163
179
  --map <file> Path to replacement YAML
@@ -170,14 +186,28 @@ ${colors.cyan}EXTRACT OPTIONS:${colors.reset}
170
186
  ${colors.cyan}TEMPLATE OPTIONS:${colors.reset}
171
187
  --template-output <file> Output path for template (default: stdout)
172
188
 
189
+ ${colors.cyan}CACHE OPTIONS:${colors.reset}
190
+ --cache-action <action> Cache action: stats, clean, list (default: stats)
191
+ --max-age <days> Max cache age in days for clean (default: 30)
192
+
193
+ ${colors.cyan}SEARCH OPTIONS:${colors.reset}
194
+ --query <name> Font name to search for (required)
195
+ --limit <n> Max results to show (default: 10)
196
+ --threshold <0-1> Minimum similarity (default: 0.3)
197
+
173
198
  ${colors.cyan}EXAMPLES:${colors.reset}
174
199
  svgfonts embed icon.svg # Embed fonts with subsetting
175
200
  svgfonts embed --full icon.svg # Embed complete fonts
201
+ svgfonts embed --woff2 icon.svg # Embed with WOFF2 compression
176
202
  svgfonts list document.svg # List all fonts in SVG
177
203
  svgfonts replace --map fonts.yml *.svg # Apply replacement map
178
204
  svgfonts extract -o ./fonts doc.svg # Extract embedded fonts
179
205
  svgfonts interactive icon.svg # Interactive mode
180
206
  svgfonts template > fonts.yml # Generate template
207
+ svgfonts cache # Show font cache stats
208
+ svgfonts cache --cache-action clean # Clean old cached fonts
209
+ svgfonts search --query "roboto" # Search for fonts by name
210
+ svgfonts dedupe icon.svg # Merge duplicate @font-face rules
181
211
 
182
212
  ${colors.cyan}ENVIRONMENT:${colors.reset}
183
213
  SVGM_REPLACEMENT_MAP Path to default replacement map YAML
@@ -215,6 +245,9 @@ function parseArgs(args) {
215
245
  case "list":
216
246
  case "interactive":
217
247
  case "template":
248
+ case "cache":
249
+ case "search":
250
+ case "dedupe":
218
251
  if (!result.command) {
219
252
  result.command = arg;
220
253
  } else {
@@ -307,6 +340,41 @@ function parseArgs(args) {
307
340
  result.templateOutput = args[++i];
308
341
  break;
309
342
 
343
+ // Embed new options
344
+ case "--woff2":
345
+ result.woff2 = true;
346
+ break;
347
+
348
+ case "--no-cache":
349
+ result.useCache = false;
350
+ break;
351
+
352
+ case "--search-alternatives":
353
+ result.searchAlternatives = true;
354
+ break;
355
+
356
+ // Cache options
357
+ case "--cache-action":
358
+ result.cacheAction = args[++i];
359
+ break;
360
+
361
+ case "--max-age":
362
+ result.maxAge = parseInt(args[++i], 10);
363
+ break;
364
+
365
+ // Search options
366
+ case "--query":
367
+ result.query = args[++i];
368
+ break;
369
+
370
+ case "--limit":
371
+ result.limit = parseInt(args[++i], 10);
372
+ break;
373
+
374
+ case "--threshold":
375
+ result.threshold = parseFloat(args[++i]);
376
+ break;
377
+
310
378
  default:
311
379
  // Assume it's an input file
312
380
  if (!arg.startsWith("-")) {
@@ -601,6 +669,176 @@ function cmdTemplate() {
601
669
  }
602
670
  }
603
671
 
672
+ // ============================================================================
673
+ // COMMAND: CACHE
674
+ // ============================================================================
675
+ function cmdCache() {
676
+ // Initialize cache if needed
677
+ FontManager.initFontCache();
678
+
679
+ switch (config.cacheAction) {
680
+ case "stats": {
681
+ const stats = FontManager.getFontCacheStats();
682
+ log(`\n${colors.bright}Font Cache Statistics${colors.reset}`);
683
+ log(`${"─".repeat(40)}`);
684
+ log(` Location: ${FontManager.FONT_CACHE_DIR}`);
685
+ log(` Total fonts: ${stats.totalFonts}`);
686
+ log(` Total size: ${formatSize(stats.totalSize)}`);
687
+ if (stats.oldestAge > 0) {
688
+ const oldestDays = Math.floor(stats.oldestAge / (24 * 60 * 60 * 1000));
689
+ const newestDays = Math.floor(stats.newestAge / (24 * 60 * 60 * 1000));
690
+ log(` Oldest entry: ${oldestDays} days ago`);
691
+ log(` Newest entry: ${newestDays} days ago`);
692
+ } else {
693
+ log(` Oldest entry: N/A`);
694
+ log(` Newest entry: N/A`);
695
+ }
696
+ break;
697
+ }
698
+
699
+ case "clean": {
700
+ const maxAgeMs = config.maxAge * 24 * 60 * 60 * 1000;
701
+ const result = FontManager.cleanupFontCache(maxAgeMs);
702
+ if (result.removed > 0) {
703
+ logSuccess(`Cleaned ${result.removed} old cached fonts (${formatSize(result.freedBytes)})`);
704
+ } else {
705
+ log("No fonts to clean (all within max age)");
706
+ }
707
+ break;
708
+ }
709
+
710
+ case "list": {
711
+ // Read the cache index directly to get list
712
+ const indexPath = join(FontManager.FONT_CACHE_DIR, FontManager.FONT_CACHE_INDEX);
713
+ log(`\n${colors.bright}Cached Fonts${colors.reset}`);
714
+ log(`${"─".repeat(60)}`);
715
+ if (existsSync(indexPath)) {
716
+ try {
717
+ const index = JSON.parse(readFileSync(indexPath, "utf8"));
718
+ const entries = Object.entries(index.fonts);
719
+ if (entries.length > 0) {
720
+ for (const [key, data] of entries) {
721
+ const age = Math.floor((Date.now() - data.timestamp) / (24 * 60 * 60 * 1000));
722
+ log(` ${key.slice(0, 30).padEnd(30)} ${formatSize(data.size || 0).padStart(10)} ${age}d ago`);
723
+ }
724
+ } else {
725
+ log(" No cached fonts");
726
+ }
727
+ } catch {
728
+ log(" No cached fonts");
729
+ }
730
+ } else {
731
+ log(" No cached fonts");
732
+ }
733
+ break;
734
+ }
735
+
736
+ default:
737
+ logError(`Unknown cache action: ${config.cacheAction}`);
738
+ process.exit(CONSTANTS.EXIT_ERROR);
739
+ }
740
+ }
741
+
742
+ // ============================================================================
743
+ // COMMAND: SEARCH
744
+ // ============================================================================
745
+ async function cmdSearch() {
746
+ if (!config.query) {
747
+ logError("Missing --query option. Usage: svgfonts search --query <font-name>");
748
+ process.exit(CONSTANTS.EXIT_ERROR);
749
+ }
750
+
751
+ log(`\n${colors.bright}Searching for fonts matching: "${config.query}"${colors.reset}`);
752
+ log(`${"─".repeat(60)}`);
753
+
754
+ const results = await FontManager.searchSimilarFonts(config.query, {
755
+ limit: config.limit,
756
+ threshold: config.threshold,
757
+ });
758
+
759
+ if (results.length === 0) {
760
+ log(`No fonts found matching "${config.query}" with similarity >= ${config.threshold}`);
761
+ return;
762
+ }
763
+
764
+ log(`\n${colors.cyan}# Similarity Font Name${" ".repeat(25)}Source${colors.reset}`);
765
+ log(`${"─".repeat(60)}`);
766
+
767
+ results.forEach((result, idx) => {
768
+ const simPercent = Math.round(result.similarity * 100);
769
+ const simBar = "█".repeat(Math.round(simPercent / 10)) + "░".repeat(10 - Math.round(simPercent / 10));
770
+ const simColor = simPercent >= 80 ? colors.green : simPercent >= 50 ? colors.yellow : colors.dim;
771
+ log(
772
+ `${(idx + 1).toString().padStart(2)}. ${simColor}${simBar}${colors.reset} ${simPercent.toString().padStart(3)}% ${result.name.padEnd(28)} ${colors.dim}${result.source}${colors.reset}`
773
+ );
774
+ });
775
+
776
+ log(`\n${colors.dim}Use 'svgfonts embed --source google <svg>' to embed a font${colors.reset}`);
777
+ }
778
+
779
+ // ============================================================================
780
+ // COMMAND: DEDUPE
781
+ // ============================================================================
782
+ async function cmdDedupe(files) {
783
+ for (const file of files) {
784
+ logVerbose(`Processing: ${file}`);
785
+
786
+ const content = readFileSync(file, "utf8");
787
+
788
+ // Create backup
789
+ if (!config.noBackup && !config.dryRun) {
790
+ const backupPath = FontManager.createBackup(file, { noBackup: config.noBackup });
791
+ if (backupPath) {
792
+ logVerbose(`Backup created: ${backupPath}`);
793
+ }
794
+ }
795
+
796
+ const doc = parseSVG(content);
797
+
798
+ // Detect duplicates first
799
+ const { duplicates, total } = FontManager.detectDuplicateFontFaces(doc);
800
+
801
+ if (duplicates.length === 0) {
802
+ log(`${basename(file)}: No duplicate @font-face rules found (${total} total)`);
803
+ continue;
804
+ }
805
+
806
+ if (config.dryRun) {
807
+ log(`[dry-run] Would merge ${duplicates.length} duplicate font group(s) in: ${file}`);
808
+ for (const dup of duplicates) {
809
+ log(` - "${dup.family}" (${dup.weight}/${dup.style}): ${dup.count} occurrences`);
810
+ }
811
+ continue;
812
+ }
813
+
814
+ // Merge duplicates
815
+ const result = FontManager.mergeDuplicateFontFaces(doc);
816
+
817
+ if (result.removed > 0) {
818
+ const output = serializeSVG(doc);
819
+
820
+ // Validate if requested
821
+ if (config.validate) {
822
+ const validation = await FontManager.validateSvgAfterFontOperation(output, "dedupe");
823
+ if (!validation.valid) {
824
+ logError(`Validation failed for ${file}: ${validation.errors.join(", ")}`);
825
+ continue;
826
+ }
827
+ }
828
+
829
+ const outputPath = config.output || file;
830
+ writeFileSync(outputPath, output);
831
+
832
+ logSuccess(`Merged ${result.removed} duplicate @font-face rule(s): ${outputPath}`);
833
+ for (const dup of duplicates) {
834
+ log(` - "${dup.family}" (${dup.weight}/${dup.style})`);
835
+ }
836
+ } else {
837
+ log(`${basename(file)}: No duplicates merged`);
838
+ }
839
+ }
840
+ }
841
+
604
842
  // ============================================================================
605
843
  // COMMAND: INTERACTIVE
606
844
  // ============================================================================
@@ -721,13 +959,13 @@ async function cmdInteractive(files) {
721
959
  });
722
960
 
723
961
  const question = (prompt) =>
724
- new Promise((resolve) => rl.question(prompt, resolve));
962
+ new Promise((res) => rl.question(prompt, res));
725
963
 
726
964
  /**
727
965
  * Read single keypress (for arrow key navigation)
728
966
  */
729
- const readKey = () =>
730
- new Promise((resolve) => {
967
+ const _readKey = () =>
968
+ new Promise((res) => {
731
969
  const wasRaw = process.stdin.isRaw;
732
970
  if (process.stdin.setRawMode) {
733
971
  process.stdin.setRawMode(true);
@@ -737,7 +975,7 @@ async function cmdInteractive(files) {
737
975
  if (process.stdin.setRawMode) {
738
976
  process.stdin.setRawMode(wasRaw);
739
977
  }
740
- resolve(key.toString());
978
+ res(key.toString());
741
979
  });
742
980
  });
743
981
 
@@ -1361,6 +1599,18 @@ async function main() {
1361
1599
  process.exit(CONSTANTS.EXIT_SUCCESS);
1362
1600
  }
1363
1601
 
1602
+ // Handle cache command (no inputs needed)
1603
+ if (config.command === "cache") {
1604
+ cmdCache();
1605
+ process.exit(CONSTANTS.EXIT_SUCCESS);
1606
+ }
1607
+
1608
+ // Handle search command (no inputs needed)
1609
+ if (config.command === "search") {
1610
+ await cmdSearch();
1611
+ process.exit(CONSTANTS.EXIT_SUCCESS);
1612
+ }
1613
+
1364
1614
  // Collect files
1365
1615
  const files = collectSvgFiles(config.inputs, config.recursive);
1366
1616
 
@@ -1394,6 +1644,10 @@ async function main() {
1394
1644
  await cmdInteractive(files);
1395
1645
  break;
1396
1646
 
1647
+ case "dedupe":
1648
+ await cmdDedupe(files);
1649
+ break;
1650
+
1397
1651
  default:
1398
1652
  // Default to embed
1399
1653
  await cmdEmbed(files);