fontisan 0.1.0 → 0.2.0

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 (185) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +529 -65
  3. data/Gemfile +1 -0
  4. data/LICENSE +5 -1
  5. data/README.adoc +1301 -275
  6. data/Rakefile +27 -2
  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 +309 -0
  12. data/lib/fontisan/collection/builder.rb +260 -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 +241 -0
  16. data/lib/fontisan/collection/writer.rb +306 -0
  17. data/lib/fontisan/commands/base_command.rb +8 -1
  18. data/lib/fontisan/commands/convert_command.rb +291 -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 +295 -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 +178 -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 +69 -0
  37. data/lib/fontisan/converters/conversion_strategy.rb +96 -0
  38. data/lib/fontisan/converters/format_converter.rb +259 -0
  39. data/lib/fontisan/converters/outline_converter.rb +936 -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 +121 -12
  57. data/lib/fontisan/font_writer.rb +301 -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 +177 -0
  61. data/lib/fontisan/hints/postscript_hint_applier.rb +185 -0
  62. data/lib/fontisan/hints/postscript_hint_extractor.rb +254 -0
  63. data/lib/fontisan/hints/truetype_hint_applier.rb +71 -0
  64. data/lib/fontisan/hints/truetype_hint_extractor.rb +162 -0
  65. data/lib/fontisan/loading_modes.rb +113 -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 +233 -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 +296 -10
  88. data/lib/fontisan/optimizers/charstring_rewriter.rb +161 -0
  89. data/lib/fontisan/optimizers/pattern_analyzer.rb +308 -0
  90. data/lib/fontisan/optimizers/stack_tracker.rb +246 -0
  91. data/lib/fontisan/optimizers/subroutine_builder.rb +134 -0
  92. data/lib/fontisan/optimizers/subroutine_generator.rb +207 -0
  93. data/lib/fontisan/optimizers/subroutine_optimizer.rb +107 -0
  94. data/lib/fontisan/outline_extractor.rb +423 -0
  95. data/lib/fontisan/subset/builder.rb +268 -0
  96. data/lib/fontisan/subset/glyph_mapping.rb +215 -0
  97. data/lib/fontisan/subset/options.rb +142 -0
  98. data/lib/fontisan/subset/profile.rb +152 -0
  99. data/lib/fontisan/subset/table_subsetter.rb +461 -0
  100. data/lib/fontisan/svg/font_face_generator.rb +278 -0
  101. data/lib/fontisan/svg/font_generator.rb +264 -0
  102. data/lib/fontisan/svg/glyph_generator.rb +168 -0
  103. data/lib/fontisan/svg/view_box_calculator.rb +137 -0
  104. data/lib/fontisan/tables/cff/cff_glyph.rb +176 -0
  105. data/lib/fontisan/tables/cff/charset.rb +282 -0
  106. data/lib/fontisan/tables/cff/charstring.rb +905 -0
  107. data/lib/fontisan/tables/cff/charstring_builder.rb +322 -0
  108. data/lib/fontisan/tables/cff/charstrings_index.rb +162 -0
  109. data/lib/fontisan/tables/cff/dict.rb +351 -0
  110. data/lib/fontisan/tables/cff/dict_builder.rb +242 -0
  111. data/lib/fontisan/tables/cff/encoding.rb +274 -0
  112. data/lib/fontisan/tables/cff/header.rb +102 -0
  113. data/lib/fontisan/tables/cff/index.rb +237 -0
  114. data/lib/fontisan/tables/cff/index_builder.rb +170 -0
  115. data/lib/fontisan/tables/cff/private_dict.rb +284 -0
  116. data/lib/fontisan/tables/cff/top_dict.rb +236 -0
  117. data/lib/fontisan/tables/cff.rb +487 -0
  118. data/lib/fontisan/tables/cff2/blend_operator.rb +240 -0
  119. data/lib/fontisan/tables/cff2/charstring_parser.rb +591 -0
  120. data/lib/fontisan/tables/cff2/operand_stack.rb +232 -0
  121. data/lib/fontisan/tables/cff2.rb +341 -0
  122. data/lib/fontisan/tables/cvar.rb +242 -0
  123. data/lib/fontisan/tables/fvar.rb +2 -2
  124. data/lib/fontisan/tables/glyf/compound_glyph.rb +483 -0
  125. data/lib/fontisan/tables/glyf/compound_glyph_resolver.rb +136 -0
  126. data/lib/fontisan/tables/glyf/curve_converter.rb +343 -0
  127. data/lib/fontisan/tables/glyf/glyph_builder.rb +450 -0
  128. data/lib/fontisan/tables/glyf/simple_glyph.rb +382 -0
  129. data/lib/fontisan/tables/glyf.rb +235 -0
  130. data/lib/fontisan/tables/gvar.rb +270 -0
  131. data/lib/fontisan/tables/hhea.rb +124 -0
  132. data/lib/fontisan/tables/hmtx.rb +287 -0
  133. data/lib/fontisan/tables/hvar.rb +191 -0
  134. data/lib/fontisan/tables/loca.rb +322 -0
  135. data/lib/fontisan/tables/maxp.rb +192 -0
  136. data/lib/fontisan/tables/mvar.rb +185 -0
  137. data/lib/fontisan/tables/name.rb +99 -30
  138. data/lib/fontisan/tables/variation_common.rb +346 -0
  139. data/lib/fontisan/tables/vvar.rb +234 -0
  140. data/lib/fontisan/true_type_collection.rb +156 -2
  141. data/lib/fontisan/true_type_font.rb +297 -11
  142. data/lib/fontisan/utilities/brotli_wrapper.rb +159 -0
  143. data/lib/fontisan/utilities/checksum_calculator.rb +18 -0
  144. data/lib/fontisan/utils/thread_pool.rb +134 -0
  145. data/lib/fontisan/validation/checksum_validator.rb +170 -0
  146. data/lib/fontisan/validation/consistency_validator.rb +197 -0
  147. data/lib/fontisan/validation/structure_validator.rb +198 -0
  148. data/lib/fontisan/validation/table_validator.rb +158 -0
  149. data/lib/fontisan/validation/validator.rb +152 -0
  150. data/lib/fontisan/variable/axis_normalizer.rb +215 -0
  151. data/lib/fontisan/variable/delta_applicator.rb +313 -0
  152. data/lib/fontisan/variable/glyph_delta_processor.rb +218 -0
  153. data/lib/fontisan/variable/instancer.rb +344 -0
  154. data/lib/fontisan/variable/metric_delta_processor.rb +282 -0
  155. data/lib/fontisan/variable/region_matcher.rb +208 -0
  156. data/lib/fontisan/variable/static_font_builder.rb +213 -0
  157. data/lib/fontisan/variable/table_updater.rb +219 -0
  158. data/lib/fontisan/variation/blend_applier.rb +199 -0
  159. data/lib/fontisan/variation/cache.rb +298 -0
  160. data/lib/fontisan/variation/cache_key_builder.rb +162 -0
  161. data/lib/fontisan/variation/converter.rb +268 -0
  162. data/lib/fontisan/variation/data_extractor.rb +86 -0
  163. data/lib/fontisan/variation/delta_applier.rb +266 -0
  164. data/lib/fontisan/variation/delta_parser.rb +228 -0
  165. data/lib/fontisan/variation/inspector.rb +275 -0
  166. data/lib/fontisan/variation/instance_generator.rb +273 -0
  167. data/lib/fontisan/variation/interpolator.rb +231 -0
  168. data/lib/fontisan/variation/metrics_adjuster.rb +318 -0
  169. data/lib/fontisan/variation/optimizer.rb +418 -0
  170. data/lib/fontisan/variation/parallel_generator.rb +150 -0
  171. data/lib/fontisan/variation/region_matcher.rb +221 -0
  172. data/lib/fontisan/variation/subsetter.rb +463 -0
  173. data/lib/fontisan/variation/table_accessor.rb +105 -0
  174. data/lib/fontisan/variation/validator.rb +345 -0
  175. data/lib/fontisan/variation/variation_context.rb +211 -0
  176. data/lib/fontisan/version.rb +1 -1
  177. data/lib/fontisan/woff2/directory.rb +257 -0
  178. data/lib/fontisan/woff2/header.rb +101 -0
  179. data/lib/fontisan/woff2/table_transformer.rb +163 -0
  180. data/lib/fontisan/woff2_font.rb +712 -0
  181. data/lib/fontisan/woff_font.rb +483 -0
  182. data/lib/fontisan.rb +120 -0
  183. data/scripts/compare_stack_aware.rb +187 -0
  184. data/scripts/measure_optimization.rb +141 -0
  185. metadata +205 -4
