fontisan 0.4.46 → 0.4.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. checksums.yaml +4 -4
  2. data/BENCHMARKS.md +56 -0
  3. data/CHANGELOG.md +12 -0
  4. data/Cargo.lock +550 -0
  5. data/Cargo.toml +12 -0
  6. data/Gemfile +10 -0
  7. data/README.adoc +52 -0
  8. data/Rakefile +5 -1
  9. data/benchmark/baseline.json +5 -0
  10. data/benchmark/ci_gate.rb +217 -0
  11. data/benchmark/corpus/harness.rb +175 -0
  12. data/benchmark/corpus/ops.rb +150 -0
  13. data/benchmark/corpus/run.rb +182 -0
  14. data/benchmark/corpus/scan_child.rb +8 -0
  15. data/benchmark/corpus/tiers.rb +176 -0
  16. data/benchmark/tier_baseline.json +12 -0
  17. data/docs/.vitepress/config.ts +2 -1
  18. data/docs/guide/engines.md +117 -0
  19. data/docs/reference-facade.md +130 -0
  20. data/ext/fontisan_cdylib/Cargo.lock +313 -0
  21. data/ext/fontisan_cdylib/Cargo.toml +18 -0
  22. data/ext/fontisan_cdylib/build.rs +22 -0
  23. data/ext/fontisan_cdylib/extconf.rb +36 -0
  24. data/ext/fontisan_cdylib/src/lib.rs +13 -0
  25. data/ext/fontisan_native/Cargo.toml +21 -0
  26. data/ext/fontisan_native/build.rs +22 -0
  27. data/ext/fontisan_native/clippy.toml +10 -0
  28. data/ext/fontisan_native/extconf.rb +59 -0
  29. data/ext/fontisan_native/src/lib.rs +463 -0
  30. data/lib/fontisan/audit/checks/cmap_check.rb +7 -0
  31. data/lib/fontisan/audit/checks/hinting_check.rb +1 -1
  32. data/lib/fontisan/audit/checks/maxp_check.rb +1 -1
  33. data/lib/fontisan/audit/checks/opentype_conformance_check.rb +5 -2
  34. data/lib/fontisan/base_collection.rb +30 -0
  35. data/lib/fontisan/byte_ops.rb +219 -0
  36. data/lib/fontisan/cli.rb +5 -5
  37. data/lib/fontisan/commands/audit_command.rb +38 -43
  38. data/lib/fontisan/commands/convert_command.rb +53 -0
  39. data/lib/fontisan/commands/instance_command.rb +75 -0
  40. data/lib/fontisan/commands/subset_command.rb +43 -0
  41. data/lib/fontisan/commands/unpack_command.rb +56 -0
  42. data/lib/fontisan/converters/cff_table_builder.rb +1 -1
  43. data/lib/fontisan/converters/collection_converter.rb +13 -5
  44. data/lib/fontisan/converters/format_converter.rb +1 -1
  45. data/lib/fontisan/converters/woff2_encoder.rb +8 -6
  46. data/lib/fontisan/converters/woff_writer.rb +6 -5
  47. data/lib/fontisan/engine.rb +350 -0
  48. data/lib/fontisan/ffi/client.rb +121 -0
  49. data/lib/fontisan/ffi.rb +343 -0
  50. data/lib/fontisan/ffi_platform.rb +24 -0
  51. data/lib/fontisan/font_loader.rb +5 -1
  52. data/lib/fontisan/font_writer.rb +13 -1
  53. data/lib/fontisan/native.rb +231 -0
  54. data/lib/fontisan/pipeline/transformation_pipeline.rb +10 -8
  55. data/lib/fontisan/pipeline.rb +1 -1
  56. data/lib/fontisan/reference/table_digests.rb +251 -0
  57. data/lib/fontisan/reference.rb +619 -0
  58. data/lib/fontisan/scan.rb +372 -0
  59. data/lib/fontisan/sfnt_directory.rb +41 -0
  60. data/lib/fontisan/sfnt_font.rb +16 -6
  61. data/lib/fontisan/subset/builder.rb +2 -1
  62. data/lib/fontisan/tables/cff/charstring.rb +16 -0
  63. data/lib/fontisan/tables/cff.rb +15 -5
  64. data/lib/fontisan/tables/cmap.rb +11 -1
  65. data/lib/fontisan/tables/glyf.rb +4 -6
  66. data/lib/fontisan/tables/gpos.rb +7 -0
  67. data/lib/fontisan/tables/gsub.rb +7 -0
  68. data/lib/fontisan/tables/layout_common.rb +172 -0
  69. data/lib/fontisan/tables/name.rb +10 -4
  70. data/lib/fontisan/utilities/brotli_wrapper.rb +39 -4
  71. data/lib/fontisan/validators/opentype_validator.rb +15 -3
  72. data/lib/fontisan/variable/instancer.rb +23 -192
  73. data/lib/fontisan/variable/static_font_builder.rb +4 -1
  74. data/lib/fontisan/variable/table_updater.rb +4 -2
  75. data/lib/fontisan/variation/instance_generator.rb +3 -149
  76. data/lib/fontisan/variation/instancing/axis_space.rb +136 -0
  77. data/lib/fontisan/variation/instancing/glyph_outline.rb +204 -0
  78. data/lib/fontisan/variation/instancing/gvar_reader.rb +146 -0
  79. data/lib/fontisan/variation/instancing/item_variation_store.rb +92 -0
  80. data/lib/fontisan/variation/instancing/name_rewriter.rb +90 -0
  81. data/lib/fontisan/variation/instancing/true_type_instancer.rb +326 -0
  82. data/lib/fontisan/variation/instancing.rb +20 -0
  83. data/lib/fontisan/variation/variable_svg_generator.rb +19 -1
  84. data/lib/fontisan/variation.rb +1 -0
  85. data/lib/fontisan/version.rb +1 -1
  86. data/lib/fontisan/woff2/collection_decoder.rb +15 -1
  87. data/lib/fontisan/woff2/collection_encoder.rb +29 -6
  88. data/lib/fontisan/woff2/encoder_rules.rb +12 -4
  89. data/lib/fontisan/woff2/glyf_loca_reconstruct.rb +1 -1
  90. data/lib/fontisan/woff2_collection.rb +7 -5
  91. data/lib/fontisan/woff2_font.rb +12 -3
  92. data/lib/fontisan/woff_container.rb +83 -0
  93. data/lib/fontisan/woff_font.rb +24 -20
  94. data/lib/fontisan.rb +24 -1
  95. data/rakelib/bench.rake +9 -0
  96. data/rakelib/cdylib.rake +39 -0
  97. data/rakelib/corpus.rake +272 -0
  98. data/rakelib/corpus_convert.rake +43 -0
  99. data/rakelib/corpus_goldens.rake +22 -0
  100. data/rakelib/corpus_malformed.rake +20 -0
  101. data/rakelib/corpus_scan.rake +24 -0
  102. data/rakelib/java.rake +61 -0
  103. data/rakelib/native.rake +43 -0
  104. data/scripts/build-woff2.sh +57 -0
  105. data/scripts/corpus/generate_convert_goldens.rb +478 -0
  106. data/scripts/corpus/generate_goldens.rb +282 -0
  107. data/scripts/corpus/generate_malformed_goldens.rb +254 -0
  108. data/scripts/corpus/generate_scan_goldens.rb +98 -0
  109. data/scripts/tier_fallback.rb +219 -0
  110. data/scripts/tier_smoke.rb +64 -0
  111. metadata +75 -3
