fontisan 0.2.3 → 0.2.5

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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +221 -49
  3. data/README.adoc +519 -5
  4. data/Rakefile +20 -7
  5. data/lib/fontisan/cli.rb +67 -6
  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 +88 -0
  9. data/lib/fontisan/commands/validate_command.rb +107 -151
  10. data/lib/fontisan/config/conversion_matrix.yml +58 -20
  11. data/lib/fontisan/converters/outline_converter.rb +6 -3
  12. data/lib/fontisan/converters/svg_generator.rb +45 -0
  13. data/lib/fontisan/converters/woff2_encoder.rb +84 -13
  14. data/lib/fontisan/models/bitmap_glyph.rb +123 -0
  15. data/lib/fontisan/models/bitmap_strike.rb +94 -0
  16. data/lib/fontisan/models/color_glyph.rb +57 -0
  17. data/lib/fontisan/models/color_layer.rb +53 -0
  18. data/lib/fontisan/models/color_palette.rb +60 -0
  19. data/lib/fontisan/models/font_info.rb +26 -0
  20. data/lib/fontisan/models/svg_glyph.rb +89 -0
  21. data/lib/fontisan/models/validation_report.rb +227 -0
  22. data/lib/fontisan/open_type_font.rb +6 -0
  23. data/lib/fontisan/optimizers/charstring_rewriter.rb +19 -8
  24. data/lib/fontisan/optimizers/pattern_analyzer.rb +4 -2
  25. data/lib/fontisan/optimizers/subroutine_builder.rb +6 -5
  26. data/lib/fontisan/optimizers/subroutine_optimizer.rb +5 -2
  27. data/lib/fontisan/pipeline/output_writer.rb +2 -2
  28. data/lib/fontisan/pipeline/transformation_pipeline.rb +4 -8
  29. data/lib/fontisan/tables/cbdt.rb +169 -0
  30. data/lib/fontisan/tables/cblc.rb +290 -0
  31. data/lib/fontisan/tables/cff.rb +6 -12
  32. data/lib/fontisan/tables/cmap.rb +82 -2
  33. data/lib/fontisan/tables/colr.rb +291 -0
  34. data/lib/fontisan/tables/cpal.rb +281 -0
  35. data/lib/fontisan/tables/glyf/glyph_builder.rb +5 -1
  36. data/lib/fontisan/tables/glyf.rb +118 -0
  37. data/lib/fontisan/tables/head.rb +60 -0
  38. data/lib/fontisan/tables/hhea.rb +74 -0
  39. data/lib/fontisan/tables/maxp.rb +60 -0
  40. data/lib/fontisan/tables/name.rb +76 -0
  41. data/lib/fontisan/tables/os2.rb +113 -0
  42. data/lib/fontisan/tables/post.rb +57 -0
  43. data/lib/fontisan/tables/sbix.rb +379 -0
  44. data/lib/fontisan/tables/svg.rb +301 -0
  45. data/lib/fontisan/true_type_font.rb +6 -0
  46. data/lib/fontisan/validators/basic_validator.rb +85 -0
  47. data/lib/fontisan/validators/font_book_validator.rb +130 -0
  48. data/lib/fontisan/validators/opentype_validator.rb +112 -0
  49. data/lib/fontisan/validators/profile_loader.rb +139 -0
  50. data/lib/fontisan/validators/validator.rb +484 -0
  51. data/lib/fontisan/validators/web_font_validator.rb +102 -0
  52. data/lib/fontisan/version.rb +1 -1
  53. data/lib/fontisan/woff2/directory.rb +40 -11
  54. data/lib/fontisan/woff2/table_transformer.rb +506 -73
  55. data/lib/fontisan/woff2_font.rb +29 -9
  56. data/lib/fontisan/woff_font.rb +17 -4
  57. data/lib/fontisan.rb +90 -6
  58. metadata +20 -9
  59. data/lib/fontisan/config/validation_rules.yml +0 -149
  60. data/lib/fontisan/validation/checksum_validator.rb +0 -170
  61. data/lib/fontisan/validation/consistency_validator.rb +0 -197
  62. data/lib/fontisan/validation/structure_validator.rb +0 -198
  63. data/lib/fontisan/validation/table_validator.rb +0 -158
  64. data/lib/fontisan/validation/validator.rb +0 -152
  65. data/lib/fontisan/validation/variable_font_validator.rb +0 -218
@@ -46,6 +46,34 @@ module Fontisan
46
46
  end
47
47
  end
48
48
 
