Class: Fontisan::Tables::Cff2::TableBuilder

Inherits:
Tables::Cff::TableBuilder
  • Object
show all
Defined in:
lib/fontisan/tables/cff2/table_builder.rb

Overview

Rebuilds CFF2 table with modifications while preserving variation data

CFF2TableBuilder extends CFF TableBuilder to handle CFF2-specific structures including Variable Store and blend operators in CharStrings. It preserves variation data while applying hints to variable fonts.

Key Principles:

  • Variable Store is read-only and preserved unchanged
  • Blend operators in CharStrings are maintained
  • Blend in Private DICT is preserved
  • Reuses Phase 1+2 infrastructure for CharString modification

Reference: Adobe Technical Note #5177 (CFF2)

Examples:

Rebuild CFF2 with hints

reader = CFF2TableReader.new(cff2_data)
builder = CFF2TableBuilder.new(reader, hint_set)
new_cff2 = builder.build

Constant Summary collapse

INVALID_CFF_KEYS =

CFF2-specific operators not supported by CFF DictBuilder

[24].freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(reader, hint_set = nil) ⇒ TableBuilder

Initialize builder with CFF2 table reader and hint set

Parameters:

  • reader (CFF2TableReader)

    CFF2 table reader

  • hint_set (Object) (defaults to: nil)

    Hint set with font-level and per-glyph hints



43
44
45
46
47
48
49
50
51
52
53
54
55
56
# File 'lib/fontisan/tables/cff2/table_builder.rb', line 43

def initialize(reader, hint_set = nil)
  @reader = reader
  @hint_set = hint_set

  # Read CFF2 structures
  @reader.read_header
  @reader.read_top_dict
  @variable_store = @reader.read_variable_store

  # Determine number of axes from Variable Store
  @num_axes = extract_num_axes

  # Don't call super - CFF2 has different structure
end

Instance Attribute Details

#num_axesInteger (readonly)

Returns Number of variation axes.

Returns:

  • (Integer)

    Number of variation axes



37
38
39
# File 'lib/fontisan/tables/cff2/table_builder.rb', line 37

def num_axes
  @num_axes
end

#readerCFF2TableReader (readonly)

Returns CFF2 table reader.

Returns:

  • (CFF2TableReader)

    CFF2 table reader



31
32
33
# File 'lib/fontisan/tables/cff2/table_builder.rb', line 31

def reader
  @reader
end

#variable_storeHash? (readonly)

Returns Variable Store data.

Returns:

  • (Hash, nil)

    Variable Store data



34
35
36
# File 'lib/fontisan/tables/cff2/table_builder.rb', line 34

def variable_store
  @variable_store
end

Instance Method Details

#buildString

Build CFF2 table with hints applied

Returns:

  • (String)

    Binary CFF2 table data



61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
# File 'lib/fontisan/tables/cff2/table_builder.rb', line 61

def build
  # Check if we need to modify anything
  return @reader.data unless should_modify?

  # Extract and modify sections
  header_data = extract_header
  top_dict_hash = @reader.top_dict
  charstrings_data = extract_and_modify_charstrings
  private_dict_data = extract_and_modify_private_dict
  vstore_data = extract_variable_store

  # Rebuild CFF2 table
  rebuild_cff2_table(
    header: header_data,
    top_dict: top_dict_hash,
    charstrings: charstrings_data,
    private_dict: private_dict_data,
    vstore: vstore_data,
  )
end

#calculate_cff2_offsets(header_size:, charstrings:, private_dict:, vstore:) ⇒ Hash

Calculate offsets for CFF2 sections

Parameters:

  • header_size (Integer)

    Header size

  • charstrings (String)

    CharStrings data

  • private_dict (String, nil)

    Private DICT data

  • vstore (String, nil)

    Variable Store data

Returns:

  • (Hash)

    Section offsets



415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
# File 'lib/fontisan/tables/cff2/table_builder.rb', line 415

