Class: Fontisan::Tables::Cff

Inherits:
Binary::BaseRecord show all
Defined in:
lib/fontisan/tables/cff.rb,
lib/fontisan/tables/cff/dict.rb,
lib/fontisan/tables/cff/index.rb,
lib/fontisan/tables/cff/header.rb,
lib/fontisan/tables/cff/charset.rb,
lib/fontisan/tables/cff/encoding.rb,
lib/fontisan/tables/cff/top_dict.rb,
lib/fontisan/tables/cff/cff_glyph.rb,
lib/fontisan/tables/cff/charstring.rb,
lib/fontisan/tables/cff/dict_builder.rb,
lib/fontisan/tables/cff/private_dict.rb,
lib/fontisan/tables/cff/index_builder.rb,
lib/fontisan/tables/cff/table_builder.rb,
lib/fontisan/tables/cff/charstring_parser.rb,
lib/fontisan/tables/cff/charstrings_index.rb,
lib/fontisan/tables/cff/charstring_builder.rb,
lib/fontisan/tables/cff/offset_recalculator.rb,
lib/fontisan/tables/cff/private_dict_writer.rb,
lib/fontisan/tables/cff/charstring_rebuilder.rb,
lib/fontisan/tables/cff/hint_operation_injector.rb

Overview

CFF (Compact Font Format) table parser

The CFF table contains PostScript-based glyph outline data for OpenType fonts with CFF outlines (as opposed to TrueType glyf/loca outlines). CFF is identified by the 'OTTO' signature in the font's sfnt version.

CFF Table Structure:

CFF Table = Header
          + Name INDEX
          + Top DICT INDEX
          + String INDEX
          + Global Subr INDEX
          + [Encodings]
          + [Charsets]
          + [FDSelect]
          + [CharStrings INDEX]
          + [Font DICT INDEX]
          + [Private DICT]
          + [Local Subr INDEX]

This implementation focuses on the foundational structures (Header and INDEX) which are used throughout CFF. Additional structures like DICT, CharStrings, Charset, and Encoding require separate implementations.

Reference: Adobe CFF specification https://adobe-type-tools.github.io/font-tech-notes/pdfs/5176.CFF.pdf

Reference: docs/ttfunk-feature-analysis.md lines 2607-2648

Examples:

Reading a CFF table

data = font.table_data['CFF ']
cff = Fontisan::Tables::Cff.read(data)
puts cff.font_count  # => 1
puts cff.header.version  # => "1.0"

Defined Under Namespace

Classes: CFFGlyph, CharString, CharStringBuilder, CharStringParser, CharStringRebuilder, Charset, CharstringsIndex, Dict, DictBuilder, Encoding, Header, HintOperationInjector, Index, IndexBuilder, OffsetRecalculator, PrivateDict, PrivateDictWriter, TableBuilder, TopDict

Constant Summary collapse

TAG =

OpenType table tag for CFF

"CFF "

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Instance Attribute Details

#global_subr_indexCff::Index (readonly)

Returns Global Subr INDEX containing global subroutines.

Returns:

  • (Cff::Index)

    Global Subr INDEX containing global subroutines



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

def global_subr_index
  @global_subr_index
end

#headerCff::Header (readonly)

Returns CFF header structure.

Returns:



71
72
73
# File 'lib/fontisan/tables/cff.rb', line 71

def header
  @header
end

#name_indexCff::Index (readonly)

Returns Name INDEX containing font names.

Returns:

  • (Cff::Index)

    Name INDEX containing font names



74
75
76
# File 'lib/fontisan/tables/cff.rb', line 74

def name_index
  @name_index
end

#raw_dataString (readonly)

Returns Raw binary data for the entire CFF table.

Returns:

  • (String)

    Raw binary data for the entire CFF table



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

def raw_data
  @raw_data
end

#string_indexCff::Index (readonly)

Returns String INDEX containing string data.

Returns:

  • (Cff::Index)

    String INDEX containing string data



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

def string_index
  @string_index
end

#top_dict_indexCff::Index (readonly)

