Class: Fontisan::GlyphAccessor

Inherits:
Object
  • Object
show all
Defined in:
lib/fontisan/glyph_accessor.rb

Overview

High-level utility class for unified glyph access across font formats

[‘GlyphAccessor`](lib/fontisan/glyph_accessor.rb) provides a clean, unified interface for accessing glyphs regardless of the underlying font format (TrueType with glyf table or OpenType with CFF table).

This class automatically detects the font format and delegates to the appropriate table parser, abstracting away the complexity of different glyph storage mechanisms.

Key features:

  • Unified glyph access by ID, Unicode character, or PostScript name

  • Automatic format detection (TrueType glyf vs CFF)

  • Metrics retrieval (advance width, left sidebearing)

  • Glyph closure calculation for subsetting (tracks composite dependencies)

  • Validation of glyph IDs and character mappings

  • Bounded LRU cache to prevent unbounded memory growth

Reference: [‘docs/ttfunk-feature-analysis.md:541-575`](docs/ttfunk-feature-analysis.md:541)

Examples:

Basic usage

font = Fontisan::TrueTypeFont.from_file('font.ttf')
accessor = Fontisan::GlyphAccessor.new(font)

# Access glyph by ID
glyph = accessor.glyph_for_id(42)
puts glyph.class  # => SimpleGlyph or CompoundGlyph

# Access glyph by Unicode character
glyph_a = accessor.glyph_for_char(0x0041)  # 'A'

# Get metrics
metrics = accessor.metrics_for_id(42)
puts "Width: #{metrics[:advance_width]}, LSB: #{metrics[:lsb]}"

Subsetting workflow with closure

# Calculate all glyphs needed (including composite dependencies)
base_glyphs = [0, 1, 65, 66, 67]  # .notdef, A, B, C
all_glyphs = accessor.closure_for(base_glyphs)
puts "Total glyphs needed: #{all_glyphs.size}"

Constant Summary collapse

MAX_GLYPH_CACHE_SIZE =

Maximum number of glyphs to cache before LRU eviction

10_000

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(font) ⇒ GlyphAccessor

Initialize a new glyph accessor

Parameters:

Raises:

  • (ArgumentError)

    If font is nil or doesn’t respond to table method



55
56
57
58
59
60
61
62
63
64
65
66
# File 'lib/fontisan/glyph_accessor.rb', line 55

def initialize(font)
  raise ArgumentError, "Font cannot be nil" if font.nil?

  unless font.respond_to?(:table)
    raise ArgumentError, "Font must respond to :table method"
  end

  @font = font
  @glyph_cache = {}
  @closure_cache = {}
  @glyph_access_times = {}
end

Instance Attribute Details

#fontTrueTypeFont, OpenTypeFont (readonly)

Font instance this accessor operates on



49
50
51
# File 'lib/fontisan/glyph_accessor.rb', line 49

def font
  @font
end

Instance Method Details

#cff?Boolean

Check if font uses CFF outlines (CFF table)

Returns:

  • (Boolean)

    True if font has CFF table



302
303
304
# File 'lib/fontisan/glyph_accessor.rb', line 302

def cff?
  font.table("CFF ") != nil
end

#clear_cachevoid

This method returns an undefined value.

Clear internal caches to free memory

Useful for long-running processes that access many glyphs.



380
381
382
383
384
385
386
387
388
# File 'lib/fontisan/glyph_accessor.rb', line 380

def clear_cache
  @glyph_cache.clear
  @closure_cache.clear
  @glyph_access_times.clear

  # Also clear glyf table cache if present
  glyf = font.table("glyf")
  glyf&.clear_cache if glyf.respond_to?(:clear_cache)
end

#closure_for(glyph_ids) ⇒ Set<Integer>

Calculate glyph closure for subsetting

This method recursively tracks all glyphs needed for a given set of glyph IDs, including component glyphs referenced by compound glyphs. This is essential for font subsetting to ensure all required glyphs are included.

