fontisan 0.1.0

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 (56) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +13 -0
  4. data/.rubocop_todo.yml +217 -0
  5. data/Gemfile +15 -0
  6. data/LICENSE +24 -0
  7. data/README.adoc +984 -0
  8. data/Rakefile +95 -0
  9. data/exe/fontisan +7 -0
  10. data/fontisan.gemspec +44 -0
  11. data/lib/fontisan/binary/base_record.rb +57 -0
  12. data/lib/fontisan/binary/structures.rb +84 -0
  13. data/lib/fontisan/cli.rb +192 -0
  14. data/lib/fontisan/commands/base_command.rb +82 -0
  15. data/lib/fontisan/commands/dump_table_command.rb +71 -0
  16. data/lib/fontisan/commands/features_command.rb +94 -0
  17. data/lib/fontisan/commands/glyphs_command.rb +50 -0
  18. data/lib/fontisan/commands/info_command.rb +120 -0
  19. data/lib/fontisan/commands/optical_size_command.rb +41 -0
  20. data/lib/fontisan/commands/scripts_command.rb +59 -0
  21. data/lib/fontisan/commands/tables_command.rb +52 -0
  22. data/lib/fontisan/commands/unicode_command.rb +76 -0
  23. data/lib/fontisan/commands/variable_command.rb +61 -0
  24. data/lib/fontisan/config/features.yml +143 -0
  25. data/lib/fontisan/config/scripts.yml +42 -0
  26. data/lib/fontisan/constants.rb +78 -0
  27. data/lib/fontisan/error.rb +15 -0
  28. data/lib/fontisan/font_loader.rb +109 -0
  29. data/lib/fontisan/formatters/text_formatter.rb +314 -0
  30. data/lib/fontisan/models/all_scripts_features_info.rb +21 -0
  31. data/lib/fontisan/models/features_info.rb +42 -0
  32. data/lib/fontisan/models/font_info.rb +99 -0
  33. data/lib/fontisan/models/glyph_info.rb +26 -0
  34. data/lib/fontisan/models/optical_size_info.rb +33 -0
  35. data/lib/fontisan/models/scripts_info.rb +39 -0
  36. data/lib/fontisan/models/table_info.rb +55 -0
  37. data/lib/fontisan/models/unicode_mappings.rb +42 -0
  38. data/lib/fontisan/models/variable_font_info.rb +82 -0
  39. data/lib/fontisan/open_type_collection.rb +97 -0
  40. data/lib/fontisan/open_type_font.rb +292 -0
  41. data/lib/fontisan/parsers/tag.rb +77 -0
  42. data/lib/fontisan/tables/cmap.rb +284 -0
  43. data/lib/fontisan/tables/fvar.rb +157 -0
  44. data/lib/fontisan/tables/gpos.rb +111 -0
  45. data/lib/fontisan/tables/gsub.rb +111 -0
  46. data/lib/fontisan/tables/head.rb +114 -0
  47. data/lib/fontisan/tables/layout_common.rb +73 -0
  48. data/lib/fontisan/tables/name.rb +188 -0
  49. data/lib/fontisan/tables/os2.rb +175 -0
  50. data/lib/fontisan/tables/post.rb +148 -0
  51. data/lib/fontisan/true_type_collection.rb +98 -0
  52. data/lib/fontisan/true_type_font.rb +313 -0
  53. data/lib/fontisan/utilities/checksum_calculator.rb +89 -0
  54. data/lib/fontisan/version.rb +5 -0
  55. data/lib/fontisan.rb +80 -0
  56. metadata +150 -0
