fontisan 0.2.0 → 0.2.1

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 (74) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +270 -131
  3. data/README.adoc +158 -4
  4. data/Rakefile +44 -47
  5. data/lib/fontisan/cli.rb +84 -33
  6. data/lib/fontisan/collection/builder.rb +81 -0
  7. data/lib/fontisan/collection/table_deduplicator.rb +76 -0
  8. data/lib/fontisan/commands/base_command.rb +16 -0
  9. data/lib/fontisan/commands/convert_command.rb +97 -170
  10. data/lib/fontisan/commands/instance_command.rb +71 -80
  11. data/lib/fontisan/commands/validate_command.rb +25 -0
  12. data/lib/fontisan/config/validation_rules.yml +1 -1
  13. data/lib/fontisan/constants.rb +10 -0
  14. data/lib/fontisan/converters/format_converter.rb +150 -1
  15. data/lib/fontisan/converters/outline_converter.rb +80 -18
  16. data/lib/fontisan/converters/woff_writer.rb +1 -1
  17. data/lib/fontisan/font_loader.rb +3 -5
  18. data/lib/fontisan/font_writer.rb +7 -6
  19. data/lib/fontisan/hints/hint_converter.rb +133 -0
  20. data/lib/fontisan/hints/postscript_hint_applier.rb +221 -140
  21. data/lib/fontisan/hints/postscript_hint_extractor.rb +100 -0
  22. data/lib/fontisan/hints/truetype_hint_applier.rb +90 -44
  23. data/lib/fontisan/hints/truetype_hint_extractor.rb +127 -0
  24. data/lib/fontisan/loading_modes.rb +2 -0
  25. data/lib/fontisan/models/font_export.rb +2 -2
  26. data/lib/fontisan/models/hint.rb +173 -1
  27. data/lib/fontisan/models/validation_report.rb +1 -1
  28. data/lib/fontisan/open_type_font.rb +25 -9
  29. data/lib/fontisan/open_type_font_extensions.rb +54 -0
  30. data/lib/fontisan/pipeline/format_detector.rb +249 -0
  31. data/lib/fontisan/pipeline/output_writer.rb +154 -0
  32. data/lib/fontisan/pipeline/strategies/base_strategy.rb +75 -0
  33. data/lib/fontisan/pipeline/strategies/instance_strategy.rb +93 -0
  34. data/lib/fontisan/pipeline/strategies/named_strategy.rb +118 -0
  35. data/lib/fontisan/pipeline/strategies/preserve_strategy.rb +56 -0
  36. data/lib/fontisan/pipeline/transformation_pipeline.rb +411 -0
  37. data/lib/fontisan/pipeline/variation_resolver.rb +165 -0
  38. data/lib/fontisan/tables/cff/charstring.rb +33 -4
  39. data/lib/fontisan/tables/cff/charstring_builder.rb +34 -0
  40. data/lib/fontisan/tables/cff/charstring_parser.rb +237 -0
  41. data/lib/fontisan/tables/cff/charstring_rebuilder.rb +172 -0
  42. data/lib/fontisan/tables/cff/dict_builder.rb +15 -0
  43. data/lib/fontisan/tables/cff/hint_operation_injector.rb +207 -0
  44. data/lib/fontisan/tables/cff/offset_recalculator.rb +70 -0
  45. data/lib/fontisan/tables/cff/private_dict_writer.rb +125 -0
  46. data/lib/fontisan/tables/cff/table_builder.rb +221 -0
  47. data/lib/fontisan/tables/cff.rb +2 -0
  48. data/lib/fontisan/tables/cff2/private_dict_blend_handler.rb +246 -0
  49. data/lib/fontisan/tables/cff2/region_matcher.rb +200 -0
  50. data/lib/fontisan/tables/cff2/table_builder.rb +574 -0
  51. data/lib/fontisan/tables/cff2/table_reader.rb +419 -0
  52. data/lib/fontisan/tables/cff2/variation_data_extractor.rb +212 -0
  53. data/lib/fontisan/tables/cff2.rb +9 -4
  54. data/lib/fontisan/tables/cvar.rb +2 -41
  55. data/lib/fontisan/tables/gvar.rb +2 -41
  56. data/lib/fontisan/true_type_font.rb +24 -9
  57. data/lib/fontisan/true_type_font_extensions.rb +54 -0
  58. data/lib/fontisan/utilities/checksum_calculator.rb +42 -0
  59. data/lib/fontisan/validation/checksum_validator.rb +2 -2
  60. data/lib/fontisan/validation/table_validator.rb +1 -1
  61. data/lib/fontisan/validation/variable_font_validator.rb +218 -0
  62. data/lib/fontisan/variation/converter.rb +120 -13
  63. data/lib/fontisan/variation/instance_writer.rb +341 -0
  64. data/lib/fontisan/variation/tuple_variation_header.rb +51 -0
  65. data/lib/fontisan/variation/variable_svg_generator.rb +268 -0
  66. data/lib/fontisan/variation/variation_preserver.rb +288 -0
  67. data/lib/fontisan/version.rb +1 -1
  68. data/lib/fontisan/version.rb.orig +9 -0
  69. data/lib/fontisan/woff2/glyf_transformer.rb +666 -0
  70. data/lib/fontisan/woff2/hmtx_transformer.rb +164 -0
  71. data/lib/fontisan/woff2_font.rb +475 -470
  72. data/lib/fontisan/woff_font.rb +16 -11
  73. data/lib/fontisan.rb +12 -0
  74. metadata +31 -2
@@ -2,32 +2,18 @@
2
2
 
3
3
  require "bindata"
4
4
  require "brotli"
5
+ require "stringio"
5
6
  require_relative "constants"
7
+ require_relative "loading_modes"
6
8
  require_relative "utilities/checksum_calculator"
9
+ require_relative "woff2/header"
10
+ require_relative "woff2/glyf_transformer"
11
+ require_relative "woff2/hmtx_transformer"
12
+ require_relative "true_type_font"
13
+ require_relative "open_type_font"
14
+ require_relative "error"
7
15
 
8
16
  module Fontisan