The closure always includes glyph 0 (.notdef) as required by the OpenType specification.

Examples:

Calculate closure for subsetting

# Want to subset to just "ABC"
base_glyphs = [65, 66, 67]  # Assuming these are glyph IDs for A, B, C
all_needed = accessor.closure_for(base_glyphs)
# all_needed includes base glyphs + any composite dependencies + .notdef

Closure with composite glyphs

# If 'Ä' (glyph 100) is composite referencing 'A' (glyph 65) and
# dieresis (glyph 200)
closure = accessor.closure_for([100])
# Returns: [0, 100, 65, 200]  (includes .notdef, Ä, A, dieresis)

Parameters:

  • glyph_ids (Array<Integer>)

    Base set of glyph IDs

Returns:

  • (Set<Integer>)

    Complete set of glyph IDs needed (including composite dependencies)

Raises:

  • (ArgumentError)

    If glyph_ids is not an array



332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
# File 'lib/fontisan/glyph_accessor.rb', line 332

def closure_for(glyph_ids)
  unless glyph_ids.is_a?(Array)
    raise ArgumentError, "glyph_ids must be an Array"
  end

  # Start with provided glyphs plus .notdef
  result = Set.new([0])
  glyph_ids.each { |id| result.add(id) if glyph_exists?(id) }

  # CFF fonts have no composite glyphs, so return early
  return result if cff?

  # Recursively collect composite dependencies (TrueType only)
  to_process = result.to_a.dup
  processed = Set.new

  while (glyph_id = to_process.shift)
    next if processed.include?(glyph_id)

    processed.add(glyph_id)

    # Get glyph and check if it's compound
    glyph = glyph_for_id(glyph_id)
    next unless glyph
    next unless glyph.respond_to?(:compound?) && glyph.compound?

    # Add component glyph IDs
    if glyph.respond_to?(:components)
      glyph.components.each do |component|
        component_id = component[:glyph_index]
        next unless glyph_exists?(component_id)

        unless result.include?(component_id)
          result.add(component_id)
          to_process << component_id
        end
      end
    end
  end

  result
end

#glyph_exists?(glyph_id) ⇒ Boolean

Check if a glyph ID exists and is valid

Parameters:

  • glyph_id (Integer)

    Glyph ID to check

Returns:

  • (Boolean)

    True if glyph ID is valid



275
276
277
278
279
280
281
282
# File 'lib/fontisan/glyph_accessor.rb', line 275

def glyph_exists?(glyph_id)
  return false if glyph_id.nil? || glyph_id.negative?

  maxp = font.table("maxp")
  return false unless maxp

  glyph_id < maxp.num_glyphs
end

#glyph_for_char(char_code) ⇒ SimpleGlyph, ...

Get glyph object for a Unicode character code

Uses the cmap table to map the character code to a glyph ID, then retrieves the corresponding glyph.

Examples:

Get glyph for ‘A’

glyph_a = accessor.glyph_for_char(0x0041)
glyph_a = accessor.glyph_for_char('A'.ord)  # Equivalent

Parameters:

  • char_code (Integer)

    Unicode character code (e.g., 0x0041 for ‘A’)

Returns:

  • (SimpleGlyph, CompoundGlyph, nil)

    Glyph object or nil if character is not mapped

Raises:



126
127
128
129
130
131
# File 'lib/fontisan/glyph_accessor.rb', line 126

def glyph_for_char(char_code)
  glyph_id = char_to_glyph_id(char_code)
  return nil unless glyph_id

  glyph_for_id(glyph_id)
end

#glyph_for_id(glyph_id) ⇒ SimpleGlyph, ...

Get glyph object for a glyph ID

Returns the appropriate glyph object based on the font format:

  • TrueType fonts: [‘SimpleGlyph`](lib/fontisan/tables/glyf/simple_glyph.rb) or [`CompoundGlyph`](lib/fontisan/tables/glyf/compound_glyph.rb)

  • CFF fonts: [‘CFFGlyph`](lib/fontisan/tables/cff/cff_glyph.rb)

