fontisan 0.4.47 → 0.4.48

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 (99) hide show
  1. checksums.yaml +4 -4
  2. data/BENCHMARKS.md +56 -0
  3. data/CHANGELOG.md +12 -0
  4. data/Cargo.lock +550 -0
  5. data/Cargo.toml +12 -0
  6. data/Gemfile +10 -0
  7. data/README.adoc +52 -0
  8. data/Rakefile +5 -1
  9. data/benchmark/baseline.json +5 -0
  10. data/benchmark/ci_gate.rb +217 -0
  11. data/benchmark/corpus/harness.rb +175 -0
  12. data/benchmark/corpus/ops.rb +150 -0
  13. data/benchmark/corpus/run.rb +182 -0
  14. data/benchmark/corpus/scan_child.rb +8 -0
  15. data/benchmark/corpus/tiers.rb +176 -0
  16. data/benchmark/tier_baseline.json +12 -0
  17. data/docs/.vitepress/config.ts +2 -1
  18. data/docs/guide/engines.md +117 -0
  19. data/docs/reference-facade.md +130 -0
  20. data/ext/fontisan_cdylib/Cargo.lock +313 -0
  21. data/ext/fontisan_cdylib/Cargo.toml +18 -0
  22. data/ext/fontisan_cdylib/build.rs +22 -0
  23. data/ext/fontisan_cdylib/extconf.rb +36 -0
  24. data/ext/fontisan_cdylib/src/lib.rs +13 -0
  25. data/ext/fontisan_native/Cargo.toml +21 -0
  26. data/ext/fontisan_native/build.rs +22 -0
  27. data/ext/fontisan_native/clippy.toml +10 -0
  28. data/ext/fontisan_native/extconf.rb +59 -0
  29. data/ext/fontisan_native/src/lib.rs +463 -0
  30. data/lib/fontisan/audit/checks/cmap_check.rb +7 -0
  31. data/lib/fontisan/audit/checks/hinting_check.rb +1 -1
  32. data/lib/fontisan/audit/checks/maxp_check.rb +1 -1
  33. data/lib/fontisan/audit/checks/opentype_conformance_check.rb +5 -2
  34. data/lib/fontisan/base_collection.rb +30 -0
  35. data/lib/fontisan/byte_ops.rb +219 -0
  36. data/lib/fontisan/cli.rb +5 -5
  37. data/lib/fontisan/commands/audit_command.rb +38 -43
  38. data/lib/fontisan/commands/convert_command.rb +53 -0
  39. data/lib/fontisan/commands/instance_command.rb +75 -0
  40. data/lib/fontisan/commands/subset_command.rb +43 -0
  41. data/lib/fontisan/commands/unpack_command.rb +56 -0
  42. data/lib/fontisan/converters/cff_table_builder.rb +1 -1
  43. data/lib/fontisan/converters/collection_converter.rb +13 -5
  44. data/lib/fontisan/converters/format_converter.rb +1 -1
  45. data/lib/fontisan/converters/woff2_encoder.rb +8 -6
  46. data/lib/fontisan/converters/woff_writer.rb +6 -5
  47. data/lib/fontisan/engine.rb +350 -0
  48. data/lib/fontisan/ffi/client.rb +121 -0
  49. data/lib/fontisan/ffi.rb +343 -0
  50. data/lib/fontisan/ffi_platform.rb +24 -0
  51. data/lib/fontisan/font_loader.rb +5 -1
  52. data/lib/fontisan/font_writer.rb +13 -1
  53. data/lib/fontisan/native.rb +231 -0
  54. data/lib/fontisan/reference/table_digests.rb +251 -0
  55. data/lib/fontisan/reference.rb +619 -0
  56. data/lib/fontisan/scan.rb +372 -0
  57. data/lib/fontisan/sfnt_directory.rb +41 -0
  58. data/lib/fontisan/sfnt_font.rb +16 -6
  59. data/lib/fontisan/subset/builder.rb +2 -1
  60. data/lib/fontisan/tables/cff/charstring.rb +16 -0
  61. data/lib/fontisan/tables/cff.rb +15 -5
  62. data/lib/fontisan/tables/cmap.rb +11 -1
  63. data/lib/fontisan/tables/gpos.rb +7 -0
  64. data/lib/fontisan/tables/gsub.rb +7 -0
  65. data/lib/fontisan/tables/layout_common.rb +172 -0
  66. data/lib/fontisan/tables/name.rb +10 -4
  67. data/lib/fontisan/utilities/brotli_wrapper.rb +39 -4
  68. data/lib/fontisan/validators/opentype_validator.rb +2 -0
  69. data/lib/fontisan/variable/instancer.rb +1 -0
  70. data/lib/fontisan/variable/static_font_builder.rb +4 -1
  71. data/lib/fontisan/variable/table_updater.rb +4 -2
  72. data/lib/fontisan/variation/instancing/true_type_instancer.rb +14 -6
  73. data/lib/fontisan/version.rb +1 -1
  74. data/lib/fontisan/woff2/collection_decoder.rb +15 -1
  75. data/lib/fontisan/woff2/collection_encoder.rb +29 -6
  76. data/lib/fontisan/woff2/encoder_rules.rb +12 -4
  77. data/lib/fontisan/woff2/glyf_loca_reconstruct.rb +1 -1
  78. data/lib/fontisan/woff2_collection.rb +7 -5
  79. data/lib/fontisan/woff2_font.rb +12 -3
  80. data/lib/fontisan/woff_container.rb +83 -0
  81. data/lib/fontisan/woff_font.rb +24 -20
  82. data/lib/fontisan.rb +24 -1
  83. data/rakelib/bench.rake +9 -0
  84. data/rakelib/cdylib.rake +39 -0
  85. data/rakelib/corpus.rake +272 -0
  86. data/rakelib/corpus_convert.rake +43 -0
  87. data/rakelib/corpus_goldens.rake +22 -0
  88. data/rakelib/corpus_malformed.rake +20 -0
  89. data/rakelib/corpus_scan.rake +24 -0
  90. data/rakelib/java.rake +61 -0
  91. data/rakelib/native.rake +43 -0
  92. data/scripts/build-woff2.sh +57 -0
  93. data/scripts/corpus/generate_convert_goldens.rb +478 -0
  94. data/scripts/corpus/generate_goldens.rb +282 -0
  95. data/scripts/corpus/generate_malformed_goldens.rb +254 -0
  96. data/scripts/corpus/generate_scan_goldens.rb +98 -0
  97. data/scripts/tier_fallback.rb +219 -0
  98. data/scripts/tier_smoke.rb +64 -0
  99. metadata +68 -3
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
+ require "tmpdir"
4
5
 
