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
@@ -68,6 +68,178 @@ module Fontisan
68
68
  array :lookup_list_indices, type: :uint16,
69
69
  initial_length: -> { lookup_index_count }
70
70
  end
71
+
72
+ # Scripts paired with their feature tags, from one parse of the
73
+ # ScriptList and FeatureList instead of re-reading both per script.
74
+ #
75
+ # Gives exactly the answers of calling `scripts` and then
76
+ # `features(script_tag:)` for each (the first record of a tag decides;
77
+ # any short read yields []), but its total LangSys work is capped at
78
+ # {WORK_PER_BYTE} x the table length, so a table whose scripts each
79
+ # re-walk thousands of overlapping LangSys arrays cannot take minutes.
80
+ # Scripts reached after the budget is gone read as failed ([]), the
81
+ # same outcome as any other bad read. Mirrors the native tiers'
82
+ # `LayoutTable::script_features`.
83
+ class ScriptFeatures
84
+ # Work units per byte of table; a well-formed font spends a small
85
+ # fraction of one.
86
+ WORK_PER_BYTE = 2
87
+
88
+ # A short read or an exhausted budget: the script reads as [].
89
+ class Failed < StandardError; end
90
+
91
+ # @param table [Gsub, Gpos]
92
+ def initialize(table)
93
+ @table = table
94
+ @data = table.table_data
95
+ @budget = @data.bytesize * WORK_PER_BYTE
96
+ end
97
+
98
+ # @return [Hash{String=>Array<String>}] script tag => feature tags,
99
+ # in record order
100
+ def call
101
+ return {} if @table.script_list_offset.zero?
102
+
103
+ list_data = slice_from(@data, @table.script_list_offset - 10)
104
+ return {} if list_data.nil? || list_data.empty?
105
+
106
+ records = ScriptList.read(list_data).script_records
107
+ features = feature_tags
108
+ records.each_with_object({}) do |record, result|
109
+ tag = record.script_tag
110
+ next if result.key?(tag)
111
+
112
+ result[tag] = script_feature_tags(list_data, record.script_offset,
113
+ features)
114
+ end
115
+ rescue StandardError
116
+ {}
117
+ end
118
+
119
+ private
120
+
121
+ # Ruby `String#[start..]`: negative starts count from the end.
122
+ def slice_from(string, start)
123
+ string[start..]
124
+ end
125
+
126
+ # FeatureList tags, or nil when every script must read as [].
127
+ def feature_tags
128
+ return nil if @table.script_list_offset.zero? ||
129
+ @table.feature_list_offset.zero?
130
+
131
+ data = slice_from(@data, @table.feature_list_offset - 10)
132
+ return nil if data.nil? || data.empty?
133
+
134
+ FeatureList.read(data).feature_tags
135
+ rescue StandardError
136
+ nil
137
+ end
138
+
139
+ def script_feature_tags(list_data, offset, features)
140
+ return [] unless features
141
+
142
+ indices = feature_indices(list_data, offset)
143
+ indices.filter_map { |index| features[index] }.uniq
144
+ rescue StandardError
145
+ []
146
+ end
147
+
148
+ # Feature indices of one Script table: its default LangSys and every
149
+ # LangSys record, deduplicated in first-seen order.
150
+ def feature_indices(list_data, offset)
151
+ data = slice_from(list_data, offset)
152
+ return [] if data.nil? || data.empty?
153
+
154
+ default_offset, count = read_header(data)
155
+ spend(count)
156
+ offsets = lang_sys_offsets(data, count)
157
+ collector = IndexCollector.new(data)
158
+ ([default_offset] + offsets).each do |lang_offset|
159
+ next if lang_offset.zero? || lang_offset >= data.bytesize
160
+
161
+ collector.add(lang_offset) { |units| spend(units) }
162
+ end
163
+ collector.indices
164
+ end
165
+
166
+ def read_header(data)
167
+ raise Failed if data.bytesize < 4
168
+
169
+ data.unpack("nn")
170
+ end
171
+
172
+ def lang_sys_offsets(data, count)
173
+ raise Failed if data.bytesize < 4 + (6 * count)
174
+
175
+ Array.new(count) { |i| data.byteslice(8 + (6 * i), 2).unpack1("n") }
176
+ end
177
+
178
+ def spend(units)
179
+ raise Failed if units > @budget
180
+
181
+ @budget -= units
182
+ end
183
+ end
184
+
185
+ # Distinct feature indices of many LangSys tables in first-seen order,
186
+ # reading every u16 word once. A LangSys index array is a run of words at
187
+ # a fixed byte parity inside the Script table, so hostile overlapping
188
+ # arrays would otherwise re-read the same words again and again; a word
189
+ # already collected is skipped (a "next unread slot" union-find per
190
+ # parity), which leaves the result and its order unchanged.
191
+ class IndexCollector
192
+ attr_reader :indices
193
+
194
+ def initialize(data)
195
+ @data = data
196
+ @skip = [{}, {}]
197
+ @seen = {}
198
+ @indices = []
199
+ end
200
+
201
+ # LangSys at +offset+: reserved, required index, count, count x u16.
202
+ # Yields the work units it costs (1 per table, 1 per word read).
203
+ def add(offset)
204
+ raise ScriptFeatures::Failed if @data.bytesize < offset + 6
205
+
206
+ count = @data.byteslice(offset + 4, 2).unpack1("n")
207
+ raise ScriptFeatures::Failed if @data.bytesize < offset + 6 + (2 * count)
208
+
209
+ yield(1)
210
+ first = offset + 6
211
+ parity = first & 1
212
+ slot = find(parity, first >> 1)
213
+ limit = (first + (2 * count)) >> 1
214
+ while slot < limit
215
+ yield(1)
216
+ collect(@data.byteslice((slot * 2) + parity, 2).unpack1("n"))
217
+ @skip[parity][slot] = slot + 1
218
+ slot = find(parity, slot + 1)
219
+ end
220
+ end
221
+
222
+ private
223
+
224
+ def collect(index)
225
+ return if @seen.key?(index)
226
+
227
+ @seen[index] = true
228
+ @indices << index
229
+ end
230
+
231
+ def find(parity, slot)
232
+ skip = @skip[parity]
233
+ root = slot
234
+ root = skip[root] while skip.key?(root)
235
+ while slot != root
236
+ following = skip[slot]
237
+ skip[slot] = root
238
+ slot = following
239
+ end
240
+ root
241
+ end
242
+ end
71
243
  end
