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
@@ -1,13 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "bindata"
4
- require_relative "constants"
3
+ require_relative "base_collection"
5
4
 
6
5
  module Fontisan
7
- # OpenType Collection domain object using BinData
6
+ # OpenType Collection domain object
8
7
  #
9
- # Represents a complete OpenType Collection file (OTC) using BinData's declarative
10
- # DSL for binary structure definition. Parallel to TrueTypeCollection but for OpenType fonts.
8
+ # Represents a complete OpenType Collection file (OTC). Inherits all shared
9
+ # functionality from BaseCollection and implements OTC-specific behavior.
11
10
  #
12
11
  # @example Reading and extracting fonts
13
12
  # File.open("fonts.otc", "rb") do |io|
@@ -15,39 +14,26 @@ module Fontisan
15
14
  # puts otc.num_fonts # => 4
16
15
  # fonts = otc.extract_fonts(io) # => [OpenTypeFont, OpenTypeFont, ...]
17
16
  # end
18
- class OpenTypeCollection < BinData::Record
19
- endian :big
20
-
21
- string :tag, length: 4, assert: "ttcf"
22
- uint16 :major_version
23
- uint16 :minor_version
24
- uint32 :num_fonts
25
- array :font_offsets, type: :uint32, initial_length: :num_fonts
26
-
27
- # Read OpenType Collection from a file
17
+ class OpenTypeCollection < BaseCollection
18
+ # Get the font class for OpenType collections
28
19
  #
29
- # @param path [String] Path to the OTC file
30
- # @return [OpenTypeCollection] A new instance
31
- # @raise [ArgumentError] if path is nil or empty
32
- # @raise [Errno::ENOENT] if file does not exist
33
- # @raise [RuntimeError] if file format is invalid
34
- def self.from_file(path)
35
- if path.nil? || path.to_s.empty?
36
- raise ArgumentError,
37
- "path cannot be nil or empty"
38
- end
39
- raise Errno::ENOENT, "File not found: #{path}" unless File.exist?(path)
20
+ # @return [Class] OpenTypeFont class
21
+ def self.font_class
22
+ require_relative "open_type_font"
23
+ OpenTypeFont
24
+ end
40
25
 
41
- File.open(path, "rb") { |io| read(io) }
42
- rescue BinData::ValidityError => e
43
- raise "Invalid OTC file: #{e.message}"
44
- rescue EOFError => e
45
- raise "Invalid OTC file: unexpected end of file - #{e.message}"
26
+ # Get the collection format identifier
27
+ #
28
+ # @return [String] "OTC" for OpenType Collection
29
+ def self.collection_format
30
+ "OTC"
46
31
  end
47
32
 
48
33
  # Extract fonts as OpenTypeFont objects
49
34
  #
50
35
  # Reads each font from the OTC file and returns them as OpenTypeFont objects.
36
+ # This method uses the from_collection method.
51
37
  #
52
38
  # @param io [IO] Open file handle to read fonts from
53
39
  # @return [Array<OpenTypeFont>] Array of font objects
@@ -58,194 +44,5 @@ module Fontisan
58
44
  OpenTypeFont.from_collection(io, offset)
59
45
  end
60
46
  end
