fontisan 0.2.2 → 0.2.4

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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +94 -48
  3. data/README.adoc +293 -3
  4. data/Rakefile +20 -7
  5. data/lib/fontisan/base_collection.rb +296 -0
  6. data/lib/fontisan/commands/base_command.rb +2 -19
  7. data/lib/fontisan/commands/convert_command.rb +16 -13
  8. data/lib/fontisan/commands/info_command.rb +156 -50
  9. data/lib/fontisan/config/conversion_matrix.yml +58 -20
  10. data/lib/fontisan/converters/outline_converter.rb +6 -3
  11. data/lib/fontisan/converters/svg_generator.rb +45 -0
  12. data/lib/fontisan/converters/woff2_encoder.rb +106 -13
  13. data/lib/fontisan/font_loader.rb +109 -26
  14. data/lib/fontisan/formatters/text_formatter.rb +72 -19
  15. data/lib/fontisan/models/bitmap_glyph.rb +123 -0
  16. data/lib/fontisan/models/bitmap_strike.rb +94 -0
  17. data/lib/fontisan/models/collection_brief_info.rb +6 -0
  18. data/lib/fontisan/models/collection_info.rb +6 -1
  19. data/lib/fontisan/models/color_glyph.rb +57 -0
  20. data/lib/fontisan/models/color_layer.rb +53 -0
  21. data/lib/fontisan/models/color_palette.rb +60 -0
  22. data/lib/fontisan/models/font_info.rb +26 -0
  23. data/lib/fontisan/models/svg_glyph.rb +89 -0
  24. data/lib/fontisan/open_type_collection.rb +17 -220
  25. data/lib/fontisan/open_type_font.rb +6 -0
  26. data/lib/fontisan/optimizers/charstring_rewriter.rb +19 -8
  27. data/lib/fontisan/optimizers/pattern_analyzer.rb +4 -2
  28. data/lib/fontisan/optimizers/subroutine_builder.rb +6 -5
  29. data/lib/fontisan/optimizers/subroutine_optimizer.rb +5 -2
  30. data/lib/fontisan/pipeline/output_writer.rb +2 -2
  31. data/lib/fontisan/tables/cbdt.rb +169 -0
  32. data/lib/fontisan/tables/cblc.rb +290 -0
  33. data/lib/fontisan/tables/cff.rb +6 -12
  34. data/lib/fontisan/tables/colr.rb +291 -0
  35. data/lib/fontisan/tables/cpal.rb +281 -0
  36. data/lib/fontisan/tables/glyf/glyph_builder.rb +5 -1
  37. data/lib/fontisan/tables/sbix.rb +379 -0
  38. data/lib/fontisan/tables/svg.rb +301 -0
  39. data/lib/fontisan/true_type_collection.rb +29 -113
  40. data/lib/fontisan/true_type_font.rb +6 -0
  41. data/lib/fontisan/validation/woff2_header_validator.rb +278 -0
  42. data/lib/fontisan/validation/woff2_table_validator.rb +270 -0
  43. data/lib/fontisan/validation/woff2_validator.rb +248 -0
  44. data/lib/fontisan/version.rb +1 -1
  45. data/lib/fontisan/woff2/directory.rb +40 -11
  46. data/lib/fontisan/woff2/table_transformer.rb +506 -73
  47. data/lib/fontisan/woff2_font.rb +29 -9
  48. data/lib/fontisan/woff_font.rb +17 -4
  49. data/lib/fontisan.rb +12 -0
  50. metadata +18 -2