72
244
  end
73
245
  end
@@ -108,8 +108,14 @@ module Fontisan
108
108
  array :name_records, type: :name_record, initial_length: :record_count
109
109
  rest :string_storage
110
110
 
111
- # Cache for decoded names
112
- attr_accessor :decoded_names_cache
111
+ # Cache for decoded names. Created on first use: do_read does not set
112
+ # it up when the table is empty, and english_name then raised
113
+ # NoMethodError on nil.
114
+ attr_writer :decoded_names_cache
115
+
116
+ def decoded_names_cache
117
+ @decoded_names_cache ||= {}
118
+ end
113
119
 
114
120
  # Hook that gets called after all fields are read
115
121
  def after_read_hook
@@ -168,7 +174,7 @@ module Fontisan
168
174
  # @return [String, nil] The decoded string or nil if not found
169
175
  def english_name(name_id)
170
176
  # Check cache first
171
- return @decoded_names_cache[name_id] if @decoded_names_cache.key?(name_id)
177
+ return decoded_names_cache[name_id] if decoded_names_cache.key?(name_id)
172
178
 
173
179
  # Find record (don't decode yet)
174
180
  record = find_name_record(
@@ -187,7 +193,7 @@ module Fontisan
187
193
 
188
194
  # Decode only this one record
189
195
  decoded = decode_name_record(record)
190
- @decoded_names_cache[name_id] = decoded
196
+ decoded_names_cache[name_id] = decoded
191
197
  decoded
192
198
  end
193
199
 
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "brotli"
4
-
5
3
  module Fontisan
6
4
  module Utilities
7
5
  # Wrapper for Brotli compression with consistent settings
@@ -19,6 +17,15 @@ module Fontisan
19
17
  # @example Decompress data
20
18
  # decompressed = BrotliWrapper.decompress(compressed_data)
21
19
  class BrotliWrapper
20
+ # Raised when the optional `brotli` C extension cannot be loaded (the
21
+ # java-platform gem omits it; WOFF2 is then served by the :ffi tier).
22
+ class Unavailable < Fontisan::Error; end
23
+
24
+ MISSING_MESSAGE = "WOFF2 needs the `brotli` gem, which is not available " \
25
+ "(java-platform fontisan omits it). Use " \
26
+ "FONTISAN_ENGINE=ffi or auto with the bundled cdylib, " \
27
+ "or install the brotli gem."
28
+
22
29
  # Default compression quality (0-11, higher = better but slower)
23
30
  # Quality 11 gives best compression for WOFF2
24
31
  DEFAULT_QUALITY = 11
@@ -29,6 +36,30 @@ module Fontisan
29
36
  # Maximum quality level
30
37
  MAX_QUALITY = 11
31
38
 
39
+ # Whether the `brotli` gem loads here. Memoised; never raises.
40
+ def self.available?
41
+ return @available if defined?(@available)
42
+
43
+ @available = begin
44
+ require "brotli"
45
+ true
46
+ rescue LoadError
47
+ false
48
+ end
49
+ end
50
+
51
+ # Forget the memoised probe (specs).
52
+ def self.reset!
53
+ remove_instance_variable(:@available) if defined?(@available)
54
+ end
55
+
56
+ # The ::Brotli module, or {Unavailable} with a clear message.
57
+ def self.library
58
+ raise Unavailable, MISSING_MESSAGE unless available?
59
+
60
+ ::Brotli
61
+ end
62
+
32
63
  # Compress data using Brotli
33
64
  #
34
65
  # @param data [String] Data to compress
@@ -48,7 +79,9 @@ module Fontisan
48
79
  validate_data!(data)
49
80
 
50
81
  begin
51
- Brotli.deflate(data, quality: quality, mode: mode)
82
+ library.deflate(data, quality: quality, mode: mode)
83
+ rescue Unavailable
84
+ raise
52
85
  rescue StandardError => e
53
86
  raise Fontisan::Error,
54
87
  "Brotli compression failed: #{e.message}"
@@ -67,7 +100,9 @@ module Fontisan
67
100
  validate_data!(data)
68
101
 
69
102
  begin
70
- Brotli.inflate(data)
103
+ library.inflate(data)
104
+ rescue Unavailable
105
+ raise
71
106
  rescue StandardError => e
72
107
  raise Fontisan::Error,
73
108
  "Brotli decompression failed: #{e.message}"
@@ -90,6 +90,8 @@ module Fontisan
90
90
  # Check 35: Cmap glyph indices must be valid
91
91
  check_structure :cmap_glyph_indices, severity: :error do |font|
92
92
  cmap = font.table("cmap")
93
+ next true unless cmap
94
+
93
95
  maxp = font.table("maxp")
94
96
  cmap.valid_glyph_indices?(maxp.num_glyphs)
95
97
  end
@@ -45,6 +45,7 @@ module Fontisan
45
45
  # @example
46
46
  # binary = instancer.instance({ "wght" => 700, "wdth" => 100 })
47
47
  def instance(user_coords, _options = {})
48
+ Fontisan::Engine.record_ruby(:instance)
48
49
  tables = Variation::Instancing::TrueTypeInstancer
49
50
  .new(@font.table_data, user_coords).call
50
51
  FontWriter.write_font(tables)
@@ -40,6 +40,8 @@ module Fontisan
40
40
  # { ascent: 2048, descent: -512, line_gap: 0 }
41
41
  # @param options [Hash] Build options
42
42
  # @option options [Boolean] :update_modified Update head modified timestamp
43
+ # @option options [Integer, Time] :timestamp Fixed value to stamp when
44
+ # updating head.modified (nil = current time)
43
45
  # @return [String] Complete static font binary
44
46
  def build(varied_metrics = {}, font_metrics = {}, options = {})
45
47
  # Collect tables for static font
@@ -170,7 +172,8 @@ options = {})
170
172
  if options[:update_modified] == false