5
6
  module Fontisan
6
7
  module Commands
@@ -98,8 +99,63 @@ module Fontisan
98
99
  raise Fontisan::Error, "Unexpected error during unpacking: #{e.message}"
99
100
  end
100
101
 
102
+ # Execute the command through the engine selection (the CLI entry): a
103
+ # tier other than Ruby serves the extraction when it can, and the result
104
+ # and the files written are those of {#run}. Anything the engine cannot
105
+ # honour (a +format+ or +prefix+, verbose output, an OTC input, a
106
+ # non-"ttcf" file, an index out of range) runs {#run} on the Ruby tier.
107
+ #
108
+ # @return [Hash] same as {#run}
109
+ def run_routed
110
+ indices = routed_indices
111
+ return run unless indices
112
+
113
+ FileUtils.mkdir_p(@output_dir)
114
+ files = indices.map { |index| extract_member(index) }
115
+ { collection: @collection_path, output_dir: @output_dir,
116
+ num_fonts: collection_member_count, fonts_extracted: files.size,
117
+ extracted_files: files }
118
+ rescue Fontisan::Error => e
119
+ raise Fontisan::Error, "Collection unpacking failed: #{e.message}"
120
+ end
121
+
101
122
  private
102
123
 
124
+ # Member indices to extract on a non-Ruby tier; nil when Ruby serves.
125
+ def routed_indices
126
+ return nil if @format || @prefix || @verbose || !ttcf_file?
127
+
128
+ count = collection_member_count
129
+ return nil if @font_index && @font_index >= count
130
+
131
+ indices = @font_index ? [@font_index] : (0...count).to_a
132
+ probe = { index: indices.first, output_dir: @output_dir.to_s }
133
+ Engine.resolve(:extract, @collection_path.to_s, probe) == :ruby ? nil : indices
134
+ end
135
+
136
+ def ttcf_file?
137
+ File.extname(@collection_path).casecmp?(".otc") ? false : File.binread(@collection_path, 4) == "ttcf"
138
+ end
139
+
140
+ # numFonts of the TTC header.
141
+ def collection_member_count
142
+ File.binread(@collection_path, 12).unpack1("@8N")
143
+ end
144
+
145
+ # One member through {Reference.extract}. A single requested index keeps
146
+ # the engine's file name; all members get Ruby's "<name>_<index>" form.
147
+ def extract_member(index)
148
+ return Reference.extract(@collection_path, index: index, output_dir: @output_dir) if @font_index
149
+
150
+ Dir.mktmpdir("fontisan-unpack") do |scratch|
151
+ file = Reference.extract(@collection_path, index: index, output_dir: scratch)
152
+ ext = File.extname(file)
153
+ target = File.join(@output_dir, "#{File.basename(file, ext)}_#{index}#{ext}")
154
+ FileUtils.mv(file, target)
155
+ target
156
+ end
157
+ end
158
+
103
159
  # Validate command options