@@ -0,0 +1,664 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Models
5
+ # Universal outline representation for format-agnostic glyph outlines
6
+ #
7
+ # [`Outline`](lib/fontisan/models/outline.rb) provides a format-independent
8
+ # representation of glyph outlines that can be converted to/from both
9
+ # TrueType (quadratic) and CFF (cubic) formats. This enables bidirectional
10
+ # TTF ↔ OTF conversion.
11
+ #
12
+ # The outline stores paths as a sequence of drawing commands:
13
+ # - **move_to**: Start a new contour at (x, y)
14
+ # - **line_to**: Draw a line to (x, y)
15
+ # - **quad_to**: Quadratic Bézier curve with control point (cx, cy) to (x, y)
16
+ # - **curve_to**: Cubic Bézier curve with control points (cx1, cy1), (cx2, cy2) to (x, y)
17
+ # - **close_path**: Close the current contour
18
+ #
19
+ # This command-based representation:
20
+ # - Is format-agnostic (works for both TrueType and CFF)
21
+ # - Preserves curve type information
22
+ # - Makes conversion logic clear and testable
23
+ # - Enables easy validation and manipulation
24
+ #
25
+ # @example Creating an outline from commands
26
+ # outline = Fontisan::Models::Outline.new(
27
+ # glyph_id: 65,
28
+ # commands: [
29
+ # { type: :move_to, x: 100, y: 0 },
30
+ # { type: :line_to, x: 200, y: 700 },
31
+ # { type: :line_to, x: 300, y: 0 },
32
+ # { type: :close_path }
33
+ # ],
34
+ # bbox: { x_min: 100, y_min: 0, x_max: 300, y_max: 700 },
35
+ # width: 400
36
+ # )
37
+ #
38
+ # @example Converting from TrueType
39
+ # outline = Fontisan::Models::Outline.from_truetype(glyph, glyph_id)
40
+ #
41
+ # @example Converting from CFF
42
+ # outline = Fontisan::Models::Outline.from_cff(charstring, glyph_id)
43
+ class Outline
44
+ # @return [Integer] Glyph identifier
45
+ attr_reader :glyph_id
46
+
47
+ # @return [Array<Hash>] Array of drawing commands
48
+ # Each command is a hash with :type and coordinate keys
49
+ attr_reader :commands
50
+
51
+ # @return [Hash] Bounding box {:x_min, :y_min, :x_max, :y_max}
52
+ attr_reader :bbox
53
+
54
+ # @return [Integer, nil] Advance width (optional)
55
+ attr_reader :width
56
+
57
+ # Initialize a new universal outline
58
+ #
59
+ # @param glyph_id [Integer] Glyph identifier
60
+ # @param commands [Array<Hash>] Drawing commands
61
+ # @param bbox [Hash] Bounding box
62
+ # @param width [Integer, nil] Advance width (optional)
63
+ # @raise [ArgumentError] If parameters are invalid
64
+ def initialize(glyph_id:, commands:, bbox:, width: nil)
65
+ validate_parameters!(glyph_id, commands, bbox)
66
+
67
+ @glyph_id = glyph_id
68
+ @commands = commands.freeze
69
+ @bbox = bbox.freeze
70
+ @width = width
71
+ end
72
+
73
+ # Create outline from TrueType glyph
74
+ #
75
+ # TrueType glyphs use quadratic Bézier curves. This method extracts
76
+ # the contours and converts them to our universal command format.
77
+ #
78
+ # @param glyph [SimpleGlyph, CompoundGlyph] TrueType glyph object
79
+ # @param glyph_id [Integer] Glyph identifier
80
+ # @return [Outline] Universal outline instance
81
+ # @raise [ArgumentError] If glyph is invalid
82
+ def self.from_truetype(glyph, glyph_id)
83
+ raise ArgumentError, "glyph cannot be nil" if glyph.nil?
84
+ raise ArgumentError, "glyph must be simple glyph" unless glyph.simple?
85
+
86
+ commands = []
87
+ bbox = {
88
+ x_min: glyph.x_min,
89
+ y_min: glyph.y_min,
90
+ x_max: glyph.x_max,
91
+ y_max: glyph.y_max,
92
+ }
93
+
94
+ # Process each contour
95
+ glyph.num_contours.times do |contour_index|
96
+ points = glyph.points_for_contour(contour_index)
97
+ next if points.nil? || points.empty?
98
+
99
+ contour_commands = convert_truetype_contour_to_commands(points)
100
+ commands.concat(contour_commands)
101
+ end
102
+
103
+ new(
104
+ glyph_id: glyph_id,
105
+ commands: commands,
106
+ bbox: bbox,
107
+ )
108
+ end
109
+
110
+ # Create outline from CFF CharString
111
+ #
112
+ # CFF uses cubic Bézier curves. This method executes the CharString
113
+ # and converts the path to our universal command format.
114
+ #
115
+ # @param charstring [CharString] CFF CharString object
116
+ # @param glyph_id [Integer] Glyph identifier
117
+ # @return [Outline] Universal outline instance
118
+ # @raise [ArgumentError] If charstring is invalid
119
+ def self.from_cff(charstring, glyph_id)
120
+ raise ArgumentError, "charstring cannot be nil" if charstring.nil?
121
+
122
+ # Get path from CharString
123
+ path = charstring.path
124
+ raise ArgumentError, "CharString has no path data" if path.nil? || path.empty?
125
+
126
+ commands = convert_cff_path_to_commands(path)
127
+
128
+ # Get bounding box
129
+ bbox_array = charstring.bounding_box
130
+ raise ArgumentError, "CharString has no bounding box" unless bbox_array
131
+
132
+ bbox = {
133
+ x_min: bbox_array[0],
134
+ y_min: bbox_array[1],
135
+ x_max: bbox_array[2],
136
+ y_max: bbox_array[3],
137
+ }
138
+
139
+ new(
140
+ glyph_id: glyph_id,
141
+ commands: commands,
142
+ bbox: bbox,
143
+ )
144
+ end
145
+
146
+ # Convert to TrueType contour format
147
+ #
148
+ # Converts universal commands to TrueType contour format with
149
+ # quadratic curves. Cubic curves are approximated as quadratics.
150
+ #
151
+ # @return [Array<Array<Hash>>] Array of contours
152
+ def to_truetype_contours
153
+ contours = []
154
+ current_contour = []
155
+
156
+ commands.each do |cmd|
157
+ case cmd[:type]
158
+ when :move_to
159
+ # Start new contour
160
+ contours << current_contour unless current_contour.empty?
161
+ current_contour = []
162
+ current_contour << {
163
+ x: cmd[:x].round,
164
+ y: cmd[:y].round,
165
+ on_curve: true,
166
+ }
167
+ when :line_to
168
+ current_contour << {
169
+ x: cmd[:x].round,
170
+ y: cmd[:y].round,
171
+ on_curve: true,
172
+ }
173
+ when :quad_to
174
+ # Quadratic curve - add control point and end point
175
+ current_contour << {
176
+ x: cmd[:cx].round,
177
+ y: cmd[:cy].round,
178
+ on_curve: false,
179
+ }
180
+ current_contour << {
181
+ x: cmd[:x].round,
182
+ y: cmd[:y].round,
183
+ on_curve: true,
184
+ }
185
+ when :curve_to
186
+ # Cubic curve - approximate as quadratic
187
+ # Convert cubic Bézier to quadratic (may need multiple segments)
188
+ # For now, use simple midpoint approximation
189
+ control_x = ((cmd[:cx1] + cmd[:cx2]) / 2.0).round
190
+ control_y = ((cmd[:cy1] + cmd[:cy2]) / 2.0).round
191
+
192
+ current_contour << {
193
+ x: control_x,
194
+ y: control_y,
195
+ on_curve: false,
196
+ }
197
+ current_contour << {
198
+ x: cmd[:x].round,
199
+ y: cmd[:y].round,
200
+ on_curve: true,
201
+ }
202
+ when :close_path
203
+ # Close contour
204
+ contours << current_contour unless current_contour.empty?
205
+ current_contour = []
206
+ end
207
+ end
208
+
209
+ # Add final contour if not closed
210
+ contours << current_contour unless current_contour.empty?
211
+
212
+ contours
213
+ end
214
+
215
+ # Convert to CFF drawing commands
216
+ #
217
+ # Converts universal commands to CFF CharString format with
218
+ # cubic curves. Quadratic curves are elevation to cubic (exact).
219
+ #
220
+ # @return [Array<Hash>] Array of CFF command hashes
221
+ def to_cff_commands
222
+ cff_commands = []
223
+
224
+ commands.each do |cmd|
225
+ case cmd[:type]
226
+ when :move_to
227
+ cff_commands << {
228
+ type: :move_to,
229
+ x: cmd[:x].round,
230
+ y: cmd[:y].round,
231
+ }
232
+ when :line_to
233
+ cff_commands << {
234
+ type: :line_to,
235
+ x: cmd[:x].round,
236
+ y: cmd[:y].round,
237
+ }
238
+ when :quad_to
239
+ # Quadratic to cubic (degree elevation - exact conversion)
240
+ # For quadratic: P0 (current), P1 (control), P2 (end)
241
+ # Cubic control points: CP1 = P0 + 2/3*(P1 - P0), CP2 = P2 + 2/3*(P1 - P2)
242
+ # We need the previous point (P0)
243
+ prev = find_previous_point(cff_commands)
244
+
245
+ cx1 = (prev[:x] + (2.0 / 3.0) * (cmd[:cx] - prev[:x])).round
246
+ cy1 = (prev[:y] + (2.0 / 3.0) * (cmd[:cy] - prev[:y])).round
247
+
248
+ cx2 = (cmd[:x] + (2.0 / 3.0) * (cmd[:cx] - cmd[:x])).round
249
+ cy2 = (cmd[:y] + (2.0 / 3.0) * (cmd[:cy] - cmd[:y])).round
250
+
251
+ cff_commands << {
252
+ type: :curve_to,
253
+ x1: cx1,
254
+ y1: cy1,
255
+ x2: cx2,
256
+ y2: cy2,
257
+ x: cmd[:x].round,
258
+ y: cmd[:y].round,
259
+ }
260
+ when :curve_to
261
+ # Already cubic - direct mapping
262
+ cff_commands << {
263
+ type: :curve_to,
264
+ x1: cmd[:cx1].round,
265
+ y1: cmd[:cy1].round,
266
+ x2: cmd[:cx2].round,
267
+ y2: cmd[:cy2].round,
268
+ x: cmd[:x].round,
269
+ y: cmd[:y].round,
270
+ }
271
+ when :close_path
272
+ # CFF doesn't have explicit close - handled by move_to
273
+ end
274
+ end
275
+
276
+ cff_commands
277
+ end
278
+
279
+ # Check if outline is empty
280
+ #
281
+ # @return [Boolean] True if no drawing commands
282
+ def empty?
283
+ commands.empty? || commands.all? { |cmd| cmd[:type] == :close_path }
284
+ end
285
+
286
+ # Get number of commands
287
+ #
288
+ # @return [Integer] Number of commands
289
+ def command_count
290
+ commands.length
291
+ end
292
+
293
+ # Get number of contours
294
+ #
295
+ # @return [Integer] Number of contours
296
+ def contour_count
297
+ commands.count { |cmd| cmd[:type] == :move_to }
298
+ end
299
+
300
+ # String representation
301
+ #
302
+ # @return [String] Human-readable representation
303
+ def to_s
304
+ "#<#{self.class.name} glyph_id=#{glyph_id} " \
305
+ "commands=#{command_count} contours=#{contour_count} " \
306
+ "bbox=#{bbox.inspect}>"
307
+ end
308
+
309
+ alias inspect to_s
310
+
311
+ # Apply affine transformation to outline
312
+ #
313
+ # Applies a 2x3 affine transformation matrix to all points in the outline.
314
+ # The matrix is in the format [a, b, c, d, e, f] representing:
315
+ # x' = a*x + c*y + e
316
+ # y' = b*x + d*y + f
317
+ #
318
+ # @param matrix [Array<Float>] Transformation matrix [a, b, c, d, e, f]
319
+ # @return [Outline] New outline with transformed commands
320
+ def transform(matrix)
321
+ a, b, c, d, e, f = matrix
322
+
323
+ # Transform all commands
324
+ transformed_commands = commands.map do |cmd|
325
+ case cmd[:type]
326
+ when :move_to, :line_to
327
+ {
328
+ type: cmd[:type],
329
+ x: (a * cmd[:x] + c * cmd[:y] + e),
330
+ y: (b * cmd[:x] + d * cmd[:y] + f),
331
+ }
332
+ when :quad_to
333
+ {
334
+ type: :quad_to,
335
+ cx: (a * cmd[:cx] + c * cmd[:cy] + e),
336
+ cy: (b * cmd[:cx] + d * cmd[:cy] + f),
337
+ x: (a * cmd[:x] + c * cmd[:y] + e),
338
+ y: (b * cmd[:x] + d * cmd[:y] + f),
339
+ }
340
+ when :curve_to
341
+ {
342
+ type: :curve_to,
343
+ cx1: (a * cmd[:cx1] + c * cmd[:cy1] + e),
344
+ cy1: (b * cmd[:cx1] + d * cmd[:cy1] + f),
345
+ cx2: (a * cmd[:cx2] + c * cmd[:cy2] + e),
346
+ cy2: (b * cmd[:cx2] + d * cmd[:cy2] + f),
347
+ x: (a * cmd[:x] + c * cmd[:y] + e),
348
+ y: (b * cmd[:x] + d * cmd[:y] + f),
349
+ }
350
+ when :close_path
351
+ cmd
352
+ else
353
+ cmd
354
+ end
355
+ end
356
+
357
+ # Calculate transformed bounding box
358
+ # Apply transformation to all four corners
359
+ corners = [
360
+ [bbox[:x_min], bbox[:y_min]],
361
+ [bbox[:x_max], bbox[:y_min]],
362
+ [bbox[:x_min], bbox[:y_max]],
363
+ [bbox[:x_max], bbox[:y_max]],
364
+ ].map do |x, y|
365
+ [a * x + c * y + e, b * x + d * y + f]
366
+ end
367
+
368
+ x_coords = corners.map(&:first)
369
+ y_coords = corners.map(&:last)
370
+
371
+ transformed_bbox = {
372
+ x_min: x_coords.min.round,
373
+ y_min: y_coords.min.round,
374
+ x_max: x_coords.max.round,
375
+ y_max: y_coords.max.round,
376
+ }
377
+
378
+ Outline.new(
379
+ glyph_id: glyph_id,
380
+ commands: transformed_commands,
381
+ bbox: transformed_bbox,
382
+ width: width,
383
+ )
384
+ end
385
+
386
+ # Merge another outline into this one
387
+ #
388
+ # Combines the commands from another outline with this one,
389
+ # creating a composite outline. The bounding box is recalculated
390
+ # to encompass both outlines.
391
+ #
392
+ # @param other [Outline] Outline to merge
393
+ # @return [void]
394
+ def merge!(other)
395
+ return if other.empty?
396
+
397
+ # Merge commands (skip close_path before adding new contours)
398
+ merged_commands = commands.dup
399
+ merged_commands.pop if merged_commands.last && merged_commands.last[:type] == :close_path
400
+
401
+ # Add other's commands
402
+ merged_commands.concat(other.commands)
403
+
404
+ # Recalculate bounding box
405
+ merged_bbox = {
406
+ x_min: [bbox[:x_min], other.bbox[:x_min]].min,
407
+ y_min: [bbox[:y_min], other.bbox[:y_min]].min,
408
+ x_max: [bbox[:x_max], other.bbox[:x_max]].max,
409
+ y_max: [bbox[:y_max], other.bbox[:y_max]].max,
410
+ }
411
+
412
+ # Update instance variables
413
+ @commands = merged_commands.freeze
414
+ @bbox = merged_bbox.freeze
415
+ end
416
+
417
+ private
418
+
419
+ # Validate initialization parameters
420
+ #
421
+ # @param glyph_id [Integer] Glyph ID
422
+ # @param commands [Array] Commands array
423
+ # @param bbox [Hash] Bounding box
424
+ # @raise [ArgumentError] If validation fails
425
+ def validate_parameters!(glyph_id, commands, bbox)
426
+ if glyph_id.nil? || !glyph_id.is_a?(Integer) || glyph_id.negative?
427
+ raise ArgumentError,
428
+ "glyph_id must be non-negative Integer, got: #{glyph_id.inspect}"
429
+ end
430
+
431
+ unless commands.is_a?(Array)
432
+ raise ArgumentError,
433
+ "commands must be Array, got: #{commands.class}"
434
+ end
435
+
436
+ unless bbox.is_a?(Hash)
437
+ raise ArgumentError,
438
+ "bbox must be Hash, got: #{bbox.class}"
439
+ end
440
+
441
+ required_keys = %i[x_min y_min x_max y_max]
442
+ missing_keys = required_keys - bbox.keys
443
+ unless missing_keys.empty?
444
+ raise ArgumentError,
445
+ "bbox missing keys: #{missing_keys.join(', ')}"
446
+ end
447
+
448
+ # Validate commands
449
+ commands.each_with_index do |cmd, i|
450
+ unless cmd.is_a?(Hash) && cmd.key?(:type)
451
+ raise ArgumentError,
452
+ "command #{i} must be Hash with :type key"
453
+ end
454
+
455
+ validate_command!(cmd, i)
456
+ end
457
+ end
458
+
459
+ # Validate individual command
460
+ #
461
+ # @param cmd [Hash] Command to validate
462
+ # @param index [Integer] Command index (for error messages)
463
+ # @raise [ArgumentError] If command is invalid
464
+ def validate_command!(cmd, index)
465
+ case cmd[:type]
466
+ when :move_to, :line_to
467
+ unless cmd.key?(:x) && cmd.key?(:y)
468
+ raise ArgumentError,
469
+ "command #{index} (#{cmd[:type]}) missing :x or :y"
470
+ end
471
+ when :quad_to
472
+ unless cmd.key?(:cx) && cmd.key?(:cy) && cmd.key?(:x) && cmd.key?(:y)
473
+ raise ArgumentError,
474
+ "command #{index} (quad_to) missing required keys"
475
+ end
476
+ when :curve_to
477
+ required = %i[cx1 cy1 cx2 cy2 x y]
478
+ missing = required - cmd.keys
479
+ unless missing.empty?
480
+ raise ArgumentError,
481
+ "command #{index} (curve_to) missing keys: #{missing.join(', ')}"
482
+ end
483
+ when :close_path
484
+ # No additional validation needed
485
+ else
486
+ raise ArgumentError,
487
+ "command #{index} has invalid type: #{cmd[:type]}"
488
+ end
489
+ end
490
+
491
+ # Convert TrueType contour points to commands
492
+ #
493
+ # @param points [Array<Hash>] Array of points with :x, :y, :on_curve
494
+ # @return [Array<Hash>] Array of commands
495
+ def self.convert_truetype_contour_to_commands(points)
496
+ return [] if points.empty?
497
+
498
+ commands = []
499
+ i = 0
500
+
501
+ # Move to first point
502
+ first = points[i]
503
+ commands << { type: :move_to, x: first[:x], y: first[:y] }
504
+ i += 1
505
+
506
+ # Process remaining points
507
+ while i < points.length
508
+ point = points[i]
509
+
510
+ if point[:on_curve]
511
+ # Line to on-curve point
512
+ commands << { type: :line_to, x: point[:x], y: point[:y] }
513
+ i += 1
514
+ else
515
+ # Off-curve point - quadratic curve control point
516
+ control = point
517
+ i += 1
518
+
519
+ if i < points.length && !points[i][:on_curve]
520
+ # Two consecutive off-curve points - implied on-curve at midpoint
521
+ next_control = points[i]
522
+ implied_x = (control[:x] + next_control[:x]) / 2.0
523
+ implied_y = (control[:y] + next_control[:y]) / 2.0
524
+
525
+ commands << {
526
+ type: :quad_to,
527
+ cx: control[:x],
528
+ cy: control[:y],
529
+ x: implied_x,
530
+ y: implied_y,
531
+ }
532
+ elsif i < points.length
533
+ # Next point is on-curve - end of quadratic curve
534
+ end_point = points[i]
535
+ commands << {
536
+ type: :quad_to,
537
+ cx: control[:x],
538
+ cy: control[:y],
539
+ x: end_point[:x],
540
+ y: end_point[:y],
541
+ }
542
+ i += 1
543
+ else
544
+ # Curves back to first point
545
+ commands << {
546
+ type: :quad_to,
547
+ cx: control[:x],
548
+ cy: control[:y],
549
+ x: first[:x],
550
+ y: first[:y],
551
+ }
552
+ end
553
+ end
554
+ end
555
+
556
+ # Close path
557
+ commands << { type: :close_path }
558
+
559
+ commands
560
+ end
561
+
562
+ # Convert CFF path to universal commands
563
+ #
564
+ # CFF doesn't have explicit closepath operators - contours are implicitly
565
+ # closed when a new moveto starts or at endchar. We add explicit
566
+ # close_path commands only when the contour is geometrically closed
567
+ # (last point equals first point), to preserve open contours from TTF.
568
+ #
569
+ # @param path [Array<Hash>] CFF path data
570
+ # @return [Array<Hash>] Universal commands
571
+ def self.convert_cff_path_to_commands(path)
572
+ commands = []
573
+ contour_start = nil # Track the start point of current contour
574
+
575
+ path.each_with_index do |cmd, _index|
576
+ case cmd[:type]
577
+ when :move_to
578
+ # Before starting new contour, close previous one if it was geometrically closed
579
+ if contour_start && !commands.empty? && commands.last[:type] != :close_path
580
+ # Check if last point equals start point (contour is closed)
581
+ last_cmd = commands.last
582
+ last_point = case last_cmd[:type]
583
+ when :line_to
584
+ { x: last_cmd[:x], y: last_cmd[:y] }
585
+ when :curve_to
586
+ { x: last_cmd[:x], y: last_cmd[:y] }
587
+ end
588
+
589
+ if last_point &&
590
+ (last_point[:x] - contour_start[:x]).abs <= 1 &&
591
+ (last_point[:y] - contour_start[:y]).abs <= 1
592
+ # Contour is geometrically closed
593
+ commands << { type: :close_path }
594
+ end
595
+ end
596
+
597
+ # Start new contour
598
+ contour_start = { x: cmd[:x].round, y: cmd[:y].round }
599
+ commands << {
600
+ type: :move_to,
601
+ x: cmd[:x].round,
602
+ y: cmd[:y].round,
603
+ }
604
+ when :line_to
605
+ commands << {
606
+ type: :line_to,
607
+ x: cmd[:x].round,
608
+ y: cmd[:y].round,
609
+ }
610
+ when :curve_to
611
+ # CFF cubic curve
612
+ commands << {
613
+ type: :curve_to,
614
+ cx1: cmd[:x1].round,
615
+ cy1: cmd[:y1].round,
616
+ cx2: cmd[:x2].round,
617
+ cy2: cmd[:y2].round,
618
+ x: cmd[:x].round,
619
+ y: cmd[:y].round,
620
+ }
621
+ end
622
+ end
623
+
624
+ # Close the final contour if it was geometrically closed
625
+ if contour_start && !commands.empty? && commands.last[:type] != :close_path
626
+ last_cmd = commands.last
627
+ last_point = case last_cmd[:type]
628
+ when :line_to
629
+ { x: last_cmd[:x], y: last_cmd[:y] }
630
+ when :curve_to
631
+ { x: last_cmd[:x], y: last_cmd[:y] }
632
+ end
633
+
634
+ if last_point &&
635
+ (last_point[:x] - contour_start[:x]).abs <= 1 &&
636
+ (last_point[:y] - contour_start[:y]).abs <= 1
637
+ # Contour is geometrically closed
638
+ commands << { type: :close_path }
639
+ end
640
+ end
641
+
642
+ commands
643
+ end
644
+
645
+ # Find previous point from commands
646
+ #
647
+ # @param commands [Array<Hash>] CFF commands
648
+ # @return [Hash] Previous point {:x, :y}
649
+ def find_previous_point(commands)
650
+ commands.reverse_each do |cmd|
651
+ case cmd[:type]
652
+ when :move_to, :line_to
653
+ return { x: cmd[:x], y: cmd[:y] }
654
+ when :curve_to
655
+ return { x: cmd[:x], y: cmd[:y] }
656
+ end
657
+ end
658
+
659
+ # Default to origin if no previous point found
660
+ { x: 0, y: 0 }
661
+ end
662
+ end
663
+ end
664
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lutaml/model"
4
+
5
+ module Fontisan
6
+ module Models
7
+ # Model for table sharing statistics
8
+ #
9
+ # Represents table deduplication information in a TTC/OTC collection.
10
+ # Shows which tables are shared between fonts.
11
+ #
12
+ # @example Creating table sharing info
13
+ # sharing = TableSharingInfo.new(
14
+ # shared_tables: 12,
15
+ # unique_tables: 48,
16
+ # sharing_percentage: 20.0,
17
+ # space_saved_bytes: 156300
18
+ # )
19
+ class TableSharingInfo < Lutaml::Model::Serializable
20
+ attribute :shared_tables, :integer
21
+ attribute :unique_tables, :integer
22
+ attribute :sharing_percentage, :float
23
+ attribute :space_saved_bytes, :integer
24
+
25
+ yaml do
26
+ map "shared_tables", to: :shared_tables
27
+ map "unique_tables", to: :unique_tables
28
+ map "sharing_percentage", to: :sharing_percentage
29
+ map "space_saved_bytes", to: :space_saved_bytes
30
+ end
31
+
32
+ json do
33
+ map "shared_tables", to: :shared_tables
34
+ map "unique_tables", to: :unique_tables
35
+ map "sharing_percentage", to: :sharing_percentage
36
+ map "space_saved_bytes", to: :space_saved_bytes
37
+ end
38
+ end
39
+ end
40
+ end