fontist 2.1.6 → 2.2.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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +5 -0
  3. data/.rubocop_todo.yml +184 -120
  4. data/README.adoc +1 -1
  5. data/TODO.fontist-v5.md +196 -0
  6. data/fontist.gemspec +1 -1
  7. data/lib/fontist/cache/store.rb +17 -7
  8. data/lib/fontist/cli.rb +116 -0
  9. data/lib/fontist/errors.rb +61 -0
  10. data/lib/fontist/extract.rb +13 -0
  11. data/lib/fontist/font.rb +9 -1
  12. data/lib/fontist/font_collection.rb +1 -0
  13. data/lib/fontist/font_finder.rb +154 -0
  14. data/lib/fontist/font_installer.rb +172 -15
  15. data/lib/fontist/font_model.rb +1 -0
  16. data/lib/fontist/font_path.rb +4 -4
  17. data/lib/fontist/font_style.rb +17 -0
  18. data/lib/fontist/format_matcher.rb +176 -0
  19. data/lib/fontist/format_spec.rb +80 -0
  20. data/lib/fontist/formula.rb +126 -215
  21. data/lib/fontist/formula_picker.rb +39 -4
  22. data/lib/fontist/import/create_formula.rb +80 -3
  23. data/lib/fontist/import/formula_builder.rb +5 -1
  24. data/lib/fontist/import/google/font_database.rb +15 -153
  25. data/lib/fontist/import/google/formula_builder.rb +26 -0
  26. data/lib/fontist/import/google/formula_builders/base_formula_builder.rb +93 -0
  27. data/lib/fontist/import/google/formula_builders/formula_builder_v4.rb +155 -0
  28. data/lib/fontist/import/google/formula_builders/formula_builder_v5.rb +193 -0
  29. data/lib/fontist/import/google_fonts_importer.rb +17 -5
  30. data/lib/fontist/import/{macos.rb → macos_importer.rb} +4 -2
  31. data/lib/fontist/import/recursive_extraction.rb +2 -0
  32. data/lib/fontist/import/{sil_import.rb → sil_importer.rb} +3 -1
  33. data/lib/fontist/import/upgrade_formulas.rb +1 -1
  34. data/lib/fontist/import/v4_to_v5_migrator.rb +263 -0
  35. data/lib/fontist/import_cli.rb +20 -2
  36. data/lib/fontist/indexes/index_mixin.rb +8 -4
  37. data/lib/fontist/manifest.rb +27 -1
  38. data/lib/fontist/path_scanning.rb +1 -1
  39. data/lib/fontist/resource.rb +54 -0
  40. data/lib/fontist/resource_collection.rb +18 -0
  41. data/lib/fontist/system_index.rb +18 -9
  42. data/lib/fontist/utils/downloader.rb +0 -2
  43. data/lib/fontist/utils/github_client.rb +5 -2
  44. data/lib/fontist/utils/github_url.rb +4 -3
  45. data/lib/fontist/utils.rb +1 -1
  46. data/lib/fontist/version.rb +1 -1
  47. data/lib/fontist.rb +2 -2
  48. metadata +19 -8
