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
data/README.adoc CHANGED
@@ -53,6 +53,38 @@ command mappings and usage examples.
53
53
  round-trip. See link:docs/TYPE1_FONTS.adoc[Type 1 Fonts Guide].
54
54
 
55
55
 
56
+ == Engine
57
+
58
+ Fontisan can serve its read, audit, scan, convert, extract, instance and subset
59
+ operations from three tiers: `native` (a precompiled Rust extension), `ffi` (the
60
+ bundled Rust shared library, loaded through the `ffi` gem) and `ruby` (the pure
61
+ Ruby implementation, always present).
62
+
63
+ The default is `auto`: the first available tier wins, in the order
64
+ `native`, `ffi`, `ruby`. An operation a tier cannot serve goes to the next one.
65
+ Installed from the source gem on a machine with no Rust toolchain and no
66
+ bundled library, `auto` serves everything from `ruby`, with no warning and
67
+ no error.
68
+
69
+ [cols="1,3"]
70
+ |===
71
+ |Variable |Effect
72
+
73
+ |`FONTISAN_ENGINE=auto\|native\|ffi\|ruby`
74
+ |Pick the tier. Unset or empty means `auto`. `ruby` never uses the Rust tiers.
75
+ A forced tier that is not available falls back to `ruby`.
76
+
77
+ |`FONTISAN_ENGINE_STRICT=1`
78
+ |No fallback: a forced tier that is not available raises
79
+ `Fontisan::Engine::TierUnavailable` at setup. `1`, `true`, `yes` and `on` enable it;
80
+ unset, empty, `0`, `false`, `no` and `off` leave it off; any other value raises
81
+ `Fontisan::Engine::ConfigError`.
82
+ |===
83
+
84
+ `Fontisan::Engine.served_by(:read)` reports which tier served the last call of
85
+ an operation. See link:docs/guide/engines.md[Engines] for tiers, platforms and
86
+ troubleshooting.
87
+
56
88
  == Installation
57
89
 
58
90
  Add this line to your application's Gemfile:
@@ -2734,6 +2766,26 @@ bundle exec rake fixtures:clean # Remove downloaded fonts
2734
2766
  ----
2735
2767
 
2736
2768
 
