fontisan 0.4.46 → 0.4.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. checksums.yaml +4 -4
  2. data/BENCHMARKS.md +56 -0
  3. data/CHANGELOG.md +12 -0
  4. data/Cargo.lock +550 -0
  5. data/Cargo.toml +12 -0
  6. data/Gemfile +10 -0
  7. data/README.adoc +52 -0
  8. data/Rakefile +5 -1
  9. data/benchmark/baseline.json +5 -0
  10. data/benchmark/ci_gate.rb +217 -0
  11. data/benchmark/corpus/harness.rb +175 -0
  12. data/benchmark/corpus/ops.rb +150 -0
  13. data/benchmark/corpus/run.rb +182 -0
  14. data/benchmark/corpus/scan_child.rb +8 -0
  15. data/benchmark/corpus/tiers.rb +176 -0
  16. data/benchmark/tier_baseline.json +12 -0
  17. data/docs/.vitepress/config.ts +2 -1
  18. data/docs/guide/engines.md +117 -0
  19. data/docs/reference-facade.md +130 -0
  20. data/ext/fontisan_cdylib/Cargo.lock +313 -0
  21. data/ext/fontisan_cdylib/Cargo.toml +18 -0
  22. data/ext/fontisan_cdylib/build.rs +22 -0
  23. data/ext/fontisan_cdylib/extconf.rb +36 -0
  24. data/ext/fontisan_cdylib/src/lib.rs +13 -0
  25. data/ext/fontisan_native/Cargo.toml +21 -0
  26. data/ext/fontisan_native/build.rs +22 -0
  27. data/ext/fontisan_native/clippy.toml +10 -0
  28. data/ext/fontisan_native/extconf.rb +59 -0
  29. data/ext/fontisan_native/src/lib.rs +463 -0
  30. data/lib/fontisan/audit/checks/cmap_check.rb +7 -0
  31. data/lib/fontisan/audit/checks/hinting_check.rb +1 -1
  32. data/lib/fontisan/audit/checks/maxp_check.rb +1 -1
  33. data/lib/fontisan/audit/checks/opentype_conformance_check.rb +5 -2
  34. data/lib/fontisan/base_collection.rb +30 -0
  35. data/lib/fontisan/byte_ops.rb +219 -0
  36. data/lib/fontisan/cli.rb +5 -5
  37. data/lib/fontisan/commands/audit_command.rb +38 -43
  38. data/lib/fontisan/commands/convert_command.rb +53 -0
  39. data/lib/fontisan/commands/instance_command.rb +75 -0
  40. data/lib/fontisan/commands/subset_command.rb +43 -0
  41. data/lib/fontisan/commands/unpack_command.rb +56 -0
  42. data/lib/fontisan/converters/cff_table_builder.rb +1 -1
  43. data/lib/fontisan/converters/collection_converter.rb +13 -5
  44. data/lib/fontisan/converters/format_converter.rb +1 -1
  45. data/lib/fontisan/converters/woff2_encoder.rb +8 -6
  46. data/lib/fontisan/converters/woff_writer.rb +6 -5
  47. data/lib/fontisan/engine.rb +350 -0
  48. data/lib/fontisan/ffi/client.rb +121 -0
  49. data/lib/fontisan/ffi.rb +343 -0
  50. data/lib/fontisan/ffi_platform.rb +24 -0
  51. data/lib/fontisan/font_loader.rb +5 -1
  52. data/lib/fontisan/font_writer.rb +13 -1
  53. data/lib/fontisan/native.rb +231 -0
  54. data/lib/fontisan/pipeline/transformation_pipeline.rb +10 -8
  55. data/lib/fontisan/pipeline.rb +1 -1
  56. data/lib/fontisan/reference/table_digests.rb +251 -0
  57. data/lib/fontisan/reference.rb +619 -0
  58. data/lib/fontisan/scan.rb +372 -0
  59. data/lib/fontisan/sfnt_directory.rb +41 -0
  60. data/lib/fontisan/sfnt_font.rb +16 -6
  61. data/lib/fontisan/subset/builder.rb +2 -1
  62. data/lib/fontisan/tables/cff/charstring.rb +16 -0
  63. data/lib/fontisan/tables/cff.rb +15 -5
  64. data/lib/fontisan/tables/cmap.rb +11 -1
  65. data/lib/fontisan/tables/glyf.rb +4 -6
  66. data/lib/fontisan/tables/gpos.rb +7 -0
  67. data/lib/fontisan/tables/gsub.rb +7 -0
  68. data/lib/fontisan/tables/layout_common.rb +172 -0
  69. data/lib/fontisan/tables/name.rb +10 -4
  70. data/lib/fontisan/utilities/brotli_wrapper.rb +39 -4
  71. data/lib/fontisan/validators/opentype_validator.rb +15 -3
  72. data/lib/fontisan/variable/instancer.rb +23 -192
  73. data/lib/fontisan/variable/static_font_builder.rb +4 -1
  74. data/lib/fontisan/variable/table_updater.rb +4 -2
  75. data/lib/fontisan/variation/instance_generator.rb +3 -149
  76. data/lib/fontisan/variation/instancing/axis_space.rb +136 -0
  77. data/lib/fontisan/variation/instancing/glyph_outline.rb +204 -0
  78. data/lib/fontisan/variation/instancing/gvar_reader.rb +146 -0
  79. data/lib/fontisan/variation/instancing/item_variation_store.rb +92 -0
  80. data/lib/fontisan/variation/instancing/name_rewriter.rb +90 -0
  81. data/lib/fontisan/variation/instancing/true_type_instancer.rb +326 -0
  82. data/lib/fontisan/variation/instancing.rb +20 -0
  83. data/lib/fontisan/variation/variable_svg_generator.rb +19 -1
  84. data/lib/fontisan/variation.rb +1 -0
  85. data/lib/fontisan/version.rb +1 -1
  86. data/lib/fontisan/woff2/collection_decoder.rb +15 -1
  87. data/lib/fontisan/woff2/collection_encoder.rb +29 -6
  88. data/lib/fontisan/woff2/encoder_rules.rb +12 -4
  89. data/lib/fontisan/woff2/glyf_loca_reconstruct.rb +1 -1
  90. data/lib/fontisan/woff2_collection.rb +7 -5
  91. data/lib/fontisan/woff2_font.rb +12 -3
  92. data/lib/fontisan/woff_container.rb +83 -0
  93. data/lib/fontisan/woff_font.rb +24 -20
  94. data/lib/fontisan.rb +24 -1
  95. data/rakelib/bench.rake +9 -0
  96. data/rakelib/cdylib.rake +39 -0
  97. data/rakelib/corpus.rake +272 -0
  98. data/rakelib/corpus_convert.rake +43 -0
  99. data/rakelib/corpus_goldens.rake +22 -0
  100. data/rakelib/corpus_malformed.rake +20 -0
  101. data/rakelib/corpus_scan.rake +24 -0
  102. data/rakelib/java.rake +61 -0
  103. data/rakelib/native.rake +43 -0
  104. data/scripts/build-woff2.sh +57 -0
  105. data/scripts/corpus/generate_convert_goldens.rb +478 -0
  106. data/scripts/corpus/generate_goldens.rb +282 -0
  107. data/scripts/corpus/generate_malformed_goldens.rb +254 -0
  108. data/scripts/corpus/generate_scan_goldens.rb +98 -0
  109. data/scripts/tier_fallback.rb +219 -0
  110. data/scripts/tier_smoke.rb +64 -0
  111. metadata +75 -3
