fontisan 0.2.8 → 0.2.10

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.
@@ -0,0 +1,690 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bindata"
4
+ require_relative "constants"
5
+ require_relative "loading_modes"
6
+ require_relative "utilities/checksum_calculator"
7
+ require_relative "sfnt_table"
8
+ require_relative "tables/head_table"
9
+ require_relative "tables/name_table"
10
+ require_relative "tables/os2_table"
11
+ require_relative "tables/cmap_table"
12
+ require_relative "tables/glyf_table"
13
+ require_relative "tables/hhea_table"
14
+ require_relative "tables/maxp_table"
15
+ require_relative "tables/post_table"
16
+ require_relative "tables/hmtx_table"
17
+ require_relative "tables/loca_table"
18
+
19
+ module Fontisan
20
+ # SFNT Offset Table structure
21
+ #
22
+ # Common structure for both TrueType and OpenType fonts.
23
+ class OffsetTable < BinData::Record
24
+ endian :big
25
+ uint32 :sfnt_version
26
+ uint16 :num_tables
27
+ uint16 :search_range
28
+ uint16 :entry_selector
29
+ uint16 :range_shift
30
+ end
31
+
32
+ # SFNT Table Directory Entry structure
33
+ #
34
+ # Common structure for both TrueType and OpenType fonts.
35
+ class TableDirectory < BinData::Record
36
+ endian :big
37
+ string :tag, length: 4
38
+ uint32 :checksum
39
+ uint32 :offset
40
+ uint32 :table_length
41
+ end
42
+
43
+ # Base class for SFNT font formats (TrueType and OpenType)
44
+ #
45
+ # This class contains all shared SFNT structure and behavior.
46
+ # TrueType and OpenType fonts inherit from this class and add
47
+ # format-specific functionality.
48
+ #
49
+ # @abstract Subclasses must implement format-specific validation
50
+ #
51
+ # @example Reading a font (format detected automatically)
52
+ # font = Fontisan::FontLoader.load("font.ttf") # Returns TrueTypeFont
53
+ # font = Fontisan::FontLoader.load("font.otf") # Returns OpenTypeFont
54
+ #
55
+ # @example Reading and analyzing a font
56
+ # ttf = Fontisan::TrueTypeFont.from_file("font.ttf")
57
+ # puts ttf.header.num_tables # => 14
58
+ # name_table = ttf.table("name")
59
+ # puts name_table.english_name(Tables::Name::FAMILY)
60
+ #
61
+ # @example Loading with metadata mode
62
+ # ttf = Fontisan::TrueTypeFont.from_file("font.ttf", mode: :metadata)
63
+ # puts ttf.loading_mode # => :metadata
64
+ # ttf.table_available?("GSUB") # => false
65
+ #
66
+ # @example Writing a font
67
+ # ttf.to_file("output.ttf")
68
+ class SfntFont < BinData::Record
69
+ endian :big
70
+
71
+ offset_table :header
72
+ array :tables, type: :table_directory, initial_length: lambda {
73
+ header.num_tables
74
+ }
75
+
76
+ # Table data is stored separately since it's at variable offsets
77
+ attr_accessor :table_data
78
+
79
+ # Parsed table instances cache
80
+ attr_accessor :parsed_tables
81
+
82
+ # OOP SfntTable instances (tag => SfntTable)
83
+ attr_accessor :sfnt_tables
84
+
85
+ # Table entry lookup cache (tag => TableDirectory)
86
+ attr_accessor :table_entry_cache
87
+
88
+ # Loading mode for this font (:metadata or :full)
89
+ attr_accessor :loading_mode
90
+
91
+ # IO source for lazy loading
92
+ attr_accessor :io_source
93
+
94
+ # Whether lazy loading is enabled
95
+ attr_accessor :lazy_load_enabled
96
+
97
+ # Map table tag to parser class (cached as constant for performance)
98
+ #
99
+ # @return [Hash<String, Class>] Mapping of table tags to parser classes
100
+ TABLE_CLASS_MAP = {
101
+ Constants::HEAD_TAG => Tables::Head,
102
+ Constants::HHEA_TAG => Tables::Hhea,
103
+ Constants::HMTX_TAG => Tables::Hmtx,
104
+ Constants::MAXP_TAG => Tables::Maxp,
105
+ Constants::NAME_TAG => Tables::Name,
106
+ Constants::OS2_TAG => Tables::Os2,
107
+ Constants::POST_TAG => Tables::Post,
108
+ Constants::CMAP_TAG => Tables::Cmap,
109
+ Constants::FVAR_TAG => Tables::Fvar,
110
+ Constants::GSUB_TAG => Tables::Gsub,
111
+ Constants::GPOS_TAG => Tables::Gpos,
112
+ Constants::GLYF_TAG => Tables::Glyf,
113
+ Constants::LOCA_TAG => Tables::Loca,
114
+ "SVG " => Tables::Svg,
115
+ "COLR" => Tables::Colr,
116
+ "CPAL" => Tables::Cpal,
117
+ "CBDT" => Tables::Cbdt,
118
+ "CBLC" => Tables::Cblc,
119
+ "sbix" => Tables::Sbix,
120
+ }.freeze
121
+
122
+ # Map table tag to SfntTable wrapper class (cached as constant for performance)
123
+ #
124
+ # @return [Hash<String, Class>] Mapping of table tags to SfntTable wrapper classes
125
+ SFNT_TABLE_CLASS_MAP = {
126
+ Constants::HEAD_TAG => Tables::HeadTable,
127
+ Constants::NAME_TAG => Tables::NameTable,
128
+ Constants::OS2_TAG => Tables::Os2Table,
129
+ Constants::CMAP_TAG => Tables::CmapTable,
130
+ Constants::GLYF_TAG => Tables::GlyfTable,
131
+ Constants::HHEA_TAG => Tables::HheaTable,
132
+ Constants::MAXP_TAG => Tables::MaxpTable,
133
+ Constants::POST_TAG => Tables::PostTable,
134
+ Constants::HMTX_TAG => Tables::HmtxTable,
135
+ Constants::LOCA_TAG => Tables::LocaTable,
136
+ }.freeze
137
+
138
+ # Padding bytes for table alignment (frozen to avoid reallocation)
139
+ PADDING_BYTES = ("\x00" * 4).freeze
140
+
141
+ # Read SFNT Font from a file
142
+ #
143
+ # @param path [String] Path to the font file
144
+ # @param mode [Symbol] Loading mode (:metadata or :full, default: :full)
145
+ # @param lazy [Boolean] If true, load tables on demand (default: false)
146
+ # @return [SfntFont] A new instance
147
+ # @raise [ArgumentError] if path is nil or empty, or if mode is invalid
148
+ # @raise [Errno::ENOENT] if file does not exist
149
+ # @raise [RuntimeError] if file format is invalid
150
+ def self.from_file(path, mode: LoadingModes::FULL, lazy: false)
151
+ if path.nil? || path.to_s.empty?
152
+ raise ArgumentError,
153
+ "path cannot be nil or empty"
154
+ end
155
+ raise Errno::ENOENT, "File not found: #{path}" unless File.exist?(path)
156
+
157
+ # Validate mode
158
+ LoadingModes.validate_mode!(mode)
159
+
160
+ File.open(path, "rb") do |io|
161
+ font = read(io)
162
+ font.initialize_storage
163
+ font.loading_mode = mode
164
+ font.lazy_load_enabled = lazy
165
+
166
+ if lazy
167
+ # Keep file handle open for lazy loading
168
+ font.io_source = File.open(path, "rb")
169
+ font.setup_finalizer
170
+ else
171
+ # Read tables upfront
172
+ font.read_table_data(io)
173
+ end
174
+
175
+ font
176
+ end
177
+ rescue BinData::ValidityError, EOFError => e
178
+ raise "Invalid font file: #{e.message}"
179
+ end
180
+
181
+ # Read SFNT Font from collection at specific offset
182
+ #
183
+ # @param io [IO] Open file handle
184
+ # @param offset [Integer] Byte offset to the font
185
+ # @param mode [Symbol] Loading mode (:metadata or :full, default: :full)
186
+ # @return [SfntFont] A new instance
187
+ def self.from_collection(io, offset, mode: LoadingModes::FULL)
188
+ LoadingModes.validate_mode!(mode)
189
+
190
+ io.seek(offset)
191
+ font = read(io)
192
+ font.initialize_storage
193
+ font.loading_mode = mode
194
+ font.read_table_data(io)
195
+ font
196
+ end
197
+
198
+ # Initialize storage hashes
199
+ #
200
+ # @return [void]
201
+ def initialize_storage
202
+ @table_data = {}
203
+ @parsed_tables = {}
204
+ @sfnt_tables = {}
205
+ @table_entry_cache = {}
206
+ @tag_encoding_cache = {} # Cache for normalized tag encodings
207
+ @table_names = nil # Cache for table names array
208
+ @loading_mode = LoadingModes::FULL
209
+ @lazy_load_enabled = false
210
+ @io_source = nil
211
+ end
212
+
213
+ # Read table data for all tables in the font
214
+ #
215
+ # In metadata mode, only reads metadata tables. In full mode, reads all tables.
216
+ # In lazy load mode, doesn't read data upfront.
217
+ #
218
+ # @param io [IO] IO object to read from
219
+ # @return [void]
220
+ def read_table_data(io)
221
+ @table_data = {}
222
+
223
+ if @lazy_load_enabled
224
+ # Don't read data, just keep IO reference
225
+ @io_source = io
226
+ return
227
+ end
228
+
229
+ if @loading_mode == LoadingModes::METADATA
230
+ # Only read metadata tables for performance
231
+ # Use page-aware batched reading to maximize filesystem prefetching
232
+ read_metadata_tables_batched(io)
233
+ else
234
+ # Read all tables
235
+ tables.each do |entry|
236
+ io.seek(entry.offset)
237
+ # Normalize tag encoding for hash key consistency
238
+ tag_key = normalize_tag(entry.tag)
239
+ @table_data[tag_key] = io.read(entry.table_length)
240
+ end
241
+ end
242
+ end
243
+
244
+ # Read metadata tables using page-aware batching
245
+ #
246
+ # Groups adjacent tables within page boundaries and reads them together
247
+ # to maximize filesystem prefetching and minimize random seeks.
248
+ #
249
+ # @param io [IO] Open file handle
250
+ # @return [void]
251
+ def read_metadata_tables_batched(io)
252
+ # Typical filesystem page size (4KB is common, but 8KB gives better prefetch window)
253
+ page_threshold = 8192
254
+
255
+ # Get metadata tables sorted by offset for sequential access
256
+ metadata_entries = tables.select { |entry| LoadingModes::METADATA_TABLES_SET.include?(entry.tag) }
257
+ metadata_entries.sort_by!(&:offset)
258
+
259
+ return if metadata_entries.empty?
260
+
261
+ # Group adjacent tables within page threshold for batched reading
262
+ i = 0
263
+ while i < metadata_entries.size
264
+ batch_start = metadata_entries[i]
265
+ batch_end = batch_start
266
+ batch_entries = [batch_start]
267
+
268
+ # Extend batch while next table is within page threshold
269
+ j = i + 1
270
+ while j < metadata_entries.size
271
+ next_entry = metadata_entries[j]
272
+ gap = next_entry.offset - (batch_end.offset + batch_end.table_length)
273
+
274
+ # If gap is small (within page threshold), include in batch
275
+ if gap <= page_threshold
276
+ batch_end = next_entry
277
+ batch_entries << next_entry
278
+ j += 1
279
+ else
280
+ break
281
+ end
282
+ end
283
+
284
+ # Read batch
285
+ if batch_entries.size == 1
286
+ # Single table, read normally
287
+ io.seek(batch_start.offset)
288
+ tag_key = normalize_tag(batch_start.tag)
289
+ @table_data[tag_key] = io.read(batch_start.table_length)
290
+ else
291
+ # Multiple tables, read contiguous segment
292
+ batch_offset = batch_start.offset
293
+ batch_length = (batch_end.offset + batch_end.table_length) - batch_start.offset
294
+
295
+ io.seek(batch_offset)
296
+ batch_data = io.read(batch_length)
297
+
298
+ # Extract individual tables from batch
299
+ batch_entries.each do |entry|
300
+ relative_offset = entry.offset - batch_offset
301
+ tag_key = normalize_tag(entry.tag)
302
+ @table_data[tag_key] =
303
+ batch_data[relative_offset, entry.table_length]
304
+ end
305
+ end
306
+
307
+ i = j
308
+ end
309
+ end
310
+
311
+ # Write SFNT Font to a file
312
+ #
313
+ # Writes the complete font structure to disk, including proper checksum
314
+ # calculation and table alignment.
315
+ #
316
+ # @param path [String] Path where the font file will be written
317
+ # @return [Integer] Number of bytes written
318
+ # @raise [IOError] if writing fails
319
+ def to_file(path)
320
+ File.open(path, "w+b") do |io|
321
+ # Write header and tables (directory)
322
+ write_structure(io)
323
+
324
+ # Write table data with updated offsets
325
+ write_table_data_with_offsets(io)
326
+
327
+ # Update checksum adjustment in head table BEFORE closing file
328
+ # This avoids Windows file locking issues when Tempfiles are used
329
+ head = head_table
330
+ update_checksum_adjustment_in_io(io, head.offset) if head
331
+
332
+ io.pos
333
+ end
334
+
335
+ File.size(path)
336
+ end
337
+
338
+ # Validate format correctness
339
+ #
340
+ # @return [Boolean] true if the font format is valid, false otherwise
341
+ def valid?
342
+ return false unless header
343
+ return false unless tables.respond_to?(:length)
344
+ return false unless @table_data.is_a?(Hash)
345
+ return false if tables.length != header.num_tables
346
+ return false unless head_table
347
+
348
+ true
349
+ end
350
+
351
+ # Check if font has a specific table (optimized with cache)
352
+ #
353
+ # @param tag [String] The table tag to check for
354
+ # @return [Boolean] true if table exists, false otherwise
355
+ def has_table?(tag)
356
+ !find_table_entry(tag).nil?
357
+ end
358
+
359
+ # Check if a table is available in the current loading mode
360
+ #
361
+ # @param tag [String] The table tag to check
362
+ # @return [Boolean] true if table is available in current mode
363
+ def table_available?(tag)
364
+ return false unless has_table?(tag)
365
+
366
+ LoadingModes.table_allowed?(@loading_mode, tag)
367
+ end
368
+
369
+ # Find a table entry by tag (cached for performance)
370
+ #
371
+ # @param tag [String] The table tag to find
372
+ # @return [TableDirectory, nil] The table entry or nil
373
+ def find_table_entry(tag)
374
+ return @table_entry_cache[tag] if @table_entry_cache.key?(tag)
375
+
376
+ entry = tables.find { |entry| entry.tag == tag }
377
+ @table_entry_cache[tag] = entry
378
+ entry
379
+ end
380
+
381
+ # Get the head table entry
382
+ #
383
+ # @return [TableDirectory, nil] The head table entry or nil
384
+ def head_table
385
+ find_table_entry(Constants::HEAD_TAG)
386
+ end
387
+
388
+ # Get list of all table tags (cached for performance)
389
+ #
390
+ # @return [Array<String>] Array of table tag strings
391
+ def table_names
392
+ @table_names ||= tables.map(&:tag)
393
+ end
394
+
395
+ # Get OOP SfntTable instance for a table
396
+ #
397
+ # Returns a SfntTable (or subclass) instance that encapsulates the table's
398
+ # metadata, lazy loading, parsing, and validation. This provides a more
399
+ # object-oriented interface than the separate TableDirectory/@table_data/@parsed_tables
400
+ # approach.
401
+ #
402
+ # @param tag [String] The table tag to retrieve
403
+ # @return [SfntTable, nil] SfntTable instance (or subclass like HeadTable), or nil if not found
404
+ #
405
+ # @example Using SfntTable for validation
406
+ # head = font.sfnt_table("head")
407
+ # head.validate! # Performs head-specific validation
408
+ # head.units_per_em # => 2048 (convenience method)
409
+ def sfnt_table(tag)
410
+ # Return cached instance if available (fast path)
411
+ cached = @sfnt_tables[tag]
412
+ return cached if cached
413
+
414
+ # Only check has_table? if not cached (avoids redundant lookup)
415
+ return nil unless has_table?(tag)
416
+
417
+ # Create and cache (find_table_entry is already cached internally)
418
+ @sfnt_tables[tag] = create_sfnt_table(tag)
419
+ end
420
+
421
+ # Get all SfntTable instances
422
+ #
423
+ # @return [Hash<String, SfntTable>] Hash mapping tag => SfntTable instance
424
+ def all_sfnt_tables
425
+ table_names.each_with_object({}) do |tag, hash|
426
+ hash[tag] = sfnt_table(tag)
427
+ end
428
+ end
429
+
430
+ # Get parsed table instance
431
+ #
432
+ # This method parses the raw table data into a structured table object
433
+ # and caches the result for subsequent calls. Enforces mode restrictions.
434
+ #
435
+ # @param tag [String] The table tag to retrieve
436
+ # @return [Tables::*, nil] Parsed table object or nil if not found
437
+ # @raise [ArgumentError] if table is not available in current loading mode
438
+ def table(tag)
439
+ # Check mode restrictions
440
+ unless table_available?(tag)
441
+ if has_table?(tag)
442
+ raise ArgumentError,
443
+ "Table '#{tag}' is not available in #{@loading_mode} mode. " \
444
+ "Available tables: #{LoadingModes.tables_for(@loading_mode).inspect}"
445
+ else
446
+ return nil
447
+ end
448
+ end
449
+
450
+ # Return cached if available (fast path)
451
+ return @parsed_tables[tag] if @parsed_tables.key?(tag)
452
+
453
+ # Lazy load table data if enabled
454
+ load_table_data(tag) if @lazy_load_enabled && !@table_data.key?(tag)
455
+
456
+ # Parse and cache
457
+ @parsed_tables[tag] ||= parse_table(tag)
458
+ end
459
+
460
+ # Get units per em from head table
461
+ #
462
+ # @return [Integer, nil] Units per em value
463
+ def units_per_em
464
+ head = table(Constants::HEAD_TAG)
465
+ head&.units_per_em
466
+ end
467
+
468
+ # Convenience methods for accessing common name table fields
469
+ # These are particularly useful in minimal mode
470
+
471
+ # Get font family name
472
+ #
473
+ # @return [String, nil] Family name or nil if not found
474
+ def family_name
475
+ name_table = table(Constants::NAME_TAG)
476
+ name_table&.english_name(Tables::Name::FAMILY)
477
+ end
478
+
479
+ # Get font subfamily name (e.g., Regular, Bold, Italic)
480
+ #
481
+ # @return [String, nil] Subfamily name or nil if not found
482
+ def subfamily_name
483
+ name_table = table(Constants::NAME_TAG)
484
+ name_table&.english_name(Tables::Name::SUBFAMILY)
485
+ end
486
+
487
+ # Get full font name
488
+ #
489
+ # @return [String, nil] Full name or nil if not found
490
+ def full_name
491
+ name_table = table(Constants::NAME_TAG)
492
+ name_table&.english_name(Tables::Name::FULL_NAME)
493
+ end
494
+
495
+ # Get PostScript name
496
+ #
497
+ # @return [String, nil] PostScript name or nil if not found
498
+ def post_script_name
499
+ name_table = table(Constants::NAME_TAG)
500
+ name_table&.english_name(Tables::Name::POSTSCRIPT_NAME)
501
+ end
502
+
503
+ # Get preferred family name
504
+ #
505
+ # @return [String, nil] Preferred family name or nil if not found
506
+ def preferred_family_name
507
+ name_table = table(Constants::NAME_TAG)
508
+ name_table&.english_name(Tables::Name::PREFERRED_FAMILY)
509
+ end
510
+
511
+ # Get preferred subfamily name
512
+ #
513
+ # @return [String, nil] Preferred subfamily name or nil if not found
514
+ def preferred_subfamily_name
515
+ name_table = table(Constants::NAME_TAG)
516
+ name_table&.english_name(Tables::Name::PREFERRED_SUBFAMILY)
517
+ end
518
+
519
+ # Close the IO source (for lazy loading)
520
+ #
521
+ # @return [void]
522
+ def close
523
+ @io_source&.close
524
+ @io_source = nil
525
+ end
526
+
527
+ # Setup finalizer for cleanup
528
+ #
529
+ # @return [void]
530
+ def setup_finalizer
531
+ ObjectSpace.define_finalizer(self, self.class.finalize(@io_source))
532
+ end
533
+
534
+ # Finalizer proc for closing IO
535
+ #
536
+ # @param io [IO] The IO object to close
537
+ # @return [Proc] The finalizer proc
538
+ def self.finalize(io)
539
+ proc { io&.close }
540
+ end
541
+
542
+ # Update checksum adjustment in head table using IO
543
+ #
544
+ # Calculates the checksum of the entire file and writes the
545
+ # adjustment value to the head table's checksumAdjustment field.
546
+ #
547
+ # @param io [IO] IO object to read from and write to
548
+ # @param head_offset [Integer] Offset to the head table
549
+ # @return [void]
550
+ def update_checksum_adjustment_in_io(io, head_offset)
551
+ io.rewind
552
+ checksum = Utilities::ChecksumCalculator.calculate_checksum_from_io(io)
553
+ adjustment = Utilities::ChecksumCalculator.calculate_adjustment(checksum)
554
+ io.seek(head_offset + 8)
555
+ io.write([adjustment].pack("N"))
556
+ end
557
+
558
+ # Update checksum adjustment in head table by file path
559
+ #
560
+ # Opens the file, calculates the checksum, and updates the adjustment.
561
+ #
562
+ # @param path [String] Path to the font file
563
+ # @param head_offset [Integer] Offset to the head table
564
+ # @return [void]
565
+ def update_checksum_adjustment_in_file(path, head_offset)
566
+ File.open(path, "r+b") do |io|
567
+ update_checksum_adjustment_in_io(io, head_offset)
568
+ end
569
+ end
570
+
571
+ private
572
+
573
+ # Normalize tag encoding to UTF-8 (cached for performance)
574
+ #
575
+ # @param tag [String] The tag to normalize
576
+ # @return [String] UTF-8 encoded tag
577
+ def normalize_tag(tag)
578
+ @tag_encoding_cache[tag] ||= tag.dup.force_encoding("UTF-8")
579
+ end
580
+
581
+ # Load a single table's data on demand
582
+ #
583
+ # Uses direct seek-and-read for minimal overhead. This ensures lazy loading
584
+ # performance is comparable to eager loading when accessing all tables.
585
+ #
586
+ # @param tag [String] The table tag to load
587
+ # @return [void]
588
+ def load_table_data(tag)
589
+ return unless @io_source
590
+
591
+ entry = find_table_entry(tag)
592
+ return nil unless entry
593
+
594
+ # Direct seek and read - same as eager loading but on-demand
595
+ @io_source.seek(entry.offset)
596
+ tag_key = normalize_tag(tag)
597
+ @table_data[tag_key] = @io_source.read(entry.table_length)
598
+ end
599
+
600
+ # Parse a table from raw data
601
+ #
602
+ # @param tag [String] The table tag to parse
603
+ # @return [Tables::*, nil] Parsed table object or nil
604
+ def parse_table(tag)
605
+ raw_data = @table_data[tag]
606
+ return nil unless raw_data
607
+
608
+ table_class = table_class_for(tag)
609
+ return nil unless table_class
610
+
611
+ table_class.read(raw_data)
612
+ end
613
+
614
+ # Map table tag to SfntTable wrapper class
615
+ #
616
+ # @param tag [String] The table tag
617
+ # @return [SfntTable, nil] SfntTable instance or nil
618
+ def create_sfnt_table(tag)
619
+ entry = find_table_entry(tag)
620
+ return nil unless entry
621
+
622
+ # Use hash lookup for O(1) dispatch instead of case statement
623
+ table_class = SFNT_TABLE_CLASS_MAP[tag] || SfntTable
624
+ table_class.new(self, entry)
625
+ end
626
+
627
+ # Map table tag to parser class
628
+ #
629
+ # @param tag [String] The table tag
630
+ # @return [Class, nil] Table parser class or nil
631
+ def table_class_for(tag)
632
+ TABLE_CLASS_MAP[tag]
633
+ end
634
+
635
+ # Write the structure (header + table directory) to IO
636
+ #
637
+ # @param io [IO] Open file handle
638
+ # @return [void]
639
+ def write_structure(io)
640
+ # Write header
641
+ header.write(io)
642
+
643
+ # Write table directory with placeholder offsets
644
+ tables.each do |entry|
645
+ io.write(entry.tag)
646
+ io.write([entry.checksum].pack("N"))
647
+ io.write([0].pack("N")) # Placeholder offset
648
+ io.write([entry.table_length].pack("N"))
649
+ end
650
+ end
651
+
652
+ # Write table data and update offsets in directory
653
+ #
654
+ # @param io [IO] Open file handle
655
+ # @return [void]
656
+ def write_table_data_with_offsets(io)
657
+ tables.each_with_index do |entry, index|
658
+ # Record current position
659
+ current_position = io.pos
660
+
661
+ # Write table data
662
+ data = @table_data[entry.tag]
663
+ raise IOError, "Missing table data for tag '#{entry.tag}'" if data.nil?
664
+
665
+ io.write(data)
666
+
667
+ # Add padding to align to 4-byte boundary
668
+ padding = (Constants::TABLE_ALIGNMENT - (io.pos % Constants::TABLE_ALIGNMENT)) % Constants::TABLE_ALIGNMENT
669
+ io.write(PADDING_BYTES[0, padding]) if padding.positive?
670
+
671
+ # Zero out checksumAdjustment field in head table
672
+ if entry.tag == Constants::HEAD_TAG
673
+ current_pos = io.pos
674
+ io.seek(current_position + 8)
675
+ io.write([0].pack("N"))
676
+ io.seek(current_pos)
677
+ end
678
+
679
+ # Update offset in table directory
680
+ # Table directory starts at byte 12, each entry is 16 bytes
681
+ # Offset field is at byte 8 within each entry
682
+ directory_offset_position = 12 + (index * 16) + 8
683
+ current_pos = io.pos
684
+ io.seek(directory_offset_position)
685
+ io.write([current_position].pack("N")) # Offset is now known
686
+ io.seek(current_pos)
687
+ end
688
+ end
689
+ end
690
+ end