171
173
  original_data
172
174
  else
173
- @table_updater.update_head_modified(original_data)
175
+ @table_updater.update_head_modified(original_data,
176
+ options[:timestamp])
174
177
  end
175
178
  end
176
179
 
@@ -157,9 +157,11 @@ module Fontisan
157
157
  # Update head table's modified timestamp
158
158
  #
159
159
  # @param original_data [String] Original head table binary
160
- # @param timestamp [Time] New modification time
160
+ # @param timestamp [Time, Integer, nil] New modification time (Time or
161
+ # Unix seconds); nil uses the current time
161
162
  # @return [String] Updated head table binary
162
- def update_head_modified(original_data, timestamp = Time.now)
163
+ def update_head_modified(original_data, timestamp = nil)
164
+ timestamp ||= Time.now
163
165
  io = StringIO.new(original_data)
164
166
  io.set_encoding(Encoding::BINARY)
165
167
 
@@ -9,6 +9,8 @@ module Fontisan
9
9
  # chosen location. cvar, GPOS/GDEF variations and MVAR are not applied.
10
10
  class TrueTypeInstancer
11
11
  DROPPED = %w[fvar gvar avar HVAR VVAR MVAR STAT cvar DSIG].freeze
12
+ # Tables read by #call besides the variation tables checked below.
13
+ REQUIRED = %w[head hhea hmtx maxp loca].freeze
12
14
  WIDTH_CLASSES = [50, 62.5, 75, 87.5, 100, 112.5, 125, 150, 200].freeze