@@ -0,0 +1,196 @@
1
+ # Fontist v5 Schema Implementation Plan
2
+
3
+ ## Overview
4
+
5
+ This document outlines the implementation plan for Formula Schema v5 with
6
+ multi-format font support. The architecture uses **v5-only classes** -
7
+ migration from v4 to v5 is handled by a separate migration script.
8
+
9
+ ## Architecture
10
+
11
+ ### Core Principle: v5 Only
12
+
13
+ All formula classes now use v5 schema with:
14
+ - `schema_version: 5` (default)
15
+ - Format metadata on resources (`format`, `variable_axes`)
16
+ - Format metadata on styles (`formats`, `variable_font`, `variable_axes`)
17
+
18
+ ### Class Hierarchy
19
+
20
+ ```
21
+ Formula (v5 only)
22
+ ├── schema_version: 5
23
+ ├── resources: ResourceCollection
24
+ │ └── Resource
25
+ │ ├── format: string (ttf, otf, woff, woff2, ttc, otc, dfont)
26
+ │ └── variable_axes: array (e.g., ["wght", "wdth"])
27
+ ├── fonts: FontModel[]
28
+ │ └── styles: FontStyle[]
29
+ │ ├── formats: array
30
+ │ ├── variable_font: boolean
31
+ │ └── variable_axes: array
32
+ └── font_collections: FontCollection[]
33
+ ```
34
+
35
+ ## Implementation Status
36
+
37
+ ### Phase 1: Core Models ✅ DONE
38
+
39
+ - [x] FormatSpec model (`lib/fontist/format_spec.rb`)
40
+ - [x] FormatMatcher service (`lib/fontist/format_matcher.rb`)
41
+ - [x] FontFinder service (`lib/fontist/font_finder.rb`)
42
+ - [x] v5-only Formula class (`lib/fontist/formula.rb`)
43
+ - [x] v5-only Resource class (`lib/fontist/resource.rb`)
44
+ - [x] v5-only FontStyle class (`lib/fontist/font_style.rb`)
45
+ - [x] ResourceCollection class (`lib/fontist/resource_collection.rb`)
46
+ - [x] All 65 tests passing
47
+
48
+ ### Phase 2: Import System Fixes ✅ DONE
49
+
50
+ #### Importer Format Metadata Status
51
+
52
+ | Importer | format in resources | variable_axes | Status |
53
+ |----------|-------------------|---------------|--------|
54
+ | Google V5 | ✅ Dynamic | ✅ Yes | DONE |
55
+ | Google V4 | ✅ Hardcoded "ttf" | ⚠️ Skipped | N/A |
56
+ | macOS | ✅ Via CreateFormula | ✅ Via CreateFormula | DONE |
57
+ | SIL | ✅ Via CreateFormula | ✅ Via CreateFormula | DONE |
58
+
59
+ #### Tasks
60
+
61
+ - [x] Fix `CreateFormula` class to detect and add format metadata
62
+ - [x] macOS importer uses CreateFormula (now has format detection)
63
+ - [x] SIL importer uses CreateFormula (now has format detection)
64
+ - [x] Verify Google V5 importer works correctly
65
+
66
+ ### Phase 3: Migration Script ✅ DONE
67
+
68
+ Create `lib/fontist/import/v4_to_v5_migrator.rb`: ✅ Created
69
+
70
+ Migration features:
71
+ - [x] Reads v4 YAML
72
+ - [x] Adds schema_version: 5
73
+ - [x] Detects format from file extensions
74
+ - [x] Detects variable fonts from filename patterns
75
+ - [x] Writes v5 YAML
76
+ - [x] CLI command: `fontist migrate-formulas INPUT [OUTPUT]`
77
+
78
+ ### Phase 4: Run Imports ✅ DONE (via migration)
79
+
80
+ All existing formulas migrated to v5 schema. Fresh imports can be run if needed:
81
+
82
+ ```bash
83
+ # Google Fonts (if fresh import needed)
84
+ fontist import google --schema-version=5 --output-path=./Formulas/google --force
85
+
86
+ # macOS Fonts (if fresh import needed)
87
+ fontist import macos --schema-version=5 --output-path=./Formulas/macos --force
88
+
89
+ # SIL Fonts (if fresh import needed)
90
+ fontist import sil --schema-version=5 --output-path=./Formulas/sil --force
91
+ ```
92
+
93
+ ### Phase 5: Migrate Existing Formulas ✅ DONE
94
+
95
+ ```bash
96
+ # Run migration on all existing v4 formulas
97
+ fontist migrate-formulas ../formulas/Formulas ../formulas/Formulas --verbose
98
+ ```
99
+
100
+ Results:
101
+ - Migrated: 3206 formulas
102
+ - Skipped (already v5): 474 formulas
103
+ - Failed: 1 (malformed YAML backup file)
104
+ - Total time: ~5 seconds
105
+
106
+ ## Critical Files
107
+
108
+ ### Core Classes (v5 only)
109
+
110
+ | File | Description | Status |
111
+ |------|-------------|--------|
112
+ | `lib/fontist/formula.rb` | v5 formula with schema_version | ✅ Done |
113
+ | `lib/fontist/resource.rb` | Resource with format/variable_axes | ✅ Done |
114
+ | `lib/fontist/resource_collection.rb` | Resource collection | ✅ Done |
115
+ | `lib/fontist/font_style.rb` | FontStyle with format metadata | ✅ Done |
116
+ | `lib/fontist/font_model.rb` | FontModel using FontStyle | ✅ Done |
117
+ | `lib/fontist/font_collection.rb` | FontCollection using FontModel | ✅ Done |
118
+
119
+ ### Services
120
+
121
+ | File | Description | Status |
122
+ |------|-------------|--------|
123
+ | `lib/fontist/format_spec.rb` | FormatSpec model | ✅ Done |
124
+ | `lib/fontist/format_matcher.rb` | Format matching service | ✅ Done |
125
+ | `lib/fontist/font_finder.rb` | Font discovery by capabilities | ✅ Done |
126
+
127
+ ### Import System
128
+
129
+ | File | Description | Status |
130
+ |------|-------------|--------|
131
+ | `lib/fontist/import/create_formula.rb` | Generic formula creator | ✅ Fixed |
132
+ | `lib/fontist/import/formula_builder.rb` | Base formula builder | ✅ Works |
133
+ | `lib/fontist/import/google/formula_builder_v5.rb` | Google V5 builder | ✅ Done |
134
+ | `lib/fontist/import/macos_importer.rb` | macOS importer | ✅ Works |
135
+ | `lib/fontist/import/sil_importer.rb` | SIL importer | ✅ Works |
136
+ | `lib/fontist/import/v4_to_v5_migrator.rb` | Migration script | ✅ Created |
137
+
138
+ ## Next Steps
139
+
140
+ 1. ~~**Fix CreateFormula class**~~ - ✅ Done
141
+ 2. ~~**Fix macOS importer**~~ - ✅ Works via CreateFormula
142
+ 3. ~~**Fix SIL importer**~~ - ✅ Works via CreateFormula
143
+ 4. ~~**Create V4→V5 migrator**~~ - ✅ Created
144
+ 5. ~~**Migrate formulas**~~ - ✅ 3206 migrated, 474 skipped
145
+ 6. ~~**Commit formulas**~~ - ✅ Committed to v5 branch (3cbe032)
146
+ 7. ~~**Fix V5 builder for variable fonts**~~ - ✅ Done (8e0e568)
147
+ 8. **Run fresh Google import** - Import with v5 schema to get WOFF2/variable fonts
148
+ 9. **Verify** - Test installation with v5 formulas
149
+
150
+ ## v5 Formula Example (Roboto Flex)
151
+
152
+ ```yaml
153
+ schema_version: 5
154
+ resources:
155
+ woff2_variable:
156
+ format: woff2
157
+ variable_axes: [GRAD, XOPQ, XTRA, YOPQ, YTAS, YTDE, YTFI, YTLC, YTUC, opsz, slnt, wdth, wght]
158
+ ttf_variable:
159
+ format: ttf
160
+ variable_axes: [GRAD, XOPQ, XTRA, YOPQ, YTAS, YTDE, YTFI, YTLC, YTUC, opsz, slnt, wdth, wght]
161
+ fonts:
162
+ - styles:
163
+ - variable_font: true
164
+ variable_axes: [GRAD, XOPQ, XTRA, YOPQ, YTAS, YTDE, YTFI, YTLC, YTUC, opsz, slnt, wdth, wght]
165
+ formats: [ttf, woff2]
166
+ ```
167
+
168
+ ## Breaking Changes
169
+
170
+ - v4 formulas are no longer supported directly
171
+ - Migration or re-import required for all formulas
172
+ - All formulas must have `schema_version: 5`
173
+ - Resources must have `format` field in v5
174
+
175
+ ## Format Detection Logic
176
+
177
+ ### File Extension → Format
178
+
179
+ | Extension | Format |
180
+ |-----------|--------|
181
+ | .ttf | ttf |
182
+ | .otf | otf |
183
+ | .woff | woff |
184
+ | .woff2 | woff2 |
185
+ | .ttc | ttc |
186
+ | .otc | otc |
187
+ | .dfont | dfont |
188
+
189
+ ### Filename Pattern → Variable Axes
190
+
191
+ | Pattern | Axes |
192
+ |---------|------|
193
+ | `Font[wght].ttf` | ["wght"] |
194
+ | `Font[wght,wdth].ttf` | ["wght", "wdth"] |
195
+ | `Font-Variable.ttf` | Detect from font file |
196
+ | `Font-VF.ttf` | Detect from font file |
data/fontist.gemspec CHANGED
@@ -30,7 +30,7 @@ Gem::Specification.new do |spec|
30
30
  spec.executables = ["fontist"]
