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,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
|