61
-
62
- # Get a single font from the collection
63
- #
64
- # @param index [Integer] Index of the font (0-based)
65
- # @param io [IO] Open file handle
66
- # @param mode [Symbol] Loading mode (:metadata or :full, default: :full)
67
- # @return [OpenTypeFont, nil] Font object or nil if index out of range
68
- def font(index, io, mode: LoadingModes::FULL)
69
- return nil if index >= num_fonts
70
-
71
- require_relative "open_type_font"
72
- OpenTypeFont.from_collection(io, font_offsets[index], mode: mode)
73
- end
74
-
75
- # Get font count
76
- #
77
- # @return [Integer] Number of fonts in collection
78
- def font_count
79
- num_fonts
80
- end
81
-
82
- # Validate format correctness
83
- #
84
- # @return [Boolean] true if the format is valid, false otherwise
85
- def valid?
86
- tag == Constants::TTC_TAG && num_fonts.positive? && font_offsets.length == num_fonts
87
- rescue StandardError
88
- false
89
- end
90
-
91
- # Get the OTC version as a single integer
92
- #
93
- # @return [Integer] Version number (e.g., 0x00010000 for version 1.0)
94
- def version
95
- (major_version << 16) | minor_version
96
- end
97
-
98
- # List all fonts in the collection with basic metadata
99
- #
100
- # Returns a CollectionListInfo model containing summaries of all fonts.
101
- # This is the API method used by the `ls` command for collections.
102
- #
103
- # @param io [IO] Open file handle to read fonts from
104
- # @return [CollectionListInfo] List of fonts with metadata
105
- #
106
- # @example List fonts in collection
107
- # File.open("fonts.otc", "rb") do |io|
108
- # otc = OpenTypeCollection.read(io)
109
- # list = otc.list_fonts(io)
110
- # list.fonts.each { |f| puts "#{f.index}: #{f.family_name}" }
111
- # end
112
- def list_fonts(io)
113
- require_relative "models/collection_list_info"
114
- require_relative "models/collection_font_summary"
115
- require_relative "open_type_font"
116
- require_relative "tables/name"
117
-
118
- fonts = font_offsets.map.with_index do |offset, index|
119
- font = OpenTypeFont.from_collection(io, offset)
120
-
121
- # Extract basic font info
122
- name_table = font.table("name")
123
- post_table = font.table("post")
124
-
125
- family_name = name_table&.english_name(Tables::Name::FAMILY) || "Unknown"
126
- subfamily_name = name_table&.english_name(Tables::Name::SUBFAMILY) || "Regular"
127
- postscript_name = name_table&.english_name(Tables::Name::POSTSCRIPT_NAME) || "Unknown"
128
-
129
- # Determine font format
130
- sfnt = font.header.sfnt_version
131
- font_format = case sfnt
132
- when 0x00010000, 0x74727565 # 0x74727565 = 'true'
133
- "TrueType"
134
- when 0x4F54544F # 'OTTO'
135
- "OpenType"
136
- else
137
- "Unknown"
138
- end
139
-
140
- num_glyphs = post_table&.glyph_names&.length || 0
141
- num_tables = font.table_names.length
142
-
143
- Models::CollectionFontSummary.new(
144
- index: index,
145
- family_name: family_name,
146
- subfamily_name: subfamily_name,
147
- postscript_name: postscript_name,
148
- font_format: font_format,
149
- num_glyphs: num_glyphs,
150
- num_tables: num_tables,
151
- )
152
- end
153
-
154
- Models::CollectionListInfo.new(
155
- collection_path: nil, # Will be set by command
156
- num_fonts: num_fonts,
157
- fonts: fonts,
158
- )
159
- end
160
-
161
- # Get comprehensive collection metadata
162
- #
163
- # Returns a CollectionInfo model with header information, offsets,
164
- # and table sharing statistics.
165
- # This is the API method used by the `info` command for collections.
166
- #
167
- # @param io [IO] Open file handle to read fonts from
168
- # @param path [String] Collection file path (for file size)
169
- # @return [CollectionInfo] Collection metadata
170
- #
171
- # @example Get collection info
172
- # File.open("fonts.otc", "rb") do |io|
173
- # otc = OpenTypeCollection.read(io)
174
- # info = otc.collection_info(io, "fonts.otc")
175
- # puts "Version: #{info.version_string}"
176
- # end
177
- def collection_info(io, path)
178
- require_relative "models/collection_info"
179
- require_relative "models/table_sharing_info"
180
-
181
- # Calculate table sharing statistics
182
- table_sharing = calculate_table_sharing(io)
183
-
184
- # Get file size
185
- file_size = path ? File.size(path) : 0
186
-
187
- Models::CollectionInfo.new(
188
- collection_path: path,
189
- collection_format: "OTC",
190
- ttc_tag: tag,
191
- major_version: major_version,
192
- minor_version: minor_version,
193
- num_fonts: num_fonts,
194
- font_offsets: font_offsets.to_a,
195
- file_size_bytes: file_size,
196
- table_sharing: table_sharing,
197
- )
198
- end
199
-
200
- private
201
-
202
- # Calculate table sharing statistics
203
- #
204
- # Analyzes which tables are shared between fonts and calculates
205
- # space savings from deduplication.
206
- #
207
- # @param io [IO] Open file handle
208
- # @return [TableSharingInfo] Sharing statistics
209
- def calculate_table_sharing(io)
210
- require_relative "models/table_sharing_info"
211
- require_relative "open_type_font"
212
-
213
- # Extract all fonts
214
- fonts = font_offsets.map do |offset|
215
- OpenTypeFont.from_collection(io, offset)
216
- end
217
-
218
- # Build table hash map (checksum -> size)
219
- table_map = {}
220
- total_table_size = 0
221
-
222
- fonts.each do |font|
223
- font.tables.each do |entry|
224
- key = entry.checksum
225
- size = entry.table_length
226
- table_map[key] ||= size
227
- total_table_size += size
228
- end
229
- end
230
-
231
- # Count unique vs shared
232
- unique_tables = table_map.size
233
- total_tables = fonts.sum { |f| f.tables.length }
234
- shared_tables = total_tables - unique_tables
235
-
236
- # Calculate space saved
237
- unique_size = table_map.values.sum
238
- space_saved = total_table_size - unique_size
239
-
240
- # Calculate sharing percentage
241
- sharing_pct = total_tables.positive? ? (shared_tables.to_f / total_tables * 100).round(2) : 0.0
242
-
243
- Models::TableSharingInfo.new(
244
- shared_tables: shared_tables,
245
- unique_tables: unique_tables,
246
- sharing_percentage: sharing_pct,
247
- space_saved_bytes: space_saved,
248
- )
249
- end
250
47
  end
