Class: Fontisan::SfntFont Abstract

Inherits:
BinData::Record
  • Object
show all
Defined in:
lib/fontisan/sfnt_font.rb

Overview

This class is abstract.

Subclasses must implement format-specific validation

Base class for SFNT font formats (TrueType and OpenType)

This class contains all shared SFNT structure and behavior. TrueType and OpenType fonts inherit from this class and add format-specific functionality.

Examples:

Reading a font (format detected automatically)

font = Fontisan::FontLoader.load("font.ttf")  # Returns TrueTypeFont
font = Fontisan::FontLoader.load("font.otf")  # Returns OpenTypeFont

Reading and analyzing a font

ttf = Fontisan::TrueTypeFont.from_file("font.ttf")
puts ttf.header.num_tables  # => 14
name_table = ttf.table("name")
puts name_table.english_name(Tables::Name::FAMILY)

Loading with metadata mode

ttf = Fontisan::TrueTypeFont.from_file("font.ttf", mode: :metadata)
puts ttf.loading_mode  # => :metadata
ttf.table_available?("GSUB")  # => false

Writing a font

ttf.to_file("output.ttf")

Direct Known Subclasses

OpenTypeFont, TrueTypeFont

Constant Summary collapse

TABLE_CLASS_MAP =

Map table tag to parser class (cached as constant for performance)

Returns:

  • (Hash<String, Class>)

    Mapping of table tags to parser classes

{
  Constants::HEAD_TAG => Tables::Head,
  Constants::HHEA_TAG => Tables::Hhea,
  Constants::HMTX_TAG => Tables::Hmtx,
  Constants::MAXP_TAG => Tables::Maxp,
  Constants::NAME_TAG => Tables::Name,
  Constants::OS2_TAG => Tables::Os2,
  Constants::POST_TAG => Tables::Post,
  Constants::CMAP_TAG => Tables::Cmap,
  Constants::FVAR_TAG => Tables::Fvar,
  Constants::GSUB_TAG => Tables::Gsub,
  Constants::GPOS_TAG => Tables::Gpos,
  Constants::GLYF_TAG => Tables::Glyf,
  Constants::LOCA_TAG => Tables::Loca,
  "SVG " => Tables::Svg,
  "COLR" => Tables::Colr,
  "CPAL" => Tables::Cpal,
  "CBDT" => Tables::Cbdt,
  "CBLC" => Tables::Cblc,
  "sbix" => Tables::Sbix,
}.freeze
SFNT_TABLE_CLASS_MAP =

Map table tag to SfntTable wrapper class (cached as constant for performance)

Returns:

  • (Hash<String, Class>)

    Mapping of table tags to SfntTable wrapper classes

{
  Constants::HEAD_TAG => Tables::HeadTable,
  Constants::NAME_TAG => Tables::NameTable,
  Constants::OS2_TAG => Tables::Os2Table,
  Constants::CMAP_TAG => Tables::CmapTable,
  Constants::GLYF_TAG => Tables::GlyfTable,
  Constants::HHEA_TAG => Tables::HheaTable,
  Constants::MAXP_TAG => Tables::MaxpTable,
  Constants::POST_TAG => Tables::PostTable,
  Constants::HMTX_TAG => Tables::HmtxTable,
  Constants::LOCA_TAG => Tables::LocaTable,
}.freeze
PADDING_BYTES =

Padding bytes for table alignment (frozen to avoid reallocation)

("\x00" * 4).freeze

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Instance Attribute Details

#io_sourceObject

IO source for lazy loading



92
93
94
# File 'lib/fontisan/sfnt_font.rb', line 92

def io_source
  @io_source
end

#lazy_load_enabledObject

Whether lazy loading is enabled



95
96
97
# File 'lib/fontisan/sfnt_font.rb', line 95

def lazy_load_enabled
  @lazy_load_enabled
end

#loading_modeObject

Loading mode for this font (:metadata or :full)



89
90
91
# File 'lib/fontisan/sfnt_font.rb', line 89

def loading_mode
  @loading_mode
end

#parsed_tablesObject

Parsed table instances cache