@@ -0,0 +1,290 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+ require_relative "../binary/base_record"
5
+
6
+ module Fontisan
7
+ module Tables
8
+ # CBLC (Color Bitmap Location) table parser
9
+ #
10
+ # The CBLC table contains location information for bitmap glyphs at various
11
+ # sizes (strikes). It works together with the CBDT table which contains the
12
+ # actual bitmap data.
13
+ #
14
+ # CBLC Table Structure:
15
+ # ```
16
+ # CBLC Table = Header (8 bytes)
17
+ # + BitmapSize Records (48 bytes each)
18
+ # ```
19
+ #
20
+ # Header (8 bytes):
21
+ # - version (uint32): Table version (0x00020000 or 0x00030000)
22
+ # - numSizes (uint32): Number of BitmapSize records
23
+ #
24
+ # Each BitmapSize record (48 bytes) contains:
25
+ # - indexSubTableArrayOffset (uint32): Offset to index subtable array
26
+ # - indexTablesSize (uint32): Size of index subtables
27
+ # - numberOfIndexSubTables (uint32): Number of index subtables
28
+ # - colorRef (uint32): Not used, set to 0
29
+ # - hori (SbitLineMetrics, 12 bytes): Horizontal line metrics
30
+ # - vert (SbitLineMetrics, 12 bytes): Vertical line metrics
31
+ # - startGlyphIndex (uint16): First glyph ID in strike
32
+ # - endGlyphIndex (uint16): Last glyph ID in strike
33
+ # - ppemX (uint8): Horizontal pixels per em
34
+ # - ppemY (uint8): Vertical pixels per em
35
+ # - bitDepth (uint8): Bit depth (1, 2, 4, 8, 32)
36
+ # - flags (int8): Flags
37
+ #
38
+ # Reference: OpenType CBLC specification
39
+ # https://docs.microsoft.com/en-us/typography/opentype/spec/cblc
40
+ #
41
+ # @example Reading a CBLC table
42
+ # data = font.table_data['CBLC']
43
+ # cblc = Fontisan::Tables::Cblc.read(data)
44
+ # strikes = cblc.strikes
45
+ # puts "Font has #{strikes.length} bitmap strikes"
46
+ class Cblc < Binary::BaseRecord
47
+ # OpenType table tag for CBLC
48
+ TAG = "CBLC"
49
+
50
+ # Supported CBLC versions
51
+ VERSION_2_0 = 0x00020000
52
+ VERSION_3_0 = 0x00030000
53
+
54
+ # SbitLineMetrics structure (12 bytes)
55
+ #
56
+ # Contains metrics for horizontal or vertical layout
57
+ class SbitLineMetrics < Binary::BaseRecord
58
+ endian :big
59
+ int8 :ascender
60
+ int8 :descender
61
+ uint8 :width_max
62
+ int8 :caret_slope_numerator
63
+ int8 :caret_slope_denominator
64
+ int8 :caret_offset
65
+ int8 :min_origin_sb
66
+ int8 :min_advance_sb
67
+ int8 :max_before_bl
68
+ int8 :min_after_bl
69
+ int8 :pad1
70
+ int8 :pad2
71
+ end
72
+
73
+ # BitmapSize record structure (48 bytes)
74
+ #
75
+ # Describes a bitmap strike at a specific ppem size
76
+ class BitmapSize < Binary::BaseRecord
77
+ endian :big
78
+ uint32 :index_subtable_array_offset
79
+ uint32 :index_tables_size
80
+ uint32 :number_of_index_subtables
81
+ uint32 :color_ref
82
+
83
+ # Read the SbitLineMetrics structures manually
84
+ def self.read(io)
85
+ data = io.is_a?(String) ? io : io.read
86
+ size = new
87
+
88
+ io = StringIO.new(data)
89
+ size.instance_variable_set(:@index_subtable_array_offset, io.read(4).unpack1("N"))
90
+ size.instance_variable_set(:@index_tables_size, io.read(4).unpack1("N"))
91
+ size.instance_variable_set(:@number_of_index_subtables, io.read(4).unpack1("N"))
92
+ size.instance_variable_set(:@color_ref, io.read(4).unpack1("N"))
93
+
94
+ # Parse hori and vert metrics (12 bytes each)
95
+ hori_data = io.read(12)
96
+ vert_data = io.read(12)
97
+ size.instance_variable_set(:@hori, SbitLineMetrics.read(hori_data))
98
+ size.instance_variable_set(:@vert, SbitLineMetrics.read(vert_data))
99
+
100
+ # Parse remaining fields
101
+ size.instance_variable_set(:@start_glyph_index, io.read(2).unpack1("n"))
102
+ size.instance_variable_set(:@end_glyph_index, io.read(2).unpack1("n"))
103
+ size.instance_variable_set(:@ppem_x, io.read(1).unpack1("C"))
104
+ size.instance_variable_set(:@ppem_y, io.read(1).unpack1("C"))
105
+ size.instance_variable_set(:@bit_depth, io.read(1).unpack1("C"))
106
+ size.instance_variable_set(:@flags, io.read(1).unpack1("c"))
107
+
108
+ size
109
+ end
110
+
111
+ attr_reader :index_subtable_array_offset, :index_tables_size,
112
+ :number_of_index_subtables, :color_ref, :hori, :vert,
113
+ :start_glyph_index, :end_glyph_index, :ppem_x, :ppem_y,
114
+ :bit_depth, :flags
115
+
116
+ # Get ppem size (assumes square pixels)
117
+ #
118
+ # @return [Integer] Pixels per em
119
+ def ppem
120
+ ppem_x
121
+ end
122
+
123
+ # Get glyph range for this strike
124
+ #
125
+ # @return [Range] Range of glyph IDs
126
+ def glyph_range
127
+ start_glyph_index..end_glyph_index
128
+ end
129
+
130
+ # Check if this strike includes a specific glyph ID
131
+ #
132
+ # @param glyph_id [Integer] Glyph ID to check
133
+ # @return [Boolean] True if glyph is in range
134
+ def includes_glyph?(glyph_id)
135
+ glyph_range.include?(glyph_id)
136
+ end
137
+ end
138
+
139
+ # @return [Integer] CBLC version
140
+ attr_reader :version
141
+
142
+ # @return [Integer] Number of bitmap size records
143
+ attr_reader :num_sizes
144
+
145
+ # @return [Array<BitmapSize>] Parsed bitmap size records
146
+ attr_reader :bitmap_sizes
147
+
148
+ # @return [String] Raw binary data for the entire CBLC table
149
+ attr_reader :raw_data
150
+
151
+ # Override read to parse CBLC structure
152
+ #
153
+ # @param io [IO, String] Binary data to read
154
+ # @return [Cblc] Parsed CBLC table
155
+ def self.read(io)
156
+ cblc = new
157
+ return cblc if io.nil?
158
+
159
+ data = io.is_a?(String) ? io : io.read
160
+ cblc.parse!(data)
161
+ cblc
162
+ end
163
+
164
+ # Parse the CBLC table structure
165
+ #
166
+ # @param data [String] Binary data for the CBLC table
167
+ # @raise [CorruptedTableError] If CBLC structure is invalid
168
+ def parse!(data)
169
+ @raw_data = data
170
+ io = StringIO.new(data)
171
+
172
+ # Parse CBLC header (8 bytes)
173
+ parse_header(io)
174
+ validate_header!
175
+
176
+ # Parse bitmap size records
177
+ parse_bitmap_sizes(io)
178
+ rescue StandardError => e
179
+ raise CorruptedTableError, "Failed to parse CBLC table: #{e.message}"
180
+ end
181
+
182
+ # Get bitmap strikes (sizes)
183
+ #
184
+ # @return [Array<BitmapSize>] Array of bitmap strikes
185
+ def strikes
186
+ bitmap_sizes || []
187
+ end
188
+
189
+ # Get strikes for specific ppem size
190
+ #
191
+ # @param ppem [Integer] Pixels per em
192
+ # @return [Array<BitmapSize>] Strikes matching ppem
193
+ def strikes_for_ppem(ppem)
194
+ strikes.select { |size| size.ppem == ppem }
195
+ end
196
+
197
+ # Check if glyph has bitmap at ppem size
198
+ #
199
+ # @param glyph_id [Integer] Glyph ID
200
+ # @param ppem [Integer] Pixels per em
201
+ # @return [Boolean] True if glyph has bitmap
202
+ def has_bitmap_for_glyph?(glyph_id, ppem)
203
+ strikes_for_ppem(ppem).any? do |strike|
204
+ strike.includes_glyph?(glyph_id)
205
+ end
206
+ end
207
+
208
+ # Get all available ppem sizes
209
+ #
210
+ # @return [Array<Integer>] Sorted array of ppem sizes
211
+ def ppem_sizes
212
+ strikes.map(&:ppem).uniq.sort
213
+ end
214
+
215
+ # Get all glyph IDs that have bitmaps across all strikes
216
+ #
217
+ # @return [Array<Integer>] Array of glyph IDs
218
+ def glyph_ids_with_bitmaps
219
+ strikes.flat_map { |strike| strike.glyph_range.to_a }.uniq.sort
220
+ end
221
+
222
+ # Get strikes that include a specific glyph ID
223
+ #
224
+ # @param glyph_id [Integer] Glyph ID
225
+ # @return [Array<BitmapSize>] Strikes containing glyph
226
+ def strikes_for_glyph(glyph_id)
227
+ strikes.select { |strike| strike.includes_glyph?(glyph_id) }
228
+ end
229
+
230
+ # Get the number of bitmap strikes
231
+ #
232
+ # @return [Integer] Number of strikes
233
+ def num_strikes
234
+ num_sizes || 0
235
+ end
236
+
237
+ # Validate the CBLC table structure
238
+ #
239
+ # @return [Boolean] True if valid
240
+ def valid?
241
+ return false if version.nil?
242
+ return false unless [VERSION_2_0, VERSION_3_0].include?(version)
243
+ return false if num_sizes.nil? || num_sizes.negative?
244
+ return false unless bitmap_sizes
245
+
246
+ true
247
+ end
248
+
249
+ private
250
+
251
+ # Parse CBLC header (8 bytes)
252
+ #
253
+ # @param io [StringIO] Input stream
254
+ def parse_header(io)
255
+ @version = io.read(4).unpack1("N")
256
+ @num_sizes = io.read(4).unpack1("N")
257
+ end
258
+
259
+ # Validate header values
260
+ #
261
+ # @raise [CorruptedTableError] If validation fails
262
+ def validate_header!
263
+ unless [VERSION_2_0, VERSION_3_0].include?(version)
264
+ raise CorruptedTableError,
265
+ "Unsupported CBLC version: 0x#{version.to_s(16).upcase} " \
266
+ "(only versions 2.0 and 3.0 supported)"
267
+ end
268
+
269
+ if num_sizes.negative?
270
+ raise CorruptedTableError,
271
+ "Invalid numSizes: #{num_sizes}"
272
+ end
273
+ end
274
+
275
+ # Parse bitmap size records
276
+ #
277
+ # @param io [StringIO] Input stream
278
+ def parse_bitmap_sizes(io)
279
+ @bitmap_sizes = []
280
+ return if num_sizes.zero?
281
+
282
+ # Each BitmapSize record is 48 bytes
283
+ num_sizes.times do
284
+ size_data = io.read(48)
285
+ @bitmap_sizes << BitmapSize.read(size_data)
286
+ end
287
+ end
288
+ end
289
+ end
290
+ end
@@ -268,8 +268,7 @@ module Fontisan
268
268
 