31
31
 
32
32
  spec.add_dependency "down", "~> 5.0"
33
- spec.add_dependency "excavate", "~> 0.3", ">= 0.3.8"
33
+ spec.add_dependency "excavate", "~> 1.0", ">= 1.0.3"
34
34
  spec.add_dependency "fontisan", "~> 0.2", ">= 0.2.11"
35
35
  spec.add_dependency "fuzzy_match", "~> 2.1"
36
36
  spec.add_dependency "git", "> 1.0"
@@ -79,12 +79,17 @@ module Fontist
79
79
  return nil unless File.exist?(cache_path(key))
80
80
 
81
81
  begin
82
- Marshal.load(File.read(cache_path(key)))
83
- rescue ArgumentError, TypeError => e
82
+ # Use binary read mode for Marshal data - critical on Windows
83
+ Marshal.load(File.binread(cache_path(key)))
84
+ rescue ArgumentError, TypeError, EOFError
84
85
  # Cache file is corrupted - delete it and return nil
85
86
  # This can happen on Windows when file is read while being written,
86
87
  # or when cache files from previous runs are corrupted
87
- File.delete(cache_path(key)) rescue nil
88
+ begin
89
+ File.delete(cache_path(key))
90
+ rescue StandardError
91
+ nil
92
+ end
88
93
  nil
