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 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



64
65
66
# File 'lib/fontisan/tables/cff.rb', line 64

def global_subr_index
  @global_subr_index
end

#headerCff::Header (readonly)

Returns CFF header structure.

Returns:



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

def header
  @header
end

#name_indexCff::Index (readonly)

Returns Name INDEX containing font names.

Returns:

  • (Cff::Index)

    Name INDEX containing font names



52
53
54
# File 'lib/fontisan/tables/cff.rb', line 52

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



67
68
69
# File 'lib/fontisan/tables/cff.rb', line 67

def raw_data
  @raw_data
end

#string_indexCff::Index (readonly)

Returns String INDEX containing string data.

Returns:

  • (Cff::Index)

    String INDEX containing string data



61
62
63
# File 'lib/fontisan/tables/cff.rb', line 61

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



55
56
57
# File 'lib/fontisan/tables/cff.rb', line 55

def top_dict_index
  @top_dict_index
end

#top_dictsArray<TopDict> (readonly)

Returns Parsed Top DICT objects.

Returns:

  • (Array<TopDict>)

    Parsed Top DICT objects



58
59
60
# File 'lib/fontisan/tables/cff.rb', line 58

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



73
74
75
76
77
78
79
80
# File 'lib/fontisan/tables/cff.rb', line 73

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



189
190
191
# File 'lib/fontisan/tables/cff.rb', line 189

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



196
197
198
# File 'lib/fontisan/tables/cff.rb', line 196

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



345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
# File 'lib/fontisan/tables/cff.rb', line 345

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
  warn "Failed to get CharString for glyph #{glyph_index}: #{e.message}"
  nil
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:



314
315
316
317
318
319
320
321
322
323
324
325
326
327
# File 'lib/fontisan/tables/cff.rb', line 314

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
  warn "Failed to parse CharStrings INDEX: #{e.message}"
  nil
end

#custom_string_countInteger

Get count of custom strings (beyond standard strings)

Returns:

  • (Integer)

    Number of custom strings



230
231
232
# File 'lib/fontisan/tables/cff.rb', line 230

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



163
164
165
# File 'lib/fontisan/tables/cff.rb', line 163

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



171
172
173
174
175
176
177
# File 'lib/fontisan/tables/cff.rb', line 171

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



182
183
184
# File 'lib/fontisan/tables/cff.rb', line 182

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



237
238
239
# File 'lib/fontisan/tables/cff.rb', line 237

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



369
370
371
372
# File 'lib/fontisan/tables/cff.rb', line 369

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



281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
# File 'lib/fontisan/tables/cff.rb', line 281

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
  warn "Failed to parse Local Subr INDEX: #{e.message}"
  nil
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:



92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
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
# File 'lib/fontisan/tables/cff.rb', line 92

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



255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
# File 'lib/fontisan/tables/cff.rb', line 255

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
  warn "Failed to parse Private DICT: #{e.message}"
  nil
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



214
215
216
217
218
219
220
221
222
223
224
225
# File 'lib/fontisan/tables/cff.rb', line 214

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



245
246
247
# File 'lib/fontisan/tables/cff.rb', line 245

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

#valid?Boolean

Validate the CFF table structure

Returns:

  • (Boolean)

    True if valid



377
378
379
380
381
382
383
384
385
386
# File 'lib/fontisan/tables/cff.rb', line 377

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



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

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