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.
- 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/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/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 +2 -0
- data/lib/fontisan/variable/instancer.rb +1 -0
- data/lib/fontisan/variable/static_font_builder.rb +4 -1
- data/lib/fontisan/variable/table_updater.rb +4 -2
- data/lib/fontisan/variation/instancing/true_type_instancer.rb +14 -6
- 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 +68 -3
data/lib/fontisan/ffi.rb
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "tmpdir"
|
|
6
|
+
require "rbconfig"
|
|
7
|
+
require "fontisan/ffi_platform"
|
|
8
|
+
|
|
9
|
+
module Fontisan
|
|
10
|
+
# The `:ffi` engine tier: the Rust engine's C ABI (fontisan.h, version 1)
|
|
11
|
+
# loaded through the optional `ffi` gem. It serves the read-side
|
|
12
|
+
# {Fontisan::Reference} operations (read, audit, audit_dir, scan) and the
|
|
13
|
+
# byte operations (convert, extract, instance, subset: card 2f-ffi, the same
|
|
14
|
+
# routing rules as {Fontisan::Native} through {Fontisan::ByteOps}); every
|
|
15
|
+
# other operation, and dfont / Type 1 files, are the Ruby tier's.
|
|
16
|
+
#
|
|
17
|
+
# The C ABI has no glyph-id operation: a subset's glyph ids come from the
|
|
18
|
+
# Ruby facade's `glyph_ids` (a pure cmap read, run on the Ruby tier).
|
|
19
|
+
#
|
|
20
|
+
# The `ffi` gem and the shared library are loaded lazily, on the first probe.
|
|
21
|
+
# A missing gem, missing or unloadable library, missing symbol or ABI
|
|
22
|
+
# mismatch makes {.available?} false and never raises.
|
|
23
|
+
#
|
|
24
|
+
# The library is looked up in FONTISAN_CDYLIB (a file path), then in
|
|
25
|
+
# lib/fontisan/ffi/<platform>/ (the java-platform gem, one directory per
|
|
26
|
+
# bundled OS and architecture, see {FfiPlatform}), then in lib/fontisan/ffi/
|
|
27
|
+
# (the platform gems).
|
|
28
|
+
#
|
|
29
|
+
# Tier contract: `available?`, `supports?(operation, path)` and
|
|
30
|
+
# `call(operation, *args, **kwargs)`, as for {Fontisan::Native}.
|
|
31
|
+
module Ffi
|
|
32
|
+
class AbiMismatch < Fontisan::Error; end
|
|
33
|
+
|
|
34
|
+
LIBRARY = "fontisan_cdylib"
|
|
35
|
+
OPERATIONS = %i[read audit audit_dir scan convert extract instance subset].freeze
|
|
36
|
+
RUBY_ONLY_FORMATS = %i[dfont pfa pfb].freeze
|
|
37
|
+
|
|
38
|
+
OP_CONVERT = 1
|
|
39
|
+
OP_EXTRACT = 2
|
|
40
|
+
OP_SUBSET = 6
|
|
41
|
+
OP_INSTANCE = 7
|
|
42
|
+
OP_READ = 8
|
|
43
|
+
OP_AUDIT = 9
|
|
44
|
+
OP_SCAN = 10
|
|
45
|
+
OP_AUDIT_DIR = 11
|
|
46
|
+
FLAG_METADATA = 1
|
|
47
|
+
FLAG_LAZY = 2
|
|
48
|
+
FLAG_VALIDATE = 1
|
|
49
|
+
|
|
50
|
+
CONVERT_FAILED = 8
|
|
51
|
+
SUBSET_PROFILE_CODES = { "pdf" => 0, "web" => 1, "minimal" => 2 }.freeze
|
|
52
|
+
FIXED_ONE = 65_536
|
|
53
|
+
|
|
54
|
+
class << self
|
|
55
|
+
include ByteOps
|
|
56
|
+
|
|
57
|
+
# Probe: true only when the `ffi` gem and a compatible library both load.
|
|
58
|
+
def available?
|
|
59
|
+
return @available if defined?(@available)
|
|
60
|
+
|
|
61
|
+
@available = probe
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Forget the memoised probe and the loaded library.
|
|
65
|
+
def reset!
|
|
66
|
+
@client = nil
|
|
67
|
+
remove_instance_variable(:@available) if defined?(@available)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Whether this tier serves +operation+ for +path+: the read-side
|
|
71
|
+
# operations on anything but dfont and Type 1 files.
|
|
72
|
+
def supports?(operation, path = nil)
|
|
73
|
+
return false unless available? && OPERATIONS.include?(operation)
|
|
74
|
+
|
|
75
|
+
case operation
|
|
76
|
+
when *ByteOps::BYTE_OPERATIONS then path.is_a?(String) && File.file?(path) && !ruby_only?(path)
|
|
77
|
+
when :audit_dir then path.nil? || File.directory?(path)
|
|
78
|
+
when :scan then path.nil? || File.file?(path) && !ruby_only?(path)
|
|
79
|
+
else path.nil? || File.file?(path) && !ruby_only?(path)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Options the engine honours; anything else is served by Ruby. The
|
|
84
|
+
# read-side operations check their own options and fall back inside.
|
|
85
|
+
def supports_options?(operation, options, path = nil)
|
|
86
|
+
return byte_options?(operation, options, path) if ByteOps::BYTE_OPERATIONS.include?(operation)
|
|
87
|
+
|
|
88
|
+
OPERATIONS.include?(operation)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def call(operation, *, **)
|
|
92
|
+
raise Fontisan::Error, "ffi tier cannot serve #{operation}" unless OPERATIONS.include?(operation)
|
|
93
|
+
raise Fontisan::Error, "ffi tier cannot serve #{operation}: library not loaded" unless available?
|
|
94
|
+
|
|
95
|
+
__send__(:"serve_#{operation}", *, **)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
attr_reader :client
|
|
101
|
+
|
|
102
|
+
def probe
|
|
103
|
+
require "fontisan/ffi/client"
|
|
104
|
+
path = library_path or return false
|
|
105
|
+
@client = Client.new(path)
|
|
106
|
+
true
|
|
107
|
+
rescue StandardError, ScriptError
|
|
108
|
+
@client = nil
|
|
109
|
+
false
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def library_path
|
|
113
|
+
override = ENV.fetch("FONTISAN_CDYLIB", "").strip
|
|
114
|
+
return override unless override.empty?
|
|
115
|
+
|
|
116
|
+
extension = case RbConfig::CONFIG["host_os"]
|
|
117
|
+
when /darwin/ then "dylib"
|
|
118
|
+
when /mswin|mingw|cygwin/ then "dll"
|
|
119
|
+
else "so"
|
|
120
|
+
end
|
|
121
|
+
prefix = extension == "dll" ? "" : "lib"
|
|
122
|
+
file = "#{prefix}#{LIBRARY}.#{extension}"
|
|
123
|
+
platform = FfiPlatform.dir
|
|
124
|
+
bundled = platform && File.expand_path("ffi/#{platform}/#{file}", __dir__)
|
|
125
|
+
bundled && File.file?(bundled) ? bundled : File.expand_path("ffi/#{file}", __dir__)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def ruby_only?(path)
|
|
129
|
+
RUBY_ONLY_FORMATS.include?(FontLoader.detect_format(path))
|
|
130
|
+
rescue StandardError
|
|
131
|
+
false
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Writes the converted font to +output+; returns the ConvertCommand hash.
|
|
135
|
+
def serve_convert(path, output:, timestamp: nil, **options)
|
|
136
|
+
path = File.path(path)
|
|
137
|
+
target = convert_target(options.fetch(:to))
|
|
138
|
+
stamp = timestamp ? unix_seconds(timestamp) : Time.now.to_i
|
|
139
|
+
prefix = [ByteOps::TARGET_CODES.fetch(target), 0, stamp].pack("NNq>")
|
|
140
|
+
bytes = byte_op(OP_CONVERT, prefix + File.binread(path))
|
|
141
|
+
finish_convert(path, target, File.path(output), bytes)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Same file layout as UnpackCommand: <output_dir>/<basename>.
|
|
145
|
+
def serve_extract(path, index:, output_dir:)
|
|
146
|
+
payload = byte_op(OP_EXTRACT, [index, 0, 0, 0].pack("NNNN") + File.binread(File.path(path)))
|
|
147
|
+
length = payload.unpack1("N")
|
|
148
|
+
name = payload.byteslice(4, length).force_encoding(Encoding::UTF_8)
|
|
149
|
+
FileUtils.mkdir_p(output_dir)
|
|
150
|
+
File.join(output_dir, name).tap { |out| File.binwrite(out, payload.byteslice(4 + length..)) }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def serve_instance(path, coords:, timestamp: nil)
|
|
154
|
+
_ = timestamp
|
|
155
|
+
axes = coords.map { |tag, value| tag.to_s.b + [(Float(value) * FIXED_ONE).round].pack("l>") }
|
|
156
|
+
byte_op(OP_INSTANCE, [axes.size].pack("N") + axes.join + File.binread(File.path(path)))
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def serve_subset(path, unicodes: nil, text: nil, profile: "web", timestamp: nil)
|
|
160
|
+
_ = timestamp
|
|
161
|
+
path = File.path(path)
|
|
162
|
+
gids = subset_glyph_ids(path, unicodes, text)
|
|
163
|
+
head = [SUBSET_PROFILE_CODES.fetch(profile.to_s), gids.size, 0, 0].pack("NNNN")
|
|
164
|
+
byte_op(OP_SUBSET, head + gids.pack("N*") + File.binread(path))
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Glyph ids of a subset request. A WOFF2 source on a platform without the
|
|
168
|
+
# `brotli` gem (the java gem on JRuby) cannot be read by the Ruby tier, so
|
|
169
|
+
# the engine decodes it (a convert to the same flavor) and the Ruby tier
|
|
170
|
+
# reads the cmap of that SFNT, under the source's own basename.
|
|
171
|
+
def subset_glyph_ids(path, unicodes, text)
|
|
172
|
+
return ruby(:glyph_ids, path, unicodes: unicodes, text: text) unless woff2_without_brotli?(path)
|
|
173
|
+
|
|
174
|
+
Dir.mktmpdir("fontisan-ffi-subset") do |dir|
|
|
175
|
+
decoded = File.join(dir, File.basename(path))
|
|
176
|
+
File.binwrite(decoded, decode_woff2(path))
|
|
177
|
+
ruby(:glyph_ids, decoded, unicodes: unicodes, text: text)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def woff2_without_brotli?(path)
|
|
182
|
+
File.binread(path, 4) == "wOF2" && !Utilities::BrotliWrapper.available?
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def decode_woff2(path)
|
|
186
|
+
input = File.binread(path)
|
|
187
|
+
target = input.byteslice(4, 4) == "OTTO" ? :otf : :ttf
|
|
188
|
+
byte_op(OP_CONVERT, [ByteOps::TARGET_CODES.fetch(target), 0, 0].pack("NNq>") + input)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# The payload of a byte operation; a failure is raised as the Ruby
|
|
192
|
+
# exception the message names (the ABI carries no cause chain).
|
|
193
|
+
def byte_op(operation, input)
|
|
194
|
+
reply = client.call(operation, input)
|
|
195
|
+
return reply.payload if reply.status == Client::OK
|
|
196
|
+
|
|
197
|
+
raise_byte_failure(reply)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def raise_byte_failure(reply)
|
|
201
|
+
match = ByteOps::FAILURE_MESSAGE.match(reply.message)
|
|
202
|
+
if reply.status == CONVERT_FAILED && match
|
|
203
|
+
klass, message = byte_failure(match[1], match[2])
|
|
204
|
+
raise build_instance(klass, message)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
raise Fontisan::Error, "fontisan ffi status #{reply.status}: #{reply.message}"
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def serve_read(path, mode: :metadata, lazy: true)
|
|
211
|
+
return ruby(:read, path, mode: mode, lazy: lazy) unless %i[metadata full].include?(mode)
|
|
212
|
+
|
|
213
|
+
path = reference_path(path)
|
|
214
|
+
flags = (mode == :metadata ? FLAG_METADATA : 0) | (lazy ? FLAG_LAZY : 0)
|
|
215
|
+
reply = client.call(OP_READ, File.binread(path), flags)
|
|
216
|
+
return ruby(:read, path, mode: mode, lazy: lazy) if reply.status == Client::UNSUPPORTED
|
|
217
|
+
|
|
218
|
+
rows(reply)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def serve_audit(path, validate: false)
|
|
222
|
+
return ruby(:audit, path, validate: validate) unless [true, false, nil].include?(validate)
|
|
223
|
+
|
|
224
|
+
path = reference_path(path)
|
|
225
|
+
input = [File.basename(path).bytesize].pack("N") + File.basename(path).b + File.binread(path)
|
|
226
|
+
reply = client.call(OP_AUDIT, input, validate ? FLAG_VALIDATE : 0)
|
|
227
|
+
return ruby(:audit, path, validate: validate) if reply.status == Client::UNSUPPORTED
|
|
228
|
+
|
|
229
|
+
rows(reply)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def serve_scan(paths)
|
|
233
|
+
Array(paths).flat_map do |path|
|
|
234
|
+
reply = client.call(OP_SCAN, File.path(path))
|
|
235
|
+
result = reply.status == Client::OK ? decode(reply.payload) : nil
|
|
236
|
+
result = nil if result&.any? { |row| row["status"] == "unsupported" }
|
|
237
|
+
# "status" is the engine's routing marker; Reference rows have none.
|
|
238
|
+
result ? result.map { |row| row.except("status") } : ruby(:scan, [path])
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def serve_audit_dir(dir, validate: false)
|
|
243
|
+
return ruby(:audit_dir, dir, validate: validate) unless [true, false, nil].include?(validate)
|
|
244
|
+
|
|
245
|
+
dir = reference_path(dir)
|
|
246
|
+
reply = client.call(OP_AUDIT_DIR, dir, validate ? FLAG_VALIDATE : 0)
|
|
247
|
+
raise Fontisan::Error, reply.message unless reply.status == Client::OK
|
|
248
|
+
|
|
249
|
+
decode(reply.payload).filter_map { |row| dir_entry(dir, row, validate) }
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def dir_entry(dir, row, validate)
|
|
253
|
+
rel = Reference.relative_row_path(dir, row["path"])
|
|
254
|
+
# Like the Ruby and native tiers: nothing below a hidden directory.
|
|
255
|
+
return if rel.split("/").any? { |part| part.start_with?(".") }
|
|
256
|
+
|
|
257
|
+
seen_by(File.join(dir, rel), :ffi)
|
|
258
|
+
case row["status"]
|
|
259
|
+
when "ok" then { "path" => rel, "faces" => row["reports"] }
|
|
260
|
+
when "error" then { "path" => rel, "error" => row["error"].slice("class", "message") }
|
|
261
|
+
else ruby_entry(dir, rel, validate)
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# A file the engine does not read (dfont, Type 1): the Ruby tier audits it.
|
|
266
|
+
def ruby_entry(dir, rel, validate)
|
|
267
|
+
file = File.join(dir, rel)
|
|
268
|
+
seen_by(file, :ruby)
|
|
269
|
+
{ "path" => rel, "faces" => Reference.normalize(Engine.ruby_call(:audit, file, validate: validate)) }
|
|
270
|
+
rescue StandardError => e
|
|
271
|
+
{ "path" => rel, "error" => { "class" => e.class.name, "message" => e.message } }
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def seen_by(file, tier)
|
|
275
|
+
recorder = Thread.current[Engine::ROW_RECORDER]
|
|
276
|
+
recorder[file] = tier if recorder
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def reference_path(path)
|
|
280
|
+
Reference.__send__(:path_arg, path)
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def ruby(operation, ...)
|
|
284
|
+
Engine.ruby_call(operation, ...)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# The rows of a successful read/audit reply, or the Ruby exception the
|
|
288
|
+
# engine reports for a failed one.
|
|
289
|
+
def rows(reply)
|
|
290
|
+
case reply.status
|
|
291
|
+
when Client::OK then decode(reply.payload)
|
|
292
|
+
when Client::READ_FAILED then raise_reported(JSON.parse(reply.message))
|
|
293
|
+
else raise Fontisan::Error, "fontisan ffi status #{reply.status}: #{reply.message}"
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# Packed rows: u32 BE count, then per row u32 BE length + UTF-8 JSON.
|
|
298
|
+
def decode(payload)
|
|
299
|
+
count, = payload.unpack("N")
|
|
300
|
+
offset = 4
|
|
301
|
+
Array.new(count) do
|
|
302
|
+
length = payload.byteslice(offset, 4).unpack1("N")
|
|
303
|
+
row = JSON.parse(payload.byteslice(offset + 4, length))
|
|
304
|
+
offset += 4 + length
|
|
305
|
+
row
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Raise the reported error as its Ruby class, message and cause chain.
|
|
310
|
+
def raise_reported(report)
|
|
311
|
+
raise_chain([report.slice("class", "message"),
|
|
312
|
+
*Array(report["causes"])])
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def raise_chain(infos)
|
|
316
|
+
info, *rest = infos
|
|
317
|
+
return raise(build_error(info)) if rest.empty?
|
|
318
|
+
|
|
319
|
+
begin
|
|
320
|
+
raise_chain(rest)
|
|
321
|
+
rescue StandardError => e
|
|
322
|
+
raise build_error(info), cause: e
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def build_error(info)
|
|
327
|
+
klass = begin
|
|
328
|
+
Object.const_get(info["class"])
|
|
329
|
+
rescue NameError
|
|
330
|
+
nil
|
|
331
|
+
end
|
|
332
|
+
return Fontisan::Error.new("#{info['class']}: #{info['message']}") unless klass.is_a?(Class) && klass <= StandardError
|
|
333
|
+
|
|
334
|
+
error = klass.new(info["message"])
|
|
335
|
+
return error if error.message == info["message"]
|
|
336
|
+
|
|
337
|
+
# Errno classes prefix their text; the report already carries it whole.
|
|
338
|
+
error.define_singleton_method(:message) { info["message"] }
|
|
339
|
+
error
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
# Names the per-platform directory under lib/fontisan/ffi/ that holds a
|
|
5
|
+
# cdylib in the `java`-platform gem. That gem is one artefact for every OS,
|
|
6
|
+
# so the library cannot sit at one flat path the way it does in a platform
|
|
7
|
+
# gem (two macOS architectures would collide). Shared by {Fontisan::Ffi}
|
|
8
|
+
# and rakelib/java.rake.
|
|
9
|
+
module FfiPlatform
|
|
10
|
+
DIRS = %w[linux-x86_64 darwin-arm64 darwin-x86_64 windows-x86_64].freeze
|
|
11
|
+
|
|
12
|
+
# @return [String, nil] one of {DIRS}, nil for an unbundled platform
|
|
13
|
+
def self.dir(host_os = RbConfig::CONFIG["host_os"], host_cpu = RbConfig::CONFIG["host_cpu"])
|
|
14
|
+
os = case host_os
|
|
15
|
+
when /darwin/ then "darwin"
|
|
16
|
+
when /mswin|mingw|cygwin|windows/ then "windows"
|
|
17
|
+
when /linux/ then "linux"
|
|
18
|
+
end
|
|
19
|
+
cpu = host_cpu.to_s.match?(/arm64|aarch64/) ? "arm64" : "x86_64"
|
|
20
|
+
candidate = "#{os}-#{cpu}"
|
|
21
|
+
DIRS.include?(candidate) ? candidate : nil
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
data/lib/fontisan/font_loader.rb
CHANGED
|
@@ -31,6 +31,10 @@ module Fontisan
|
|
|
31
31
|
# font = FontLoader.load("font.ttf", lazy: true) # Tables loaded on-demand
|
|
32
32
|
# font = FontLoader.load("font.ttf", lazy: false) # All tables loaded upfront
|
|
33
33
|
class FontLoader
|
|
34
|
+
# The one text every tier raises for a file that is no font at all.
|
|
35
|
+
UNKNOWN_FORMAT_MESSAGE =
|
|
36
|
+
"Unknown font format. Expected TTF, OTF, TTC, OTC, WOFF, WOFF2, PFB, or PFA file."
|
|
37
|
+
|
|
34
38
|
# Number of bytes read from the start of a file to identify its format.
|
|
35
39
|
# 100 bytes is enough to comfortably contain the Adobe Type 1 PFA header
|
|
36
40
|
# plus its leading whitespace, and far more than the 4 bytes needed for
|
|
@@ -91,7 +95,7 @@ module Fontisan
|
|
|
91
95
|
when :pfa, :pfb then Type1Font.from_file(path, mode: resolved_mode)
|
|
92
96
|
else
|
|
93
97
|
raise InvalidFontError,
|
|
94
|
-
|
|
98
|
+
UNKNOWN_FORMAT_MESSAGE
|
|
95
99
|
end
|
|
96
100
|
end
|
|
97
101
|
|
data/lib/fontisan/font_writer.rb
CHANGED
|
@@ -162,7 +162,7 @@ module Fontisan
|
|
|
162
162
|
|
|
163
163
|
tags.each do |tag|
|
|
164
164
|
data = @tables[tag]
|
|
165
|
-
checksum = calculate_table_checksum(data)
|
|
165
|
+
checksum = calculate_table_checksum(directory_checksum_source(tag, data))
|
|
166
166
|
|
|
167
167
|
# Calculate padding to 4-byte boundary
|
|
168
168
|
padding_length = Utilities::Padding.boundary(data.bytesize)
|
|
@@ -227,6 +227,18 @@ module Fontisan
|
|
|
227
227
|
].pack("a4 N N N")
|
|
228
228
|
end
|
|
229
229
|
|
|
230
|
+
# The bytes a table's directory checksum is computed over. The OpenType
|
|
231
|
+
# spec computes the head checksum with checkSumAdjustment (offset 8) as
|
|
232
|
+
# zero; using the source font's value made write -> reload -> write
|
|
233
|
+
# produce different bytes.
|
|
234
|
+
def directory_checksum_source(tag, data)
|
|
235
|
+
return data unless tag == "head" && data.bytesize >= 12
|
|
236
|
+
|
|
237
|
+
zeroed = data.b
|
|
238
|
+
zeroed[8, 4] = "\x00\x00\x00\x00".b
|
|
239
|
+
zeroed
|
|
240
|
+
end
|
|
241
|
+
|
|
230
242
|
# Calculate checksum for a table
|
|
231
243
|
#
|
|
232
244
|
# The checksum is calculated by summing all uint32 values in the table.
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Fontisan
|
|
6
|
+
# The `:native` engine tier: the fontisan-rs engine through a magnus
|
|
7
|
+
# extension (`ext/fontisan_native`, built by rb-sys).
|
|
8
|
+
#
|
|
9
|
+
# Tier contract (see {Fontisan::Engine}): `available?`,
|
|
10
|
+
# `supports?(operation, path)`, `supports_options?(operation, kwargs)` and
|
|
11
|
+
# `call(operation, *args, **kwargs)`.
|
|
12
|
+
#
|
|
13
|
+
# The extension returns the same Hash / Array / String shapes as
|
|
14
|
+
# {Fontisan::Reference}: it walks the engine's documents straight into Ruby
|
|
15
|
+
# objects (no JSON text on either side), with the GVL released around each
|
|
16
|
+
# engine call. A failure arrives as {Failure}; {.call} rebuilds the Ruby
|
|
17
|
+
# tier's exception class, message and cause chain from it.
|
|
18
|
+
module Native
|
|
19
|
+
EXTENSION = "fontisan/native/fontisan_native"
|
|
20
|
+
# Cross-compiled platform gems ship one build per Ruby minor, in a
|
|
21
|
+
# directory named for it (rake-compiler's fat-binary layout).
|
|
22
|
+
VERSIONED_EXTENSION = "fontisan/native/#{RUBY_VERSION[/\A\d+\.\d+/]}/fontisan_native".freeze
|
|
23
|
+
ABI_VERSION = 1
|
|
24
|
+
|
|
25
|
+
# The fontisan-rs commit both binaries must be built from: the `rev` of the
|
|
26
|
+
# engine pin in ext/*/Cargo.toml, which each build embeds. ABI_VERSION does
|
|
27
|
+
# not move on a repin, so this is what keeps a stale binary from serving old
|
|
28
|
+
# behaviour. A repin seds it with the Cargo files (repin-push162.sh).
|
|
29
|
+
ENGINE_REV = "daffb3f45b67517e924a3e5d0e113a2ecbde9959"
|
|
30
|
+
|
|
31
|
+
# Operations served natively. Convert, extract, instance and subset are
|
|
32
|
+
# path-based only (card 2f): the object-based entry points
|
|
33
|
+
# (`Subset::Builder.new(font, ...)`, `Variable::Instancer.new(font)`) are
|
|
34
|
+
# not Reference operations, so they never reach a gate and stay on Ruby.
|
|
35
|
+
OPERATIONS = %i[read audit audit_dir scan convert extract instance subset].freeze
|
|
36
|
+
|
|
37
|
+
# Raised by the extension for an engine failure; `details` is
|
|
38
|
+
# `[class name, message, [[cause class, cause message], ...]]`.
|
|
39
|
+
class Failure < StandardError
|
|
40
|
+
attr_reader :details
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# A Rust panic, caught in the extension and surfaced as a Ruby error.
|
|
44
|
+
class Panic < Fontisan::Error; end
|
|
45
|
+
|
|
46
|
+
class << self
|
|
47
|
+
include ByteOps
|
|
48
|
+
|
|
49
|
+
# Probe: true only when the extension loads and reports the ABI this
|
|
50
|
+
# gem expects. Any load failure (missing library, missing symbol, ABI
|
|
51
|
+
# mismatch, a broken loader) is a plain false, never an exception.
|
|
52
|
+
def available?
|
|
53
|
+
return @available if defined?(@available)
|
|
54
|
+
|
|
55
|
+
@available = probe.tap { |ok| warn_unusable unless ok }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Forget the memoised probe (tests, or after installing the extension).
|
|
59
|
+
def reset!
|
|
60
|
+
remove_instance_variable(:@available) if defined?(@available)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# @return [Boolean] whether the extension serves +operation+ for +path+.
|
|
64
|
+
# dfont and Type 1 files, missing files and non-directories (for
|
|
65
|
+
# audit_dir) stay with the Ruby tier.
|
|
66
|
+
def supports?(operation, path = nil)
|
|
67
|
+
return false unless available? && OPERATIONS.include?(operation)
|
|
68
|
+
return false unless path.is_a?(String)
|
|
69
|
+
|
|
70
|
+
case operation
|
|
71
|
+
when :audit_dir then File.directory?(path)
|
|
72
|
+
# A FIFO, device or socket is never handed to the extension: it would
|
|
73
|
+
# block in the engine's read with the GVL released, where no signal or
|
|
74
|
+
# Timeout can interrupt it. Ruby's read is interruptible.
|
|
75
|
+
else File.file?(path) && native_supports_file(path)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Options the extension honours; anything else is served by Ruby.
|
|
80
|
+
# +path+ is the call's input path (the Engine passes it when this method
|
|
81
|
+
# takes a third argument): collection inputs and the `.otc` hint decide
|
|
82
|
+
# some of the byte ops.
|
|
83
|
+
def supports_options?(operation, options, path = nil)
|
|
84
|
+
case operation
|
|
85
|
+
when :read then read_options?(options)
|
|
86
|
+
when *ByteOps::BYTE_OPERATIONS then byte_options?(operation, options, path)
|
|
87
|
+
when :audit, :audit_dir then options.keys.all?(:validate) && [true, false, nil].include?(options[:validate])
|
|
88
|
+
when :scan then options.empty?
|
|
89
|
+
else false
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def call(operation, *, **)
|
|
94
|
+
case operation
|
|
95
|
+
when :read then read(*, **)
|
|
96
|
+
when :audit then audit(*, **)
|
|
97
|
+
when :audit_dir then audit_dir(*, **)
|
|
98
|
+
when :scan then scan(*)
|
|
99
|
+
when :convert then convert(*, **)
|
|
100
|
+
when :extract then extract(*, **)
|
|
101
|
+
when :instance then instance(*, **)
|
|
102
|
+
when :subset then subset(*, **)
|
|
103
|
+
else raise Fontisan::Error, "native tier cannot serve #{operation}"
|
|
104
|
+
end
|
|
105
|
+
rescue Failure => e
|
|
106
|
+
raise_failure(e.details, byte_operation: ByteOps::BYTE_OPERATIONS.include?(operation))
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
def probe
|
|
112
|
+
load_extension
|
|
113
|
+
abi_version == ABI_VERSION && engine_rev == ENGINE_REV
|
|
114
|
+
rescue StandardError, ScriptError
|
|
115
|
+
false
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# An extension file that is shipped but does not load or report the
|
|
119
|
+
# expected ABI leaves every call on the Ruby tier. Say so once; the
|
|
120
|
+
# fallback itself is unchanged. No file at all (a source install without
|
|
121
|
+
# cargo) is the normal :ruby gem and stays quiet.
|
|
122
|
+
def warn_unusable
|
|
123
|
+
return if @warned_unusable || !extension_present?
|
|
124
|
+
|
|
125
|
+
@warned_unusable = true
|
|
126
|
+
warn "fontisan: the native extension is present but did not load (or has the wrong ABI " \
|
|
127
|
+
"or was built from another engine rev); " \
|
|
128
|
+
"falling back to the Ruby tier. Reinstall the gem or run `bundle exec rake compile`."
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def extension_present?
|
|
132
|
+
[VERSIONED_EXTENSION, EXTENSION].any? { |feature| $LOAD_PATH.resolve_feature_path(feature) }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def load_extension
|
|
136
|
+
require VERSIONED_EXTENSION
|
|
137
|
+
rescue LoadError
|
|
138
|
+
require EXTENSION
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def abi_version
|
|
142
|
+
respond_to?(:extension_abi_version) ? extension_abi_version : nil
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def engine_rev
|
|
146
|
+
respond_to?(:extension_engine_rev) ? extension_engine_rev : nil
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def read_options?(options)
|
|
150
|
+
return false unless options.keys.all? { |key| %i[mode lazy].include?(key) }
|
|
151
|
+
|
|
152
|
+
%w[metadata full].include?(options.fetch(:mode, :metadata).to_s) &&
|
|
153
|
+
[true, false].include?(options.fetch(:lazy, true))
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Writes the converted font to +output+ and returns the ConvertCommand
|
|
157
|
+
# result hash, rebuilt from the engine's bytes and the input header.
|
|
158
|
+
def convert(path, output:, timestamp: nil, **options)
|
|
159
|
+
path = File.path(path)
|
|
160
|
+
target = convert_target(options.fetch(:to))
|
|
161
|
+
bytes = native_convert(path, ByteOps::TARGET_CODES.fetch(target), timestamp ? unix_seconds(timestamp) : Time.now.to_i)
|
|
162
|
+
finish_convert(path, target, File.path(output), bytes)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Same file layout as UnpackCommand: <output_dir>/<basename>.
|
|
166
|
+
def extract(path, index:, output_dir:)
|
|
167
|
+
name, bytes = native_extract(File.path(path), index)
|
|
168
|
+
FileUtils.mkdir_p(output_dir)
|
|
169
|
+
File.join(output_dir, name).tap { |out| File.binwrite(out, bytes) }
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def instance(path, coords:, timestamp: nil)
|
|
173
|
+
_ = timestamp
|
|
174
|
+
values = coords.to_h { |tag, value| [tag.to_s, Float(value)] }
|
|
175
|
+
native_instance(File.path(path), values.to_a)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def subset(path, unicodes: nil, text: nil, profile: "web", timestamp: nil)
|
|
179
|
+
_ = timestamp
|
|
180
|
+
path = File.path(path)
|
|
181
|
+
codepoints = Array(unicodes).map(&:to_i) + text.to_s.codepoints
|
|
182
|
+
gids = native_glyph_ids(path, codepoints, File.basename(path))
|
|
183
|
+
native_subset(path, gids, profile.to_s)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def read(path, mode: :metadata, lazy: true)
|
|
187
|
+
native_read(File.path(path), mode.to_s == "metadata", lazy)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def audit(path, validate: false)
|
|
191
|
+
native_audit(File.path(path), validate ? true : false)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def scan(paths)
|
|
195
|
+
Array(paths).flat_map { |path| native_scan(File.path(path)) }
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Same entries as Reference.audit_dir. A file the engine leaves to Ruby
|
|
199
|
+
# (dfont, Type 1) is audited through the gate, so it is served and
|
|
200
|
+
# recorded as :ruby.
|
|
201
|
+
def audit_dir(dir, validate: false)
|
|
202
|
+
dir = File.path(dir)
|
|
203
|
+
native_audit_dir_rows(dir, validate ? true : false).filter_map do |row|
|
|
204
|
+
rel = Fontisan::Reference.relative_row_path(dir, row["path"])
|
|
205
|
+
next if rel.split("/").any? { |part| part.start_with?(".") }
|
|
206
|
+
|
|
207
|
+
dir_entry(dir, row, rel, validate)
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def dir_entry(dir, row, rel, validate)
|
|
212
|
+
case row["status"]
|
|
213
|
+
when "ok" then { "faces" => row["reports"], "path" => rel }
|
|
214
|
+
# The gate records the path it is given and Engine.audit_dir looks it up
|
|
215
|
+
# as File.join(dir, rel); the row's own path has a native separator on
|
|
216
|
+
# Windows, so it would never match.
|
|
217
|
+
when "unsupported" then audit_with_ruby(File.join(dir, rel), rel, validate)
|
|
218
|
+
else
|
|
219
|
+
error = row["error"]
|
|
220
|
+
{ "error" => { "class" => error["class"], "message" => error["message"] }, "path" => rel }
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def audit_with_ruby(file, rel, validate)
|
|
225
|
+
{ "faces" => Fontisan::Reference.audit(file, validate: validate), "path" => rel }
|
|
226
|
+
rescue StandardError => e
|
|
227
|
+
{ "error" => { "class" => e.class.name, "message" => e.message }, "path" => rel }
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|