89
94
  end
90
95
  end
@@ -92,15 +97,20 @@ module Fontist
92
97
  def write_entry(key, entry)
93
98
  # Use temp file + atomic rename to prevent race conditions
94
99
  # This ensures readers never see partial writes, even on Windows
95
- temp_path = cache_path(key) + ".tmp"
100
+ temp_path = "#{cache_path(key)}.tmp"
96
101
 
97
- File.write(temp_path, Marshal.dump(entry))
102
+ # Use binary write mode for Marshal data - critical on Windows
103
+ File.binwrite(temp_path, Marshal.dump(entry))
98
104
  # Atomic rename (overwrites target atomically)
99
105
  # File.rename is atomic on all platforms for same filesystem
100
106
  File.rename(temp_path, cache_path(key))
101
- rescue => e
107
+ rescue StandardError => e
102
108
  # Clean up temp file if rename fails
103
- File.delete(temp_path) rescue nil
109
+ begin
110
+ File.delete(temp_path)
111
+ rescue StandardError
112
+ nil
113
+ end
104
114
  raise e
105
115
  end
106
116
 
data/lib/fontist/cli.rb CHANGED
@@ -116,6 +116,31 @@ module Fontist
116
116
  type: :string, aliases: :l,
117
117
  enum: ["fontist", "user", "system"],
118
118
  desc: "Install location: fontist (default), user, system"