80
81
82
# File 'lib/fontisan/sfnt_font.rb', line 80

def parsed_tables
  @parsed_tables
end

#sfnt_tablesObject

OOP SfntTable instances (tag => SfntTable)



83
84
85
# File 'lib/fontisan/sfnt_font.rb', line 83

def sfnt_tables
  @sfnt_tables
end

#table_dataObject

Table data is stored separately since it’s at variable offsets



77
78
79
# File 'lib/fontisan/sfnt_font.rb', line 77

def table_data
  @table_data
end

#table_entry_cacheObject

Table entry lookup cache (tag => TableDirectory)



86
87
88
# File 'lib/fontisan/sfnt_font.rb', line 86

def table_entry_cache
  @table_entry_cache
end

Class Method Details

.finalize(io) ⇒ Proc

Finalizer proc for closing IO

Parameters:

  • io (IO)

    The IO object to close

Returns:

  • (Proc)

    The finalizer proc



557
558
559
# File 'lib/fontisan/sfnt_font.rb', line 557

def self.finalize(io)
  proc { io&.close }
end

.from_collection(io, offset, mode: LoadingModes::FULL) ⇒ SfntFont

Read SFNT Font from collection at specific offset

Parameters:

  • io (IO)

    Open file handle

  • offset (Integer)

    Byte offset to the font

  • mode (Symbol) (defaults to: LoadingModes::FULL)

    Loading mode (:metadata or :full, default: :full)

Returns:



208
209
210
211
212
213
214
215
216
217
# File 'lib/fontisan/sfnt_font.rb', line 208

def self.from_collection(io, offset, mode: LoadingModes::FULL)
  LoadingModes.validate_mode!(mode)

  io.seek(offset)
  font = read(io)
  font.initialize_storage
  font.loading_mode = mode
  font.read_table_data(io)
  font
end

.from_file(path, mode: LoadingModes::FULL, lazy: false) ⇒ SfntFont

Read SFNT Font from a file

Parameters:

  • path (String)

    Path to the font file

  • mode (Symbol) (defaults to: LoadingModes::FULL)

    Loading mode (:metadata or :full, default: :full)

  • lazy (Boolean) (defaults to: false)

    If true, load tables on demand (default: false)

Returns:

Raises:

  • (ArgumentError)

    if path is nil or empty, or if mode is invalid

  • (Errno::ENOENT)

    if file does not exist

  • (RuntimeError)

    if file format is invalid



150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
# File 'lib/fontisan/sfnt_font.rb', line 150

def self.from_file(path, mode: LoadingModes::FULL, lazy: false)
  if path.nil? || path.to_s.empty?
    raise ArgumentError, "path cannot be nil or empty"
  end
  raise Errno::ENOENT, "File not found: #{path}" unless File.exist?(path)

  LoadingModes.validate_mode!(mode)

  font = new
  font.initialize_storage
  font.loading_mode = mode
  font.lazy_load_enabled = lazy

  lazy ? load_lazy(path, font) : load_eager(path, font)
end

Instance Method Details

#all_sfnt_tablesHash<String, SfntTable>

Get all SfntTable instances

Returns:

  • (Hash<String, SfntTable>)

    Hash mapping tag => SfntTable instance



445
446
447
# File 'lib/fontisan/sfnt_font.rb', line 445

def all_sfnt_tables
  table_names.to_h { |tag| [tag, sfnt_table(tag)] }
end

#closevoid

This method returns an undefined value.

Close the IO source (for lazy loading)



541
542
543
544
# File 'lib/fontisan/sfnt_font.rb', line 541

def close
  @io_source&.close
  @io_source = nil
end

#family_nameString?

Get font family name

Returns:

  • (String, nil)

    Family name or nil if not found



493
494
495
496
# File 'lib/fontisan/sfnt_font.rb', line 493

def family_name
  name_table = table(Constants::NAME_TAG)
  name_table&.english_name(Tables::Name::FAMILY)
end

#find_table_entry(tag) ⇒ TableDirectory?

Find a table entry by tag (cached for performance)

Parameters:

  • tag (String)

    The table tag to find

Returns:



394
395
396
397
398
399
400
# File 'lib/fontisan/sfnt_font.rb', line 394

def find_table_entry(tag)
  return @table_entry_cache[tag] if @table_entry_cache.key?(tag)

  entry = tables.find { |entry| entry.tag == tag }
  @table_entry_cache[tag] = entry
  entry
end

#full_nameString?

Get full font name

Returns:

  • (String, nil)

    Full name or nil if not found



509
510
511
512
# File 'lib/fontisan/sfnt_font.rb', line 509

def full_name
  name_table = table(Constants::NAME_TAG)
  name_table&.english_name(Tables::Name::FULL_NAME)
end

#has_table?(tag) ⇒ Boolean

Check if font has a specific table (optimized with cache)

Parameters:

  • tag (String)

    The table tag to check for

Returns:

  • (Boolean)

    true if table exists, false otherwise



376
377
378
# File 'lib/fontisan/sfnt_font.rb', line 376

def has_table?(tag)
  !find_table_entry(tag).nil?
end

#head_tableTableDirectory?

Get the head table entry

Returns:



405
406
407
# File 'lib/fontisan/sfnt_font.rb', line 405

def head_table
  find_table_entry(Constants::HEAD_TAG)
end

#initialize_storagevoid

This method returns an undefined value.

Initialize storage hashes



222
223
224
225
226
227
228
229
230
231
232
# File 'lib/fontisan/sfnt_font.rb', line 222

def initialize_storage
  @table_data = {}
  @parsed_tables = {}
  @sfnt_tables = {}
  @table_entry_cache = {}
  @tag_encoding_cache = {} # Cache for normalized tag encodings
  @table_names = nil # Cache for table names array
  @loading_mode = LoadingModes::FULL
  @lazy_load_enabled = false
  @io_source = nil
end

#post_script_nameString?

Get PostScript name

Returns:

  • (String, nil)

    PostScript name or nil if not found



517
518
519
520
# File 'lib/fontisan/sfnt_font.rb', line 517

def post_script_name
  name_table = table(Constants::NAME_TAG)
  name_table&.english_name(Tables::Name::POSTSCRIPT_NAME)
end

#preferred_family_nameString?

Get preferred family name

Returns:

  • (String, nil)

    Preferred family name or nil if not found



525
526
527
528
# File 'lib/fontisan/sfnt_font.rb', line 525

def preferred_family_name
  name_table = table(Constants::NAME_TAG)
  name_table&.english_name(Tables::Name::PREFERRED_FAMILY)
end

#preferred_subfamily_nameString?

Get preferred subfamily name

Returns:

  • (String, nil)

    Preferred subfamily name or nil if not found



533
534
535
536
# File 'lib/fontisan/sfnt_font.rb', line 533

def preferred_subfamily_name
  name_table = table(Constants::NAME_TAG)
  name_table&.english_name(Tables::Name::PREFERRED_SUBFAMILY)
end

#read_metadata_tables_batched(io) ⇒ void

This method returns an undefined value.

Read metadata tables using page-aware batching

Groups adjacent tables within page boundaries and reads them together to maximize filesystem prefetching and minimize random seeks.

Parameters:

  • io (IO)

    Open file handle



272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
# File 'lib/fontisan/sfnt_font.rb', line 272

def (io)
  # Typical filesystem page size (4KB is common, but 8KB gives better prefetch window)
  page_threshold = 8192

  # Get metadata tables sorted by offset for sequential access
   = tables.select { |entry| LoadingModes::METADATA_TABLES_SET.include?(entry.tag) }
  .sort_by!(&:offset)

  return if .empty?

  # Group adjacent tables within page threshold for batched reading
  i = 0
  while i < .size
    batch_start = [i]
    batch_end = batch_start
    batch_entries = [batch_start]

    # Extend batch while next table is within page threshold
    j = i + 1
    while j < .size
      next_entry = [j]
      gap = next_entry.offset - (batch_end.offset + batch_end.table_length)

      # If gap is small (within page threshold), include in batch
      if gap <= page_threshold
        batch_end = next_entry
        batch_entries << next_entry
        j += 1
      else
        break
      end
    end

    # Read batch
    if batch_entries.size == 1
      # Single table, read normally
      io.seek(batch_start.offset)
      tag_key = normalize_tag(batch_start.tag)
      @table_data[tag_key] = io.read(batch_start.table_length)
    else
      # Multiple tables, read contiguous segment
      batch_offset = batch_start.offset
      batch_length = (batch_end.offset + batch_end.table_length) - batch_start.offset

      io.seek(batch_offset)
      batch_data = io.read(batch_length)

      # Extract individual tables from batch
      batch_entries.each do |entry|
        relative_offset = entry.offset - batch_offset
        tag_key = normalize_tag(entry.tag)
        @table_data[tag_key] =
          batch_data[relative_offset, entry.table_length]
      end
    end

    i = j
  end
