fontist 3.0.25 → 3.0.27

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7e161f1ace760cebffefd73f61835fef3abb2875972836c3ad7e82324a465d11
4
- data.tar.gz: 85f425e3a050b0e9674e32b413d750bde0d40e4e34f3c261c6138dadc571e0a5
3
+ metadata.gz: 4bca0e4898879c116eb90bccf581f82177caafe4b981ac97d2ed974dbb9c19d2
4
+ data.tar.gz: 409088146ecdb388d8efd008f0b7d1d908863fb8d27b6d58aa5371812f5b93bb
5
5
  SHA512:
6
- metadata.gz: 282670c9e6c9ebc53dedbaa927e80259a489f44bc33392d388c977ad909209b721b354b7922a2190f0e7554221a63ca3c15a234a55b100e27bdce92cca1e496e
7
- data.tar.gz: 46ca6895943eefe3a7dce37a6e4cf08241530b6dfacc9fef5233f259ea091ab9d94aac2d4bdd903cc468948d3783a6ceff66219fd04f3514576f88a4a62048ef
6
+ metadata.gz: 49bbea30d8a19d69ad796f7dd7fe49c7c6bf940d38a9aeef07aae568f7700cb1b4a9d39e1427f1002dca02837ed41768975fdc6e9baa37d8a4921235a6a6071d
7
+ data.tar.gz: adc3b7fe62335de562352d1c516d0c9c71298bf0b5c5764ab2ae8bd64fc921dc55814602e78e40440c6c916e15a3b0ca6649af8ba5f9b88f1ff672ed31f95be3
@@ -1,10 +1,24 @@
1
1
  require "fileutils"
2
2
  require "digest"
3
+ require "json"
3
4
 
4
5
  module Fontist
5
6
  module Cache
6
- # Backend store using Marshal for serialization
7
+ # Backend store persisting entries as inert JSON.
8
+ #
9
+ # The previous Marshal backend was removed deliberately: Marshal
10
+ # load of a file from a user-writable path executes arbitrary
11
+ # object instantiation on tampered content, and fontist runs
12
+ # embedded in long-lived host processes where that escalation
13
+ # lands. Values must be
14
+ # JSON-native (hashes with string keys, arrays, strings, numbers,
15
+ # booleans, nil); anything else skips the write and reads back as
16
+ # a miss. Files written by the previous Marshal backend carry a
17
+ # different extension and are simply never read; #clear removes
18
+ # them.
7
19
  class Store
20
+ FORMAT_VERSION = 1
21
+
8
22
  def initialize(cache_dir)
9
23
  @cache_dir = cache_dir.to_s
10
24
  ensure_cache_dir
@@ -26,6 +40,10 @@ module Fontist
26
40
  ensure_cache_dir
27
41
  entry = CacheEntry.new(value, ttl)
28
42
  write_entry(key, entry)
43
+ rescue TypeError, JSON::GeneratorError
44
+ # Values outside the JSON type system (Time, Date, arbitrary
45
+ # objects) skip the write and read back as a miss
46
+ false
29
47
  end
30
48
 
31
49
  def delete(key)
@@ -35,20 +53,29 @@ module Fontist
35
53
  end
36
54
 
37
55
  def clear
38
- Dir.glob(File.join(@cache_dir, "*.marshal")).each do |f|
39
- File.delete(f)
40
- rescue StandardError
41
- nil
42
- end
56
+ glob_delete("*.json")
57
+ # Sweep entries written by the pre-JSON Marshal backend
58
+ glob_delete("*.marshal")
43
59
  end
44
60
 
45
61
  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
62
+ # Clean up orphaned .tmp files from interrupted writes: this
63
+ # happens when a process crashes between write and rename
64
+ glob_delete("*.tmp")
65
+ end
66
+
67
+ # Cache entry with TTL support
68
+ class CacheEntry
69
+ attr_reader :value, :expires_at
70
+
71
+ def initialize(value, ttl = nil, expires_at: nil)
72
+ @value = value
73
+ @expires_at = expires_at
74
+ @expires_at ||= (Time.now + ttl).to_i if ttl
75
+ end
76
+
77
+ def expired?
78
+ !!(@expires_at && Time.now.to_i > @expires_at)
52
79
  end
53
80
  end
54
81
 
@@ -61,7 +88,7 @@ module Fontist
61
88
  end
62
89
 
63
90
  def cache_path(key)
64
- File.join(@cache_dir, "#{sanitize_key(key)}.marshal")
91
+ File.join(@cache_dir, "#{sanitize_key(key)}.json")
65
92
  end
66
93
 
67
94
  def sanitize_key(key)
@@ -75,57 +102,44 @@ module Fontist
75
102
  end
76
103
  end
77
104
 
105
+ def glob_delete(pattern)
106
+ Dir.glob(File.join(@cache_dir, pattern)).each do |f|
107
+ File.delete(f)
108
+ rescue StandardError
109
+ nil
110
+ end
111
+ end
112
+
78
113
  def read_entry(key)
79
114
  return nil unless File.exist?(cache_path(key))
80
115
 