119
+ # Format selection options
120
+ option :format,
121
+ type: :string,
122
+ desc: "Font format to install (ttf, otf, woff, woff2, ttc, otc). " \
123
+ "If format not available, will transcode from available formats."
124
+ option :variable_axes,
125
+ type: :string,
126
+ desc: "Variable axes to match (comma-separated, e.g., 'wght,wdth')"
127
+ option :prefer_variable,
128
+ type: :boolean,
129
+ desc: "Prefer variable fonts over static fonts"
130
+ option :prefer_format,
131
+ type: :string,
132
+ desc: "Preferred format when multiple available"
133
+ option :transcode_path,
134
+ type: :string,
135
+ desc: "Directory to save transcoded fonts (default: same as install location)"
136
+ option :keep_original,
137
+ type: :boolean,
138
+ default: true,
139
+ desc: "Keep original font after transcoding"
140
+ # Collection options
141
+ option :collection_index,
142
+ type: :numeric,
143
+ desc: "Extract specific font from TTC/OTC collection (0-indexed)"
119
144
  def install(*fonts)
120
145
  handle_class_options(options)
121
146
 
@@ -216,6 +241,52 @@ module Fontist
216
241
  handle_error(e)
217
242
  end
218
243
 
244
+ desc "find", "Find fonts by capabilities"
245
+ option :axes, type: :string,
246
+ desc: "Variable axes to match (comma-separated, e.g., 'wght,wdth')"
247
+ option :variable, type: :boolean,
248
+ desc: "Find all variable fonts"
249
+ option :category, type: :string,
250
+ desc: "Filter by category (sans-serif, serif, monospace, display)"
251
+ option :format, type: :string,
252
+ desc: "Filter by format (ttf, otf, woff2)"
253
+ option :json, type: :boolean,
254
+ desc: "Output as JSON"
255
+ def find
256
+ handle_class_options(options)
257
+
258
+ require_relative "font_finder"
259
+ require_relative "format_spec"
260
+
261
+ finder = FontFinder.new(
262
+ format_spec: FormatSpec.new(format: options[:format]),
263
+ category: options[:category],
264
+ )
265
+
266
+ results = if options[:variable]
267
+ finder.variable_fonts
268
+ elsif options[:axes]
269
+ axes = options[:axes].split(",").map(&:strip)
270
+ finder.by_axes(axes)
271
+ elsif options[:category]
272
+ finder.by_category(options[:category])
273
+ else
274
+ error("Please specify --axes, --variable, or --category",
275
+ STATUS_UNKNOWN_ERROR)
276
+ return
277
+ end
278
+
279
+ if options[:json]
280
+ Fontist.ui.say(JSON.pretty_generate(results.map(&:to_h)))
281
+ else
282
+ print_find_results(results)
283
+ end
284
+
285
+ success
286
+ rescue Fontist::Errors::GeneralError => e
287
+ handle_error(e)
288
+ end
289
+
219
290
  desc "update", "Update formulas"
220
291
  def update
221
292
  handle_class_options(options)
@@ -227,6 +298,34 @@ module Fontist
227
298
  STATUS_REPO_COULD_NOT_BE_UPDATED
228
299
  end
229
300
 
301
+ desc "migrate-formulas INPUT [OUTPUT]",
302
+ "Migrate v4 formulas to v5 schema"
303
+ option :verbose, type: :boolean, desc: "Show detailed progress"
304
+ option :dry_run, type: :boolean,
305
+ desc: "Show what would be done without making changes"
306
+ def migrate_formulas(input, output = nil)
307
+ handle_class_options(options)
308
+
309
+ require_relative "import/v4_to_v5_migrator"
310
+
311
+ migrator = Fontist::Import::V4ToV5Migrator.new(input, output,
312
+ verbose: options[:verbose],
313
+ dry_run: options[:dry_run])
314
+
315
+ results = migrator.migrate_all
316
+
317
+ if results[:failed].positive?
318
+ Fontist.ui.error("Migration completed with #{results[:failed]} error(s)")
319
+ STATUS_UNKNOWN_ERROR
320
+ else
321
+ Fontist.ui.success("Migrated #{results[:migrated]} formula(s), " \
322
+ "skipped #{results[:skipped]} already v5 formula(s)")
323
+ success
324
+ end
325
+ rescue Fontist::Errors::GeneralError => e
326
+ handle_error(e)
327
+ end
328
+
230
329
  desc "manifest SUBCOMMAND ...ARGS", "Manage font manifests"