9
- # WOFF2 Header structure
10
- #
11
- # WOFF2 header is more compact than WOFF, using variable-length integers
12
- # for some fields and omitting redundant information.
13
- class Woff2Header < BinData::Record
14
- endian :big
15
- uint32 :signature # 0x774F4632 'wOF2'
16
- uint32 :flavor # sfnt version (0x00010000 for TTF, 'OTTO' for CFF)
17
- uint32 :woff2_length # Total size of WOFF2 file
18
- uint16 :num_tables # Number of entries in directory
19
- uint16 :reserved # Reserved, must be zero
20
- uint32 :total_sfnt_size # Total size needed for uncompressed font
21
- uint32 :total_compressed_size # Total size of compressed data block
22
- uint16 :major_version # Major version of WOFF file
23
- uint16 :minor_version # Minor version of WOFF file
24
- uint32 :meta_offset # Offset to metadata block
25
- uint32 :meta_length # Length of compressed metadata block
26
- uint32 :meta_orig_length # Length of uncompressed metadata block
27
- uint32 :priv_offset # Offset to private data block
28
- uint32 :priv_length # Length of private data block
29
- end
30
-
31
17
  # WOFF2 Table Directory Entry structure
32
18
  #
33
19
  # WOFF2 table directory entries are more complex than WOFF,
@@ -56,7 +42,8 @@ module Fontisan
56
42
 
57
43
  def initialize
58
44
  @flags = 0
59
- @transform_version = TRANSFORM_NONE
45
+ # Don't initialize transform_version - leave it nil
46
+ # It will be set during parsing if table is transformed
60
47
  end
61
48
 
62
49
  # Check if table is transformed
@@ -85,59 +72,21 @@ module Fontisan
85
72
  end
86
73
  end
87
74
 
88
- # Web Open Font Format 2.0 (WOFF2) font domain object
89
- #
90
- # Represents a WOFF2 font file that uses Brotli compression and table
91
- # transformations. WOFF2 is significantly more complex than WOFF.
75
+ # Web Open Font Format 2.0 (WOFF2) font loader
92
76
  #
93
- # According to the WOFF2 specification (https://www.w3.org/TR/WOFF2/):
94
- # - Tables can be transformed (glyf, loca, hmtx have special formats)
95
- # - All compressed data in a single Brotli stream
96
- # - Variable-length integer encoding (UIntBase128, 255UInt16)
97
- # - More efficient compression than WOFF
77
+ # This class manages WOFF2 font files and provides access to
78
+ # decompressed tables and transformed data.
98
79
  #
99
80
  # @example Reading a WOFF2 font
100
- # woff2 = Woff2Font.from_file("font.woff2")
101
- # puts woff2.header.num_tables
102
- # name_table = woff2.table("name")
103
- # puts name_table.english_name(Tables::Name::FAMILY)
104
- #
105
- # @example Converting to TTF/OTF
106
- # woff2 = Woff2Font.from_file("font.woff2")
107
- # woff2.to_ttf("output.ttf") # if TrueType flavored
108
- # woff2.to_otf("output.otf") # if CFF flavored
81
+ # font = Woff2Font.from_file("font.woff2")
82
+ # puts font.header.flavor
83
+ # puts font.table_names
109
84
  class Woff2Font
110
- attr_accessor :header, :table_entries, :decompressed_tables,
111
- :parsed_tables, :io_source
112
-
113
- # WOFF2 signature constant
114
- WOFF2_SIGNATURE = 0x774F4632 # 'wOF2'
85
+ # Simple struct for storing file path
86
+ IOSource = Struct.new(:path)
115
87
 
116
- # Read WOFF2 font from a file
117
- #
118
- # @param path [String] Path to the WOFF2 file
119
- # @return [Woff2Font] A new instance
120
- # @raise [ArgumentError] if path is nil or empty
121
- # @raise [Errno::ENOENT] if file does not exist
122
- # @raise [InvalidFontError] if file format is invalid
123
- def self.from_file(path)
124
- if path.nil? || path.to_s.empty?
125
- raise ArgumentError, "path cannot be nil or empty"
126
- end
127
- raise Errno::ENOENT, "File not found: #{path}" unless File.exist?(path)
128
-
129
- File.open(path, "rb") do |io|
130
- font = new
131
- font.read_from_io(io)
132
- font.validate_signature!
133
- font.initialize_storage
134
- font.decompress_and_parse_tables(io)
135
- font.io_source = io
136
- font
137
- end
138
- rescue BinData::ValidityError, EOFError => e
139
- raise InvalidFontError, "Invalid WOFF2 file: #{e.message}"
140
- end
88
+ attr_accessor :header, :table_entries, :decompressed_tables, :parsed_tables, :io_source
89
+ attr_accessor :underlying_font # Allow both reading and setting for table delegation
141
90
 
142
91
  def initialize
143
92
  @header = nil
@@ -145,253 +94,297 @@ module Fontisan
145
94
  @decompressed_tables = {}
146
95
  @parsed_tables = {}
147
96
  @io_source = nil
148
- end
149
-
150
- # Read header and table directory from IO
151
- #
152
- # @param io [IO] Open file handle
153
- # @return [void]
154
- def read_from_io(io)
155
- @header = Woff2Header.read(io)
156
- read_table_directory(io)
97
+ @underlying_font = nil # Store the actual TrueTypeFont/OpenTypeFont
157
98
  end
158
99
 
159
100
  # Initialize storage hashes
160
- #
161
- # @return [void]
162
101
  def initialize_storage
163
102
  @decompressed_tables ||= {}
164
103
  @initialize_storage ||= {}
165
104
  end
166
105
 
167
- # Validate WOFF2 signature
168
- #
169
- # @raise [InvalidFontError] if signature is invalid
170
- # @return [void]
171
- def validate_signature!
172
- signature_value = header.signature.to_i
173
- unless signature_value == WOFF2_SIGNATURE
174
- Kernel.raise(::Fontisan::InvalidFontError,
175
- "Invalid WOFF2 signature: expected 0x#{WOFF2_SIGNATURE.to_s(16)}, " \
176
- "got 0x#{signature_value.to_s(16)}")
177
- end
178
- end
179
-
180
- # Check if font is TrueType flavored
181
- #
182
- # @return [Boolean] true if TrueType, false if CFF
106
+ # Check if font has TrueType flavor
183
107
  def truetype?
184
- [Constants::SFNT_VERSION_TRUETYPE, 0x00010000].include?(header.flavor)
108
+ return false unless @header
109
+
110
+ [Constants::SFNT_VERSION_TRUETYPE, 0x00010000].include?(@header.flavor)
185
111
  end
186
112
 
187
- # Check if font is CFF flavored (OpenType with CFF outlines)
188
- #
189
- # @return [Boolean] true if CFF, false if TrueType
113
+ # Check if font has CFF flavor
190
114
  def cff?