def calculate_cff2_offsets(header_size:, charstrings:, private_dict:,
vstore:)
  # Start after header
  offset = header_size

  # Top DICT offset (immediately after header)
  top_dict_offset = offset

  # Estimate Top DICT size (will be recalculated)
  # For now, use original Top DICT size from reader
  top_dict_size = estimate_top_dict_size

  offset += top_dict_size

  # CharStrings offset
  charstrings_offset = offset
  offset += charstrings&.size || 0

  # Private DICT offset
  private_dict_offset = offset
  private_dict_size = private_dict&.size || 0
  offset += private_dict_size

  # Variable Store offset
  vstore_offset = vstore ? offset : nil

  {
    top_dict: top_dict_offset,
    charstrings: charstrings_offset,
    private_dict: private_dict_offset,
    private_dict_size: private_dict_size,
    vstore: vstore_offset,
  }
end

#calculate_stem_countInteger

Calculate stem count from font-level hints

Stem count is needed for hintmask/cntrmask parsing. Extracted from blue values and stem snap arrays.

Returns:

  • (Integer)

    Total stem count (hstem + vstem)



162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
# File 'lib/fontisan/tables/cff2/table_builder.rb', line 162

def calculate_stem_count
  return 0 unless @hint_set
  return 0 unless @hint_set.is_a?(Models::HintSet)

  begin
    font_hints = JSON.parse(@hint_set.private_dict_hints || "{}")
  rescue JSON::ParserError
    return 0
  end

  return 0 if font_hints.nil? || font_hints.empty?

  # Count stems from blue zones (hstem)
  hstem_count = 0
  blue_values = font_hints["blue_values"] || font_hints[:blue_values]
  if blue_values.is_a?(Array)
    hstem_count = blue_values.size / 2
  end

  # Count stems from stem snap (vstem)
  vstem_count = 0
  stem_snap_h = font_hints["stem_snap_h"] || font_hints[:stem_snap_h]
  if stem_snap_h.is_a?(Array)
    vstem_count = stem_snap_h.size
  end

  hstem_count + vstem_count
end

#convert_operators_to_symbols(dict) ⇒ Hash

Convert integer operator keys to symbol keys

Parameters:

  • dict (Hash)

    Dictionary with integer or string keys

Returns:

  • (Hash)

    Dictionary with symbol keys



506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
# File 'lib/fontisan/tables/cff2/table_builder.rb', line 506

def convert_operators_to_symbols(dict)
  # Operator mapping: integer => symbol
  operator_map = {
    0 => :version,
    1 => :notice,
    2 => :full_name,
    3 => :family_name,
    4 => :weight,
    5 => :font_bbox,
    6 => :blue_values,
    7 => :other_blues,
    8 => :family_blues,
    9 => :family_other_blues,
    10 => :std_hw,
    11 => :std_vw,
    15 => :charset,
    16 => :encoding,
    17 => :charstrings,
    18 => :private,
    19 => :subrs,
    20 => :default_width_x,
    21 => :nominal_width_x,
    # Note: operator 24 (vstore) is CFF2-specific and handled separately
  }

  result = {}
  dict.each do |key, value|
    # Skip vstore (operator 24) - CFF2 specific, not in CFF DictBuilder
    next if INVALID_CFF_KEYS.include?(key)

    # Convert string keys to symbols for DictBuilder
    symbol_key = if key.is_a?(String)
                   key.to_sym
                 elsif key.is_a?(Integer)
                   operator_map[key] || key
                 else
                   key
                 end

    result[symbol_key] = value
  end
  result
end

#estimate_top_dict_sizeInteger

Estimate Top DICT size

Returns:

  • (Integer)

    Estimated size



453
454
455
456
457
458
# File 'lib/fontisan/tables/cff2/table_builder.rb', line 453

def estimate_top_dict_size
  # Use original Top DICT size from reader as estimate
  # In CFF2, Top DICT size is in header
  top_dict_length = @reader.header[:top_dict_length]
  top_dict_length || 50 # Default estimate
end

#extract_and_modify_charstringsString

Extract and optionally modify CharStrings

Returns:

  • (String)

    CharStrings INDEX binary data



272
273
274
275
276
277
278
279
280
281
282
283
284
# File 'lib/fontisan/tables/cff2/table_builder.rb', line 272