Examples:

Get a glyph

glyph = accessor.glyph_for_id(65)
if glyph
  puts "Bounding box: #{glyph.bounding_box}"
  puts "Type: #{glyph.simple? ? 'simple' : 'compound'}"
end

Parameters:

  • glyph_id (Integer)

    Glyph ID (0-based, 0 is .notdef)

Returns:

  • (SimpleGlyph, CompoundGlyph, CFFGlyph, nil)

    Glyph object or nil if glyph is empty or invalid

Raises:



87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
# File 'lib/fontisan/glyph_accessor.rb', line 87

def glyph_for_id(glyph_id)
  validate_glyph_id!(glyph_id)

  # Check cache first and update access time
  if @glyph_cache.key?(glyph_id)
    @glyph_access_times[glyph_id] = Time.now.to_f
    return @glyph_cache[glyph_id]
  end

  glyph = if truetype?
            truetype_glyph(glyph_id)
          elsif cff?
            cff_glyph(glyph_id)
          else
            raise Fontisan::MissingTableError,
                  "Font has neither glyf nor CFF table"
          end

  # Evict least recently used entry if cache is full
  evict_lru_glyph if @glyph_cache.size >= MAX_GLYPH_CACHE_SIZE

  @glyph_cache[glyph_id] = glyph
  @glyph_access_times[glyph_id] = Time.now.to_f
  glyph
end

#glyph_for_name(glyph_name) ⇒ SimpleGlyph, ...

Get glyph object for a PostScript glyph name

Uses the post table (if available) to map the glyph name to a glyph ID. This method is primarily useful for fonts with post table version 2.0.

Examples:

Get glyph by name

glyph = accessor.glyph_for_name("A")
glyph = accessor.glyph_for_name("Aacute")

Parameters:

  • glyph_name (String)

    PostScript glyph name (e.g., “A”, “Aacute”)

Returns:

  • (SimpleGlyph, CompoundGlyph, nil)

    Glyph object or nil if name is not found

Raises:



147
148
149
150
151
152
# File 'lib/fontisan/glyph_accessor.rb', line 147

def glyph_for_name(glyph_name)
  glyph_id = name_to_glyph_id(glyph_name)
  return nil unless glyph_id

  glyph_for_id(glyph_id)
end

#has_glyph_for_char?(char_code) ⇒ Boolean

Check if a Unicode character is mapped in the font

Parameters:

  • char_code (Integer)

    Unicode character code

Returns:

  • (Boolean)

    True if character has a glyph mapping



288
289
290
# File 'lib/fontisan/glyph_accessor.rb', line 288

def has_glyph_for_char?(char_code)
  !char_to_glyph_id(char_code).nil?
end

#metrics_for_char(char_code) ⇒ Hash{Symbol => Integer}?

Get horizontal metrics for a Unicode character

Examples:

Get metrics for ‘A’

metrics = accessor.metrics_for_char(0x0041)

Parameters:

  • char_code (Integer)

    Unicode character code

Returns:

  • (Hash{Symbol => Integer}, nil)

    Metrics hash or nil if not mapped

Raises:



189
190
191
192
193
194
# File 'lib/fontisan/glyph_accessor.rb', line 189

def metrics_for_char(char_code)
  glyph_id = char_to_glyph_id(char_code)
  return nil unless glyph_id

  metrics_for_id(glyph_id)
end

#metrics_for_id(glyph_id) ⇒ Hash{Symbol => Integer}?

Get horizontal metrics for a glyph ID

Returns a hash with advance width and left sidebearing in font units.

Examples:

Get metrics

metrics = accessor.metrics_for_id(65)  # 'A'
puts "Advance width: #{metrics[:advance_width]} FUnits"
puts "Left sidebearing: #{metrics[:lsb]} FUnits"