191
- [Constants::SFNT_VERSION_OTTO, 0x4F54544F].include?(header.flavor) # 'OTTO'
115
+ return false unless @header
116
+
117
+ [Constants::SFNT_VERSION_OTTO, 0x4F54544F].include?(@header.flavor)
192
118
  end
193
119
 
194
- # Get decompressed table data
195
- #
196
- # Provides unified interface compatible with WoffFont
120
+ # Check if font is a variable font
197
121
  #
198
- # @param tag [String] The table tag
199
- # @return [String, nil] Decompressed table data or nil if not found
200
- def table_data(tag)
201
- @decompressed_tables[tag]
122
+ # @return [Boolean] true if font has fvar table (variable font)
123
+ def variable_font?
124
+ has_table?("fvar")
202
125
  end
203
126
 
204
- # Check if font has a specific table
205
- #
206
- # @param tag [String] The table tag to check for
207
- # @return [Boolean] true if table exists, false otherwise
208
- def has_table?(tag)
209
- table_entries.any? { |entry| entry.tag == tag }
127
+ # Validate WOFF2 signature
128
+ def validate_signature!
129
+ unless @header && @header.signature == Woff2::Woff2Header::SIGNATURE
130
+ raise InvalidFontError, "Invalid WOFF2 signature"
131
+ end
210
132
  end
211
133
 
212
- # Find a table entry by tag
213
- #
214
- # @param tag [String] The table tag to find
215
- # @return [Woff2TableDirectoryEntry, nil] The table entry or nil
216
- def find_table_entry(tag)
217
- table_entries.find { |entry| entry.tag == tag }
134
+ # Check if font is valid
135
+ def valid?
136
+ return false unless @header
137
+ return false unless @header.signature == Woff2::Woff2Header::SIGNATURE
138
+ return false unless @header.num_tables == @table_entries.length
139
+ return false unless has_table?("head")
140
+
141
+ true
218
142
  end
219
143
 
220
- # Get list of all table tags
221
- #
222
- # @return [Array<String>] Array of table tag strings
223
- def table_names
224
- table_entries.map(&:tag)
144
+ # Check if table exists
145
+ def has_table?(tag)
146
+ @table_entries.any? { |entry| entry.tag == tag }
225
147
  end
226
148
 
227
- # Get parsed table instance
228
- #
229
- # This method decompresses and parses the raw table data into a
230
- # structured table object and caches the result for subsequent calls.
231
- #
232
- # @param tag [String] The table tag to retrieve
233
- # @return [Tables::*, nil] Parsed table object or nil if not found
234
- def table(tag)
235
- @parsed_tables[tag] ||= parse_table(tag)
149
+ # Find table entry by tag
150
+ def find_table_entry(tag)
151
+ @table_entries.find { |entry| entry.tag == tag }
236
152
  end
237
153
 
238
- # Get units per em from head table
239
- #
240
- # @return [Integer, nil] Units per em value
241
- def units_per_em
242
- head = table(Constants::HEAD_TAG)
243
- head&.units_per_em
154
+ # Get list of table tags
155
+ def table_names
156
+ @table_entries.map(&:tag)
244
157
  end
245
158
 
246
- # Get WOFF2 metadata if present
247
- #
248
- # @return [String, nil] Decompressed metadata XML or nil
249
- def metadata
250
- return nil if header.meta_length.zero?
251
- return @metadata if defined?(@metadata)
159
+ # Get decompressed table data
160
+ def table_data(tag)
161
+ # First try underlying font's table data if available
162
+ if @underlying_font && @underlying_font.respond_to?(:table_data)
163
+ underlying_data = @underlying_font.table_data[tag]
164
+ return underlying_data if underlying_data
165
+ end
252
166
 
253
- File.open(io_source.path, "rb") do |io|
254
- io.seek(header.meta_offset)
255
- compressed_meta = io.read(header.meta_length)
256
- @metadata = Brotli.inflate(compressed_meta)
167
+ # Fallback to decompressed_tables
168
+ @decompressed_tables[tag]
169
+ end
257
170
 
258
- # Verify decompressed size
259
- if @metadata.bytesize != header.meta_orig_length
260
- raise InvalidFontError,
261
- "Metadata size mismatch: expected #{header.meta_orig_length}, got #{@metadata.bytesize}"
262
- end
171
+ # Get parsed table object
172
+ def table(tag)
173
+ # Delegate to underlying font if available
174
+ return @underlying_font.table(tag) if @underlying_font
263
175
 
264
- @metadata
265
- end
266
- rescue StandardError => e
267
- warn "Failed to decompress WOFF2 metadata: #{e.message}"
268
- @metadata = nil
176
+ # Fallback to parsed_tables hash
177
+ # Normalize tag to UTF-8 string for hash lookup
178
+ # Use dup to create mutable copy since force_encoding modifies in place
179
+ tag_key = tag.to_s.dup.force_encoding("UTF-8")
180
+ @parsed_tables[tag_key]
269
181
  end
270
182
 
271
- # Convert WOFF2 to TTF format
272
- #
273
- # Decompresses and reconstructs tables, then builds a standard TTF file
274
- #
275
- # @param output_path [String] Path where TTF file will be written
276
- # @return [Integer] Number of bytes written
277
- # @raise [InvalidFontError] if font is not TrueType flavored
183
+ # Convert to TTF
278
184
  def to_ttf(output_path)
279
185
  unless truetype?
280
- raise InvalidFontError,
281
- "Cannot convert to TTF: font is CFF flavored (use to_otf)"
186
+ raise InvalidFontError, "Cannot convert to TTF: font is not TrueType flavored"
282
187
  end
283
188
 
284
- build_sfnt_font(output_path, Constants::SFNT_VERSION_TRUETYPE)
189
+ # Build SFNT and create TrueTypeFont
190
+ sfnt_data = self.class.build_sfnt_in_memory(@header, @table_entries, @decompressed_tables)
191
+ sfnt_io = StringIO.new(sfnt_data)
192
+
193
+ # Create actual TrueTypeFont and save for table delegation
194
+ @underlying_font = TrueTypeFont.read(sfnt_io)
195
+ @underlying_font.initialize_storage
196
+ @underlying_font.read_table_data(sfnt_io)
197
+
198
+ FontWriter.write_to_file(@underlying_font.tables, output_path)
285
199
  end
286
200
 
287
- # Convert WOFF2 to OTF format
288
- #
289
- # Decompresses and reconstructs tables, then builds a standard OTF file
290
- #
291
- # @param output_path [String] Path where OTF file will be written
292
- # @return [Integer] Number of bytes written
293
- # @raise [InvalidFontError] if font is not CFF flavored
201
+ # Convert to OTF
294
202
  def to_otf(output_path)