251
48
  end
@@ -512,6 +512,12 @@ module Fontisan
512
512
  Constants::GPOS_TAG => Tables::Gpos,
513
513
  Constants::GLYF_TAG => Tables::Glyf,
514
514
  Constants::LOCA_TAG => Tables::Loca,
515
+ "SVG " => Tables::Svg,
516
+ "COLR" => Tables::Colr,
517
+ "CPAL" => Tables::Cpal,
518
+ "CBDT" => Tables::Cbdt,
519
+ "CBLC" => Tables::Cblc,
520
+ "sbix" => Tables::Sbix,
515
521
  }[tag]
516
522
  end
517
523
 
@@ -30,12 +30,13 @@ module Fontisan
30
30
  #
31
31
  # @param charstring [String] original CharString bytes
32
32
  # @param patterns [Array<Pattern>] patterns to replace in this CharString
33
+ # @param glyph_id [Integer, nil] glyph ID being rewritten (for position filtering)
33
34
  # @return [String] rewritten CharString with subroutine calls
34
- def rewrite(charstring, patterns)
35
+ def rewrite(charstring, patterns, glyph_id = nil)
35
36
  return charstring if patterns.empty?
36
37
 
37
38
  # Build list of all replacements: [position, pattern]
38
- replacements = build_replacement_list(charstring, patterns)
39
+ replacements = build_replacement_list(charstring, patterns, glyph_id)
39
40
 
40
41
  # Remove overlapping replacements
41
42
  replacements = remove_overlaps(replacements)
@@ -120,16 +121,26 @@ module Fontisan
120
121
  # Build list of all pattern replacements with their positions
121
122
  # @param charstring [String] CharString being rewritten
122
123
  # @param patterns [Array<Pattern>] patterns to find
124
+ # @param glyph_id [Integer, nil] glyph ID being rewritten (for position filtering)
123
125
  # @return [Array<Array>] array of [position, pattern] pairs
124
- def build_replacement_list(charstring, patterns)
126
+ def build_replacement_list(charstring, patterns, glyph_id = nil)
125
127
  replacements = []
126
128
 