49
+ # Individual check result from DSL-based validation
50
+ class CheckResult < Lutaml::Model::Serializable
51
+ attribute :check_id, :string
52
+ attribute :passed, :boolean
53
+ attribute :severity, :string
54
+ attribute :messages, :string, collection: true, default: -> { [] }
55
+ attribute :table, :string
56
+ attribute :field, :string
57
+
58
+ yaml do
59
+ map "check_id", to: :check_id
60
+ map "passed", to: :passed
61
+ map "severity", to: :severity
62
+ map "messages", to: :messages
63
+ map "table", to: :table
64
+ map "field", to: :field
65
+ end
66
+
67
+ json do
68
+ map "check_id", to: :check_id
69
+ map "passed", to: :passed
70
+ map "severity", to: :severity
71
+ map "messages", to: :messages
72
+ map "table", to: :table
73
+ map "field", to: :field
74
+ end
75
+ end
76
+
49
77
  # Validation summary counts
50
78
  class Summary < Lutaml::Model::Serializable
51
79
  attribute :errors, :integer, default: -> { 0 }
@@ -69,12 +97,22 @@ module Fontisan
69
97
  attribute :valid, :boolean
70
98
  attribute :issues, Issue, collection: true, default: -> { [] }
71
99
  attribute :summary, Summary, default: -> { Summary.new }
100
+ attribute :profile, :string
101
+ attribute :status, :string
102
+ attribute :use_case, :string
103
+ attribute :checks_performed, :string, collection: true, default: -> { [] }
104
+ attribute :check_results, CheckResult, collection: true, default: -> { [] }
72
105
 
73
106
  yaml do
74
107
  map "font_path", to: :font_path
75
108
  map "valid", to: :valid
76
109
  map "summary", to: :summary
77
110
  map "issues", to: :issues
111
+ map "profile", to: :profile
112
+ map "status", to: :status
113
+ map "use_case", to: :use_case
114
+ map "checks_performed", to: :checks_performed
115
+ map "check_results", to: :check_results
78
116
  end
79
117
 
80
118
  json do
@@ -82,6 +120,11 @@ module Fontisan
82
120
  map "valid", to: :valid
83
121
  map "summary", to: :summary
84
122
  map "issues", to: :issues
123
+ map "profile", to: :profile
124
+ map "status", to: :status
125
+ map "use_case", to: :use_case
126
+ map "checks_performed", to: :checks_performed
127
+ map "check_results", to: :check_results
85
128
  end
86
129
 
87
130
  # Add an error to the report
@@ -198,6 +241,190 @@ module Fontisan
198
241
 
199
242
  lines.join("\n")
200
243
  end
