fontist 2.1.6 → 2.2.1

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 +10 -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 +173 -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 +133 -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
@@ -1,14 +1,18 @@
1
1
  require "excavate"
2
+ require_relative "format_matcher"
2
3
 
3
4
  module Fontist
4
5
  class FontInstaller
5
6
  attr_reader :location
6
7
 
7
- def initialize(formula, font_name: nil, no_progress: false, location: nil)
8
+ def initialize(formula, font_name: nil, no_progress: false, location: nil,
9
+ format_spec: nil, confirmation: "no")
8
10
  @formula = formula
9
11
  @font_name = font_name
10
12
  @no_progress = no_progress
11
13
  @location = InstallLocation.create(formula, location_type: location)
14
+ @format_spec = format_spec
15
+ @confirmation = confirmation
12
16
  end
13
17
 
14
18
  def install(confirmation:)
@@ -54,6 +58,7 @@ module Fontist
54
58
 
55
59
  def license_is_accepted?(confirmation)
56
60
  return true unless @formula.license_required?
61
+ return true if @formula.licensed_for_current_platform?
57
62
 
58
63
  "yes".casecmp?(confirmation)
59
64
  end
@@ -93,7 +98,20 @@ module Fontist
93
98
  end
94
99
 
95
100
  def resource_options
96
- @formula.resources.first
101
+ @resource_options ||= begin
102
+ if @formula.resources.size == 1 || !@formula.v5?
103
+ @formula.resources.first
104
+ elsif @format_spec&.has_constraints?
105
+ matcher = FormatMatcher.new(@format_spec)
106
+ matcher.select_preferred_resource(@formula.resources)
107
+ else
108
+ find_desktop_resource || @formula.resources.first
109
+ end
110
+ end
111
+ end
112
+
113
+ def find_desktop_resource
114
+ @formula.resources.find { |r| r.format && FormatMatcher::DESKTOP_FORMATS.include?(r.format) }
97
115
  end
98
116
 
99
117
  def font_file?(path)
@@ -105,13 +123,32 @@ module Fontist
105
123
  end
106
124
 
107
125
  def source_files
108
- @source_files ||= fonts.flat_map do |font|
109
- font.styles.map do |style|
110
- style.source_font || style.font
126
+ @source_files ||= begin
127
+ styles = filtered_styles
128
+
129
+ # Use FormatMatcher for filtering
130
+ if @format_spec&.has_constraints? && @formula.v5?
131
+ matcher = FormatMatcher.new(@format_spec)
132
+ styles = matcher.filter_styles(styles)
111
133
  end
134
+
135
+ file_names = styles.map { |s| s.source_font || s.font }
136
+
137
+ if @formula.v5? && resource_options&.source == "google" && file_names.any?
138
+ resource_basenames = Array(resource_options.files).map { |f| File.basename(f) }
139
+ unless file_names.any? { |f| resource_basenames.include?(f) }
140
+ return resource_basenames
141
+ end
142
+ end
143
+
144
+ file_names
112
145
  end
113
146
  end
114
147
 
148
+ def filtered_styles
149
+ fonts.flat_map(&:styles)
150
+ end
151
+
115
152
  def fonts
116
153
  @formula.all_fonts.select do |font|
117
154
  @font_name.nil? || font.name.casecmp?(@font_name)
@@ -129,23 +166,144 @@ module Fontist
129
166
  end
130
167
 
131
168
  def subdirectories
132
- @subdirectories ||= [@formula.extract].flatten.compact.filter_map(&:options).filter_map(&:fonts_sub_dir)
169
+ @subdirectories ||= begin
170
+ extracts = [@formula.extract].flatten.compact
171
+ # options is a collection, so we need to flatten it too
172
+ options = extracts.flat_map { |e| e.options }.compact
173
+ options.filter_map(&:fonts_sub_dir)
174
+ end
133
175
  end
134
176
 
135
177
  def install_font_file(source)
136
178
  source_basename = File.basename(source)
137
179
  target_name = target_filename(source_basename) || source_basename