104
160
  #
105
161
  # @raise [ArgumentError] if options are invalid
@@ -188,7 +188,7 @@ module Fontisan
188
188
  return font_name.dup.force_encoding("ASCII-8BIT") if font_name
189
189
  end
190
190
 
191
- "UnnamedFont"
191
+ "UnnamedFont".b
192
192
  end
193
193
  end
194
194
  end
@@ -262,7 +262,9 @@ conv_options = nil)
262
262
  converter = FormatConverter.new
263
263
 
264
264
  # Build converter options, including ConversionOptions if provided
265
- converter_opts = options.dup
265
+ # timestamp is a WOFF2 encoder option; the outline strategy rejects
266
+ # any option key it does not declare, even with a nil value.
267
+ converter_opts = options.except(:timestamp)
266
268
  converter_opts[:options] = conv_options if conv_options
267
269
 
268
270
  begin
@@ -288,7 +290,10 @@ conv_options = nil)
288
290
  note: "Conversion failed, kept original: #{e.message}",
289
291
  }
290
292
  else
291
- raise Error, "Font #{index} conversion failed: #{e.message}"
293
+ # Keep MissingTableError as its class so callers can rescue it
294
+ # by type; the message format is unchanged.
295
+ error_class = e.is_a?(Fontisan::MissingTableError) ? Fontisan::MissingTableError : Fontisan::Error
296
+ raise error_class, "Font #{index} conversion failed: #{e.message}"
292
297
  end
293
298
  end
294
299
  else
@@ -348,7 +353,8 @@ conv_options = nil)
348
353
  elsif font.has_table?("glyf")
349
354
  :ttf
350
355
  else
351
- raise Error, "Cannot detect font format"
356
+ raise Fontisan::MissingTableError,
357
+ "Font has neither glyf nor CFF table"
352
358
  end
353
359
  end
354
360
 
@@ -444,11 +450,13 @@ conv_options = nil)
444
450
  #
445
451
  # @param fonts [Array<Font>] Fonts
446
452
  # @param output_path [String] Output path (.woff2)
447
- # @param options [Hash] Options (looks for :brotli_quality)
453
+ # @param options [Hash] Options (looks for :brotli_quality, :timestamp)
448
454
  # @return [void]
449
455
  def repack_woff2_collection(fonts, output_path, options)
450
456
  brotli_quality = options.fetch(:brotli_quality, 11)
451
- encoder = Woff2::CollectionEncoder.new(brotli_quality:)
457
+ encoder = Woff2::CollectionEncoder.new(
458
+ brotli_quality:, timestamp: options[:timestamp],
459
+ )
452
460
  bytes = encoder.encode_fonts(fonts)
453
461
  File.binwrite(output_path, bytes)
454
462
  end
@@ -515,7 +515,7 @@ _options)
515
515
  raise ArgumentError,
516
516
  "Option(s) #{conflicts.map(&:inspect).join(', ')} do not apply " \
517
517
  "to #{source_format}→#{target_format} " \
518
- "(#{this_class.name.demodulize}). " \
518
+ "(#{this_class.name.split('::').last}). " \
519
519
  "Accepted: #{accepted_list}"
520
520
  end
521
521
 
@@ -54,6 +54,11 @@ module Fontisan
54
54
  cli: "--[no-]transform-tables",
55
55
  desc: "apply glyf/loca and hmtx transformations"
56
56
 
57
+ option :timestamp, type: :integer, default: nil,
58
+ cli: "--timestamp=SECONDS",
59
+ desc: "fixed head.modified (Unix seconds) for " \
60
+ "reproducible output; default is the clock"
61
+
57
62
  # Convert font to WOFF2 format.
58
63
  #
59
64
  # @param font [TrueTypeFont, OpenTypeFont] Source font
@@ -77,7 +82,8 @@ module Fontisan
77
82
 