295
203
  unless cff?
296
- raise InvalidFontError,
297
- "Cannot convert to OTF: font is TrueType flavored (use to_ttf)"
204
+ raise InvalidFontError, "Cannot convert to OTF: font is not CFF flavored"
298
205
  end
299
206
 
300
- build_sfnt_font(output_path, Constants::SFNT_VERSION_OTTO)
207
+ # Build SFNT and create OpenTypeFont
208
+ sfnt_data = self.class.build_sfnt_in_memory(@header, @table_entries, @decompressed_tables)
209
+ sfnt_io = StringIO.new(sfnt_data)
210
+
211
+ # Create actual OpenTypeFont and save for table delegation
212
+ @underlying_font = OpenTypeFont.read(sfnt_io)
213
+ @underlying_font.initialize_storage
214
+ @underlying_font.read_table_data(sfnt_io)
215
+
216
+ FontWriter.write_to_file(@underlying_font.tables, output_path)
301
217
  end
302
218
 
303
- # Validate format correctness
304
- #
305
- # @return [Boolean] true if the WOFF2 format is valid, false otherwise
306
- def valid?
307
- return false unless header
308
- return false unless header.signature == WOFF2_SIGNATURE
309
- return false unless table_entries.respond_to?(:length)
310
- return false if table_entries.length != header.num_tables
311
- return false unless has_table?(Constants::HEAD_TAG)
219
+ # Get metadata (if present)
220
+ def metadata
221
+ return nil unless @header&.meta_length&.positive?
222
+ return nil unless @io_source
223
+
224
+ begin
225
+ File.open(@io_source.path, "rb") do |io|
226
+ io.seek(@header.meta_offset)
227
+ compressed_meta = io.read(@header.meta_length)
228
+ Brotli.inflate(compressed_meta)
229
+ end
230
+ rescue StandardError => e
231
+ warn "Failed to decompress metadata: #{e.message}"
232
+ nil
233
+ end
234
+ end
312
235
 
313
- true
236
+ # Convenience methods for accessing common name table fields
237
+
238
+ # Get font family name
239
+ def family_name
240
+ name_table = table("name")
241
+ name_table&.english_name(Tables::Name::FAMILY)
314
242
  end
315
243
 
316
- private
244
+ # Get font subfamily name
245
+ def subfamily_name
246
+ name_table = table("name")
247
+ name_table&.english_name(Tables::Name::SUBFAMILY)
248
+ end
317
249
 
318
- # Read variable-length UIntBase128 integer
319
- #
320
- # WOFF2 uses a variable-length encoding for table sizes:
321
- # - If high bit is 0, it's a single byte value
322
- # - If high bit is 1, continue reading bytes
323
- # - Maximum 5 bytes for a 32-bit value
324
- #
325
- # @param io [IO] Open file handle
326
- # @return [Integer] The decoded integer value
327
- def read_uint_base128(io)
328
- result = 0
329
- 5.times do
330
- byte = io.read(1).unpack1("C")
331
- return nil unless byte
250
+ # Get full font name
251
+ def full_name
252
+ name_table = table("name")
253
+ name_table&.english_name(Tables::Name::FULL_NAME)
254
+ end
332
255
 
333
- # Continue if high bit is set
334
- if (byte & 0x80).zero?
335
- return (result << 7) | byte
336
- else
337
- result = (result << 7) | (byte & 0x7F)
338
- end
339
- end
256
+ # Get PostScript name
257
+ def post_script_name
258
+ name_table = table("name")
259
+ name_table&.english_name(Tables::Name::POSTSCRIPT_NAME)
260
+ end
340
261
 
341
- # If we're here, the encoding is invalid
342
- raise InvalidFontError, "Invalid UIntBase128 encoding"
262
+ # Get preferred family name
263
+ def preferred_family_name
264
+ name_table = table("name")
265
+ name_table&.english_name(Tables::Name::PREFERRED_FAMILY)
343
266
  end
344
267
 
345
- # Read 255UInt16 variable-length integer
346
- #
347
- # Used in transformed glyf table:
348
- # - If value < 253, it's the value itself (1 byte)
349
- # - If value == 253, read next byte + 253 (2 bytes)
350
- # - If value == 254, read next 2 bytes as big-endian (3 bytes)
351
- # - If value == 255, read next 2 bytes + 506 (3 bytes special)
268
+ # Get preferred subfamily name
269
+ def preferred_subfamily_name
270
+ name_table = table("name")
271
+ name_table&.english_name(Tables::Name::PREFERRED_SUBFAMILY)
272
+ end
273
+
274
+ # Get units per em
275
+ def units_per_em
276
+ head = table("head")
277
+ head&.units_per_em
278
+ end
279
+
280
+ # Read WOFF2 font from a file and return Woff2Font instance
352
281
  #
353
- # @param io [IO] Open file handle
354
- # @return [Integer] The decoded integer value
355
- def read_255_uint16(io)
356
- first = io.read(1).unpack1("C")
357
- return nil unless first
282
+ # @param path [String] Path to the WOFF2 file
283
+ # @param mode [Symbol] Loading mode (:metadata or :full)
284
+ # @param lazy [Boolean] If true, load tables on demand
285
+ # @return [Woff2Font] The WOFF2 font object
286
+ # @raise [ArgumentError] if path is nil or empty
287
+ # @raise [Errno::ENOENT] if file does not exist
288
+ # @raise [InvalidFontError] if file format is invalid
289
+ def self.from_file(path, mode: LoadingModes::FULL, lazy: false)
290
+ if path.nil? || path.to_s.empty?
291
+ raise ArgumentError, "path cannot be nil or empty"
292
+ end
293
+ raise Errno::ENOENT, "File not found: #{path}" unless File.exist?(path)
358
294
 
