fontist 2.1.1 → 2.1.3

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.
data/docs/package.json CHANGED
@@ -7,11 +7,11 @@
7
7
  },
8
8
  "type": "module",
9
9
  "devDependencies": {
10
- "@types/node": "latest",
11
- "prettier": "^3.2.4",
12
- "typescript": "latest",
13
- "vitepress": "^1.0.0-rc.44",
14
- "vue": "^3.4.15",
15
- "vue-tsc": "^1.8.27"
10
+ "@types/node": "^25.0.0",
11
+ "prettier": "^3.8.1",
12
+ "typescript": "^5.9.3",
13
+ "vitepress": "^1.6.4",
14
+ "vue": "^3.5.29",
15
+ "vue-tsc": "^3.2.5"
16
16
  }
17
17
  }
data/fontist.gemspec CHANGED
@@ -36,8 +36,10 @@ Gem::Specification.new do |spec|
36
36
  spec.add_dependency "git", "> 1.0"
37
37
  spec.add_dependency "json", "~> 2.0"
38
38
  spec.add_dependency "lutaml-model", "~> 0.7"
39
+ spec.add_dependency "lutaml-xsd", "~> 1.0"
39
40
  spec.add_dependency "marcel", "~> 1.0"
40
41
  spec.add_dependency "nokogiri", "~> 1.0"
42
+ spec.add_dependency "octokit", "~> 4.0"
41
43
  spec.add_dependency "paint", "~> 2.3"
42
44
  spec.add_dependency "parallel", "~> 1.24"
43
45
  spec.add_dependency "plist", "~> 3.0"
@@ -42,6 +42,16 @@ module Fontist
42
42
  end
43
43
  end
44
44
 
45
+ def cleanup_temp_files
46
+ # Clean up orphaned .tmp files from interrupted writes
47
+ # This can happen if the process crashes between File.write and File.rename
48
+ Dir.glob(File.join(@cache_dir, "*.tmp")).each do |tmp|
49
+ File.delete(tmp)
50
+ rescue StandardError
51
+ nil
52
+ end
53
+ end
54
+
45
55
  private
46
56
 
47
57
  attr_reader :cache_dir
@@ -68,11 +78,30 @@ module Fontist
68
78
  def read_entry(key)
69
79
  return nil unless File.exist?(cache_path(key))
70
80
 
71
- Marshal.load(File.read(cache_path(key)))
81
+ begin
82
+ Marshal.load(File.read(cache_path(key)))
83
+ rescue ArgumentError, TypeError => e
84
+ # Cache file is corrupted - delete it and return nil
85
+ # This can happen on Windows when file is read while being written,
86
+ # or when cache files from previous runs are corrupted
87
+ File.delete(cache_path(key)) rescue nil
88
+ nil
89
+ end
72
90
  end
73
91
 
74
92
  def write_entry(key, entry)
75
- File.write(cache_path(key), Marshal.dump(entry))
93
+ # Use temp file + atomic rename to prevent race conditions
94
+ # This ensures readers never see partial writes, even on Windows
95
+ temp_path = cache_path(key) + ".tmp"
96
+
97
+ File.write(temp_path, Marshal.dump(entry))
98
+ # Atomic rename (overwrites target atomically)
99
+ # File.rename is atomic on all platforms for same filesystem
100
+ File.rename(temp_path, cache_path(key))
101
+ rescue => e
102
+ # Clean up temp file if rename fails
103
+ File.delete(temp_path) rescue nil
104
+ raise e
76
105
  end
77
106
 
78
107
  # Cache entry with TTL support
@@ -42,11 +42,11 @@ module Fontist
42
42
 
43
43
  # For collections, we need different handling
44
44
  if is_collection
45
- # Load and validate the first font in the collection for indexability
45
+ # Load the first font in the collection
46
46
  font = Fontisan::FontLoader.load(path, font_index: 0,
47
47
  mode: :metadata, lazy: true)
48
48
 
49
- # Validate the font using indexability profile
49
+ # Validate the font using indexability profile (log issues but don't reject)
50
50
  validator = load_indexability_validator
51
51
  validation_report = validator.validate(font)
52
52
 