127
129
  patterns.each do |pattern|
128
- # Find all positions where this pattern occurs
129
- positions = find_pattern_positions(charstring, pattern)
130
+ if glyph_id && pattern.respond_to?(:positions) && pattern.positions.is_a?(Hash)
131
+ # Use exact positions from pattern analysis for this glyph
132
+ glyph_positions = pattern.positions[glyph_id] || []
130
133
 
131
- positions.each do |position|
132
- replacements << [position, pattern]
134
+ glyph_positions.each do |position|
135
+ replacements << [position, pattern]
136
+ end
137
+ else
138
+ # Fallback for backward compatibility (unit tests without glyph_id)
139
+ positions = find_pattern_positions(charstring, pattern)
140
+
141
+ positions.each do |position|
142
+ replacements << [position, pattern]
143
+ end
133
144
  end
134
145
  end
135
146
 
@@ -140,7 +151,7 @@ module Fontisan
140
151
  # @param charstring [String] CharString to search
141
152
  # @param pattern [Pattern] pattern to find
142
153
  # @return [Array<Integer>] array of start positions
143
- def find_pattern_positions(charstring, pattern)
154
+ def find_pattern_positions(charstring, pattern, glyph_id = nil)
144
155
  positions = []
145
156
  offset = 0
146
157
 
@@ -160,7 +160,9 @@ module Fontisan
160
160
  charstrings.length
161
161
  end
162
162
 
163
- sampled_glyphs = charstrings.keys.sample(sample_size)
163
+ # Use deterministic selection instead of random sampling
164
+ # Sort keys first to ensure consistent ordering across platforms
165
+ sampled_glyphs = charstrings.keys.sort.take(sample_size)
164
166
 
165
167
  # NEW: Pre-compute boundaries for sampled glyphs
166
168
  # Check if boundaries are useful (more than just start position)
@@ -249,7 +251,7 @@ module Fontisan
249
251
  # Build positions hash
250
252
  positions = {}
251
253
  by_glyph.each do |glyph_id, glyph_occurrences|
252
- positions[glyph_id] = glyph_occurrences.map(&:last)
254
+ positions[glyph_id] = glyph_occurrences.map(&:last).uniq
253
255
  end
254
256
 