359
- case first
360
- when 0..252
361
- first
362
- when 253
363
- second = io.read(1).unpack1("C")
364
- 253 + second
365
- when 254
366
- io.read(2).unpack1("n")
367
- when 255
368
- value = io.read(2).unpack1("n")
369
- value + 506
295
+ woff2 = new
296
+ woff2.io_source = IOSource.new(path)
297
+
298
+ File.open(path, "rb") do |io|
299
+ # Read header to determine font flavor
300
+ woff2.header = Woff2::Woff2Header.read(io)
301
+
302
+ # Validate signature
303
+ unless woff2.header.signature == Woff2::Woff2Header::SIGNATURE
304
+ raise InvalidFontError,
305
+ "Invalid WOFF2 signature: expected 0x#{Woff2::Woff2Header::SIGNATURE.to_s(16)}, " \
306
+ "got 0x#{woff2.header.signature.to_i.to_s(16)}"
307
+ end
308
+
309
+ # Read table directory
310
+ woff2.table_entries = read_table_directory_from_io(io, woff2.header)
311
+
312
+ # Decompress table data
313
+ woff2.decompressed_tables = decompress_tables(io, woff2.header, woff2.table_entries)
314
+
315
+ # Apply table transformations if present
316
+ apply_transformations!(woff2.table_entries, woff2.decompressed_tables)
317
+
318
+ # Build SFNT structure in memory
319
+ sfnt_data = build_sfnt_in_memory(woff2.header, woff2.table_entries, woff2.decompressed_tables)
320
+
321
+ # Create StringIO for reading
322
+ sfnt_io = StringIO.new(sfnt_data)
323
+ sfnt_io.rewind
324
+
325
+ # Parse tables based on font type
326
+ if woff2.truetype?
327
+ font = TrueTypeFont.read(sfnt_io)
328
+ font.initialize_storage
329
+ font.loading_mode = mode
330
+ font.lazy_load_enabled = lazy
331
+
332
+ # Create fresh StringIO for table data reading
333
+ table_io = StringIO.new(sfnt_data)
334
+ font.read_table_data(table_io)
335
+
336
+ # Store underlying font for table access delegation
337
+ woff2.underlying_font = font
338
+ woff2.parsed_tables = font.parsed_tables
339
+ elsif woff2.cff?
340
+ font = OpenTypeFont.read(sfnt_io)
341
+ font.initialize_storage
342
+ font.loading_mode = mode
343
+ font.lazy_load_enabled = lazy
344
+
345
+ # Create fresh StringIO for table data reading
346
+ table_io = StringIO.new(sfnt_data)
347
+ font.read_table_data(table_io)
348
+
349
+ # Store underlying font for table access delegation
350
+ woff2.underlying_font = font
351
+ woff2.parsed_tables = font.parsed_tables
352
+ else
353
+ raise InvalidFontError,
354
+ "Unknown WOFF2 flavor: 0x#{woff2.header.flavor.to_s(16)}"
355
+ end
370
356
  end
357
+
358
+ woff2
359
+ rescue BinData::ValidityError, EOFError => e
360
+ raise InvalidFontError, "Invalid WOFF2 file: #{e.message}"
371
361
  end
372
362
 
373
- # Read WOFF2 table directory
374
- #
375
- # The table directory in WOFF2 is more compact than WOFF,
376
- # using variable-length integers and known table indices.
363
+ # Read table directory from IO
377
364
  #
378
365
  # @param io [IO] Open file handle
379
- # @return [void]
380
- def read_table_directory(io)
381
- @table_entries = []
366
+ # @param header [Woff2::Woff2Header] WOFF2 header
367
+ # @return [Array<Woff2TableDirectoryEntry>] Table entries
368
+ def self.read_table_directory_from_io(io, header)
369
+ table_entries = []
382
370
 
383
371
  header.num_tables.times do
384
372
  entry = Woff2TableDirectoryEntry.new
385
373
 
386
- # Read flags byte
387
- flags = io.read(1).unpack1("C")
374
+ # Read flags byte with nil check
375
+ flags_data = io.read(1)
376
+ raise EOFError, "Unexpected EOF while reading table directory flags" if flags_data.nil?
377
+
378
+ flags = flags_data.unpack1("C")
388
379
  entry.flags = flags
389
380
 
390
381
  # Determine tag
391
382
  tag_index = flags & 0x3F
392
383
  if tag_index == 0x3F
393
384
  # Custom tag (4 bytes)
394
- entry.tag = io.read(4).force_encoding("UTF-8")
385
+ tag_data = io.read(4)
386
+ raise EOFError, "Unexpected EOF while reading custom tag" if tag_data.nil? || tag_data.bytesize < 4
387
+ entry.tag = tag_data.force_encoding("UTF-8")
395
388
  else
396
389
  # Known tag from table
397
390
  entry.tag = Woff2TableDirectoryEntry::KNOWN_TAGS[tag_index]
@@ -401,64 +394,153 @@ module Fontisan
401
394
  end
402
395
 
403
396
  # Read orig_length (UIntBase128)
404
- entry.orig_length = read_uint_base128(io)
397
+ entry.orig_length = read_uint_base128_from_io(io)
405
398
 
406
- # For transformed tables, read transform_length
399
+ # Determine if transformLength should be read
400
+ # According to WOFF2 spec section 4.2:
401
+ # - glyf/loca with version 0: TRANSFORMED (transformLength present)
402
+ # - hmtx with non-zero version: TRANSFORMED (transformLength present)
403
+ # - all other tables: transformation version is 0 (no transformLength)
407
404
  transform_version = (flags >> 6) & 0x03
408
- if transform_version != 0 && ["glyf", "loca",
409
- "hmtx"].include?(entry.tag)
410
- entry.transform_length = read_uint_base128(io)
405
+ has_transform_length = if ["glyf", "loca"].include?(entry.tag) && transform_version.zero?
406
+ true
407
+ elsif entry.tag == "hmtx" && transform_version != 0
408
+ true
409
+ else
410
+ false
411
+ end
412
+
413
+ if has_transform_length
414
+ entry.transform_length = read_uint_base128_from_io(io)
411
415
  entry.transform_version = transform_version
412
416
  end
413
417
 
414
- @table_entries << entry
418
+ table_entries << entry
415
419
  end
420
+
421
+ table_entries
416
422
  end
417
423
 
418
- # Decompress table data block and reconstruct tables
419
- #
420
- # WOFF2 stores all table data in a single Brotli-compressed block.
421
- # After decompression, we need to:
422
- # 1. Split into individual tables
423
- # 2. Reconstruct transformed tables (glyf, loca, hmtx)
424
+ # Read variable-length UIntBase128 integer from IO
424
425
  #
425
426
  # @param io [IO] Open file handle
426
- # @return [void]
427
- def decompress_and_parse_tables(io)
428
- # Position after table directory
429
- # The compressed data starts immediately after the table directory
430
- compressed_offset = header.to_binary_s.bytesize +
431
- calculate_table_directory_size
427
+ # @return [Integer] The decoded integer value
428
+ def self.read_uint_base128_from_io(io)
429
+ result = 0
430
+ 5.times do
431
+ byte_data = io.read(1)
432
+ raise EOFError, "Unexpected EOF while reading UIntBase128" if byte_data.nil?
433
+
434
+ byte = byte_data.unpack1("C")
435
+
436
+ # Continue if high bit is set
437
+ if (byte & 0x80).zero?
438
+ return (result << 7) | byte
439
+ else
440
+ result = (result << 7) | (byte & 0x7F)
441
+ end
442
+ end
443
+
444
+ # If we're here, the encoding is invalid
445
+ raise InvalidFontError, "Invalid UIntBase128 encoding"
446
+ end
432
447
 