def extract_and_modify_charstrings
  charstrings_offset = extract_charstrings_offset
  return nil unless charstrings_offset

  charstrings_index = @reader.read_charstrings(charstrings_offset)

  if @hint_set && !@hint_set.hinted_glyph_ids.empty?
    modify_charstrings(charstrings_index)
  else
    # Return original CharStrings as binary
    extract_charstrings_binary(charstrings_offset)
  end
end

#extract_and_modify_private_dictString?

Extract and optionally modify Private DICT

Returns:

  • (String, nil)

    Binary Private DICT data



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

def extract_and_modify_private_dict
  if @hint_set && has_font_level_hints?
    # Modify and serialize
    modified_dict = modify_private_dict
    return nil unless modified_dict

    serialize_private_dict(modified_dict)
  else
    # Return original Private DICT
    private_dict_info = extract_private_dict_info
    return nil unless private_dict_info

    size, offset = private_dict_info
    @reader.data[offset, size]
  end
end

#extract_charstrings_binary(offset) ⇒ String

Extract CharStrings INDEX as binary

Parameters:

  • offset (Integer)

    CharStrings offset in table

Returns:

  • (String)

    Binary CharStrings INDEX data



290
291
292
293
294
295
296
297
298
299
300
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
326
327
328
329
# File 'lib/fontisan/tables/cff2/table_builder.rb', line 290

def extract_charstrings_binary(offset)
  io = StringIO.new(@reader.data)
  io.seek(offset)

  # Read INDEX structure: count (2 bytes)
  count = io.read(2).unpack1("n")
  return [0].pack("n") if count.zero?

  # Read offSize (1 byte)
  off_size = io.read(1).unpack1("C")

  # Calculate INDEX size
  # count + offSize + (count+1)*offSize + data_size
  offset_array_size = (count + 1) * off_size

  # Read offset array to get data size
  offsets = []
  (count + 1).times do
    offset_bytes = io.read(off_size)
    case off_size
    when 1
      offsets << offset_bytes.unpack1("C")
    when 2
      offsets << offset_bytes.unpack1("n")
    when 3
      offsets << (offset_bytes.bytes[0] << 16 | offset_bytes.bytes[1] << 8 | offset_bytes.bytes[2])
    when 4
      offsets << offset_bytes.unpack1("N")
    end
  end

  data_size = offsets.last - 1 # Offsets are 1-based

  # Calculate total INDEX size
  index_size = 2 + 1 + offset_array_size + data_size

  # Reset and extract full INDEX
  io.seek(offset)
  io.read(index_size)
end

#extract_charstrings_offsetInteger

Extract CharStrings offset from Top DICT

CFF2 Top DICT operator 17 contains CharStrings offset.

Returns:

  • (Integer)

    CharStrings offset



109
110
111
112
113
114
115
# File 'lib/fontisan/tables/cff2/table_builder.rb', line 109

def extract_charstrings_offset
  top_dict = @reader.top_dict
  return nil unless top_dict

  # Operator 17 = CharStrings offset
  top_dict[17]
end

#extract_headerString

Extract CFF2 header bytes

Returns:

  • (String)

    Binary header data



264
265
266
267
# File 'lib/fontisan/tables/cff2/table_builder.rb', line 264

def extract_header
  header_size = @reader.header[:header_size]
  @reader.data[0, header_size]
end

#extract_num_axesInteger

Extract number of variation axes from Variable Store

Returns:

  • (Integer)

    Number of axes



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

def extract_num_axes
  return 0 unless @variable_store

  # Get from first region's axis count
  regions = @variable_store[:regions]
  return 0 if regions.nil? || regions.empty?

  regions.first[:axis_count] || 0
end

#extract_private_dict_infoArray<Integer>?

Extract Private DICT information from Top DICT

Returns:

  • (Array<Integer>, nil)

    [size, offset] or nil if not present



230
231
232
233
234
235
236
237
# File 'lib/fontisan/tables/cff2/table_builder.rb', line 230

