fontisan 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +221 -49
  3. data/README.adoc +519 -5
  4. data/Rakefile +20 -7
  5. data/lib/fontisan/cli.rb +67 -6
  6. data/lib/fontisan/commands/base_command.rb +2 -19
  7. data/lib/fontisan/commands/convert_command.rb +16 -13
  8. data/lib/fontisan/commands/info_command.rb +88 -0
  9. data/lib/fontisan/commands/validate_command.rb +107 -151
  10. data/lib/fontisan/config/conversion_matrix.yml +58 -20
  11. data/lib/fontisan/converters/outline_converter.rb +6 -3
  12. data/lib/fontisan/converters/svg_generator.rb +45 -0
  13. data/lib/fontisan/converters/woff2_encoder.rb +84 -13
  14. data/lib/fontisan/models/bitmap_glyph.rb +123 -0
  15. data/lib/fontisan/models/bitmap_strike.rb +94 -0
  16. data/lib/fontisan/models/color_glyph.rb +57 -0
  17. data/lib/fontisan/models/color_layer.rb +53 -0
  18. data/lib/fontisan/models/color_palette.rb +60 -0
  19. data/lib/fontisan/models/font_info.rb +26 -0
  20. data/lib/fontisan/models/svg_glyph.rb +89 -0
  21. data/lib/fontisan/models/validation_report.rb +227 -0
  22. data/lib/fontisan/open_type_font.rb +6 -0
  23. data/lib/fontisan/optimizers/charstring_rewriter.rb +19 -8
  24. data/lib/fontisan/optimizers/pattern_analyzer.rb +4 -2
  25. data/lib/fontisan/optimizers/subroutine_builder.rb +6 -5
  26. data/lib/fontisan/optimizers/subroutine_optimizer.rb +5 -2
  27. data/lib/fontisan/pipeline/output_writer.rb +2 -2
  28. data/lib/fontisan/pipeline/transformation_pipeline.rb +4 -8
  29. data/lib/fontisan/tables/cbdt.rb +169 -0
  30. data/lib/fontisan/tables/cblc.rb +290 -0
  31. data/lib/fontisan/tables/cff.rb +6 -12
  32. data/lib/fontisan/tables/cmap.rb +82 -2
  33. data/lib/fontisan/tables/colr.rb +291 -0
  34. data/lib/fontisan/tables/cpal.rb +281 -0
  35. data/lib/fontisan/tables/glyf/glyph_builder.rb +5 -1
  36. data/lib/fontisan/tables/glyf.rb +118 -0
  37. data/lib/fontisan/tables/head.rb +60 -0
  38. data/lib/fontisan/tables/hhea.rb +74 -0
  39. data/lib/fontisan/tables/maxp.rb +60 -0
  40. data/lib/fontisan/tables/name.rb +76 -0
  41. data/lib/fontisan/tables/os2.rb +113 -0
  42. data/lib/fontisan/tables/post.rb +57 -0
  43. data/lib/fontisan/tables/sbix.rb +379 -0
  44. data/lib/fontisan/tables/svg.rb +301 -0
  45. data/lib/fontisan/true_type_font.rb +6 -0
  46. data/lib/fontisan/validators/basic_validator.rb +85 -0
  47. data/lib/fontisan/validators/font_book_validator.rb +130 -0
  48. data/lib/fontisan/validators/opentype_validator.rb +112 -0
  49. data/lib/fontisan/validators/profile_loader.rb +139 -0
  50. data/lib/fontisan/validators/validator.rb +484 -0
  51. data/lib/fontisan/validators/web_font_validator.rb +102 -0
  52. data/lib/fontisan/version.rb +1 -1
  53. data/lib/fontisan/woff2/directory.rb +40 -11
  54. data/lib/fontisan/woff2/table_transformer.rb +506 -73
  55. data/lib/fontisan/woff2_font.rb +29 -9
  56. data/lib/fontisan/woff_font.rb +17 -4
  57. data/lib/fontisan.rb +90 -6
  58. metadata +20 -9
  59. data/lib/fontisan/config/validation_rules.yml +0 -149
  60. data/lib/fontisan/validation/checksum_validator.rb +0 -170
  61. data/lib/fontisan/validation/consistency_validator.rb +0 -197
  62. data/lib/fontisan/validation/structure_validator.rb +0 -198
  63. data/lib/fontisan/validation/table_validator.rb +0 -158
  64. data/lib/fontisan/validation/validator.rb +0 -152
  65. data/lib/fontisan/validation/variable_font_validator.rb +0 -218
