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.
Files changed (111) 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/pipeline/transformation_pipeline.rb +10 -8
  55. data/lib/fontisan/pipeline.rb +1 -1
  56. data/lib/fontisan/reference/table_digests.rb +251 -0
  57. data/lib/fontisan/reference.rb +619 -0
  58. data/lib/fontisan/scan.rb +372 -0
  59. data/lib/fontisan/sfnt_directory.rb +41 -0
  60. data/lib/fontisan/sfnt_font.rb +16 -6
  61. data/lib/fontisan/subset/builder.rb +2 -1
  62. data/lib/fontisan/tables/cff/charstring.rb +16 -0
  63. data/lib/fontisan/tables/cff.rb +15 -5
  64. data/lib/fontisan/tables/cmap.rb +11 -1
  65. data/lib/fontisan/tables/glyf.rb +4 -6
  66. data/lib/fontisan/tables/gpos.rb +7 -0
  67. data/lib/fontisan/tables/gsub.rb +7 -0
  68. data/lib/fontisan/tables/layout_common.rb +172 -0
  69. data/lib/fontisan/tables/name.rb +10 -4
  70. data/lib/fontisan/utilities/brotli_wrapper.rb +39 -4
  71. data/lib/fontisan/validators/opentype_validator.rb +15 -3
  72. data/lib/fontisan/variable/instancer.rb +23 -192
  73. data/lib/fontisan/variable/static_font_builder.rb +4 -1
  74. data/lib/fontisan/variable/table_updater.rb +4 -2
  75. data/lib/fontisan/variation/instance_generator.rb +3 -149
  76. data/lib/fontisan/variation/instancing/axis_space.rb +136 -0
  77. data/lib/fontisan/variation/instancing/glyph_outline.rb +204 -0
  78. data/lib/fontisan/variation/instancing/gvar_reader.rb +146 -0
  79. data/lib/fontisan/variation/instancing/item_variation_store.rb +92 -0
  80. data/lib/fontisan/variation/instancing/name_rewriter.rb +90 -0
  81. data/lib/fontisan/variation/instancing/true_type_instancer.rb +326 -0
  82. data/lib/fontisan/variation/instancing.rb +20 -0
  83. data/lib/fontisan/variation/variable_svg_generator.rb +19 -1
  84. data/lib/fontisan/variation.rb +1 -0
  85. data/lib/fontisan/version.rb +1 -1
  86. data/lib/fontisan/woff2/collection_decoder.rb +15 -1
  87. data/lib/fontisan/woff2/collection_encoder.rb +29 -6
  88. data/lib/fontisan/woff2/encoder_rules.rb +12 -4
  89. data/lib/fontisan/woff2/glyf_loca_reconstruct.rb +1 -1
  90. data/lib/fontisan/woff2_collection.rb +7 -5
  91. data/lib/fontisan/woff2_font.rb +12 -3
  92. data/lib/fontisan/woff_container.rb +83 -0
  93. data/lib/fontisan/woff_font.rb +24 -20
  94. data/lib/fontisan.rb +24 -1
  95. data/rakelib/bench.rake +9 -0
  96. data/rakelib/cdylib.rake +39 -0
  97. data/rakelib/corpus.rake +272 -0
  98. data/rakelib/corpus_convert.rake +43 -0
  99. data/rakelib/corpus_goldens.rake +22 -0
  100. data/rakelib/corpus_malformed.rake +20 -0
  101. data/rakelib/corpus_scan.rake +24 -0
  102. data/rakelib/java.rake +61 -0
  103. data/rakelib/native.rake +43 -0
  104. data/scripts/build-woff2.sh +57 -0
  105. data/scripts/corpus/generate_convert_goldens.rb +478 -0
  106. data/scripts/corpus/generate_goldens.rb +282 -0
  107. data/scripts/corpus/generate_malformed_goldens.rb +254 -0
  108. data/scripts/corpus/generate_scan_goldens.rb +98 -0
  109. data/scripts/tier_fallback.rb +219 -0
  110. data/scripts/tier_smoke.rb +64 -0
  111. metadata +75 -3
@@ -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
@@ -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
- "Unknown font format. Expected TTF, OTF, TTC, OTC, WOFF, WOFF2, PFB, or PFA file."
98
+ UNKNOWN_FORMAT_MESSAGE
95
99
  end
96
100
  end
97
101
 
@@ -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
@@ -216,7 +216,7 @@ module Fontisan
216
216
  return tables if source_format == target
217
217
 
218
218
  # Use FormatConverter for outline conversion
219
- if needs_outline_conversion?(source_format, target) || target == :svg
219
+ if needs_outline_conversion?(tables, target) || target == :svg
220
220
  converter = Converters::FormatConverter.new
221
221
  # Create temporary font object from tables
222
222
  font = build_font_from_tables(tables, source_format)
@@ -229,16 +229,18 @@ module Fontisan
229
229
 
230
230
  # Check if outline conversion is needed
231
231
  #
232
- # @param source [Symbol] Source format
232
+ # TTF <-> OTF changes the outline format. WOFF/WOFF2 targets only
233
+ # repackage whichever outlines the font already has.
234
+ #
235
+ # @param tables [Hash] Font tables (their outlines decide the source)
233
236
  # @param target [Symbol] Target format
234
237
  # @return [Boolean] True if outline conversion needed
235
- def needs_outline_conversion?(source, target)
236
- # TTF ↔ OTF requires outline conversion
237
- ttf_formats = %i[ttf ttc woff woff2]
238
- otf_formats = %i[otf otc]
238
+ def needs_outline_conversion?(tables, target)
239
+ target_outline = { ttf: :glyf, ttc: :glyf, otf: :cff, otc: :cff }[target]
240
+ return false unless target_outline
239
241
 
240
- (ttf_formats.include?(source) && otf_formats.include?(target)) ||
241
- (otf_formats.include?(source) && ttf_formats.include?(target))
242
+ source_outline = tables.key?("glyf") ? :glyf : :cff
243
+ source_outline != target_outline
242
244
  end
243
245
 
244
246
  # Write output font file