81
- begin
82
- # Use binary read mode for Marshal data - critical on Windows
83
- Marshal.load(File.binread(cache_path(key)))
84
- rescue ArgumentError, TypeError, EOFError
85
- # Cache file is corrupted - delete it and return nil
86
- # This can happen on Windows when file is read while being written,
87
- # or when cache files from previous runs are corrupted
88
- begin
89
- File.delete(cache_path(key))
90
- rescue StandardError
91
- nil
92
- end
93
- nil
94
- end
116
+ payload = JSON.parse(File.read(cache_path(key)))
117
+ CacheEntry.new(payload["value"], expires_at: payload["expires_at"])
118
+ rescue JSON::ParserError, TypeError, EOFError
119
+ # Corrupted file - delete it and return nil. This can happen
120
+ # on Windows when a file is read while being written, or when
121
+ # cache files from previous runs are corrupted
122
+ delete(key)
123
+ nil
95
124
  end
96
125
 
97
126
  def write_entry(key, entry)
98
- # Use temp file + atomic rename to prevent race conditions
99
- # This ensures readers never see partial writes, even on Windows
100
- temp_path = "#{cache_path(key)}.tmp"
127
+ payload = entry_payload(entry)
101
128
 
102
- # Use binary write mode for Marshal data - critical on Windows
103
- File.binwrite(temp_path, Marshal.dump(entry))
104
- # Atomic rename (overwrites target atomically)
105
- # File.rename is atomic on all platforms for same filesystem
129
+ # Temp file + atomic rename: readers never see partial writes
130
+ temp_path = "#{cache_path(key)}.tmp"
131
+ File.write(temp_path, payload)
106
132
  File.rename(temp_path, cache_path(key))
107
133
  rescue StandardError => e
108
- # Clean up temp file if rename fails
109
- begin
110
- File.delete(temp_path)
111
- rescue StandardError
112
- nil
113
- end
134
+ # Clean up temp file if the write or rename fails
135
+ delete(temp_path)
114
136
  raise e
115
137
  end
116
138
 
117
- # Cache entry with TTL support
118
- class CacheEntry
119
- attr_reader :value, :expires_at
120
-
121
- def initialize(value, ttl)
122
- @value = value
123
- @expires_at = ttl ? (Time.now + ttl).to_i : nil
124
- end
125
-
126
- def expired?
127
- !!(@expires_at && Time.now.to_i > @expires_at)
128
- end
139
+ def entry_payload(entry)
140
+ JSON.dump("version" => FORMAT_VERSION,
141
+ "expires_at" => entry.expires_at,
142
+ "value" => entry.value)
129
143
  end
130
144
  end
131
145
  end
@@ -13,11 +13,15 @@ module Fontist
13
13
  new(directory_path, files, Time.now.to_i)
14
14
  end
15
15
 
16
- # Create from existing data (for cache restoration)
16
+ # Create from existing data (for cache restoration). The cache
17
+ # store serializes to JSON, so keys arrive as strings; normalize
18
+ # them back to the symbol-keyed shape used throughout this class
17
19
  def self.from_hash(hash)
20
+ hash = (hash || {}).transform_keys(&:to_sym)
21
+ files = Array(hash[:files]).map { |file| file.transform_keys(&:to_sym) }
18
22
  new(
19
23
  hash[:directory_path].to_s,
20
- hash[:files],
24
+ files,
21
25
  hash[:scanned_at],
22
26
  )
23
27
  end
@@ -140,6 +140,33 @@ module Fontist
140
140
  end
141
141
  end
142
142
 
143
+ # Scan metadata for a system index collection, persisted in a
144
+ # companion file beside the index (an index file is a flat entry
145
+ # list and cannot carry scalars). Restoring it across processes is
146
+ # what makes the 30-minute scan threshold and the directory-mtime
147
+ # change check work at all.
148
+ class SystemIndexMetadata < Lutaml::Model::Serializable
149
+ attribute :last_scan_time, :integer, default: -> { 0 }
150
+ attribute :directory_mtimes, :string, collection: true, default: -> { [] }
151
+
152
+ key_value do
153
+ map "last_scan_time", to: :last_scan_time
154
+ map "directory_mtimes", to: :directory_mtimes
155
+ end
156
+
157
+ def self.load(path)
158
+ return new unless File.exist?(path)
159
+
160
+ from_yaml(File.read(path))
161
+ rescue Lutaml::Model::Error, Psych::SyntaxError, TypeError
162
+ new
163
+ end
164
+
165
+ def save(path)
166
+ File.write(path, to_yaml)
167
+ end
168
+ end
169
+
143
170
  class SystemIndexFontCollection < Lutaml::Model::Collection
144
171
  include Utils::Locking
145
172
 
@@ -151,14 +178,40 @@ module Fontist
151
178
  attribute :directory_mtimes, :string, collection: true, default: -> { [] }
152
179
 
153
180
  key_value do
181
+ # A bare entry list: the scan metadata lives in a companion
182
+ # metadata file (see SystemIndexMetadata), because a flat list
183
+ # cannot carry scalars and lutaml ignores scalar maps declared
184
+ # alongside map_instances
154
185
  map_instances to: :fonts