@@ -0,0 +1,478 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "json"
6
+ require "open3"
7
+ require "tmpdir"
8
+ require "zlib"
9
+ require "fontisan"
10
+ require_relative "../../corpus/derive/sfnt"
11
+
12
+ # Convert goldens (fontist/fontisan#156, card 0c).
13
+ #
14
+ # One golden file per corpus font: corpus/goldens/convert/<font-id>.json. Each
15
+ # holds one result per applicable case, generated through Fontisan::Reference:
16
+ #
17
+ # format:<to> Reference.convert (single fonts: ttf otf woff woff2;
18
+ # collections: ttc otc woff2), every target but the source
19
+ # extract:<i> Reference.extract, every collection member
20
+ # instance:<i> Reference.instance, every named instance of an fvar font
21
+ # subset:text-latin / subset:unicodes-sample Reference.subset, profile "web"
22
+ #
23
+ # A result records the comparison class (index rule): "exact" (sha256 of the
24
+ # raw output bytes) except WOFF/WOFF2 encode, "decoded-exact" (the output
25
+ # sha256 plus the per-table sha256 after decoding), and the OTS verdict.
26
+ # Nothing is stored but hashes. Errors are goldens too (class + masked message).
27
+ module ConvertGoldens
28
+ ROOT = File.expand_path("../..", __dir__)
29
+ GOLDEN_DIR = File.join(ROOT, "corpus", "goldens", "convert")
30
+ MANIFEST = File.join(ROOT, "corpus", "manifest.json")
31
+ TRIAGE = File.join(ROOT, "corpus", "TRIAGE_CONVERT.md")
32
+ # The one fixed head.modified for WOFF2 encode (2025-01-01T00:00:00Z).
33
+ TIMESTAMP = 1_735_689_600
34
+ SINGLE_TARGETS = %w[ttf otf woff woff2].freeze
35
+ COLLECTION_TARGETS = %w[ttc otc woff2].freeze
36
+ SUBSET_TEXT = "Hamburgefonstiv 0123"
37
+ SUBSET_SAMPLE = 8
38
+ MAGICS = {
39
+ "\x00\x01\x00\x00".b => "ttf", "true" => "ttf", "OTTO" => "otf",
40
+ "wOFF" => "woff", "wOF2" => "woff2", "ttcf" => "ttc"
41
+ }.freeze
42
+ # Not convert subjects: malformed inputs are card 0e's, dfont/Type 1 are
43
+ # routing-only rows.
44
+ EXCLUDED_ID = /\Amalformed-/
45
+ OUTPUT_EXT = { "ttf" => "ttf", "otf" => "otf", "woff" => "woff", "woff2" => "woff2",
46
+ "ttc" => "ttc", "otc" => "otc" }.freeze
47
+
48
+ class ToolMissing < StandardError; end
49
+
50
+ module_function
51
+
52
+ def manifest_entries(tiers)
53
+ JSON.parse(File.read(MANIFEST)).fetch("fonts").select { |f| tiers.include?(f["tier"]) }
54
+ end
55
+
56
+ def font_path(entry)
57
+ if entry["type"] == "derived"
58
+ File.join(ROOT, "corpus", "derived", entry.fetch("file"))
59
+ else
60
+ File.join(ROOT, "corpus", "fonts", entry.fetch("id"), entry.fetch("file"))
61
+ end
62
+ end
63
+
64
+ def sniff(path)
65
+ MAGICS[File.binread(path, 4)]
66
+ end
67
+
68
+ # Fonts that are convert subjects, with their detected input format.
69
+ def subjects(tiers)
70
+ manifest_entries(tiers).filter_map do |entry|
71
+ next if entry["id"].match?(EXCLUDED_ID)
72
+
73
+ path = font_path(entry)
74
+ next unless File.exist?(path)
75
+
76
+ format = sniff(path)
77
+ format ? [entry, path, format] : nil
78
+ end
79
+ end
80
+
81
+ # --- case enumeration ---------------------------------------------------
82
+
83
+ def read_faces(path)
84
+ Fontisan::Reference.read(path, mode: :full, lazy: false)
85
+ end
86
+
87
+ def instance_coords(face)
88
+ fvar = face["fvar"] or return []
89
+ tags = fvar["axes"].map { |a| a["axis_tag"] }
90
+ fvar["instances"].map do |inst|
91
+ tags.zip(inst["coordinates_raw"]).to_h { |tag, raw| [tag, raw.to_f / Fontisan::Reference::FIXED_ONE] }
92
+ end
93
+ end
94
+
95
+ # => { "format:ttf" => {op: :format, to: "ttf"}, ... } in a fixed order
96
+ def cases_for(path, format)
97
+ faces = read_faces(path)
98
+ cases = {}
99
+ if format == "ttc" || faces.size > 1 || collection?(path)
100
+ COLLECTION_TARGETS.each { |to| cases["format:#{to}"] = { op: :format, to: to } }
101
+ faces.each_index { |i| cases["extract:#{i}"] = { op: :extract, index: i } }
102
+ return cases
103
+ end
104
+ SINGLE_TARGETS.each { |to| cases["format:#{to}"] = { op: :format, to: to } unless to == format }
105
+ instance_coords(faces.first).each_with_index do |coords, i|
106
+ cases["instance:#{i}"] = { op: :instance, coords: coords }
107
+ end
108
+ cases["subset:text-latin"] = { op: :subset, text: SUBSET_TEXT }
109
+ cases["subset:unicodes-sample"] = { op: :subset, sample: true }
110
+ cases
111
+ end
112
+
113
+ def collection?(path)
114
+ File.binread(path, 4) == "ttcf" || (File.binread(path, 4) == "wOF2" && woff2_flavor(path) == "ttcf")
115
+ end
116
+
117
+ def woff2_flavor(path)
118
+ File.binread(path, 8).byteslice(4, 4)
119
+ end
120
+
121
+ def sample_codepoints(path)
122
+ cps = cmap_codepoints(path)
123
+ return cps if cps.size <= SUBSET_SAMPLE
124
+
125
+ step = (cps.size - 1).fdiv(SUBSET_SAMPLE - 1)
126
+ Array.new(SUBSET_SAMPLE) { |i| cps[(i * step).round] }.uniq
127
+ end
128
+
129
+ # Codepoints of a face's cmap. A WOFF2 input on a platform without the `brotli`
130
+ # gem (the java gem on JRuby) is decoded by the pinned woff2_decompress first,
131
+ # so the sample does not depend on the tier under test.
132
+ def cmap_codepoints(path)
133
+ return load_codepoints(path) unless File.binread(path, 4) == "wOF2" && !Fontisan::Utilities::BrotliWrapper.available?
134
+
135
+ Dir.mktmpdir("sample-cps") do |tmp|
136
+ input = File.join(tmp, "in.woff2")
137
+ FileUtils.cp(path, input)
138
+ _out, err, status = Open3.capture3(woff2_decompress, input)
139
+ decoded = File.join(tmp, "in.ttf")
140
+ raise "woff2_decompress failed: #{err.strip}" unless status.success? && File.exist?(decoded)
141
+
142
+ load_codepoints(decoded)
143
+ end
144
+ end
145
+
146
+ def load_codepoints(path)
147
+ Fontisan::FontLoader.load(path, mode: :full, lazy: false).table("cmap").unicode_mappings.keys.sort
148
+ end
149
+
150
+ # --- tools ----------------------------------------------------------------
151
+
152
+ def ots_command
153
+ @ots_command ||= (ENV["OTS_PYTHON"] || "python3").then { |py| [py, "-m", "ots"] }
154
+ end
155
+
156
+ def woff2_decompress
157
+ @woff2_decompress ||= ENV["WOFF2_DECOMPRESS"] ||
158
+ Open3.capture2(File.join(ROOT, "scripts", "build-woff2.sh"), err: File::NULL).first.lines.last.to_s.strip
159
+ end
160
+
161
+ def check_tools!
162
+ out, status = Open3.capture2e(*ots_command, "--version")
163
+ ok = status.success? || out.include?("Usage") || out.include?("Unrecognized")
164
+ raise ToolMissing, "OTS not runnable via `#{ots_command.join(' ')}` (set OTS_PYTHON): #{out.strip}" unless ok
165
+ raise ToolMissing, "woff2_decompress not available" unless File.executable?(woff2_decompress.to_s)
166
+ end
167
+
168
+ # --- decoding -------------------------------------------------------------
169
+
170
+ def table_digests_of_sfnt(bytes)
171
+ if bytes.byteslice(0, 4) == "ttcf"
172
+ CorpusSfnt.parse_ttc(bytes).map { |_v, tables| digest_tables(tables.to_h) }
173
+ else
174
+ [digest_tables(CorpusSfnt.parse(bytes).last.to_h)]
175
+ end
176
+ end
177
+
178
+ def digest_tables(tables)
179
+ tables.sort.to_h { |tag, data| [tag, Digest::SHA256.hexdigest(data)] }
180
+ end
181
+
182
+ def decode_woff(bytes)
183
+ num = bytes.byteslice(12, 2).unpack1("n")
184
+ tables = {}
185
+ num.times do |i|
186
+ tag, offset, comp, orig = bytes.byteslice(44 + (i * 20), 20).unpack("a4NNN")
187
+ data = bytes.byteslice(offset, comp)
188
+ tables[tag] = comp < orig ? Zlib::Inflate.inflate(data) : data
189
+ end
190
+ [digest_tables(tables)]
191
+ end
192
+
193
+ def decode_woff2(bytes, tmp)
194
+ input = File.join(tmp, "decode.woff2")
195
+ File.binwrite(input, bytes)
196
+ _out, err, status = Open3.capture3(woff2_decompress, input)
197
+ out = File.join(tmp, "decode.ttf")
198
+ raise "woff2_decompress failed: #{err.strip}" unless status.success? && File.exist?(out)
199
+
200
+ decoded = File.binread(out)
201
+ FileUtils.rm_f([input, out])
202
+ table_digests_of_sfnt(decoded)
203
+ end
204
+
205
+ def decoded_digests(format, bytes, tmp)
206
+ case format
207
+ when "woff" then decode_woff(bytes)
208
+ when "woff2" then decode_woff2(bytes, tmp)
209
+ end
210
+ end
211
+
212
+ # --- OTS ------------------------------------------------------------------
213
+
214
+ def ots_run(path, index = nil)
215
+ dest = File.join(File.dirname(path), "ots-dest.bin")
216
+ args = [*ots_command, path]
217
+ args.push(dest, index.to_s) if index
218
+ out, status = Open3.capture2e(*args)
219
+ FileUtils.rm_f(dest)
220
+ status.success? ? nil : out.lines.grep(/ERROR|Failed/).first(2).map(&:strip).join(" | ")
221
+ end
222
+
223
+ # Collections are sanitised member by member; WOFF2 collections are first
224
+ # decoded by the pinned woff2_decompress.
225
+ def ots_verdict(path, format, tmp)
226
+ target = path
227
+ if format == "woff2" && woff2_flavor(path) == "ttcf"
228
+ target = File.join(tmp, "ots-decoded.ttf")
229
+ FileUtils.cp(path, File.join(tmp, "ots-decoded.woff2"))
230
+ _o, _e, st = Open3.capture3(woff2_decompress, File.join(tmp, "ots-decoded.woff2"))
231
+ return { "verdict" => "fail", "detail" => "woff2_decompress failed" } unless st.success?
232
+ end
233
+ failures = member_count(target).times.filter_map do |i|
234
+ msg = ots_run(target, member_count(target) > 1 ? i : nil)
235
+ "member #{i}: #{msg}" if msg
236
+ end
237
+ { "verdict" => failures.empty? ? "pass" : "fail" }.tap { |v| v["detail"] = failures.join(" ; ") unless failures.empty? }
238
+ end
239
+
240
+ # Index rule: a golden whose output fails OTS is a known_reference_bug; the
241
+ # tag is the first OTS error with numbers masked.
242
+ def ots_bug_tag(detail)
243
+ first = detail.to_s[/ERROR: ([^|;]+)/, 1] || detail.to_s
244
+ first.strip.gsub(/\b\d+\b/, "N")
245
+ end
246
+
247
+ def member_count(path)
248
+ return 1 unless File.binread(path, 4) == "ttcf"
249
+
250
+ File.binread(path, 12).unpack1("@8N")
251
+ end
252
+
253
+ # --- running a case -------------------------------------------------------
254
+
255
+ def mask(message, tmp)
256
+ message.to_s.gsub(tmp, "<tmp>").gsub(ROOT, "<root>").gsub(/0x\h+/, "0x?")
257
+ .gsub(%r{(?:/[\w.\-\[\],]+)+/}, "<dir>/").gsub(/\b\d+\b/, "N").lines.first.to_s.strip
258
+ end
259
+
260
+ def run_case(path, format, spec, tmp)
261
+ result = { "params" => params_of(spec) }
262
+ result["class"] = spec[:op] == :format && %w[woff woff2].include?(spec[:to]) ? "decoded-exact" : "exact"
263
+ out_path, out_format = produce(path, spec, tmp)
264
+ bytes = File.binread(out_path)
265
+ result["outcome"] = "ok"
266
+ result["output"] = { "format" => out_format, "sha256" => Digest::SHA256.hexdigest(bytes), "size" => bytes.bytesize }
267
+ result["output"]["basename"] = File.basename(out_path) if spec[:op] == :extract
268
+ decoded = decoded_digests(out_format, bytes, tmp)
269
+ result["decoded_tables"] = decoded if decoded
270
+ result["ots"] = ots_verdict(out_path, out_format, tmp)
271
+ result["known_reference_bug"] = ots_bug_tag(result["ots"]["detail"]) if result["ots"]["verdict"] == "fail"
272
+ result
273
+ rescue ToolMissing
274
+ raise
275
+ rescue StandardError => e
276
+ result.merge("outcome" => "error", "error" => { "class" => e.class.name, "message" => mask(e.message, tmp) })
277
+ ensure
278
+ FileUtils.rm_rf(Dir.glob(File.join(tmp, "*")))
279
+ end
280
+
281
+ def params_of(spec)
282
+ params = spec.except(:op, :coords).transform_keys(&:to_s).merge("op" => spec[:op].to_s)
283
+ params["coords_raw"] = spec[:coords].transform_values { |v| (v * Fontisan::Reference::FIXED_ONE).round } if spec[:coords]
284
+ params.sort.to_h
285
+ end
286
+
287
+ def produce(path, spec, tmp)
288
+ case spec[:op]
289
+ when :format
290
+ out = File.join(tmp, "out.#{OUTPUT_EXT.fetch(spec[:to])}")
291
+ Fontisan::Reference.convert(path, to: spec[:to], output: out, timestamp: TIMESTAMP)
292
+ [out, sniff(out) || "unknown"]
293
+ when :extract
294
+ out = Fontisan::Reference.extract(path, index: spec[:index], output_dir: tmp)
295
+ [out, sniff(out) || "unknown"]
296
+ when :instance
297
+ write_bytes(tmp, Fontisan::Reference.instance(path, coords: spec[:coords], timestamp: TIMESTAMP))
298
+ when :subset
299
+ write_bytes(tmp, Fontisan::Reference.subset(path, **subset_args(path, spec), profile: "web", timestamp: TIMESTAMP))
300
+ end
301
+ end
302
+
303
+ def write_bytes(tmp, bytes)
304
+ out = File.join(tmp, "out.bin")
305
+ File.binwrite(out, bytes)
306
+ [out, sniff(out) || "unknown"]
307
+ end
308
+
309
+ def subset_args(path, spec)
310
+ spec[:text] ? { text: spec[:text] } : { unicodes: sample_codepoints(path) }
311
+ end
312
+
313
+ # --- golden assembly ------------------------------------------------------
314
+
315
+ def golden_for(entry, path, format)
316
+ specs = cases_for(path, format)
317
+ results = {}
318
+ Dir.mktmpdir("convert-golden") do |tmp|
319
+ specs.each do |key, spec|
320
+ results[key] = run_case(path, format, spec, tmp)
321
+ results[key]["params"]["unicodes"] = sample_codepoints(path) if spec[:sample]
322
+ results[key]["params"].delete("sample")
323
+ end
324
+ end
325
+ { "font" => entry["id"], "source_sha256" => Digest::SHA256.file(path).hexdigest,
326
+ "source_format" => format, "timestamp" => TIMESTAMP, "cases" => results }
327
+ end
328
+
329
+ # Returns { "<font-id>.json" => canonical json text }.
330
+ def generate(tiers, only: nil)
331
+ check_tools!
332
+ subjects(tiers).each_with_object({}) do |(entry, path, format), files|
333
+ next if only && !Array(only).include?(entry["id"])
334
+
335
+ files["#{entry['id']}.json"] = Fontisan::Reference.canonical_json(golden_for(entry, path, format))
336
+ end
337
+ end
338
+
339
+ def expected_keys(tiers)
340
+ subjects(tiers).to_h { |entry, path, format| [entry["id"], cases_for(path, format).keys] }
341
+ end
342
+
343
+ REASONS = {
344
+ /Variations: Required fvar/ => "static output drops fvar/gvar but GDEF/GPOS keep ItemVariationStore references (measured: instance and VF->static convert outputs)",
345
+ /post: Bad number of glyphs/ => "subset leaves the post table at the source glyph count (not subsetted)",
346
+ /CFF : Failed to parse Name INDEX/ => "TTF->OTF conversion writes a CFF table with an invalid Name INDEX",
347
+ /Layout: bad glyph/ => "subset leaves GSUB/GPOS referencing glyph ids beyond the subset glyph count",
348
+ /overlapping tables/ => "TTC writer emits overlapping table records for shared tables",
349
+ /zero-length table|missing required table|invalid table offset|Unsupported majorVersion/ =>
350
+ "input is a deliberate quirk font (corpus/derive/quirks.rb); Ruby passes the quirk through and OTS rejects it",
351
+ /cmap: bad glyph id offset/ => "extracted member keeps a cmap invalid for OTS (TestTTCv2)",
352
+ /glyf: Count too high/ => "WOFF encode of a WOFF2-decoded TTF writes a glyf table OTS rejects",
353
+ }.freeze
354
+
355
+ def reason_for(tag)
356
+ REASONS.each { |re, why| return why if tag.match?(re) }
357
+ "unclassified"
358
+ end
359
+
360
+ # corpus/TRIAGE_CONVERT.md: every OTS exception, grouped by failure tag.
361
+ def triage_text(dir = GOLDEN_DIR)
362
+ groups = Hash.new { |h, k| h[k] = [] }
363
+ Dir.glob(File.join(dir, "*.json")).each do |file|
364
+ g = JSON.parse(File.read(file))
365
+ g["cases"].each { |k, v| groups[v["known_reference_bug"]] << "#{g['font']}/#{k}" if v.dig("ots", "verdict") == "fail" }
366
+ end
367
+ lines = ["# Convert goldens: OTS exceptions (card 0c)", "",
368
+ "Generated by `rake corpus:convert:regenerate`; do not edit by hand. Each entry is a",
369
+ "`known_reference_bug`: the golden keeps Ruby's output, Rust reproduces it, and the output",
370
+ "fails OTS (`python -m ots`). Fixing the Ruby behaviour regenerates the golden and removes the entry.", ""]
371
+ groups.sort_by { |tag, _| tag }.each do |tag, ids|
372
+ lines << "## #{tag} (#{ids.size})" << "" << "Reason: #{reason_for(tag)}." << ""
373
+ ids.sort.each { |id| lines << "- OTS exception: `#{id}`" }
374
+ lines << ""
375
+ end
376
+ lines.concat(known_notes)
377
+ lines.concat(error_section(dir))
378
+ "#{lines.join("\n")}\n"
379
+ end
380
+
381
+ def known_notes
382
+ ["# Known reference bugs outside OTS", "",
383
+ "- `Tables::Cff::CharString` hintmask/cntrmask: `@io&.read` is nil for the top-level mask bytes, so they are",
384
+ " not skipped and CID-keyed CFF glyphs become empty (visible in noto-sans-jp-subset-regular-otf otf->ttf",
385
+ " and its subsets). known_reference_bug: goldens keep Ruby's output; Rust reproduces it.", ""]
386
+ end
387
+
388
+ # Cases where Ruby raises: recorded as error goldens (Rust must raise the
389
+ # same class and message). Listed so a wrong-looking error is not mistaken
390
+ # for a harness fault.
391
+ def error_section(dir)
392
+ errors = Hash.new { |h, k| h[k] = [] }
393
+ Dir.glob(File.join(dir, "*.json")).each do |file|
394
+ g = JSON.parse(File.read(file))
395
+ g["cases"].each do |k, v|
396
+ errors["#{v['error']['class']}: #{v['error']['message']}"] << "#{g['font']}/#{k}" if v["outcome"] == "error"
397
+ end
398
+ end
399
+ out = ["# Reference errors (error goldens)", "",
400
+ "Ruby raises on these inputs today; the golden keeps class and masked message.", ""]
401
+ errors.sort.each do |msg, ids|
402
+ out << "## #{msg} (#{ids.size})" << ""
403
+ ids.sort.each { |id| out << "- Reference error: `#{id}`" }
404
+ out << ""
405
+ end
406
+ out
407
+ end
408
+
409
+ def write(files, dir = GOLDEN_DIR)
410
+ FileUtils.mkdir_p(dir)
411
+ files.each { |name, text| File.write(File.join(dir, name), text) }
412
+ File.write(TRIAGE, triage_text(dir)) if dir == GOLDEN_DIR
413
+ end
414
+
415
+ # => array of human-readable problems (empty = golden matches)
416
+ def verify(tiers, only: nil, dir: GOLDEN_DIR)
417
+ generated = generate(tiers, only: only)
418
+ problems = []
419
+ generated.each do |name, text|
420
+ file = File.join(dir, name)
421
+ if !File.exist?(file) then problems << "#{name}: golden missing"
422
+ elsif !same_golden?(File.read(file), text) then problems << "#{name}: #{diff_cases(File.read(file), text)}"
423
+ end
424
+ end
425
+ unless only
426
+ stale = Dir.children(dir).grep(/\.json\z/) - generated.keys
427
+ problems.concat(stale.map { |n| "#{n}: golden has no corpus font" })
428
+ end
429
+ problems
430
+ end
431
+
432
+ # Goldens are Ruby's output. A non-Ruby tier (FONTISAN_ENGINE=native|ffi|auto)
433
+ # compresses with its own zlib/brotli, so for the "decoded-exact" class (woff,
434
+ # woff2) its compressed size and sha256 may differ; the decoded tables must
435
+ # not. Every other case is compared exactly, on every tier.
436
+ def same_golden?(golden_text, new_text)
437
+ return golden_text == new_text unless compressed_bytes_may_differ?
438
+
439
+ relaxed(JSON.parse(golden_text)) == relaxed(JSON.parse(new_text))
440
+ end
441
+
442
+ def compressed_bytes_may_differ?
443
+ !%w[ruby].include?(ENV.fetch("FONTISAN_ENGINE", "auto").strip.downcase)
444
+ end
445
+
446
+ def relaxed(golden)
447
+ golden["cases"].each_value do |c|
448
+ c["output"]&.delete_if { |k, _| %w[sha256 size].include?(k) } if c["class"] == "decoded-exact" && c["outcome"] == "ok"
449
+ end
450
+ golden
451
+ end
452
+
453
+ def diff_cases(old_text, new_text)
454
+ old = JSON.parse(old_text)["cases"]
455
+ new = JSON.parse(new_text)["cases"]
456
+ keys = (old.keys | new.keys).reject { |k| old[k] == new[k] }
457
+ keys.empty? ? "header differs" : "differs in #{keys.join(', ')}"
458
+ end
459
+
460
+ # OTS failures that the golden records, as "<font-id>/<case-key>".
461
+ def ots_failures(dir = GOLDEN_DIR)
462
+ Dir.glob(File.join(dir, "*.json")).flat_map do |file|
463
+ g = JSON.parse(File.read(file))
464
+ g["cases"].select { |_k, v| v.dig("ots", "verdict") == "fail" }.keys.map { |k| "#{g['font']}/#{k}" }
465
+ end.sort
466
+ end
467
+
468
+ def listed_exceptions
469
+ return [] unless File.exist?(TRIAGE)
470
+
471
+ File.read(TRIAGE).scan(%r{^- OTS exception: `([^`]+)`}).flatten.sort
472
+ end
473
+ end
474
+
475
+ if $PROGRAM_NAME == __FILE__
476
+ tiers = ENV.fetch("TIERS", "ci").split(",")
477
+ ConvertGoldens.write(ConvertGoldens.generate(tiers))
478
+ end