@@ -54,10 +54,9 @@ module Fontist
54
54
  error_messages = validation_report.errors.map do |e|
55
55
  "#{e.category}: #{e.message}"
56
56
  end.join("; ")
57
- # rubocop:disable Layout/LineLength
58
- raise Errors::FontFileError,
59
- "Font from collection failed indexability validation: #{error_messages}"
60
- # rubocop:enable Layout/LineLength
57
+ Fontist.ui.debug(
58
+ "Font validation issues for #{File.basename(path)}: #{error_messages}",
59
+ )
61
60
  end
62
61
 
63
62
  else
@@ -82,11 +81,14 @@ module Fontist
82
81
  validation_report = validator.validate(font)
83
82
 
84
83
  unless validation_report.valid?
84
+ # Log validation issues but don't reject outright
85
+ # We'll check if we can still extract metadata
85
86
  error_messages = validation_report.errors.map do |e|
86
87
  "#{e.category}: #{e.message}"
87
88
  end.join("; ")
88
- raise Errors::FontFileError,
89
- "Font file failed indexability validation: #{error_messages}"
89
+ Fontist.ui.debug(
90
+ "Font validation issues for #{File.basename(path)}: #{error_messages}",
91
+ )
90
92
  end
91
93
 
92
94
  font
@@ -180,17 +182,66 @@ module Fontist
180
182
  return {} unless name_table
181
183
 
182
184
  # Extract all needed name strings using Fontisan's API
185
+ # Fall back to non-English names if English names are not available
183
186
  {
184
- full_name: name_table.english_name(Fontisan::Tables::Name::FULL_NAME),
185
- family_name: name_table.english_name(Fontisan::Tables::Name::FAMILY),
186
- subfamily_name: name_table.english_name(Fontisan::Tables::Name::SUBFAMILY),
187
- preferred_family: name_table.english_name(Fontisan::Tables::Name::PREFERRED_FAMILY),
188
- preferred_subfamily: name_table.english_name(Fontisan::Tables::Name::PREFERRED_SUBFAMILY),
189
- postscript_name: name_table.english_name(Fontisan::Tables::Name::POSTSCRIPT_NAME),
187
+ full_name: name_table.english_name(Fontisan::Tables::Name::FULL_NAME) ||
188
+ extract_any_name(name_table, Fontisan::Tables::Name::FULL_NAME),
189
+ family_name: name_table.english_name(Fontisan::Tables::Name::FAMILY) ||
190
+ extract_any_name(name_table, Fontisan::Tables::Name::FAMILY),
191
+ subfamily_name: name_table.english_name(Fontisan::Tables::Name::SUBFAMILY) ||
192
+ extract_any_name(name_table, Fontisan::Tables::Name::SUBFAMILY),
193
+ preferred_family: name_table.english_name(Fontisan::Tables::Name::PREFERRED_FAMILY) ||
194
+ extract_any_name(name_table, Fontisan::Tables::Name::PREFERRED_FAMILY),
195
+ preferred_subfamily: name_table.english_name(Fontisan::Tables::Name::PREFERRED_SUBFAMILY) ||
196
+ extract_any_name(name_table, Fontisan::Tables::Name::PREFERRED_SUBFAMILY),
197
+ postscript_name: name_table.english_name(Fontisan::Tables::Name::POSTSCRIPT_NAME) ||
198
+ extract_any_name(name_table, Fontisan::Tables::Name::POSTSCRIPT_NAME),
190
199
  }
191
200
  end
192
201
  # rubocop:enable Metrics/MethodLength
193
202
 