end

#read_table_data(io) ⇒ void

This method returns an undefined value.

Read table data for all tables in the font

In metadata mode, only reads metadata tables. In full mode, reads all tables. In lazy load mode, doesn’t read data upfront.

Parameters:

  • io (IO)

    IO object to read from



241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
# File 'lib/fontisan/sfnt_font.rb', line 241

def read_table_data(io)
  @table_data = {}

  if @lazy_load_enabled
    # Don't read data, just keep IO reference
    @io_source = io
    return
  end

  if @loading_mode == LoadingModes::METADATA
    # Only read metadata tables for performance
    # Use page-aware batched reading to maximize filesystem prefetching
    (io)
  else
    # Read all tables
    tables.each do |entry|
      io.seek(entry.offset)
      # Normalize tag encoding for hash key consistency
      tag_key = normalize_tag(entry.tag)
      @table_data[tag_key] = io.read(entry.table_length)
    end
  end
end

#setup_finalizervoid

This method returns an undefined value.

Setup finalizer for cleanup



549
550
551
# File 'lib/fontisan/sfnt_font.rb', line 549

def setup_finalizer
  ObjectSpace.define_finalizer(self, self.class.finalize(@io_source))
end

#sfnt_table(tag) ⇒ SfntTable?

Get OOP SfntTable instance for a table

Returns a SfntTable (or subclass) instance that encapsulates the table’s metadata, lazy loading, parsing, and validation. This provides a more object-oriented interface than the separate TableDirectory/@table_data/@parsed_tables approach.

Examples:

Using SfntTable for validation

head = font.sfnt_table("head")
head.validate!  # Performs head-specific validation
head.units_per_em  # => 2048 (convenience method)

Parameters:

  • tag (String)

    The table tag to retrieve

Returns:

  • (SfntTable, nil)

    SfntTable instance (or subclass like HeadTable), or nil if not found



430
431
432
433
434
435
436
437
438
439
440
# File 'lib/fontisan/sfnt_font.rb', line 430

def sfnt_table(tag)
  # Return cached instance if available (fast path)
  cached = @sfnt_tables[tag]
  return cached if cached

  # Only check has_table? if not cached (avoids redundant lookup)
  return nil unless has_table?(tag)

  # Create and cache (find_table_entry is already cached internally)
  @sfnt_tables[tag] = create_sfnt_table(tag)
end

#subfamily_nameString?

Get font subfamily name (e.g., Regular, Bold, Italic)

Returns:

  • (String, nil)

    Subfamily name or nil if not found



501
502
503
504
# File 'lib/fontisan/sfnt_font.rb', line 501

def subfamily_name
  name_table = table(Constants::NAME_TAG)
  name_table&.english_name(Tables::Name::SUBFAMILY)
end

#table(tag) ⇒ Tables::*?

Get parsed table instance

This method parses the raw table data into a structured table object and caches the result for subsequent calls. Enforces mode restrictions.

Parameters:

  • tag (String)

    The table tag to retrieve

Returns:

  • (Tables::*, nil)

    Parsed table object or nil if not found

Raises:

  • (ArgumentError)

    if table is not available in current loading mode



457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
# File 'lib/fontisan/sfnt_font.rb', line 457

