Class: Fontisan::OpenTypeFont

Inherits:
BinData::Record
  • Object
show all
Defined in:
lib/fontisan/open_type_font.rb,
lib/fontisan/open_type_font_extensions.rb

Overview

Extensions to OpenTypeFont for table-based construction

Constant Summary collapse

PAGE_SIZE =

Page size for lazy loading alignment (typical filesystem page size)

4096

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Instance Attribute Details

#io_sourceObject

IO source for lazy loading



45
46
47
# File 'lib/fontisan/open_type_font.rb', line 45

def io_source
  @io_source
end

#lazy_load_enabledObject

Whether lazy loading is enabled



48
49
50
# File 'lib/fontisan/open_type_font.rb', line 48

def lazy_load_enabled
  @lazy_load_enabled
end

#loading_modeObject

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



42
43
44
# File 'lib/fontisan/open_type_font.rb', line 42

def loading_mode
  @loading_mode
end

#page_cacheObject

Page cache for lazy loading (maps page_start_offset => page_data)



51
52
53
# File 'lib/fontisan/open_type_font.rb', line 51

def page_cache
  @page_cache
end

#parsed_tablesObject

Parsed table instances cache



39
40
41
# File 'lib/fontisan/open_type_font.rb', line 39

def parsed_tables
  @parsed_tables
end

#table_dataObject

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



36
37
38
# File 'lib/fontisan/open_type_font.rb', line 36

def table_data
  @table_data
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



424
425
426
# File 'lib/fontisan/open_type_font.rb', line 424

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

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

Read OpenType 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:



102
103
104
105
106
107
108
109
110
111
# File 'lib/fontisan/open_type_font.rb', line 102

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) ⇒ OpenTypeFont

Read OpenType Font from a file

Parameters:

  • path (String)

    Path to the OTF 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 for eager loading)

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



65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
# File 'lib/fontisan/open_type_font.rb', line 65

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)

  # Validate mode
  LoadingModes.validate_mode!(mode)

  File.open(path, "rb") do |io|
    font = read(io)
    font.initialize_storage
    font.loading_mode = mode
    font.lazy_load_enabled = lazy

    if lazy
      # Keep file handle open for lazy loading
      font.io_source = File.open(path, "rb")
      font.setup_finalizer
    else
      # Read tables upfront
      font.read_table_data(io)
    end

    font
  end
rescue BinData::ValidityError, EOFError => e
  raise "Invalid OTF file: #{e.message}"
end

.from_tables(tables) ⇒ OpenTypeFont

Create font from hash of tables

This is used during font conversion when we have tables but not a file.

Parameters:

  • tables (Hash<String, String>)

    Map of table tag to binary data

Returns:



12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
# File 'lib/fontisan/open_type_font_extensions.rb', line 12

def self.from_tables(tables)
  # Create minimal header structure
  font = new
  font.initialize_storage
  font.loading_mode = LoadingModes::FULL

  # Store table data
  font.table_data = tables

  # Build header from tables
  num_tables = tables.size
  max_power = 0
  n = num_tables
  while n > 1
    n >>= 1
    max_power += 1
  end

  search_range = (1 << max_power) * 16
  entry_selector = max_power
  range_shift = (num_tables * 16) - search_range

  font.header.sfnt_version = 0x4F54544F # 'OTTO' for OpenType/CFF
  font.header.num_tables = num_tables
  font.header.search_range = search_range
  font.header.entry_selector = entry_selector
  font.header.range_shift = range_shift

  # Build table directory
  font.tables.clear
  tables.each_key do |tag|
    entry = TableDirectory.new
    entry.tag = tag
    entry.checksum = 0 # Will be calculated on write
    entry.offset = 0 # Will be calculated on write
    entry.table_length = tables[tag].bytesize
    font.tables << entry
  end

  font
end

Instance Method Details

#cff?Boolean

Check if font is CFF flavored

Returns:

  • (Boolean)

    true for OpenType fonts



272
273
274
# File 'lib/fontisan/open_type_font.rb', line 272

def cff?
  true
end

#closevoid

This method returns an undefined value.

Close the IO source (for lazy loading)



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

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



360
361
362
363
# File 'lib/fontisan/open_type_font.rb', line 360

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

Parameters:

  • tag (String)

    The table tag to find

