fontisan 0.2.7 → 0.2.9

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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +103 -0
  3. data/.rubocop_todo.yml +65 -361
  4. data/CHANGELOG.md +116 -0
  5. data/Gemfile +1 -1
  6. data/README.adoc +106 -27
  7. data/Rakefile +12 -7
  8. data/benchmark/variation_quick_bench.rb +1 -1
  9. data/docs/APPLE_LEGACY_FONTS.adoc +173 -0
  10. data/docs/COLLECTION_VALIDATION.adoc +143 -0
  11. data/docs/COLOR_FONTS.adoc +127 -0
  12. data/docs/DOCUMENTATION_SUMMARY.md +141 -0
  13. data/docs/FONT_HINTING.adoc +9 -1
  14. data/docs/VALIDATION.adoc +254 -0
  15. data/docs/WOFF_WOFF2_FORMATS.adoc +94 -0
  16. data/lib/fontisan/cli.rb +45 -13
  17. data/lib/fontisan/collection/dfont_builder.rb +2 -1
  18. data/lib/fontisan/commands/convert_command.rb +2 -4
  19. data/lib/fontisan/commands/info_command.rb +3 -3
  20. data/lib/fontisan/commands/pack_command.rb +2 -1
  21. data/lib/fontisan/commands/validate_command.rb +157 -6
  22. data/lib/fontisan/converters/collection_converter.rb +22 -13
  23. data/lib/fontisan/converters/svg_generator.rb +2 -1
  24. data/lib/fontisan/converters/woff2_encoder.rb +6 -6
  25. data/lib/fontisan/converters/woff_writer.rb +3 -1
  26. data/lib/fontisan/font_loader.rb +7 -6
  27. data/lib/fontisan/formatters/text_formatter.rb +18 -14
  28. data/lib/fontisan/hints/hint_converter.rb +1 -1
  29. data/lib/fontisan/hints/hint_validator.rb +13 -10
  30. data/lib/fontisan/hints/truetype_instruction_analyzer.rb +15 -8
  31. data/lib/fontisan/hints/truetype_instruction_generator.rb +1 -1
  32. data/lib/fontisan/models/collection_validation_report.rb +104 -0
  33. data/lib/fontisan/models/font_report.rb +24 -0
  34. data/lib/fontisan/models/validation_report.rb +7 -2
  35. data/lib/fontisan/open_type_font.rb +18 -425
  36. data/lib/fontisan/optimizers/charstring_rewriter.rb +1 -1
  37. data/lib/fontisan/optimizers/subroutine_optimizer.rb +6 -2
  38. data/lib/fontisan/sfnt_font.rb +699 -0
  39. data/lib/fontisan/sfnt_table.rb +264 -0
  40. data/lib/fontisan/subset/glyph_mapping.rb +2 -0
  41. data/lib/fontisan/subset/table_subsetter.rb +2 -2
  42. data/lib/fontisan/tables/cblc.rb +8 -4
  43. data/lib/fontisan/tables/cff/index.rb +2 -0
  44. data/lib/fontisan/tables/cff.rb +6 -3
  45. data/lib/fontisan/tables/cff2/private_dict_blend_handler.rb +1 -1
  46. data/lib/fontisan/tables/cff2.rb +1 -1
  47. data/lib/fontisan/tables/cmap.rb +5 -5
  48. data/lib/fontisan/tables/cmap_table.rb +231 -0
  49. data/lib/fontisan/tables/glyf.rb +8 -10
  50. data/lib/fontisan/tables/glyf_table.rb +255 -0
  51. data/lib/fontisan/tables/head.rb +3 -3
  52. data/lib/fontisan/tables/head_table.rb +111 -0
  53. data/lib/fontisan/tables/hhea.rb +4 -4
  54. data/lib/fontisan/tables/hhea_table.rb +255 -0
  55. data/lib/fontisan/tables/hmtx_table.rb +191 -0
  56. data/lib/fontisan/tables/loca_table.rb +212 -0
  57. data/lib/fontisan/tables/maxp.rb +2 -2
  58. data/lib/fontisan/tables/maxp_table.rb +258 -0
  59. data/lib/fontisan/tables/name.rb +1 -1
  60. data/lib/fontisan/tables/name_table.rb +176 -0
  61. data/lib/fontisan/tables/os2.rb +8 -8
  62. data/lib/fontisan/tables/os2_table.rb +329 -0
  63. data/lib/fontisan/tables/post.rb +2 -2
  64. data/lib/fontisan/tables/post_table.rb +183 -0
  65. data/lib/fontisan/tables/sbix.rb +5 -4
  66. data/lib/fontisan/true_type_font.rb +12 -464
  67. data/lib/fontisan/utilities/checksum_calculator.rb +0 -44
  68. data/lib/fontisan/validation/collection_validator.rb +4 -2
  69. data/lib/fontisan/validators/basic_validator.rb +11 -21
  70. data/lib/fontisan/validators/font_book_validator.rb +29 -50
  71. data/lib/fontisan/validators/opentype_validator.rb +24 -28
  72. data/lib/fontisan/validators/validator.rb +87 -66
  73. data/lib/fontisan/validators/web_font_validator.rb +16 -21
  74. data/lib/fontisan/version.rb +1 -1
  75. data/lib/fontisan/woff2/glyf_transformer.rb +31 -8
  76. data/lib/fontisan/woff2/hmtx_transformer.rb +2 -1
  77. data/lib/fontisan/woff2/table_transformer.rb +4 -2
  78. data/lib/fontisan/woff2_font.rb +4 -2
  79. data/lib/fontisan/woff_font.rb +46 -30
  80. data/lib/fontisan.rb +2 -2
  81. data/scripts/compare_stack_aware.rb +1 -1
  82. data/scripts/measure_optimization.rb +1 -2
  83. metadata +23 -2
