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
|
@@ -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
|
+
}
|
data/docs/.vitepress/config.ts
CHANGED
|
@@ -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://
|
|
364
|
+
message: 'Fontisan is a [Ribose](https://www.ribose.com/) project',
|
|
364
365
|
copyright: `Copyright © 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 <basename>" |
|
|
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: #<Class: msg>."`.
|
|
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}`.
|