def table(tag)
  # Check mode restrictions
  unless table_available?(tag)
    if has_table?(tag)
      raise ArgumentError,
            "Table '#{tag}' is not available in #{@loading_mode} mode. " \
            "Available tables: #{LoadingModes.tables_for(@loading_mode).inspect}"
    else
      return nil
    end
  end

  # Return cached if available (fast path)
  return @parsed_tables[tag] if @parsed_tables.key?(tag)

  # Lazy load table data if enabled
  load_table_data(tag) if @lazy_load_enabled && !@table_data.key?(tag)

  # Parse and cache
  @parsed_tables[tag] ||= parse_table(tag)
end

#table_available?(tag) ⇒ Boolean

Check if a table is available in the current loading mode

Parameters:

  • tag (String)

    The table tag to check

Returns:

  • (Boolean)

    true if table is available in current mode



384
385
386
387
388
# File 'lib/fontisan/sfnt_font.rb', line 384

def table_available?(tag)
  return false unless has_table?(tag)

  LoadingModes.table_allowed?(@loading_mode, tag)
end

#table_namesArray<String>

Get list of all table tags (cached for performance)

Returns:

  • (Array<String>)

    Array of table tag strings



412
413
414
# File 'lib/fontisan/sfnt_font.rb', line 412

def table_names
  @table_names ||= tables.map(&:tag)
end

#to_file(path) ⇒ Integer

Write SFNT Font to a file

Writes the complete font structure to disk, including proper checksum calculation and table alignment.

Parameters:

  • path (String)

    Path where the font file will be written

Returns:

  • (Integer)

    Number of bytes written

Raises:

  • (IOError)

    if writing fails



340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
# File 'lib/fontisan/sfnt_font.rb', line 340

def to_file(path)
  File.open(path, "w+b") do |io|
    # Write header and tables (directory)
    write_structure(io)

    # Write table data with updated offsets
    write_table_data_with_offsets(io)

    # Update checksum adjustment in head table BEFORE closing file
    # This avoids Windows file locking issues when Tempfiles are used
    head = head_table
    update_checksum_adjustment_in_io(io, head.offset) if head

    io.pos
  end

  File.size(path)
end

#units_per_emInteger?

Get units per em from head table

Returns:

  • (Integer, nil)

    Units per em value



482
483
484
485
# File 'lib/fontisan/sfnt_font.rb', line 482

def units_per_em
  head = table(Constants::HEAD_TAG)
  head&.units_per_em
end

#update_checksum_adjustment_in_file(path, head_offset) ⇒ void

This method returns an undefined value.

Update checksum adjustment in head table by file path

Opens the file, calculates the checksum, and updates the adjustment.

Parameters:

  • path (String)

    Path to the font file

  • head_offset (Integer)

    Offset to the head table



584
585
586
587
588
# File 'lib/fontisan/sfnt_font.rb', line 584

def update_checksum_adjustment_in_file(path, head_offset)
  File.open(path, "r+b") do |io|
    update_checksum_adjustment_in_io(io, head_offset)
  end
end

#update_checksum_adjustment_in_io(io, head_offset) ⇒ void

This method returns an undefined value.

Update checksum adjustment in head table using IO

Calculates the checksum of the entire file and writes the adjustment value to the head table’s checksumAdjustment field.

Parameters:

  • io (IO)

    IO object to read from and write to

  • head_offset (Integer)

    Offset to the head table



569
570
571
572
573
574
575
# File 'lib/fontisan/sfnt_font.rb', line 569

def update_checksum_adjustment_in_io(io, head_offset)
  io.rewind
  checksum = Utilities::ChecksumCalculator.calculate_checksum_from_io(io)
  adjustment = Utilities::ChecksumCalculator.calculate_adjustment(checksum)
  io.seek(head_offset + 8)
  io.write([adjustment].pack("N"))
end

#valid?Boolean

Validate format correctness

Returns:

  • (Boolean)

    true if the font format is valid, false otherwise



362
363
364
365
366
367
368
369
370
# File 'lib/fontisan/sfnt_font.rb', line 362

def valid?
  return false unless header
  return false unless tables.respond_to?(:length)
  return false unless @table_data.is_a?(Hash)
  return false if tables.length != header.num_tables
  return false unless head_table

  true
end