@@ -6,7 +6,7 @@ module Fontisan
6
6
  module Pipeline
7
7
  autoload :FormatDetector, "fontisan/pipeline/format_detector"
8
8
  autoload :OutputWriter, "fontisan/pipeline/output_writer"
9
- autoload :Strategies, "fontisan/pipeline/strategies/base_strategy"
9
+ autoload :Strategies, "fontisan/pipeline/strategies"
10
10
  autoload :TransformationPipeline,
11
11
  "fontisan/pipeline/transformation_pipeline"
12
12
  autoload :VariationResolver, "fontisan/pipeline/variation_resolver"
@@ -0,0 +1,251 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Fontisan
6
+ module Reference
7
+ # Canonical digests of parsed table content, computed from the raw table
8
+ # bytes with the OpenType layouts written out here (not through the
9
+ # table classes), so another implementation can reproduce them from the
10
+ # same bytes. Every function returns JSON-ready data with String keys;
11
+ # Fixed 16.16 values are raw integers. A structure that runs past the
12
+ # end of its table raises {Truncated}.
13
+ #
14
+ # See docs/reference-facade.md ("Parsed table digests").
15
+ module TableDigests
16
+ class Truncated < StandardError; end
17
+
18
+ module_function
19
+
20
+ # SHA-256 over the concatenation of the 32-byte digests of each part.
21
+ def digest_of_parts(parts)
22
+ Digest::SHA256.hexdigest(parts.map { |p| Digest::SHA256.digest(p) }.join)
23
+ end
24
+
25
+ def sha(bytes)
26
+ Digest::SHA256.hexdigest(bytes)
27
+ end
28
+
29
+ def slice(data, offset, length)
30
+ raise Truncated, "#{length} bytes at #{offset} past #{data.bytesize}" if
31
+ offset.negative? || length.negative? || offset + length > data.bytesize
32
+
33
+ data.byteslice(offset, length)
34
+ end
35
+
36
+ def u8(data, off) = slice(data, off, 1).unpack1("C")
37
+ def u16(data, off) = slice(data, off, 2).unpack1("n")
38
+ def u32(data, off) = slice(data, off, 4).unpack1("N")
39
+ def i32(data, off) = slice(data, off, 4).unpack1("l>")
40
+
41
+ # gvar: header fields, the shared tuples, and one digest over every
42
+ # glyph's GlyphVariationData byte range (located by the offset array).
43
+ def gvar(data)
44
+ axis_count = u16(data, 4)
45
+ shared_count = u16(data, 6)
46
+ shared_offset = u32(data, 8)
47
+ glyph_count = u16(data, 12)
48
+ flags = u16(data, 14)
49
+ array_offset = u32(data, 16)
50
+ long = flags.anybits?(1)
51
+ step = long ? 4 : 2
52
+ offsets = Array.new(glyph_count + 1) do |i|
53
+ value = long ? u32(data, 20 + (i * step)) : u16(data, 20 + (i * step)) * 2
54
+ array_offset + value
55
+ end
56
+ parts = offsets.each_cons(2).map { |a, b| slice(data, a, b - a) }
57
+ {
58
+ "axis_count" => axis_count,
59
+ "flags" => flags,
60
+ "glyph_count" => glyph_count,
61
+ "glyphs_with_data" => parts.count { |p| !p.empty? },
62
+ "glyphs_digest" => digest_of_parts(parts),
63
+ "shared_tuple_count" => shared_count,
64
+ "shared_tuples_sha256" => sha(slice(data, shared_offset, shared_count * axis_count * 2)),
65
+ "version" => [u16(data, 0), u16(data, 2)],
66
+ }
67
+ end
68
+
69
+ # HVAR / VVAR share a layout: header, ItemVariationStore, and up to
70
+ # three DeltaSetIndexMaps.
71
+ def hvar(data)
72
+ maps = { "advance_mapping" => 8, "lsb_mapping" => 12, "rsb_mapping" => 16 }
73
+ result = { "version" => [u16(data, 0), u16(data, 2)] }
74
+ result["item_variation_store"] = item_variation_store(data, u32(data, 4))
75
+ maps.each do |name, at|
76
+ offset = u32(data, at)
77
+ result[name] = offset.zero? ? nil : delta_set_index_map(data, offset)
78
+ end
79
+ result
80
+ end
81
+
82
+ # MVAR: value records (tag, outer, inner) and the ItemVariationStore
83
+ # (Offset16 from the table start).
84
+ def mvar(data)
85
+ record_size = u16(data, 6)
86
+ count = u16(data, 8)
87
+ store_offset = u16(data, 10)
88
+ {
89
+ "version" => [u16(data, 0), u16(data, 2)],
90
+ "value_records" => Array.new(count) do |i|
91
+ at = 12 + (i * record_size)
92
+ { "tag" => slice(data, at, 4), "outer" => u16(data, at + 4), "inner" => u16(data, at + 6) }
93
+ end,
94
+ "item_variation_store" => store_offset.zero? ? nil : item_variation_store(data, store_offset),
95
+ }
96
+ end
97
+
98
+ # Exact byte ranges: header+offsets, region list, each
99
+ # ItemVariationData. Digests are per range, then one over all.
100
+ def item_variation_store(data, base)
101
+ region_offset = u32(data, base + 2)
102
+ data_count = u16(data, base + 6)
103
+ region_axes = u16(data, base + region_offset)
104
+ region_count = u16(data, base + region_offset + 2)
105
+ region_len = 4 + (region_count * region_axes * 6)
106
+ subtables = Array.new(data_count) do |i|
107
+ start = base + u32(data, base + 8 + (i * 4))
108
+ items = u16(data, start)
109
+ words = u16(data, start + 2)
110
+ indexes = u16(data, start + 4)
111
+ long = words.anybits?(0x8000)
112
+ word_count = words & 0x7FFF
113
+ row = long ? (word_count * 4) + ((indexes - word_count) * 2) : word_count + indexes
114
+ slice(data, start, 6 + (indexes * 2) + (items * row))
115
+ end
116
+ {
117
+ "region_count" => region_count,
118
+ "item_data_count" => data_count,
119
+ "regions_sha256" => sha(slice(data, base + region_offset, region_len)),
120
+ "data_digest" => digest_of_parts(subtables),
121
+ "data_sha256" => subtables.map { |s| sha(s) },
122
+ }
123
+ end
124
+
125
+ # DeltaSetIndexMap: format 0 (u16 count) or 1 (u32 count).
126
+ def delta_set_index_map(data, offset)
127
+ format = u8(data, offset)
128
+ entry_format = u8(data, offset + 1)
129
+ count = format.zero? ? u16(data, offset + 2) : u32(data, offset + 2)
130
+ header = format.zero? ? 4 : 6
131
+ size = ((entry_format >> 4) & 3) + 1
132
+ {
133
+ "format" => format,
134
+ "entry_format" => entry_format,
135
+ "map_count" => count,
136
+ "sha256" => sha(slice(data, offset, header + (count * size))),
137
+ }
138
+ end
139
+
140
+ # STAT: design axes and every axis value record, Fixed as raw ints.
141
+ def stat(data)
142
+ axis_size = u16(data, 4)
143
+ axis_count = u16(data, 6)
144
+ axes_offset = u32(data, 8)
145
+ value_count = u16(data, 12)
146
+ values_offset = u32(data, 14)
147
+ minor = u16(data, 2)
148
+ {
149
+ "version" => [u16(data, 0), minor],
150
+ "elided_fallback_name_id" => minor >= 1 ? u16(data, 18) : nil,
151
+ "axes" => Array.new(axis_count) do |i|
152
+ at = axes_offset + (i * axis_size)
153
+ { "tag" => slice(data, at, 4), "name_id" => u16(data, at + 4), "ordering" => u16(data, at + 6) }
154
+ end,
155
+ "axis_values" => Array.new(value_count) do |i|
156
+ axis_value(data, values_offset + u16(data, values_offset + (i * 2)))
157
+ end,
158
+ }
159
+ end
160
+
161
+ def axis_value(data, pos)
162
+ format = u16(data, pos)
163
+ case format
164
+ when 1
165
+ fixed_value(data, pos, format, 8, %w[value])
166
+ when 2
167
+ fixed_value(data, pos, format, 8, %w[nominal range_min range_max])
168
+ when 3
169
+ fixed_value(data, pos, format, 8, %w[value linked_value])
170
+ when 4
171
+ n = u16(data, pos + 2)
172
+ { "format" => 4, "flags" => u16(data, pos + 4), "value_name_id" => u16(data, pos + 6),
173
+ "axis_values" => Array.new(n) do |j|
174
+ { "axis_index" => u16(data, pos + 8 + (j * 6)), "value" => i32(data, pos + 10 + (j * 6)) }
175
+ end }
176
+ else
177
+ { "format" => format, "unsupported" => true }
178
+ end
179
+ end
180
+
181
+ def fixed_value(data, pos, format, first, names)
182
+ out = { "format" => format, "axis_index" => u16(data, pos + 2), "flags" => u16(data, pos + 4),
183
+ "value_name_id" => u16(data, pos + 6) }
184
+ names.each_with_index { |n, i| out["#{n}_raw"] = i32(data, pos + first + (i * 4)) }
185
+ out
186
+ end
187
+
188
+ # CFF2: CharStrings INDEX located through Top DICT operator 17.
189
+ # Returns the per-glyph charstring byte slices.
190
+ def cff2_charstrings(data)
191
+ header_size = u8(data, 2)
192
+ top_length = u16(data, 3)
193
+ offset = dict_operand(slice(data, header_size, top_length), 17)
194
+ return [] unless offset
195
+
196
+ count = u32(data, offset)
197
+ return [] if count.zero?
198
+
199
+ off_size = u8(data, offset + 4)
200
+ # The offset array must fit in the table; a fuzzed count of 2^32
201
+ # would otherwise build billions of slices (same bound as the Rust
202
+ # engine's INDEX reader).
203
+ raise Truncated, "invalid CFF2 offSize #{off_size}" unless (1..4).cover?(off_size)
204
+
205
+ slice(data, offset + 5, (count + 1) * off_size)
206
+ read = ->(i) { slice(data, offset + 5 + (i * off_size), off_size).bytes.inject(0) { |a, b| (a << 8) | b } }
207
+ base = offset + 5 + ((count + 1) * off_size) - 1
208
+ Array.new(count) { |i| slice(data, base + read.call(i), read.call(i + 1) - read.call(i)) }
209
+ end
210
+
211
+ # The last operand before `operator` in a DICT, or nil. Only the
212
+ # operand encodings a CharStrings offset can use are decoded; real
213
+ # numbers are skipped (pushed as 0).
214
+ def dict_operand(dict, operator)
215
+ stack = []
216
+ i = 0
217
+ while i < dict.bytesize
218
+ lead = dict.getbyte(i)
219
+ if lead <= 21
220
+ op = lead == 12 ? (i += 1) && (1200 + dict.getbyte(i)) : lead
221
+ return stack.last if op == operator
222
+
223
+ stack.clear
224
+ else
225
+ value, i = dict_number(dict, i, lead)
226
+ stack << value
227
+ end
228
+ i += 1
229
+ end
230
+ nil
231
+ end
232
+
233
+ # Decode the operand starting at `i`; returns [value, index of its
234
+ # last byte].
235
+ def dict_number(dict, idx, lead)
236
+ case lead
237
+ when 28 then [dict.byteslice(idx + 1, 2).unpack1("s>"), idx + 2]
238
+ when 29 then [dict.byteslice(idx + 1, 4).unpack1("l>"), idx + 4]
239
+ when 30
240
+ idx += 1
241
+ idx += 1 until idx >= dict.bytesize || dict.getbyte(idx) >> 4 == 0xF || dict.getbyte(idx) & 0xF == 0xF
242
+ [0, idx]
243
+ when 32..246 then [lead - 139, idx]
244
+ when 247..250 then [((lead - 247) * 256) + dict.getbyte(idx + 1) + 108, idx + 1]
245
+ when 251..254 then [-((lead - 251) * 256) - dict.getbyte(idx + 1) - 108, idx + 1]
246
+ else [0, idx]
247
+ end
248
+ end
249
+ end
250
+ end
251
+ end