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,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lutaml/model"
4
+
5
+ module Fontisan
6
+ module Models
7
+ # FontExport represents complete font structure for export to YAML/JSON
8
+ #
9
+ # This model encapsulates the entire font structure including header
10
+ # information, all tables (parsed and binary), and metadata. It supports
11
+ # round-trip conversion: font → export → import → font.
12
+ #
13
+ # @example Exporting a font
14
+ # export = FontExport.new(source_file: "font.ttf")
15
+ # export.extract_from_font(font)
16
+ # yaml_output = export.to_yaml
17
+ #
18
+ # @example Importing from YAML
19
+ # export = FontExport.from_yaml(yaml_string)
20
+ # font = export.rebuild_font
21
+ class FontExport < Lutaml::Model::Serializable
22
+ # Metadata about the export
23
+ class Metadata < Lutaml::Model::Serializable
24
+ attribute :source_file, :string
25
+ attribute :export_date, :string
26
+ attribute :exporter_version, :string
27
+ attribute :font_format, :string
28
+
29
+ yaml do
30
+ map "source_file", to: :source_file
31
+ map "export_date", to: :export_date
32
+ map "exporter_version", to: :exporter_version
33
+ map "font_format", to: :font_format
34
+ end
35
+
36
+ json do
37
+ map "source_file", to: :source_file
38
+ map "export_date", to: :export_date
39
+ map "exporter_version", to: :exporter_version
40
+ map "font_format", to: :font_format
41
+ end
42
+ end
43
+
44
+ # Font header information
45
+ class Header < Lutaml::Model::Serializable
46
+ attribute :sfnt_version, :string
47
+ attribute :num_tables, :integer
48
+ attribute :search_range, :integer
49
+ attribute :entry_selector, :integer
50
+ attribute :range_shift, :integer
51
+
52
+ yaml do
53
+ map "sfnt_version", to: :sfnt_version
54
+ map "num_tables", to: :num_tables
55
+ map "search_range", to: :search_range
56
+ map "entry_selector", to: :entry_selector
57
+ map "range_shift", to: :range_shift
58
+ end
59
+
60
+ json do
61
+ map "sfnt_version", to: :sfnt_version
62
+ map "num_tables", to: :num_tables
63
+ map "search_range", to: :search_range
64
+ map "entry_selector", to: :entry_selector
65
+ map "range_shift", to: :range_shift
66
+ end
67
+ end
68
+
69
+ # Individual table export
70
+ class TableExport < Lutaml::Model::Serializable
71
+ attribute :tag, :string
72
+ attribute :checksum, :string
73
+ attribute :parsed, :boolean, default: -> { false }
74
+ attribute :data, :string, default: -> {}
75
+ attribute :fields, :string, default: -> {}
76
+
77
+ yaml do
78
+ map "tag", to: :tag
79
+ map "checksum", to: :checksum
80
+ map "parsed", to: :parsed
81
+ map "data", to: :data
82
+ map "fields", to: :fields
83
+ end
84
+
85
+ json do
86
+ map "tag", to: :tag
87
+ map "checksum", to: :checksum
88
+ map "parsed", to: :parsed
89
+ map "data", to: :data
90
+ map "fields", to: :fields
91
+ end
92
+ end
93
+
94
+ attribute :metadata, Metadata
95
+ attribute :header, Header
96
+ attribute :tables, TableExport, collection: true, default: -> { [] }
97
+
98
+ yaml do
99
+ map "metadata", to: :metadata
100
+ map "header", to: :header
101
+ map "tables", to: :tables
102
+ end
103
+
104
+ json do
105
+ map "metadata", to: :metadata
106
+ map "header", to: :header
107
+ map "tables", to: :tables
108
+ end
109
+
110
+ # Find a table by tag
111
+ #
112
+ # @param tag [String] The table tag (e.g., "head", "name")
113
+ # @return [TableExport, nil] The table or nil if not found
114
+ def find_table(tag)
115
+ tables.find { |t| t.tag == tag }
116
+ end
117
+
118
+ # Get all parsed tables
119
+ #
120
+ # @return [Array<TableExport>] Array of parsed tables
121
+ def parsed_tables
122
+ tables.select(&:parsed)
123
+ end
124
+
125
+ # Get all binary-only tables
126
+ #
127
+ # @return [Array<TableExport>] Array of binary tables
128
+ def binary_tables
129
+ tables.reject(&:parsed)
130
+ end
131
+
132
+ # Add a table to the export
133
+ #
134
+ # @param tag [String] Table tag
135
+ # @param checksum [String] Table checksum
136
+ # @param parsed [Boolean] Whether table is parsed
137
+ # @param data [String, nil] Binary data (hex/base64)
138
+ # @param fields [Hash, nil] Parsed fields as Hash/JSON
139
+ # @return [void]
140
+ def add_table(tag:, checksum:, parsed: false, data: nil, fields: nil)
141
+ tables << TableExport.new(
142
+ tag: tag,
143
+ checksum: checksum,
144
+ parsed: parsed,
145
+ data: data,
146
+ fields: fields,
147
+ )
148
+ end
149
+
150
+ # Validate export structure
151
+ #
152
+ # @return [Boolean] True if export is valid
153
+ def valid?
154
+ !metadata.nil? && !header.nil? && !tables.empty?
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lutaml/model"
4
+
5
+ module Fontisan
6
+ module Models
7
+ # Model for quick font summary
8
+ #
9
+ # Represents a brief overview of an individual font file.
10
+ # Used by LsCommand when operating on TTF/OTF files.
11
+ #
12
+ # @example Creating a font summary
13
+ # summary = FontSummary.new(
14
+ # font_path: "font.ttf",
15
+ # family_name: "Helvetica",
16
+ # subfamily_name: "Regular",
17
+ # font_format: "TrueType",
18
+ # num_glyphs: 268,
19
+ # num_tables: 14
20
+ # )
21
+ class FontSummary < Lutaml::Model::Serializable
22
+ attribute :font_path, :string
23
+ attribute :family_name, :string
24
+ attribute :subfamily_name, :string
25
+ attribute :font_format, :string
26
+ attribute :num_glyphs, :integer
27
+ attribute :num_tables, :integer
28
+
29
+ yaml do
30
+ map "font_path", to: :font_path
31
+ map "family_name", to: :family_name
32
+ map "subfamily_name", to: :subfamily_name
33
+ map "font_format", to: :font_format
34
+ map "num_glyphs", to: :num_glyphs
35
+ map "num_tables", to: :num_tables
36
+ end
37
+
38
+ json do
39
+ map "font_path", to: :font_path
40
+ map "family_name", to: :family_name
41
+ map "subfamily_name", to: :subfamily_name
42
+ map "font_format", to: :font_format
43
+ map "num_glyphs", to: :num_glyphs
44
+ map "num_tables", to: :num_tables
45
+ end
46
+ end
47
+ end
48
+ end
@@ -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