def extract_private_dict_info
  # Extract from Top DICT (operator 18)
  private_info = @reader.top_dict[18]
  return nil unless private_info

  # Format: [size, offset]
  private_info
end

#extract_variable_storeString?

Extract Variable Store as binary (unchanged)

Returns:

  • (String, nil)

    Binary Variable Store data



354
355
356
357
358
359
360
361
362
363
364
# File 'lib/fontisan/tables/cff2/table_builder.rb', line 354

def extract_variable_store
  return nil unless @variable_store

  vstore_offset = @reader.top_dict[24] # operator 24 = vstore
  return nil unless vstore_offset

  # Extract Variable Store bytes unchanged
  # For simplicity, extract from vstore_offset to end of table
  # In production, we'd parse structure to get exact size
  @reader.data[vstore_offset..]
end

#has_font_level_hints?Boolean

Check if font-level hints are present

Returns:

  • (Boolean)

    True if private_dict_hints are present



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

def has_font_level_hints?
  return false unless @hint_set.is_a?(Models::HintSet)

  hints = JSON.parse(@hint_set.private_dict_hints || "{}")
  !hints.empty?
rescue JSON::ParserError
  false
end

#modify_charstrings(charstrings_index) ⇒ String

Modify CharStrings with per-glyph hints

Uses Phase 1 CharStringRebuilder and Phase 2 HintOperationInjector to inject hints while preserving blend operators.

Parameters:

  • charstrings_index (CharstringsIndex)

    Source CharStrings INDEX

Returns:

  • (String)

    Modified CharStrings INDEX binary data



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
# File 'lib/fontisan/tables/cff2/table_builder.rb', line 124

def modify_charstrings(charstrings_index)
  return nil unless @hint_set

  # Get hinted glyph IDs from HintSet
  hinted_glyph_ids = @hint_set.hinted_glyph_ids
  return nil if hinted_glyph_ids.empty?

  # Create rebuilder with stem count
  stem_count = calculate_stem_count
  rebuilder = Cff::CharStringRebuilder.new(charstrings_index,
                                           stem_count: stem_count)

  # Modify each glyph with hints
  hinted_glyph_ids.each do |glyph_id|
    # Get hints for this glyph
    hints = @hint_set.get_glyph_hints(glyph_id)
    next if hints.nil? || hints.empty?

    # Convert glyph_id to integer if it's a string
    glyph_index = glyph_id.to_i

    rebuilder.modify_charstring(glyph_index) do |operations|
      # Inject hints while preserving blend operators
      injector = Cff::HintOperationInjector.new
      injector.inject(hints, operations)
    end
  end

  # Rebuild CharStrings INDEX
  rebuilder.rebuild
end

#modify_private_dictHash?

Modify Private DICT with font-level hints

Handles variable hint values using PrivateDictBlendHandler while preserving existing blend operators.

Returns:

  • (Hash, nil)

    Modified Private DICT data



209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
# File 'lib/fontisan/tables/cff2/table_builder.rb', line 209

def modify_private_dict
  # Read original Private DICT
  private_dict_info = extract_private_dict_info
  return nil unless private_dict_info

  size, offset = private_dict_info
  private_dict = @reader.read_private_dict(size, offset)

  # Create handler
  handler = PrivateDictBlendHandler.new(private_dict)

  # Get font-level hints
  font_hints = JSON.parse(@hint_set.private_dict_hints)

  # Rebuild with hints (preserving blend)
  handler.rebuild_with_hints(font_hints, num_axes: @num_axes)
end

#preserve_variable_storeHash?

Preserve Variable Store unchanged

Variable Store is read-only for hint application. We simply copy it to output without modification.

Returns:

  • (Hash, nil)

    Variable Store data



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

def preserve_variable_store
  @variable_store
end

#rebuild_cff2_table(header:, top_dict:, charstrings:, private_dict:, vstore:) ⇒ String

Rebuild complete CFF2 table

Parameters:

  • header (String)

    CFF2 header

  • top_dict (Hash)

    Top DICT hash

  • charstrings (String)

    CharStrings INDEX

  • private_dict (String, nil)

    Private DICT

  • vstore (String, nil)

    Variable Store