433
- io.seek(compressed_offset)
448
+ # Decompress tables from WOFF2 compressed data block
449
+ #
450
+ # @param io [IO] Open file handle
451
+ # @param header [Woff2::Woff2Header] WOFF2 header
452
+ # @param table_entries [Array<Woff2TableDirectoryEntry>] Table entries
453
+ # @return [Hash<String, String>] Map of tag to decompressed data
454
+ def self.decompress_tables(io, header, table_entries)
455
+ # IO stream is already positioned at compressed data after reading table directory
456
+ # No need to seek - just read from current position
434
457
  compressed_data = io.read(header.total_compressed_size)
435
458
 
436
459
  # Decompress entire data block with Brotli
437
460
  decompressed_data = Brotli.inflate(compressed_data)
438
461
 
439
462
  # Split decompressed data into individual tables
463
+ decompressed_tables = {}
440
464
  offset = 0
465
+
441
466
  table_entries.each do |entry|
442
467
  table_size = entry.transform_length || entry.orig_length
443
-
444
468
  table_data = decompressed_data[offset, table_size]
445
469
  offset += table_size
446
470
 
447
- # Reconstruct transformed tables
448
- if entry.transform_version && entry.transform_version != Woff2TableDirectoryEntry::TRANSFORM_NONE
449
- table_data = reconstruct_transformed_table(entry, table_data)
471
+ decompressed_tables[entry.tag] = table_data
472
+ end
473
+
474
+ decompressed_tables
475
+ end
476
+
477
+ # Apply table transformations for glyf/loca/hmtx tables
478
+ #
479
+ # @param table_entries [Array<Woff2TableDirectoryEntry>] Table entries
480
+ # @param decompressed_tables [Hash<String, String>] Decompressed tables
481
+ # @return [void] Modifies decompressed_tables in place
482
+ def self.apply_transformations!(table_entries, decompressed_tables)
483
+ # Find entries that need transformation
484
+ glyf_entry = table_entries.find { |e| e.tag == "glyf" }
485
+ hmtx_entry = table_entries.find { |e| e.tag == "hmtx" }
486
+
487
+ # Get required metadata for transformations
488
+ maxp_data = decompressed_tables["maxp"]
489
+ hhea_data = decompressed_tables["hhea"]
490
+
491
+ return unless maxp_data && hhea_data
492
+
493
+ # Parse num_glyphs from maxp table
494
+ # maxp format: version(4) + numGlyphs(2) + ...
495
+ num_glyphs = maxp_data[4, 2].unpack1("n")
496
+
497
+ # Parse numberOfHMetrics from hhea table
498
+ # hhea format: ... + numberOfHMetrics(2) at offset 34
499
+ number_of_h_metrics = hhea_data[34, 2].unpack1("n")
500
+
501
+ # Check if this is a variable font by checking for fvar table
502
+ variable_font = table_entries.any? { |e| e.tag == "fvar" }
503
+
504
+ # Transform glyf/loca if needed
505
+ # transform_length is only set when table is actually transformed
506
+ # Check that transform_length exists and is greater than 0
507
+ if glyf_entry&.instance_variable_defined?(:@transform_length) &&
508
+ glyf_entry.transform_length&.positive?
509
+ transformed_glyf = decompressed_tables["glyf"]
510
+
511
+ if transformed_glyf
512
+ result = Woff2::GlyfTransformer.reconstruct(
513
+ transformed_glyf,
514
+ num_glyphs,
515
+ variable_font: variable_font
516
+ )
517
+ decompressed_tables["glyf"] = result[:glyf]
518
+ decompressed_tables["loca"] = result[:loca]
450
519
  end
520
+ end
451
521
 
452
- @decompressed_tables[entry.tag] = table_data
522
+ # Transform hmtx if needed
523
+ # transform_length is only set when table is actually transformed
524
+ # Check that transform_length exists and is greater than 0
525
+ if hmtx_entry&.instance_variable_defined?(:@transform_length) &&
526
+ hmtx_entry.transform_length&.positive?
527
+ transformed_hmtx = decompressed_tables["hmtx"]
528
+
529
+ if transformed_hmtx
530
+ decompressed_tables["hmtx"] = Woff2::HmtxTransformer.reconstruct(
531
+ transformed_hmtx,
532
+ num_glyphs,
533
+ number_of_h_metrics,
534
+ )
535
+ end
453
536
  end
454
537
  end
455
538
 
456
539
  # Calculate size of table directory
457
540
  #
458
- # Variable-length encoding makes this non-trivial
459
- #
541
+ # @param table_entries [Array<Woff2TableDirectoryEntry>] Table entries
460
542
  # @return [Integer] Size in bytes
461
- def calculate_table_directory_size
543
+ def self.calculate_table_directory_size(table_entries)
462
544
  size = 0
463
545
  table_entries.each do |entry|
464
546
  size += 1 # flags byte
@@ -471,7 +553,7 @@ module Fontisan
471
553
  size += uint_base128_size(entry.orig_length)
472
554
 
473
555
  # transform_length if present
474
- if entry.transform_version && entry.transform_version != Woff2TableDirectoryEntry::TRANSFORM_NONE
556
+ if entry.transform_version && !entry.transform_version.nil?
475
557
  size += uint_base128_size(entry.transform_length)
476
558
  end
477
559
  end
@@ -482,7 +564,7 @@ module Fontisan
482
564
  #
483
565
  # @param value [Integer] The value to encode
484
566
  # @return [Integer] Estimated size in bytes
485
- def uint_base128_size(value)
567
+ def self.uint_base128_size(value)
486
568
  return 1 if value < 128
487
569
 
488
570
  bytes = 0
@@ -491,222 +573,145 @@ module Fontisan
491
573
  bytes += 1
492
574
  v >>= 7
493
575
  end
494
- [bytes, 5].min # Max 5 bytes
576
+ [
577
+ bytes,
578
+ 5,
579
+ ].min # Max 5 bytes
495
580
  end
496
581
 