244
+
245
+ # Check if font passed validation (alias for valid)
246
+ #
247
+ # @return [Boolean] true if font passed validation
248
+ def passed?
249
+ valid
250
+ end
251
+
252
+ # Check if font is valid (alias for valid attribute)
253
+ #
254
+ # @return [Boolean] true if font is valid
255
+ def valid?
256
+ valid
257
+ end
258
+
259
+ # Get result for a specific check by ID
260
+ #
261
+ # @param check_id [Symbol, String] The check identifier
262
+ # @return [CheckResult, nil] The check result or nil if not found
263
+ def result_of(check_id)
264
+ check_results.find { |cr| cr.check_id == check_id.to_s }
265
+ end
266
+
267
+ # Get all passed checks
268
+ #
269
+ # @return [Array<CheckResult>] Array of passed checks
270
+ def passed_checks
271
+ check_results.select(&:passed)
272
+ end
273
+
274
+ # Get all failed checks
275
+ #
276
+ # @return [Array<CheckResult>] Array of failed checks
277
+ def failed_checks
278
+ check_results.reject(&:passed)
279
+ end
280
+
281
+ # Severity filtering methods
282
+
283
+ # Get issues by severity level
284
+ #
285
+ # @param severity [Symbol, String] Severity level
286
+ # @return [Array<Issue>] Array of issues with the specified severity
287
+ def issues_by_severity(severity)
288
+ issues.select { |issue| issue.severity == severity.to_s }
289
+ end
290
+
291
+ # Get fatal error issues
292
+ #
293
+ # @return [Array<Issue>] Array of fatal error issues
294
+ def fatal_errors
295
+ issues_by_severity(:fatal)
296
+ end
297
+
298
+ # Get error issues only
299
+ #
300
+ # @return [Array<Issue>] Array of error issues
301
+ def errors_only
302
+ issues_by_severity(:error)
303
+ end
304
+
305
+ # Get warning issues only
306
+ #
307
+ # @return [Array<Issue>] Array of warning issues
308
+ def warnings_only
309
+ issues_by_severity(:warning)
310
+ end
311
+
312
+ # Get info issues only
313
+ #
314
+ # @return [Array<Issue>] Array of info issues
315
+ def info_only
316
+ issues_by_severity(:info)
317
+ end
318
+
319
+ # Category filtering methods
320
+
321
+ # Get issues by category
322
+ #
323
+ # @param category [String] Category name
324
+ # @return [Array<Issue>] Array of issues in the specified category
325
+ def issues_by_category(category)
326
+ issues.select { |issue| issue.category == category.to_s }
327
+ end
328
+
329
+ # Get check results for a specific table
330
+ #
331
+ # @param table_tag [String] Table tag (e.g., 'name', 'head')
332
+ # @return [Array<CheckResult>] Array of check results for the table
333
+ def table_issues(table_tag)
334
+ check_results.select { |cr| cr.table == table_tag.to_s }
335
+ end
336
+
337
+ # Get check results for a specific field in a table
338
+ #
339
+ # @param table_tag [String] Table tag
340
+ # @param field_name [String, Symbol] Field name
341
+ # @return [Array<CheckResult>] Array of check results for the field
342
+ def field_issues(table_tag, field_name)
343
+ check_results.select { |cr| cr.table == table_tag.to_s && cr.field == field_name.to_s }
344
+ end
345
+
346
+ # Check filtering methods
347
+
348
+ # Get checks by status
349
+ #
350
+ # @param passed [Boolean] true for passed checks, false for failed checks
351
+ # @return [Array<CheckResult>] Array of checks with the specified status
352
+ def checks_by_status(passed:)
353
+ check_results.select { |cr| cr.passed == passed }
354
+ end
355
+
356
+ # Get IDs of failed checks
357
+ #
358
+ # @return [Array<String>] Array of failed check IDs
359
+ def failed_check_ids
360
+ failed_checks.map(&:check_id)
361
+ end
362
+
363
+ # Get IDs of passed checks
364
+ #
365
+ # @return [Array<String>] Array of passed check IDs
366
+ def passed_check_ids
367
+ passed_checks.map(&:check_id)
368
+ end
369
+
370
+ # Statistics methods
371
+
372
+ # Calculate failure rate as percentage
373
+ #
374
+ # @return [Float] Failure rate (0.0 to 1.0)
375
+ def failure_rate
376
+ return 0.0 if check_results.empty?
377
+ failed_checks.length.to_f / check_results.length
378
+ end
379
+
380
+ # Calculate pass rate as percentage
381
+ #
382
+ # @return [Float] Pass rate (0.0 to 1.0)
383
+ def pass_rate
384
+ 1.0 - failure_rate
385
+ end
386
+
387
+ # Get severity distribution
388
+ #
389
+ # @return [Hash] Hash with :errors, :warnings, :info counts
390
+ def severity_distribution
391
+ {
392
+ errors: summary.errors,
393
+ warnings: summary.warnings,
394
+ info: summary.info,
395
+ }
396
+ end
397
+
398
+ # Export format methods
399
+
400
+ # Generate full detailed text report
401
+ #
402
+ # @return [String] Detailed text report
403
+ def to_text_report
404
+ text_summary
405
+ end
406
+
407
+ # Generate brief summary
408
+ #
409
+ # @return [String] Brief summary string
410
+ def to_summary
411
+ "#{summary.errors} errors, #{summary.warnings} warnings, #{summary.info} info"
412
+ end
413
+
414
+ # Generate tabular format for CLI
415
+ #
416
+ # @return [String] Tabular format output
417
+ def to_table_format
418
+ lines = []
419
+ lines << "CHECK_ID | STATUS | SEVERITY | TABLE"
420
+ lines << "-" * 60
421
+ check_results.each do |cr|
422
+ status = cr.passed ? "PASS" : "FAIL"
423
+ table = cr.table || "N/A"
424
+ lines << "#{cr.check_id} | #{status} | #{cr.severity} | #{table}"
425
+ end
426
+ lines.join("\n")
427
+ end
201
428
  end
202
429
  end
203
430
  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
@@ -263,16 +263,12 @@ module Fontisan
263
263
  def validate_output
264
264
  return unless File.exist?(@output_path)
265
265
 
266
- require_relative "../validation/validator"
266
+ # Use new validation framework with production profile
267
+ report = Fontisan.validate(@output_path, profile: :production)
267
268
 
268
- # Load font for validation
269
- font = FontLoader.load(@output_path, mode: :full)
270
- validator = Validation::Validator.new
271
- result = validator.validate(font, @output_path)
269
+ return if report.valid?
272
270
 
273
- return if result.valid
274
-
275
- error_messages = result.errors.map(&:message).join(", ")
271
+ error_messages = report.errors.map(&:message).join(", ")
276
272
  raise Error, "Output validation failed: #{error_messages}"
277
273
  end
278
274
 
@@ -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