Class: Fontisan::Tables::Cff2::BlendOperator

Inherits:
Object
  • Object
show all
Defined in:
lib/fontisan/tables/cff2/blend_operator.rb

Overview

Blend operator handler for CFF2 CharStrings

The blend operator is the key mechanism for applying variations in CFF2. It takes base values and deltas, and applies them based on variation coordinates to produce blended values.

Blend Operator Format:

Stack: base1 Δ1_axis1 ... Δ1_axisN base2 Δ2_axis1 ... Δ2_axisN ... K N

Where:

- base_i = base value for the i-th operand
- Δi_axisj = delta for i-th operand on j-th axis
- K = number of operands to blend (integer)
- N = number of variation axes (integer)

Result:

Produces K blended values on the stack

Blending Formula:

blended_value = base + Σ(delta[i] * scalar[i])

Where scalar is computed from the current design space coordinates for axis i.

Reference: Adobe Technical Note #5177

Examples:

Applying blend with coordinates

blend = BlendOperator.new(num_axes: 2)
data = {
  num_values: 2,
  num_axes: 2,
  blends: [
    { base: 100, deltas: [10, 5] },
    { base: 200, deltas: [20, 10] }
  ]
}
scalars = [0.5, 0.3]  # From coordinate interpolation
result = blend.apply(data, scalars)
# => [105.0, 216.0]  # 100 + (10*0.5 + 5*0.3), 200 + (20*0.5 + 10*0.3)

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(num_axes:) ⇒ BlendOperator

Initialize blend operator handler

Parameters:

  • num_axes (Integer)

    Number of variation axes



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

def initialize(num_axes:)
  @num_axes = num_axes
end

Instance Attribute Details

#num_axesInteger (readonly)

Returns Number of variation axes.

Returns:

  • (Integer)

    Number of variation axes



47
48
49
# File 'lib/fontisan/tables/cff2/blend_operator.rb', line 47

def num_axes
  @num_axes
end

Class Method Details

.operand_count(k, n) ⇒ Integer

Get number of operands required for blend

Parameters:

  • k (Integer)

    Number of values to blend

  • n (Integer)

    Number of axes

Returns:

  • (Integer)

    Total operands required (including K and N)



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

def self.operand_count(k, n)
  k * (n + 1) + 2
end

.sufficient_operands?(stack_size, k, n) ⇒ Boolean

Check if enough operands are available

Parameters:

  • stack_size (Integer)

    Current stack size

  • k (Integer)

    Number of values to blend

  • n (Integer)

    Number of axes

Returns:

  • (Boolean)

    True if enough operands



234
235
236
# File 'lib/fontisan/tables/cff2/blend_operator.rb', line 234

def self.sufficient_operands?(stack_size, k, n)
  stack_size >= operand_count(k, n)
end

Instance Method Details

#apply(blend_data, scalars) ⇒ Array<Float>

Apply blend operation with variation scalars

Computes blended values from base values and deltas using the provided scalars (one per axis).

Parameters:

  • blend_data (Hash)

    Parsed blend data from parse()

  • scalars (Array<Float>)

    Variation scalars (one per axis)

Returns:

  • (Array<Float>)

    Blended values



113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
# File 'lib/fontisan/tables/cff2/blend_operator.rb', line 113

def apply(blend_data, scalars)
  return [] if blend_data.nil?

  # Ensure we have scalars for all axes
  scalars = Array(scalars)
  if scalars.size < blend_data[:num_axes]
    # Pad with zeros if not enough scalars
    scalars = scalars + ([0.0] * (blend_data[:num_axes] - scalars.size))
  end

  # Apply blend to each value
  blend_data[:blends].map do |blend|
    apply_single_blend(blend, scalars)
  end
end

#apply_single_blend(blend, scalars) ⇒ Float

Apply blend to a single value

Parameters:

  • blend (Hash)

    Single blend entry with :base and :deltas

  • scalars (Array<Float>)

    Variation scalars

Returns:

  • (Float)

    Blended value



134
135
136
137
138
139
140
141
142
143
144
145
146
# File 'lib/fontisan/tables/cff2/blend_operator.rb', line 134

def apply_single_blend(blend, scalars)
  base = blend[:base].to_f
  deltas = blend[:deltas]

  # Apply formula: result = base + Σ(delta[i] * scalar[i])
  result = base
  deltas.each_with_index do |delta, axis_index|
    scalar = scalars[axis_index] || 0.0
    result += delta.to_f * scalar
  end

  result