497
- # Reconstruct transformed table from WOFF2 format
498
- #
499
- # WOFF2 can transform certain tables for better compression:
500
- # - glyf/loca: Complex transformation with multiple streams
501
- # - hmtx: Can omit redundant data
582
+ # Build SFNT binary structure in memory
502
583
  #
503
- # @param entry [Woff2TableDirectoryEntry] Table entry
504
- # @param data [String] Transformed table data
505
- # @return [String] Reconstructed standard table data
506
- def reconstruct_transformed_table(entry, data)
507
- case entry.tag
508
- when "glyf", "loca"
509
- reconstruct_glyf_loca(entry, data)
510
- when "hmtx"
511
- reconstruct_hmtx(entry, data)
512
- else
513
- # Unknown transformation, return as-is
514
- data
584
+ # @param header [Woff2::Woff2Header] WOFF2 header
585
+ # @param table_entries [Array<Woff2TableDirectoryEntry>] Table entries
586
+ # @param decompressed_tables [Hash<String, String>] Decompressed table data
587
+ # @return [String] Complete SFNT binary data
588
+ def self.build_sfnt_in_memory(header, table_entries, decompressed_tables)
589
+ sfnt_data = +""
590
+
591
+ # Calculate offset table fields
592
+ num_tables = table_entries.length
593
+ entry_selector = (Math.log(num_tables) / Math.log(2)).floor
594
+ search_range = (2**entry_selector) * 16
595
+ range_shift = num_tables * 16 - search_range
596
+
597
+ # Write offset table
598
+ sfnt_data << [header.flavor].pack("N")
599
+ sfnt_data << [num_tables].pack("n")
600
+ sfnt_data << [search_range].pack("n")
601
+ sfnt_data << [entry_selector].pack("n")
602
+ sfnt_data << [range_shift].pack("n")
603
+
604
+ # Calculate table offsets
605
+ offset = 12 + (num_tables * 16) # Header + directory
606
+ table_records = []
607
+
608
+ table_entries.each do |entry|
609
+ tag = entry.tag
610
+ data = decompressed_tables[tag]
611
+ next unless data
612
+
613
+ length = data.bytesize
614
+
615
+ # Calculate checksum
616
+ checksum = Utilities::ChecksumCalculator.calculate_table_checksum(data)
617
+
618
+ table_records << {
619
+ tag: tag,
620
+ checksum: checksum,
621
+ offset: offset,
622
+ length: length,
623
+ data: data,
624
+ }
625
+
626
+ # Update offset for next table (with padding)
627
+ offset += length
628
+ padding = (Constants::TABLE_ALIGNMENT - (length % Constants::TABLE_ALIGNMENT)) %
629
+ Constants::TABLE_ALIGNMENT
630
+ offset += padding
515
631
  end
516
- end
517
632
 
518
- # Reconstruct glyf/loca tables from WOFF2 transformed format
519
- #
520
- # This is the most complex WOFF2 transformation. The transformed
521
- # glyf table contains multiple streams that need to be reconstructed.
522
- #
523
- # @param entry [Woff2TableDirectoryEntry] Table entry
524
- # @param data [String] Transformed data
525
- # @return [String] Reconstructed glyf or loca table data
526
- def reconstruct_glyf_loca(_entry, _data)
527
- # TODO: Implement full glyf/loca reconstruction
528
- # This is extremely complex and requires:
529
- # 1. Parse glyph streams (nContour, nPoints, flags, coords, etc.)
530
- # 2. Reconstruct standard glyf format
531
- # 3. Build loca table with proper offsets
532
- #
533
- # For now, return empty data to prevent crashes
534
- # This will need proper implementation for production use
535
- warn "WOFF2 transformed glyf/loca reconstruction not yet implemented"
536
- ""
537
- end
538
-
539
- # Reconstruct hmtx table from WOFF2 transformed format
540
- #
541
- # WOFF2 can store hmtx in a more compact format by:
542
- # - Omitting redundant advance widths
543
- # - Using flags to indicate presence of LSB array
544
- #
545
- # @param entry [Woff2TableDirectoryEntry] Table entry
546
- # @param data [String] Transformed data
547
- # @return [String] Reconstructed hmtx table data
548
- def reconstruct_hmtx(_entry, data)
549
- # TODO: Implement hmtx reconstruction
550
- # This requires:
551
- # 1. Parse flags
552
- # 2. Reconstruct advance width array
553
- # 3. Reconstruct LSB array (if present) or derive from glyf
554
- #
555
- # For now, return as-is
556
- warn "WOFF2 transformed hmtx reconstruction not yet implemented"
557
- data
558
- end
559
-
560
- # Parse a table from decompressed data
561
- #
562
- # @param tag [String] The table tag to parse
563
- # @return [Tables::*, nil] Parsed table object or nil
564
- def parse_table(tag)
565
- raw_data = table_data(tag)
566
- return nil unless raw_data
633
+ # Write table directory
634
+ table_records.each do |record|
635
+ sfnt_data << record[:tag].ljust(4, "\x00")
636
+ sfnt_data << [record[:checksum]].pack("N")
637
+ sfnt_data << [record[:offset]].pack("N")
638
+ sfnt_data << [record[:length]].pack("N")
639
+ end
567
640
 
568
- table_class = table_class_for(tag)
569
- return nil unless table_class
641
+ # Write table data with padding
642
+ table_records.each do |record|
643
+ sfnt_data << record[:data]
644
+
645
+ # Add padding
646
+ padding = (Constants::TABLE_ALIGNMENT - (record[:length] % Constants::TABLE_ALIGNMENT)) %
647
+ Constants::TABLE_ALIGNMENT
648
+ sfnt_data << ("\x00" * padding) if padding.positive?
649
+ end
570
650
 
571
- table_class.read(raw_data)
651
+ # Update checksumAdjustment in head table
652
+ update_checksum_in_memory(sfnt_data, table_records)
653
+
654
+ sfnt_data
572
655
  end
573
656
 
574
- # Map table tag to parser class
575
- #
576
- # @param tag [String] The table tag
577
- # @return [Class, nil] Table parser class or nil
578
- def table_class_for(tag)
579
- {
580
- Constants::HEAD_TAG => Tables::Head,
581
- Constants::HHEA_TAG => Tables::Hhea,
582
- Constants::HMTX_TAG => Tables::Hmtx,
583
- Constants::MAXP_TAG => Tables::Maxp,
584
- Constants::NAME_TAG => Tables::Name,
585
- Constants::OS2_TAG => Tables::Os2,
586
- Constants::POST_TAG => Tables::Post,
587
- Constants::CMAP_TAG => Tables::Cmap,
588
- Constants::FVAR_TAG => Tables::Fvar,
589
- Constants::GSUB_TAG => Tables::Gsub,
590
- Constants::GPOS_TAG => Tables::Gpos,
591
- }[tag]
592
- end
593
-
594
- # Build an SFNT font file (TTF or OTF) from decompressed WOFF2 data
657
+ # Update checksumAdjustment field in head table in memory
595
658
  #
