fontisan 0.4.47 → 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 (99) 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/reference/table_digests.rb +251 -0
  55. data/lib/fontisan/reference.rb +619 -0
  56. data/lib/fontisan/scan.rb +372 -0
  57. data/lib/fontisan/sfnt_directory.rb +41 -0
  58. data/lib/fontisan/sfnt_font.rb +16 -6
  59. data/lib/fontisan/subset/builder.rb +2 -1
  60. data/lib/fontisan/tables/cff/charstring.rb +16 -0
  61. data/lib/fontisan/tables/cff.rb +15 -5
  62. data/lib/fontisan/tables/cmap.rb +11 -1
  63. data/lib/fontisan/tables/gpos.rb +7 -0
  64. data/lib/fontisan/tables/gsub.rb +7 -0
  65. data/lib/fontisan/tables/layout_common.rb +172 -0
  66. data/lib/fontisan/tables/name.rb +10 -4
  67. data/lib/fontisan/utilities/brotli_wrapper.rb +39 -4
  68. data/lib/fontisan/validators/opentype_validator.rb +2 -0
  69. data/lib/fontisan/variable/instancer.rb +1 -0
  70. data/lib/fontisan/variable/static_font_builder.rb +4 -1
  71. data/lib/fontisan/variable/table_updater.rb +4 -2
  72. data/lib/fontisan/variation/instancing/true_type_instancer.rb +14 -6
  73. data/lib/fontisan/version.rb +1 -1
  74. data/lib/fontisan/woff2/collection_decoder.rb +15 -1
  75. data/lib/fontisan/woff2/collection_encoder.rb +29 -6
  76. data/lib/fontisan/woff2/encoder_rules.rb +12 -4
  77. data/lib/fontisan/woff2/glyf_loca_reconstruct.rb +1 -1
  78. data/lib/fontisan/woff2_collection.rb +7 -5
  79. data/lib/fontisan/woff2_font.rb +12 -3
  80. data/lib/fontisan/woff_container.rb +83 -0
  81. data/lib/fontisan/woff_font.rb +24 -20
  82. data/lib/fontisan.rb +24 -1
  83. data/rakelib/bench.rake +9 -0
  84. data/rakelib/cdylib.rake +39 -0
  85. data/rakelib/corpus.rake +272 -0
  86. data/rakelib/corpus_convert.rake +43 -0
  87. data/rakelib/corpus_goldens.rake +22 -0
  88. data/rakelib/corpus_malformed.rake +20 -0
  89. data/rakelib/corpus_scan.rake +24 -0
  90. data/rakelib/java.rake +61 -0
  91. data/rakelib/native.rake +43 -0
  92. data/scripts/build-woff2.sh +57 -0
  93. data/scripts/corpus/generate_convert_goldens.rb +478 -0
  94. data/scripts/corpus/generate_goldens.rb +282 -0
  95. data/scripts/corpus/generate_malformed_goldens.rb +254 -0
  96. data/scripts/corpus/generate_scan_goldens.rb +98 -0
  97. data/scripts/tier_fallback.rb +219 -0
  98. data/scripts/tier_smoke.rb +64 -0
  99. metadata +68 -3