end

#calculate_scalars(coordinates, axes) ⇒ Array<Float>

Calculate variation scalars from coordinates

This converts normalized coordinates [-1, 1] to scalars for each axis. For now, this is a simple pass-through, but will integrate with the interpolator in Phase B.

Parameters:

  • coordinates (Hash<String, Float>)

    Axis coordinates

  • axes (Array<VariationAxisRecord>)

    Variation axes from fvar

Returns:

  • (Array<Float>)

    Scalars for each axis



157
158
159
160
161
162
163
164
# File 'lib/fontisan/tables/cff2/blend_operator.rb', line 157

def calculate_scalars(coordinates, axes)
  return [] if axes.nil? || axes.empty?

  axes.map do |axis|
    coord = coordinates[axis.axis_tag] || axis.default_value
    normalize_coordinate(coord, axis)
  end
end

#normalize_coordinate(value, axis) ⇒ Float

Normalize a coordinate value to [-1, 1] range

Parameters:

Returns:

  • (Float)

    Normalized coordinate in [-1, 1]



171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
# File 'lib/fontisan/tables/cff2/blend_operator.rb', line 171

def normalize_coordinate(value, axis)
  # Clamp to axis range
  value = [[value, axis.min_value].max, axis.max_value].min

  # Normalize to [-1, 1]
  if value < axis.default_value
    # Normalize between min and default (maps to -1..0)
    range = axis.default_value - axis.min_value
    return -1.0 if range.zero?

    (value - axis.default_value) / range
  elsif value > axis.default_value
    # Normalize between default and max (maps to 0..1)
    range = axis.max_value - axis.default_value
    return 1.0 if range.zero?

    (value - axis.default_value) / range
  else
    # At default value
    0.0
  end
end

#parse(operands) ⇒ Hash?

Parse blend operands from stack

Extracts blend data from a flattened array of operands.

Parameters:

  • operands (Array<Numeric>)

    Stack operands (including K and N)

Returns:

  • (Hash, nil)

    Parsed blend data or nil if invalid



62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
# File 'lib/fontisan/tables/cff2/blend_operator.rb', line 62

def parse(operands)
  return nil if operands.size < 2

  # Last two values are K and N
  n = operands[-1].to_i
  k = operands[-2].to_i

  # Validate number of axes matches
  if n != @num_axes
    warn "Blend operator axes mismatch: expected #{@num_axes}, got #{n}"
    return nil
  end

  # Validate we have enough operands: K * (N + 1) + 2
  required_total = k * (n + 1) + 2
  if operands.size < required_total
    warn "Blend requires #{required_total} operands, got #{operands.size}"
    return nil
  end

  # Extract blend operands (everything except K and N)
  blend_operands = operands[-required_total..-3]

  # Parse into base + deltas structure
  blends = []
  k.times do |i|
    offset = i * (n + 1)
    base = blend_operands[offset]
    deltas = blend_operands[offset + 1, n] || []

    blends << {
      base: base,
      deltas: deltas,
    }
  end

  {
    num_values: k,
    num_axes: n,
    blends: blends,
  }
end

#valid?(blend_data) ⇒ Boolean

Validate blend data structure

Parameters:

  • blend_data (Hash)

    Blend data to validate

Returns:

  • (Boolean)

    True if valid



198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
# File 'lib/fontisan/tables/cff2/blend_operator.rb', line 198

def valid?(blend_data)
  return false if blend_data.nil?
  return false unless blend_data.is_a?(Hash)
  return false unless blend_data.key?(:num_values)
  return false unless blend_data.key?(:num_axes)
  return false unless blend_data.key?(:blends)
  return false unless blend_data[:num_values].is_a?(Integer)
  return false unless blend_data[:num_axes].is_a?(Integer)
  return false unless blend_data[:blends].is_a?(Array)
  return false if blend_data[:blends].size != blend_data[:num_values]

  # Validate each blend entry
  blend_data[:blends].all? do |blend|
    blend.is_a?(Hash) &&
      blend.key?(:base) &&
      blend.key?(:deltas) &&
      blend[:deltas].is_a?(Array) &&
      blend[:deltas].size == blend_data[:num_axes]
  end
end