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,282 @@
1
+ # frozen_string_literal: true
2
+
3
+ $LOAD_PATH.unshift(File.expand_path("../../lib", __dir__))
4
+ require "fontisan"
5
+ require "json"
6
+ require "digest"
7
+ require "fileutils"
8
+ require "tmpdir"
9
+
10
+ # Read goldens for the reference corpus (fontist/fontisan#156 card 0b).
11
+ #
12
+ # One JSON file per manifest font, written through Fontisan::Reference (the
13
+ # canonical serialisations are in docs/reference-facade.md):
14
+ #
15
+ # corpus/goldens/read/<id>.json ci tier, committed
16
+ # corpus/goldens/read-large/<id>.json large tier, generated in CI (nightly)
17
+ #
18
+ # A golden holds the `read` result for each loading mode and lazy flag
19
+ # (identical variants collapse to {"same_as" => key}), the `audit` result with
20
+ # and without --validate, and the known_reference_bug tags from
21
+ # corpus/TRIAGE.md. Malformed fonts belong to card 0e and are not covered here.
22
+ module CorpusGoldens
23
+ ROOT = File.expand_path("../..", __dir__)
24
+ MANIFEST = File.join(ROOT, "corpus", "manifest.json")
25
+ GOLDEN_DIRS = { "ci" => "read", "large" => "read-large" }.freeze
26
+ SWALLOW_DIR = File.join(ROOT, "corpus", "goldens", "read", "_swallow")
27
+ DIVERGE_DIR = File.join(ROOT, "corpus", "goldens", "read", "_diverge")
28
+ MALFORMED = /\Amalformed-/
29
+ VARIANTS = [[:metadata, true], [:metadata, false], [:full, true], [:full, false]].freeze
30
+ SCHEMA = 1
31
+
32
+ # TRIAGE.md ids of Ruby results that are kept as the reference (the golden
33
+ # keeps Ruby's output and Rust reproduces it). Keyed by manifest id; "*"
34
+ # applies to every font.
35
+ KNOWN_REFERENCE_BUGS = {
36
+ "fonttools-test-t1-pfb" => %w[T1-READ],
37
+ "fonttools-test-t1-pfa" => %w[T1-READ],
38
+ "fonttools-test-woff" => %w[WOFF-CHECKS],
39
+ "fonttools-test-woff2" => %w[WOFF-CHECKS],
40
+ "source-sans-3-regular-otf-woff" => %w[WOFF-CHECKS],
41
+ "source-sans-3-regular-otf-woff2" => %w[WOFF-CHECKS],
42
+ "source-sans-3-regular-ttf-woff" => %w[WOFF-CHECKS],
43
+ "source-sans-3-regular-ttf-woff2" => %w[WOFF-CHECKS],
44
+ "noto-sans-vf-wdth-wght" => %w[MVAR-OFFSET],
45
+ "source-sans-3-vf-italic-ttf" => %w[MVAR-OFFSET],
46
+ "source-sans-3-vf-upright-cff2" => %w[MVAR-OFFSET CFF2-PARSER],
47
+ "source-sans-3-vf-upright-ttf" => %w[MVAR-OFFSET],
48
+ "quirk-name-format-1" => %w[NAME-FORMAT1],
49
+ "names-mac-roman-only" => %w[NAME-MAC-DECODE],
50
+ }.freeze
51
+
52
+ # NAME-FULL-REGULAR is derived: it applies to every font whose audit carries
53
+ # the full-name message, so a new font cannot be missed.
54
+ FULL_NAME_MESSAGE = /Full name .* doesn't match Family \+ Subfamily/
55
+ FULL_NAME_TAG = "NAME-FULL-REGULAR"
56
+
57
+ module_function
58
+
59
+ def manifest
60
+ JSON.parse(File.read(MANIFEST)).fetch("fonts")
61
+ end
62
+
63
+ def entries(tier)
64
+ manifest.select { |f| f["tier"] == tier && !f["id"].match?(MALFORMED) }
65
+ end
66
+
67
+ def font_path(entry)
68
+ dir = entry["type"] == "derived" ? "derived" : File.join("fonts", entry["id"])
69
+ File.join(ROOT, "corpus", dir, entry["file"])
70
+ end
71
+
72
+ def golden_dir(tier)
73
+ File.join(ROOT, "corpus", "goldens", GOLDEN_DIRS.fetch(tier))
74
+ end
75
+
76
+ def golden_path(tier, id)
77
+ File.join(golden_dir(tier), "#{id}.json")
78
+ end
79
+
80
+ # Goldens are only meaningful with the loader's env overrides unset.
81
+ def assert_clean_env!
82
+ bad = %w[FONTISAN_MODE FONTISAN_LAZY].select { |k| ENV.key?(k) }
83
+ raise "unset #{bad.join(', ')} before generating goldens" unless bad.empty?
84
+ end
85
+
86
+ # The error as data: class and message with the path reduced to a basename.
87
+ def outcome(path)
88
+ { "ok" => yield }
89
+ rescue StandardError => e
90
+ msg = e.message.gsub(path, File.basename(path)).gsub("#{File.dirname(path)}/", "")
91
+ { "error" => { "class" => e.class.name, "message" => msg } }
92
+ end
93
+
94
+ def read_variants(path)
95
+ seen = {}
96
+ VARIANTS.each_with_object({}) do |(mode, lazy), out|
97
+ key = "#{mode}/#{lazy ? 'lazy' : 'eager'}"
98
+ result = outcome(path) { Fontisan::Reference.read(path, mode: mode, lazy: lazy) }
99
+ comparable = strip_variant(result)
100
+ out[key] = seen[comparable] ? { "same_as" => seen[comparable] } : result
101
+ seen[comparable] ||= key
102
+ end
103
+ end
104
+
105
+ # The variant itself is the key, so `mode` and `lazy` do not make otherwise
106
+ # identical results differ.
107
+ def strip_variant(result)
108
+ return result unless result["ok"]
109
+
110
+ { "ok" => result["ok"].map { |face| face.except("mode", "lazy") } }
111
+ end
112
+
113
+ def audit_variants(path)
114
+ {
115
+ "default" => outcome(path) { Fontisan::Reference.audit(path, validate: false) },
116
+ "validate" => outcome(path) { Fontisan::Reference.audit(path, validate: true) },
117
+ }
118
+ end
119
+
120
+ def reference_bugs(id, audit)
121
+ tags = KNOWN_REFERENCE_BUGS.fetch(id, []).dup
122
+ tags << FULL_NAME_TAG if JSON.generate(audit).match?(FULL_NAME_MESSAGE)
123
+ tags.sort
124
+ end
125
+
126
+ def golden(entry, tier)
127
+ path = font_path(entry)
128
+ doc = {
129
+ "schema" => SCHEMA,
130
+ "id" => entry["id"],
131
+ "file" => File.basename(path),
132
+ "tier" => tier,
133
+ "font_sha256" => entry.fetch("sha256"),
134
+ "comparison_class" => "exact",
135
+ "generated_in" => tier == "large" ? "ci" : "any",
136
+ "read" => read_variants(path),
137
+ "audit" => audit_variants(path),
138
+ }
139
+ doc["known_reference_bug"] = reference_bugs(entry["id"], doc["audit"])
140
+ Fontisan::Reference.canonical_json(doc)
141
+ end
142
+
143
+ # Cases that hit the audit's error-swallowing paths on purpose, so a native
144
+ # tier reproduces them: GSUB/GPOS scripts and features answer [] when the
145
+ # table cannot be parsed (`collect_features`), and total_glyphs answers 0
146
+ # when maxp cannot be read. Each is TestTTF with one table replaced; the
147
+ # input font is committed next to its golden.
148
+ SWALLOW_SOURCE = "fonttools-test-ttf"
149
+ GARBAGE_LAYOUT = ([1, 0, 10, 10, 10].pack("n*") + ("\xFF".b * 6)).freeze
150
+ SWALLOW_CASES = {
151
+ "gsub-garbage" => ->(_tables) { { "GSUB" => GARBAGE_LAYOUT } },
152
+ "gpos-garbage" => ->(_tables) { { "GPOS" => GARBAGE_LAYOUT } },
153
+ "maxp-truncated" => ->(tables) { { "maxp" => tables.fetch("maxp").byteslice(0, 4) } },
154
+ }.freeze
155
+
156
+ # { path => bytes } for every swallow case: the input font and its golden.
157
+ def swallow
158
+ source = Fontisan::FontLoader.load(font_path(manifest.find { |e| e["id"] == SWALLOW_SOURCE }))
159
+ tables = source.table_data
160
+ SWALLOW_CASES.each_with_object({}) do |(name, change), out|
161
+ font = Fontisan::FontWriter.write_font(tables.merge(change.call(tables)))
162
+ input = File.join(SWALLOW_DIR, "#{name}.ttf")
163
+ out[input] = font
164
+ out[File.join(SWALLOW_DIR, "#{name}.json")] = swallow_golden(name, font)
165
+ end
166
+ end
167
+
168
+ def swallow_golden(name, font)
169
+ Dir.mktmpdir("swallow") do |dir|
170
+ path = File.join(dir, "#{name}.ttf")
171
+ File.binwrite(path, font)
172
+ Fontisan::Reference.canonical_json(
173
+ "schema" => SCHEMA, "case" => name, "source" => SWALLOW_SOURCE,
174
+ "font_sha256" => Digest::SHA256.hexdigest(font), "comparison_class" => "exact",
175
+ "known_reference_bug" => [],
176
+ "read" => { "full/eager" => outcome(path) { Fontisan::Reference.read(path, mode: :full, lazy: false) } },
177
+ "audit" => audit_variants(path)
178
+ )
179
+ end
180
+ end
181
+
182
+ # Divergences found after Phase 0 that the dedupe above or the malformed
183
+ # goldens never exercised (fontisan-rs docs/divergences.md):
184
+ #
185
+ # 1. WOFF1 `read` echoes the requested mode in each face's "mode". The read
186
+ # goldens collapse variants with `same_as` and strip `mode` first, so it
187
+ # was never compared for WOFF1. woff1-mode.json keeps all four variants
188
+ # explicit, with no dedupe, for every WOFF1 font in the ci tier.
189
+ # 2. audit(validate: true) on a bad table checksum yields one fatal
190
+ # "Check table_directory failed to execute" (BinData::Uint32be#to_s(16)
191
+ # takes no argument). Tagged CHECKSUM-BINDATA: Rust reproduces it.
192
+ BAD_CHECKSUM = "malformed-bad-checksum"
193
+ CHECKSUM_TAG = "CHECKSUM-BINDATA"
194
+
195
+ def woff1_entries
196
+ entries("ci").select { |e| e["file"].end_with?(".woff") }
197
+ end
198
+
199
+ def woff1_mode_golden
200
+ cases = woff1_entries.to_h do |e|
201
+ path = font_path(e)
202
+ variants = VARIANTS.to_h do |mode, lazy|
203
+ key = "#{mode}/#{lazy ? 'lazy' : 'eager'}"
204
+ [key, outcome(path) { Fontisan::Reference.read(path, mode: mode, lazy: lazy) }]
205
+ end
206
+ [e["id"], { "font_sha256" => e.fetch("sha256"), "read" => variants }]
207
+ end
208
+ Fontisan::Reference.canonical_json(
209
+ "schema" => SCHEMA, "case" => "woff1-mode", "comparison_class" => "exact",
210
+ "known_reference_bug" => [], "cases" => cases
211
+ )
212
+ end
213
+
214
+ def bad_checksum_golden
215
+ entry = manifest.find { |e| e["id"] == BAD_CHECKSUM }
216
+ path = font_path(entry)
217
+ Fontisan::Reference.canonical_json(
218
+ "schema" => SCHEMA, "case" => "bad-checksum-validate", "source" => BAD_CHECKSUM,
219
+ "file" => File.basename(path), "font_sha256" => entry.fetch("sha256"),
220
+ "comparison_class" => "exact", "known_reference_bug" => [CHECKSUM_TAG],
221
+ "audit" => audit_variants(path)
222
+ )
223
+ end
224
+
225
+ def diverge
226
+ {
227
+ File.join(DIVERGE_DIR, "woff1-mode.json") => woff1_mode_golden,
228
+ File.join(DIVERGE_DIR, "bad-checksum-validate.json") => bad_checksum_golden,
229
+ }
230
+ end
231
+
232
+ # Every golden of a tier as { path => bytes }, generated fresh.
233
+ def generate(tier)
234
+ assert_clean_env!
235
+ out = entries(tier).to_h { |e| [golden_path(tier, e["id"]), golden(e, tier)] }
236
+ out.merge!(swallow).merge!(diverge) if tier == "ci"
237
+ out
238
+ end
239
+
240
+ def write(tier)
241
+ out = generate(tier)
242
+ FileUtils.mkdir_p(golden_dir(tier))
243
+ out.each do |path, bytes|
244
+ FileUtils.mkdir_p(File.dirname(path))
245
+ File.binwrite(path, bytes)
246
+ end
247
+ out.size
248
+ end
249
+
250
+ # Compare fresh output with the committed goldens. Returns problem strings.
251
+ def verify(tier)
252
+ problems = []
253
+ fresh = generate(tier)
254
+ fresh.each do |path, bytes|
255
+ rel = path.delete_prefix("#{ROOT}/")
256
+ next problems << "missing golden #{rel}" unless File.exist?(path)
257
+
258
+ committed = File.binread(path).force_encoding(bytes.encoding)
259
+ problems << "golden differs #{rel}#{first_difference(committed, bytes)}" unless committed == bytes
260
+ end
261
+ problems.concat(stale(tier, fresh.keys))
262
+ end
263
+
264
+ def stale(tier, expected)
265
+ Dir.glob(File.join(golden_dir(tier), "**", "*.{json,ttf}")).reject { |p| expected.include?(p) }
266
+ .map { |p| "stale golden #{p.delete_prefix("#{ROOT}/")} (no manifest font)" }
267
+ end
268
+
269
+ def first_difference(committed, fresh)
270
+ return " (binary, #{committed.bytesize} vs #{fresh.bytesize} bytes)" unless fresh.encoding == Encoding::UTF_8
271
+
272
+ a = committed.force_encoding("UTF-8").lines
273
+ b = fresh.lines
274
+ n = a.zip(b).index { |x, y| x != y }
275
+ n ? " at line #{n + 1}: committed #{a[n]&.strip.inspect} fresh #{b[n]&.strip.inspect}" : ""
276
+ end
277
+ end
278
+
279
+ if $PROGRAM_NAME == __FILE__
280
+ tier = ARGV.fetch(0, "ci")
281
+ puts "wrote #{CorpusGoldens.write(tier)} goldens (tier #{tier})"
282
+ end
@@ -0,0 +1,254 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Malformed-input goldens (fontist/fontisan#156, card 0e).
4
+ #
5
+ # For each committed malformed font (manifest entries with "malformed_kind")
6
+ # this records what the Ruby reference does on each operation: outcome, error
7
+ # class, normalised message (paths and numbers masked), position/context
8
+ # markers and the point where it fails. Read-type operations go through
9
+ # Fontisan::Reference; the per-step probe (load, name, table reads) uses
10
+ # FontLoader directly because the facade has no per-step operation.
11
+ #
12
+ # ruby scripts/corpus/generate_malformed_goldens.rb [--check]
13
+ #
14
+ # Writes corpus/goldens/malformed/<id>.json. --check compares instead of writing
15
+ # (used by rake corpus:malformed:verify).
16
+ $LOAD_PATH.unshift(File.expand_path("../../lib", __dir__))
17
+ require "fontisan"
18
+ require "digest"
19
+ require "fileutils"
20
+ require "json"
21
+ require "timeout"
22
+
23
+ module MalformedGoldens
24
+ ROOT = File.expand_path("../..", __dir__)
25
+ MANIFEST = File.join(ROOT, "corpus", "manifest.json")
26
+ DERIVED_DIR = File.join(ROOT, "corpus", "derived")
27
+ GOLDEN_DIR = File.join(ROOT, "corpus", "goldens", "malformed")
28
+
29
+ # The Ruby reference itself never returns on a few fuzz-found inputs (a cmap
30
+ # format 12 group spanning the u32 range, a CFF2 INDEX of 2^32 entries). An
31
+ # operation that runs past this many seconds is recorded as outcome "timeout";
32
+ # the tiers are then only required to finish without crashing (see
33
+ # {.matches?}). Real fonts take milliseconds.
34
+ OP_TIMEOUT = 5
35
+
36
+ COMBOS = [[:metadata, true], [:metadata, false],
37
+ [:full, true], [:full, false]].freeze
38
+
39
+ # Steps tried in order after a load; the first failing one is the failure
40
+ # point. table_cmap is full-mode only (metadata gates it out by design).
41
+ STEPS = %w[load name table_head table_cmap].freeze
42
+
43
+ PATH_RE = %r{(?<![\w/])(?:[A-Za-z]:)?(?:\.{0,2}/)(?:[\w.~+-]+/)*[\w.~+-]*|
44
+ (?:[\w.~+-]+/)*[\w.~+-]+\.(?:ttf|otf|ttc|otc|woff2?|dfont|pf[ab]|json|rb)\b}x
45
+ HEX_RE = /0x\h+/i
46
+ NUM_RE = /\d+/
47
+
48
+ MARKER_RES = [
49
+ /end of file reached/i,
50
+ /\boffset\s*[:=]?\s*\d+/i,
51
+ /\bposition\s*[:=]?\s*\d+/i,
52
+ /\bbyte\s+\d+/i,
53
+ /\bglyph\s+\d+/i,
54
+ /\bindex\s+\d+/i,
55
+ /\btable\s+['"][^'"]{1,4}['"]/i,
56
+ ].freeze
57
+
58
+ module_function
59
+
60
+ def entries
61
+ JSON.parse(File.read(MANIFEST)).fetch("fonts")
62
+ .select { |e| e["malformed_kind"] }.sort_by { |e| e["id"] }
63
+ end
64
+
65
+ # Mask paths first (they contain digits), then hex, then decimal numbers.
66
+ def normalize_message(message)
67
+ message.to_s.gsub(PATH_RE, "<PATH>").gsub(HEX_RE, "<HEX>").gsub(NUM_RE, "<N>")
68
+ end
69
+
70
+ def markers(*messages)
71
+ messages.compact.flat_map do |m|
72
+ MARKER_RES.flat_map { |re| m.scan(re).map { |s| normalize_message(s.downcase) } }
73
+ end.uniq.sort
74
+ end
75
+
76
+ def causes(exception)
77
+ out = []
78
+ cause = exception.cause
79
+ while cause && out.size < 5
80
+ out << { class: cause.class.name, message: normalize_message(cause.message) }
81
+ cause = cause.cause
82
+ end
83
+ out
84
+ end
85
+
86
+ def error_record(exception)
87
+ {
88
+ outcome: "error",
89
+ error_class: exception.class.name,
90
+ message: normalize_message(exception.message),
91
+ markers: markers(exception.message, *causes(exception).map { |c| c[:message] }),
92
+ causes: causes(exception),
93
+ }
94
+ end
95
+
96
+ def attempt(&)
97
+ { outcome: "ok" }.merge(Timeout.timeout(OP_TIMEOUT, &) || {})
98
+ rescue Timeout::Error
99
+ { outcome: "timeout" }
100
+ rescue StandardError => e
101
+ error_record(e)
102
+ end
103
+
104
+ def digest(data)
105
+ Digest::SHA256.hexdigest(Fontisan::Reference.canonical_json(data))
106
+ end
107
+
108
+ def probe_step(step, path, mode, lazy, font)
109
+ case step
110
+ when "load"
111
+ attempt { { format: Fontisan::FontLoader.detect_format(path).to_s } }
112
+ when "name"
113
+ attempt { { family: font.table("name").english_name(1) } }
114
+ when "table_head"
115
+ attempt { { units_per_em: font.table("head").units_per_em } }
116
+ when "table_cmap"
117
+ attempt { { mappings: font.table("cmap").unicode_mappings.size } }
118
+ end
119
+ end
120
+
121
+ # load, name, table reads, read: per (mode, lazy).
122
+ def combo(path, mode, lazy)
123
+ steps = {}
124
+ font = nil
125
+ steps["load"] = attempt do
126
+ font = Fontisan::FontLoader.load(path, mode: mode, lazy: lazy)
127
+ { format: font.class.name }
128
+ end
129
+ (STEPS - ["load"]).each do |step|
130
+ next if step == "table_cmap" && mode == :metadata
131
+
132
+ steps[step] = if font
133
+ probe_step(step, path, mode, lazy, font)
134
+ else
135
+ { outcome: "skipped", reason: "load failed" }
136
+ end
137
+ end
138
+ font.close if font.respond_to?(:close)
139
+ steps["read"] = attempt do
140
+ faces = Fontisan::Reference.read(path, mode: mode, lazy: lazy)
141
+ { faces: faces.size, digest: digest(faces) }
142
+ end
143
+ failing = STEPS.find { |s| steps[s] && steps[s][:outcome] == "error" }
144
+ { failure_point: failing, ops: steps }
145
+ end
146
+
147
+ def audit(path)
148
+ attempt do
149
+ reports = Fontisan::Reference.audit(path)
150
+ { faces: reports.size, digest: digest(reports) }
151
+ end
152
+ end
153
+
154
+ def scan(path)
155
+ rows = Timeout.timeout(OP_TIMEOUT) { Fontisan::Reference.scan([path]) }
156
+ rows.each { |r| r["path"] = File.basename(r["path"]) }
157
+ {
158
+ outcome: "ok",
159
+ rows: rows.size,
160
+ digest: digest(rows),
161
+ summary: rows.map { |r| scan_summary(r) },
162
+ }
163
+ rescue Timeout::Error
164
+ { outcome: "timeout" }
165
+ rescue StandardError => e
166
+ error_record(e)
167
+ end
168
+
169
+ def scan_summary(row)
170
+ errors = row["errors"].transform_values do |err|
171
+ next nil unless err
172
+
173
+ { class: err["class"], message: normalize_message(err["message"]),
174
+ markers: markers(err["message"]),
175
+ cause: err["cause"] && { class: err["cause"]["class"],
176
+ message: normalize_message(err["cause"]["message"]) } }
177
+ end
178
+ { format: row["format"], num_faces: row["num_faces"],
179
+ validation_status: row["validation"] && row["validation"]["status"],
180
+ errors: errors }
181
+ end
182
+
183
+ def golden(entry, dir: DERIVED_DIR)
184
+ path = File.join(dir, entry.fetch("file"))
185
+ {
186
+ id: entry["id"], kind: entry["malformed_kind"], file: entry["file"],
187
+ file_sha256: Digest::SHA256.file(path).hexdigest,
188
+ comparison: "exact",
189
+ combos: COMBOS.to_h { |m, l| ["#{m}/lazy=#{l}", combo(path, m, l)] },
190
+ audit: audit(path),
191
+ scan: scan(path)
192
+ }
193
+ end
194
+
195
+ def render(entry, dir: DERIVED_DIR)
196
+ Fontisan::Reference.canonical_json(golden(entry, dir: dir))
197
+ end
198
+
199
+ def golden_path(entry)
200
+ File.join(GOLDEN_DIR, "#{entry['id']}.json")
201
+ end
202
+
203
+ # Whether +actual+ (parsed render) equals +want+ (parsed golden). A node the
204
+ # reference timed out on matches whatever the tier returned, because that
205
+ # operation has no reference result; a crash would have killed the process.
206
+ def matches?(want, actual)
207
+ case want
208
+ when Hash
209
+ return true if want["outcome"] == "timeout" && actual.is_a?(Hash)
210
+
211
+ actual.is_a?(Hash) && want.keys.sort == actual.keys.sort &&
212
+ want.all? { |k, v| matches?(v, actual[k]) }
213
+ when Array
214
+ actual.is_a?(Array) && want.size == actual.size &&
215
+ want.zip(actual).all? { |w, a| matches?(w, a) }
216
+ else
217
+ want == actual
218
+ end
219
+ end
220
+
221
+ # @return [Array<String>] problems; empty when the committed goldens match
222
+ def verify(dir: DERIVED_DIR)
223
+ problems = []
224
+ entries.each do |e|
225
+ gp = golden_path(e)
226
+ unless File.exist?(gp)
227
+ problems << "#{e['id']}: golden missing"
228
+ next
229
+ end
230
+ actual = render(e, dir: dir)
231
+ same = File.read(gp) == actual || matches?(JSON.parse(File.read(gp)), JSON.parse(actual))
232
+ problems << "#{e['id']}: golden differs from current behaviour" unless same
233
+ end
234
+ extra = Dir[File.join(GOLDEN_DIR, "*.json")].map { |f| File.basename(f, ".json") } - entries.map { |e| e["id"] }
235
+ problems.concat(extra.map { |x| "#{x}: golden has no manifest entry" })
236
+ problems
237
+ end
238
+
239
+ def regenerate
240
+ FileUtils.mkdir_p(GOLDEN_DIR)
241
+ entries.each { |e| File.write(golden_path(e), render(e)) }
242
+ entries.size
243
+ end
244
+ end
245
+
246
+ if $PROGRAM_NAME == __FILE__
247
+ if ARGV.include?("--check")
248
+ problems = MalformedGoldens.verify
249
+ abort problems.join("\n") unless problems.empty?
250
+ puts "malformed goldens ok (#{MalformedGoldens.entries.size})"
251
+ else
252
+ puts "wrote #{MalformedGoldens.regenerate} malformed goldens"
253
+ end
254
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ $LOAD_PATH.unshift(File.expand_path("../../lib", __dir__))
4
+ require "fontisan"
5
+ require "json"
6
+ require "fileutils"
7
+
8
+ # Scan goldens for the reference corpus (fontist/fontisan#156 card 0f).
9
+ #
10
+ # One JSON file per manifest row (malformed included: scan never raises),
11
+ # written from Fontisan::Reference.scan with the path reduced to a basename:
12
+ #
13
+ # corpus/goldens/scan/<id>.json ci tier, committed
14
+ # corpus/goldens/scan-large/<id>.json large tier, generated in CI (nightly)
15
+ module ScanGoldens
16
+ ROOT = File.expand_path("../..", __dir__)
17
+ MANIFEST = File.join(ROOT, "corpus", "manifest.json")
18
+ GOLDEN_DIRS = { "ci" => "scan", "large" => "scan-large" }.freeze
19
+ SCHEMA = 1
20
+
21
+ module_function
22
+
23
+ def manifest
24
+ JSON.parse(File.read(MANIFEST)).fetch("fonts")
25
+ end
26
+
27
+ def entries(tier)
28
+ manifest.select { |f| f["tier"] == tier }.sort_by { |f| f["id"] }
29
+ end
30
+
31
+ def font_path(entry)
32
+ dir = entry["type"] == "derived" ? "derived" : File.join("fonts", entry["id"])
33
+ File.join(ROOT, "corpus", dir, entry["file"])
34
+ end
35
+
36
+ def golden_dir(tier)
37
+ File.join(ROOT, "corpus", "goldens", GOLDEN_DIRS.fetch(tier))
38
+ end
39
+
40
+ def golden_path(tier, id)
41
+ File.join(golden_dir(tier), "#{id}.json")
42
+ end
43
+
44
+ # Rows for one font as Reference.scan returns them, paths reduced to basenames.
45
+ def rows(path)
46
+ Fontisan::Reference.scan([path]).map { |row| row.merge("path" => File.basename(row["path"])) }
47
+ end
48
+
49
+ def golden(entry)
50
+ Fontisan::Reference.canonical_json(
51
+ "schema" => SCHEMA,
52
+ "id" => entry["id"],
53
+ "file" => entry["file"],
54
+ "tier" => entry["tier"],
55
+ "font_sha256" => entry.fetch("sha256"),
56
+ "comparison_class" => "exact",
57
+ "rows" => rows(font_path(entry)),
58
+ )
59
+ end
60
+
61
+ def generate(tier)
62
+ bad = %w[FONTISAN_MODE FONTISAN_LAZY].select { |k| ENV.key?(k) }
63
+ raise "unset #{bad.join(', ')} before generating goldens" unless bad.empty?
64
+
65
+ entries(tier).to_h { |e| [golden_path(tier, e["id"]), golden(e)] }
66
+ end
67
+
68
+ def write(tier)
69
+ out = generate(tier)
70
+ FileUtils.mkdir_p(golden_dir(tier))
71
+ out.each { |path, bytes| File.binwrite(path, bytes) }
72
+ out.size
73
+ end
74
+
75
+ # Compare fresh output with the committed goldens. Returns problem strings.
76
+ def verify(tier)
77
+ fresh = generate(tier)
78
+ problems = fresh.filter_map do |path, bytes|
79
+ rel = path.delete_prefix("#{ROOT}/")
80
+ if !File.exist?(path)
81
+ "missing golden #{rel}"
82
+ elsif File.binread(path).force_encoding(bytes.encoding) != bytes
83
+ "golden differs #{rel}"
84
+ end
85
+ end
86
+ problems + stale(tier, fresh.keys)
87
+ end
88
+
89
+ def stale(tier, expected)
90
+ Dir.glob(File.join(golden_dir(tier), "*.json")).reject { |p| expected.include?(p) }
91
+ .map { |p| "stale golden #{p.delete_prefix("#{ROOT}/")} (no manifest font)" }
92
+ end
93
+ end
94
+
95
+ if $PROGRAM_NAME == __FILE__
96
+ tier = ARGV.fetch(0, "ci")
97
+ puts "wrote #{ScanGoldens.write(tier)} scan goldens (tier #{tier})"
98
+ end