269
269
  PrivateDict.new(private_data)
270
270
  rescue StandardError => e
271
- warn "Failed to parse Private DICT: #{e.message}"
272
- nil
271
+ raise CorruptedTableError, "Failed to parse Private DICT: #{e.message}"
273
272
  end
274
273
 
275
274
  # Get the Local Subr INDEX for a specific font
@@ -300,8 +299,7 @@ module Fontisan
300
299
  io.seek(absolute_offset)
301
300
  Index.new(io, start_offset: absolute_offset)
302
301
  rescue StandardError => e
303
- warn "Failed to parse Local Subr INDEX: #{e.message}"
304
- nil
302
+ raise CorruptedTableError, "Failed to parse Local Subr INDEX: #{e.message}"
305
303
  end
306
304
 
307
305
  # Get the CharStrings INDEX for a specific font
@@ -322,8 +320,7 @@ module Fontisan
322
320
  io.seek(charstrings_offset)
323
321
  CharstringsIndex.new(io, start_offset: charstrings_offset)
324
322
  rescue StandardError => e
325
- warn "Failed to parse CharStrings INDEX: #{e.message}"
326
- nil
323
+ raise CorruptedTableError, "Failed to parse CharStrings INDEX: #{e.message}"
327
324
  end
328
325
 
329
326
  # Get a CharString for a specific glyph