596
- # @param output_path [String] Path where font will be written
597
- # @param sfnt_version [Integer] SFNT version
598
- # @return [Integer] Number of bytes written
599
- def build_sfnt_font(output_path, sfnt_version)
600
- File.open(output_path, "wb") do |io|
601
- # Calculate offset table fields
602
- num_tables = table_entries.length
603
- search_range, entry_selector, range_shift = calculate_offset_table_fields(num_tables)
604
-
605
- # Write offset table
606
- io.write([sfnt_version].pack("N"))
607
- io.write([num_tables].pack("n"))
608
- io.write([search_range].pack("n"))
609
- io.write([entry_selector].pack("n"))
610
- io.write([range_shift].pack("n"))
611
-
612
- # Calculate table offsets
613
- offset = 12 + (num_tables * 16) # Header + directory
614
- table_records = []
615
-
616
- table_entries.each do |entry|
617
- tag = entry.tag
618
- data = @decompressed_tables[tag]
619
- next unless data
620
-
621
- length = data.bytesize
622
-
623
- # Calculate checksum
624
- checksum = Utilities::ChecksumCalculator.calculate_table_checksum(data)
625
-
626
- table_records << {
627
- tag: tag,
628
- checksum: checksum,
629
- offset: offset,
630
- length: length,
631
- data: data,
632
- }
633
-
634
- # Update offset for next table (with padding)
635
- offset += length
636
- padding = (Constants::TABLE_ALIGNMENT - (length % Constants::TABLE_ALIGNMENT)) %
637
- Constants::TABLE_ALIGNMENT
638
- offset += padding
639
- end
659
+ # @param sfnt_data [String] The SFNT binary data
660
+ # @param table_records [Array<Hash>] Table records with offsets
661
+ # @return [void]
662
+ def self.update_checksum_in_memory(sfnt_data, table_records)
663
+ # Find head table record
664
+ head_record = table_records.find { |r| r[:tag] == Constants::HEAD_TAG }
665
+ return unless head_record
640
666
 
641
- # Write table directory
642
- table_records.each do |record|
643
- io.write(record[:tag].ljust(4, "\x00"))
644
- io.write([record[:checksum]].pack("N"))
645
- io.write([record[:offset]].pack("N"))
646
- io.write([record[:length]].pack("N"))
667
+ # Zero out checksumAdjustment field first
668
+ head_offset = head_record[:offset]
669
+ sfnt_data[head_offset + 8, 4] = "\x00\x00\x00\x00"
647
670
 
648
- # Write table data
649
- io.write(record[:data])
671
+ # Calculate file checksum
672
+ checksum = 0
673
+ sfnt_data.bytes.each_slice(4) do |bytes|
674
+ word = bytes.pack("C*").ljust(4, "\x00").unpack1("N")
675
+ checksum = (checksum + word) & 0xFFFFFFFF
676
+ end
650
677
 
651
- # Add padding
652
- padding = (Constants::TABLE_ALIGNMENT - (record[:length] % Constants::TABLE_ALIGNMENT)) %
653
- Constants::TABLE_ALIGNMENT
654
- io.write("\x00" * padding) if padding.positive?
655
- end
678
+ # Calculate adjustment
679
+ adjustment = (0xB1B0AFBA - checksum) & 0xFFFFFFFF
656
680
 
657
- io.pos
658
- end
681
+ # Write adjustment to head table
682
+ sfnt_data[head_offset + 8, 4] = [adjustment].pack("N")
683
+ end
684
+
685
+ private
659
686
 
660
- # Update checksum adjustment in head table
661
- update_checksum_adjustment_in_file(output_path)
687
+ # Read variable-length UIntBase128 integer from IO
688
+ def read_uint_base128(io)
689
+ self.class.read_uint_base128_from_io(io)
690
+ end
662
691
 
663
- File.size(output_path)
692
+ # Read 255UInt16 variable-length integer
693
+ def read_255_uint16(io)
694
+ code = io.read(1).unpack1("C")
695
+
696
+ case code
697
+ when 0..252
698
+ code
699
+ when 253
700
+ 253 + io.read(1).unpack1("C")
701
+ when 254
702
+ io.read(2).unpack1("n")
703
+ when 255
704
+ io.read(2).unpack1("n") + 506
705
+ end
664
706
  end
665
707
 
666
708
  # Calculate offset table fields
667
- #
668
- # @param num_tables [Integer] Number of tables
669
- # @return [Array<Integer>] [searchRange, entrySelector, rangeShift]
670
709
  def calculate_offset_table_fields(num_tables)
671
710
  entry_selector = (Math.log(num_tables) / Math.log(2)).floor
672
711
  search_range = (2**entry_selector) * 16
673
712
  range_shift = num_tables * 16 - search_range
674
- [search_range, entry_selector, range_shift]
675
- end
676
-
677
- # Update checksumAdjustment field in head table
678
- #
679
- # @param path [String] Path to the font file
680
- # @return [void]
681
- def update_checksum_adjustment_in_file(path)
682
- # Calculate file checksum
683
- checksum = Utilities::ChecksumCalculator.calculate_file_checksum(path)
684
713
 
685
- # Calculate adjustment
686
- adjustment = Utilities::ChecksumCalculator.calculate_adjustment(checksum)
687
-
688
- # Find head table position in output file
689
- File.open(path, "rb") do |io|
690
- io.seek(4) # Skip sfnt_version
691
- num_tables = io.read(2).unpack1("n")
692
- io.seek(12) # Start of table directory
693
-
694
- num_tables.times do
695
- tag = io.read(4)
696
- io.read(4) # checksum
697
- offset = io.read(4).unpack1("N")
698
- io.read(4) # length
699
-
700
- if tag == Constants::HEAD_TAG
701
- # Write adjustment to head table (offset 8 within head table)
702
- File.open(path, "r+b") do |write_io|
703
- write_io.seek(offset + 8)
704
- write_io.write([adjustment].pack("N"))
705
- end
706
- break
707
- end
708
- end
709
- end
714
+ [search_range, entry_selector, range_shift]
710
715
  end
711
716
  end
712
717
  end