Class: Fontisan::Models::Outline

Inherits:
Object
  • Object
show all
Defined in:
lib/fontisan/models/outline.rb

Overview

Universal outline representation for format-agnostic glyph outlines

[‘Outline`](lib/fontisan/models/outline.rb) provides a format-independent representation of glyph outlines that can be converted to/from both TrueType (quadratic) and CFF (cubic) formats. This enables bidirectional TTF ↔ OTF conversion.

The outline stores paths as a sequence of drawing commands:

  • move_to: Start a new contour at (x, y)

  • line_to: Draw a line to (x, y)

  • quad_to: Quadratic Bézier curve with control point (cx, cy) to (x, y)

  • curve_to: Cubic Bézier curve with control points (cx1, cy1), (cx2, cy2) to (x, y)

  • close_path: Close the current contour

This command-based representation:

  • Is format-agnostic (works for both TrueType and CFF)

  • Preserves curve type information

  • Makes conversion logic clear and testable

  • Enables easy validation and manipulation

Examples:

Creating an outline from commands

outline = Fontisan::Models::Outline.new(
  glyph_id: 65,
  commands: [
    { type: :move_to, x: 100, y: 0 },
    { type: :line_to, x: 200, y: 700 },
    { type: :line_to, x: 300, y: 0 },
    { type: :close_path }
  ],
  bbox: { x_min: 100, y_min: 0, x_max: 300, y_max: 700 },
  width: 400
)

Converting from TrueType

outline = Fontisan::Models::Outline.from_truetype(glyph, glyph_id)

Converting from CFF

outline = Fontisan::Models::Outline.from_cff(charstring, glyph_id)

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(glyph_id:, commands:, bbox:, width: nil) ⇒ Outline

Initialize a new universal outline

Parameters:

  • glyph_id (Integer)

    Glyph identifier

  • commands (Array<Hash>)

    Drawing commands

  • bbox (Hash)

    Bounding box

  • width (Integer, nil) (defaults to: nil)

    Advance width (optional)

Raises:

  • (ArgumentError)

    If parameters are invalid



64
65
66
67
68
69
70
71
# File 'lib/fontisan/models/outline.rb', line 64

def initialize(glyph_id:, commands:, bbox:, width: nil)
  validate_parameters!(glyph_id, commands, bbox)

  @glyph_id = glyph_id
  @commands = commands.freeze
  @bbox = bbox.freeze
  @width = width
end

Instance Attribute Details

#bboxHash (readonly)

Returns Bounding box :y_min, :x_max, :y_max.

Returns:

  • (Hash)

    Bounding box :y_min, :x_max, :y_max



52
53
54
# File 'lib/fontisan/models/outline.rb', line 52

def bbox
  @bbox
end

#commandsArray<Hash> (readonly)

Returns Array of drawing commands Each command is a hash with :type and coordinate keys.

Returns:

  • (Array<Hash>)

    Array of drawing commands Each command is a hash with :type and coordinate keys



49
50
51
# File 'lib/fontisan/models/outline.rb', line 49

def commands
  @commands
end

#glyph_idInteger (readonly)

Returns Glyph identifier.

Returns:

  • (Integer)

    Glyph identifier



45
46
47
# File 'lib/fontisan/models/outline.rb', line 45

def glyph_id
  @glyph_id
end

#widthInteger? (readonly)

Returns Advance width (optional).

Returns:

  • (Integer, nil)

    Advance width (optional)



55
56
57
# File 'lib/fontisan/models/outline.rb', line 55

def width
  @width
end

Class Method Details

.convert_cff_path_to_commands(path) ⇒ Array<Hash>

Convert CFF path to universal commands

CFF doesn’t have explicit closepath operators - contours are implicitly closed when a new moveto starts or at endchar. We add explicit close_path commands only when the contour is geometrically closed (last point equals first point), to preserve open contours from TTF.

Parameters:

  • path (Array<Hash>)

    CFF path data

Returns:

  • (Array<Hash>)

    Universal commands



574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
# File 'lib/fontisan/models/outline.rb', line 574

def self.convert_cff_path_to_commands(path)
  commands = []
  contour_start = nil # Track the start point of current contour

  path.each_with_index do |cmd, _index|
    case cmd[:type]
    when :move_to
      # Before starting new contour, close previous one if it was geometrically closed
      if contour_start && !commands.empty? && commands.last[:type] != :close_path
        # Check if last point equals start point (contour is closed)
        last_cmd = commands.last
        last_point = case last_cmd[:type]
                     when :line_to
                       { x: last_cmd[:x], y: last_cmd[:y] }
                     when :curve_to
                       { x: last_cmd[:x], y: last_cmd[:y] }
                     end

        if last_point &&
            (last_point[:x] - contour_start[:x]).abs <= 1 &&
            (last_point[:y] - contour_start[:y]).abs <= 1
          # Contour is geometrically closed
          commands << { type: :close_path }
        end
      end

      # Start new contour
      contour_start = { x: cmd[:x].round, y: cmd[:y].round }
      commands << {
        type: :move_to,
        x: cmd[:x].round,
        y: cmd[:y].round,
      }
    when :line_to
      commands << {
        type: :line_to,
        x: cmd[:x].round,
        y: cmd[:y].round,
      }
    when :curve_to
      # CFF cubic curve
      commands << {
        type: :curve_to,
        cx1: cmd[:x1].round,
        cy1: cmd[:y1].round,
        cx2: cmd[:x2].round,
        cy2: cmd[:y2].round,
        x: cmd[:x].round,
        y: cmd[:y].round,
      }
    end
  end

  # Close the final contour if it was geometrically closed
  if contour_start && !commands.empty? && commands.last[:type] != :close_path
    last_cmd = commands.last
    last_point = case last_cmd[:type]
                 when :line_to
                   { x: last_cmd[:x], y: last_cmd[:y] }
                 when :curve_to
                   { x: last_cmd[:x], y: last_cmd[:y] }
                 end

    if last_point &&
        (last_point[:x] - contour_start[:x]).abs <= 1 &&
        (last_point[:y] - contour_start[:y]).abs <= 1
      # Contour is geometrically closed
      commands << { type: :close_path }
    end
  end

  commands
end

.convert_truetype_contour_to_commands(points) ⇒ Array<Hash>

Convert TrueType contour points to commands

Parameters:

  • points (Array<Hash>)

    Array of points with :x, :y, :on_curve

Returns:

  • (Array<Hash>)

    Array of commands



498
499
500
501
502
503
504
505
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
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
# File 'lib/fontisan/models/outline.rb', line 498

def self.convert_truetype_contour_to_commands(points)
  return [] if points.empty?

  commands = []
  i = 0

  # Move to first point
  first = points[i]
  commands << { type: :move_to, x: first[:x], y: first[:y] }
  i += 1

  # Process remaining points
  while i < points.length
    point = points[i]

    if point[:on_curve]
      # Line to on-curve point
      commands << { type: :line_to, x: point[:x], y: point[:y] }
      i += 1
    else
      # Off-curve point - quadratic curve control point
      control = point
      i += 1

      if i < points.length && !points[i][:on_curve]
        # Two consecutive off-curve points - implied on-curve at midpoint
        next_control = points[i]
        implied_x = (control[:x] + next_control[:x]) / 2.0
        implied_y = (control[:y] + next_control[:y]) / 2.0

        commands << {
          type: :quad_to,
          cx: control[:x],
          cy: control[:y],
          x: implied_x,
          y: implied_y,
        }
      elsif i < points.length
        # Next point is on-curve - end of quadratic curve
        end_point = points[i]
        commands << {
          type: :quad_to,
          cx: control[:x],
          cy: control[:y],
          x: end_point[:x],
          y: end_point[:y],
        }
        i += 1
      else
        # Curves back to first point
        commands << {
          type: :quad_to,
          cx: control[:x],
          cy: control[:y],
          x: first[:x],
          y: first[:y],
        }
      end
    end
  end

  # Close path
  commands << { type: :close_path }

  commands
end

.from_cff(charstring, glyph_id) ⇒ Outline

Create outline from CFF CharString

CFF uses cubic Bézier curves. This method executes the CharString and converts the path to our universal command format.

Parameters:

  • charstring (CharString)

    CFF CharString object

  • glyph_id (Integer)

    Glyph identifier

Returns:

  • (Outline)

    Universal outline instance

Raises:

  • (ArgumentError)

    If charstring is invalid



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
# File 'lib/fontisan/models/outline.rb', line 119

def self.from_cff(charstring, glyph_id)
  raise ArgumentError, "charstring cannot be nil" if charstring.nil?

  # Get path from CharString
  path = charstring.path
  if path.nil? || path.empty?
    raise ArgumentError,
          "CharString has no path data"
  end

  commands = convert_cff_path_to_commands(path)

  # Get bounding box
  bbox_array = charstring.bounding_box
  raise ArgumentError, "CharString has no bounding box" unless bbox_array

  bbox = {
    x_min: bbox_array[0],
    y_min: bbox_array[1],
    x_max: bbox_array[2],
    y_max: bbox_array[3],
  }

  new(
    glyph_id: glyph_id,
    commands: commands,
    bbox: bbox,
  )
end

.from_truetype(glyph, glyph_id) ⇒ Outline

Create outline from TrueType glyph

TrueType glyphs use quadratic Bézier curves. This method extracts the contours and converts them to our universal command format.

Parameters:

  • glyph (SimpleGlyph, CompoundGlyph)

    TrueType glyph object

  • glyph_id (Integer)

    Glyph identifier

Returns:

  • (Outline)

    Universal outline instance

Raises:

  • (ArgumentError)

    If glyph is invalid



82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
# File 'lib/fontisan/models/outline.rb', line 82

def self.from_truetype(glyph, glyph_id)
  raise ArgumentError, "glyph cannot be nil" if glyph.nil?
  raise ArgumentError, "glyph must be simple glyph" unless glyph.simple?

  commands = []
  bbox = {
    x_min: glyph.x_min,
    y_min: glyph.y_min,
    x_max: glyph.x_max,
    y_max: glyph.y_max,
  }

  # Process each contour
  glyph.num_contours.times do |contour_index|
    points = glyph.points_for_contour(contour_index)
    next if points.nil? || points.empty?

    contour_commands = convert_truetype_contour_to_commands(points)
    commands.concat(contour_commands)
  end

  new(
    glyph_id: glyph_id,
    commands: commands,
    bbox: bbox,
  )
end

Instance Method Details

#command_countInteger

Get number of commands

Returns:

  • (Integer)

    Number of commands



292
293
294
# File 'lib/fontisan/models/outline.rb', line 292

def command_count
  commands.length
end

#contour_countInteger

Get number of contours

Returns:

  • (Integer)

    Number of contours



299
300
301
# File 'lib/fontisan/models/outline.rb', line 299

def contour_count
  commands.count { |cmd| cmd[:type] == :move_to }
end

#empty?Boolean

Check if outline is empty

Returns:

  • (Boolean)

    True if no drawing commands



285
286
287
# File 'lib/fontisan/models/outline.rb', line 285

def empty?
  commands.empty? || commands.all? { |cmd| cmd[:type] == :close_path }
end

#merge!(other) ⇒ void

This method returns an undefined value.

Merge another outline into this one

Combines the commands from another outline with this one, creating a composite outline. The bounding box is recalculated to encompass both outlines.

Parameters:

  • other (Outline)

    Outline to merge



397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
# File 'lib/fontisan/models/outline.rb', line 397

def merge!(other)
  return if other.empty?

  # Merge commands (skip close_path before adding new contours)
  merged_commands = commands.dup
  merged_commands.pop if merged_commands.last && merged_commands.last[:type] == :close_path

  # Add other's commands
  merged_commands.concat(other.commands)

  # Recalculate bounding box
  merged_bbox = {
    x_min: [bbox[:x_min], other.bbox[:x_min]].min,
    y_min: [bbox[:y_min], other.bbox[:y_min]].min,
    x_max: [bbox[:x_max], other.bbox[:x_max]].max,
    y_max: [bbox[:y_max], other.bbox[:y_max]].max,
  }

  # Update instance variables
  @commands = merged_commands.freeze
  @bbox = merged_bbox.freeze
end

#to_cff_commandsArray<Hash>

Convert to CFF drawing commands

Converts universal commands to CFF CharString format with cubic curves. Quadratic curves are elevation to cubic (exact).

Returns:

  • (Array<Hash>)

    Array of CFF command hashes



224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
# File 'lib/fontisan/models/outline.rb', line 224

def to_cff_commands
  cff_commands = []

  commands.each do |cmd|
    case cmd[:type]
    when :move_to
      cff_commands << {
        type: :move_to,
        x: cmd[:x].round,
        y: cmd[:y].round,
      }
    when :line_to
      cff_commands << {
        type: :line_to,
        x: cmd[:x].round,
        y: cmd[:y].round,
      }
    when :quad_to
      # Quadratic to cubic (degree elevation - exact conversion)
      # For quadratic: P0 (current), P1 (control), P2 (end)
      # Cubic control points: CP1 = P0 + 2/3*(P1 - P0), CP2 = P2 + 2/3*(P1 - P2)
      # We need the previous point (P0)
      prev = find_previous_point(cff_commands)

      cx1 = (prev[:x] + (2.0 / 3.0) * (cmd[:cx] - prev[:x])).round
      cy1 = (prev[:y] + (2.0 / 3.0) * (cmd[:cy] - prev[:y])).round

      cx2 = (cmd[:x] + (2.0 / 3.0) * (cmd[:cx] - cmd[:x])).round
      cy2 = (cmd[:y] + (2.0 / 3.0) * (cmd[:cy] - cmd[:y])).round

      cff_commands << {
        type: :curve_to,
        x1: cx1,
        y1: cy1,
        x2: cx2,
        y2: cy2,
        x: cmd[:x].round,
        y: cmd[:y].round,
      }
    when :curve_to
      # Already cubic - direct mapping
      cff_commands << {
        type: :curve_to,
        x1: cmd[:cx1].round,
        y1: cmd[:cy1].round,
        x2: cmd[:cx2].round,
        y2: cmd[:cy2].round,
        x: cmd[:x].round,
        y: cmd[:y].round,
      }
    when :close_path
      # CFF doesn't have explicit close - handled by move_to
    end
  end

  cff_commands
end

#to_sString Also known as: inspect

String representation

Returns:

  • (String)

    Human-readable representation



306
307
308
309
310
# File 'lib/fontisan/models/outline.rb', line 306

def to_s
  "#<#{self.class.name} glyph_id=#{glyph_id} " \
    "commands=#{command_count} contours=#{contour_count} " \
    "bbox=#{bbox.inspect}>"
end

#to_truetype_contoursArray<Array<Hash>>

Convert to TrueType contour format

Converts universal commands to TrueType contour format with quadratic curves. Cubic curves are approximated as quadratics.

Returns:

  • (Array<Array<Hash>>)

    Array of contours



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
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
# File 'lib/fontisan/models/outline.rb', line 155

def to_truetype_contours
  contours = []
  current_contour = []

  commands.each do |cmd|
    case cmd[:type]
    when :move_to
      # Start new contour
      contours << current_contour unless current_contour.empty?
      current_contour = []
      current_contour << {
        x: cmd[:x].round,
        y: cmd[:y].round,
        on_curve: true,
      }
    when :line_to
      current_contour << {
        x: cmd[:x].round,
        y: cmd[:y].round,
        on_curve: true,
      }
    when :quad_to
      # Quadratic curve - add control point and end point
      current_contour << {
        x: cmd[:cx].round,
        y: cmd[:cy].round,
        on_curve: false,
      }
      current_contour << {
        x: cmd[:x].round,
        y: cmd[:y].round,
        on_curve: true,
      }
    when :curve_to
      # Cubic curve - approximate as quadratic
      # Convert cubic Bézier to quadratic (may need multiple segments)
      # For now, use simple midpoint approximation
      control_x = ((cmd[:cx1] + cmd[:cx2]) / 2.0).round
      control_y = ((cmd[:cy1] + cmd[:cy2]) / 2.0).round

      current_contour << {
        x: control_x,
        y: control_y,
        on_curve: false,
      }
      current_contour << {
        x: cmd[:x].round,
        y: cmd[:y].round,
        on_curve: true,
      }
    when :close_path
      # Close contour
      contours << current_contour unless current_contour.empty?
      current_contour = []
    end
  end

  # Add final contour if not closed
  contours << current_contour unless current_contour.empty?

  contours
end

#transform(matrix) ⇒ Outline

Apply affine transformation to outline

Applies a 2x3 affine transformation matrix to all points in the outline. The matrix is in the format [a, b, c, d, e, f] representing:

x' = a*x + c*y + e
y' = b*x + d*y + f

Parameters:

  • matrix (Array<Float>)

    Transformation matrix [a, b, c, d, e, f]

Returns:

  • (Outline)

    New outline with transformed commands



323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
# File 'lib/fontisan/models/outline.rb', line 323

def transform(matrix)
  a, b, c, d, e, f = matrix

  # Transform all commands
  transformed_commands = commands.map do |cmd|
    case cmd[:type]
    when :move_to, :line_to
      {
        type: cmd[:type],
        x: (a * cmd[:x] + c * cmd[:y] + e),
        y: (b * cmd[:x] + d * cmd[:y] + f),
      }
    when :quad_to
      {
        type: :quad_to,
        cx: (a * cmd[:cx] + c * cmd[:cy] + e),
        cy: (b * cmd[:cx] + d * cmd[:cy] + f),
        x: (a * cmd[:x] + c * cmd[:y] + e),
        y: (b * cmd[:x] + d * cmd[:y] + f),
      }
    when :curve_to
      {
        type: :curve_to,
        cx1: (a * cmd[:cx1] + c * cmd[:cy1] + e),
        cy1: (b * cmd[:cx1] + d * cmd[:cy1] + f),
        cx2: (a * cmd[:cx2] + c * cmd[:cy2] + e),
        cy2: (b * cmd[:cx2] + d * cmd[:cy2] + f),
        x: (a * cmd[:x] + c * cmd[:y] + e),
        y: (b * cmd[:x] + d * cmd[:y] + f),
      }
    when :close_path
      cmd
    else
      cmd
    end
  end

  # Calculate transformed bounding box
  # Apply transformation to all four corners
  corners = [
    [bbox[:x_min], bbox[:y_min]],
    [bbox[:x_max], bbox[:y_min]],
    [bbox[:x_min], bbox[:y_max]],
    [bbox[:x_max], bbox[:y_max]],
  ].map do |x, y|
    [a * x + c * y + e, b * x + d * y + f]
  end

  x_coords = corners.map(&:first)
  y_coords = corners.map(&:last)

  transformed_bbox = {
    x_min: x_coords.min.round,
    y_min: y_coords.min.round,
    x_max: x_coords.max.round,
    y_max: y_coords.max.round,
  }

  Outline.new(
    glyph_id: glyph_id,
    commands: transformed_commands,
    bbox: transformed_bbox,
    width: width,
  )
end