@@ -0,0 +1,291 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+ require_relative "../binary/base_record"
5
+
6
+ module Fontisan
7
+ module Tables
8
+ # COLR (Color) table parser
9
+ #
10
+ # The COLR table defines layered color glyphs where each layer references
11
+ # a glyph ID and a palette index from the CPAL table. This enables fonts
12
+ # to display multi-colored glyphs such as emoji or brand logos.
13
+ #
14
+ # COLR Table Structure:
15
+ # ```
16
+ # COLR Table = Header (14 bytes)
17
+ # + Base Glyph Records (6 bytes each)
18
+ # + Layer Records (4 bytes each)
19
+ # ```
20
+ #
21
+ # Version 0 Structure:
22
+ # - version (uint16): Table version (0)
23
+ # - numBaseGlyphRecords (uint16): Number of base glyphs
24
+ # - baseGlyphRecordsOffset (uint32): Offset to base glyph records array
25
+ # - layerRecordsOffset (uint32): Offset to layer records array
26
+ # - numLayerRecords (uint16): Number of layer records
27
+ #
28
+ # The COLR table must be used together with the CPAL (Color Palette) table
29
+ # which defines the actual RGB color values referenced by palette indices.
30
+ #
31
+ # Reference: OpenType COLR specification
32
+ # https://docs.microsoft.com/en-us/typography/opentype/spec/colr
33
+ #
34
+ # @example Reading a COLR table
35
+ # data = font.table_data['COLR']
36
+ # colr = Fontisan::Tables::Colr.read(data)
37
+ # layers = colr.layers_for_glyph(42)
38
+ # puts "Glyph 42 has #{layers.length} color layers"
39
+ class Colr < Binary::BaseRecord
40
+ # OpenType table tag for COLR
41
+ TAG = "COLR"
42
+
43
+ # Base Glyph Record structure for COLR table
44
+ #
45
+ # Each base glyph record associates a glyph ID with its color layers.
46
+ # Structure (6 bytes): glyph_id, first_layer_index, num_layers
47
+ class BaseGlyphRecord < Binary::BaseRecord
48
+ endian :big
49
+ uint16 :glyph_id
50
+ uint16 :first_layer_index
51
+ uint16 :num_layers
52
+
53
+ def has_layers?
54
+ num_layers.positive?
55
+ end
56
+ end
57
+
58
+ # Layer Record structure for COLR table
59
+ #
60
+ # Each layer record specifies a glyph and palette index.
61
+ # Structure (4 bytes): glyph_id, palette_index
62
+ class LayerRecord < Binary::BaseRecord
63
+ endian :big
64
+ FOREGROUND_COLOR = 0xFFFF
65
+
66
+ uint16 :glyph_id
67
+ uint16 :palette_index
68
+
69
+ def uses_foreground_color?
70
+ palette_index == FOREGROUND_COLOR
71
+ end
72
+
73
+ def uses_palette_color?
74
+ !uses_foreground_color?
75
+ end
76
+ end
77
+
78
+ # @return [Integer] COLR version (0 for version 0)
79
+ attr_reader :version
80
+
81
+ # @return [Integer] Number of base glyph records
82
+ attr_reader :num_base_glyph_records
83
+
84
+ # @return [Integer] Offset to base glyph records array
85
+ attr_reader :base_glyph_records_offset
86
+
87
+ # @return [Integer] Offset to layer records array
88
+ attr_reader :layer_records_offset
89
+
90
+ # @return [Integer] Number of layer records
91
+ attr_reader :num_layer_records
92
+
93
+ # @return [String] Raw binary data for the entire COLR table
94
+ attr_reader :raw_data
95
+
96
+ # @return [Array<BaseGlyphRecord>] Parsed base glyph records
97
+ attr_reader :base_glyph_records
98
+
99
+ # @return [Array<LayerRecord>] Parsed layer records
100
+ attr_reader :layer_records
101
+
102
+ # Override read to parse COLR structure
103
+ #
104
+ # @param io [IO, String] Binary data to read
105
+ # @return [Colr] Parsed COLR table
106
+ def self.read(io)
107
+ colr = new
108
+ return colr if io.nil?
109
+
110
+ data = io.is_a?(String) ? io : io.read
111
+ colr.parse!(data)
112
+ colr
113
+ end
114
+
115
+ # Parse the COLR table structure
116
+ #
117
+ # @param data [String] Binary data for the COLR table
118
+ # @raise [CorruptedTableError] If COLR structure is invalid
119
+ def parse!(data)
120
+ @raw_data = data
121
+ io = StringIO.new(data)
122
+
123
+ # Parse COLR header (14 bytes)
124
+ parse_header(io)
125
+ validate_header!
126
+
127
+ # Parse base glyph records
128
+ parse_base_glyph_records(io)
129
+
130
+ # Parse layer records
131
+ parse_layer_records(io)
132
+ rescue StandardError => e
133
+ raise CorruptedTableError, "Failed to parse COLR table: #{e.message}"
134
+ end
135
+
136
+ # Get color layers for a specific glyph ID
137
+ #
138
+ # Returns an array of LayerRecord objects for the specified glyph.
139
+ # Returns empty array if glyph has no color layers.
140
+ #
141
+ # @param glyph_id [Integer] Glyph ID to look up
142
+ # @return [Array<LayerRecord>] Array of layer records for this glyph
143
+ def layers_for_glyph(glyph_id)
144
+ # Find base glyph record for this glyph ID
145
+ base_record = find_base_glyph_record(glyph_id)
146
+ return [] unless base_record
147
+
148
+ # Extract layers for this glyph
149
+ first_index = base_record.first_layer_index
150
+ num_layers = base_record.num_layers
151
+
152
+ return [] if num_layers.zero?
153
+
154
+ # Return slice of layer records
155
+ layer_records[first_index, num_layers] || []
156
+ end
157
+
158
+ # Check if COLR table has color data for a specific glyph
159
+ #
160
+ # @param glyph_id [Integer] Glyph ID to check
161
+ # @return [Boolean] True if glyph has color layers
162
+ def has_color_glyph?(glyph_id)
163
+ !layers_for_glyph(glyph_id).empty?
164
+ end
165
+
166
+ # Get all glyph IDs that have color data
167
+ #
168
+ # @return [Array<Integer>] Array of glyph IDs with color layers
169
+ def color_glyph_ids
170
+ base_glyph_records.map(&:glyph_id)
171
+ end
172
+
173
+ # Get the number of color glyphs in this table
174
+ #
175
+ # @return [Integer] Number of base glyphs
176
+ def num_color_glyphs
177
+ num_base_glyph_records
178
+ end
179
+
180
+ # Validate the COLR table structure
181
+ #
182
+ # @return [Boolean] True if valid
183
+ def valid?
184
+ return false if version.nil?
185
+ return false if version != 0 # Only version 0 supported currently
186
+ return false if num_base_glyph_records.nil? || num_base_glyph_records.negative?
187
+ return false if num_layer_records.nil? || num_layer_records.negative?
188
+ return false unless base_glyph_records
189
+ return false unless layer_records
190
+
191
+ true
192
+ end
193
+
194
+ private
195
+
196
+ # Parse COLR header (14 bytes)
197
+ #
198
+ # @param io [StringIO] Input stream
199
+ def parse_header(io)
200
+ @version = io.read(2).unpack1("n")
201
+ @num_base_glyph_records = io.read(2).unpack1("n")
202
+ @base_glyph_records_offset = io.read(4).unpack1("N")
203
+ @layer_records_offset = io.read(4).unpack1("N")
204
+ @num_layer_records = io.read(2).unpack1("n")
205
+ end
206
+
207
+ # Validate header values
208
+ #
209
+ # @raise [CorruptedTableError] If validation fails
210
+ def validate_header!
211
+ unless version.zero?
212
+ raise CorruptedTableError,
213
+ "Unsupported COLR version: #{version} (only version 0 supported)"
214
+ end
215
+
216
+ if num_base_glyph_records.negative?
217
+ raise CorruptedTableError,
218
+ "Invalid numBaseGlyphRecords: #{num_base_glyph_records}"
219
+ end
220
+
221
+ if num_layer_records.negative?
222
+ raise CorruptedTableError,
223
+ "Invalid numLayerRecords: #{num_layer_records}"
224
+ end
225
+ end
226
+
227
+ # Parse base glyph records array
228
+ #
229
+ # @param io [StringIO] Input stream
230
+ def parse_base_glyph_records(io)
231
+ @base_glyph_records = []
232
+ return if num_base_glyph_records.zero?
233
+
234
+ # Seek to base glyph records
235
+ io.seek(base_glyph_records_offset)
236
+
237
+ # Parse each base glyph record (6 bytes each)
238
+ num_base_glyph_records.times do
239
+ record_data = io.read(6)
240
+ record = BaseGlyphRecord.read(record_data)
241
+ @base_glyph_records << record
242
+ end
243
+ end
244
+
245
+ # Parse layer records array
246
+ #
247
+ # @param io [StringIO] Input stream
248
+ def parse_layer_records(io)
249
+ @layer_records = []
250
+ return if num_layer_records.zero?
251
+
252
+ # Seek to layer records
253
+ io.seek(layer_records_offset)
254
+
255
+ # Parse each layer record (4 bytes each)
256
+ num_layer_records.times do
257
+ record_data = io.read(4)
258
+ record = LayerRecord.read(record_data)
259
+ @layer_records << record
260
+ end
261
+ end
262
+
263
+ # Find base glyph record for a specific glyph ID
264
+ #
265
+ # Uses binary search since base glyph records are sorted by glyph ID
266
+ #
267
+ # @param glyph_id [Integer] Glyph ID to find
268
+ # @return [BaseGlyphRecord, nil] Base glyph record or nil if not found
269
+ def find_base_glyph_record(glyph_id)
270
+ # Binary search through base glyph records
271
+ left = 0
272
+ right = base_glyph_records.length - 1
273
+
274
+ while left <= right
275
+ mid = (left + right) / 2
276
+ record = base_glyph_records[mid]
277
+
278
+ if record.glyph_id == glyph_id
279
+ return record
280
+ elsif record.glyph_id < glyph_id
281
+ left = mid + 1
282
+ else
283
+ right = mid - 1
284
+ end
285
+ end
286
+
287
+ nil
288
+ end
289
+ end
290
+ end
291
+ end
@@ -0,0 +1,281 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+ require_relative "../binary/base_record"
5
+
6
+ module Fontisan
7
+ module Tables
8
+ # CPAL (Color Palette) table parser
9
+ #
10
+ # The CPAL table defines color palettes used by COLR layers. Each palette
11
+ # contains an array of RGBA color values that can be referenced by the
12
+ # COLR table's palette indices.
13
+ #
14
+ # CPAL Table Structure:
15
+ # ```
16
+ # CPAL Table = Header
17
+ # + Palette Indices Array
18
+ # + Color Records Array
19
+ # + [Palette Types Array] (version 1)
20
+ # + [Palette Labels Array] (version 1)
21
+ # + [Palette Entry Labels Array] (version 1)
22
+ # ```
23
+ #
24
+ # Version 0 Header (12 bytes):
25
+ # - version (uint16): Table version (0 or 1)
26
+ # - numPaletteEntries (uint16): Number of colors per palette
27
+ # - numPalettes (uint16): Number of palettes
28
+ # - numColorRecords (uint16): Total number of color records
29
+ # - colorRecordsArrayOffset (uint32): Offset to color records array
30
+ #
31
+ # Version 1 adds optional metadata for palette types and labels.
32
+ #
33
+ # Color Record Structure (4 bytes, BGRA format):
34
+ # - blue (uint8)
35
+ # - green (uint8)
36
+ # - red (uint8)
37
+ # - alpha (uint8)
38
+ #
39
+ # Reference: OpenType CPAL specification
40
+ # https://docs.microsoft.com/en-us/typography/opentype/spec/cpal
41
+ #
42
+ # @example Reading a CPAL table
43
+ # data = font.table_data['CPAL']
44
+ # cpal = Fontisan::Tables::Cpal.read(data)
45
+ # palette = cpal.palette(0) # Get first palette
46
+ # puts palette.colors.first # => "#RRGGBBAA"
47
+ class Cpal < Binary::BaseRecord
48
+ # OpenType table tag for CPAL
49
+ TAG = "CPAL"
50
+
51
+ # @return [Integer] CPAL version (0 or 1)
52
+ attr_reader :version
53
+
54
+ # @return [Integer] Number of color entries per palette
55
+ attr_reader :num_palette_entries
56
+
57
+ # @return [Integer] Number of palettes in this table
58
+ attr_reader :num_palettes
59
+
60
+ # @return [Integer] Total number of color records
61
+ attr_reader :num_color_records
62
+
63
+ # @return [Integer] Offset to color records array
64
+ attr_reader :color_records_array_offset
65
+
66
+ # @return [String] Raw binary data for the entire CPAL table
67
+ attr_reader :raw_data
68
+
69
+ # @return [Array<Integer>] Palette indices (start index for each palette)
70
+ attr_reader :palette_indices
71
+
72
+ # @return [Array<Hash>] Parsed color records (RGBA hashes)
73
+ attr_reader :color_records
74
+
75
+ # Override read to parse CPAL structure
76
+ #
77
+ # @param io [IO, String] Binary data to read
78
+ # @return [Cpal] Parsed CPAL table
79
+ def self.read(io)
80
+ cpal = new
81
+ return cpal if io.nil?
82
+
83
+ data = io.is_a?(String) ? io : io.read
84
+ cpal.parse!(data)
85
+ cpal
86
+ end
87
+
88
+ # Parse the CPAL table structure
89
+ #
90
+ # @param data [String] Binary data for the CPAL table
91
+ # @raise [CorruptedTableError] If CPAL structure is invalid
92
+ def parse!(data)
93
+ @raw_data = data
94
+ io = StringIO.new(data)
95
+
96
+ # Parse CPAL header
97
+ parse_header(io)
98
+ validate_header!
99
+
100
+ # Parse palette indices array
101
+ parse_palette_indices(io)
102
+
103
+ # Parse color records
104
+ parse_color_records(io)
105
+
106
+ # Version 1 features (palette types, labels) not implemented yet
107
+ # TODO: Add version 1 features in follow-up task
108
+ rescue StandardError => e
109
+ raise CorruptedTableError, "Failed to parse CPAL table: #{e.message}"
110
+ end
111
+
112
+ # Get a specific palette by index
113
+ #
114
+ # Returns an array of color strings in hex format (#RRGGBBAA).
115
+ # Each palette contains num_palette_entries colors.
116
+ #
117
+ # @param index [Integer] Palette index (0-based)
118
+ # @return [Array<String>, nil] Array of hex color strings, or nil if invalid
119
+ def palette(index)
120
+ return nil if index.negative? || index >= num_palettes
121
+
122
+ # Get starting index for this palette
123
+ start_index = palette_indices[index]
124
+
125
+ # Extract colors for this palette
126
+ colors = []
127
+ num_palette_entries.times do |i|
128
+ color_record = color_records[start_index + i]
129
+ colors << color_to_hex(color_record) if color_record
130
+ end
131
+
132
+ colors
133
+ end
134
+
135
+ # Get all palettes
136
+ #
137
+ # @return [Array<Array<String>>] Array of palettes, each an array of hex colors
138
+ def all_palettes
139
+ (0...num_palettes).map { |i| palette(i) }
140
+ end
141
+
142
+ # Get color at specific palette and entry index
143
+ #
144
+ # @param palette_index [Integer] Palette index
145
+ # @param entry_index [Integer] Entry index within palette
146
+ # @return [String, nil] Hex color string or nil
147
+ def color_at(palette_index, entry_index)
148
+ return nil if palette_index.negative? || palette_index >= num_palettes
149
+ return nil if entry_index.negative? || entry_index >= num_palette_entries
150
+
151
+ start_index = palette_indices[palette_index]
152
+ color_record = color_records[start_index + entry_index]
153
+ color_record ? color_to_hex(color_record) : nil
154
+ end
155
+
156
+ # Validate the CPAL table structure
157
+ #
158
+ # @return [Boolean] True if valid
159
+ def valid?
160
+ return false if version.nil?
161
+ return false unless [0, 1].include?(version)
162
+ return false if num_palette_entries.nil? || num_palette_entries.negative?
163
+ return false if num_palettes.nil? || num_palettes.negative?
164
+ return false if num_color_records.nil? || num_color_records.negative?
165
+ return false unless palette_indices
166
+ return false unless color_records
167
+
168
+ true
169
+ end
170
+
171
+ private
172
+
173
+ # Parse CPAL header (12 bytes for version 0, 16 bytes for version 1)
174
+ #
175
+ # @param io [StringIO] Input stream
176
+ def parse_header(io)
177
+ @version = io.read(2).unpack1("n")
178
+ @num_palette_entries = io.read(2).unpack1("n")
179
+ @num_palettes = io.read(2).unpack1("n")
180
+ @num_color_records = io.read(2).unpack1("n")
181
+ @color_records_array_offset = io.read(4).unpack1("N")
182
+
183
+ # Version 1 has additional header fields
184
+ if version == 1
185
+ # TODO: Parse version 1 header fields
186
+ # - paletteTypesArrayOffset (uint32)
187
+ # - paletteLabelsArrayOffset (uint32)
188
+ # - paletteEntryLabelsArrayOffset (uint32)
189
+ end
190
+ end
191
+
192
+ # Validate header values
193
+ #
194
+ # @raise [CorruptedTableError] If validation fails
195
+ def validate_header!
196
+ unless [0, 1].include?(version)
197
+ raise CorruptedTableError,
198
+ "Unsupported CPAL version: #{version} (only versions 0 and 1 supported)"
199
+ end
200
+
201
+ if num_palette_entries.negative?
202
+ raise CorruptedTableError,
203
+ "Invalid numPaletteEntries: #{num_palette_entries}"
204
+ end
205
+
206
+ if num_palettes.negative?
207
+ raise CorruptedTableError,
208
+ "Invalid numPalettes: #{num_palettes}"
209
+ end
210
+
211
+ if num_color_records.negative?
212
+ raise CorruptedTableError,
213
+ "Invalid numColorRecords: #{num_color_records}"
214
+ end
215
+
216
+ # Validate that total color records match expected count
217
+ expected_records = num_palettes * num_palette_entries
218
+ unless num_color_records >= expected_records
219
+ raise CorruptedTableError,
220
+ "Insufficient color records: expected at least #{expected_records}, " \
221
+ "got #{num_color_records}"
222
+ end
223
+ end
224
+
225
+ # Parse palette indices array
226
+ #
227
+ # @param io [StringIO] Input stream
228
+ def parse_palette_indices(io)
229
+ @palette_indices = []
230
+ return if num_palettes.zero?
231
+
232
+ # Palette indices immediately follow header (at offset 12 for v0, 16 for v1)
233
+ # Each index is uint16 (2 bytes)
234
+ num_palettes.times do
235
+ index = io.read(2).unpack1("n")
236
+ @palette_indices << index
237
+ end
238
+ end
239
+
240
+ # Parse color records array
241
+ #
242
+ # @param io [StringIO] Input stream
243
+ def parse_color_records(io)
244
+ @color_records = []
245
+ return if num_color_records.zero?
246
+
247
+ # Seek to color records array
248
+ io.seek(color_records_array_offset)
249
+
250
+ # Parse each color record (4 bytes, BGRA format)
251
+ num_color_records.times do
252
+ blue = io.read(1).unpack1("C")
253
+ green = io.read(1).unpack1("C")
254
+ red = io.read(1).unpack1("C")
255
+ alpha = io.read(1).unpack1("C")
256
+
257
+ @color_records << {
258
+ red: red,
259
+ green: green,
260
+ blue: blue,
261
+ alpha: alpha,
262
+ }
263
+ end
264
+ end
265
+
266
+ # Convert color record to hex string
267
+ #
268
+ # @param color [Hash] Color hash with :red, :green, :blue, :alpha keys
269
+ # @return [String] Hex color string (#RRGGBBAA)
270
+ def color_to_hex(color)
271
+ format(
272
+ "#%<red>02X%<green>02X%<blue>02X%<alpha>02X",
273
+ red: color[:red],
274
+ green: color[:green],
275
+ blue: color[:blue],
276
+ alpha: color[:alpha],
277
+ )
278
+ end
279
+ end
280
+ end
281
+ end
@@ -76,7 +76,7 @@ module Fontisan
76
76
  contours = outline.to_truetype_contours
77
77
  raise ArgumentError, "no contours in outline" if contours.empty?
78
78
 
79
- # Calculate bounding box from contours
79
+ # Calculate bounding box from contours (on-curve points only)
80
80
  bbox = calculate_bounding_box(contours)
81
81
 
82
82
  # Build binary data
@@ -389,6 +389,10 @@ axis)
389
389
 
390
390
  contours.each do |contour|
391
391
  contour.each do |point|
392
+ # Only consider on-curve points for bounding box
393
+ # Off-curve points are control points and may lie outside the actual outline
394
+ next unless point[:on_curve]
395
+
392
396
  x = point[:x]
393
397
  y = point[:y]
394
398