data/rakelib/java.rake ADDED
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The `java`-platform gem (fontisan#156 card 2g). `brotli` is a C extension with
4
+ # no JRuby build, and a gemspec is evaluated when the gem is built, so JRuby
5
+ # gets its own gem: no `brotli`, no `rb_sys`, no magnus extension, a runtime
6
+ # dependency on `ffi`, and the cdylib for every bundled OS and architecture
7
+ # under lib/fontisan/ffi/<platform>/ (see Fontisan::FfiPlatform). WOFF2 there is
8
+ # served by the :ffi tier.
9
+ #
10
+ # rake java:gem # builds the host's cdylib, packs what is staged
11
+ # FONTISAN_JAVA_ALL=1 rake java:gem # CI: the four are already staged (extracted
12
+ # # from the platform gems); fail unless all are
13
+ # rake java:stage # only copies the host's cdylib into its directory
14
+ require "fileutils"
15
+ require "rubygems/package"
16
+ require_relative "../lib/fontisan/ffi_platform"
17
+
18
+ # Builds the java gem's specification from fontisan.gemspec.
19
+ module FontisanJavaGem
20
+ STAGED = "lib/fontisan/ffi/*/*fontisan_cdylib.{so,dylib,dll}"
21
+
22
+ def self.spec
23
+ # load caches its result: copy, or the mutations below reach the source spec.
24
+ base = Gem::Specification.load("fontisan.gemspec").dup
25
+ base.platform = "java"
26
+ base.extensions = []
27
+ base.dependencies.delete_if { |d| %w[brotli rb_sys].include?(d.name) }
28
+ base.add_dependency "ffi", "~> 1.15"
29
+ base.files = base.files.reject { |f| f.start_with?("ext/") } + Dir[STAGED]
30
+ base
31
+ end
32
+
33
+ def self.staged_platforms
34
+ Dir[STAGED].map { |f| File.basename(File.dirname(f)) }.uniq
35
+ end
36
+ end
37
+
38
+ namespace :java do
39
+ desc "Build the host cdylib and copy it to lib/fontisan/ffi/<platform>/"
40
+ task :stage do
41
+ next if ENV["FONTISAN_JAVA_ALL"]
42
+
43
+ dir = Fontisan::FfiPlatform.dir or abort "no java-gem directory for #{RbConfig::CONFIG['host_os']}"
44
+ Rake::Task["cdylib:vendor"].invoke
45
+ dest = File.join("lib/fontisan/ffi", dir)
46
+ FileUtils.mkdir_p(dest)
47
+ FileUtils.cp(Dir["lib/fontisan/ffi/*fontisan_cdylib.{so,dylib,dll}"], dest)
48
+ end
49
+
50
+ desc "Build pkg/fontisan-VERSION-java.gem without brotli, bundling the staged cdylibs"
51
+ task gem: :stage do
52
+ missing = Fontisan::FfiPlatform::DIRS - FontisanJavaGem.staged_platforms
53
+ abort "missing cdylibs for: #{missing.join(', ')}" if ENV["FONTISAN_JAVA_ALL"] && !missing.empty?
54
+ puts "bundling cdylibs for: #{FontisanJavaGem.staged_platforms.join(', ')}; not staged: #{missing.join(', ')}"
55
+
56
+ FileUtils.mkdir_p("pkg")
57
+ built = Gem::Package.build(FontisanJavaGem.spec)
58
+ FileUtils.mv(built, "pkg/")
59
+ puts "pkg/#{built}"
60
+ end
61
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Builds the :native tier (ext/fontisan_native, magnus + rb-sys) into
4
+ # lib/fontisan/native/ with `rake compile`, and the platform gems with
5
+ # `rake native gem` (this machine) or rb-sys-dock / oxidize-rb/actions/cross-gem
6
+ # (see .github/workflows/gem-build.yml). Needs a Rust toolchain: without cargo
7
+ # (or the rb_sys gem) the task is not defined and every other task still loads.
8
+ cargo = begin
9
+ system("cargo", "--version", out: File::NULL, err: File::NULL)
10
+ rescue SystemCallError
11
+ false
12
+ end
13
+
14
+ # A musl cdylib cannot link with a static C runtime, and the native extension
15
+ # is itself a shared object. rb-sys-dock exports the target as RUST_TARGET.
16
+ rust_target = ENV["CARGO_BUILD_TARGET"] || ENV["RUST_TARGET"] || ENV.fetch("RUBY_TARGET", nil)
17
+ if rust_target.to_s.include?("musl") && !ENV.fetch("RUSTFLAGS", "").include?("crt-static")
18
+ ENV["RUSTFLAGS"] = [ENV.fetch("RUSTFLAGS", nil), "-C target-feature=-crt-static"].compact.join(" ")
19
+ end
20
+
21
+ if cargo
22
+ begin
23
+ require "rb_sys/extensiontask"
24
+
25
+ RbSys::ExtensionTask.new("fontisan_native", Gem::Specification.load("fontisan.gemspec")) do |ext|
26
+ ext.ext_dir = "ext/fontisan_native"
27
+ ext.lib_dir = "lib/fontisan/native"
28
+
29
+ # Runs when a platform gem's spec is assembled. The platform gem carries
30
+ # its binaries and builds nothing at install: no extension, no rb_sys, no
31
+ # cdylib sources, and the cdylib built for this target (lib/fontisan/ffi)
32
+ # next to the magnus extension, so the :ffi tier works too.
33
+ ext.cross_compiling do |spec|
34
+ Rake::Task["cdylib:vendor"].invoke
35
+ spec.extensions.clear
36
+ spec.files.reject! { |f| f.start_with?("ext/") }
37
+ spec.files += Dir["lib/fontisan/ffi/*.{so,dylib,dll}"]
38
+ end
39
+ end
40
+ rescue LoadError
41
+ # rb_sys / rake-compiler absent: nothing to compile.
42
+ end
43
+ end
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env bash
2
+ # Build Google's woff2_decompress from a pinned source commit (brotli comes
3
+ # pinned via the woff2 submodule). Used by the corpus to cross-check the
4
+ # WOFF2 collection and, later, to compare WOFF/WOFF2 encode output after
5
+ # decoding (compressed bytes depend on the brotli build, so they are not
6
+ # compared directly).
7
+ #
8
+ # Why `make` and not CMake: upstream's CMakeLists.txt resolves brotli with
9
+ # find_package (the SYSTEM brotli, measured: a CMake build on macOS linked
10
+ # /opt/homebrew brotli), which would silently un-pin brotli. The Makefile
11
+ # compiles the brotli submodule sources into the binary, so the whole decoder
12
+ # is pinned by one commit.
13
+ #
14
+ # Linux / macOS: needs git, make, a C/C++ compiler.
15
+ # Windows: run from an MSYS2 MINGW64 shell with
16
+ # pacman -S --needed git make mingw-w64-x86_64-gcc
17
+ # (upstream google/woff2 has no Windows CI; this path is untested upstream.)
18
+ #
19
+ # Output: prints the absolute path of woff2_decompress on stdout (last line).
20
+ # Override the install dir with WOFF2_DIR.
21
+ set -euo pipefail
22
+
23
+ PIN="7b67e12355fe7da148db5c19d4261f0a498a9537"
24
+ REPO="https://github.com/google/woff2.git"
25
+ root="$(cd "$(dirname "$0")/.." && pwd)"
26
+ dest="${WOFF2_DIR:-$root/corpus/fonts/.tools/woff2}"
27
+ src="$dest/src"
28
+ exe="$dest/bin/woff2_decompress"
29
+ case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) exe="$exe.exe" ;; esac
30
+
31
+ if [ -x "$exe" ] && [ "$(cat "$dest/PIN" 2>/dev/null || true)" = "$PIN make" ]; then
32
+ echo "woff2_decompress already built at pin $PIN" >&2
33
+ echo "$exe"
34
+ exit 0
35
+ fi
36
+
37
+ command -v git >/dev/null || { echo "build-woff2: git is required" >&2; exit 1; }
38
+ command -v make >/dev/null || { echo "build-woff2: make is required" >&2; exit 1; }
39
+
40
+ rm -rf "$dest"
41
+ mkdir -p "$dest/bin"
42
+ git clone --recursive "$REPO" "$src" >&2
43
+ (
44
+ cd "$src"
45
+ git checkout --quiet "$PIN"
46
+ git submodule update --init --recursive >&2
47
+ test "$(git rev-parse HEAD)" = "$PIN"
48
+ )
49
+
50
+ (cd "$src" && make woff2_decompress -j"$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 2)" >&2)
51
+
52
+ built="$src/woff2_decompress"
53
+ [ -f "$built" ] || built="$src/woff2_decompress.exe"
54
+ [ -f "$built" ] || { echo "build-woff2: woff2_decompress not produced" >&2; exit 1; }
55
+ cp "$built" "$exe"
56
+ echo "$PIN make" > "$dest/PIN"
57
+ echo "$exe"
@@ -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