231
330
  subcommand "manifest", Fontist::ManifestCLI
232
331
 
@@ -239,6 +338,7 @@ module Fontist
239
338
  "Uses `fnmatch` patterns."
240
339
  option :name_prefix, desc: "Prefix to add to all font family names, " \
241
340
  "e.g. 'Wine ' for compatibility fonts"
341
+ option :schema_version, type: :numeric, default: 5, desc: "Formula schema version (default: 5)"
242
342
  def create_formula(url)
243
343
  handle_class_options(options)
244
344
  name = Fontist::Import::CreateFormula.new(url, options).call
@@ -371,5 +471,21 @@ module Fontist
371
471
  end
372
472
  end
373
473
  # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
474
+
475
+ def print_find_results(results)
476
+ if results.empty?
477
+ Fontist.ui.say("No fonts found matching criteria")
478
+ return
479
+ end
480
+
481
+ Fontist.ui.say("Found #{results.count} fonts:\n")
482
+
483
+ results.each do |match|
484
+ Fontist.ui.say(" #{match.name}")
485
+ Fontist.ui.say(" Axes: #{match.axes.join(', ')}") if match.axes&.any?
486
+ Fontist.ui.say(" Format: #{match.format}") if match.format
487
+ Fontist.ui.say(" Category: #{match.category}") if match.category
488
+ end
489
+ end
374
490
  end
375
491
  end
@@ -53,6 +53,13 @@ module Fontist
53
53
  end
54
54
  end
55
55
 
56
+ class UnsupportedSchemaVersionError < GeneralError
57
+ def initialize(version)
58
+ super("Unsupported formula schema version: #{version}. " \
59
+ "Supported versions: 4, 5")
60
+ end
61
+ end
62
+
56
63
  class MainRepoNotFoundError < FormulaIndexNotFoundError; end
57
64
 
58
65
  class InvalidConfigAttributeError < GeneralError; end
@@ -197,5 +204,59 @@ module Fontist
197
204
  end.join("\n")
198
205
  end
199
206
  end
207
+
208
+ # Format not available for font
209
+ class FormatNotAvailableError < GeneralError
210
+ def initialize(font_name, requested_format, available_formats)
211
+ super("Format '#{requested_format}' not available for font " \
212
+ "'#{font_name}'. Available formats: #{available_formats.join(', ')}")
213
+ end
214
+ end
215
+
216
+ # Variable axes not supported
217
+ class VariableAxesNotSupportedError < GeneralError
218
+ def initialize(font_name, requested_axes)
219
+ super("Variable axes #{requested_axes.join(', ')} not supported by " \
220
+ "font '#{font_name}'")
221
+ end
222
+ end
223
+
224
+ # Transcoding errors
225
+ class UnsupportedTranscodeError < GeneralError
226
+ def initialize(source_format, target_format)
227
+ super("Cannot transcode from '#{source_format}' to '#{target_format}'. " \
228
+ "Supported: TTF/OTF to WOFF/WOFF2")
229
+ end
230
+ end
231
+
232
+ class TranscodeToolNotFoundError < GeneralError
233
+ def initialize(tool_name)
234
+ super("Transcode tool '#{tool_name}' not found. " \
235
+ "Please install the required package.")
236
+ end
237
+ end
238
+
239
+ class SourceNotFoundError < GeneralError
240
+ def initialize(path)
241
+ super("Source font file not found: #{path}")
242
+ end
243
+ end
244
+
245
+ class CollectionIndexError < GeneralError
246
+ def initialize(index, max_index)
247
+ super("Collection index #{index} out of range. " \
248
+ "Valid range: 0-#{max_index}")
249
+ end
250
+ end
251
+
252
+ # License-related transcoding error
253
+ class TranscodeLicenseNotAcceptedError < GeneralError
254
+ def initialize(font_name)
255
+ super("Font '#{font_name}' requires license agreement. " \
256
+ "Transcoding is a form of modification that may not be permitted " \
257
+ "by all licenses. Please accept the license with " \
258
+ "--accept-all-licenses to proceed with transcoding.")
259
+ end
260
+ end
200
261
  end
