fontist 2.1.2 → 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
  }
@@ -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
@@ -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,3 +1,3 @@
1
1
  module Fontist
2
- VERSION = "2.1.2".freeze
2
+ VERSION = "2.1.3".freeze
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fontist
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.1.2
4
+ version: 2.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-02-10 00:00:00.000000000 Z
11
+ date: 2026-03-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: down
@@ -275,6 +275,7 @@ files:
275
275
  - ".github/workflows/rake-metanorma.yaml"
276
276
  - ".github/workflows/rake.yml"
277
277
  - ".github/workflows/release.yml"
278
+ - ".github/workflows/release.yml.orig"
278
279
  - ".github/workflows/tebako-pack.yml"
279
280
  - ".gitignore"
280
281
  - ".gitmodules"
@@ -288,7 +289,6 @@ files:
288
289
  - Rakefile
289
290
  - docs/.gitignore
290
291
  - docs/.vitepress/config.ts
291
- - docs/google-fonts-multi-format-usage.md
292
292
  - docs/guide/api-ruby.md
293
293
  - docs/guide/ci.md
294
294
  - docs/guide/fontconfig.md
@@ -305,6 +305,7 @@ files:
305
305
  - fontist.gemspec
306
306
  - formula_filename_index.yml
307
307
  - formula_index.yml
308
+ - google-fonts-multi-format-usage.md
308
309
  - lib/fontist.rb
309
310
  - lib/fontist/cache/manager.rb
310
311
  - lib/fontist/cache/store.rb