@@ -358,8 +355,7 @@ module Fontisan
358
355
  local_subr_index,
359
356
  )
360
357
  rescue StandardError => e
361
- warn "Failed to get CharString for glyph #{glyph_index}: #{e.message}"
362
- nil
358
+ raise CorruptedTableError, "Failed to get CharString for glyph #{glyph_index}: #{e.message}"
363
359
  end
364
360
 
365
361
  # Get the number of glyphs in a font
@@ -437,8 +433,7 @@ module Fontisan
437
433
  num_glyphs = glyph_count(index)
438
434
  Charset.new(charset_data, num_glyphs, self)
439
435
  rescue StandardError => e
440
- warn "Failed to parse Charset: #{e.message}"
441
- nil
436
+ raise CorruptedTableError, "Failed to parse Charset: #{e.message}"
442
437
  end
443
438
 
444
439
  # Get the Encoding for a specific font
@@ -467,8 +462,7 @@ module Fontisan
467
462
  num_glyphs = glyph_count(index)
468
463
  Encoding.new(encoding_data, num_glyphs)
469
464
  rescue StandardError => e
470
- warn "Failed to parse Encoding: #{e.message}"
471
- nil
465
+ raise CorruptedTableError, "Failed to parse Encoding: #{e.message}"
472
466
  end