155
- map "last_scan_time", to: :last_scan_time
156
- map "directory_mtimes", to: :directory_mtimes
157
186
  end
158
187
 
159
188
  # Don't rebuild index more frequently than this (in seconds)
160
189
  INDEX_REBUILD_THRESHOLD = 30 * 60 # 30 minutes
161
190
 
191
+ def metadata_path
192
+ "#{@path}.metadata.yml"
193
+ end
194
+
195
+ def persist_metadata
196
+ return unless @path
197
+
198
+ SystemIndexMetadata
199
+ .new(last_scan_time: last_scan_time,
200
+ directory_mtimes: directory_mtimes)
201
+ .save(metadata_path)
202
+ end
203
+
204
+ def restore_metadata
205
+ return unless @path
206
+ return unless last_scan_time.to_i.zero?
207
+
208
+ meta = SystemIndexMetadata.load(metadata_path)
209
+ return unless meta.last_scan_time.to_i.positive?
210
+
211
+ self.last_scan_time = meta.last_scan_time
212
+ self.directory_mtimes = meta.directory_mtimes
213
+ end
214
+
162
215
  def set_path(path)
163
216
  @path = path
164
217
  end
@@ -172,12 +225,17 @@ module Fontist
172
225
  return new.set_content(path, paths_loader) unless File.exist?(path)
173
226
 
174
227
  from_yaml(File.read(path)).set_content(path, paths_loader)
228
+ rescue Lutaml::Model::Error, Psych::SyntaxError, TypeError
229
+ # Corrupt or unreadable index files degrade to an empty
230
+ # collection; the next rebuild rewrites them
231
+ new.set_content(path, paths_loader)
175
232
  end
176
233
 
177
234
  def set_content(path, paths_loader)
178
235
  tap do |content|
179
236
  content.set_path(path)
180
237
  content.set_path_loader(paths_loader)
238
+ content.restore_metadata
181
239
  end
182
240
  end
183
241
 
@@ -351,7 +409,8 @@ module Fontist
351
409
 
352
410
  def build(forced: false, verbose: false, stats: nil)
353
411
  if forced
354
- return rebuild_with_lock(verbose: verbose, stats: stats)
412
+ return rebuild_with_lock(verbose: verbose, stats: stats,
413
+ honor_recent: false)
355
414
  end
356
415
 
357
416
  previous_index = load_index
@@ -372,14 +431,18 @@ module Fontist
372
431
 
373
432
  private
374
433
 
375
- def rebuild_with_lock(verbose: false, stats: nil)
434
+ def rebuild_with_lock(verbose: false, stats: nil, honor_recent: true)
376
435
  # Use file locking to prevent concurrent rebuilds across processes
377
436
  lock_path = index_lock_path
378
437
  start_time = Time.now
379
438
 
380
439
  lock(lock_path) do
381
- # Re-check if another process already rebuilt while we waited for lock
382
- if File.exist?(@path)
440
+ # Re-check if another process already rebuilt while we waited
441
+ # for lock. Forced rebuilds (add_font, explicit rebuild) carry
442
+ # write intent and must always rescan: honoring the recent
443
+ # rebuild here served the pre-write index and silently dropped
444
+ # the font that triggered the rebuild
445
+ if honor_recent && File.exist?(@path)
383
446
  existing = self.class.from_file(path: @path,
384
447
  paths_loader: @paths_loader)
385
448
 
@@ -407,6 +470,7 @@ module Fontist
407
470
  self.last_scan_time = Time.now.to_i
408
471
  self.directory_mtimes = scan_directory_mtimes.map { |k, v| "#{k}:#{v}" }
409
472
  to_file(@path)
473
+ persist_metadata
410
474
 
411
475
  # Show completion message for large collections
412
476
  elapsed = Time.now - start_time
@@ -505,6 +569,7 @@ module Fontist
505
569
 
506
570
  # Save to file
507
571
  to_file(@path)
572
+ persist_metadata
508
573
  end
509
574
 
510
575
  def detect_paths(paths, verbose: false, stats: nil)
@@ -1,3 +1,3 @@
1
1
  module Fontist
2
- VERSION = "3.0.25".freeze
2
+ VERSION = "3.0.27".freeze
3
3
  end
data/lib/fontist.rb CHANGED
@@ -25,6 +25,7 @@ module Fontist
25
25
  autoload :FontPath, "#{__dir__}/fontist/font_path"
26
26
  autoload :FontStyle, "#{__dir__}/fontist/font_style"
27
27
  autoload :Fontconfig, "#{__dir__}/fontist/fontconfig"
28
+ autoload :FormatSpec, "#{__dir__}/fontist/format_spec"
28
29
  autoload :Formula, "#{__dir__}/fontist/formula"
29
30
  autoload :FormulaCollection, "#{__dir__}/fontist/formula"
30
31
  autoload :FormulaPicker, "#{__dir__}/fontist/formula_picker"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fontist
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.25
4
+ version: 3.0.27
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.