fontisan 0.1.0 → 0.2.1

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 (214) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +672 -69
  3. data/Gemfile +1 -0
  4. data/LICENSE +5 -1
  5. data/README.adoc +1477 -297
  6. data/Rakefile +63 -41
  7. data/benchmark/variation_quick_bench.rb +47 -0
  8. data/docs/EXTRACT_TTC_MIGRATION.md +549 -0
  9. data/fontisan.gemspec +4 -1
  10. data/lib/fontisan/binary/base_record.rb +22 -1
  11. data/lib/fontisan/cli.rb +364 -4
  12. data/lib/fontisan/collection/builder.rb +341 -0
  13. data/lib/fontisan/collection/offset_calculator.rb +227 -0
  14. data/lib/fontisan/collection/table_analyzer.rb +204 -0
  15. data/lib/fontisan/collection/table_deduplicator.rb +317 -0
  16. data/lib/fontisan/collection/writer.rb +306 -0
  17. data/lib/fontisan/commands/base_command.rb +24 -1
  18. data/lib/fontisan/commands/convert_command.rb +218 -0
  19. data/lib/fontisan/commands/export_command.rb +161 -0
  20. data/lib/fontisan/commands/info_command.rb +40 -6
  21. data/lib/fontisan/commands/instance_command.rb +286 -0
  22. data/lib/fontisan/commands/ls_command.rb +113 -0
  23. data/lib/fontisan/commands/pack_command.rb +241 -0
  24. data/lib/fontisan/commands/subset_command.rb +245 -0
  25. data/lib/fontisan/commands/unpack_command.rb +338 -0
  26. data/lib/fontisan/commands/validate_command.rb +203 -0
  27. data/lib/fontisan/commands/variable_command.rb +30 -1
  28. data/lib/fontisan/config/collection_settings.yml +56 -0
  29. data/lib/fontisan/config/conversion_matrix.yml +212 -0
  30. data/lib/fontisan/config/export_settings.yml +66 -0
  31. data/lib/fontisan/config/subset_profiles.yml +100 -0
  32. data/lib/fontisan/config/svg_settings.yml +60 -0
  33. data/lib/fontisan/config/validation_rules.yml +149 -0
  34. data/lib/fontisan/config/variable_settings.yml +99 -0
  35. data/lib/fontisan/config/woff2_settings.yml +77 -0
  36. data/lib/fontisan/constants.rb +79 -0
  37. data/lib/fontisan/converters/conversion_strategy.rb +96 -0
  38. data/lib/fontisan/converters/format_converter.rb +408 -0
  39. data/lib/fontisan/converters/outline_converter.rb +998 -0
  40. data/lib/fontisan/converters/svg_generator.rb +244 -0
  41. data/lib/fontisan/converters/table_copier.rb +117 -0
  42. data/lib/fontisan/converters/woff2_encoder.rb +416 -0
  43. data/lib/fontisan/converters/woff_writer.rb +391 -0
  44. data/lib/fontisan/error.rb +203 -0
  45. data/lib/fontisan/export/exporter.rb +262 -0
  46. data/lib/fontisan/export/table_serializer.rb +255 -0
  47. data/lib/fontisan/export/transformers/font_to_ttx.rb +172 -0
  48. data/lib/fontisan/export/transformers/head_transformer.rb +96 -0
  49. data/lib/fontisan/export/transformers/hhea_transformer.rb +59 -0
  50. data/lib/fontisan/export/transformers/maxp_transformer.rb +63 -0
  51. data/lib/fontisan/export/transformers/name_transformer.rb +63 -0
  52. data/lib/fontisan/export/transformers/os2_transformer.rb +121 -0
  53. data/lib/fontisan/export/transformers/post_transformer.rb +51 -0
  54. data/lib/fontisan/export/ttx_generator.rb +527 -0
  55. data/lib/fontisan/export/ttx_parser.rb +300 -0
  56. data/lib/fontisan/font_loader.rb +122 -15
  57. data/lib/fontisan/font_writer.rb +302 -0
  58. data/lib/fontisan/formatters/text_formatter.rb +102 -0
  59. data/lib/fontisan/glyph_accessor.rb +503 -0
  60. data/lib/fontisan/hints/hint_converter.rb +310 -0
  61. data/lib/fontisan/hints/postscript_hint_applier.rb +266 -0
  62. data/lib/fontisan/hints/postscript_hint_extractor.rb +354 -0
  63. data/lib/fontisan/hints/truetype_hint_applier.rb +117 -0
  64. data/lib/fontisan/hints/truetype_hint_extractor.rb +289 -0
  65. data/lib/fontisan/loading_modes.rb +115 -0
  66. data/lib/fontisan/metrics_calculator.rb +277 -0
  67. data/lib/fontisan/models/collection_font_summary.rb +52 -0
  68. data/lib/fontisan/models/collection_info.rb +76 -0
  69. data/lib/fontisan/models/collection_list_info.rb +37 -0
  70. data/lib/fontisan/models/font_export.rb +158 -0
  71. data/lib/fontisan/models/font_summary.rb +48 -0
  72. data/lib/fontisan/models/glyph_outline.rb +343 -0
  73. data/lib/fontisan/models/hint.rb +405 -0
  74. data/lib/fontisan/models/outline.rb +664 -0
  75. data/lib/fontisan/models/table_sharing_info.rb +40 -0
  76. data/lib/fontisan/models/ttx/glyph_order.rb +31 -0
  77. data/lib/fontisan/models/ttx/tables/binary_table.rb +67 -0
  78. data/lib/fontisan/models/ttx/tables/head_table.rb +74 -0
  79. data/lib/fontisan/models/ttx/tables/hhea_table.rb +74 -0
  80. data/lib/fontisan/models/ttx/tables/maxp_table.rb +55 -0
  81. data/lib/fontisan/models/ttx/tables/name_table.rb +45 -0
  82. data/lib/fontisan/models/ttx/tables/os2_table.rb +157 -0
  83. data/lib/fontisan/models/ttx/tables/post_table.rb +50 -0
  84. data/lib/fontisan/models/ttx/ttfont.rb +49 -0
  85. data/lib/fontisan/models/validation_report.rb +203 -0
  86. data/lib/fontisan/open_type_collection.rb +156 -2
  87. data/lib/fontisan/open_type_font.rb +321 -19
  88. data/lib/fontisan/open_type_font_extensions.rb +54 -0
  89. data/lib/fontisan/optimizers/charstring_rewriter.rb +161 -0
  90. data/lib/fontisan/optimizers/pattern_analyzer.rb +308 -0
  91. data/lib/fontisan/optimizers/stack_tracker.rb +246 -0
  92. data/lib/fontisan/optimizers/subroutine_builder.rb +134 -0
  93. data/lib/fontisan/optimizers/subroutine_generator.rb +207 -0
  94. data/lib/fontisan/optimizers/subroutine_optimizer.rb +107 -0
  95. data/lib/fontisan/outline_extractor.rb +423 -0
  96. data/lib/fontisan/pipeline/format_detector.rb +249 -0
  97. data/lib/fontisan/pipeline/output_writer.rb +154 -0
  98. data/lib/fontisan/pipeline/strategies/base_strategy.rb +75 -0
  99. data/lib/fontisan/pipeline/strategies/instance_strategy.rb +93 -0
  100. data/lib/fontisan/pipeline/strategies/named_strategy.rb +118 -0
  101. data/lib/fontisan/pipeline/strategies/preserve_strategy.rb +56 -0
  102. data/lib/fontisan/pipeline/transformation_pipeline.rb +411 -0
  103. data/lib/fontisan/pipeline/variation_resolver.rb +165 -0
  104. data/lib/fontisan/subset/builder.rb +268 -0
  105. data/lib/fontisan/subset/glyph_mapping.rb +215 -0
  106. data/lib/fontisan/subset/options.rb +142 -0
  107. data/lib/fontisan/subset/profile.rb +152 -0
  108. data/lib/fontisan/subset/table_subsetter.rb +461 -0
  109. data/lib/fontisan/svg/font_face_generator.rb +278 -0
  110. data/lib/fontisan/svg/font_generator.rb +264 -0
  111. data/lib/fontisan/svg/glyph_generator.rb +168 -0
  112. data/lib/fontisan/svg/view_box_calculator.rb +137 -0
  113. data/lib/fontisan/tables/cff/cff_glyph.rb +176 -0
  114. data/lib/fontisan/tables/cff/charset.rb +282 -0
  115. data/lib/fontisan/tables/cff/charstring.rb +934 -0
  116. data/lib/fontisan/tables/cff/charstring_builder.rb +356 -0
  117. data/lib/fontisan/tables/cff/charstring_parser.rb +237 -0
  118. data/lib/fontisan/tables/cff/charstring_rebuilder.rb +172 -0
  119. data/lib/fontisan/tables/cff/charstrings_index.rb +162 -0
  120. data/lib/fontisan/tables/cff/dict.rb +351 -0
  121. data/lib/fontisan/tables/cff/dict_builder.rb +257 -0
  122. data/lib/fontisan/tables/cff/encoding.rb +274 -0
  123. data/lib/fontisan/tables/cff/header.rb +102 -0
  124. data/lib/fontisan/tables/cff/hint_operation_injector.rb +207 -0
  125. data/lib/fontisan/tables/cff/index.rb +237 -0
  126. data/lib/fontisan/tables/cff/index_builder.rb +170 -0
  127. data/lib/fontisan/tables/cff/offset_recalculator.rb +70 -0
  128. data/lib/fontisan/tables/cff/private_dict.rb +284 -0
  129. data/lib/fontisan/tables/cff/private_dict_writer.rb +125 -0
  130. data/lib/fontisan/tables/cff/table_builder.rb +221 -0
  131. data/lib/fontisan/tables/cff/top_dict.rb +236 -0
  132. data/lib/fontisan/tables/cff.rb +489 -0
  133. data/lib/fontisan/tables/cff2/blend_operator.rb +240 -0
  134. data/lib/fontisan/tables/cff2/charstring_parser.rb +591 -0
  135. data/lib/fontisan/tables/cff2/operand_stack.rb +232 -0
  136. data/lib/fontisan/tables/cff2/private_dict_blend_handler.rb +246 -0
  137. data/lib/fontisan/tables/cff2/region_matcher.rb +200 -0
  138. data/lib/fontisan/tables/cff2/table_builder.rb +574 -0
  139. data/lib/fontisan/tables/cff2/table_reader.rb +419 -0
  140. data/lib/fontisan/tables/cff2/variation_data_extractor.rb +212 -0
  141. data/lib/fontisan/tables/cff2.rb +346 -0
  142. data/lib/fontisan/tables/cvar.rb +203 -0
  143. data/lib/fontisan/tables/fvar.rb +2 -2
  144. data/lib/fontisan/tables/glyf/compound_glyph.rb +483 -0
  145. data/lib/fontisan/tables/glyf/compound_glyph_resolver.rb +136 -0
  146. data/lib/fontisan/tables/glyf/curve_converter.rb +343 -0
  147. data/lib/fontisan/tables/glyf/glyph_builder.rb +450 -0
  148. data/lib/fontisan/tables/glyf/simple_glyph.rb +382 -0
  149. data/lib/fontisan/tables/glyf.rb +235 -0
  150. data/lib/fontisan/tables/gvar.rb +231 -0
  151. data/lib/fontisan/tables/hhea.rb +124 -0
  152. data/lib/fontisan/tables/hmtx.rb +287 -0
  153. data/lib/fontisan/tables/hvar.rb +191 -0
  154. data/lib/fontisan/tables/loca.rb +322 -0
  155. data/lib/fontisan/tables/maxp.rb +192 -0
  156. data/lib/fontisan/tables/mvar.rb +185 -0
  157. data/lib/fontisan/tables/name.rb +99 -30
  158. data/lib/fontisan/tables/variation_common.rb +346 -0
  159. data/lib/fontisan/tables/vvar.rb +234 -0
  160. data/lib/fontisan/true_type_collection.rb +156 -2
  161. data/lib/fontisan/true_type_font.rb +321 -20
  162. data/lib/fontisan/true_type_font_extensions.rb +54 -0
  163. data/lib/fontisan/utilities/brotli_wrapper.rb +159 -0
  164. data/lib/fontisan/utilities/checksum_calculator.rb +60 -0
  165. data/lib/fontisan/utils/thread_pool.rb +134 -0
  166. data/lib/fontisan/validation/checksum_validator.rb +170 -0
  167. data/lib/fontisan/validation/consistency_validator.rb +197 -0
  168. data/lib/fontisan/validation/structure_validator.rb +198 -0
  169. data/lib/fontisan/validation/table_validator.rb +158 -0
  170. data/lib/fontisan/validation/validator.rb +152 -0
  171. data/lib/fontisan/validation/variable_font_validator.rb +218 -0
  172. data/lib/fontisan/variable/axis_normalizer.rb +215 -0
  173. data/lib/fontisan/variable/delta_applicator.rb +313 -0
  174. data/lib/fontisan/variable/glyph_delta_processor.rb +218 -0
  175. data/lib/fontisan/variable/instancer.rb +344 -0
  176. data/lib/fontisan/variable/metric_delta_processor.rb +282 -0
  177. data/lib/fontisan/variable/region_matcher.rb +208 -0
  178. data/lib/fontisan/variable/static_font_builder.rb +213 -0
  179. data/lib/fontisan/variable/table_updater.rb +219 -0
  180. data/lib/fontisan/variation/blend_applier.rb +199 -0
  181. data/lib/fontisan/variation/cache.rb +298 -0
  182. data/lib/fontisan/variation/cache_key_builder.rb +162 -0
  183. data/lib/fontisan/variation/converter.rb +375 -0
  184. data/lib/fontisan/variation/data_extractor.rb +86 -0
  185. data/lib/fontisan/variation/delta_applier.rb +266 -0
  186. data/lib/fontisan/variation/delta_parser.rb +228 -0
  187. data/lib/fontisan/variation/inspector.rb +275 -0
  188. data/lib/fontisan/variation/instance_generator.rb +273 -0
  189. data/lib/fontisan/variation/instance_writer.rb +341 -0
  190. data/lib/fontisan/variation/interpolator.rb +231 -0
  191. data/lib/fontisan/variation/metrics_adjuster.rb +318 -0
  192. data/lib/fontisan/variation/optimizer.rb +418 -0
  193. data/lib/fontisan/variation/parallel_generator.rb +150 -0
  194. data/lib/fontisan/variation/region_matcher.rb +221 -0
  195. data/lib/fontisan/variation/subsetter.rb +463 -0
  196. data/lib/fontisan/variation/table_accessor.rb +105 -0
  197. data/lib/fontisan/variation/tuple_variation_header.rb +51 -0
  198. data/lib/fontisan/variation/validator.rb +345 -0
  199. data/lib/fontisan/variation/variable_svg_generator.rb +268 -0
  200. data/lib/fontisan/variation/variation_context.rb +211 -0
  201. data/lib/fontisan/variation/variation_preserver.rb +288 -0
  202. data/lib/fontisan/version.rb +1 -1
  203. data/lib/fontisan/version.rb.orig +9 -0
  204. data/lib/fontisan/woff2/directory.rb +257 -0
  205. data/lib/fontisan/woff2/glyf_transformer.rb +666 -0
  206. data/lib/fontisan/woff2/header.rb +101 -0
  207. data/lib/fontisan/woff2/hmtx_transformer.rb +164 -0
  208. data/lib/fontisan/woff2/table_transformer.rb +163 -0
  209. data/lib/fontisan/woff2_font.rb +717 -0
  210. data/lib/fontisan/woff_font.rb +488 -0
  211. data/lib/fontisan.rb +132 -0
  212. data/scripts/compare_stack_aware.rb +187 -0
  213. data/scripts/measure_optimization.rb +141 -0
  214. metadata +234 -4