201
262
  end
@@ -21,5 +21,18 @@ module Fontist
21
21
  map "file", to: :file
22
22
  map "options", to: :options
23
23
  end
24
+
25
+ def empty?
26
+ format.nil? && file.nil? && options_empty?
27
+ end
28
+
29
+ private
30
+
31
+ def options_empty?
32
+ return true if options.nil?
33
+ return options.empty? if options.respond_to?(:empty?)
34
+ return options.file.nil? && options.fonts_sub_dir.nil? if options.is_a?(ExtractOptions)
35
+ false
36
+ end
24
37
  end
25
38
  end
data/lib/fontist/font.rb CHANGED
@@ -1,3 +1,5 @@
1
+ require_relative "format_spec"
2
+
1
3
  module Fontist
2
4
  class Font
3
5
  def initialize(options = {})
@@ -14,6 +16,9 @@ module Fontist
14
16
  @update_fontconfig = options[:update_fontconfig]
15
17
  @install_location = options[:location] || options[:install_location]
16
18
 
19
+ # Accept FormatSpec or create from options
20
+ @format_spec = options[:format_spec] || FormatSpec.from_options(options)
21
+
17
22
  validate_location_parameter!
18
23
  check_or_create_fontist_path!
19
24
  end
@@ -179,6 +184,8 @@ module Fontist
179
184
  options = {
180
185
  no_progress: @no_progress,
181
186
  location: @install_location,
187
+ format_spec: @format_spec,
188
+ confirmation: @confirmation,
182
189
  }
183
190
 
184
191
  if @by_formula
@@ -194,7 +201,8 @@ module Fontist
194
201
  size_limit: @size_limit,
195
202
  version: @version,
196
203
  smallest: @smallest,
197
- newest: @newest)
204
+ newest: @newest,
205
+ format_spec: @format_spec)
198
206
  .call(downloadable_formulas)
199
207
  end
200
208
 
@@ -1,6 +1,7 @@
1
1
  require "lutaml/model"
2
2
 
3
3
  module Fontist
4
+ # FontCollection - uses FontModel with v5 format metadata
4
5
  class FontCollection < Lutaml::Model::Serializable
5
6
  attribute :filename, :string
6
7
  attribute :source_filename, :string