473
467
  end
474
468
 
@@ -0,0 +1,291 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+ require_relative "../binary/base_record"
5
+
6
+ module Fontisan
7
+ module Tables
8
+ # COLR (Color) table parser
9
+ #
10
+ # The COLR table defines layered color glyphs where each layer references
11
+ # a glyph ID and a palette index from the CPAL table. This enables fonts
12
+ # to display multi-colored glyphs such as emoji or brand logos.
13
+ #
14
+ # COLR Table Structure:
15
+ # ```
16
+ # COLR Table = Header (14 bytes)
17
+ # + Base Glyph Records (6 bytes each)
18
+ # + Layer Records (4 bytes each)
19
+ # ```
20
+ #
21
+ # Version 0 Structure:
22
+ # - version (uint16): Table version (0)
23
+ # - numBaseGlyphRecords (uint16): Number of base glyphs
24
+ # - baseGlyphRecordsOffset (uint32): Offset to base glyph records array
25
+ # - layerRecordsOffset (uint32): Offset to layer records array
26
+ # - numLayerRecords (uint16): Number of layer records
27
+ #
28
+ # The COLR table must be used together with the CPAL (Color Palette) table
29
+ # which defines the actual RGB color values referenced by palette indices.
30
+ #
31
+ # Reference: OpenType COLR specification
32
+ # https://docs.microsoft.com/en-us/typography/opentype/spec/colr
33
+ #
34
+ # @example Reading a COLR table
35
+ # data = font.table_data['COLR']
36
+ # colr = Fontisan::Tables::Colr.read(data)
37
+ # layers = colr.layers_for_glyph(42)
38
+ # puts "Glyph 42 has #{layers.length} color layers"
39
+ class Colr < Binary::BaseRecord
40
+ # OpenType table tag for COLR
41
+ TAG = "COLR"
42
+
43
+ # Base Glyph Record structure for COLR table
44
+ #
45
+ # Each base glyph record associates a glyph ID with its color layers.
46
+ # Structure (6 bytes): glyph_id, first_layer_index, num_layers
47
+ class BaseGlyphRecord < Binary::BaseRecord
48
+ endian :big
49
+ uint16 :glyph_id
50
+ uint16 :first_layer_index
51
+ uint16 :num_layers
52
+
53
+ def has_layers?
54
+ num_layers.positive?
55
+ end
56
+ end
57
+
58
+ # Layer Record structure for COLR table
59
+ #
60
+ # Each layer record specifies a glyph and palette index.
61
+ # Structure (4 bytes): glyph_id, palette_index
62
+ class LayerRecord < Binary::BaseRecord
63
+ endian :big
64
+ FOREGROUND_COLOR = 0xFFFF
65
+
66
+ uint16 :glyph_id
67
+ uint16 :palette_index
68
+
69
+ def uses_foreground_color?
70
+ palette_index == FOREGROUND_COLOR
71
+ end
72
+
73
+ def uses_palette_color?
74
+ !uses_foreground_color?
75
+ end
76
+ end
77
+
78
+ # @return [Integer] COLR version (0 for version 0)
79
+ attr_reader :version
80
+
81
+ # @return [Integer] Number of base glyph records
82
+ attr_reader :num_base_glyph_records
83
+
84
+ # @return [Integer] Offset to base glyph records array
85
+ attr_reader :base_glyph_records_offset
86
+
87
+ # @return [Integer] Offset to layer records array
88
+ attr_reader :layer_records_offset
89
+
90
+ # @return [Integer] Number of layer records
91
+ attr_reader :num_layer_records
92
+
93
+ # @return [String] Raw binary data for the entire COLR table
94
+ attr_reader :raw_data
95
+
96
+ # @return [Array<BaseGlyphRecord>] Parsed base glyph records
97
+ attr_reader :base_glyph_records
98
+
99
+ # @return [Array<LayerRecord>] Parsed layer records
100
+ attr_reader :layer_records
101
+
102
+ # Override read to parse COLR structure
103
+ #
104
+ # @param io [IO, String] Binary data to read
105
+ # @return [Colr] Parsed COLR table
106
+ def self.read(io)
107
+ colr = new
108
+ return colr if io.nil?
109
+
110
+ data = io.is_a?(String) ? io : io.read
111
+ colr.parse!(data)
112
+ colr
113
+ end
114
+
115
+ # Parse the COLR table structure
116
+ #
117
+ # @param data [String] Binary data for the COLR table
118
+ # @raise [CorruptedTableError] If COLR structure is invalid
119
+ def parse!(data)
120
+ @raw_data = data
121
+ io = StringIO.new(data)
122
+
123
+ # Parse COLR header (14 bytes)
124
+ parse_header(io)
125
+ validate_header!
126
+
127
+ # Parse base glyph records
128
+ parse_base_glyph_records(io)
129
+
130
+ # Parse layer records
131
+ parse_layer_records(io)
132
+ rescue StandardError => e
133
+ raise CorruptedTableError, "Failed to parse COLR table: #{e.message}"
134
+ end
135
+
136
+ # Get color layers for a specific glyph ID
137
+ #
138
+ # Returns an array of LayerRecord objects for the specified glyph.
139
+ # Returns empty array if glyph has no color layers.
140
+ #
141
+ # @param glyph_id [Integer] Glyph ID to look up
142
+ # @return [Array<LayerRecord>] Array of layer records for this glyph
143
+ def layers_for_glyph(glyph_id)
144
+ # Find base glyph record for this glyph ID
145
+ base_record = find_base_glyph_record(glyph_id)
146
+ return [] unless base_record
147
+
148
+ # Extract layers for this glyph
149
+ first_index = base_record.first_layer_index
150
+ num_layers = base_record.num_layers
151
+
152
+ return [] if num_layers.zero?
153
+
154
+ # Return slice of layer records
155
+ layer_records[first_index, num_layers] || []
156
+ end
157
+
158
+ # Check if COLR table has color data for a specific glyph
159
+ #
160
+ # @param glyph_id [Integer] Glyph ID to check
161
+ # @return [Boolean] True if glyph has color layers
162
+ def has_color_glyph?(glyph_id)
163
+ !layers_for_glyph(glyph_id).empty?
164
+ end
165
+
166
+ # Get all glyph IDs that have color data
167
+ #
168
+ # @return [Array<Integer>] Array of glyph IDs with color layers
169
+ def color_glyph_ids
170
+ base_glyph_records.map(&:glyph_id)
171
+ end
172
+
173
+ # Get the number of color glyphs in this table
174
+ #
175
+ # @return [Integer] Number of base glyphs
176
+ def num_color_glyphs
177
+ num_base_glyph_records
178
+ end
179
+
180
+ # Validate the COLR table structure
181
+ #
182
+ # @return [Boolean] True if valid
183
+ def valid?
184
+ return false if version.nil?
185
+ return false if version != 0 # Only version 0 supported currently
186
+ return false if num_base_glyph_records.nil? || num_base_glyph_records.negative?
187
+ return false if num_layer_records.nil? || num_layer_records.negative?
188
+ return false unless base_glyph_records
189
+ return false unless layer_records
190
+
191
+ true
192
+ end
193
+
194
+ private
195
+
196
+ # Parse COLR header (14 bytes)
197
+ #
198
+ # @param io [StringIO] Input stream
199
+ def parse_header(io)
200
+ @version = io.read(2).unpack1("n")
201
+ @num_base_glyph_records = io.read(2).unpack1("n")
202
+ @base_glyph_records_offset = io.read(4).unpack1("N")
203
+ @layer_records_offset = io.read(4).unpack1("N")
204
+ @num_layer_records = io.read(2).unpack1("n")
205
+ end
206
+
207
+ # Validate header values
208
+ #
209
+ # @raise [CorruptedTableError] If validation fails
210
+ def validate_header!
211
+ unless version.zero?
212
+ raise CorruptedTableError,
213
+ "Unsupported COLR version: #{version} (only version 0 supported)"
214
+ end
215
+
216
+ if num_base_glyph_records.negative?
217
+ raise CorruptedTableError,
218
+ "Invalid numBaseGlyphRecords: #{num_base_glyph_records}"
219
+ end
220
+
221
+ if num_layer_records.negative?
222
+ raise CorruptedTableError,
223
+ "Invalid numLayerRecords: #{num_layer_records}"
224
+ end
225
+ end
226
+
227
+ # Parse base glyph records array
228
+ #
229
+ # @param io [StringIO] Input stream
230
+ def parse_base_glyph_records(io)
231
+ @base_glyph_records = []
232
+ return if num_base_glyph_records.zero?
233
+
234
+ # Seek to base glyph records
235
+ io.seek(base_glyph_records_offset)
236
+
237
+ # Parse each base glyph record (6 bytes each)
238
+ num_base_glyph_records.times do
239
+ record_data = io.read(6)
240
+ record = BaseGlyphRecord.read(record_data)
241
+ @base_glyph_records << record
242
+ end
243
+ end
244
+
245
+ # Parse layer records array
246
+ #
247
+ # @param io [StringIO] Input stream
248
+ def parse_layer_records(io)
249
+ @layer_records = []
250
+ return if num_layer_records.zero?
251
+
252
+ # Seek to layer records
253
+ io.seek(layer_records_offset)
254
+
255
+ # Parse each layer record (4 bytes each)
256
+ num_layer_records.times do
257
+ record_data = io.read(4)
258
+ record = LayerRecord.read(record_data)
259
+ @layer_records << record
260
+ end
261
+ end
262
+
263
+ # Find base glyph record for a specific glyph ID
264
+ #
265
+ # Uses binary search since base glyph records are sorted by glyph ID
266
+ #
267
+ # @param glyph_id [Integer] Glyph ID to find
268
+ # @return [BaseGlyphRecord, nil] Base glyph record or nil if not found
269
+ def find_base_glyph_record(glyph_id)
270
+ # Binary search through base glyph records
271
+ left = 0
272
+ right = base_glyph_records.length - 1
273
+
274
+ while left <= right
275
+ mid = (left + right) / 2
276
+ record = base_glyph_records[mid]
277
+
278
+ if record.glyph_id == glyph_id
279
+ return record
280
+ elsif record.glyph_id < glyph_id
281
+ left = mid + 1
282
+ else
283
+ right = mid - 1
284
+ end
285
+ end
286
+
287
+ nil
288
+ end
289
+ end
290
+ end
291
+ end