fontisan 0.4.47 → 0.4.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. checksums.yaml +4 -4
  2. data/BENCHMARKS.md +56 -0
  3. data/CHANGELOG.md +12 -0
  4. data/Cargo.lock +550 -0
  5. data/Cargo.toml +12 -0
  6. data/Gemfile +10 -0
  7. data/README.adoc +52 -0
  8. data/Rakefile +5 -1
  9. data/benchmark/baseline.json +5 -0
  10. data/benchmark/ci_gate.rb +217 -0
  11. data/benchmark/corpus/harness.rb +175 -0
  12. data/benchmark/corpus/ops.rb +150 -0
  13. data/benchmark/corpus/run.rb +182 -0
  14. data/benchmark/corpus/scan_child.rb +8 -0
  15. data/benchmark/corpus/tiers.rb +176 -0
  16. data/benchmark/tier_baseline.json +12 -0
  17. data/docs/.vitepress/config.ts +2 -1
  18. data/docs/guide/engines.md +117 -0
  19. data/docs/reference-facade.md +130 -0
  20. data/ext/fontisan_cdylib/Cargo.lock +313 -0
  21. data/ext/fontisan_cdylib/Cargo.toml +18 -0
  22. data/ext/fontisan_cdylib/build.rs +22 -0
  23. data/ext/fontisan_cdylib/extconf.rb +36 -0
  24. data/ext/fontisan_cdylib/src/lib.rs +13 -0
  25. data/ext/fontisan_native/Cargo.toml +21 -0
  26. data/ext/fontisan_native/build.rs +22 -0
  27. data/ext/fontisan_native/clippy.toml +10 -0
  28. data/ext/fontisan_native/extconf.rb +59 -0
  29. data/ext/fontisan_native/src/lib.rs +463 -0
  30. data/lib/fontisan/audit/checks/cmap_check.rb +7 -0
  31. data/lib/fontisan/audit/checks/hinting_check.rb +1 -1
  32. data/lib/fontisan/audit/checks/maxp_check.rb +1 -1
  33. data/lib/fontisan/audit/checks/opentype_conformance_check.rb +5 -2
  34. data/lib/fontisan/base_collection.rb +30 -0
  35. data/lib/fontisan/byte_ops.rb +219 -0
  36. data/lib/fontisan/cli.rb +5 -5
  37. data/lib/fontisan/commands/audit_command.rb +38 -43
  38. data/lib/fontisan/commands/convert_command.rb +53 -0
  39. data/lib/fontisan/commands/instance_command.rb +75 -0
  40. data/lib/fontisan/commands/subset_command.rb +43 -0
  41. data/lib/fontisan/commands/unpack_command.rb +56 -0
  42. data/lib/fontisan/converters/cff_table_builder.rb +1 -1
  43. data/lib/fontisan/converters/collection_converter.rb +13 -5
  44. data/lib/fontisan/converters/format_converter.rb +1 -1
  45. data/lib/fontisan/converters/woff2_encoder.rb +8 -6
  46. data/lib/fontisan/converters/woff_writer.rb +6 -5
  47. data/lib/fontisan/engine.rb +350 -0
  48. data/lib/fontisan/ffi/client.rb +121 -0
  49. data/lib/fontisan/ffi.rb +343 -0
  50. data/lib/fontisan/ffi_platform.rb +24 -0
  51. data/lib/fontisan/font_loader.rb +5 -1
  52. data/lib/fontisan/font_writer.rb +13 -1
  53. data/lib/fontisan/native.rb +231 -0
  54. data/lib/fontisan/reference/table_digests.rb +251 -0
  55. data/lib/fontisan/reference.rb +619 -0
  56. data/lib/fontisan/scan.rb +372 -0
  57. data/lib/fontisan/sfnt_directory.rb +41 -0
  58. data/lib/fontisan/sfnt_font.rb +16 -6
  59. data/lib/fontisan/subset/builder.rb +2 -1
  60. data/lib/fontisan/tables/cff/charstring.rb +16 -0
  61. data/lib/fontisan/tables/cff.rb +15 -5
  62. data/lib/fontisan/tables/cmap.rb +11 -1
  63. data/lib/fontisan/tables/gpos.rb +7 -0
  64. data/lib/fontisan/tables/gsub.rb +7 -0
  65. data/lib/fontisan/tables/layout_common.rb +172 -0
  66. data/lib/fontisan/tables/name.rb +10 -4
  67. data/lib/fontisan/utilities/brotli_wrapper.rb +39 -4
  68. data/lib/fontisan/validators/opentype_validator.rb +2 -0
  69. data/lib/fontisan/variable/instancer.rb +1 -0
  70. data/lib/fontisan/variable/static_font_builder.rb +4 -1
  71. data/lib/fontisan/variable/table_updater.rb +4 -2
  72. data/lib/fontisan/variation/instancing/true_type_instancer.rb +14 -6
  73. data/lib/fontisan/version.rb +1 -1
  74. data/lib/fontisan/woff2/collection_decoder.rb +15 -1
  75. data/lib/fontisan/woff2/collection_encoder.rb +29 -6
  76. data/lib/fontisan/woff2/encoder_rules.rb +12 -4
  77. data/lib/fontisan/woff2/glyf_loca_reconstruct.rb +1 -1
  78. data/lib/fontisan/woff2_collection.rb +7 -5
  79. data/lib/fontisan/woff2_font.rb +12 -3
  80. data/lib/fontisan/woff_container.rb +83 -0
  81. data/lib/fontisan/woff_font.rb +24 -20
  82. data/lib/fontisan.rb +24 -1
  83. data/rakelib/bench.rake +9 -0
  84. data/rakelib/cdylib.rake +39 -0
  85. data/rakelib/corpus.rake +272 -0
  86. data/rakelib/corpus_convert.rake +43 -0
  87. data/rakelib/corpus_goldens.rake +22 -0
  88. data/rakelib/corpus_malformed.rake +20 -0
  89. data/rakelib/corpus_scan.rake +24 -0
  90. data/rakelib/java.rake +61 -0
  91. data/rakelib/native.rake +43 -0
  92. data/scripts/build-woff2.sh +57 -0
  93. data/scripts/corpus/generate_convert_goldens.rb +478 -0
  94. data/scripts/corpus/generate_goldens.rb +282 -0
  95. data/scripts/corpus/generate_malformed_goldens.rb +254 -0
  96. data/scripts/corpus/generate_scan_goldens.rb +98 -0
  97. data/scripts/tier_fallback.rb +219 -0
  98. data/scripts/tier_smoke.rb +64 -0
  99. metadata +68 -3
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Runs every benchmark op once-per-process-style and prints JSON:
4
+ # {"ops": {"name": seconds_per_call | null}, "scan_peak_rss_kb": n | null, ...}
5
+ #
6
+ # Usage: ruby -I<fontisan>/lib benchmark/corpus/run.rb --corpus DIR [--side head|base]
7
+ #
8
+ # The harness lives in the checkout that invokes it; the fontisan under test
9
+ # is whatever `require "fontisan"` resolves to (-I or BUNDLE_GEMFILE).
10
+ #
11
+ # FONTISAN_BENCH_SLOWDOWN=2 makes the `head` side run each op N times slower
12
+ # (it sleeps (N-1) x the op's own time), to prove the gate goes red.
13
+
14
+ require "json"
15
+ require "optparse"
16
+ require "rbconfig"
17
+ require "benchmark"
18
+ require_relative "ops"
19
+
20
+ module FontisanBench
21
+ # Times ops and measures the peak RSS of the directory scan.
22
+ class Runner
23
+ MIN_SECONDS = 0.25
24
+ MIN_REPS = 3
25
+ SLOW_SECONDS = 2.0
26
+ MAX_REPS = 200
27
+
28
+ def initialize(corpus:, side:, only: nil)
29
+ @ops = Ops.new(corpus)
30
+ @side = side
31
+ @only = only
32
+ @slowdown = slowdown_for(side)
33
+ end
34
+
35
+ def call
36
+ ops = {}
37
+ @ops.all.each do |op|
38
+ next if @only && !@only.include?(op.name)
39
+
40
+ ops[op.name] = runnable?(op) ? time(op) : nil
41
+ end
42
+ { "ops" => ops, "scan_peak_rss_kb" => scan_rss, "ruby" => RUBY_VERSION,
43
+ "fontisan_version" => (defined?(Fontisan::VERSION) ? Fontisan::VERSION : nil) }
44
+ end
45
+
46
+ private
47
+
48
+ # FONTISAN_BENCH_SLOWDOWN_TIER=native limits the forced slowdown to the
49
+ # run whose FONTISAN_ENGINE is that tier (the tiers gate self-test).
50
+ def slowdown_for(side)
51
+ return 1.0 unless side == "head"
52
+
53
+ tier = ENV.fetch("FONTISAN_BENCH_SLOWDOWN_TIER", "")
54
+ return 1.0 unless tier.empty? || tier == ENV.fetch("FONTISAN_ENGINE", "")
55
+
56
+ ENV.fetch("FONTISAN_BENCH_SLOWDOWN", "1").to_f
57
+ end
58
+
59
+ def runnable?(bench)
60
+ bench.requires.all? { |const| Object.const_defined?(const) }
61
+ end
62
+
63
+ # Fastest call in seconds after one untimed warm-up call. Calls repeat
64
+ # until MIN_SECONDS of measured time and at least MIN_REPS calls (unless
65
+ # the op is already slow), and the minimum is reported: it is the figure
66
+ # least disturbed by other load on a shared runner.
67
+ def time(bench)
68
+ bench.body.call
69
+ times = []
70
+ while times.size < MAX_REPS && keep_timing?(times)
71
+ one = Benchmark.realtime { bench.body.call }
72
+ # forced slowdown: sleep (N-1) x the call's own time, counted as run time
73
+ times << one + (@slowdown > 1 ? slow_sleep(one) : 0)
74
+ end
75
+ times.min
76
+ end
77
+
78
+ def keep_timing?(times)
79
+ total = times.sum
80
+ times.empty? || total < MIN_SECONDS || (times.size < MIN_REPS && total < SLOW_SECONDS)
81
+ end
82
+
83
+ def slow_sleep(one)
84
+ extra = one * (@slowdown - 1)
85
+ sleep(extra)
86
+ extra
87
+ end
88
+
89
+ def scan_rss
90
+ return nil unless Object.const_defined?("Fontisan::Reference")
91
+ return nil if @only && !@only.include?("scan_dir")
92
+
93
+ lib = File.dirname($LOADED_FEATURES.grep(%r{/fontisan\.rb\z}).first.to_s)
94
+ Rss.peak_kb(
95
+ [RbConfig.ruby, "-I", lib, File.join(__dir__, "scan_child.rb"), @ops.corpus],
96
+ env: ENV.to_h,
97
+ )
98
+ end
99
+ end
100
+
101
+ # Peak resident set size of a child process: time(1) on macOS and Linux,
102
+ # a PowerShell sampler of PeakWorkingSet64 on Windows. nil when the
103
+ # platform cannot measure it; the tiers gate then skips only the RSS check.
104
+ module Rss
105
+ WINDOWS = /mswin|mingw|cygwin/
106
+ # Polls the child's peak working set until it exits (the value is gone
107
+ # once the process object is released, so it is sampled, never read late).
108
+ POWERSHELL = <<~PS
109
+ $psi = New-Object System.Diagnostics.ProcessStartInfo
110
+ $psi.FileName = $env:FONTISAN_RSS_EXE
111
+ $psi.Arguments = $env:FONTISAN_RSS_ARGS
112
+ $psi.UseShellExecute = $false
113
+ $p = [System.Diagnostics.Process]::Start($psi)
114
+ $peak = 0
115
+ while (-not $p.HasExited) {
116
+ try { $p.Refresh(); if ($p.PeakWorkingSet64 -gt $peak) { $peak = $p.PeakWorkingSet64 } } catch {}
117
+ Start-Sleep -Milliseconds 5
118
+ }
119
+ $p.WaitForExit()
120
+ if ($p.ExitCode -ne 0) { exit 1 }
121
+ Write-Output ("PEAK_BYTES=" + $peak)
122
+ PS
123
+
124
+ module_function
125
+
126
+ def supported?
127
+ windows? || (File.executable?("/usr/bin/time") && !RbConfig::CONFIG["host_os"].match?(WINDOWS))
128
+ end
129
+
130
+ def windows?
131
+ RbConfig::CONFIG["host_os"].match?(WINDOWS)
132
+ end
133
+
134
+ def peak_kb(cmd, env: {})
135
+ require "open3"
136
+ return windows_peak_kb(cmd, env) if windows?
137
+
138
+ flag = RbConfig::CONFIG["host_os"].include?("darwin") ? "-l" : "-v"
139
+ return nil unless File.executable?("/usr/bin/time")
140
+
141
+ _out, err, status = Open3.capture3(env, "/usr/bin/time", flag, *cmd)
142
+ return nil unless status.success?
143
+
144
+ parse(err, flag)
145
+ end
146
+
147
+ def windows_peak_kb(cmd, env)
148
+ args = cmd.drop(1).map { |a| %("#{a}") }.join(" ")
149
+ out, _err, status = Open3.capture3(env.merge("FONTISAN_RSS_EXE" => cmd.first, "FONTISAN_RSS_ARGS" => args),
150
+ "powershell", "-NoProfile", "-NonInteractive", "-Command", POWERSHELL)
151
+ return nil unless status.success?
152
+
153
+ m = out.match(/PEAK_BYTES=(\d+)/)
154
+ m && m[1].to_i.positive? ? m[1].to_i / 1024 : nil
155
+ rescue SystemCallError
156
+ nil
157
+ end
158
+
159
+ def parse(text, flag)
160
+ if flag == "-l" # macOS: bytes
161
+ m = text.match(/^\s*(\d+)\s+maximum resident set size/)
162
+ m && (m[1].to_i / 1024)
163
+ else # Linux: kbytes
164
+ m = text.match(/Maximum resident set size \(kbytes\): (\d+)/)
165
+ m && m[1].to_i
166
+ end
167
+ end
168
+ end
169
+ end
170
+
171
+ if $PROGRAM_NAME == __FILE__
172
+ opts = { side: "head", corpus: nil, only: nil }
173
+ OptionParser.new do |o|
174
+ o.on("--corpus DIR") { |v| opts[:corpus] = v }
175
+ o.on("--side SIDE") { |v| opts[:side] = v }
176
+ o.on("--only LIST") { |v| opts[:only] = v.split(",") }
177
+ end.parse!
178
+ abort "--corpus DIR is required" unless opts[:corpus]
179
+
180
+ require "fontisan"
181
+ puts JSON.generate(FontisanBench::Runner.new(**opts).call)
182
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Child process for the scan peak-RSS measurement: scans every file of the
4
+ # corpus fonts directory once. Usage: ruby -I<lib> scan_child.rb CORPUS_DIR
5
+ require "fontisan"
6
+
7
+ paths = Dir.glob(File.join(ARGV.fetch(0), "fonts", "**", "*")).select { |f| File.file?(f) }.sort
8
+ Fontisan::Reference.scan(paths)
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "harness"
4
+
5
+ module FontisanBench
6
+ # The ruby, native and ffi tiers measured in one job: the same checkout,
7
+ # one engine per run (FONTISAN_ENGINE, strict), interleaved rounds.
8
+ module Tiers
9
+ TIERS = %w[ruby native ffi].freeze
10
+ # Ops served by the engine tiers (they go through Fontisan::Reference).
11
+ OPS = %w[audit_file audit_dir scan_dir].freeze
12
+ # The hard floor (#160): native must be faster than Ruby on these. The ffi
13
+ # tier is held to the same floor when it ran (it is optional: no cdylib, no
14
+ # ffi row). Both are floors only; the baseline regression gate is native-only
15
+ # because no ffi baselines exist.
16
+ GATED_OPS = %w[audit_file audit_dir scan_dir].freeze
17
+ # Native peak RSS may exceed ruby's by this much before the gate fails.
18
+ # #160 says "must not exceed the Ruby baseline's"; the scan's peak RSS is
19
+ # sampled from a child process (page granularity, allocator noise), and
20
+ # healthy native/ruby ratios measured 0.84x-0.99x, so a literal 1.00x
21
+ # decides on noise (0.3 MB of 58 MB flipped a verdict). 3% is about 1.7 MB:
22
+ # above the noise, far below a real regression (a 1 MB/call leak read 1.35x).
23
+ RSS_TOLERANCE = 0.03
24
+ # Committed expected native factors per platform (see benchmark/tier_baseline.json).
25
+ BASELINE_FILE = File.expand_path("../tier_baseline.json", __dir__)
26
+
27
+ # One tier of the working tree. Runs as the `head` side of run.rb.
28
+ class TierSide < Side
29
+ attr_reader :tier
30
+
31
+ def initialize(tier, dir)
32
+ super(tier, dir, ref: tier)
33
+ @tier = tier
34
+ end
35
+
36
+ def env
37
+ super.merge("FONTISAN_ENGINE" => tier, "FONTISAN_ENGINE_STRICT" => "1")
38
+ end
39
+
40
+ def run_once(harness_dir, corpus, only: nil)
41
+ cmd = ["bundle", "exec", RbConfig.ruby, File.join(harness_dir, "run.rb"),
42
+ "--corpus", corpus, "--side", "head"]
43
+ cmd += ["--only", only.join(",")] if only
44
+ out, err, st = Open3.capture3(env, *cmd, chdir: dir)
45
+ raise Error, "tier #{tier} bench run failed:\n#{err}" unless st.success?
46
+
47
+ JSON.parse(out.lines.reverse_each.find { |l| l.start_with?("{") })
48
+ end
49
+ end
50
+
51
+ Row = Struct.new(:metric, :times, :factors, :status, :note, keyword_init: true)
52
+
53
+ module_function
54
+
55
+ # The expected native factors of +platform+, or nil when none are committed.
56
+ def baseline(platform, file: BASELINE_FILE)
57
+ data = JSON.parse(File.read(file))
58
+ entry = data.dig("platforms", platform)
59
+ return nil unless entry
60
+
61
+ { "tolerance" => data.fetch("tolerance"), "factors" => entry }
62
+ end
63
+
64
+ # Tiers that run at all. ruby and native are required; ffi (needs the
65
+ # cdylib) is reported when it can run and "unavailable" when it cannot.
66
+ def sides(dir)
67
+ TIERS.map { |t| TierSide.new(t, dir) }
68
+ end
69
+
70
+ def measure(sides, rounds:, harness_dir:, corpus:, only: OPS, into: nil, log: $stderr)
71
+ m = into || Measurement.new
72
+ rounds.times do |i|
73
+ (i.even? ? sides : sides.reverse).each do |side|
74
+ log.puts "round #{i + 1}/#{rounds}: #{side.name}"
75
+ record(m, side, harness_dir, corpus, only, log)
76
+ end
77
+ end
78
+ m
79
+ end
80
+
81
+ def record(measurement, side, harness_dir, corpus, only, log)
82
+ measurement.record(side.name, side.run_once(harness_dir, corpus, only: only))
83
+ rescue Error => e
84
+ raise if side.tier != "ffi"
85
+
86
+ log.puts "ffi tier unavailable: #{e.message.lines.first(3).join.strip}"
87
+ end
88
+
89
+ # Rows: per op the median per tier and the factor ruby / tier (above 1.0
90
+ # is faster than Ruby), then the scan peak RSS per tier.
91
+ def rows(measure, baseline: nil)
92
+ ops = measure.op_names.map { |op| op_row(op, TIERS.to_h { |t| [t, measure.median_op(t, op)] }, baseline) }
93
+ ops << rss_row(TIERS.to_h { |t| [t, measure.median_rss(t)] })
94
+ end
95
+
96
+ def op_row(name, times, baseline = nil)
97
+ ruby = times["ruby"]
98
+ factors = times.transform_values { |v| ruby && v ? ruby / v : nil }
99
+ status = "ok"
100
+ note = nil
101
+ if GATED_OPS.include?(name)
102
+ status, note = floor(times, factors)
103
+ status, note = against_baseline(name, factors, baseline) if status == "ok"
104
+ end
105
+ Row.new(metric: name, times: times, factors: factors, status: status, note: note)
106
+ end
107
+
108
+ def floor(times, factors)
109
+ return ["FAIL", "no ruby or native measurement"] unless times["ruby"] && times["native"]
110
+ return ["FAIL", "native is slower than ruby"] if factors["native"] < 1.0
111
+ return ["FAIL", "ffi is slower than ruby"] if factors["ffi"] && factors["ffi"] < 1.0
112
+
113
+ %w[ok gated]
114
+ end
115
+
116
+ # Regression gate: native may lose at most the tolerance against the
117
+ # platform's committed factor. Both tiers run in the same job, so the
118
+ # factor cancels the runner's speed. Without a baseline only the floor holds.
119
+ def against_baseline(name, factors, baseline)
120
+ return ["ok", "floor only, no baseline for this platform"] unless baseline
121
+
122
+ expected = baseline["factors"][name]
123
+ return ["ok", "gated"] unless expected
124
+
125
+ least = expected * (1 - baseline["tolerance"])
126
+ return ["ok", "gated, at least #{format('%.2fx', least)}"] if factors["native"] >= least
127
+
128
+ ["FAIL", format("native factor %<got>.2fx is more than %<pct>d%% below the expected %<want>.2fx",
129
+ got: factors["native"], pct: (baseline["tolerance"] * 100).round, want: expected)]
130
+ end
131
+
132
+ # Both engine tiers share the RSS rule. ffi's cdylib is loaded into the Ruby
133
+ # process, so its peak RSS is directly comparable to ruby's (local 0.98x).
134
+ def rss_row(rss)
135
+ status = "ok"
136
+ note = nil
137
+ if rss["ruby"].nil? || rss["native"].nil?
138
+ note = "peak RSS unsupported on this platform; RSS check skipped"
139
+ status = "skipped"
140
+ elsif (over = %w[native ffi].find { |t| rss[t] && rss[t] > rss["ruby"] * (1 + RSS_TOLERANCE) })
141
+ status = "FAIL"
142
+ note = "#{over} peak RSS exceeds ruby's by more than #{(RSS_TOLERANCE * 100).round}%"
143
+ else
144
+ note = "gated"
145
+ end
146
+ Row.new(metric: "scan_peak_rss_kb", times: rss, factors: rss.transform_values { |v| v && rss["ruby"] ? v.to_f / rss["ruby"] : nil },
147
+ status: status, note: note)
148
+ end
149
+
150
+ def failed?(rows)
151
+ rows.any? { |r| r.status == "FAIL" }
152
+ end
153
+
154
+ def markdown(rows, title:, rounds:)
155
+ out = ["## #{title}", "",
156
+ "Median of #{rounds}+ interleaved rounds, one engine per run (`FONTISAN_ENGINE`, strict). " \
157
+ "Factor = ruby time / tier time (above 1.00x is faster than Ruby; RSS rows show tier / ruby). " \
158
+ "Hard floor: native and ffi factors on #{GATED_OPS.join(', ')} must be at least 1.00x and their peak RSS " \
159
+ "must not exceed ruby's by more than #{(RSS_TOLERANCE * 100).round}%. With a committed baseline for the " \
160
+ "platform, the native factor must also stay within its tolerance of the expected factor. " \
161
+ "Other columns are reported, not gated.", "",
162
+ "| metric | ruby | native | ffi | native factor | ffi factor | status |", "|---|---|---|---|---|---|---|"]
163
+ rows.each { |r| out << table_line(r) }
164
+ out << ""
165
+ out << (failed?(rows) ? "**RESULT: FAIL**" : "**RESULT: PASS**")
166
+ out.join("\n")
167
+ end
168
+
169
+ def table_line(row)
170
+ cells = TIERS.map { |t| Compare.fmt(row.metric, row.times[t]) }
171
+ f = %w[native ffi].map { |t| row.factors[t] ? format("%.2fx", row.factors[t]) : "-" }
172
+ status = [row.status, row.note].compact.join(": ")
173
+ "| #{row.metric} | #{cells.join(' | ')} | #{f.join(' | ')} | #{status} |"
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,12 @@
1
+ {
2
+ "schema": 1,
3
+ "tolerance": 0.25,
4
+ "note": "Expected native factor (ruby time / native time) per platform and op, from the perf-gate.yml tiers jobs (run 42233249352 on 2b648ed, BENCHMARKS.md). audit_dir re-measured in run 42789182973 on 91beb5b: the Ruby tier got about 26% faster at audit_dir (one budgeted layout parse, 8e55ebd) while native time stayed flat, so the ratio fell. The tiers gate fails when a measured factor is more than `tolerance` below it. Both tiers run in the same job, so the factor cancels runner speed. A platform without an entry is held to the 1.00x floor only: arm64-darwin has no CI measurement yet. Raise a value only deliberately, from a green CI run.",
5
+ "platforms": {
6
+ "x86_64-linux": { "audit_file": 43.14, "audit_dir": 19.93, "scan_dir": 13.58 },
7
+ "x86_64-linux-musl": { "audit_file": 21.07, "audit_dir": 14.96, "scan_dir": 10.76 },
8
+ "aarch64-linux": { "audit_file": 45.51, "audit_dir": 11.15, "scan_dir": 7.86 },
9
+ "x86_64-darwin": { "audit_file": 36.89, "audit_dir": 11.90, "scan_dir": 7.78 },
10
+ "x64-mingw-ucrt": { "audit_file": 29.71, "audit_dir": 17.69, "scan_dir": 10.57 }
11
+ }
12
+ }
@@ -245,6 +245,7 @@ export default defineConfig({
245
245
  text: "Advanced Topics",
246
246
  collapsed: true,
247
247
  items: [
248
+ { text: "Engines", link: "/guide/engines" },
248
249
  { text: "Universal Outline Model", link: "/guide/universal-outline" },
249
250
  ],
250
251
  },
@@ -360,7 +361,7 @@ export default defineConfig({
360
361
  ],
361
362
 
362
363
  footer: {
363
- message: 'Fontisan is a [Ribose](https://open.ribose.com/) project',
364
+ message: 'Fontisan is a [Ribose](https://www.ribose.com/) project',
364
365
  copyright: `Copyright &copy; 2026 Ribose Group Inc. All rights reserved.`,
365
366
  },
366
367
 
@@ -0,0 +1,117 @@
1
+ ---
2
+ title: Engines
3
+ ---
4
+
5
+ # Engines
6
+
7
+ The path-based operations of `Fontisan::Reference` (`read`, `outline_hashes`, `audit`, `audit_dir`, `scan`, `convert`, `extract`, `instance`, `glyph_ids`, `subset`) can run on one of three engine tiers. All tiers return the same results; they differ in speed.
8
+
9
+ | Tier | What it is |
10
+ |---|---|
11
+ | `ruby` | The pure Ruby implementation. Always available. |
12
+ | `native` | A compiled Rust extension (`Fontisan::Native`) loaded into the Ruby process. |
13
+ | `ffi` | A bundled Rust shared library (`Fontisan::Ffi`) called through the `ffi` gem, which is not a runtime dependency of fontisan. |
14
+
15
+ Reference timings on macOS arm64 (see `BENCHMARKS.md`, local run, not CI), native versus Ruby:
16
+
17
+ | Operation | Speedup |
18
+ |---|---|
19
+ | `audit` (one file) | 30x |
20
+ | `audit_dir` | 8.2x |
21
+ | `scan` (directory) | 3.9x |
22
+
23
+ Peak memory of a directory scan is about 4% lower on the native tier.
24
+
25
+ ## Selecting an engine
26
+
27
+ Set `FONTISAN_ENGINE` before the process starts. Unset or empty means `auto`:
28
+
29
+ | Value | Behaviour |
30
+ |---|---|
31
+ | `ruby` | Use the Ruby tier only. |
32
+ | `native` | Use the native tier. |
33
+ | `ffi` | Use the ffi tier. |
34
+ | `auto` | The default. Use the first tier that is available and supports the operation, in the order native, ffi, ruby. |
35
+
36
+ ```sh
37
+ FONTISAN_ENGINE=native FONTISAN_ENGINE_STRICT=1 fontisan convert MyFont.ttf --to woff2 --output out.woff2
38
+ ruby -e 'require "fontisan"; p Fontisan::Reference.audit("MyFont.ttf").keys'
39
+ ```
40
+
41
+ Any other value raises `Fontisan::Engine::ConfigError`.
42
+
43
+ ### Command line
44
+
45
+ The same variables apply to the CLI, but only for the commands that go through the engine: `convert`, `unpack`, `subset` and `instance`. Each routes only its plain cases: `convert` a single-target conversion (several targets, instance coordinates, conversion options, hinting, a collection target format, compression options or a font index run on Ruby); `subset` by `--text` or `--unicode` with the `pdf`, `web` or `minimal` profile; `instance` of a TrueType variable font by axis coordinates to the same format (`--to`, `--named-instance` and `--list-instances` run on Ruby). Every other command (`info`, `validate`, `export`, `pack`, and so on) runs on Ruby.
46
+
47
+ ## Strict mode
48
+
49
+ If a forced tier (`native` or `ffi`) is not available, fontisan falls back to Ruby silently. Set `FONTISAN_ENGINE_STRICT=1` (also `true`, `yes`, `on`; `0`, `false`, `no`, `off` leave it off, anything else raises `Fontisan::Engine::ConfigError`) to raise `Fontisan::Engine::TierUnavailable` instead:
50
+
51
+ ```sh
52
+ FONTISAN_ENGINE=native FONTISAN_ENGINE_STRICT=1 ruby -rfontisan -e 'Fontisan::Reference.audit("MyFont.ttf")'
53
+ ```
54
+
55
+ Strict mode does not apply to operations a tier cannot serve (see below); those always go to Ruby.
56
+
57
+ ## Which tier served a call
58
+
59
+ `Fontisan::Engine.served_by(operation)` returns the tier (a Symbol) that served the last call of that operation in the current process, or `nil` if there was none.
60
+
61
+ ```ruby
62
+ ENV["FONTISAN_ENGINE"] = "auto"
63
+ Fontisan::Reference.audit("MyFont.ttf")
64
+ Fontisan::Engine.served_by(:audit) # => :native
65
+ ```
66
+
67
+ `Fontisan::Engine.scan(paths)` and `Fontisan::Engine.audit_dir(dir)` return the same rows as the `Reference` methods with an added `"served_by"` key per row or entry. Results of the `Reference` methods themselves do not change with the tier.
68
+
69
+ ## What stays on Ruby
70
+
71
+ These always run on the Ruby tier, whichever engine is selected, and strict mode does not raise for them:
72
+
73
+ - The object-based API: `Fontisan::Subset::Builder.new(font, ...)` and `Fontisan::Variable::Instancer`. Only the path-based `Reference.subset` and `Reference.instance` can use another tier.
74
+ - Apple dfont files.
75
+ - CLI commands other than `convert`, `unpack` and `subset`, and the non-plain `convert` cases listed above.
76
+ - Type 1 fonts (PFB/PFA).
77
+
78
+ ## Platforms
79
+
80
+ Platform gems with the native extension and the bundled shared library are published for:
81
+
82
+ - `x86_64-linux`
83
+ - `x86_64-linux-musl`
84
+ - `arm64-darwin`
85
+ - `x86_64-darwin`
86
+ - `x64-mingw-ucrt` (Ruby 3.2 to 4.0)
87
+
88
+ The `java` gem (JRuby) carries the shared library only (bundled for linux-x86_64, darwin-arm64, darwin-x86_64 and windows-x86_64; no musl or linux-arm64), so it serves the `ffi` tier. It depends on neither `brotli` nor `rb_sys`; a WOFF2 operation that falls to the Ruby tier on this gem raises `Fontisan::Engine::Woff2Unavailable`.
89
+
90
+ The plain source gem builds the native extension at install time when a Rust toolchain (`cargo`) and the `rb_sys` gem are present. Without them the install still succeeds and fontisan runs on the Ruby tier.
91
+
92
+ ## Troubleshooting
93
+
94
+ ### "native tier not available"
95
+
96
+ With `FONTISAN_ENGINE=native` and `FONTISAN_ENGINE_STRICT=1` you get `TierUnavailable`: the native extension did not load. Check:
97
+
98
+ ```sh
99
+ ruby -rfontisan -e 'p Fontisan::Native.available?, Fontisan::Ffi.available?'
100
+ ```
101
+
102
+ - `false` on an installed platform gem: the gem was installed on a Ruby version or platform it does not cover. Run `gem list fontisan` and check the platform suffix; install the matching one.
103
+ - `false` on a source install: the extension was skipped because `cargo` or `rb_sys` was missing. See below.
104
+ - Without strict mode, the same condition silently serves from Ruby; use `Fontisan::Engine.served_by` to confirm which tier ran.
105
+
106
+ For the ffi tier, `FONTISAN_CDYLIB` can point at a specific shared library file.
107
+
108
+ ### Building from source
109
+
110
+ Install a Rust toolchain ([rustup](https://rustup.rs)), then reinstall so the extension is compiled:
111
+
112
+ ```sh
113
+ gem install rb_sys
114
+ gem install fontisan --platform ruby
115
+ ```
116
+
117
+ With Bundler, add `gem "fontisan", force_ruby_platform: true` to build from the source gem. Set `FONTISAN_NATIVE_REQUIRED=1` during the install to make a missing toolchain or a failed build an error instead of a silent skip. `FONTISAN_SKIP_CDYLIB=1` skips building the shared library for the ffi tier.
@@ -0,0 +1,130 @@
1
+ # Reference facade (`Fontisan::Reference`)
2
+
3
+ Every golden is generated through this module; every engine tier serves the
4
+ same operations. Operations take a file path, never a loaded font. Read-type
5
+ results are JSON-ready: Hash with String keys sorted bytewise, Array, String,
6
+ Integer, true, false, nil. No Float (Fixed 16.16 values are raw integers) and
7
+ no clock value. `Reference.canonical_json(data)` = `JSON.pretty_generate` of the
8
+ normalised data plus a trailing newline.
9
+
10
+ Errors: the wrapped class's exception propagates unchanged (`Errno::ENOENT`,
11
+ `Fontisan::InvalidFontError`, `Fontisan::Error` "Conversion failed: ...",
12
+ `ArgumentError`). `scan` and `audit_dir` never raise for a bad font; the failure
13
+ is recorded in the row/entry. A non-path argument raises `TypeError`.
14
+
15
+ ## Operations
16
+
17
+ | Op | Returns |
18
+ |---|---|
19
+ | `read(path, mode: :metadata, lazy: true)` | Array of faces (below) |
20
+ | `outline_hashes(path, index: 0)` | `{"kind"=>"glyf"/"cff"/nil, "hashes"=>[hex...]}` |
21
+ | `audit(path, validate: false)` | Array of canonical audit reports, one per face |
22
+ | `audit_dir(dir, validate: false)` | Array of `{"path"=>rel, "faces"=>[...]}` or `{"path","error"=>{"class","message"}}`, sorted by relative path; files with ttf otf ttc otc woff woff2 dfont pfa pfb extensions |
23
+ | `scan(paths)` | Array of scan rows (below); `Fontisan.scan` delegates here |
24
+ | `convert(path, to:, output:, timestamp: nil)` | the `ConvertCommand` result Hash; runs quietly; `timestamp` is passed only when `to` is `woff2` |
25
+ | `extract(path, index:, output_dir:)` | path of the extracted file (`UnpackCommand`) |
26
+ | `instance(path, coords:, timestamp: nil)` | binary String (`Instancer#instance`); `timestamp` ignored |
27
+ | `glyph_ids(path, unicodes:, text:)` | sorted unique gids; raises `Fontisan::Error` "None of the requested characters exist in &lt;basename&gt;" |
28
+ | `subset(path, unicodes:, text:, profile: "web", timestamp: nil)` | binary String; gids then `Subset::Builder`; `timestamp` ignored |
29
+
30
+ Three additions to existing behaviour, nothing else: optional `timestamp:`
31
+ (Unix seconds or Time) fixing `head.modified` in WOFF2 encode and
32
+ `StaticFontBuilder` (nil = today's clock behaviour); fontist's `glyph_ids` step
33
+ in `subset`; quiet `convert`.
34
+
35
+ ## `read` face
36
+
37
+ `index, format, mode, lazy, file_sha256`, `tables` (`tag, length, sha256, error`
38
+ per table; sha256 nil when the loading mode gates the table out, `error` is the
39
+ exception class when loading/parsing failed), `names` (`platform_id, encoding_id,
40
+ language_id, name_id, string` decoded as fontist's `decode_name_record_manually`),
41
+ `header_fields` (`head`, `hhea`, `maxp`, `OS/2`: every field of the parsed record
42
+ with Ruby's field names; Strings that are printable ASCII stay text, others are
43
+ `"0x"+hex`), `cmap` (`count`, `sha256`, `subtable_formats`; sha256 is SHA-256 over
44
+ the sorted codepoints as big-endian uint32), `fvar` (`axes` with `*_raw` Fixed
45
+ integers, `instances` with `coordinates_raw`), `outlines` (`kind`, `count`,
46
+ `digest` = SHA-256 over the concatenated 32-byte per-glyph hashes).
47
+
48
+ Per-glyph outline hash: glyf fonts hash the glyph's byte range of `glyf`, located
49
+ via the parsed `loca` (empty glyph = hash of the empty string); CFF fonts hash the
50
+ CharString bytes of the CharStrings INDEX entry; CFF2 fonts (`kind` "cff2")
51
+ hash the CharStrings INDEX entries located through Top DICT operator 17 (the
52
+ INDEX count is a uint32).
53
+
54
+ Further `read` face fields (all nil when the table is absent or gated out):
55
+
56
+ - `post`: `version_raw, italic_angle_raw, underline_position,
57
+ underline_thickness, is_fixed_pitch, min_mem_type42, max_mem_type42,
58
+ min_mem_type1, max_mem_type1`, `glyph_name_count` and `glyph_names_sha256`
59
+ (SHA-256 of the glyph names joined with "\n"), or `glyph_names_error`.
60
+ - `hmtx_metrics`: `count` (glyphs) and `sha256` over the decoded `Hmtx#metric_for`
61
+ result of every glyph (advance as uint32 BE, lsb as int32 BE, concatenated), or
62
+ `{error}`. A decoded-value digest: the `tables` digests are raw bytes and no
63
+ convert golden reads hmtx. (Parsed glyf coordinates are covered by the convert
64
+ goldens, whose ttf -> otf rebuild decodes every simple glyph; measured red under
65
+ a coordinate-delta mutation.)
66
+ - `layout`: `GSUB` / `GPOS` => `{scripts, features}` where `features` maps each
67
+ script tag to its feature tags, exactly as the table classes report them
68
+ (they answer `[]` when parsing fails; that is the reference behaviour).
69
+ - `variation`: `gvar`, `HVAR`, `VVAR`, `MVAR`, `STAT` parsed digests, below;
70
+ `{"error"=>class}` when the table cannot be read.
71
+
72
+ ## Parsed table digests (`variation`)
73
+
74
+ Computed from the raw table bytes by `Fontisan::Reference::TableDigests`. A
75
+ "parts digest" is SHA-256 over the concatenated 32-byte SHA-256 of each part.
76
+
77
+ - `gvar`: `version, axis_count, flags, glyph_count, glyphs_with_data,
78
+ shared_tuple_count, shared_tuples_sha256` (the `shared_tuple_count x
79
+ axis_count x 2` bytes at `sharedTuplesOffset`), `glyphs_digest` = parts
80
+ digest over each glyph's GlyphVariationData byte range (offset array
81
+ `u16*2` or `u32` by flag bit 0, relative to `glyphVariationDataArrayOffset`).
82
+ - `item_variation_store` (HVAR, VVAR, MVAR): `region_count, item_data_count,
83
+ regions_sha256` (4 + regionCount x axisCount x 6 bytes), `data_sha256` (one
84
+ per ItemVariationData: 6 + 2 x regionIndexCount + itemCount x row bytes; row
85
+ = wordCount + regionIndexCount, or 4 x wordCount + 2 x (regionIndexCount -
86
+ wordCount) with the long-words flag), `data_digest` = parts digest of those.
87
+ - `HVAR`/`VVAR`: `version`, `item_variation_store`, and `advance_mapping`,
88
+ `lsb_mapping`, `rsb_mapping` (nil when the offset is 0) each
89
+ `{format, entry_format, map_count, sha256}` over the whole DeltaSetIndexMap
90
+ (4 or 6 byte header + mapCount x entrySize).
91
+ - `MVAR`: `version`, `value_records` (`tag, outer, inner`), `item_variation_store`
92
+ (Offset16 from the table start, per the OpenType spec).
93
+ - `STAT`: `version, elided_fallback_name_id` (nil before 1.1), `axes` (`tag,
94
+ name_id, ordering`), `axis_values` in table order: formats 1-3 as
95
+ `format, axis_index, flags, value_name_id` plus `value_raw` /
96
+ `nominal_raw, range_min_raw, range_max_raw` / `value_raw, linked_value_raw`
97
+ (Fixed 16.16 integers); format 4 with `axis_values` (`axis_index, value`).
98
+
99
+ ## Audit canonical form
100
+
101
+ `AuditCommand` with `include_codepoints: false`; `generated_at` and
102
+ `fontisan_version` dropped; Float fields become raw Fixed integers:
103
+ `font_revision` -> `font_revision_raw`, axis `min_value/default_value/max_value`
104
+ -> `*_raw` (value x 65536, rounded).
105
+
106
+ Object addresses in messages (`#<Class:0x000...>`, from checks that crash) become
107
+ `#<Class>`, so the text is stable between runs.
108
+
109
+ ## Scan row (one per face)
110
+
111
+ `path, collection_index (nil for non-collections), sha256, format, is_collection,
112
+ num_faces, has_fvar`, `names` and `english_names` (`family_name, full_name,
113
+ subfamily_name, preferred_family, preferred_subfamily, postscript_name`;
114
+ `names` uses fontist's any-platform fallback as `FontFile`, `english_names` is
115
+ `english_name` only as `CollectionFile`; `english_names` is nil for
116
+ non-collections), `variable_instances` (`family, style, full_name`, fontist's
117
+ RIBBI rule, from a second full-mode load), `validation` (`profile, valid,
118
+ status, issues[severity, category, message, location], error_summary` = fontist's
119
+ `"category: message; ..."`), and `errors`:
120
+
121
+ - `font_file`: what `FontFile.extract_font_info_from_path` would raise (only
122
+ rows of the face it reads: non-collection or index 0), as `{class, message,
123
+ cause}`; message `"Font file could not be parsed: #&lt;Class: msg&gt;."`.
124
+ - `collection_file`: what `CollectionFile.from_path` would raise for the file
125
+ (same on every row of the file): "Font collection could not be loaded: ..."
126
+ (for a non-collection the text is "...: File is not a recognized font collection
127
+ (TTC/OTC/dfont)", because `CollectionFile` wraps its own raise; a collection with
128
+ no faces gets one row of its own, `num_faces` 0, to carry the error) or "Font collection failed indexability validation (first font): ...".
129
+ - `collection_member`: error of the lazy:false metadata load that
130
+ `CollectionFile#[]` performs for this face, `{class, message}`.