13
15
 
14
16
  # @param tables [Hash{String=>String}] raw tables of the variable font
@@ -45,13 +47,19 @@ module Fontisan
45
47
  unless @tables["fvar"]
46
48
  raise MissingVariationTableError.new(table: "fvar")
47
49
  end
48
- return if @tables["gvar"] && @tables["glyf"]
50
+ unless @tables["gvar"] && @tables["glyf"]
51
+ raise MissingVariationTableError.new(
52
+ table: "gvar",
53
+ message: "Static instancing needs TrueType outlines with gvar " \
54
+ "(CFF2 variable fonts are not supported yet)",
55
+ )
56
+ end
57
+
58
+ REQUIRED.each do |tag|
59
+ next if @tables[tag]
49
60
 
50
- raise MissingVariationTableError.new(
51
- table: "gvar",
52
- message: "Static instancing needs TrueType outlines with gvar " \
53
- "(CFF2 variable fonts are not supported yet)",
54
- )
61
+ raise MissingTableError, "Font missing required #{tag} table"
62
+ end
55
63
  end
56
64
 
57
65
  def read_metrics(num_glyphs)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fontisan
4
- VERSION = "0.4.47"
4
+ VERSION = "0.4.48"
5
5
  end
@@ -59,15 +59,29 @@ module Fontisan
59
59
  # the glyf was transformed.
60
60
  font_entries.map do |fe|
61
61
  font_tables = {}
62
+ glyf_transformed = false
62
63
  fe.table_indices.each do |idx|
63
64
  entry = table_entries.fetch(idx)
64
65
  font_tables[entry.tag] = entry.raw_bytes
66
+ glyf_transformed = !entry.transform_length.nil? if entry.tag == "glyf"
65
67
  end
66
- reconstruct_glyf_loca!(font_tables) if font_tables.key?("glyf")
68
+ reconstruct_glyf_loca!(font_tables) if glyf_transformed
67
69
  { flavor: fe.flavor, tables: font_tables }
68
70
  end
69
71
  end
70
72
 
73
+ # Number of fonts, read from the CollectionDirectory alone: no Brotli
74
+ # inflate, so it works where the `brotli` gem is absent (JRuby).
75
+ #
76
+ # @return [Integer]
77
+ def font_count
78
+ validate_signature!
79
+ validate_collection_flavor!
80
+
81
+ _entries, post_dir_pos = read_table_directory
82
+ read_collection_directory(post_dir_pos).first.length
83
+ end
84
+
71
85
  private
72
86
 
73
87
  def validate_signature!
@@ -21,10 +21,15 @@ module Fontisan
21
21
  # adjacent (loca immediately follows glyf) in the directory.
22
22
  class CollectionEncoder
23
23
  TTC_FLAVOR = 0x74746366 # 'ttcf'
24
+ # Largest padded glyf a short (uint16, offset / 2) loca can index.
25
+ SHORT_LOCA_GLYF_MAX = 0x1FFFE
24
26
 