Returns Top DICT INDEX containing font-level data.

Returns:

  • (Cff::Index)

    Top DICT INDEX containing font-level data



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

def top_dict_index
  @top_dict_index
end

#top_dictsArray<TopDict> (readonly)

Returns Parsed Top DICT objects.

Returns:

  • (Array<TopDict>)

    Parsed Top DICT objects



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

def top_dicts
  @top_dicts
end

Class Method Details

.read(io) ⇒ Cff

Override read to parse CFF structure

Parameters:

  • io (IO, String)

    Binary data to read

Returns:

  • (Cff)

    Parsed CFF table



95
96
97
98
99
100
101
102
# File 'lib/fontisan/tables/cff.rb', line 95

def self.read(io)
  cff = new
  return cff if io.nil?

  data = io.is_a?(String) ? io : io.read
  cff.parse!(data)
  cff
end

Instance Method Details

#cff2?Boolean

Check if this is a CFF2 table (variable CFF)

Returns:

  • (Boolean)

    True if CFF version 2



211
212
213
# File 'lib/fontisan/tables/cff.rb', line 211

def cff2?
  @header&.cff2? || false
end

#cff?Boolean

Check if this is a standard CFF table (non-variable)

Returns:

  • (Boolean)

    True if CFF version 1



218
219
220
# File 'lib/fontisan/tables/cff.rb', line 218

def cff?
  @header&.cff? || false
end

#charstring_for_glyph(glyph_index, font_index = 0) ⇒ CharString?

Get a CharString for a specific glyph

This returns an interpreted CharString object with the glyph's outline data

Examples:

Getting a glyph's CharString

cff = Fontisan::Tables::Cff.read(data)
charstring = cff.charstring_for_glyph(42)
puts charstring.width
puts charstring.bounding_box
charstring.to_commands.each { |cmd| puts cmd.inspect }

Parameters:

  • glyph_index (Integer)

    Glyph index (0-based, 0 is typically .notdef)

  • font_index (Integer) (defaults to: 0)

    Font index in CFF (default 0)

Returns:

  • (CharString, nil)

    Interpreted CharString, or nil if not found



366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
# File 'lib/fontisan/tables/cff.rb', line 366

def charstring_for_glyph(glyph_index, font_index = 0)
  charstrings = charstrings_index(font_index)
  return nil unless charstrings

  priv_dict = private_dict(font_index)
  return nil unless priv_dict

  local_subr_index = local_subrs(font_index)

  charstrings.charstring_at(
    glyph_index,
    priv_dict,
    @global_subr_index,
    local_subr_index,
  )
rescue StandardError => e
  raise CorruptedTableError,
        "Failed to get CharString for glyph #{glyph_index}: #{e.message}"
end

#charstrings_index(index = 0) ⇒ CharstringsIndex?

Get the CharStrings INDEX for a specific font

The CharStrings INDEX contains glyph outline programs

Parameters:

  • index (Integer) (defaults to: 0)

    Font index (0-based)

Returns:



335
336
337
338
339
340
341
342
343
344
345
346
347
348
# File 'lib/fontisan/tables/cff.rb', line 335

def charstrings_index(index = 0)
  top = top_dict(index)
  return nil unless top

  charstrings_offset = top.charstrings
  return nil unless charstrings_offset

  io = StringIO.new(@raw_data)
  io.seek(charstrings_offset)
  CharstringsIndex.new(io, start_offset: charstrings_offset)
rescue StandardError => e
  raise CorruptedTableError,
        "Failed to parse CharStrings INDEX: #{e.message}"
end

#custom_string_countInteger

Get count of custom strings (beyond standard strings)

Returns:

  • (Integer)

    Number of custom strings



252
253
254
# File 'lib/fontisan/tables/cff.rb', line 252

def custom_string_count
  @string_index&.count || 0
end

#font_countInteger

Get the number of fonts in this CFF table

Typically 1 for most OpenType fonts, but CFF supports multiple fonts

Returns:

  • (Integer)

    Number of fonts



185
186
187
# File 'lib/fontisan/tables/cff.rb', line 185