180
+ source_format = detect_font_format(source)
181
+
182
+ # Check if transcoding is needed (format requested but not available)
183
+ if @format_spec&.format && @format_spec.format != source_format
184
+ check_transcode_license_warning!
185
+ install_with_conversion(source, target_name, source_format)
186
+ else
187
+ @location.install_font(source, target_name)
188
+ end
189
+ end
190
+
191
+ def detect_font_format(path)
192
+ ext = File.extname(path).downcase.delete(".")
193
+ case ext
194
+ when "ttf", "otf", "woff", "woff2", "ttc", "otc", "dfont"
195
+ ext
196
+ else
197
+ "ttf" # Default
198
+ end
199
+ end
200
+
201
+ # Check and warn about license implications of transcoding
202
+ def check_transcode_license_warning!
203
+ return unless @formula.license_required?
204
+
205
+ if @confirmation != "yes"
206
+ raise Errors::TranscodeLicenseNotAcceptedError.new(
207
+ @formula.fonts.first&.name || @formula.name,
208
+ )
209
+ end
138
210
 
139
- # Use location object to handle installation
140
- # This handles all the logic for:
141
- # - Checking if font exists
142
- # - Managed vs non-managed location handling
143
- # - Unique filename generation
144
- # - Index updates
145
- # - Warning messages
146
- @location.install_font(source, target_name)
211
+ # User has accepted the license, but we still warn them
212
+ # that transcoding may not be permitted by all licenses
213
+ Fontist.ui.warn("\n#{'=' * 60}")
214
+ Fontist.ui.warn("LICENSE TRANSCODING NOTICE")
215
+ Fontist.ui.warn("=" * 60)
216
+ Fontist.ui.warn(
217
+ "You are transcoding a font that requires a license agreement.",
218
+ )
219
+ Fontist.ui.warn(
220
+ "Some font licenses do not permit conversion or modification,",
221
+ )
222
+ Fontist.ui.warn(
223
+ "of which transcoding is a type. Please ensure your use of",
224
+ )
225
+ Fontist.ui.warn("this font complies with the license terms.")
226
+ Fontist.ui.warn("#{'=' * 60}\n")
227
+ end
228
+
229
+ def install_with_conversion(source, target_name, source_format)
230
+ target_format = @format_spec.format
231
+
232
+ # Check if Fontisan can convert between formats
233
+ matcher = FormatMatcher.new(@format_spec)
234
+ unless matcher.can_convert?(source_format, target_format)
235
+ Fontist.ui.warn(
236
+ "Cannot convert from #{source_format} to #{target_format}",
237
+ )
238
+ Fontist.ui.warn("Installing original format instead")
239
+ return @location.install_font(source, target_name)
240
+ end
241
+
242
+ begin
243
+ converted_path = convert_with_fontisan(source, target_format)
244
+
245
+ # Determine where to save converted font
246
+ converted_name = target_name.sub(/\.[^.]+$/, ".#{target_format}")
247
+
248
+ Fontist.ui.success(
249
+ "Converted #{source_format} to #{target_format}: #{converted_name}",
250
+ )
251
+
252
+ # Install converted font
253
+ result = @location.install_font(converted_path, converted_name)
254
+
255
+ # Keep original if requested and transcode_path specified
256
+ if @format_spec.keep_original && @format_spec.transcode_path
257
+ @location.install_font(source, target_name)
258
+ end
259
+
260
+ # Clean up temp converted file if Fontisan created one
261
+ cleanup_temp_file(converted_path) if converted_path != source
262
+
263
+ result
264
+ rescue StandardError => e
265
+ Fontist.ui.warn("Could not convert to #{target_format}: #{e.message}")
266
+ Fontist.ui.warn("Installing original format instead")
267
+ @location.install_font(source, target_name)
268
+ end
269
+ end
270
+
271
+ # Convert font using Fontisan library
272
+ def convert_with_fontisan(source_path, target_format)
273
+ require "fontisan"
274
+
275
+ font = Fontisan::FontLoader.load(source_path)
276
+
277
+ case target_format
278
+ when "woff"
279
+ font.to_woff(path: temp_path_for(source_path, "woff"))
280
+ when "woff2"
281
+ font.to_woff2(path: temp_path_for(source_path, "woff2"))
282
+ else
283
+ raise Errors::UnsupportedTranscodeError.new(
284
+ File.extname(source_path),
285
+ target_format,
286
+ )
287
+ end
288
+ rescue LoadError
289
+ Fontist.ui.error(
290
+ "Fontisan gem not found. Transcoding requires the fontisan gem.",
291
+ )
292
+ Fontist.ui.error("Add it to your Gemfile or run: gem install fontisan")
293
+ raise
294
+ end
295
+
296
+ def temp_path_for(source_path, format)
297
+ base = File.basename(source_path, ".*")
298
+ dir = @format_spec&.transcode_path || Dir.mktmpdir
299
+ FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
300
+ File.join(dir, "#{base}.#{format}")
301
+ end
147
302
 
