fontisan 0.2.0 → 0.2.2

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