Returns:



298
299
300
# File 'lib/fontisan/open_type_font.rb', line 298

def find_table_entry(tag)
  tables.find { |entry| entry.tag == tag }
end

#full_nameString?

Get full font name

Returns:

  • (String, nil)

    Full name or nil if not found



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

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

Parameters:

  • tag (String)

    The table tag to check for

Returns:

  • (Boolean)

    true if table exists, false otherwise



280
281
282
# File 'lib/fontisan/open_type_font.rb', line 280

def has_table?(tag)
  tables.any? { |entry| entry.tag == tag }
end

#head_tableTableDirectory?

Get the head table entry

Returns:



305
306
307
# File 'lib/fontisan/open_type_font.rb', line 305

def head_table
  find_table_entry(Constants::HEAD_TAG)
end

#initialize_storagevoid

This method returns an undefined value.

Initialize storage hashes



116
117
118
119
120
121
122
123
# File 'lib/fontisan/open_type_font.rb', line 116

def initialize_storage
  @table_data = {}
  @parsed_tables = {}
  @loading_mode = LoadingModes::FULL
  @lazy_load_enabled = false
  @io_source = nil
  @page_cache = {}
end

#post_script_nameString?

Get PostScript name

Returns:

  • (String, nil)

    PostScript name or nil if not found



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

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



392
393
394
395
# File 'lib/fontisan/open_type_font.rb', line 392

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



400
401
402
403
# File 'lib/fontisan/open_type_font.rb', line 400

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



163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
# File 'lib/fontisan/open_type_font.rb', line 163

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 = batch_start.tag.dup.force_encoding("UTF-8")
      @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 = entry.tag.dup.force_encoding("UTF-8")
        @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 metadata mode, only reads metadata tables. In full mode, reads all tables. In lazy load mode, doesn’t read data upfront.

Parameters:

  • io (IO)

    Open file handle



132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
# File 'lib/fontisan/open_type_font.rb', line 132

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)
      # Force UTF-8 encoding on tag for hash key consistency
      tag_key = entry.tag.dup.force_encoding("UTF-8")
      @table_data[tag_key] = io.read(entry.table_length)
    end
  end
end

#setup_finalizervoid

This method returns an undefined value.

Setup finalizer for cleanup



416
417
418
# File 'lib/fontisan/open_type_font.rb', line 416

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

#subfamily_nameString?

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

Returns:

  • (String, nil)

    Subfamily name or nil if not found



368
369
370
371
# File 'lib/fontisan/open_type_font.rb', line 368

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



324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
# File 'lib/fontisan/open_type_font.rb', line 324

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 @parsed_tables[tag] if @parsed_tables.key?(tag)

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

  @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



288
289
290
291
292
# File 'lib/fontisan/open_type_font.rb', line 288

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

Returns:

  • (Array<String>)

    Array of table tag strings



312
313
314
# File 'lib/fontisan/open_type_font.rb', line 312

def table_names
  tables.map(&:tag)
end

#to_file(path) ⇒ Integer

Write OpenType Font to a file

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

Parameters:

  • path (String)

    Path where the OTF file will be written

Returns:

  • (Integer)

    Number of bytes written

Raises:

  • (IOError)

    if writing fails



231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
# File 'lib/fontisan/open_type_font.rb', line 231

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

    # Write table data with updated offsets
    write_table_data_with_offsets(io)

    io.pos
  end

  # Update checksum adjustment in head table
  update_checksum_adjustment_in_file(path) if head_table

  File.size(path)
end

#truetype?Boolean

Check if font is TrueType flavored

Returns:

  • (Boolean)

    false for OpenType fonts



265
266
267
# File 'lib/fontisan/open_type_font.rb', line 265

def truetype?
  false
end

#units_per_emInteger?

Get units per em from head table

Returns:

  • (Integer, nil)

    Units per em value



349
350
351
352
# File 'lib/fontisan/open_type_font.rb', line 349

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

#valid?Boolean

Validate format correctness

Returns:

  • (Boolean)

    true if the OTF format is valid, false otherwise



251
252
253
254
255
256
257
258
259
260
# File 'lib/fontisan/open_type_font.rb', line 251

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
  return false unless has_table?(Constants::CFF_TAG)

  true
end