@@ -0,0 +1,292 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bindata"
4
+ require_relative "constants"
5
+ require_relative "utilities/checksum_calculator"
6
+
7
+ module Fontisan
8
+ # OpenType Font domain object using BinData
9
+ #
10
+ # Represents a complete OpenType Font file (CFF outlines) using BinData's declarative
11
+ # DSL for binary structure definition. Parallel to TrueTypeFont but for CFF format.
12
+ #
13
+ # @example Reading and analyzing a font
14
+ # otf = OpenTypeFont.from_file("font.otf")
15
+ # puts otf.header.num_tables # => 12
16
+ # name_table = otf.table("name")
17
+ # puts name_table.english_name(Tables::Name::FAMILY)
18
+ #
19
+ # @example Writing a font
20
+ # otf.to_file("output.otf")
21
+ class OpenTypeFont < BinData::Record
22
+ endian :big
23
+
24
+ offset_table :header
25
+ array :tables, type: :table_directory, initial_length: lambda {
26
+ header.num_tables
27
+ }
28
+
29
+ # Table data is stored separately since it's at variable offsets
30
+ attr_accessor :table_data
31
+
32
+ # Parsed table instances cache
33
+ attr_accessor :parsed_tables
34
+
35
+ # Read OpenType Font from a file
36
+ #
37
+ # @param path [String] Path to the OTF file
38
+ # @return [OpenTypeFont] A new instance
39
+ # @raise [ArgumentError] if path is nil or empty
40
+ # @raise [Errno::ENOENT] if file does not exist
41
+ # @raise [RuntimeError] if file format is invalid
42
+ def self.from_file(path)
43
+ if path.nil? || path.to_s.empty?
44
+ raise ArgumentError,
45
+ "path cannot be nil or empty"
46
+ end
47
+ raise Errno::ENOENT, "File not found: #{path}" unless File.exist?(path)
48
+
49
+ File.open(path, "rb") do |io|
50
+ font = read(io)
51
+ font.initialize_storage
52
+ font.read_table_data(io)
53
+ font
54
+ end
55
+ rescue BinData::ValidityError, EOFError => e
56
+ raise "Invalid OTF file: #{e.message}"
57
+ end
58
+
59
+ # Read OpenType Font from collection at specific offset
60
+ #
61
+ # @param io [IO] Open file handle
62
+ # @param offset [Integer] Byte offset to the font
63
+ # @return [OpenTypeFont] A new instance
64
+ def self.from_collection(io, offset)
65
+ io.seek(offset)
66
+ font = read(io)
67
+ font.initialize_storage
68
+ font.read_table_data(io)
69
+ font
70
+ end
71
+
72
+ # Initialize storage hashes
73
+ #
74
+ # @return [void]
75
+ def initialize_storage
76
+ @table_data = {}
77
+ @parsed_tables = {}
78
+ end
79
+
80
+ # Read table data for all tables
81
+ #
82
+ # @param io [IO] Open file handle
83
+ # @return [void]
84
+ def read_table_data(io)
85
+ @table_data = {}
86
+ tables.each do |entry|
87
+ io.seek(entry.offset)
88
+ # Force UTF-8 encoding on tag for hash key consistency
89
+ tag_key = entry.tag.dup.force_encoding("UTF-8")
90
+ @table_data[tag_key] = io.read(entry.table_length)
91
+ end
92
+ end
93
+
94
+ # Write OpenType Font to a file
95
+ #
96
+ # Writes the complete OTF structure to disk, including proper checksum
97
+ # calculation and table alignment.
98
+ #
99
+ # @param path [String] Path where the OTF file will be written
100
+ # @return [Integer] Number of bytes written
101
+ # @raise [IOError] if writing fails
102
+ def to_file(path)
103
+ File.open(path, "wb") do |io|
104
+ # Write header and tables (directory)
105
+ write_structure(io)
106
+
107
+ # Write table data with updated offsets
108
+ write_table_data_with_offsets(io)
109
+
110
+ io.pos
111
+ end
112
+
113
+ # Update checksum adjustment in head table
114
+ update_checksum_adjustment_in_file(path) if head_table
115
+
116
+ File.size(path)
117
+ end
118
+
119
+ # Validate format correctness
120
+ #
121
+ # @return [Boolean] true if the OTF format is valid, false otherwise
122
+ def valid?
123
+ return false unless header
124
+ return false unless tables.respond_to?(:length)
125
+ return false unless @table_data.is_a?(Hash)
126
+ return false if tables.length != header.num_tables
127
+ return false unless head_table
128
+ return false unless has_table?(Constants::CFF_TAG)
129
+
130
+ true
131
+ end
132
+
133
+ # Check if font has a specific table
134
+ #
135
+ # @param tag [String] The table tag to check for
136
+ # @return [Boolean] true if table exists, false otherwise
137
+ def has_table?(tag)
138
+ tables.any? { |entry| entry.tag == tag }
139
+ end
140
+
141
+ # Find a table entry by tag
142
+ #
143
+ # @param tag [String] The table tag to find
144
+ # @return [TableDirectory, nil] The table entry or nil
145
+ def find_table_entry(tag)
146
+ tables.find { |entry| entry.tag == tag }
147
+ end
148
+
149
+ # Get the head table entry
150
+ #
151
+ # @return [TableDirectory, nil] The head table entry or nil
152
+ def head_table
153
+ find_table_entry(Constants::HEAD_TAG)
154
+ end
155
+
156
+ # Get list of all table tags
157
+ #
158
+ # @return [Array<String>] Array of table tag strings
159
+ def table_names
160
+ tables.map(&:tag)
161
+ end
162
+
163
+ # Get parsed table instance
164
+ #
165
+ # This method parses the raw table data into a structured table object
166
+ # and caches the result for subsequent calls.
167
+ #
168
+ # @param tag [String] The table tag to retrieve
169
+ # @return [Tables::*, nil] Parsed table object or nil if not found
170
+ def table(tag)
171
+ @parsed_tables[tag] ||= parse_table(tag)
172
+ end
173
+
174
+ # Get units per em from head table
175
+ #
176
+ # @return [Integer, nil] Units per em value
177
+ def units_per_em
178
+ head = table(Constants::HEAD_TAG)
179
+ head&.units_per_em
180
+ end
181
+
182
+ private
183
+
184
+ # Parse a table from raw data
185
+ #
186
+ # @param tag [String] The table tag to parse
187
+ # @return [Tables::*, nil] Parsed table object or nil
188
+ def parse_table(tag)
189
+ raw_data = @table_data[tag]
190
+ return nil unless raw_data
191
+
192
+ table_class = table_class_for(tag)
193
+ return nil unless table_class
194
+
195
+ table_class.read(raw_data)
196
+ end
197
+
198
+ # Map table tag to parser class
199
+ #
200
+ # @param tag [String] The table tag
201
+ # @return [Class, nil] Table parser class or nil
202
+ def table_class_for(tag)
203
+ {
204
+ Constants::HEAD_TAG => Tables::Head,
205
+ Constants::NAME_TAG => Tables::Name,
206
+ Constants::OS2_TAG => Tables::Os2,
207
+ Constants::POST_TAG => Tables::Post,
208
+ Constants::CMAP_TAG => Tables::Cmap,
209
+ Constants::FVAR_TAG => Tables::Fvar,
210
+ Constants::GSUB_TAG => Tables::Gsub,
211
+ Constants::GPOS_TAG => Tables::Gpos,
212
+ }[tag]
213
+ end
214
+
215
+ # Write the structure (header + table directory) to IO
216
+ #
217
+ # @param io [IO] Open file handle
218
+ # @return [void]
219
+ def write_structure(io)
220
+ # Write header
221
+ header.write(io)
222
+
223
+ # Write table directory with placeholder offsets
224
+ tables.each do |entry|
225
+ io.write(entry.tag)
226
+ io.write([entry.checksum].pack("N"))
227
+ io.write([0].pack("N")) # Placeholder offset
228
+ io.write([entry.table_length].pack("N"))
229
+ end
230
+ end
231
+
232
+ # Write table data and update offsets in directory
233
+ #
234
+ # @param io [IO] Open file handle
235
+ # @return [void]
236
+ def write_table_data_with_offsets(io)
237
+ tables.each_with_index do |entry, index|
238
+ # Record current position
239
+ current_position = io.pos
240
+
241
+ # Write table data
242
+ data = @table_data[entry.tag]
243
+ raise IOError, "Missing table data for tag '#{entry.tag}'" if data.nil?
244
+
245
+ io.write(data)
246
+
247
+ # Add padding to align to 4-byte boundary
248
+ padding = (Constants::TABLE_ALIGNMENT - (io.pos % Constants::TABLE_ALIGNMENT)) % Constants::TABLE_ALIGNMENT
249
+ io.write("\x00" * padding) if padding.positive?
250
+
251
+ # Zero out checksumAdjustment field in head table
252
+ if entry.tag == Constants::HEAD_TAG
253
+ current_pos = io.pos
254
+ io.seek(current_position + 8)
255
+ io.write([0].pack("N"))
256
+ io.seek(current_pos)
257
+ end
258
+
259
+ # Update offset in table directory
260
+ # Table directory starts at byte 12, each entry is 16 bytes
261
+ # Offset field is at byte 8 within each entry
262
+ directory_offset_position = 12 + (index * 16) + 8
263
+ current_pos = io.pos
264
+ io.seek(directory_offset_position)
265
+ io.write([current_position].pack("N"))
266
+ io.seek(current_pos)
267
+ end
268
+ end
269
+
270
+ # Update checksumAdjustment field in head table
271
+ #
272
+ # @param path [String] Path to the OTF file
273
+ # @return [void]
274
+ def update_checksum_adjustment_in_file(path)
275
+ # Calculate file checksum
276
+ checksum = Utilities::ChecksumCalculator.calculate_file_checksum(path)
277
+
278
+ # Calculate adjustment
279
+ adjustment = Utilities::ChecksumCalculator.calculate_adjustment(checksum)
280
+
281
+ # Find head table position
282
+ head_entry = head_table
283
+ return unless head_entry
284
+
285
+ # Write adjustment to head table (offset 8 within head table)
286
+ File.open(path, "r+b") do |io|
287
+ io.seek(head_entry.offset + 8)
288
+ io.write([adjustment].pack("N"))
289
+ end
290
+ end
291
+ end
292
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Parsers
5
+ # Represents an OpenType tag (4-character identifier)
6
+ #
7
+ # OpenType tags are four-byte identifiers used to identify tables,
8
+ # scripts, languages, and features. Tags are case-sensitive and
9
+ # padded with spaces if shorter than 4 characters.
10
+ class Tag
11
+ attr_reader :value
12
+
13
+ # Initialize a new Tag
14
+ #
15
+ # @param value [String] Tag value (1-4 characters)
16
+ # @raise [Fontisan::Error] If value is not a String
17
+ def initialize(value)
18
+ @value = normalize_tag(value)
19
+ end
20
+
21
+ # Convert tag to string
22
+ #
23
+ # @return [String] 4-character tag string
24
+ def to_s
25
+ @value
26
+ end
27
+
28
+ # Compare tag with another tag or string
29
+ #
30
+ # @param other [Tag, String] Object to compare with
31
+ # @return [Boolean] True if tags are equal
32
+ def ==(other)
33
+ case other
34
+ when Tag
35
+ @value == other.value
36
+ when String
37
+ @value == normalize_tag(other)
38
+ else
39
+ false
40
+ end
41
+ end
42
+
43
+ alias eql? ==
44
+
45
+ # Generate hash for use as Hash key
46
+ #
47
+ # @return [Integer] Hash value
48
+ def hash
49
+ @value.hash
50
+ end
51
+
52
+ # Check if tag is valid (exactly 4 characters)
53
+ #
54
+ # @return [Boolean] True if tag is valid
55
+ def valid?
56
+ @value.length == 4
57
+ end
58
+
59
+ private
60
+
61
+ # Normalize tag to 4 characters
62
+ #
63
+ # @param tag [String] Tag to normalize
64
+ # @return [String] Normalized 4-character tag
65
+ # @raise [Fontisan::Error] If tag is not a String
66
+ def normalize_tag(tag)
67
+ case tag
68
+ when String
69
+ tag = tag.slice(0, 4).ljust(4, " ")
70
+ else
71
+ raise Error, "Invalid tag: #{tag.inspect}"
72
+ end
73
+ tag
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,284 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../binary/base_record"
4
+
5
+ module Fontisan
6
+ module Tables
7
+ # Parser for the 'cmap' (Character to Glyph Index Mapping) table
8
+ #
9
+ # The cmap table maps character codes to glyph indices. It supports
10
+ # multiple encoding formats to accommodate different character sets and
11
+ # Unicode planes.
12
+ #
13
+ # This implementation focuses on:
14
+ # - Format 4: Segment mapping for BMP (Basic Multilingual Plane, U+0000-U+FFFF)
15
+ # - Format 12: Segmented coverage for full Unicode support
16
+ #
17
+ # Reference: OpenType specification, cmap table
18
+ class Cmap < Binary::BaseRecord
19
+ # Platform IDs
20
+ PLATFORM_UNICODE = 0
21
+ PLATFORM_MACINTOSH = 1
22
+ PLATFORM_MICROSOFT = 3
23
+
24
+ # Microsoft Encoding IDs
25
+ ENC_MS_UNICODE_BMP = 1 # Unicode BMP (UCS-2)
26
+ ENC_MS_UNICODE_UCS4 = 10 # Unicode full repertoire (UCS-4)
27
+
28
+ endian :big
29
+
30
+ uint16 :version
31
+ uint16 :num_tables
32
+ rest :remaining_data
33
+
34
+ # Parse encoding records and subtables
35
+ def unicode_mappings
36
+ @unicode_mappings ||= parse_mappings
37
+ end
38
+
39
+ private
40
+
41
+ # Parse all encoding records and extract Unicode mappings
42
+ def parse_mappings
43
+ mappings = {}
44
+
45
+ # Get the full binary data
46
+ data = to_binary_s
47
+
48
+ # Read encoding records
49
+ records = read_encoding_records(data)
50
+
51
+ # Try to find the best Unicode subtable
52
+ # Prefer Microsoft Unicode UCS-4 (format 12), then Unicode BMP (format 4)
53
+ subtable_data = find_best_unicode_subtable(records, data)
54
+
55
+ return mappings unless subtable_data
56
+
57
+ # Parse the subtable based on its format
58
+ format = subtable_data[0, 2].unpack1("n")
59
+
60
+ case format
61
+ when 4
62
+ parse_format_4(subtable_data, mappings)
63
+ when 12
64
+ parse_format_12(subtable_data, mappings)
65
+ end
66
+
67
+ mappings
68
+ end
69
+
70
+ # Read encoding records from the beginning of the table
71
+ def read_encoding_records(data)
72
+ records = []
73
+ offset = 4 # Skip version and numTables
74
+
75
+ num_tables.times do
76
+ break if offset + 8 > data.length
77
+
78
+ platform_id = data[offset, 2].unpack1("n")
79
+ encoding_id = data[offset + 2, 2].unpack1("n")
80
+ subtable_offset = data[offset + 4, 4].unpack1("N")
81
+
82
+ records << {
83
+ platform_id: platform_id,
84
+ encoding_id: encoding_id,
85
+ offset: subtable_offset,
86
+ }
87
+
88
+ offset += 8
89
+ end
90
+
91
+ records
92
+ end
93
+
94
+ # Find the best Unicode subtable from encoding records
95
+ def find_best_unicode_subtable(records, data)
96
+ # Try in priority order: UCS-4, BMP, Unicode
97
+ find_ucs4_subtable(records, data) ||
98
+ find_bmp_subtable(records, data) ||
99
+ find_unicode_subtable(records, data)
100
+ end
101
+
102
+ # Find Microsoft Unicode UCS-4 subtable (full Unicode)
103
+ def find_ucs4_subtable(records, data)
104
+ record = records.find do |r|
105
+ r[:platform_id] == PLATFORM_MICROSOFT &&
106
+ r[:encoding_id] == ENC_MS_UNICODE_UCS4
107
+ end
108
+ extract_subtable_data(record, data)
109
+ end
110
+
111
+ # Find Microsoft Unicode BMP subtable
112
+ def find_bmp_subtable(records, data)
113
+ record = records.find do |r|
114
+ r[:platform_id] == PLATFORM_MICROSOFT &&
115
+ r[:encoding_id] == ENC_MS_UNICODE_BMP
116
+ end
117
+ extract_subtable_data(record, data)
118
+ end
119
+
120
+ # Find Unicode platform subtable (any encoding)
121
+ def find_unicode_subtable(records, data)
122
+ record = records.find { |r| r[:platform_id] == PLATFORM_UNICODE }
123
+ extract_subtable_data(record, data)
124
+ end
125
+
126
+ # Extract subtable data if record exists and offset is valid
127
+ def extract_subtable_data(record, data)
128
+ return nil unless record
129
+ return nil unless record[:offset] < data.length
130
+
131
+ data[record[:offset]..]
132
+ end
133
+
134
+ # Parse Format 4 subtable (segment mapping to delta values)
135
+ # Format 4 is the most common format for BMP Unicode fonts
136
+ # rubocop:disable Metrics/MethodLength
137
+ # rubocop:disable Metrics/CyclomaticComplexity
138
+ # rubocop:disable Metrics/PerceivedComplexity
139
+ def parse_format_4(data, mappings)
140
+ return if data.length < 14
141
+
142
+ # Format 4 header
143
+ format = data[0, 2].unpack1("n")
144
+ return unless format == 4
145
+
146
+ length = data[2, 2].unpack1("n")
147
+ return if length > data.length
148
+
149
+ seg_count_x2 = data[6, 2].unpack1("n")
150
+ seg_count = seg_count_x2 / 2
151
+
152
+ # Arrays start at offset 14
153
+ offset = 14
154
+
155
+ # Read endCode array
156
+ end_codes = []
157
+ seg_count.times do
158
+ break if offset + 2 > length
159
+
160
+ end_codes << data[offset, 2].unpack1("n")
161
+ offset += 2
162
+ end
163
+
164
+ # Skip reservedPad (2 bytes)
165
+ offset += 2
166
+
167
+ # Read startCode array
168
+ start_codes = []
169
+ seg_count.times do
170
+ break if offset + 2 > length
171
+
172
+ start_codes << data[offset, 2].unpack1("n")
173
+ offset += 2
174
+ end
175
+
176
+ # Read idDelta array
177
+ id_deltas = []
178
+ seg_count.times do
179
+ break if offset + 2 > length
180
+
181
+ id_deltas << data[offset, 2].unpack1("n")
182
+ offset += 2
183
+ end
184
+
185
+ # Read idRangeOffset array
186
+ id_range_offsets = []
187
+ id_range_offset_pos = offset
188
+ seg_count.times do
189
+ break if offset + 2 > length
190
+
191
+ id_range_offsets << data[offset, 2].unpack1("n")
192
+ offset += 2
193
+ end
194
+
195
+ # Process each segment
196
+ seg_count.times do |i|
197
+ start_code = start_codes[i]
198
+ end_code = end_codes[i]
199
+ id_delta = id_deltas[i]
200
+ id_range_offset = id_range_offsets[i]
201
+
202
+ # Skip the final segment (0xFFFF)
203
+ next if start_code == 0xFFFF
204
+
205
+ if id_range_offset.zero?
206
+ # Use idDelta directly
207
+ (start_code..end_code).each do |code|
208
+ glyph_index = (code + id_delta) & 0xFFFF
209
+ mappings[code] = glyph_index if glyph_index != 0
210
+ end
211
+ else
212
+ # Use glyphIdArray
213
+ (start_code..end_code).each do |code|
214
+ # Calculate position in glyphIdArray
215
+ array_offset = id_range_offset_pos + (i * 2) + id_range_offset
216
+ array_offset += (code - start_code) * 2
217
+
218
+ next if array_offset + 2 > length
219
+
220
+ glyph_index = data[array_offset, 2].unpack1("n")
221
+ next if glyph_index.zero?
222
+
223
+ glyph_index = (glyph_index + id_delta) & 0xFFFF
224
+ mappings[code] = glyph_index if glyph_index != 0
225
+ end
226
+ end
227
+ end
228
+ end
229
+ # rubocop:enable Metrics/MethodLength
230
+ # rubocop:enable Metrics/CyclomaticComplexity
231
+ # rubocop:enable Metrics/PerceivedComplexity
232
+
233
+ # Parse Format 12 subtable (segmented coverage)
234
+ # Format 12 supports full Unicode range
235
+ def parse_format_12(data, mappings)
236
+ header = parse_format_12_header(data)
237
+ return unless header
238
+
239
+ parse_format_12_groups(data, header[:num_groups], header[:length],
240
+ mappings)
241
+ end
242
+
243
+ # Parse Format 12 header
244
+ def parse_format_12_header(data)
245
+ return nil if data.length < 16
246
+
247
+ format = data[0, 2].unpack1("n")
248
+ return nil unless format == 12
249
+
250
+ length = data[4, 4].unpack1("N")
251
+ return nil if length > data.length
252
+
253
+ num_groups = data[12, 4].unpack1("N")
254
+
255
+ { length: length, num_groups: num_groups }
256
+ end
257
+
258
+ # Parse Format 12 sequential map groups
259
+ def parse_format_12_groups(data, num_groups, length, mappings)
260
+ offset = 16
261
+ num_groups.times do
262
+ break if offset + 12 > length
263
+
264
+ start_char_code = data[offset, 4].unpack1("N")
265
+ end_char_code = data[offset + 4, 4].unpack1("N")
266
+ start_glyph_id = data[offset + 8, 4].unpack1("N")
267
+
268
+ map_character_range(start_char_code, end_char_code, start_glyph_id,
269
+ mappings)
270
+
271
+ offset += 12
272
+ end
273
+ end
274
+
275
+ # Map a range of characters to glyphs
276
+ def map_character_range(start_char, end_char, start_glyph, mappings)
277
+ (start_char..end_char).each do |code|
278
+ glyph_index = start_glyph + (code - start_char)
279
+ mappings[code] = glyph_index if glyph_index != 0
280
+ end
281
+ end
282
+ end
283
+ end
284
+ end