Returns:

  • (String)

    Complete CFF2 table binary



374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
# File 'lib/fontisan/tables/cff2/table_builder.rb', line 374

def rebuild_cff2_table(header:, top_dict:, charstrings:, private_dict:,
vstore:)
  output = StringIO.new("".b)

  # 1. Write Header
  output.write(header)

  # 2. Calculate offsets for all sections
  offsets = calculate_cff2_offsets(
    header_size: header.size,
    charstrings: charstrings,
    private_dict: private_dict,
    vstore: vstore,
  )

  # 3. Build Top DICT with updated offsets
  updated_top_dict = update_top_dict_offsets(top_dict, offsets)
  top_dict_binary = serialize_top_dict(updated_top_dict)

  # Write Top DICT
  output.write(top_dict_binary)

  # 4. Write CharStrings
  output.write(charstrings) if charstrings

  # 5. Write Private DICT
  output.write(private_dict) if private_dict

  # 6. Write Variable Store (UNCHANGED)
  output.write(vstore) if vstore

  output.string
end

#serialize_private_dict(dict) ⇒ String

Serialize Private DICT to binary

Parameters:

  • dict (Hash)

    Private DICT hash

Returns:

  • (String)

    Binary DICT data



496
497
498
499
500
# File 'lib/fontisan/tables/cff2/table_builder.rb', line 496

def serialize_private_dict(dict)
  # Convert integer operator keys to symbol keys for DictBuilder
  symbol_dict = convert_operators_to_symbols(dict)
  Cff::DictBuilder.build(symbol_dict)
end

#serialize_top_dict(dict) ⇒ String

Serialize Top DICT to binary

Parameters:

  • dict (Hash)

    Top DICT hash with integer operator keys

Returns:

  • (String)

    Binary DICT data



486
487
488
489
490
# File 'lib/fontisan/tables/cff2/table_builder.rb', line 486

def serialize_top_dict(dict)
  # Convert integer operator keys to symbol keys for DictBuilder
  symbol_dict = convert_operators_to_symbols(dict)
  Cff::DictBuilder.build(symbol_dict)
end

#should_modify?Boolean

Check if modification is needed

Returns:

  • (Boolean)

    True if hints should be applied



252
253
254
255
256
257
258
259
# File 'lib/fontisan/tables/cff2/table_builder.rb', line 252

def should_modify?
  return false unless @hint_set

  has_per_glyph = !@hint_set.hinted_glyph_ids.empty?
  has_font_level = has_font_level_hints?

  has_per_glyph || has_font_level
end

#update_top_dict_offsets(top_dict, offsets) ⇒ Hash

Update Top DICT with new offsets

Parameters:

  • top_dict (Hash)

    Original Top DICT

  • offsets (Hash)

    Calculated offsets

Returns:

  • (Hash)

    Updated Top DICT



465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
# File 'lib/fontisan/tables/cff2/table_builder.rb', line 465

def update_top_dict_offsets(top_dict, offsets)
  updated = top_dict.dup

  # Update CharStrings offset (operator 17)
  updated[17] = offsets[:charstrings]

  # Update Private DICT [size, offset] (operator 18)
  if offsets[:private_dict_size]&.positive?
    updated[18] = [offsets[:private_dict_size], offsets[:private_dict]]
  end

  # Update Variable Store offset (operator 24)
  updated[24] = offsets[:vstore] if offsets[:vstore]

  updated
end

#validateArray<String>

Validate CFF2 structure

Returns:

  • (Array<String>)

    Validation errors (empty if valid)



553
554
555
556
557
558
559
560
561
562
563
# File 'lib/fontisan/tables/cff2/table_builder.rb', line 553

def validate
  errors = []

  errors << "Not a valid CFF2 table" unless @reader.header[:major_version] == 2

  if variable? && @num_axes.zero?
    errors << "CFF2 has Variable Store but no axes defined"
  end

  errors
end

#variable?Boolean

Check if table has variation data

Returns:

  • (Boolean)

    True if Variable Store present



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

def variable?
  !@variable_store.nil?
end