25
27
  # @param brotli_quality [Integer] 0-11
26
- def initialize(brotli_quality: 11)
28
+ # @param timestamp [Integer, nil] Unix seconds stamped into every
29
+ # head.modified; nil uses the clock
30
+ def initialize(brotli_quality: 11, timestamp: nil)
27
31
  @brotli_quality = brotli_quality
32
+ @timestamp = timestamp
28
33
  end
29
34
 
30
35
  # Encode an array of fonts as a WOFF2 collection.
@@ -58,7 +63,7 @@ module Fontisan
58
63
  tables[tag] = data
59
64
  end
60
65
 
61
- EncoderRules.apply!(tables)
66
+ EncoderRules.apply!(tables, timestamp: @timestamp)
62
67
  loca_orig_length = apply_glyf_loca_transform!(tables, font)
63
68
 
64
69
  { index:, tables:, loca_orig_length: }
@@ -73,12 +78,30 @@ module Fontisan
73
78
  head = font.table("head")
74
79
  return nil unless maxp && head
75
80
 
76
- loca_orig_length = tables["loca"].bytesize
81
+ glyf = tables["glyf"]
82
+ loca = tables["loca"]
83
+ index_format = head.index_to_loc_format
84
+ # The WOFF2 decoder pads every glyph to 4 bytes; a short loca
85
+ # (offset / 2 as uint16) wraps once that padded glyf passes
86
+ # SHORT_LOCA_GLYF_MAX, so such a member is re-emitted with a long loca.
87
+ if index_format.zero? &&
88
+ glyf.bytesize + 3 * maxp.num_glyphs > SHORT_LOCA_GLYF_MAX
89
+ canonical = GlyfCanonicalizer.new(
90
+ glyf_data: glyf, loca_data: loca, num_glyphs: maxp.num_glyphs,
91
+ source_format: 0, target_format: 1
92
+ ).canonical
93
+ glyf = canonical[:glyf]
94
+ loca = canonical[:loca]
95
+ index_format = 1
96
+ EncoderRules.set_head_index_to_loc_format!(tables, 1)
97
+ end
98
+
99
+ loca_orig_length = loca.bytesize
77
100
  tables["glyf"] = GlyfLocaTransform.new(
78
- glyf_data: tables["glyf"],
79
- loca_data: tables["loca"],
101
+ glyf_data: glyf,
102
+ loca_data: loca,
80
103
  num_glyphs: maxp.num_glyphs,
81
- index_format: head.index_to_loc_format,
104
+ index_format:,
82
105
  ).transform
83
106
  tables.delete("loca")
84
107
  loca_orig_length
@@ -29,11 +29,13 @@ module Fontisan
29
29
  #
30
30
  # @param table_data [Hash{String => String}] Map of tag → binary
31
31
  # table data. Mutated.
32
+ # @param timestamp [Integer, Time, nil] Fixed value for head.modified
33
+ # (Unix seconds); nil stamps the current wall-clock time.
32
34
  # @return [Hash{String => String}] The same hash, for chaining.
33
- def self.apply!(table_data)
35
+ def self.apply!(table_data, timestamp: nil)
34
36
  exclude_tables!(table_data)
35
37
  mark_lossless_modifying!(table_data)
36
- touch_head_modified!(table_data)
38
+ touch_head_modified!(table_data, timestamp: timestamp)
37
39
  table_data
38
40
  end
39
41
 
@@ -94,11 +96,17 @@ module Fontisan
94
96
  # are included in the checksum.
95
97
  #
96
98
  # @param table_data [Hash{String => String}]
97
- def self.touch_head_modified!(table_data)
99
+ # @param timestamp [Integer, Time, nil] Unix seconds to stamp instead of
100
+ # the current time (reproducible output); nil keeps the clock.
101
+ def self.touch_head_modified!(table_data, timestamp: nil)
98
102
  return unless table_data.key?("head")
99
103
 
100
104
  head = Tables::Head.read(table_data["head"])
101
- head.modified_raw = Tables::Head.now_longdatetime
105
+ head.modified_raw = if timestamp.nil?
106
+ Tables::Head.now_longdatetime
107
+ else
108
+ Tables::Head.to_longdatetime(timestamp)
109
+ end
102
110
  table_data["head"] = head.to_binary_s