203
+ # Extract any available name for a given name ID by manually decoding the name record
204
+ def extract_any_name(name_table, name_id)
205
+ # Find any name record with the matching name_id
206
+ records = name_table.name_records.select { |r| r.name_id == name_id }
207
+ return nil if records.empty?
208
+
209
+ # Manually decode each record
210
+ records.each do |record|
211
+ decoded = decode_name_record_manually(name_table, record)
212
+ return decoded unless decoded.nil? || decoded.empty?
213
+ end
214
+
215
+ nil
216
+ end
217
+
218
+ # Manually decode a name record since the fontisan method is private
219
+ def decode_name_record_manually(name_table, record)
220
+ # Get raw string storage
221
+ storage_bytes = name_table.string_storage.to_s.b
222
+
223
+ # Extract this record's string
224
+ offset = record.string_offset
225
+ length = record.string_length
226
+
227
+ return nil if offset + length > storage_bytes.bytesize
228
+ return nil if length.zero?
229
+
230
+ string_data = storage_bytes.byteslice(offset, length)
231
+
232
+ # Decode based on platform (same logic as fontisan)
233
+ case record.platform_id
234
+ when 0, 3 # PLATFORM_UNICODE, PLATFORM_WINDOWS
235
+ string_data.dup.force_encoding("UTF-16BE")
236
+ .encode("UTF-8", invalid: :replace, undef: :replace)
237
+ when 1 # PLATFORM_MACINTOSH
238
+ string_data.dup.force_encoding("ASCII-8BIT")
239
+ .encode("UTF-8", invalid: :replace, undef: :replace)
240
+ else
241
+ string_data.dup.force_encoding("UTF-8")
242
+ end
243
+ end
244
+
194
245
  def raise_font_file_error(exception)
195
246
  raise Errors::FontFileError,
196
247
  "Font file could not be parsed: #{exception.inspect}."
@@ -1,5 +1,39 @@
1
1
  module Fontist
2
2
  module Indexes
3
+ # IndexMixin provides common functionality for font index classes.
4
+ #
5
+ # == Performance Optimization (Tech Debt)
6
+ #
7
+ # This module uses a temporary Hash-based lookup cache during index building
8
+ # to avoid O(n²) performance when adding many entries. This is a workaround
9
+ # for Lutaml::Model::Collection's Array-based storage.
10
+ #
11
+ # === The Problem
12
+ #
13
+ # Lutaml::Model::Collection stores entries as an Array, which provides O(n)
14
+ # lookup when searching for existing keys. When building an index with
15
+ # thousands of entries, this creates O(n²) behavior:
16
+ #
17
+ # - 8867 font styles × average 2670 comparisons = ~23.6 million comparisons
18
+ # - Index building: ~26 seconds with Array lookup
19
+ #
20
+ # === The Workaround
21
+ #
22
+ # During `build` and `build_with_formulas`, we maintain a temporary
23
+ # `@index_build_cache` Hash that provides O(1) lookups. After building,
24
+ # the cache is cleared.
25
+ #
26
+ # - Index building with Hash lookup: ~0.08 seconds
27
+ # - Speedup: 325× faster
28
+ #
29
+ # === The Proper Fix
30
+ #
31
+ # This tech debt should be resolved by enhancing Lutaml::Model::Collection
32
+ # to support efficient key-based lookups. See the reproduction script at:
33
+ # `dev/lutaml_model_collection_lookup_benchmark.rb`
34
+ #
35
+ # Related issue: https://github.com/lutaml/lutaml-model/issues/XXX
36
+ #
3
37
  module IndexMixin
4
38
  def self.included(base)
5
39
  base.extend(ClassMethods)
@@ -35,13 +69,17 @@ module Fontist
35
69
  def reset_cache
36
70
  # Delete the index file to force rebuild on next access
37
71
  # This is important for tests to ensure clean state
38
- File.delete(path) if File.exist?(path)
72
+ FileUtils.rm_f(path)
39
73
  end
40
74
  end
41
75
 
76
+ # Build index by loading all formulas from disk.
77
+ # Uses Hash-based cache for O(1) lookups during building.
42
78
  def build
43
- Formula.all.each do |formula|
44
- add_formula(formula)
79
+ with_index_build_cache do
80
+ Formula.all.each do |formula|
81
+ add_formula(formula)
82
+ end
45
83
  end
46
84
 
47
85
  to_file
@@ -49,9 +87,16 @@ module Fontist
49
87
  self
50
88
  end
51
89
 
90
+ # Build index from pre-loaded formulas.
91
+ # Uses Hash-based cache for O(1) lookups during building.
92
+ #
93
+ # This is the preferred method when formulas are already loaded,
94
+ # as it avoids re-loading from disk.
52
95
  def build_with_formulas(formulas)
53
- formulas.each do |formula|
54
- add_formula(formula)
96
+ with_index_build_cache do
97
+ formulas.each do |formula|
98
+ add_formula(formula)
99
+ end
55
100
  end