def font_count
  @name_index&.count || 0
end

#font_name(index = 0) ⇒ String?

Get the PostScript name of a font by index

Parameters:

  • index (Integer) (defaults to: 0)

    Font index (0-based)

Returns:

  • (String, nil)

    PostScript font name, or nil if invalid index



193
194
195
196
197
198
199
# File 'lib/fontisan/tables/cff.rb', line 193

def font_name(index = 0)
  name_data = @name_index[index]
  return nil unless name_data

  # Font names in Name INDEX are ASCII strings
  name_data.force_encoding("ASCII-8BIT")
end

#font_namesArray<String>

Get all font names in this CFF

Returns:

  • (Array<String>)

    Array of PostScript font names



204
205
206
# File 'lib/fontisan/tables/cff.rb', line 204

def font_names
  @name_index.to_a.map { |name| name.force_encoding("ASCII-8BIT") }
end

#global_subr_countInteger

Get count of global subroutines

Returns:

  • (Integer)

    Number of global subroutines



259
260
261
# File 'lib/fontisan/tables/cff.rb', line 259

def global_subr_count
  @global_subr_index&.count || 0
end

#glyph_count(index = 0) ⇒ Integer

Get the number of glyphs in a font

Parameters:

  • index (Integer) (defaults to: 0)

    Font index (0-based)

Returns:

  • (Integer)

    Number of glyphs, or 0 if CharStrings not available



390
391
392
393
# File 'lib/fontisan/tables/cff.rb', line 390

def glyph_count(index = 0)
  charstrings = charstrings_index(index)
  charstrings&.glyph_count || 0
end

#local_subrs(index = 0) ⇒ Index?

Get the Local Subr INDEX for a specific font

Local subroutines are stored in the Private DICT area

Parameters:

  • index (Integer) (defaults to: 0)

    Font index (0-based)

Returns:

  • (Index, nil)

    Local Subr INDEX, or nil if not present



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
# File 'lib/fontisan/tables/cff.rb', line 302

def local_subrs(index = 0)
  priv_dict = private_dict(index)
  return nil unless priv_dict

  subrs_offset = priv_dict.subrs
  return nil unless subrs_offset

  top = top_dict(index)
  return nil unless top

  private_info = top.private
  return nil unless private_info

  _size, private_offset = private_info

  # Local Subr offset is relative to Private DICT start
  absolute_offset = private_offset + subrs_offset

  io = StringIO.new(@raw_data)
  io.seek(absolute_offset)
  Index.new(io, start_offset: absolute_offset)
rescue StandardError => e
  raise CorruptedTableError,
        "Failed to parse Local Subr INDEX: #{e.message}"
end

#parse!(data) ⇒ Object

Parse the CFF table structure

This parses the foundational CFF structures: Header, Name INDEX, Top DICT INDEX, String INDEX, and Global Subr INDEX.

Additional structures (CharStrings, Charset, Encoding, Private DICT) will be implemented in follow-up tasks.

Parameters:

  • data (String)

    Binary data for the CFF table

Raises:



114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
# File 'lib/fontisan/tables/cff.rb', line 114

