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



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

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:



205
206
207
208
209
210
211
212
213
214
# File 'lib/fontisan/sfnt_font.rb', line 205

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



442
443
444
445
446
# File 'lib/fontisan/sfnt_font.rb', line 442

def all_sfnt_tables
  table_names.each_with_object({}) do |tag, hash|
    hash[tag] = sfnt_table(tag)
  end
end

#closevoid

This method returns an undefined value.

Close the IO source (for lazy loading)



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

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



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

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:



391
392
393
394
395
396
397
# File 'lib/fontisan/sfnt_font.rb', line 391

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



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

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



373
374
375
# File 'lib/fontisan/sfnt_font.rb', line 373

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

#head_tableTableDirectory?

Get the head table entry

Returns:



402
403
404
# File 'lib/fontisan/sfnt_font.rb', line 402

def head_table
  find_table_entry(Constants::HEAD_TAG)
end

#initialize_storagevoid

This method returns an undefined value.

Initialize storage hashes



219
220
221
222
223
224
225
226
227
228
229
# File 'lib/fontisan/sfnt_font.rb', line 219

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



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

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



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

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



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

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



269
270
271
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
# File 'lib/fontisan/sfnt_font.rb', line 269

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



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

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



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

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



427
428
429
430
431
432
433
434
435
436
437
# File 'lib/fontisan/sfnt_font.rb', line 427

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



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

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



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

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



381
382
383
384
385
# File 'lib/fontisan/sfnt_font.rb', line 381

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



409
410
411
# File 'lib/fontisan/sfnt_font.rb', line 409

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



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

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



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

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



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

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



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

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



359
360
361
362
363
364
365
366
367
# File 'lib/fontisan/sfnt_font.rb', line 359

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