148
- # Return path if installed, nil if skipped
303
+ def cleanup_temp_file(path)
304
+ File.delete(path) if File.exist?(path)
305
+ rescue StandardError
306
+ # Ignore cleanup errors
149
307
  end
150
308
 
151
309
  def macos_asset_directory
@@ -1,6 +1,7 @@
1
1
  require "lutaml/model"
2
2
 
3
3
  module Fontist
4
+ # FontModel - uses FontStyle with v5 format metadata
4
5
  class FontModel < Lutaml::Model::Serializable
5
6
  attribute :name, :string
6
7
  attribute :styles, FontStyle, collection: true
@@ -37,10 +37,10 @@ module Fontist
37
37
 
38
38
  # On Windows, use case-insensitive comparison; on Unix, case-sensitive
39
39
  result = if Fontist::Utils::System.windows?
40
- normalized_path.downcase.start_with?(normalized_fonts_path.downcase)
41
- else
42
- normalized_path.start_with?(normalized_fonts_path)
43
- end
40
+ normalized_path.downcase.start_with?(normalized_fonts_path.downcase)
41
+ else
42
+ normalized_path.start_with?(normalized_fonts_path)
43
+ end
44
44
 
45
45
  puts " result: #{result.inspect}" if ENV["DEBUG_FONT_PATH"]
46
46
  result
@@ -1,6 +1,7 @@
1
1
  require "lutaml/model"
2
2
 
3
3
  module Fontist
4
+ # FontStyle - v5 font style with format metadata for multi-format support
4
5
  class FontStyle < Lutaml::Model::Serializable
5
6
  attribute :family_name, :string
6
7
  attribute :type, :string
@@ -17,6 +18,12 @@ module Fontist
17
18
  attribute :default_type, :string
18
19
  attribute :override, :string
19
20
 
21
+ # v5 format metadata
22
+ attribute :formats, :string, collection: true # ["ttf", "woff2"]
23
+ attribute :variable_font, :boolean # true/false
24
+ attribute :variable_axes, :string, collection: true # ["wght", "wdth"]
25
+ attribute :source_resource, :string
26
+
20
27
  key_value do
21
28
  map "family_name", to: :family_name
22
29
  map "type", to: :type
@@ -32,6 +39,16 @@ module Fontist
32
39
  map "default_family_name", to: :default_family_name
33
40
  map "default_type", to: :default_type
34
41
  map "override", to: :override
42
+ map "formats", to: :formats
43
+ map "variable_font", to: :variable_font
44
+ map "variable_axes", to: :variable_axes
45
+ map "source_resource", to: :source_resource
46
+ end
47
+
48
+ # Helper to check if this is a variable font
49
+ # Returns false if not set, true only if explicitly set to true
50
+ def variable_font?
51
+ variable_font == true
35
52
  end
36
53
  end
37
54
  end