78
83
  flavor = detect_flavor(font)
79
84
  table_data = collect_tables(font, options)
80
- Woff2::EncoderRules.apply!(table_data)
85
+ Woff2::EncoderRules.apply!(table_data,
86
+ timestamp: resolved[:timestamp])
81
87
 
82
88
  glyf_transform = apply_glyf_loca_transform!(table_data, font)
83
89
 
@@ -266,11 +272,7 @@ module Fontisan
266
272
  # encoder does the same — short loca halves the loca table size
267
273
  # and Chrome's OTS accepts both formats.
268
274
  max_canonical_glyf_size = glyf_data.bytesize + 3 * maxp.num_glyphs
269
- target_format = if max_canonical_glyf_size <= SHORT_LOCA_GLYF_MAX
270
- 0
271
- else
272
- source_format
273
- end
275
+ target_format = max_canonical_glyf_size <= SHORT_LOCA_GLYF_MAX ? 0 : 1
274
276
 
275
277
  canonical = Woff2::GlyfCanonicalizer.new(
276
278
  glyf_data:,
@@ -164,13 +164,14 @@ module Fontisan
164
164
  options.select { |k, _| names.include?(k.to_sym) }
165
165
  end
166
166
 
167
- # Collect all table data from font in input-font order (spec §6: tables
168
- # MUST be stored in the same order as the well-formed input font).
167
+ # Collect all table data from font, sorted ascending by tag (WOFF 1.0
168
+ # §5: the table directory MUST be sorted by tag; data follows directory
169
+ # order). Input-font order is not preserved.
169
170
  #
170
171
  # @param font [TrueTypeFont, OpenTypeFont]
171
172
  # @return [Hash<String, String>]
172
173
  def collect_tables_data(font)
173
- font.table_names.each_with_object({}) do |tag, h|
174
+ font.table_names.sort.each_with_object({}) do |tag, h|
174
175
  data = font.table_data[tag]
175
176
  h[tag] = data if data
176
177
  end
@@ -241,7 +242,7 @@ module Fontisan
241
242
  # emits header → directory → tables → optional metadata → optional
242
243
  # private data.
243
244
  #
244
- # @param compressed_tables [Hash<String, Hash>] in input-font order
245
+ # @param compressed_tables [Hash<String, Hash>] sorted by tag
245
246
  # @param font [TrueTypeFont, OpenTypeFont]
246
247
  # @param compressed_metadata [Hash, nil]
247
248
  # @param private_data [String, nil]
@@ -252,7 +253,7 @@ module Fontisan
252
253
  num_tables = compressed_tables.length
253
254
  data_start = header_size + (num_tables * 20)
254
255
 
255
- # Lay out tables in input-font order with 4-byte alignment padding.
256
+ # Lay out tables in directory (tag) order with 4-byte alignment padding.
256
257
  # Each entry: { tag:, info:, offset:, pad_bytes: }
257
258
  entries = []
258
259
  cursor = data_start
@@ -0,0 +1,350 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ # Engine selection for the {Fontisan::Reference} operations.
5
+ #
6
+ # FONTISAN_ENGINE=ruby|native|ffi|auto (default auto)
7
+ # FONTISAN_ENGINE_STRICT=1 (no fallback)
8
+ #
9
+ # `auto` resolves native, then ffi, then ruby: the first tier that is
10
+ # available and supports the operation wins. A forced tier that is unavailable
11
+ # falls back to Ruby silently, or raises {TierUnavailable} at setup when
12
+ # strict. An operation (or a path, e.g. dfont) a tier cannot serve always goes to
13
+ # Ruby, strict or not. FONTISAN_MODE and FONTISAN_LAZY are not read here.
14
+ #
15
+ # The Ruby tier is the unmodified Reference implementation; the gate is a
16
+ # module prepended to Reference's singleton, so Reference bodies are
17
+ # untouched. Its output is unchanged (no served_by key), so goldens stay
18
+ # tier-independent; {Engine.scan} and {Engine.audit_dir} return the same
19
+ # rows with a per-row "served_by".
20
+ module Engine
21
+ class Error < Fontisan::Error; end
22
+ class ConfigError < Error; end
23
+ class TierUnavailable < Error; end
24
+ # A WOFF2 operation fell to the Ruby tier on a platform with no `brotli`.
25
+ class Woff2Unavailable < TierUnavailable; end
26
+
27
+ SELECTIONS = %w[ruby native ffi auto].freeze
28
+ STRICT_ON = %w[1 true yes on].freeze
29
+ STRICT_OFF = ["", "0", "false", "no", "off"].freeze
30
+ AUTO_ORDER = %i[native ffi ruby].freeze
31
+ OPS = %i[read outline_hashes audit audit_dir scan convert extract
32
+ instance glyph_ids subset].freeze
33
+ REPLACEABLE_TIERS = %i[native ffi].freeze
34
+ TIER_CONTRACT = %i[available? supports? call].freeze
35
+ ROW_RECORDER = :fontisan_engine_rows
36
+ IN_OPERATION = :fontisan_engine_in_operation
37
+
38
+ Config = Struct.new(:selection, :strict)
39
+
40
+ # The Ruby tier: the original Reference methods, captured before the
41
+ # gate is prepended.
42
+ class RubyTier
43
+ def initialize(originals)
44
+ @originals = originals
45
+ end
46
+
47
+ def available?
48
+ true
49
+ end
50
+
51
+ def supports?(_operation, _path = nil)
52
+ true
53
+ end
54
+
55
+ def call(operation, ...)
56
+ @originals.fetch(operation).call(...)
57
+ end
58
+ end
59
+
60
+ # Prepended to Reference's singleton: one gate per Reference operation.
61
+ module Gate
62
+ OPS.each do |operation|
63
+ define_method(operation) do |*args, **kwargs, &block|
64
+ Engine.dispatch(operation, args, kwargs, &block)
65
+ end
66
+ end
67
+ end
68
+
69
+ @mutex = Mutex.new
70
+ @tiers = nil
71
+ @served = {}
72
+ @counters = nil
73
+ @availability = {}
74
+
75
+ class << self
76
+ # Prepend the gates onto +reference+ (once).
77
+ def install(reference)
78
+ return if reference.singleton_class.include?(Gate)
79
+
80
+ originals = OPS.to_h { |operation| [operation, reference.method(operation)] }
81
+ @tiers = { ruby: RubyTier.new(originals) }.freeze
82
+ reference.singleton_class.prepend(Gate)
83
+ end
84
+
85
+ # Current configuration from the environment; raises at setup for a
86
+ # bad value or a strict forced tier that is not available.
87
+ def setup!
88
+ # The gates are installed when Reference loads (autoload); a fresh
89
+ # process, e.g. the CLI, reaches the engine before anything touched it.
90
+ Fontisan::Reference unless @tiers
91
+ config = config_from_env
92
+ return config unless config.strict && forced?(config.selection)
93
+
94
+ tier = config.selection
95
+ unless available?(tier)
96
+ raise TierUnavailable,
97
+ "FONTISAN_ENGINE=#{tier} with FONTISAN_ENGINE_STRICT=1 " \
98
+ "but the #{tier} tier is not available"
99
+ end
100
+ config
101
+ end
102
+
103
+ # Tier (Symbol) that serves +operation+ for +path+ under the current config.
104
+ def resolve(operation, path = nil, options = nil)
105
+ config = setup!
106
+ tier = candidates(config).find do |name|
107
+ available?(name) && supports?(name, operation, path, options)
108
+ end || :ruby
109
+ require_woff2_support!(operation, path, options) if tier == :ruby
110
+ tier
111
+ end
112
+
113
+ # Tier that served the last call of +operation+ in this process; nil if none.
114
+ def served_by(operation)
115
+ @mutex.synchronize { @served[operation.to_sym] }
116
+ end
117
+
118
+ # { tier => { operation => calls } } since the last {reset!}.
119
+ def counters
120
+ @mutex.synchronize do
121
+ (@counters || {}).transform_values(&:dup)
122
+ end
123
+ end
124
+
125
+ # Scan rows with a "served_by" tier name per row.
126
+ def scan(paths)
127
+ scan_pairs(paths).flat_map do |tier, rows|
128
+ rows.map { |row| row.merge("served_by" => tier.to_s) }
129
+ end
130
+ end
131
+
132
+ # audit_dir entries with a "served_by" tier name per entry: the tier
133
+ # that audited that file. A file the serving tier hands back to Ruby
134
+ # (dfont, Type 1 inside a native audit_dir) is recorded as :ruby by the
135
+ # nested call; every other entry carries the tier that served the call.
136
+ def audit_dir(dir, validate: false)
137
+ dir_path = File.path(dir)
138
+ whole = resolve(:audit_dir, dir_path)
139
+ seen = {}
140
+ entries = with_recorder(seen) do
141
+ Reference.audit_dir(dir, validate: validate)
142
+ end
143
+ entries.map do |entry|
144
+ tier = seen[File.join(dir_path, entry["path"])] || whole
145
+ entry.merge("served_by" => tier.to_s)
146
+ end
147
+ end
148
+
149
+ # Run +operation+ on the Ruby tier, whatever is selected (a tier handing
150
+ # a file it cannot read back to Ruby).
151
+ def ruby_call(operation, ...)
152
+ tier_object(:ruby).call(operation, ...)
153
+ end
154
+
155
+ # Called by the gates.
156
+ def dispatch(operation, args, kwargs, &)
157
+ path = args.first
158
+ path = path.to_s if path.respond_to?(:to_path)
159
+ path = nil unless path.is_a?(String)
160
+ return scan_rows(args, kwargs) if operation == :scan && !args.empty?
161
+
162
+ tier = resolve(operation, path, kwargs)
163
+ run(tier, operation, path, args, kwargs, &)
164
+ end
165
+
166
+ # The object-based entry points (`Subset::Builder.new(font, ...)`,
167
+ # `Variable::Instancer.new(font)`) are never dispatched: a loaded font's
168
+ # state lives in cached parsed tables, so they always run on Ruby. They
169
+ # call this so that {served_by} reports it; inside a gated call (the
170
+ # Ruby tier's own Reference body) the gate has already recorded the tier.
171
+ def record_ruby(operation)
172
+ return if Thread.current[IN_OPERATION]
173
+
174
+ record(:ruby, operation, nil)
175
+ end
176
+
177
+ # Forget probes, counters, served state and registered tiers.
178
+ def reset!
179
+ @mutex.synchronize do
180
+ @served = {}
181
+ @counters = nil
182
+ @availability = {}
183
+ @tiers = @tiers.slice(:ruby) if @tiers
184
+ end
185
+ Native.reset!
186
+ Ffi.reset!
187
+ end
188
+
189
+ # Register a tier object (tests, or the extension itself). A bad
190
+ # registration raises here, at setup, never as a silent fallback later.
191
+ def register(name, tier)
192
+ validate_registration!(name, tier)
193
+ @mutex.synchronize do
194
+ @tiers = @tiers.merge(name.to_sym => tier).freeze
195
+ @availability.delete(name.to_sym)
196
+ end
197
+ end
198
+
199
+ private
200
+
201
+ def validate_registration!(name, tier)
202
+ unless REPLACEABLE_TIERS.include?(name.to_s.to_sym)
203
+ raise ConfigError,
204
+ "cannot register tier #{name.inspect}: only " \
205
+ "#{REPLACEABLE_TIERS.join(' and ')} can be registered"
206
+ end
207
+
208
+ missing = TIER_CONTRACT.reject { |method| tier.respond_to?(method) }
209
+ return if missing.empty?
210
+
211
+ raise ConfigError,
212
+ "tier #{name} does not implement #{missing.join(', ')}"
213
+ end
214
+
215
+ def config_from_env
216
+ selection = ENV.fetch("FONTISAN_ENGINE", "auto").strip.downcase
217
+ selection = "auto" if selection.empty?
218
+ unless SELECTIONS.include?(selection)
219
+ raise ConfigError,
220
+ "FONTISAN_ENGINE=#{selection.inspect} is not one of " \
221
+ "#{SELECTIONS.join(', ')}"
222
+ end
223
+
224
+ Config.new(selection.to_sym, strict_from_env)
225
+ end
226
+
227
+ # 1/true/yes/on is strict; unset, empty and 0/false/no/off is not. Any
228
+ # other value raises, as an unknown FONTISAN_ENGINE does: a typo must not
229
+ # silently leave a service non-strict.
230
+ def strict_from_env
231
+ value = ENV.fetch("FONTISAN_ENGINE_STRICT", "").strip.downcase
232
+ return true if STRICT_ON.include?(value)
233
+ return false if STRICT_OFF.include?(value)
234
+
235
+ raise ConfigError,
236
+ "FONTISAN_ENGINE_STRICT=#{value.inspect} is not one of " \
237
+ "#{(STRICT_ON + STRICT_OFF.reject(&:empty?)).join(', ')}"
238
+ end
239
+
240
+ # The java-platform gem has no `brotli`: a WOFF2 operation that no other
241
+ # tier took must fail here, at setup, not as a LoadError mid-parse.
242
+ def require_woff2_support!(operation, path, options)
243
+ return unless woff2_operation?(path, options)
244
+ return if Utilities::BrotliWrapper.available?
245
+
246
+ raise Woff2Unavailable,
247
+ "#{operation} on WOFF2 needs the ffi tier or the `brotli` gem; " \
248
+ "neither is available. #{Utilities::BrotliWrapper::MISSING_MESSAGE}"
249
+ end
250
+
251
+ def woff2_operation?(path, options)
252
+ File.extname(path.to_s).casecmp?(".woff2") ||
253
+ options&.dig(:to).to_s.casecmp?("woff2")
254
+ end
255
+
256
+ def forced?(selection)
257
+ %i[native ffi].include?(selection)
258
+ end
259
+
260
+ def candidates(config)
261
+ case config.selection
262
+ when :auto then AUTO_ORDER
263
+ when :ruby then [:ruby]
264
+ else [config.selection]
265
+ end
266
+ end
267
+
268
+ def tier_object(name)
269
+ case name
270
+ when :ruby then @tiers.fetch(:ruby)
271
+ else @tiers[name] || { native: Native, ffi: Ffi }.fetch(name)
272
+ end
273
+ end
274
+
275
+ def available?(name)
276
+ @mutex.synchronize do
277
+ return @availability[name] if @availability.key?(name)
278
+ end
279
+ result = probe(name)
280
+ @mutex.synchronize { @availability[name] = result }
281
+ end
282
+
283
+ # Any failure while probing is "not available"; never an exception.
284
+ def probe(name)
285
+ tier_object(name).available? || false
286
+ rescue StandardError, ScriptError
287
+ false
288
+ end
289
+
290
+ # A tier may also vet the call's keyword options (`supports_options?`).
291
+ def supports?(name, operation, path, options = nil)
292
+ tier = tier_object(name)
293
+ return false unless tier.supports?(operation, path)
294
+ return true unless options && tier.respond_to?(:supports_options?)
295
+
296
+ takes_path = tier.method(:supports_options?).arity.abs > 2
297
+ (takes_path ? tier.supports_options?(operation, options, path) : tier.supports_options?(operation, options)) || false
298
+ rescue StandardError, ScriptError
299
+ false
300
+ end
301
+
302
+ def run(tier, operation, path, args, kwargs, &)
303
+ record(tier, operation, path)
304
+ previous = Thread.current[IN_OPERATION]
305
+ Thread.current[IN_OPERATION] = true
306
+ begin
307
+ tier_object(tier).call(operation, *args, **kwargs, &)
308
+ ensure
309
+ Thread.current[IN_OPERATION] = previous
310
+ end
311
+ end
312
+
313
+ def record(tier, operation, path)
314
+ @mutex.synchronize do
315
+ @served[operation] = tier
316
+ table = (@counters ||= Hash.new { |h, k| h[k] = Hash.new(0) })
317
+ table[tier][operation] += 1
318
+ end
319
+ recorder = Thread.current[ROW_RECORDER]
320
+ recorder[path] = tier if recorder && path
321
+ end
322
+
323
+ def scan_rows(args, kwargs)
324
+ raise ArgumentError, "scan takes the paths only" unless kwargs.empty?
325
+
326
+ setup!
327
+
328
+ scan_pairs(*args).flat_map { |_tier, rows| rows }
329
+ end
330
+
331
+ # [[tier, rows], ...] one pair per path, in input order.
332
+ def scan_pairs(paths)
333
+ Array(paths).map do |path|
334
+ path_s = path.respond_to?(:to_path) ? path.to_path : path
335
+ tier = resolve(:scan, path_s.is_a?(String) ? path_s : nil)
336
+ record(tier, :scan, nil)
337
+ [tier, tier_object(tier).call(:scan, [path]).to_a]
338
+ end
339
+ end
340
+
341
+ def with_recorder(seen)
342
+ previous = Thread.current[ROW_RECORDER]
343
+ Thread.current[ROW_RECORDER] = seen
344
+ yield
345
+ ensure
346
+ Thread.current[ROW_RECORDER] = previous
347
+ end
348
+ end
349
+ end
350
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ffi"
4
+
5
+ module Fontisan
6
+ module Ffi
7
+ # One loaded copy of the cdylib: the C structs of fontisan.h (ABI 1) and
8
+ # the three exported functions. Only this file requires the optional `ffi`
9
+ # gem, and only {Fontisan::Ffi} requires this file.
10
+ class Client
11
+ MAGIC = 0x4E415346 # "FSAN"
12
+ ABI_VERSION = 1
13
+
14
+ OK = 0
15
+ INVALID_ARG = 7
16
+ READ_FAILED = 9
17
+ UNSUPPORTED = 10
18
+
19
+ # The result block of one call; its pointers die with {#close}.
20
+ Reply = Struct.new(:status, :payload, :message)
21
+
22
+ class Header < FFI::Struct
23
+ layout :magic, :uint32, :abi_version, :uint32, :struct_size, :uint32,
24
+ :reserved, :uint32
25
+ end
26
+
27
+ class Request < FFI::Struct
28
+ layout :header, Header, :op, :uint32, :flags, :uint32,
29
+ :input, :pointer, :input_len, :size_t
30
+ end
31
+
32
+ class ErrorRow < FFI::Struct
33
+ layout :code, :int32, :message, :pointer, :message_len, :size_t
34
+ end
35
+
36
+ class Result < FFI::Struct
37
+ layout :header, Header, :status, :int32, :error, ErrorRow,
38
+ :payload, :pointer, :payload_len, :size_t
39
+ end
40
+
41
+ # Loads +path+ and binds its symbols.
42
+ #
43
+ # @return [Module] an FFI::Library with the exported functions attached
44
+ def self.bind(path)
45
+ Module.new do
46
+ extend FFI::Library
47
+
48
+ ffi_lib path
49
+ attach_function :fontisan_abi_version, [], :uint32
50
+ attach_function :fontisan_call, %i[pointer pointer], :int32
51
+ attach_function :fontisan_result_free, [:pointer], :void
52
+ attach_function :fontisan_cdylib_engine_rev, [], :string
53
+ end
54
+ end
55
+
56
+ # @raise [LoadError] when the library or one of its symbols is missing; a
57
+ # library from before the engine-rev symbol existed is stale, so it too
58
+ # @raise [Fontisan::Ffi::AbiMismatch] when it speaks another ABI version
59
+ # or was built from another engine rev than {Fontisan::Native::ENGINE_REV}
60
+ def initialize(path)
61
+ @binding = self.class.bind(path)
62
+ found = @binding.fontisan_abi_version
63
+ raise AbiMismatch, "#{path} speaks ABI #{found}, this gem expects #{ABI_VERSION}" unless found == ABI_VERSION
64
+
65
+ rev = @binding.fontisan_cdylib_engine_rev
66
+ return if rev == Fontisan::Native::ENGINE_REV
67
+
68
+ raise AbiMismatch, "#{path} was built from engine #{rev}, this gem expects #{Fontisan::Native::ENGINE_REV}"
69
+ rescue FFI::NotFoundError => e
70
+ raise LoadError, e.message
71
+ end
72
+
73
+ # Run one operation.
74
+ #
75
+ # @param operation [Integer] a FONTISAN_OP_* number
76
+ # @param input [String] the request bytes
77
+ # @param flags [Integer]
78
+ # @return [Reply]
79
+ def call(operation, input, flags = 0)
80
+ input = input.b
81
+ buffer = FFI::MemoryPointer.new(:uint8, [input.bytesize, 1].max)
82
+ buffer.put_bytes(0, input)
83
+ out = FFI::MemoryPointer.new(:pointer)
84
+ @binding.fontisan_call(request(operation, flags, buffer, input.bytesize), out)
85
+ read_result(out.read_pointer)
86
+ end
87
+
88
+ private
89
+
90
+ def request(operation, flags, buffer, length)
91
+ req = Request.new
92
+ req[:header][:magic] = MAGIC
93
+ req[:header][:abi_version] = ABI_VERSION
94
+ req[:header][:struct_size] = Request.size
95
+ req[:header][:reserved] = 0
96
+ req[:op] = operation
97
+ req[:flags] = flags
98
+ req[:input] = buffer
99
+ req[:input_len] = length
100
+ req
101
+ end
102
+
103
+ def read_result(pointer)
104
+ raise Fontisan::Error, "fontisan_call returned no result block" if pointer.null?
105
+
106
+ result = Result.new(pointer)
107
+ Reply.new(result[:status], bytes(result[:payload], result[:payload_len]),
108
+ bytes(result[:error][:message], result[:error][:message_len])
109
+ .force_encoding(Encoding::UTF_8))
110
+ ensure
111
+ @binding.fontisan_result_free(pointer) unless pointer.null?
112
+ end
113
+
114
+ def bytes(pointer, length)
115
+ return "".b if pointer.null? || length.zero?
116
+
117
+ pointer.read_bytes(length)
118
+ end
119
+ end
120
+ end
121
+ end