103
111
  end
104
112
  end
@@ -378,7 +378,7 @@ module Fontisan
378
378
  end
379
379
  if count > 1
380
380
  out << (flag | FLAG_REPEAT).chr(Encoding::BINARY)
381
- out << count.chr(Encoding::BINARY)
381
+ out << (count - 1).chr(Encoding::BINARY)
382
382
  i += count
383
383
  else
384
384
  out << flag.chr(Encoding::BINARY)
@@ -62,13 +62,14 @@ module Fontisan
62
62
  #
63
63
  # @param index [Integer] 0-based font index
64
64
  # @param _io [IO, nil] Ignored.
65
- # @param _mode [Symbol] Ignored — full mode only (WOFF2 collections are
66
- # decoded eagerly).
65
+ # @param mode [Symbol] Loading mode, as for the TTC/OTC collections
66
+ # (default: full). Decoding is eager, so mode only limits which tables
67
+ # are kept on the returned font.
67
68
  # @return [TrueTypeFont, OpenTypeFont, nil] nil if index out of range
68
- def font(index, _io = nil, _mode: LoadingModes::FULL)
69
+ def font(index, _io = nil, mode: LoadingModes::FULL)
69
70
  return nil if index >= num_fonts
70
71
 
71
- build_font(decoded[index])
72
+ build_font(decoded[index], mode)
72
73
  end
73
74
 
74
75
  # Whether this object represents a font collection. WOFF2 collections
@@ -108,12 +109,13 @@ module Fontisan
108
109
  # Build a TrueTypeFont or OpenTypeFont from a decoded font entry.
109
110
  # Constructs an in-memory SFNT binary from the per-table data and
110
111
  # loads it through the normal font reader path.
111
- def build_font(entry)
112
+ def build_font(entry, mode = LoadingModes::FULL)
112
113
  sfnt = SfntBuilder.build(flavor: entry[:flavor], tables: entry[:tables])
113
114
  sfnt_io = StringIO.new(sfnt)
114
115
  font_class = truetype_flavor?(entry[:flavor]) ? TrueTypeFont : OpenTypeFont
115
116
  font = font_class.read(sfnt_io)
116
117
  font.initialize_storage
118
+ font.loading_mode = mode
117
119
  font.read_table_data(StringIO.new(sfnt))
118
120
  font
119
121
  end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "bindata"
4
- require "brotli"
5
4
  require "stringio"
6
5
 
7
6
  module Fontisan
@@ -266,7 +265,7 @@ module Fontisan
266
265
  File.open(@io_source.path, "rb") do |io|
267
266
  io.seek(@header.meta_offset)
268
267
  compressed_meta = io.read(@header.meta_length)
269
- Brotli.inflate(compressed_meta)
268
+ Utilities::BrotliWrapper.library.inflate(compressed_meta)
270
269
  end
271
270
  rescue StandardError => e
272
271
  warn "Failed to decompress metadata: #{e.message}"
@@ -516,7 +515,7 @@ module Fontisan
516
515
  compressed_data = io.read(header.total_compressed_size)
517
516
 
518
517
  # Decompress entire data block with Brotli
519
- decompressed_data = Brotli.inflate(compressed_data)
518
+ decompressed_data = inflate_brotli(compressed_data)
520
519
 
521
520
  # Split decompressed data into individual tables
522
521
  decompressed_tables = {}
@@ -533,6 +532,16 @@ module Fontisan
533
532
  decompressed_tables
534
533
  end
535
534
 
535
+ # Brotli-inflate the table block. A bad stream is the same InvalidFontError
536
+ # on every tier (the native tiers say "brotli stream is corrupt"); the
537
+ # binding's own message and class are not a contract.
538
+ def self.inflate_brotli(data)
539
+ Utilities::BrotliWrapper.library.inflate(data)
540
+ rescue StandardError
541
+ raise InvalidFontError.new("Invalid WOFF2 file: woff: brotli stream is corrupt"), cause: nil
542
+ end
543
+ private_class_method :inflate_brotli
544
+
536
545
  # Apply table transformations for glyf/loca/hmtx tables
537
546
  #
538
547
  # @param table_entries [Array<Woff2TableDirectoryEntry>] Table entries