2769
+ === Reference corpus and benchmarks
2770
+
2771
+ `corpus/` holds the golden outputs of the pure-Ruby engine (fontist/fontisan#157).
2772
+ The fonts themselves are not in git: `corpus/manifest.json` pins each source
2773
+ by URL and SHA-256, and `rake corpus:fetch` downloads and verifies them into
2774
+ `corpus/fonts/`. `corpus:verify` and `bench` run the fetch themselves, so a
2775
+ fresh clone needs no manual step.
2776
+
2777
+ [source,shell]
2778
+ ----
2779
+ bundle exec rake corpus:fetch # download + checksum the ci tier (TIER=ci|large|all)
2780
+ bundle exec rake corpus:verify # compare read goldens; never writes
2781
+ bundle exec rake bench # run the benches, print the report, write BENCHMARKS.md
2782
+ ----
2783
+
2784
+ Goldens are compared, never regenerated, by the tests. `corpus:regenerate` is
2785
+ the one explicit rewrite and belongs in a PR that shows the golden diff.
2786
+ Baseline numbers are in link:BENCHMARKS.md[BENCHMARKS.md]; the `Perf Gate`
2787
+ workflow fails a PR that makes an operation more than 25% slower than its base.
2788
+
2737
2789
  == Copyright and license
2738
2790
 
2739
2791
  Copyright https://www.ribose.com[Ribose].
data/Rakefile CHANGED
@@ -146,7 +146,11 @@ namespace :fixtures do
146
146
  end
147
147
 
148
148
  # RSpec task depends on fixtures
149
- RSpec::Core::RakeTask.new(spec: "fixtures:download")
149
+ # Corpus specs need `rake corpus:fetch` and run in the corpus workflow
150
+ # (.github/workflows/corpus.yml), not in the default suite.
151
+ RSpec::Core::RakeTask.new(spec: "fixtures:download") do |t|
152
+ t.exclude_pattern = "spec/corpus/**/*_spec.rb"
153
+ end
150
154
 
151
155
  # Default task runs spec and rubocop
152
156
  task default: %i[spec rubocop]
@@ -0,0 +1,5 @@
1
+ {
2
+ "schema": 1,
3
+ "reference_commit": "d6bc12ad648e7a3fcca58fb3d2d0139091135c87",
4
+ "note": "fontisan main at d6bc12a (Phase 0), the first commit with Fontisan::Reference, so every bench op can run on it. The weekly gate compares head with this commit in one job and fails on an op the commit cannot run (NO-BASE). Move it deliberately in a reviewed change."
5
+ }
@@ -0,0 +1,217 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Performance gate for the reference corpus benches (fontist/fontisan#157).
5
+ #
6
+ # --mode pr base (--base REF) vs head (--head REF, default HEAD), same
7
+ # job, interleaved. This is the per-PR check.
8
+ # --mode weekly head vs the pinned reference commit in benchmark/baseline.json,
9
+ # same job (ratios, never absolute times). A missing or
10
+ # unreachable reference fails, and so does an op the
11
+ # reference cannot run (NO-BASE): move reference_commit
12
+ # to a commit that can run every op.
13
+ # --mode tiers ruby vs native vs ffi (FONTISAN_ENGINE, strict) on the working
14
+ # tree, same job, interleaved. Hard floor (#160): native must be
15
+ # at least as fast as ruby on audit_file, audit_dir and scan_dir
16
+ # and its scan peak RSS must not exceed ruby's (3% tolerance).
17
+ # Regression gate: native factors must stay within 25% of
18
+ # benchmark/tier_baseline.json for --platform (default
19
+ # Gem::Platform.local; no entry = floor only). With --write FILE the
20
+ # factor table is written into the "Engine tier factors"
21
+ # section of FILE (BENCHMARKS.md), tagged with --label.
22
+ # --mode report head only (working tree); prints the report and, with
23
+ # --write FILE, writes BENCHMARKS.md. Used by `rake bench`.
24
+ #
25
+ # Each side is a `git archive` copy with its own bundle. Ops the base cannot
26
+ # run (e.g. Fontisan::Reference before it exists) are reported "new", not
27
+ # compared. Exit 0 pass, 1 regression, 2 error.
28
+ #
29
+ # FONTISAN_BENCH_SLOWDOWN=2 slows only the head side 2x (gate self-test);
30
+ # with FONTISAN_BENCH_SLOWDOWN_TIER=native only the native tier is slowed.
31
+
32
+ require "json"
33
+ require "optparse"
34
+ require "etc"
35
+ require "time"
36
+ require_relative "corpus/harness"
37
+ require_relative "corpus/tiers"
38
+
39
+ module FontisanBench
40
+ # Command line front end.
41
+ class Gate
42
+ ROOT = File.expand_path("..", __dir__)
43
+ HARNESS = File.join(__dir__, "corpus")
44
+ BASELINE = File.join(__dir__, "baseline.json")
45
+ FACTORS_HEADING = "## Engine tier factors"
46
+ FACTORS_SECTION = /^#{FACTORS_HEADING}.*?(?=^## |\z)/m
47
+ FACTORS_TAIL = /^#{FACTORS_HEADING}.*/m
48
+
49
+ def initialize(argv)
50
+ @opts = { mode: "pr", head: "HEAD", rounds: 5, tolerance: 0.25,
51
+ corpus: File.join(ROOT, "corpus") }
52
+ OptionParser.new do |o|
53
+ o.on("--mode MODE") { |v| @opts[:mode] = v }
54
+ o.on("--base REF") { |v| @opts[:base] = v }
55
+ o.on("--head REF") { |v| @opts[:head] = v }
56
+ o.on("--rounds N", Integer) { |v| @opts[:rounds] = v }
57
+ o.on("--tolerance F", Float) { |v| @opts[:tolerance] = v }
58
+ o.on("--corpus DIR") { |v| @opts[:corpus] = v }
59
+ o.on("--baseline FILE") { |v| @opts[:baseline] = v }
60
+ o.on("--save FILE") { |v| @opts[:save] = v }
61
+ o.on("--write FILE") { |v| @opts[:write] = v }
62
+ o.on("--label TEXT") { |v| @opts[:label] = v }
63
+ o.on("--platform NAME") { |v| @opts[:platform] = v }
64
+ o.on("--tier-baseline FILE") { |v| @opts[:tier_baseline] = v }
65
+ end.parse!(argv)
66
+ end
67
+
68
+ def call
69
+ return report if @opts[:mode] == "report"
70
+ return tiers if @opts[:mode] == "tiers"
71
+
72
+ Dir.mktmpdir("fontisan-gate") { |work| compare(work) }
73
+ rescue Error => e
74
+ warn "ERROR: #{e.message}"
75
+ 2
76
+ end
77
+
78
+ private
79
+
80
+ def compare(work)
81
+ base_ref = @opts[:mode] == "weekly" ? reference_commit : @opts[:base]
82
+ raise Error, "--base REF is required for --mode pr" unless base_ref
83
+
84
+ sides = [Side.from_ref("base", base_ref, root: ROOT, workdir: work),
85
+ Side.from_ref("head", @opts[:head], root: ROOT, workdir: work)]
86
+ m = Measurement.interleave(sides, rounds: @opts[:rounds], harness_dir: HARNESS, corpus: @opts[:corpus])
87
+ rows = Compare.rows(m, tolerance: @opts[:tolerance], strict: @opts[:mode] == "weekly")
88
+ rows = confirm(m, sides, rows)
89
+ title = @opts[:mode] == "weekly" ? "Perf gate: head vs pinned reference" : "Perf gate: head vs base"
90
+ puts Compare.markdown(rows, title: "#{title} (#{sides[0].ref[0, 8]} -> #{sides[1].ref[0, 8]})",
91
+ tolerance: @opts[:tolerance], rounds: @opts[:rounds])
92
+ save(m, rows, sides)
93
+ Compare.failed?(rows) ? 1 : 0
94
+ end
95
+
96
+ # A shared runner is noisy: ops that look regressed are re-measured in
97
+ # the same job (more interleaved rounds, medians over all rounds) and only
98
+ # fail if they still regress.
99
+ def confirm(measure, sides, rows)
100
+ suspects = rows.select { |r| r.status == "REGRESSION" }.map(&:metric)
101
+ return rows if suspects.empty?
102
+
103
+ ops = suspects - ["scan_peak_rss_kb"]
104
+ ops = ["scan_dir"] if ops.empty?
105
+ warn "confirming #{suspects.join(', ')} with #{@opts[:rounds]} more rounds"
106
+ Measurement.interleave(sides, rounds: @opts[:rounds], harness_dir: HARNESS,
107
+ corpus: @opts[:corpus], only: ops, into: measure)
108
+ Compare.rows(measure, tolerance: @opts[:tolerance], strict: @opts[:mode] == "weekly")
109
+ end
110
+
111
+ def tiers
112
+ sides = Tiers.sides(ROOT)
113
+ args = { harness_dir: HARNESS, corpus: @opts[:corpus] }
114
+ m = Tiers.measure(sides, rounds: @opts[:rounds], **args)
115
+ baseline = tier_baseline
116
+ rows = Tiers.rows(m, baseline: baseline)
117
+ if Tiers.failed?(rows)
118
+ warn "confirming with #{@opts[:rounds]} more rounds"
119
+ Tiers.measure(sides, rounds: @opts[:rounds], into: m, **args)
120
+ rows = Tiers.rows(m, baseline: baseline)
121
+ end
122
+ text = Tiers.markdown(rows, title: "Perf gate: engine tiers (#{@opts[:label] || runner_description})",
123
+ rounds: @opts[:rounds])
124
+ puts text
125
+ write_factors(text)
126
+ save(m, [], sides)
127
+ Tiers.failed?(rows) ? 1 : 0
128
+ end
129
+
130
+ # The committed expected factors for --platform (default: this Ruby's).
131
+ def tier_baseline
132
+ Tiers.baseline(@opts[:platform] || Gem::Platform.local.to_s,
133
+ file: @opts[:tier_baseline] || Tiers::BASELINE_FILE)
134
+ end
135
+
136
+ def write_factors(text)
137
+ return unless @opts[:write]
138
+
139
+ file = @opts[:write]
140
+ body = File.exist?(file) ? File.read(file) : ""
141
+ section = "#{FACTORS_HEADING}\n\n#{factors_section(text)}\n"
142
+ body = body.sub(FACTORS_SECTION, "").rstrip
143
+ File.write(file, "#{body}\n\n#{section}")
144
+ end
145
+
146
+ def factors_section(text)
147
+ lines = text.lines.map(&:chomp).drop(1) # drop the "## title" line
148
+ ["- Ruby: #{RUBY_VERSION} (#{RUBY_PLATFORM}); #{@opts[:label] || runner_description}",
149
+ "- fontisan SHA: `#{Side.capture('git', 'rev-parse', 'HEAD', chdir: ROOT).strip}`", *lines].join("\n").rstrip
150
+ end
151
+
152
+ def reference_commit
153
+ file = @opts[:baseline] || BASELINE
154
+ raise Error, "reference baseline #{file} is missing" unless File.exist?(file)
155
+
156
+ ref = JSON.parse(File.read(file))["reference_commit"].to_s
157
+ raise Error, "#{file} has no reference_commit" if ref.empty?
158
+
159
+ ref
160
+ end
161
+
162
+ def report
163
+ head = Side.new("head", ROOT, ref: Side.capture("git", "rev-parse", "HEAD", chdir: ROOT).strip)
164
+ m = Measurement.interleave([head], rounds: @opts[:rounds], harness_dir: HARNESS, corpus: @opts[:corpus])
165
+ text = report_markdown(m, head)
166
+ puts text
167
+ File.write(@opts[:write], text + kept_factors) if @opts[:write]
168
+ save(m, [], [head])
169
+ 0
170
+ end
171
+
172
+ # `rake bench` regenerates the file; the tier factor table (written by
173
+ # --mode tiers) is carried over.
174
+ def kept_factors
175
+ return "" unless File.exist?(@opts[:write])
176
+
177
+ kept = File.read(@opts[:write])[FACTORS_TAIL]
178
+ kept ? "\n#{kept.rstrip}\n" : ""
179
+ end
180
+
181
+ def report_markdown(measure, head)
182
+ dirty = Side.capture("git", "status", "--porcelain", "--", "lib", chdir: ROOT).strip.empty? ? "" : "+dirty"
183
+ lines = ["# Benchmarks", "",
184
+ "Reference corpus `ci` tier benches for the #157 operations (`rake bench`).", "",
185
+ "- fontisan SHA: `#{head.ref}#{dirty}`",
186
+ "- Ruby: #{RUBY_VERSION} (#{RUBY_PLATFORM})",
187
+ "- Runner: #{runner_description}",
188
+ "- Rounds: #{@opts[:rounds]} (median per op); each op is the fastest of repeated calls " \
189
+ "(at least 3 calls or 0.25 s of measured time)", "",
190
+ "| operation | median |", "|---|---|"]
191
+ measure.op_names.each { |op| lines << "| #{op} | #{Compare.fmt(op, measure.median_op('head', op))} |" }
192
+ lines << "| scan_peak_rss (directory scan) | #{Compare.fmt('scan_peak_rss_kb', measure.median_rss('head'))} |"
193
+ "#{lines.join("\n")}\n"
194
+ end
195
+
196
+ def runner_description
197
+ cpu = if RUBY_PLATFORM.include?("darwin")
198
+ Side.capture("sysctl", "-n", "machdep.cpu.brand_string").strip
199
+ else
200
+ File.foreach("/proc/cpuinfo").grep(/model name/).first.to_s.split(":", 2).last.to_s.strip
201
+ end
202
+ "#{ENV.fetch('RUNNER_OS', RbConfig::CONFIG['host_os'])}, #{cpu}, #{Etc.nprocessors} cores"
203
+ rescue StandardError
204
+ RbConfig::CONFIG["host_os"]
205
+ end
206
+
207
+ def save(measure, rows, sides)
208
+ return unless @opts[:save]
209
+
210
+ data = { "sides" => sides.to_h { |s| [s.name, s.ref] }, "runs" => measure.runs,
211
+ "rows" => rows.map(&:to_h) }
212
+ File.write(@opts[:save], JSON.pretty_generate(data))
213
+ end
214
+ end
215
+ end
216
+
217
+ exit FontisanBench::Gate.new(ARGV).call if $PROGRAM_NAME == __FILE__
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "open3"
6
+ require "rbconfig"
7
+ require "tmpdir"
8
+
9
+ module FontisanBench
10
+ class Error < StandardError; end
11
+
12
+ # One copy of fontisan to measure, with its own bundle.
13
+ class Side
14
+ attr_reader :name, :dir, :ref
15
+
16
+ # @param name [String] "base" or "head"
17
+ # @param dir [String] checkout directory (own Gemfile)
18
+ def initialize(name, dir, ref: nil)
19
+ @name = name
20
+ @dir = dir
21
+ @ref = ref
22
+ end
23
+
24
+ # `git archive REF` into a fresh directory with its own bundle. A git
25
+ # archive has no .git, so the gemspec lists no files but still loads, and
26
+ # nothing here relies on exe/.
27
+ def self.from_ref(name, ref, root:, workdir:)
28
+ sha = capture("git", "rev-parse", "--verify", "#{ref}^{commit}", chdir: root).strip
29
+ dir = File.join(workdir, name)
30
+ FileUtils.mkdir_p(dir)
31
+ archive = File.join(workdir, "#{name}.tar")
32
+ run!("git", "archive", "--format=tar", "-o", archive, sha, chdir: root)
33
+ run!("tar", "-xf", archive, "-C", dir)
34
+ lock = File.join(root, "Gemfile.lock")
35
+ FileUtils.cp(lock, dir) if File.exist?(lock)
36
+ side = new(name, dir, ref: sha)
37
+ side.bundle_install
38
+ side
39
+ end
40
+
41
+ def self.capture(*cmd, chdir: nil)
42
+ out, err, st = Open3.capture3(*cmd, chdir: chdir || Dir.pwd)
43
+ raise Error, "#{cmd.join(' ')} failed: #{err}" unless st.success?
44
+
45
+ out
46
+ end
47
+
48
+ def self.run!(*cmd, chdir: nil, env: {})
49
+ out, st = Open3.capture2e(env, *cmd, chdir: chdir || Dir.pwd)
50
+ raise Error, "#{cmd.join(' ')} failed:\n#{out}" unless st.success?
51
+
52
+ out
53
+ end
54
+
55
+ def env
56
+ { "BUNDLE_GEMFILE" => File.join(dir, "Gemfile"), "BUNDLE_FROZEN" => "false",
57
+ "BUNDLE_DEPLOYMENT" => "false" }
58
+ end
59
+
60
+ def bundle_install
61
+ self.class.run!("bundle", "install", "--quiet", env: env, chdir: dir)
62
+ end
63
+
64
+ # One process: every op once-per-calibration, JSON result.
65
+ def run_once(harness_dir, corpus, only: nil)
66
+ cmd = ["bundle", "exec", RbConfig.ruby, File.join(harness_dir, "run.rb"),
67
+ "--corpus", corpus, "--side", name]
68
+ cmd += ["--only", only.join(",")] if only
69
+ out, err, st = Open3.capture3(env, *cmd, chdir: dir)
70
+ raise Error, "bench run on #{name} failed:\n#{err}" unless st.success?
71
+
72
+ JSON.parse(out.lines.reverse_each.find { |l| l.start_with?("{") })
73
+ end
74
+ end
75
+
76
+ # Interleaved rounds over two (or one) sides, median per metric.
77
+ class Measurement
78
+ METRICS_RSS = "scan_peak_rss_kb"
79
+
80
+ attr_reader :runs
81
+
82
+ def initialize
83
+ @runs = Hash.new { |h, k| h[k] = [] }
84
+ end
85
+
86
+ def record(side_name, result)
87
+ @runs[side_name] << result
88
+ end
89
+
90
+ def op_names
91
+ @runs.values.flatten.flat_map { |r| r["ops"].keys }.uniq
92
+ end
93
+
94
+ def median_op(side, name)
95
+ median(@runs[side].map { |r| r["ops"][name] })
96
+ end
97
+
98
+ def median_rss(side)
99
+ median(@runs[side].map { |r| r[METRICS_RSS] })
100
+ end
101
+
102
+ def median(values)
103
+ v = values.compact.sort
104
+ return nil if v.empty?
105
+
106
+ mid = v.size / 2
107
+ v.size.odd? ? v[mid] : (v[mid - 1] + v[mid]) / 2.0
108
+ end
109
+
110
+ # Round i runs the sides in alternating order, so drift (thermal, noisy
111
+ # neighbours) hits both sides alike.
112
+ def self.interleave(sides, rounds:, harness_dir:, corpus:, log: $stderr, only: nil, into: nil)
113
+ m = into || new
114
+ rounds.times do |i|
115
+ order = i.even? ? sides : sides.reverse
116
+ order.each do |side|
117
+ log.puts "round #{i + 1}/#{rounds}: #{side.name}"
118
+ m.record(side.name, side.run_once(harness_dir, corpus, only: only))
119
+ end
120
+ end
121
+ m
122
+ end
123
+ end
124
+
125
+ # base vs head verdicts.
126
+ module Compare
127
+ Row = Struct.new(:metric, :base, :head, :ratio, :status, keyword_init: true)
128
+
129
+ module_function
130
+
131
+ # `strict` (weekly mode): the pinned reference must be able to run every
132
+ # op, so an op with no base number is "NO-BASE" and fails instead of "new".
133
+ def rows(measure, tolerance:, strict: false)
134
+ ops = measure.op_names.map do |op|
135
+ row(op, measure.median_op("base", op), measure.median_op("head", op), tolerance, strict: strict)
136
+ end
137
+ ops << row("scan_peak_rss_kb", measure.median_rss("base"), measure.median_rss("head"), tolerance, strict: strict)
138
+ ops
139
+ end
140
+
141
+ def row(metric, base, head, tolerance, strict: false)
142
+ return Row.new(metric: metric, base: base, head: head, status: "n/a") if head.nil? && base.nil?
143
+ return Row.new(metric: metric, base: base, head: head, status: "MISSING-ON-HEAD") if head.nil?
144
+ return Row.new(metric: metric, base: base, head: head, status: strict ? "NO-BASE" : "new") if base.nil?
145
+
146
+ ratio = head.to_f / base
147
+ Row.new(metric: metric, base: base, head: head, ratio: ratio,
148
+ status: ratio > 1 + tolerance ? "REGRESSION" : "ok")
149
+ end
150
+
151
+ def failed?(rows)
152
+ rows.any? { |r| %w[REGRESSION MISSING-ON-HEAD NO-BASE].include?(r.status) }
153
+ end
154
+
155
+ def markdown(rows, title:, tolerance:, rounds:)
156
+ out = ["## #{title}", "",
157
+ "Median of #{rounds} interleaved rounds; fails when head is more than " \
158
+ "#{(tolerance * 100).round}% slower than base.", "",
159
+ "| metric | base | head | head/base | status |", "|---|---|---|---|---|"]
160
+ rows.each do |r|
161
+ out << "| #{r.metric} | #{fmt(r.metric, r.base)} | #{fmt(r.metric, r.head)} | " \
162
+ "#{r.ratio ? format('%.2fx', r.ratio) : '-'} | #{r.status} |"
163
+ end
164
+ out << ""
165
+ out << (failed?(rows) ? "**RESULT: FAIL**" : "**RESULT: PASS**")
166
+ out.join("\n")
167
+ end
168
+
169
+ def fmt(metric, value)
170
+ return "-" if value.nil?
171
+
172
+ metric.end_with?("_kb") ? "#{(value / 1024.0).round(1)} MB" : format("%.1f ms", value * 1000)
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "tmpdir"
5
+
6
+ module FontisanBench
7
+ # One benchmarked operation. `requires` lists the constants the measured
8
+ # fontisan must define; an operation the measured tree cannot run is
9
+ # skipped, which the comparison reports as "new" (it has no base number).
10
+ Op = Struct.new(:name, :description, :requires, :body, keyword_init: true)
11
+
12
+ # The #157 operations, run against the `ci` tier of the reference corpus
13
+ # (corpus/fonts, fetched by `rake corpus:fetch`).
14
+ #
15
+ # Table parse, names, cmap, instancing and conversion call the classes the
16
+ # reference facade wraps, so the same code runs on a base that predates
17
+ # `Fontisan::Reference`; audit, audit_dir and scan go through the facade.
18
+ class Ops
19
+ SS_TTF = "source-sans-3-regular-ttf/SourceSans3-Regular.ttf"
20
+ SS_OTF = "source-sans-3-regular-otf/SourceSans3-Regular.otf"
21
+ SS_WOFF = "source-sans-3-regular-ttf-woff/SourceSans3-Regular.ttf.woff"
22
+ SS_WOFF2 = "source-sans-3-regular-ttf-woff2/SourceSans3-Regular.ttf.woff2"
23
+ SS_VF = "source-sans-3-vf-upright-ttf/SourceSans3VF-Upright.ttf"
24
+ NOTO = "noto-sans-hinted-regular/NotoSans-Regular.ttf"
25
+ TABLE_PARSE_FONTS = [SS_TTF, SS_OTF, NOTO].freeze
26
+ NAME_FONTS = [SS_TTF, SS_OTF, SS_VF, NOTO].freeze
27
+
28
+ attr_reader :corpus
29
+
30
+ def initialize(corpus)
31
+ @corpus = File.expand_path(corpus)
32
+ @fonts_dir = File.join(@corpus, "fonts")
33
+ end
34
+
35
+ def all
36
+ [table_parse, hmtx_metrics, names, cmap_coverage, audit_file, audit_dir, scan,
37
+ vf_instance, woff2_decode, convert_roundtrip, woff2_encode]
38
+ end
39
+
40
+ def scan_paths
41
+ Dir.glob(File.join(@fonts_dir, "**", "*")).select { |f| File.file?(f) }.sort
42
+ end
43
+
44
+ private
45
+
46
+ def font(rel)
47
+ File.join(@fonts_dir, rel)
48
+ end
49
+
50
+ def load_full(rel)
51
+ Fontisan::FontLoader.load(font(rel), mode: :full, lazy: false)
52
+ end
53
+
54
+ def op(name, description, requires: [], &body)
55
+ Op.new(name: name, description: description, requires: requires, body: body)
56
+ end
57
+
58
+ def table_parse
59
+ op("table_parse", "load (full) and parse every table of 3 static fonts") do
60
+ TABLE_PARSE_FONTS.each do |rel|
61
+ f = load_full(rel)
62
+ f.table_names.each { |tag| f.table(tag) }
63
+ end
64
+ end
65
+ end
66
+
67
+ # Tables::Hmtx#metric_for is the per-glyph metric lookup; table_parse only
68
+ # parses the table, so a slowdown inside the lookup was invisible.
69
+ def hmtx_metrics
70
+ op("hmtx_metrics", "hmtx metric_for every glyph of 3 static fonts") do
71
+ TABLE_PARSE_FONTS.each do |rel|
72
+ f = load_full(rel)
73
+ hmtx = f.table("hmtx")
74
+ hmtx.parse_with_context(f.table("hhea").number_of_h_metrics, f.table("maxp").num_glyphs) unless hmtx.parsed?
75
+ f.table("maxp").num_glyphs.times { |gid| hmtx.metric_for(gid) }
76
+ end
77
+ end
78
+ end
79
+
80
+ def names
81
+ op("names", "name table: full, family, subfamily, postscript, 4 fonts") do
82
+ ids = [Fontisan::Tables::Name::FULL_NAME, Fontisan::Tables::Name::FAMILY,
83
+ Fontisan::Tables::Name::SUBFAMILY, Fontisan::Tables::Name::POSTSCRIPT_NAME]
84
+ NAME_FONTS.each do |rel|
85
+ f = Fontisan::FontLoader.load(font(rel), mode: :metadata)
86
+ name = f.table("name")
87
+ ids.each { |id| name.english_name(id) }
88
+ end
89
+ end
90
+ end
91
+
92
+ def cmap_coverage
93
+ op("cmap_coverage", "cmap unicode mappings of 3 static fonts") do
94
+ TABLE_PARSE_FONTS.each do |rel|
95
+ load_full(rel).table("cmap").unicode_mappings.size
96
+ end
97
+ end
98
+ end
99
+
100
+ def audit_file
101
+ op("audit_file", "audit with validation, one font (facade)", requires: ["Fontisan::Reference"]) do
102
+ Fontisan::Reference.audit(font(SS_TTF), validate: true)
103
+ end
104
+ end
105
+
106
+ def audit_dir
107
+ op("audit_dir", "audit every file of the ci corpus directory (facade)",
108
+ requires: ["Fontisan::Reference"]) do
109
+ Fontisan::Reference.audit_dir(@fonts_dir, validate: false)
110
+ end
111
+ end
112
+
113
+ def scan
114
+ op("scan_dir", "scan rows for every file of the ci corpus directory (facade)",
115
+ requires: ["Fontisan::Reference"]) do
116
+ Fontisan::Reference.scan(scan_paths)
117
+ end
118
+ end
119
+
120
+ def vf_instance
121
+ op("vf_instance", "variable font instance at wght=700") do
122
+ Fontisan::Variable::Instancer.new(load_full(SS_VF)).instance({ "wght" => 700.0 })
123
+ end
124
+ end
125
+
126
+ def woff2_decode
127
+ op("woff2_decode", "load a WOFF2 and parse every table") do
128
+ f = load_full(SS_WOFF2)
129
+ f.table_names.each { |tag| f.table(tag) }
130
+ end
131
+ end
132
+
133
+ def convert_roundtrip
134
+ op("convert_roundtrip", "ttf -> woff -> ttf") do
135
+ Dir.mktmpdir("fontisan-bench") do |dir|
136
+ Fontisan.convert(font(SS_TTF), to: :woff, output: File.join(dir, "a.woff"), quiet: true)
137
+ Fontisan.convert(File.join(dir, "a.woff"), to: :ttf, output: File.join(dir, "b.ttf"), quiet: true)
138
+ end
139
+ end
140
+ end
141
+
142
+ def woff2_encode
143
+ op("woff2_encode", "ttf -> woff2") do
144
+ Dir.mktmpdir("fontisan-bench") do |dir|
145
+ Fontisan.convert(font(SS_TTF), to: :woff2, output: File.join(dir, "a.woff2"), quiet: true)
146
+ end
147
+ end
148
+ end
149
+ end
150
+ end