56
101
 
57
102
  to_file
@@ -73,26 +118,20 @@ module Fontist
73
118
 
74
119
  def index_key_for_style(_style)
75
120
  raise NotImplementedError,
76
- "index_key_for_style(style) must be implemented in including class"
121
+ "index_key_for_style(style) must be implemented"
77
122
  end
78
123
 
124
+ # Add a font style to the index with O(1) or O(n) lookup.
125
+ #
126
+ # Uses `@index_build_cache` Hash for O(1) lookup during building,
127
+ # falling back to O(n) Array lookup for incremental updates.
79
128
  def add_index_formula(style, formula_path)
80
- key = index_key_for_style(style)
81
- raise if key.nil? || key.empty?
82
-
83
- key = normalize_key(key)
84
- formula_path = Array(formula_path)
85
- paths = formula_path.map { |p| relative_formula_path(p) }
129
+ key = prepare_index_key(style)
130
+ paths = prepare_formula_paths(formula_path)
86
131
 
87
- if index_formula(key)
88
- index_formula(key).formula_path.concat(paths).uniq!
89
- return
90
- end
132
+ return if merge_existing_entry?(key, paths)
91
133
 
92
- entries << FormulaKeyToPath.new(
93
- key: key,
94
- formula_path: paths,
95
- )
134
+ create_and_add_entry(key, paths)
96
135
  end
97
136
 
98
137
  def load_formulas(key)
@@ -114,6 +153,56 @@ module Fontist
114
153
 
115
154
  private
116
155
 
156
+ # Yields with a Hash-based lookup cache for O(1) key lookups.
157
+ #
158
+ # This is a performance optimization to avoid O(n²) behavior
159
+ # when building indexes with thousands of entries.
160
+ #
161
+ # @yield [void] Block to execute with cache enabled
162
+ # @return [void]
163
+ def with_index_build_cache
164
+ @index_build_cache = {}
165
+ yield
166
+ ensure
167
+ @index_build_cache = nil
168
+ end
169
+
170
+ def prepare_index_key(style)
171
+ key = index_key_for_style(style)
172
+ raise if key.nil? || key.empty?
173
+
174
+ normalize_key(key)
175
+ end
176
+
177
+ def prepare_formula_paths(formula_path)
178
+ Array(formula_path).map { |p| relative_formula_path(p) }
179
+ end
180
+
181
+ # Attempt to merge paths into existing entry.
182
+ # Returns true if merged, false if no existing entry found.
183
+ def merge_existing_entry?(key, paths)
184
+ existing = find_existing_entry(key)
185
+ return false unless existing
186
+
187
+ existing.formula_path.concat(paths).uniq!
188
+ true
189
+ end
190
+
191
+ # Find existing entry using cache (O(1)) or array scan (O(n))
192
+ def find_existing_entry(key)
193
+ if @index_build_cache
194
+ @index_build_cache[key]
195
+ else
196
+ index_formula(key)
197
+ end
198
+ end
199
+
200
+ def create_and_add_entry(key, paths)
201
+ entry = FormulaKeyToPath.new(key: key, formula_path: paths)
202
+ entries << entry
203
+ @index_build_cache[key] = entry if @index_build_cache
204
+ end
205
+
117
206
  def index_formula(key)
118
207
  Array(entries).detect { |f| normalize_key(f.key) == normalize_key(key) }
119
208
  end
@@ -79,13 +79,13 @@ location: nil)
79
79
  end
80
80
 
81
81
  def validate_platform_compatibility!
82
- formula = Fontist::Formula.find(name)
83
- return if formula.nil?
84
- return if formula.compatible_with_platform?
82
+ formula = Fontist::Formula.find_many(name)
83
+ return if formula.empty?
84
+ return if formula.any?(&:compatible_with_platform?)
85
85
 
86
86
  raise Fontist::Errors::PlatformMismatchError.new(
87
87
  name,
88
- formula.platforms,
88
+ formula.map(&:platforms).flatten.uniq,
89
89
  Fontist::Utils::System.user_os,
90
90
  )
91
91
  end
@@ -682,7 +682,10 @@ spinner_index = nil)
682
682
 
