fontisan 0.4.46 → 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.
- checksums.yaml +4 -4
- data/BENCHMARKS.md +56 -0
- data/CHANGELOG.md +12 -0
- data/Cargo.lock +550 -0
- data/Cargo.toml +12 -0
- data/Gemfile +10 -0
- data/README.adoc +52 -0
- data/Rakefile +5 -1
- data/benchmark/baseline.json +5 -0
- data/benchmark/ci_gate.rb +217 -0
- data/benchmark/corpus/harness.rb +175 -0
- data/benchmark/corpus/ops.rb +150 -0
- data/benchmark/corpus/run.rb +182 -0
- data/benchmark/corpus/scan_child.rb +8 -0
- data/benchmark/corpus/tiers.rb +176 -0
- data/benchmark/tier_baseline.json +12 -0
- data/docs/.vitepress/config.ts +2 -1
- data/docs/guide/engines.md +117 -0
- data/docs/reference-facade.md +130 -0
- data/ext/fontisan_cdylib/Cargo.lock +313 -0
- data/ext/fontisan_cdylib/Cargo.toml +18 -0
- data/ext/fontisan_cdylib/build.rs +22 -0
- data/ext/fontisan_cdylib/extconf.rb +36 -0
- data/ext/fontisan_cdylib/src/lib.rs +13 -0
- data/ext/fontisan_native/Cargo.toml +21 -0
- data/ext/fontisan_native/build.rs +22 -0
- data/ext/fontisan_native/clippy.toml +10 -0
- data/ext/fontisan_native/extconf.rb +59 -0
- data/ext/fontisan_native/src/lib.rs +463 -0
- data/lib/fontisan/audit/checks/cmap_check.rb +7 -0
- data/lib/fontisan/audit/checks/hinting_check.rb +1 -1
- data/lib/fontisan/audit/checks/maxp_check.rb +1 -1
- data/lib/fontisan/audit/checks/opentype_conformance_check.rb +5 -2
- data/lib/fontisan/base_collection.rb +30 -0
- data/lib/fontisan/byte_ops.rb +219 -0
- data/lib/fontisan/cli.rb +5 -5
- data/lib/fontisan/commands/audit_command.rb +38 -43
- data/lib/fontisan/commands/convert_command.rb +53 -0
- data/lib/fontisan/commands/instance_command.rb +75 -0
- data/lib/fontisan/commands/subset_command.rb +43 -0
- data/lib/fontisan/commands/unpack_command.rb +56 -0
- data/lib/fontisan/converters/cff_table_builder.rb +1 -1
- data/lib/fontisan/converters/collection_converter.rb +13 -5
- data/lib/fontisan/converters/format_converter.rb +1 -1
- data/lib/fontisan/converters/woff2_encoder.rb +8 -6
- data/lib/fontisan/converters/woff_writer.rb +6 -5
- data/lib/fontisan/engine.rb +350 -0
- data/lib/fontisan/ffi/client.rb +121 -0
- data/lib/fontisan/ffi.rb +343 -0
- data/lib/fontisan/ffi_platform.rb +24 -0
- data/lib/fontisan/font_loader.rb +5 -1
- data/lib/fontisan/font_writer.rb +13 -1
- data/lib/fontisan/native.rb +231 -0
- data/lib/fontisan/pipeline/transformation_pipeline.rb +10 -8
- data/lib/fontisan/pipeline.rb +1 -1
- data/lib/fontisan/reference/table_digests.rb +251 -0
- data/lib/fontisan/reference.rb +619 -0
- data/lib/fontisan/scan.rb +372 -0
- data/lib/fontisan/sfnt_directory.rb +41 -0
- data/lib/fontisan/sfnt_font.rb +16 -6
- data/lib/fontisan/subset/builder.rb +2 -1
- data/lib/fontisan/tables/cff/charstring.rb +16 -0
- data/lib/fontisan/tables/cff.rb +15 -5
- data/lib/fontisan/tables/cmap.rb +11 -1
- data/lib/fontisan/tables/glyf.rb +4 -6
- data/lib/fontisan/tables/gpos.rb +7 -0
- data/lib/fontisan/tables/gsub.rb +7 -0
- data/lib/fontisan/tables/layout_common.rb +172 -0
- data/lib/fontisan/tables/name.rb +10 -4
- data/lib/fontisan/utilities/brotli_wrapper.rb +39 -4
- data/lib/fontisan/validators/opentype_validator.rb +15 -3
- data/lib/fontisan/variable/instancer.rb +23 -192
- data/lib/fontisan/variable/static_font_builder.rb +4 -1
- data/lib/fontisan/variable/table_updater.rb +4 -2
- data/lib/fontisan/variation/instance_generator.rb +3 -149
- data/lib/fontisan/variation/instancing/axis_space.rb +136 -0
- data/lib/fontisan/variation/instancing/glyph_outline.rb +204 -0
- data/lib/fontisan/variation/instancing/gvar_reader.rb +146 -0
- data/lib/fontisan/variation/instancing/item_variation_store.rb +92 -0
- data/lib/fontisan/variation/instancing/name_rewriter.rb +90 -0
- data/lib/fontisan/variation/instancing/true_type_instancer.rb +326 -0
- data/lib/fontisan/variation/instancing.rb +20 -0
- data/lib/fontisan/variation/variable_svg_generator.rb +19 -1
- data/lib/fontisan/variation.rb +1 -0
- data/lib/fontisan/version.rb +1 -1
- data/lib/fontisan/woff2/collection_decoder.rb +15 -1
- data/lib/fontisan/woff2/collection_encoder.rb +29 -6
- data/lib/fontisan/woff2/encoder_rules.rb +12 -4
- data/lib/fontisan/woff2/glyf_loca_reconstruct.rb +1 -1
- data/lib/fontisan/woff2_collection.rb +7 -5
- data/lib/fontisan/woff2_font.rb +12 -3
- data/lib/fontisan/woff_container.rb +83 -0
- data/lib/fontisan/woff_font.rb +24 -20
- data/lib/fontisan.rb +24 -1
- data/rakelib/bench.rake +9 -0
- data/rakelib/cdylib.rake +39 -0
- data/rakelib/corpus.rake +272 -0
- data/rakelib/corpus_convert.rake +43 -0
- data/rakelib/corpus_goldens.rake +22 -0
- data/rakelib/corpus_malformed.rake +20 -0
- data/rakelib/corpus_scan.rake +24 -0
- data/rakelib/java.rake +61 -0
- data/rakelib/native.rake +43 -0
- data/scripts/build-woff2.sh +57 -0
- data/scripts/corpus/generate_convert_goldens.rb +478 -0
- data/scripts/corpus/generate_goldens.rb +282 -0
- data/scripts/corpus/generate_malformed_goldens.rb +254 -0
- data/scripts/corpus/generate_scan_goldens.rb +98 -0
- data/scripts/tier_fallback.rb +219 -0
- data/scripts/tier_smoke.rb +64 -0
- metadata +75 -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
|
|
@@ -262,7 +262,9 @@ conv_options = nil)
|
|
|
262
262
|
converter = FormatConverter.new
|
|
263
263
|
|
|
264
264
|
# Build converter options, including ConversionOptions if provided
|
|
265
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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.
|
|
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 =
|
|
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
|
|
168
|
-
#
|
|
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>]
|
|
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
|
|
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
|