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



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

def global_subr_index
  @global_subr_index
end

#headerCff::Header (readonly)

Returns CFF header structure.

Returns:



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

def header
  @header
end

#name_indexCff::Index (readonly)

Returns Name INDEX containing font names.

Returns:

  • (Cff::Index)

    Name INDEX containing font names



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

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



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

def raw_data
  @raw_data
end

#string_indexCff::Index (readonly)

Returns String INDEX containing string data.

Returns:

  • (Cff::Index)

    String INDEX containing string data



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

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



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

def top_dict_index
  @top_dict_index
end

#top_dictsArray<TopDict> (readonly)

Returns Parsed Top DICT objects.

Returns:

  • (Array<TopDict>)

    Parsed Top DICT objects



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

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



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

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



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

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



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

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



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

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:



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

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



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

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



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

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



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

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



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

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



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

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



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

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



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

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:



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

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



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

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



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

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



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

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

#valid?Boolean

Validate the CFF table structure

Returns:

  • (Boolean)

    True if valid



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

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



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

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