@@ -0,0 +1,176 @@
1
+ module Fontist
2
+ # Centralized format matching service
3
+ #
4
+ # All format matching logic exists here. Other classes delegate to this.
5
+ # Supports matching across:
6
+ # - Resources (formula download sources)
7
+ # - Styles (font style entries)
8
+ # - Indexed fonts (system/user/fontist indexes)
9
+ # - Collections (TTC/OTC with multiple fonts)
10
+ #
11
+ # For transcoding, delegates to Fontisan library.
12
+ class FormatMatcher
13
+ # All supported formats
14
+ DESKTOP_FORMATS = %w[ttf otf ttc otc dfont].freeze
15
+ WEB_FORMATS = %w[woff woff2].freeze
16
+ ALL_FORMATS = (DESKTOP_FORMATS + WEB_FORMATS).freeze
17
+
18
+ def initialize(format_spec)
19
+ @spec = format_spec || FormatSpec.new
20
+ end
21
+
22
+ # Check if a resource matches the format spec
23
+ def matches_resource?(resource)
24
+ return true unless @spec.has_constraints?
25
+
26
+ if @spec.format && resource.format && resource.format != @spec.format
27
+ return false
28
+ end
29
+
30
+ if @spec.variable_requested?
31
+ return false unless resource.variable_font?
32
+ return axes_match?(resource.variable_axes) if @spec.axes.any?
33
+ end
34
+
35
+ true
36
+ end
37
+
38
+ # Check if a style matches the format spec
39
+ # @param style [FontStyle, SystemIndexFont] Style object to check
40
+ def matches_style?(style)
41
+ return true unless @spec.has_constraints?
42
+
43
+ # Check format constraint for FontStyle (v5 formulas have formats)
44
+ if @spec.format && style.is_a?(FontStyle) && style.formats && !Array(style.formats).include?(@spec.format)
45
+ return false
46
+ end
47
+
48
+ if @spec.variable_requested?
49
+ # Check if style is variable
50
+ is_variable = style.variable_font?
51
+ return false unless is_variable
52
+
53
+ return axes_match?(style.variable_axes) if @spec.axes.any?
54
+ end
55
+
56
+ true
57
+ end
58
+
59
+ # Check if an indexed font matches the format spec
60
+ def matches_indexed_font?(indexed_font)
61
+ return true unless @spec.has_constraints?
62
+ return false if @spec.format && indexed_font.format != @spec.format
63
+
64
+ if @spec.variable_requested?
65
+ return false unless indexed_font.variable_font
66
+
67
+ return axes_match?(indexed_font.variable_axes) if @spec.axes.any?
68
+ end
69
+
70
+ true
71
+ end
72
+
73
+ # Filter resources to only matching ones
74
+ def filter_resources(resources)
75
+ resources.select { |r| matches_resource?(r) }
76
+ end
77
+
78
+ # Filter styles to only matching ones
79
+ def filter_styles(styles)
80
+ styles.select { |s| matches_style?(s) }
81
+ end
82
+
83
+ # Filter indexed fonts to only matching ones
84
+ def filter_indexed_fonts(fonts)
85
+ fonts.select { |f| matches_indexed_font?(f) }
86
+ end
87
+
88
+ # Select best resource based on preferences
89
+ def select_preferred_resource(resources)
90
+ return resources.first if resources.empty?
91
+ return resources.first unless @spec.has_constraints?
92
+
93
+ # Match exact format first (e.g. format: "ttf" selects the ttf resource)
94
+ exact = find_exact_format(resources)
95
+ return exact if exact
96
+
97
+ preferred = find_preferred_format(resources)
98
+ return preferred if preferred
99
+
100
+ variable = find_variable_resource(resources)
101
+ return variable if variable
102
+
103
+ resources.first
104
+ end
105
+
106
+ # Check if requested format needs transcoding from available formats
107
+ def installation_strategy(available_formats)
108
+ requested = @spec.format
109
+
110
+ if !requested
111
+ return { strategy: :install, format: available_formats.first }
112
+ end
113
+
114
+ if available_formats.include?(requested)
115
+ return { strategy: :install, format: requested }
116
+ end
117
+
118
+ # Check if Fontisan can convert from any available format
119
+ convertible = available_formats.find { |f| self.class.can_convert?(f, requested) }
120
+ if convertible
121
+ return {
122
+ strategy: :convert,
123
+ from: convertible,
124
+ to: requested,
125
+ }
126
+ end
127
+
128
+ {
129
+ strategy: :unavailable,
130
+ requested: requested,
131
+ available: available_formats,
132
+ }
133
+ end
134
+
135
+ # Check if Fontisan can convert between formats (instance method for convenience)
136
+ def can_convert?(from_format, to_format)
137
+ self.class.can_convert?(from_format, to_format)
138
+ end
139
+
140
+ # Class method for checking Fontisan conversion capability
141
+ def self.can_convert?(from_format, to_format)
142
+ return false unless from_format && to_format
143
+
144
+ # Fontisan supports conversion from desktop to web formats
145
+ DESKTOP_FORMATS.include?(from_format.to_s) &&
146
+ WEB_FORMATS.include?(to_format.to_s)
147
+ end
148
+
149
+ private
150
+
151
+ def find_exact_format(resources)
152
+ return nil unless @spec.format
153
+
154
+ resources.find { |r| r.format == @spec.format }
155
+ end
156
+
157
+ def find_preferred_format(resources)
158
+ return nil unless @spec.prefer_format
159
+
160
+ resources.find { |r| r.format == @spec.prefer_format }
161
+ end
162
+
163
+ def find_variable_resource(resources)
164
+ return nil unless @spec.prefer_variable
165
+
166
+ resources.find { |r| r.variable_font? }
167
+ end
168
+
169
+ def axes_match?(available_axes)
170
+ return true if @spec.axes.empty?
171
+
172
+ available = Array(available_axes).map(&:to_s)
173
+ @spec.axes.all? { |axis| available.include?(axis) }
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,80 @@
1
+ require "lutaml/model"
2
+
3
+ module Fontist
4
+ # Encapsulates format requirements for font installation/lookup
5
+ #
6
+ # Supports:
7
+ # - Format selection (install specific format)
8
+ # - Variable axes selection
9
+ # - Automatic transcoding (if format not available, transcode from available)
10
+ # - Transcode destination specification
11
+ #
12
+ # This model is passed through the entire pipeline:
13
+ # CLI -> Font -> FormulaPicker -> FontInstaller -> Index
14
+ class FormatSpec < Lutaml::Model::Serializable
15
+ # Format preference (what format to install)
16
+ # If format not available in formula, will transcode from available format
17
+ attribute :format, :string
18
+
19
+ # Variable font selection
20
+ attribute :variable_axes, :string, collection: true
21
+ attribute :prefer_variable, :boolean, default: false
22
+
23
+ # Format preferences
24
+ attribute :prefer_format, :string
25
+
26
+ # Transcoding options (used when format not available)
27
+ attribute :transcode_path, :string
28
+ attribute :keep_original, :boolean, default: true
29
+
30
+ # Collection handling
31
+ attribute :collection_index, :integer
32
+
33
+ # Convenience constructor for CLI
34
+ def self.from_options(options = {})
35
+ new(
36
+ format: options[:format],
37
+ variable_axes: parse_variable_axes(options[:variable_axes]),
38
+ prefer_variable: options[:prefer_variable] || false,
39
+ prefer_format: options[:prefer_format],
40
+ transcode_path: options[:transcode_path],
41
+ keep_original: options.fetch(:keep_original, true),
42
+ collection_index: options[:collection_index],
43
+ )
44
+ end
45
+
46
+ def self.parse_variable_axes(value)
47
+ return nil if value.nil?
48
+ return value if value.is_a?(Array)
49
+
50
+ value.to_s.split(",").map(&:strip).compact
51
+ end
52
+
53
+ # Check if any format constraints are specified
54
+ def has_constraints?
55
+ !!(format || variable_axes&.any? || prefer_variable || prefer_format)
56
+ end
57
+
58
+ # Check if variable font is requested
59
+ def variable_requested?
60
+ !!(variable_axes&.any? || prefer_variable)
61
+ end
62
+
63
+ # Get axes as array (never nil)
64
+ def axes
65
+ Array(variable_axes)
66
+ end
67
+
68
+ # Check if transcoding might be needed (format specified but not available)
69
+ def needs_transcode?(available_formats)
70
+ return false unless format
71
+
72
+ !available_formats.include?(format)
73
+ end
74
+
75
+ # Check if we need a specific collection index
76
+ def specific_collection_index?
77
+ !collection_index.nil?
78
+ end
79
+ end
80
+ end