@@ -0,0 +1,154 @@
1
+ require_relative "format_spec"
2
+ require_relative "format_matcher"
3
+
4
+ module Fontist
5
+ # Find fonts by their capabilities (axes, formats, etc.)
6
+ #
7
+ # Supports:
8
+ # - Find fonts with specific variable axes
9
+ # - Find fonts with any variable support
10
+ # - Find fonts by category (sans-serif, monospace, etc.)
11
+ #
12
+ # Examples:
13
+ # FontFinder.by_axes(["wght", "wdth"]) # Fonts with both axes
14
+ # FontFinder.variable_fonts # All variable fonts
15
+ # FontFinder.by_category("monospace") # Monospace fonts
16
+ #
17
+ class FontFinder
18
+ def initialize(format_spec: nil, category: nil)
19
+ @format_spec = format_spec
20
+ @category = category
21
+ end
22
+
23
+ # Find fonts that support ALL specified axes
24
+ def by_axes(axes)
25
+ raise ArgumentError, "axes must be an array" unless axes.is_a?(Array)
26
+
27
+ matching_formulas.flat_map do |formula|
28
+ next [] unless formula.v5?
29
+
30
+ resources = each_resource(formula)
31
+ resources = apply_format_filter(resources)
32
+ resources.select do |resource|
33
+ resource.variable_font? && axes_supported?(resource, axes)
34
+ end.map do |resource|
35
+ build_font_match(formula, resource.name, resource)
36
+ end
37
+ end.flatten
38
+ end
39
+
40
+ # Find all variable fonts
41
+ def variable_fonts
42
+ matching_formulas.flat_map do |formula|
43
+ next [] unless formula.v5?
44
+
45
+ resources = each_resource(formula)
46
+ resources = apply_format_filter(resources)
47
+ resources.select(&:variable_font?).map do |resource|
48
+ build_font_match(formula, resource.name, resource)
49
+ end
50
+ end.flatten
51
+ end
52
+
53
+ # Find fonts by category
54
+ def by_category(category)
55
+ matching_formulas.select do |formula|
56
+ extract_category(formula) == category
57
+ end.map do |formula|
58
+ resource_names = extract_resource_names(formula)
59
+ FontMatch.new(
60
+ name: formula.name,
61
+ resources: resource_names,
62
+ category: category,
63
+ )
64
+ end
65
+ end
66
+
67
+ private
68
+
69
+ def each_resource(formula)
70
+ return [] unless formula.resources
71
+
72
+ Array(formula.resources)
73
+ end
74
+
75
+ def extract_resource_names(formula)
76
+ return [] unless formula.resources
77
+
78
+ Array(formula.resources).map(&:name).compact
79
+ end
80
+
81
+ def build_font_match(formula, name, resource)
82
+ FontMatch.new(
83
+ name: formula.name,
84
+ resource: name,
85
+ axes: resource.axes_tags,
86
+ format: resource.format,
87
+ category: extract_category(formula),
88
+ )
89
+ end
90
+
91
+ def matching_formulas
92
+ @matching_formulas ||= Formula.all.select do |formula|
93
+ next false if @category && extract_category(formula) != @category
94
+
95
+ true
96
+ end
97
+ end
98
+
99
+ def axes_supported?(resource, required_axes)
100
+ available = resource.axes_tags
101
+ required_axes.all? { |axis| available.include?(axis.to_s) }
102
+ end
103
+
104
+ def extract_category(formula)
105
+ # Extract from formula metadata if available
106
+ # Note: Formula does not have a category attribute currently
107
+ # Category is detected from name heuristics
108
+ detect_category_from_name(formula.name)
109
+ end
110
+
111
+ def detect_category_from_name(name)
112
+ # Heuristics for common patterns
113
+ return "monospace" if name.match?(/mono/i)
114
+ return "sans-serif" if name.match?(/sans[-\s]?serif/i)
115
+ return "serif" if name.match?(/serif/i)
116
+
117
+ "sans-serif"
118
+ end
119
+
120
+ def apply_format_filter(resources)
121
+ return resources unless @format_spec&.has_constraints?
122
+
123
+ matcher = FormatMatcher.new(@format_spec)
124
+ matcher.filter_resources(resources)
125
+ end
126
+
127
+ end
128
+
129
+ # Result object for font matches
130
+ class FontMatch
131
+ attr_reader :name, :resource, :axes, :format, :category, :resources
132
+
133
+ def initialize(name:, resource: nil, axes: [], format: nil,
134
+ category: nil, resources: nil)
135
+ @name = name
136
+ @resource = resource
137
+ @axes = axes
138
+ @format = format
139
+ @category = category
140
+ @resources = resources
141
+ end
142
+
143
+ def to_h
144
+ {
145
+ name: name,
146
+ resource: resource,
147
+ resources: resources,
148
+ axes: axes,
149
+ format: format,
150
+ category: category,
151
+ }.compact
152
+ end
153
+ end
154
+ end