255
257
  @patterns[bytes] = Pattern.new(
@@ -95,22 +95,23 @@ module Fontisan
95
95
  # @return [String] encoded bytes
96
96
  def encode_integer(num)
97
97
  # Range 1: -107 to 107 (single byte)
98
+ # CFF spec: byte value = 139 + number
98
99
  if num >= -107 && num <= 107
99
- return [32 + num].pack("c")
100
+ return [139 + num].pack("C")
100
101
  end
101
102
 
102
103
  # Range 2: 108 to 1131 (two bytes)
103
104
  if num >= 108 && num <= 1131
104
105
  b0 = 247 + ((num - 108) >> 8)
105
106
  b1 = (num - 108) & 0xff
106
- return [b0, b1].pack("c*")
107
+ return [b0, b1].pack("C*")
107
108
  end
108
109
 
109
110
  # Range 3: -1131 to -108 (two bytes)
110
111
  if num >= -1131 && num <= -108
111
112
  b0 = 251 - ((num + 108) >> 8)
112
113
  b1 = -(num + 108) & 0xff
113
- return [b0, b1].pack("c*")
114
+ return [b0, b1].pack("C*")
114
115
  end
115
116
 
116
117
  # Range 4: -32768 to 32767 (three bytes)
@@ -118,7 +119,7 @@ module Fontisan
118
119
  b0 = 29
119
120
  b1 = (num >> 8) & 0xff
120
121
  b2 = num & 0xff
121
- return [b0, b1, b2].pack("c*")
122
+ return [b0, b1, b2].pack("C*")
122
123
  end
123
124
 
124
125
  # Range 5: Larger numbers (five bytes)
@@ -127,7 +128,7 @@ module Fontisan
127
128
  b2 = (num >> 16) & 0xff
128
129
  b3 = (num >> 8) & 0xff
129
130
  b4 = num & 0xff
130
- [b0, b1, b2, b3, b4].pack("c*")
131
+ [b0, b1, b2, b3, b4].pack("C*")
131
132
  end
132
133
  end
133
134
  end
@@ -30,7 +30,9 @@ module Fontisan
30
30
  # @return [Array<Pattern>] selected patterns
31
31
  def optimize_selection
32
32
  selected = []
33
- remaining = @patterns.sort_by { |p| -p.savings }
33
+ # Sort by savings (descending), then by length (descending), then by min glyph ID,
34
+ # then by byte values for complete determinism across platforms
35
+ remaining = @patterns.sort_by { |p| [-p.savings, -p.length, p.glyphs.min, p.bytes.bytes] }
34
36
 
35
37
  remaining.each do |pattern|
36
38
  break if selected.length >= @max_subrs
@@ -50,7 +52,8 @@ module Fontisan
50
52
  # @return [Array<Pattern>] ordered subroutines
51
53
  def optimize_ordering(subroutines)
52
54
  # Higher frequency = lower ID (shorter encoding)
53
- subroutines.sort_by { |subr| -subr.frequency }
55
+ # Use same comprehensive sort keys as optimize_selection for consistency
56
+ subroutines.sort_by { |subr| [-subr.frequency, -subr.length, subr.glyphs.min, subr.bytes.bytes] }
54
57
  end
55
58
 
56
59
  # Check if nesting would be beneficial
@@ -96,9 +96,9 @@ module Fontisan
96
96
 
97
97
  writer = Converters::WoffWriter.new
98
98
  font = build_font_from_tables(tables)
99
- result = writer.convert(font, @options)
99
+ woff_data = writer.convert(font, @options)
100
100
 
101
- File.binwrite(@output_path, result[:woff_data])
101
+ File.binwrite(@output_path, woff_data)
102
102
  end
103
103
 
104
104
  # Write WOFF2 format
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+ require_relative "../binary/base_record"
5
+
6
+ module Fontisan
7
+ module Tables
8
+ # CBDT (Color Bitmap Data) table parser
9
+ #
10
+ # The CBDT table contains the actual bitmap data for color glyphs. It works
11
+ # together with the CBLC table which provides the location information for
12
+ # finding bitmaps in this table.
13
+ #
14
+ # CBDT Table Structure:
15
+ # ```
16
+ # CBDT Table = Header (8 bytes)
17
+ # + Bitmap Data (variable length)
18
+ # ```
19
+ #
20
+ # Header (8 bytes):
21
+ # - majorVersion (uint16): Major version (2 or 3)
22
+ # - minorVersion (uint16): Minor version (0)
23
+ # - reserved (uint32): Reserved, set to 0
24
+ #
25
+ # The bitmap data format depends on the index subtable format in CBLC.
26
+ # Common formats include:
27
+ # - Format 17: Small metrics, PNG data
28
+ # - Format 18: Big metrics, PNG data
29
+ # - Format 19: Metrics in CBLC, PNG data
30
+ #
31
+ # This parser provides low-level access to bitmap data. For proper bitmap
32
+ # extraction, use together with CBLC table which contains the index.
33
+ #
34
+ # Reference: OpenType CBDT specification
35
+ # https://docs.microsoft.com/en-us/typography/opentype/spec/cbdt
36
+ #
37
+ # @example Reading a CBDT table
38
+ # data = font.table_data['CBDT']
39
+ # cbdt = Fontisan::Tables::Cbdt.read(data)
40
+ # bitmap_data = cbdt.bitmap_data_at(offset, length)
41
+ class Cbdt < Binary::BaseRecord
42
+ # OpenType table tag for CBDT
43
+ TAG = "CBDT"
44
+
45
+ # Supported CBDT versions
46
+ VERSION_2_0 = 0x0002_0000
47
+ VERSION_3_0 = 0x0003_0000
48
+
49
+ # @return [Integer] Major version (2 or 3)
50
+ attr_reader :major_version
51
+
52
+ # @return [Integer] Minor version (0)
53
+ attr_reader :minor_version
54
+
55
+ # @return [String] Raw binary data for the entire CBDT table
56
+ attr_reader :raw_data
57
+
58
+ # Override read to parse CBDT structure
59
+ #
60
+ # @param io [IO, String] Binary data to read
61
+ # @return [Cbdt] Parsed CBDT table
62
+ def self.read(io)
63
+ cbdt = new
64
+ return cbdt if io.nil?
65
+
66
+ data = io.is_a?(String) ? io : io.read
67
+ cbdt.parse!(data)
68
+ cbdt
69
+ end
70
+
71
+ # Parse the CBDT table structure
72
+ #
73
+ # @param data [String] Binary data for the CBDT table
74
+ # @raise [CorruptedTableError] If CBDT structure is invalid
75
+ def parse!(data)
76
+ @raw_data = data
77
+ io = StringIO.new(data)
78
+
79
+ # Parse CBDT header (8 bytes)
80
+ parse_header(io)
81
+ validate_header!
82
+ rescue StandardError => e
83
+ raise CorruptedTableError, "Failed to parse CBDT table: #{e.message}"
84
+ end
85
+
86
+ # Get bitmap data at specific offset and length
87
+ #
88
+ # Used together with CBLC index to extract bitmap data.
89
+ #
90
+ # @param offset [Integer] Offset from start of table
91
+ # @param length [Integer] Length of bitmap data
92
+ # @return [String, nil] Binary bitmap data or nil
93
+ def bitmap_data_at(offset, length)
94
+ return nil if offset.nil? || length.nil?
95
+ return nil if offset.negative? || length.negative?
96
+ return nil if offset + length > raw_data.length
97
+
98
+ raw_data[offset, length]
99
+ end
100
+
101
+ # Get combined version number
102
+ #
103
+ # @return [Integer] Combined version (e.g., 0x00020000 for v2.0)
104
+ def version
105
+ return nil if major_version.nil? || minor_version.nil?
106
+
107
+ (major_version << 16) | minor_version
108
+ end
109
+
110
+ # Get table data size
111
+ #
112
+ # @return [Integer] Size of CBDT table in bytes
113
+ def data_size
114
+ raw_data&.length || 0
115
+ end
116
+
117
+ # Check if offset is valid for this table
118
+ #
119
+ # @param offset [Integer] Offset to check
120
+ # @return [Boolean] True if offset is within table bounds
121
+ def valid_offset?(offset)
122
+ return false if offset.nil? || offset.negative?
123
+ return false if raw_data.nil?
124
+
125
+ offset < raw_data.length
126
+ end
127
+
128
+ # Validate the CBDT table structure
129
+ #
130
+ # @return [Boolean] True if valid
131
+ def valid?
132
+ return false if major_version.nil? || minor_version.nil?
133
+ return false unless [2, 3].include?(major_version)
134
+ return false unless minor_version.zero?
135
+ return false unless raw_data
136
+
137
+ true
138
+ end
139
+
140
+ private
141
+
142
+ # Parse CBDT header (8 bytes)
143
+ #
144
+ # @param io [StringIO] Input stream
145
+ def parse_header(io)
146
+ @major_version = io.read(2).unpack1("n")
147
+ @minor_version = io.read(2).unpack1("n")
148
+ @reserved = io.read(4).unpack1("N")
149
+ end
150
+
151
+ # Validate header values
152
+ #
153
+ # @raise [CorruptedTableError] If validation fails
154
+ def validate_header!
155
+ unless [2, 3].include?(major_version)
156
+ raise CorruptedTableError,
157
+ "Unsupported CBDT major version: #{major_version} " \
158
+ "(only versions 2 and 3 supported)"
159
+ end
160
+
161
+ unless minor_version.zero?
162
+ raise CorruptedTableError,
163
+ "Unsupported CBDT minor version: #{minor_version} " \
164
+ "(only version 0 supported)"
165
+ end
166
+ end
167
+ end
168
+ end
169
+ end