@@ -0,0 +1,699 @@
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
+ # Read SFNT Font from a file
139
+ #
140
+ # @param path [String] Path to the font file
141
+ # @param mode [Symbol] Loading mode (:metadata or :full, default: :full)
142
+ # @param lazy [Boolean] If true, load tables on demand (default: false)
143
+ # @return [SfntFont] A new instance
144
+ # @raise [ArgumentError] if path is nil or empty, or if mode is invalid
145
+ # @raise [Errno::ENOENT] if file does not exist
146
+ # @raise [RuntimeError] if file format is invalid
147
+ def self.from_file(path, mode: LoadingModes::FULL, lazy: false)
148
+ if path.nil? || path.to_s.empty?
149
+ raise ArgumentError,
150
+ "path cannot be nil or empty"
151
+ end
152
+ raise Errno::ENOENT, "File not found: #{path}" unless File.exist?(path)
153
+
154
+ # Validate mode
155
+ LoadingModes.validate_mode!(mode)
156
+
157
+ File.open(path, "rb") do |io|
158
+ font = read(io)
159
+ font.initialize_storage
160
+ font.loading_mode = mode
161
+ font.lazy_load_enabled = lazy
162
+
163
+ if lazy
164
+ # Keep file handle open for lazy loading
165
+ font.io_source = File.open(path, "rb")
166
+ font.setup_finalizer
167
+ else
168
+ # Read tables upfront
169
+ font.read_table_data(io)
170
+ end
171
+
172
+ font
173
+ end
174
+ rescue BinData::ValidityError, EOFError => e
175
+ raise "Invalid font file: #{e.message}"
176
+ end
177
+
178
+ # Read SFNT Font from collection at specific offset
179
+ #
180
+ # @param io [IO] Open file handle
181
+ # @param offset [Integer] Byte offset to the font
182
+ # @param mode [Symbol] Loading mode (:metadata or :full, default: :full)
183
+ # @return [SfntFont] A new instance
184
+ def self.from_collection(io, offset, mode: LoadingModes::FULL)
185
+ LoadingModes.validate_mode!(mode)
186
+
187
+ io.seek(offset)
188
+ font = read(io)
189
+ font.initialize_storage
190
+ font.loading_mode = mode
191
+ font.read_table_data(io)
192
+ font
193
+ end
194
+
195
+ # Initialize storage hashes
196
+ #
197
+ # @return [void]
198
+ def initialize_storage
199
+ @table_data = {}
200
+ @parsed_tables = {}
201
+ @sfnt_tables = {}
202
+ @table_entry_cache = {}
203
+ @tag_encoding_cache = {} # Cache for normalized tag encodings
204
+ @table_names_cache = nil # Cache for table names array
205
+ @loading_mode = LoadingModes::FULL
206
+ @lazy_load_enabled = false
207
+ @io_source = nil
208
+
209
+ # Pre-build table entry cache for O(1) lookups
210
+ build_table_entry_cache
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 @parsed_tables[tag] if @parsed_tables.key?(tag)
451
+
452
+ # Lazy load table data if enabled
453
+ if @lazy_load_enabled && !@table_data.key?(tag)
454
+ load_table_data(tag)
455
+ end
456
+
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
+ # Build table entry cache for O(1) lookups
574
+ #
575
+ # @return [void]
576
+ def build_table_entry_cache
577
+ tables.each do |entry|
578
+ @table_entry_cache[entry.tag] = entry
579
+ end
580
+ end
581
+
582
+ # Normalize tag encoding to UTF-8 (cached for performance)
583
+ #
584
+ # @param tag [String] The tag to normalize
585
+ # @return [String] UTF-8 encoded tag
586
+ def normalize_tag(tag)
587
+ @tag_encoding_cache[tag] ||= tag.dup.force_encoding("UTF-8")
588
+ end
589
+
590
+ # Load a single table's data on demand
591
+ #
592
+ # Uses direct seek-and-read for minimal overhead. This ensures lazy loading
593
+ # performance is comparable to eager loading when accessing all tables.
594
+ #
595
+ # @param tag [String] The table tag to load
596
+ # @return [void]
597
+ def load_table_data(tag)
598
+ return unless @io_source
599
+
600
+ entry = find_table_entry(tag)
601
+ return nil unless entry
602
+
603
+ # Direct seek and read - same as eager loading but on-demand
604
+ @io_source.seek(entry.offset)
605
+ tag_key = normalize_tag(tag)
606
+ @table_data[tag_key] = @io_source.read(entry.table_length)
607
+ end
608
+
609
+ # Parse a table from raw data
610
+ #
611
+ # @param tag [String] The table tag to parse
612
+ # @return [Tables::*, nil] Parsed table object or nil
613
+ def parse_table(tag)
614
+ raw_data = @table_data[tag]
615
+ return nil unless raw_data
616
+
617
+ table_class = table_class_for(tag)
618
+ return nil unless table_class
619
+
620
+ table_class.read(raw_data)
621
+ end
622
+
623
+ # Map table tag to SfntTable wrapper class
624
+ #
625
+ # @param tag [String] The table tag
626
+ # @return [SfntTable, nil] SfntTable instance or nil
627
+ def create_sfnt_table(tag)
628
+ entry = find_table_entry(tag)
629
+ return nil unless entry
630
+
631
+ # Use hash lookup for O(1) dispatch instead of case statement
632
+ table_class = SFNT_TABLE_CLASS_MAP[tag] || SfntTable
633
+ table_class.new(self, entry)
634
+ end
635
+
636
+ # Map table tag to parser class
637
+ #
638
+ # @param tag [String] The table tag
639
+ # @return [Class, nil] Table parser class or nil
640
+ def table_class_for(tag)
641
+ TABLE_CLASS_MAP[tag]
642
+ end
643
+
644
+ # Write the structure (header + table directory) to IO
645
+ #
646
+ # @param io [IO] Open file handle
647
+ # @return [void]
648
+ def write_structure(io)
649
+ # Write header
650
+ header.write(io)
651
+
652
+ # Write table directory with placeholder offsets
653
+ tables.each do |entry|
654
+ io.write(entry.tag)
655
+ io.write([entry.checksum].pack("N"))
656
+ io.write([0].pack("N")) # Placeholder offset
657
+ io.write([entry.table_length].pack("N"))
658
+ end
659
+ end
660
+
661
+ # Write table data and update offsets in directory
662
+ #
663
+ # @param io [IO] Open file handle
664
+ # @return [void]
665
+ def write_table_data_with_offsets(io)
666
+ tables.each_with_index do |entry, index|
667
+ # Record current position
668
+ current_position = io.pos
669
+
670
+ # Write table data
671
+ data = @table_data[entry.tag]
672
+ raise IOError, "Missing table data for tag '#{entry.tag}'" if data.nil?
673
+
674
+ io.write(data)
675
+
676
+ # Add padding to align to 4-byte boundary
677
+ padding = (Constants::TABLE_ALIGNMENT - (io.pos % Constants::TABLE_ALIGNMENT)) % Constants::TABLE_ALIGNMENT
678
+ io.write("\x00" * padding) if padding.positive?
679
+
680
+ # Zero out checksumAdjustment field in head table
681
+ if entry.tag == Constants::HEAD_TAG
682
+ current_pos = io.pos
683
+ io.seek(current_position + 8)
684
+ io.write([0].pack("N"))
685
+ io.seek(current_pos)
686
+ end
687
+
688
+ # Update offset in table directory
689
+ # Table directory starts at byte 12, each entry is 16 bytes
690
+ # Offset field is at byte 8 within each entry
691
+ directory_offset_position = 12 + (index * 16) + 8
692
+ current_pos = io.pos
693
+ io.seek(directory_offset_position)
694
+ io.write([current_position].pack("N")) # Offset is now known
695
+ io.seek(current_pos)
696
+ end
697
+ end
698
+ end
699
+ end