683
683
  def parse_font(font_file, path)
684
684
  # Skip fonts with incomplete metadata
685
- return nil unless font_file.full_name && font_file.family
685
+ unless font_file.full_name && font_file.family
686
+ warn_incomplete_metadata(path)
687
+ return nil
688
+ end
686
689
 
687
690
  # Get file metadata for caching
688
691
  file_size = begin
@@ -708,6 +711,32 @@ spinner_index = nil)
708
711
  )
709
712
  end
710
713
 
714
+ def filter_valid_fonts(fonts)
715
+ fonts.select do |font|
716
+ missing_keys = ALLOWED_KEYS.reject { |key| font.send(key) }
717
+
718
+ next true if missing_keys.empty?
719
+
720
+ warn_font_metadata_incomplete(font, missing_keys)
721
+ end
722
+ end
723
+
724
+ def warn_font_metadata_incomplete(font, missing_keys)
725
+ Fontist.ui.error(<<~MSG.chomp)
726
+ Skipping font with incomplete metadata: #{font.path}
727
+ Missing attributes: #{missing_keys.join(', ')}.
728
+ This font will not be indexed, but Fontist will continue to work.
729
+ MSG
730
+ end
731
+
732
+ def warn_incomplete_metadata(path)
733
+ Fontist.ui.error(<<~MSG.chomp)
734
+ Skipping font with incomplete metadata: #{path}
735
+ Missing attributes: full_name, family_name.
736
+ This font will not be indexed, but Fontist will continue to work.
737
+ MSG
738
+ end
739
+
711
740
  def raise_font_index_corrupted(font, missing_keys)
712
741
  raise(Errors::FontIndexCorrupted, <<~MSG.chomp)
713
742
  Font index is corrupted.
@@ -1,5 +1,6 @@
1
1
  require "lutaml/model"
2
2
  require "marcel"
3
+ require_relative "locking"
3
4
 
4
5
  module Fontist
5
6
  module Utils
@@ -1,4 +1,6 @@
1
1
  require_relative "cache"
2
+ require_relative "github_url"
3
+ require_relative "github_client"
2
4
 
3
5
  module Fontist
4
6
  module Utils
@@ -59,16 +61,30 @@ module Fontist
59
61
  end
60
62
 
61
63
  def download_file
62
- tries = tries ? tries + 1 : 1
64
+ @tries ||= 0
65
+ @tries += 1
63
66
  print_download_start if @verbose
64
67
  do_download_file
65
68
  rescue Down::Error => e
66
- retry if tries < 3
69
+ if @tries < max_retries
70
+ sleep(backoff_time(@tries))
71
+ retry
72
+ end
67
73
 
68
74
  raise Fontist::Errors::InvalidResourceError,
69
75
  "Invalid URL: #{@file}. Error: #{e.inspect}."
70
76
  end
71
77
 
78
+ def max_retries
79
+ @max_retries ||= 3
80
+ end
81
+
82
+ def backoff_time(attempt)
83
+ # Exponential backoff: 2^attempt seconds, max 30 seconds
84
+ # 1st retry: 2s, 2nd: 4s, 3rd: 8s
85
+ [2**attempt, 30].min
86
+ end
87
+
72
88
  def print_download_start
73
89
  Fontist.ui.say("Downloading from: #{Paint[url, :cyan]}")
74
90
  if @verbose
@@ -115,8 +131,10 @@ module Fontist
115
131
  # rubocop:enable Metrics/MethodLength
116
132
 
117
133
  def url
118
- obj = Helpers.url_object(@file)
119
- obj.respond_to?(:url) ? obj.url : obj
134
+ @url ||= begin
135
+ raw_url = extract_raw_url
136
+ github_aware_url(raw_url)
137
+ end
120
138
  end
121
139
 
122
140
  def headers
@@ -126,6 +144,22 @@ module Fontist
126
144
  obj.headers.to_h.map { |k, v| [k.to_s, v] }.to_h || # rubocop:disable Style/HashTransformKeys, Metrics/LineLength
127
145
  {}
128
146
  end
