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,281 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+ require_relative "../binary/base_record"
5
+
6
+ module Fontisan
7
+ module Tables
8
+ # CPAL (Color Palette) table parser
9
+ #
10
+ # The CPAL table defines color palettes used by COLR layers. Each palette
11
+ # contains an array of RGBA color values that can be referenced by the
12
+ # COLR table's palette indices.
13
+ #
14
+ # CPAL Table Structure:
15
+ # ```
16
+ # CPAL Table = Header
17
+ # + Palette Indices Array
18
+ # + Color Records Array
19
+ # + [Palette Types Array] (version 1)
20
+ # + [Palette Labels Array] (version 1)
21
+ # + [Palette Entry Labels Array] (version 1)
22
+ # ```
23
+ #
24
+ # Version 0 Header (12 bytes):
25
+ # - version (uint16): Table version (0 or 1)
26
+ # - numPaletteEntries (uint16): Number of colors per palette
27
+ # - numPalettes (uint16): Number of palettes
28
+ # - numColorRecords (uint16): Total number of color records
29
+ # - colorRecordsArrayOffset (uint32): Offset to color records array
30
+ #
31
+ # Version 1 adds optional metadata for palette types and labels.
32
+ #
33
+ # Color Record Structure (4 bytes, BGRA format):
34
+ # - blue (uint8)
35
+ # - green (uint8)
36
+ # - red (uint8)
37
+ # - alpha (uint8)
38
+ #
39
+ # Reference: OpenType CPAL specification
40
+ # https://docs.microsoft.com/en-us/typography/opentype/spec/cpal
41
+ #
42
+ # @example Reading a CPAL table
43
+ # data = font.table_data['CPAL']
44
+ # cpal = Fontisan::Tables::Cpal.read(data)
45
+ # palette = cpal.palette(0) # Get first palette
46
+ # puts palette.colors.first # => "#RRGGBBAA"
47
+ class Cpal < Binary::BaseRecord
48
+ # OpenType table tag for CPAL
49
+ TAG = "CPAL"
50
+
51
+ # @return [Integer] CPAL version (0 or 1)
52
+ attr_reader :version
53
+
54
+ # @return [Integer] Number of color entries per palette
55
+ attr_reader :num_palette_entries
56
+
57
+ # @return [Integer] Number of palettes in this table
58
+ attr_reader :num_palettes
59
+
60
+ # @return [Integer] Total number of color records
61
+ attr_reader :num_color_records
62
+
63
+ # @return [Integer] Offset to color records array
64
+ attr_reader :color_records_array_offset
65
+
66
+ # @return [String] Raw binary data for the entire CPAL table
67
+ attr_reader :raw_data
68
+
69
+ # @return [Array<Integer>] Palette indices (start index for each palette)
70
+ attr_reader :palette_indices
71
+
72
+ # @return [Array<Hash>] Parsed color records (RGBA hashes)
73
+ attr_reader :color_records
74
+
75
+ # Override read to parse CPAL structure
76
+ #
77
+ # @param io [IO, String] Binary data to read
78
+ # @return [Cpal] Parsed CPAL table
79
+ def self.read(io)
80
+ cpal = new
81
+ return cpal if io.nil?
82
+
83
+ data = io.is_a?(String) ? io : io.read
84
+ cpal.parse!(data)
85
+ cpal
86
+ end
87
+
88
+ # Parse the CPAL table structure
89
+ #
90
+ # @param data [String] Binary data for the CPAL table
91
+ # @raise [CorruptedTableError] If CPAL structure is invalid
92
+ def parse!(data)
93
+ @raw_data = data
94
+ io = StringIO.new(data)
95
+
96
+ # Parse CPAL header
97
+ parse_header(io)
98
+ validate_header!
99
+
100
+ # Parse palette indices array
101
+ parse_palette_indices(io)
102
+
103
+ # Parse color records
104
+ parse_color_records(io)
105
+
106
+ # Version 1 features (palette types, labels) not implemented yet
107
+ # TODO: Add version 1 features in follow-up task
108
+ rescue StandardError => e
109
+ raise CorruptedTableError, "Failed to parse CPAL table: #{e.message}"
110
+ end
111
+
112
+ # Get a specific palette by index
113
+ #
114
+ # Returns an array of color strings in hex format (#RRGGBBAA).
115
+ # Each palette contains num_palette_entries colors.
116
+ #
117
+ # @param index [Integer] Palette index (0-based)
118
+ # @return [Array<String>, nil] Array of hex color strings, or nil if invalid
119
+ def palette(index)
120
+ return nil if index.negative? || index >= num_palettes
121
+
122
+ # Get starting index for this palette
123
+ start_index = palette_indices[index]
124
+
125
+ # Extract colors for this palette
126
+ colors = []
127
+ num_palette_entries.times do |i|
128
+ color_record = color_records[start_index + i]
129
+ colors << color_to_hex(color_record) if color_record
130
+ end
131
+
132
+ colors
133
+ end
134
+
135
+ # Get all palettes
136
+ #
137
+ # @return [Array<Array<String>>] Array of palettes, each an array of hex colors
138
+ def all_palettes
139
+ (0...num_palettes).map { |i| palette(i) }
140
+ end
141
+
142
+ # Get color at specific palette and entry index
143
+ #
144
+ # @param palette_index [Integer] Palette index
145
+ # @param entry_index [Integer] Entry index within palette
146
+ # @return [String, nil] Hex color string or nil
147
+ def color_at(palette_index, entry_index)
148
+ return nil if palette_index.negative? || palette_index >= num_palettes
149
+ return nil if entry_index.negative? || entry_index >= num_palette_entries
150
+
151
+ start_index = palette_indices[palette_index]
152
+ color_record = color_records[start_index + entry_index]
153
+ color_record ? color_to_hex(color_record) : nil
154
+ end
155
+
156
+ # Validate the CPAL table structure
157
+ #
158
+ # @return [Boolean] True if valid
159
+ def valid?
160
+ return false if version.nil?
161
+ return false unless [0, 1].include?(version)
162
+ return false if num_palette_entries.nil? || num_palette_entries.negative?
163
+ return false if num_palettes.nil? || num_palettes.negative?
164
+ return false if num_color_records.nil? || num_color_records.negative?
165
+ return false unless palette_indices
166
+ return false unless color_records
167
+
168
+ true
169
+ end
170
+
171
+ private
172
+
173
+ # Parse CPAL header (12 bytes for version 0, 16 bytes for version 1)
174
+ #
175
+ # @param io [StringIO] Input stream
176
+ def parse_header(io)
177
+ @version = io.read(2).unpack1("n")
178
+ @num_palette_entries = io.read(2).unpack1("n")
179
+ @num_palettes = io.read(2).unpack1("n")
180
+ @num_color_records = io.read(2).unpack1("n")
181
+ @color_records_array_offset = io.read(4).unpack1("N")
182
+
183
+ # Version 1 has additional header fields
184
+ if version == 1
185
+ # TODO: Parse version 1 header fields
186
+ # - paletteTypesArrayOffset (uint32)
187
+ # - paletteLabelsArrayOffset (uint32)
188
+ # - paletteEntryLabelsArrayOffset (uint32)
189
+ end
190
+ end
191
+
192
+ # Validate header values
193
+ #
194
+ # @raise [CorruptedTableError] If validation fails
195
+ def validate_header!
196
+ unless [0, 1].include?(version)
197
+ raise CorruptedTableError,
198
+ "Unsupported CPAL version: #{version} (only versions 0 and 1 supported)"
199
+ end
200
+
201
+ if num_palette_entries.negative?
202
+ raise CorruptedTableError,
203
+ "Invalid numPaletteEntries: #{num_palette_entries}"
204
+ end
205
+
206
+ if num_palettes.negative?
207
+ raise CorruptedTableError,
208
+ "Invalid numPalettes: #{num_palettes}"
209
+ end
210
+
211
+ if num_color_records.negative?
212
+ raise CorruptedTableError,
213
+ "Invalid numColorRecords: #{num_color_records}"
214
+ end
215
+
216
+ # Validate that total color records match expected count
217
+ expected_records = num_palettes * num_palette_entries
218
+ unless num_color_records >= expected_records
219
+ raise CorruptedTableError,
220
+ "Insufficient color records: expected at least #{expected_records}, " \
221
+ "got #{num_color_records}"
222
+ end
223
+ end
224
+
225
+ # Parse palette indices array
226
+ #
227
+ # @param io [StringIO] Input stream
228
+ def parse_palette_indices(io)
229
+ @palette_indices = []
230
+ return if num_palettes.zero?
231
+
232
+ # Palette indices immediately follow header (at offset 12 for v0, 16 for v1)
233
+ # Each index is uint16 (2 bytes)
234
+ num_palettes.times do
235
+ index = io.read(2).unpack1("n")
236
+ @palette_indices << index
237
+ end
238
+ end
239
+
240
+ # Parse color records array
241
+ #
242
+ # @param io [StringIO] Input stream
243
+ def parse_color_records(io)
244
+ @color_records = []
245
+ return if num_color_records.zero?
246
+
247
+ # Seek to color records array
248
+ io.seek(color_records_array_offset)
249
+
250
+ # Parse each color record (4 bytes, BGRA format)
251
+ num_color_records.times do
252
+ blue = io.read(1).unpack1("C")
253
+ green = io.read(1).unpack1("C")
254
+ red = io.read(1).unpack1("C")
255
+ alpha = io.read(1).unpack1("C")
256
+
257
+ @color_records << {
258
+ red: red,
259
+ green: green,
260
+ blue: blue,
261
+ alpha: alpha,
262
+ }
263
+ end
264
+ end
265
+
266
+ # Convert color record to hex string
267
+ #
268
+ # @param color [Hash] Color hash with :red, :green, :blue, :alpha keys
269
+ # @return [String] Hex color string (#RRGGBBAA)
270
+ def color_to_hex(color)
271
+ format(
272
+ "#%<red>02X%<green>02X%<blue>02X%<alpha>02X",
273
+ red: color[:red],
274
+ green: color[:green],
275
+ blue: color[:blue],
276
+ alpha: color[:alpha],
277
+ )
278
+ end
279
+ end
280
+ end
281
+ end
@@ -76,7 +76,7 @@ module Fontisan
76
76
  contours = outline.to_truetype_contours