def parse!(data)
  @raw_data = data
  io = StringIO.new(data)

  # Parse CFF Header (4 bytes minimum)
  @header = Cff::Header.read(io)
  @header.validate!

  # Skip any additional header bytes beyond the standard 4
  # (hdr_size can be larger for extensions)
  if @header.hdr_size > 4
    io.seek(@header.hdr_size)
  end

  # Parse Name INDEX
  # Contains PostScript names of fonts in this CFF
  # Typically just one name for single-font CFF
  name_start = io.pos
  @name_index = Cff::Index.new(io, start_offset: name_start)

  # Validate that we have at least one font
  if @name_index.count.zero?
    raise CorruptedTableError, "CFF table must contain at least one font"
  end

  # Parse Top DICT INDEX
  # Contains font-level DICTs with metadata and pointers
  # Count should match name_index count (one DICT per font)
  top_dict_start = io.pos
  @top_dict_index = Cff::Index.new(io, start_offset: top_dict_start)

  # Validate Top DICT count matches Name count
  unless @top_dict_index.count == @name_index.count
    raise CorruptedTableError,
          "Top DICT count (#{@top_dict_index.count}) " \
          "must match Name count (#{@name_index.count})"
  end

  # Parse String INDEX
  # Contains additional string data beyond standard strings
  # Standard strings (SIDs 0-390) are built-in
  string_start = io.pos
  @string_index = Cff::Index.new(io, start_offset: string_start)

  # Parse Global Subr INDEX
  # Contains subroutines used across all fonts in CFF
  # Can be empty (count = 0)
  global_subr_start = io.pos
  @global_subr_index = Cff::Index.new(io, start_offset: global_subr_start)

  # Parse Top DICTs
  @top_dicts = []
  @top_dict_index.each do |dict_data|
    @top_dicts << TopDict.new(dict_data)
  end

  # Additional parsing will be added in follow-up tasks:
  # - Charset parsing
  # - Encoding parsing
  # - CharStrings parsing
  # - FDSelect parsing (for CIDFonts)
  # - Private DICT parsing (requires Top DICT offsets)
rescue StandardError => e
  raise CorruptedTableError, "Failed to parse CFF table: #{e.message}"
end

#private_dict(index = 0) ⇒ PrivateDict?

Parse the Private DICT for a specific font

The Private DICT location is specified in the Top DICT

Parameters:

  • index (Integer) (defaults to: 0)

    Font index (0-based)

Returns:

  • (PrivateDict, nil)

    Private DICT object, or nil if not present



277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
# File 'lib/fontisan/tables/cff.rb', line 277

def private_dict(index = 0)
  top = top_dict(index)
  return nil unless top

  private_info = top.private
  return nil unless private_info

  size, offset = private_info
  return nil if size <= 0 || offset.negative?

  # Extract Private DICT data from raw CFF data
  private_data = @raw_data[offset, size]
  return nil unless private_data

  PrivateDict.new(private_data)
rescue StandardError => e
  raise CorruptedTableError, "Failed to parse Private DICT: #{e.message}"
end

#string_for_sid(sid) ⇒ String?

Get a string by String ID (SID)

CFF has 391 predefined standard strings (SIDs 0-390). Additional strings are stored in the String INDEX.

Parameters:

  • sid (Integer)

    String ID

Returns:

  • (String, nil)

    String data, or nil if invalid SID



236
237
238
239
240
241
242
243
244
245
246
247
# File 'lib/fontisan/tables/cff.rb', line 236

def string_for_sid(sid)
  # Standard strings (SIDs 0-390) are predefined
  # See CFF spec Appendix A for the complete list
  if sid <= 390
    standard_string(sid)
  else
    # Custom strings start at SID 391
    string_index_offset = sid - 391
    string_data = @string_index[string_index_offset]
    string_data&.force_encoding("ASCII-8BIT")
  end
end

#top_dict(index = 0) ⇒ TopDict?

Get the Top DICT for a specific font

Parameters:

  • index (Integer) (defaults to: 0)

    Font index (0-based)

Returns:

  • (TopDict, nil)

    Top DICT object, or nil if invalid index



267
268
269
# File 'lib/fontisan/tables/cff.rb', line 267

def top_dict(index = 0)
  @top_dicts&.[](index)
end

#valid?Boolean

Validate the CFF table structure

Returns:

  • (Boolean)

    True if valid



398
399
400
401
402
403
404
405
406
407
# File 'lib/fontisan/tables/cff.rb', line 398

def valid?
  return false unless @header&.valid?
  return false unless @name_index&.count&.positive?
  return false unless @top_dict_index
  return false unless @top_dict_index.count == @name_index.count
  return false unless @string_index
  return false unless @global_subr_index

  true
end

#versionString

Get the CFF version string

Returns:

  • (String)

    Version in "major.minor" format



225
226
227
# File 'lib/fontisan/tables/cff.rb', line 225

def version
  @header&.version || "unknown"
end