Parameters:

  • glyph_id (Integer)

    Glyph ID

Returns:

  • (Hash{Symbol => Integer}, nil)

    Hash with :advance_width and :lsb keys, or nil if glyph is invalid

Raises:



167
168
169
170
171
172
173
174
175
176
177
178
179
# File 'lib/fontisan/glyph_accessor.rb', line 167

def metrics_for_id(glyph_id)
  validate_glyph_id!(glyph_id)

  hmtx = font.table("hmtx")
  raise_missing_table!("hmtx") unless hmtx

  unless hmtx.parsed?
    # Auto-parse if not already parsed
    parse_hmtx_with_context!(hmtx)
  end

  hmtx.metric_for(glyph_id)
end

#outline_for_char(char) ⇒ Models::GlyphOutline?

Get outline for character

Convenience method that takes a character string and extracts its outline. The character is converted to its Unicode codepoint first.

Examples:

Get outline for ‘A’

outline = accessor.outline_for_char('A')
commands = outline.to_commands if outline

Handle multi-codepoint characters

outline = accessor.outline_for_char('A')  # Works
outline = accessor.outline_for_char('AB') # ArgumentError

Parameters:

  • char (String)

    Single character (e.g., ‘A’, ‘中’, ‘😀’)

Returns:

  • (Models::GlyphOutline, nil)

    Outline object or nil if character is not mapped or glyph is empty

Raises:



262
263
264
265
266
267
268
269
# File 'lib/fontisan/glyph_accessor.rb', line 262

def outline_for_char(char)
  unless char.is_a?(String) && char.length == 1
    raise ArgumentError,
          "char must be a single character String, got: #{char.inspect}"
  end

  outline_for_codepoint(char.ord)
end

#outline_for_codepoint(codepoint) ⇒ Models::GlyphOutline?

Get outline for Unicode codepoint

Maps a Unicode codepoint to a glyph ID via the cmap table, then extracts the outline for that glyph.

Examples:

Get outline for ‘A’

outline = accessor.outline_for_codepoint(0x0041)
svg_path = outline.to_svg_path if outline

Parameters:

  • codepoint (Integer)

    Unicode codepoint (e.g., 0x0041 for ‘A’)

Returns:

  • (Models::GlyphOutline, nil)

    Outline object or nil if character is not mapped or glyph is empty

Raises:



237
238
239
240
241
242
# File 'lib/fontisan/glyph_accessor.rb', line 237

def outline_for_codepoint(codepoint)
  glyph_id = char_to_glyph_id(codepoint)
  return nil unless glyph_id

  outline_for_id(glyph_id)
end

#outline_for_id(glyph_id) ⇒ Models::GlyphOutline?

Get outline for glyph by ID

Extracts the complete outline data for a glyph, including all contours, points, and bounding box information. The outline can be converted to SVG paths or drawing commands for rendering.

This method uses [‘OutlineExtractor`](lib/fontisan/outline_extractor.rb) to handle both TrueType (glyf) and CFF outline formats transparently. For compound glyphs, it recursively resolves component dependencies.

Examples:

Get outline for a glyph

outline = accessor.outline_for_id(65)  # 'A'
if outline
  puts "Contours: #{outline.contour_count}"
  puts "Points: #{outline.point_count}"
  puts "SVG: #{outline.to_svg_path}"
end

Parameters:

  • glyph_id (Integer)

    Glyph ID (0-based, 0 is .notdef)

Returns:

Raises:



219
220
221
222
# File 'lib/fontisan/glyph_accessor.rb', line 219

def outline_for_id(glyph_id)
  extractor = OutlineExtractor.new(@font)
  extractor.extract(glyph_id)
end

#truetype?Boolean

Check if font uses TrueType outlines (glyf table)

Returns:

  • (Boolean)

    True if font has glyf table



295
296
297
# File 'lib/fontisan/glyph_accessor.rb', line 295

def truetype?
  font.table("glyf") != nil
end