77
77
  raise ArgumentError, "no contours in outline" if contours.empty?
78
78
 
79
- # Calculate bounding box from contours
79
+ # Calculate bounding box from contours (on-curve points only)
80
80
  bbox = calculate_bounding_box(contours)
81
81
 
82
82
  # Build binary data
@@ -389,6 +389,10 @@ axis)
389
389
 
390
390
  contours.each do |contour|
391
391
  contour.each do |point|
392
+ # Only consider on-curve points for bounding box
393
+ # Off-curve points are control points and may lie outside the actual outline
394
+ next unless point[:on_curve]
395
+
392
396
  x = point[:x]
393
397
  y = point[:y]
394
398
 
@@ -0,0 +1,379 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+ require_relative "../binary/base_record"
5
+
6
+ module Fontisan
7
+ module Tables
8
+ # sbix (Standard Bitmap Graphics) table parser
9
+ #
10
+ # The sbix table contains embedded bitmap graphics (PNG, JPEG, TIFF)
11
+ # organized by strike sizes. This is Apple's format for color emoji.
12
+ #
13
+ # sbix Table Structure:
14
+ # ```
15
+ # sbix Table = Header (8 bytes)
16
+ # + Strike Offsets Array (4 bytes × numStrikes)
17
+ # + Strike Data (variable)
18
+ # ```
19
+ #
20
+ # Header (8 bytes):
21
+ # - version (uint16): Table version (1)
22
+ # - flags (uint16): Flags (0)
23
+ # - numStrikes (uint32): Number of bitmap strikes
24
+ #
25
+ # Each Strike contains:
26
+ # - ppem (uint16): Pixels per em
27
+ # - ppi (uint16): Pixels per inch (usually 72)
28
+ # - glyphDataOffsets (uint32 × numGlyphs+1): Array of glyph data offsets
29
+ # - glyph data records (variable)
30
+ #
31
+ # Glyph Data Record:
32
+ # - originOffsetX (int16): X offset
33
+ # - originOffsetY (int16): Y offset
34
+ # - graphicType (uint32): 'png ', 'jpg ', 'tiff', 'dupe', 'mask'
35
+ # - data (variable): Image data
36
+ #
37
+ # Reference: https://docs.microsoft.com/en-us/typography/opentype/spec/sbix
38
+ #
39
+ # @example Reading an sbix table
40
+ # data = font.table_data['sbix']
41
+ # sbix = Fontisan::Tables::Sbix.read(data)
42
+ # strikes = sbix.strikes
43
+ # png_data = sbix.glyph_data(42, 64) # Get glyph 42 at 64 ppem
44
+ class Sbix < Binary::BaseRecord
45
+ # OpenType table tag for sbix
46
+ TAG = "sbix"
47
+
48
+ # Supported sbix version
49
+ VERSION_1 = 1
50
+
51
+ # Graphic type constants (4-byte ASCII codes)
52
+ GRAPHIC_TYPE_PNG = 0x706E6720 # 'png '
53
+ GRAPHIC_TYPE_JPG = 0x6A706720 # 'jpg '
54
+ GRAPHIC_TYPE_TIFF = 0x74696666 # 'tiff'
55
+ GRAPHIC_TYPE_DUPE = 0x64757065 # 'dupe'
56
+ GRAPHIC_TYPE_MASK = 0x6D61736B # 'mask'
57
+
58
+ # Graphic type names
59
+ GRAPHIC_TYPE_NAMES = {
60
+ GRAPHIC_TYPE_PNG => "PNG",
61
+ GRAPHIC_TYPE_JPG => "JPEG",
62
+ GRAPHIC_TYPE_TIFF => "TIFF",
63
+ GRAPHIC_TYPE_DUPE => "dupe",
64
+ GRAPHIC_TYPE_MASK => "mask",
65
+ }.freeze
66
+
67
+ # @return [Integer] sbix version (should be 1)
68
+ attr_reader :version
69
+
70
+ # @return [Integer] Flags (reserved, should be 0)
71
+ attr_reader :flags
72
+
73
+ # @return [Integer] Number of bitmap strikes
74
+ attr_reader :num_strikes
75
+
76
+ # @return [Array<Integer>] Offsets to strike data from start of table
77
+ attr_reader :strike_offsets
78
+
79
+ # @return [Array<Hash>] Parsed strike records
80
+ attr_reader :strikes
81
+
82
+ # @return [String] Raw binary data for the entire sbix table
83
+ attr_reader :raw_data
84
+
85
+ # Override read to parse sbix structure
86
+ #
87
+ # @param io [IO, String] Binary data to read
88
+ # @return [Sbix] Parsed sbix table
89
+ def self.read(io)
90
+ sbix = new
91
+ return sbix if io.nil?
92
+
93
+ data = io.is_a?(String) ? io : io.read
94
+ sbix.parse!(data)
95
+ sbix
96
+ end
97
+
98
+ # Parse the sbix table structure
99
+ #
100
+ # @param data [String] Binary data for the sbix table
101
+ # @raise [CorruptedTableError] If sbix structure is invalid
102
+ def parse!(data)
103
+ @raw_data = data
104
+ io = StringIO.new(data)
105
+
106
+ # Parse sbix header (8 bytes)
107
+ parse_header(io)
108
+ validate_header!
109
+
110
+ # Parse strike offsets
111
+ parse_strike_offsets(io)
112
+
113
+ # Parse strike records
114
+ parse_strikes
115
+ rescue StandardError => e
116
+ raise CorruptedTableError, "Failed to parse sbix table: #{e.message}"
117
+ end
118
+
119
+ # Get glyph data at specific ppem
120
+ #
121
+ # @param glyph_id [Integer] Glyph ID
122
+ # @param ppem [Integer] Pixels per em
123
+ # @return [Hash, nil] Glyph data hash with keys: :origin_x, :origin_y, :graphic_type, :data
124
+ def glyph_data(glyph_id, ppem)
125
+ strike = strike_for_ppem(ppem)
126
+ return nil unless strike
127
+
128
+ extract_glyph_data(strike, glyph_id)
129
+ end
130
+
131
+ # Get strike for specific ppem
132
+ #
133
+ # @param ppem [Integer] Pixels per em
134
+ # @return [Hash, nil] Strike record or nil
135
+ def strike_for_ppem(ppem)
136
+ strikes&.find { |s| s[:ppem] == ppem }
137
+ end
138
+
139
+ # Get all ppem sizes
140
+ #
141
+ # @return [Array<Integer>] Sorted array of ppem sizes
142
+ def ppem_sizes
143
+ return [] unless strikes
144
+
145
+ strikes.map { |s| s[:ppem] }.uniq.sort
146
+ end
147
+
148
+ # Check if glyph has bitmap at ppem
149
+ #
150
+ # @param glyph_id [Integer] Glyph ID
151
+ # @param ppem [Integer] Pixels per em
152
+ # @return [Boolean] True if glyph has bitmap
153
+ def has_glyph_at_ppem?(glyph_id, ppem)
154
+ data = glyph_data(glyph_id, ppem)
155
+ !data.nil? && data[:data] && !data[:data].empty?
156
+ end
157
+
158
+ # Get supported graphic formats across all strikes
159
+ #
160
+ # @return [Array<String>] Array of format names (e.g., ["PNG", "JPEG"])
161
+ def supported_formats
162
+ return [] unless strikes
163
+
164
+ formats = []
165
+ strikes.each do |strike|
166
+ # Sample first few glyphs to detect formats
167
+ strike[:graphic_types]&.each do |type|
168
+ format_name = GRAPHIC_TYPE_NAMES[type]
169
+ formats << format_name if format_name && !["dupe", "mask"].include?(format_name)
170
+ end
171
+ end
172
+ formats.uniq.compact
173
+ end
174
+
175
+ # Validate the sbix table structure
176
+ #
177
+ # @return [Boolean] True if valid
178
+ def valid?
179
+ return false if version.nil?
180
+ return false if version != VERSION_1
181
+ return false if num_strikes.nil? || num_strikes.negative?
182
+ return false unless strikes
183
+
184
+ true
185
+ end
186
+
187
+ private
188
+
189
+ # Parse sbix header (8 bytes)
190
+ #
191
+ # @param io [StringIO] Input stream
192
+ def parse_header(io)
193
+ @version = io.read(2).unpack1("n")
194
+ @flags = io.read(2).unpack1("n")
195
+ @num_strikes = io.read(4).unpack1("N")
196
+ end
197
+
198
+ # Validate header values
199
+ #
200
+ # @raise [CorruptedTableError] If validation fails
201
+ def validate_header!
202
+ unless version == VERSION_1
203
+ raise CorruptedTableError,
204
+ "Unsupported sbix version: #{version} (only version 1 supported)"
205
+ end
206
+
207
+ if num_strikes.negative?
208
+ raise CorruptedTableError,
209
+ "Invalid numStrikes: #{num_strikes}"
210
+ end
211
+ end
212
+
213
+ # Parse strike offsets array
214
+ #
215
+ # @param io [StringIO] Input stream
216
+ def parse_strike_offsets(io)
217
+ @strike_offsets = []
218
+ return if num_strikes.zero?
219
+
220
+ num_strikes.times do
221
+ @strike_offsets << io.read(4).unpack1("N")
222
+ end
223
+ end
224
+
225
+ # Parse all strike records
226
+ #
227
+ # The number of glyphs is calculated from offset differences
228
+ def parse_strikes
229
+ @strikes = []
230
+ return if num_strikes.zero?
231
+
232
+ strike_offsets.each_with_index do |offset, index|
233
+ # Calculate strike size from offset difference
234
+ next_offset = if index < num_strikes - 1
235
+ strike_offsets[index + 1]
236
+ else
237
+ raw_data.length
238
+ end
239
+
240
+ strike = parse_strike(offset, next_offset - offset)
241
+ @strikes << strike
242
+ end
243
+ end
244
+
245
+ # Parse a single strike record
246
+ #
247
+ # @param offset [Integer] Offset from start of table
248
+ # @param size [Integer] Size of strike data
249
+ # @return [Hash] Strike record
250
+ def parse_strike(offset, size)
251
+ io = StringIO.new(raw_data)
252
+ io.seek(offset)
253
+
254
+ ppem = io.read(2).unpack1("n")
255
+ ppi = io.read(2).unpack1("n")
256
+
257
+ # Read glyph data offsets - they're relative to the start of the strike
258
+ # The array is numGlyphs+1 long, with the last offset marking the end
259
+ glyph_offsets = []
260
+
261
+ # Keep reading offsets until we find the pattern
262
+ # Offsets are relative to strike start, so they should be monotonically increasing
263
+ loop do
264
+ current_pos = io.pos
265
+ break if current_pos >= offset + size
266
+
267
+ offset_value = io.read(4)&.unpack1("N")
268
+ break unless offset_value
269
+
270
+ # If offset is beyond the strike size or smaller than previous, we've hit glyph data
271
+ if glyph_offsets.any? && offset_value < glyph_offsets.last
272
+ # Rewind - we read part of glyph data
273
+ io.seek(current_pos)
274
+ break
275
+ end
276
+
277
+ glyph_offsets << offset_value
278
+ end
279
+
280
+ num_glyphs = [glyph_offsets.length - 1, 0].max
281
+
282
+ # Sample graphic types from first few glyphs
283
+ graphic_types = sample_graphic_types(offset, glyph_offsets, size)
284
+
285
+ {
286
+ ppem: ppem,
287
+ ppi: ppi,
288
+ num_glyphs: num_glyphs,
289
+ base_offset: offset,
290
+ glyph_offsets: glyph_offsets,
291
+ graphic_types: graphic_types,
292
+ }
293
+ end
294
+
295
+ # Sample graphic types from first few glyphs
296
+ #
297
+ # @param strike_offset [Integer] Strike offset from table start
298
+ # @param glyph_offsets [Array<Integer>] Glyph data offsets (relative to strike start)
299
+ # @param strike_size [Integer] Total strike size
300
+ # @return [Array<Integer>] Unique graphic type codes found
301
+ def sample_graphic_types(strike_offset, glyph_offsets, strike_size)
302
+ types = []
303
+ return types if glyph_offsets.length < 2
304
+
305
+ # Sample first 5 glyphs or all glyphs if fewer
306
+ sample_count = [5, glyph_offsets.length - 1].min
307
+
308
+ sample_count.times do |i|
309
+ # Offsets are relative to strike start
310
+ glyph_offset = glyph_offsets[i]
311
+ next_glyph_offset = glyph_offsets[i + 1]
312
+
313
+ # Check if offsets are valid
314
+ next if glyph_offset >= strike_size || next_glyph_offset > strike_size
315
+ next if next_glyph_offset <= glyph_offset # Empty glyph
316
+
317
+ # Calculate absolute offset in table
318
+ # glyph_offset is relative to strike start, so add strike_offset
319
+ absolute_offset = strike_offset + glyph_offset
320
+ next if absolute_offset + 8 > raw_data.length # Need at least header
321
+
322
+ # Read graphic type (skip originOffsetX and originOffsetY = 4 bytes)
323
+ io = StringIO.new(raw_data)
324
+ io.seek(absolute_offset + 4)
325
+ graphic_type = io.read(4)&.unpack1("N")
326
+ types << graphic_type if graphic_type
327
+ end
328
+
329
+ types.compact.uniq
330
+ end
331
+
332
+ # Extract glyph data from strike
333
+ #
334
+ # @param strike [Hash] Strike record
335
+ # @param glyph_id [Integer] Glyph ID
336
+ # @return [Hash, nil] Glyph data or nil
337
+ def extract_glyph_data(strike, glyph_id)
338
+ return nil unless strike
339
+ return nil if glyph_id >= strike[:num_glyphs]
340
+ return nil unless strike[:glyph_offsets]
341
+ return nil if glyph_id >= strike[:glyph_offsets].length - 1
342
+
343
+ # Offsets are relative to strike start
344
+ offset = strike[:glyph_offsets][glyph_id]
345
+ next_offset = strike[:glyph_offsets][glyph_id + 1]
346
+
347
+ return nil unless offset && next_offset
348
+ return nil if next_offset <= offset # Empty glyph
349
+
350
+ # Calculate absolute position in table
351
+ absolute_offset = strike[:base_offset] + offset
352
+ data_length = next_offset - offset
353
+
354
+ # Need at least 8 bytes for glyph record header
355
+ return nil if data_length < 8
356
+ return nil if absolute_offset + data_length > raw_data.length
357
+
358
+ # Parse glyph data record
359
+ io = StringIO.new(raw_data)
360
+ io.seek(absolute_offset)
361
+
362
+ origin_x = io.read(2).unpack1("s>") # int16 big-endian
363
+ origin_y = io.read(2).unpack1("s>") # int16 big-endian
364
+ graphic_type = io.read(4).unpack1("N")
365
+
366
+ # Remaining bytes are the actual image data
367
+ image_data = io.read(data_length - 8)
368
+
369
+ {
370
+ origin_x: origin_x,
371
+ origin_y: origin_y,
372
+ graphic_type: graphic_type,
373
+ graphic_type_name: GRAPHIC_TYPE_NAMES[graphic_type] || "unknown",
374
+ data: image_data,
375
+ }
376
+ end
377
+ end
378
+ end
379
+ end