147
+
148
+ private
149
+
150
+ def extract_raw_url
151
+ obj = Helpers.url_object(@file)
152
+ obj.respond_to?(:url) ? obj.url : obj
153
+ end
154
+
155
+ def github_aware_url(raw_url)
156
+ parsed = GitHubUrl.parse(raw_url)
157
+ if parsed.matched?
158
+ GitHubClient.authenticated_download_url(parsed)
159
+ else
160
+ raw_url
161
+ end
162
+ end
129
163
  end
130
164
 
131
165
  class ProgressBar
@@ -0,0 +1,43 @@
1
+ require "octokit"
2
+
3
+ module Fontist
4
+ module Utils
5
+ class GitHubClient
6
+ class << self
7
+ def authenticated_download_url(parsed_url)
8
+ return parsed_url.original_url unless parsed_url.matched?
9
+
10
+ client = create_client
11
+ release = fetch_release(client, parsed_url)
12
+
13
+ find_asset_url(release, parsed_url.asset) || parsed_url.original_url
14
+ rescue Octokit::Error => e
15
+ Fontist.ui.say("GitHub API error: #{e.message}. Falling back to direct download.")
16
+ parsed_url.original_url
17
+ end
18
+
19
+ private
20
+
21
+ def create_client
22
+ if github_token
23
+ Octokit::Client.new(access_token: github_token)
24
+ else
25
+ Octokit::Client.new
26
+ end
27
+ end
28
+
29
+ def github_token
30
+ ENV.fetch("GITHUB_TOKEN", nil)
31
+ end
32
+
33
+ def fetch_release(client, parsed_url)
34
+ client.release_for_tag("#{parsed_url.owner}/#{parsed_url.repo}", parsed_url.tag)
35
+ end
36
+
37
+ def find_asset_url(release, asset_name)
38
+ release.assets.find { |asset| asset.name == asset_name }&.browser_download_url
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,51 @@
1
+ module Fontist
2
+ module Utils
3
+ class GitHubUrl
4
+ GITHUB_RELEASE_PATTERN =
5
+ %r{^https?://github\.com/(?<owner>[^/]+)/(?<repo>[^/]+)/releases/download/(?<tag>[^/]+)/(?<asset>.+)$}
6
+
7
+ class << self
8
+ def match?(url)
9
+ parse(url).matched?
10
+ end
11
+
12
+ def parse(url)
13
+ url_string = url.to_s
14
+ match = url_string.match(GITHUB_RELEASE_PATTERN)
15
+
16
+ if match
17
+ ParsedUrl.new(
18
+ owner: match[:owner],
19
+ repo: match[:repo],
20
+ tag: match[:tag],
21
+ asset: match[:asset],
22
+ original_url: url_string
23
+ )
24
+ else
25
+ ParsedUrl.from_non_github_url(url_string)
26
+ end
27
+ end
28
+ end
29
+
30
+ class ParsedUrl
31
+ attr_reader :owner, :repo, :tag, :asset, :original_url
32
+
33
+ def initialize(owner:, repo:, tag:, asset:, original_url:)
34
+ @owner = owner
35
+ @repo = repo
36
+ @tag = tag
37
+ @asset = asset
38
+ @original_url = original_url
39
+ end
40
+
41
+ def self.from_non_github_url(original_url)
42
+ new(owner: nil, repo: nil, tag: nil, asset: nil, original_url: original_url)
43
+ end
44
+
45
+ def matched?
46
+ !owner.nil?
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -1,3 +1,3 @@
1
1
  module Fontist
2
- VERSION = "2.1.1".freeze
2
+ VERSION = "2.1.3".freeze
3
3
  end
data/lib/fontist.rb CHANGED
@@ -188,9 +188,12 @@ module Fontist
188
188
  def self.formulas_repo_path_exists!
189
189
  return true if Dir.exist?(Fontist.formulas_repo_path.join("Formulas"))
190
190
 
191
- raise Errors::MainRepoNotFoundError.new(
192
- "Please fetch formulas with `fontist update`.",
193
- )
191
+ # Auto-update formulas repo if it doesn't exist (lazy initialization).
192
+ # This ensures formulas are always discoverable without requiring
193
+ # explicit `fontist update`.
194
+ Formula.update_formulas_repo
195
+
196
+ true
194
197
  end
195
198
  end
196
199