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/cff2_charstring_builder.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, Cff2CharStringBuilder, 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



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

def global_subr_index
  @global_subr_index
end

#headerCff::Header (readonly)

Returns CFF header structure.

Returns:



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

def header
  @header
end

#name_indexCff::Index (readonly)

Returns Name INDEX containing font names.

Returns:

  • (Cff::Index)

    Name INDEX containing font names



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

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



91
92
93
# File 'lib/fontisan/tables/cff.rb', line 91

def raw_data
  @raw_data
end

#string_indexCff::Index (readonly)

Returns String INDEX containing string data.

Returns:

  • (Cff::Index)

    String INDEX containing string data



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

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



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

def top_dict_index
  @top_dict_index
end

#top_dictsArray<TopDict> (readonly)

Returns Parsed Top DICT objects.

Returns:

  • (Array<TopDict>)

    Parsed Top DICT objects



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

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



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

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



213
214
215
# File 'lib/fontisan/tables/cff.rb', line 213

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



220
221
222
# File 'lib/fontisan/tables/cff.rb', line 220

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



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

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:



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

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



254
255
256
# File 'lib/fontisan/tables/cff.rb', line 254

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



187
188
189
# File 'lib/fontisan/tables/cff.rb', line 187

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



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

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



206
207
208
# File 'lib/fontisan/tables/cff.rb', line 206

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



261
262
263
# File 'lib/fontisan/tables/cff.rb', line 261

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



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

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



304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
# File 'lib/fontisan/tables/cff.rb', line 304

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:



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

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



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

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



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

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



269
270
271
# File 'lib/fontisan/tables/cff.rb', line 269

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

#valid?Boolean

Validate the CFF table structure

Returns:

  • (Boolean)

    True if valid



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

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



227
228
229
# File 'lib/fontisan/tables/cff.rb', line 227

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