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.
- 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/pipeline/transformation_pipeline.rb +10 -8
- data/lib/fontisan/pipeline.rb +1 -1
- 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/glyf.rb +4 -6
- 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 +15 -3
- data/lib/fontisan/variable/instancer.rb +23 -192
- data/lib/fontisan/variable/static_font_builder.rb +4 -1
- data/lib/fontisan/variable/table_updater.rb +4 -2
- data/lib/fontisan/variation/instance_generator.rb +3 -149
- data/lib/fontisan/variation/instancing/axis_space.rb +136 -0
- data/lib/fontisan/variation/instancing/glyph_outline.rb +204 -0
- data/lib/fontisan/variation/instancing/gvar_reader.rb +146 -0
- data/lib/fontisan/variation/instancing/item_variation_store.rb +92 -0
- data/lib/fontisan/variation/instancing/name_rewriter.rb +90 -0
- data/lib/fontisan/variation/instancing/true_type_instancer.rb +326 -0
- data/lib/fontisan/variation/instancing.rb +20 -0
- data/lib/fontisan/variation/variable_svg_generator.rb +19 -1
- data/lib/fontisan/variation.rb +1 -0
- 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 +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
|