@@ -0,0 +1,343 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Models
5
+ # Represents a glyph's outline data with conversion capabilities
6
+ #
7
+ # [`GlyphOutline`](lib/fontisan/models/glyph_outline.rb) is a pure data
8
+ # model that stores glyph outline information extracted from font tables.
9
+ # It provides methods to convert the outline data to various formats
10
+ # (SVG paths, drawing commands) for rendering and manipulation.
11
+ #
12
+ # The outline consists of:
13
+ # - Contours: Array of closed paths, each containing points
14
+ # - Points: All points from all contours (flattened for easy access)
15
+ # - Bounding box: The glyph's bounding rectangle
16
+ # - Glyph ID: The identifier of this glyph
17
+ #
18
+ # This class is immutable after construction to ensure data integrity.
19
+ #
20
+ # @example Creating an outline
21
+ # outline = Fontisan::Models::GlyphOutline.new(
22
+ # glyph_id: 65,
23
+ # contours: [
24
+ # [
25
+ # { x: 100, y: 0, on_curve: true },
26
+ # { x: 200, y: 700, on_curve: true },
27
+ # { x: 300, y: 0, on_curve: true }
28
+ # ]
29
+ # ],
30
+ # bbox: { x_min: 100, y_min: 0, x_max: 300, y_max: 700 }
31
+ # )
32
+ #
33
+ # @example Converting to SVG
34
+ # svg_path = outline.to_svg_path
35
+ # # => "M 100 0 L 200 700 L 300 0 Z"
36
+ #
37
+ # @example Getting drawing commands
38
+ # commands = outline.to_commands
39
+ # # => [[:move_to, 100, 0], [:line_to, 200, 700], [:line_to, 300, 0], [:close_path]]
40
+ #
41
+ # Reference: [`docs/GETTING_STARTED.md:66-121`](docs/GETTING_STARTED.md:66)
42
+ class GlyphOutline
43
+ # @return [Integer] The glyph identifier
44
+ attr_reader :glyph_id
45
+
46
+ # @return [Array<Array<Hash>>] Array of contours, each containing points
47
+ # Each point hash has keys: :x, :y, :on_curve
48
+ attr_reader :contours
49
+
50
+ # @return [Array<Hash>] All points from all contours (flattened)
51
+ attr_reader :points
52
+
53
+ # @return [Hash] Bounding box with keys: :x_min, :y_min, :x_max, :y_max
54
+ attr_reader :bbox
55
+
56
+ # Initialize a new glyph outline
57
+ #
58
+ # @param glyph_id [Integer] The glyph identifier
59
+ # @param contours [Array<Array<Hash>>] Array of contours, each containing points
60
+ # Each point must have :x, :y, and :on_curve keys
61
+ # @param bbox [Hash] Bounding box with :x_min, :y_min, :x_max, :y_max keys
62
+ # @raise [ArgumentError] If required parameters are missing or invalid
63
+ def initialize(glyph_id:, contours:, bbox:)
64
+ validate_parameters!(glyph_id, contours, bbox)
65
+
66
+ @glyph_id = glyph_id.freeze
67
+ @contours = deep_freeze(contours)
68
+ @points = extract_all_points(contours).freeze
69
+ @bbox = bbox.freeze
70
+ end
71
+
72
+ # Convert outline to SVG path data
73
+ #
74
+ # Generates SVG path commands from the outline contours. Each contour
75
+ # becomes a closed path, with move_to for the first point, line_to or
76
+ # curve_to for subsequent points, and an explicit close path.
77
+ #
78
+ # @return [String] SVG path commands (e.g., "M 100 0 L 200 700 Z")
79
+ def to_svg_path
80
+ return "" if empty?
81
+
82
+ path_parts = contours.map do |contour|
83
+ build_contour_path(contour)
84
+ end
85
+
86
+ path_parts.join(" ")
87
+ end
88
+
89
+ # Convert to drawing commands
90
+ #
91
+ # Returns an array of drawing command arrays that can be used to render
92
+ # the glyph. Each command is an array with the command type as the first
93
+ # element and coordinates as subsequent elements.
94
+ #
95
+ # Command types:
96
+ # - :move_to - Move to a point without drawing
97
+ # - :line_to - Draw a straight line to a point
98
+ # - :curve_to - Draw a quadratic Bézier curve (TrueType) or cubic curve (CFF)
99
+ # - :close_path - Close the current path
100
+ #
101
+ # @return [Array<Array>] Array of [command, *args] arrays
102
+ #
103
+ # @example
104
+ # commands = outline.to_commands
105
+ # # => [
106
+ # # [:move_to, 100, 0],
107
+ # # [:line_to, 200, 700],
108
+ # # [:line_to, 300, 0],
109
+ # # [:close_path]
110
+ # # ]
111
+ def to_commands
112
+ return [] if empty?
113
+
114
+ commands = []
115
+ contours.each do |contour|
116
+ commands.concat(build_contour_commands(contour))
117
+ end
118
+ commands
119
+ end
120
+
121
+ # Check if outline is empty (e.g., space glyph)
122
+ #
123
+ # @return [Boolean] True if the glyph has no contours
124
+ def empty?
125
+ contours.empty?
126
+ end
127
+
128
+ # Number of points in outline
129
+ #
130
+ # @return [Integer] Total number of points across all contours
131
+ def point_count
132
+ points.length
133
+ end
134
+
135
+ # Number of contours in outline
136
+ #
137
+ # @return [Integer] Number of contours
138
+ def contour_count
139
+ contours.length
140
+ end
141
+
142
+ # String representation for debugging
143
+ #
144
+ # @return [String] Human-readable representation
145
+ def to_s
146
+ "#<#{self.class.name} glyph_id=#{glyph_id} " \
147
+ "contours=#{contour_count} points=#{point_count} " \
148
+ "bbox=#{bbox.inspect}>"
149
+ end
150
+
151
+ alias inspect to_s
152
+
153
+ private
154
+
155
+ # Validate initialization parameters
156
+ #
157
+ # @param glyph_id [Integer] Glyph ID to validate
158
+ # @param contours [Array] Contours to validate
159
+ # @param bbox [Hash] Bounding box to validate
160
+ # @raise [ArgumentError] If validation fails
161
+ def validate_parameters!(glyph_id, contours, bbox)
162
+ if glyph_id.nil? || !glyph_id.is_a?(Integer) || glyph_id.negative?
163
+ raise ArgumentError,
164
+ "glyph_id must be a non-negative Integer, got: #{glyph_id.inspect}"
165
+ end
166
+
167
+ unless contours.is_a?(Array)
168
+ raise ArgumentError,
169
+ "contours must be an Array, got: #{contours.class}"
170
+ end
171
+
172
+ unless bbox.is_a?(Hash)
173
+ raise ArgumentError,
174
+ "bbox must be a Hash, got: #{bbox.class}"
175
+ end
176
+
177
+ required_bbox_keys = %i[x_min y_min x_max y_max]
178
+ missing_keys = required_bbox_keys - bbox.keys
179
+ unless missing_keys.empty?
180
+ raise ArgumentError,
181
+ "bbox missing required keys: #{missing_keys.join(', ')}"
182
+ end
183
+
184
+ # Validate contours structure
185
+ contours.each_with_index do |contour, i|
186
+ unless contour.is_a?(Array)
187
+ raise ArgumentError,
188
+ "contour #{i} must be an Array, got: #{contour.class}"
189
+ end
190
+
191
+ contour.each_with_index do |point, j|
192
+ unless point.is_a?(Hash)
193
+ raise ArgumentError,
194
+ "point #{j} in contour #{i} must be a Hash, got: #{point.class}"
195
+ end
196
+
197
+ required_point_keys = %i[x y on_curve]
198
+ missing_keys = required_point_keys - point.keys
199
+ unless missing_keys.empty?
200
+ raise ArgumentError,
201
+ "point #{j} in contour #{i} missing keys: #{missing_keys.join(', ')}"
202
+ end
203
+ end
204
+ end
205
+ end
206
+
207
+ # Extract all points from contours into a flat array
208
+ #
209
+ # @param contours [Array<Array<Hash>>] Array of contours
210
+ # @return [Array<Hash>] Flattened array of all points
211
+ def extract_all_points(contours)
212
+ contours.flatten(1)
213
+ end
214
+
215
+ # Deep freeze nested arrays and hashes for immutability
216
+ #
217
+ # @param obj [Array, Hash, Object] Object to freeze
218
+ # @return [Object] Frozen object
219
+ def deep_freeze(obj)
220
+ case obj
221
+ when Array
222
+ obj.map { |item| deep_freeze(item) }.freeze
223
+ when Hash
224
+ obj.transform_values { |value| deep_freeze(value) }.freeze
225
+ else
226
+ obj.freeze
227
+ end
228
+ end
229
+
230
+ # Build SVG path commands for a contour
231
+ #
232
+ # @param contour [Array<Hash>] Array of point hashes
233
+ # @return [String] SVG path string for this contour
234
+ def build_contour_path(contour)
235
+ return "" if contour.empty?
236
+
237
+ parts = []
238
+ i = 0
239
+
240
+ # Move to first point
241
+ first = contour[i]
242
+ parts << "M #{first[:x]} #{first[:y]}"
243
+ i += 1
244
+
245
+ # Process remaining points
246
+ while i < contour.length
247
+ point = contour[i]
248
+
249
+ if point[:on_curve]
250
+ # Line to on-curve point
251
+ parts << "L #{point[:x]} #{point[:y]}"
252
+ i += 1
253
+ else
254
+ # Off-curve point - need to handle quadratic curves
255
+ # In TrueType, off-curve points are control points for quadratic Bézier curves
256
+ # If we have consecutive off-curve points, there's an implied on-curve point
257
+ # between them at their midpoint
258
+
259
+ control = point
260
+ i += 1
261
+
262
+ if i < contour.length && !contour[i][:on_curve]
263
+ # Two consecutive off-curve points
264
+ # Implied on-curve point at midpoint
265
+ next_control = contour[i]
266
+ implied_x = (control[:x] + next_control[:x]) / 2.0
267
+ implied_y = (control[:y] + next_control[:y]) / 2.0
268
+ parts << "Q #{control[:x]} #{control[:y]} #{implied_x} #{implied_y}"
269
+ elsif i < contour.length
270
+ # Next point is on-curve - end of quadratic curve
271
+ end_point = contour[i]
272
+ parts << "Q #{control[:x]} #{control[:y]} #{end_point[:x]} #{end_point[:y]}"
273
+ i += 1
274
+ else
275
+ # Off-curve point is last - curves back to first point
276
+ parts << "Q #{control[:x]} #{control[:y]} #{first[:x]} #{first[:y]}"
277
+ end
278
+ end
279
+ end
280
+
281
+ # Close path
282
+ parts << "Z"
283
+
284
+ parts.join(" ")
285
+ end
286
+
287
+ # Build drawing commands for a contour
288
+ #
289
+ # @param contour [Array<Hash>] Array of point hashes
290
+ # @return [Array<Array>] Array of command arrays
291
+ def build_contour_commands(contour)
292
+ return [] if contour.empty?
293
+
294
+ commands = []
295
+ i = 0
296
+
297
+ # Move to first point
298
+ first = contour[i]
299
+ commands << [:move_to, first[:x], first[:y]]
300
+ i += 1
301
+
302
+ # Process remaining points
303
+ while i < contour.length
304
+ point = contour[i]
305
+
306
+ if point[:on_curve]
307
+ # Line to on-curve point
308
+ commands << [:line_to, point[:x], point[:y]]
309
+ i += 1
310
+ else
311
+ # Off-curve point - quadratic curve control point
312
+ control = point
313
+ i += 1
314
+
315
+ if i < contour.length && !contour[i][:on_curve]
316
+ # Two consecutive off-curve points
317
+ next_control = contour[i]
318
+ implied_x = (control[:x] + next_control[:x]) / 2.0
319
+ implied_y = (control[:y] + next_control[:y]) / 2.0
320
+ commands << [:curve_to, control[:x], control[:y], implied_x,
321
+ implied_y]
322
+ elsif i < contour.length
323
+ # Next point is on-curve
324
+ end_point = contour[i]
325
+ commands << [:curve_to, control[:x], control[:y], end_point[:x],
326
+ end_point[:y]]
327
+ i += 1
328
+ else
329
+ # Curves back to first point
330
+ commands << [:curve_to, control[:x], control[:y], first[:x],
331
+ first[:y]]
332
+ end
333
+ end
334
+ end
335
+
336
+ # Close path
337
+ commands << [:close_path]
338
+
339
+ commands
340
+ end
341
+ end
342
+ end
343
+ end