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,423 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "models/glyph_outline"
4
+
5
+ module Fontisan
6
+ # Extracts glyph outlines from font tables
7
+ #
8
+ # [`OutlineExtractor`](lib/fontisan/outline_extractor.rb) provides a unified
9
+ # interface for extracting glyph outline data from both TrueType (glyf table)
10
+ # and CFF (Compact Font Format) fonts. It uses a strategy pattern to handle
11
+ # the different outline formats transparently.
12
+ #
13
+ # The extractor:
14
+ # - Automatically detects font format (TrueType vs CFF)
15
+ # - Handles simple glyphs (direct outline data)
16
+ # - Handles compound glyphs (recursively resolves components)
17
+ # - Returns standardized [`GlyphOutline`](lib/fontisan/models/glyph_outline.rb) objects
18
+ #
19
+ # This class is responsible for extraction only, not business logic or
20
+ # presentation. It's designed to be composed with
21
+ # [`GlyphAccessor`](lib/fontisan/glyph_accessor.rb) for higher-level operations.
22
+ #
23
+ # @example Extracting a glyph outline
24
+ # extractor = Fontisan::OutlineExtractor.new(font)
25
+ # outline = extractor.extract(65) # 'A' character
26
+ #
27
+ # puts outline.contour_count
28
+ # puts outline.to_svg_path
29
+ #
30
+ # @example Using with GlyphAccessor
31
+ # accessor = Fontisan::GlyphAccessor.new(font)
32
+ # outline = accessor.outline_for_char('A')
33
+ #
34
+ # Reference: [`docs/GETTING_STARTED.md:125-172`](docs/GETTING_STARTED.md:125)
35
+ class OutlineExtractor
36
+ # @return [TrueTypeFont, OpenTypeFont] Font instance
37
+ attr_reader :font
38
+
39
+ # Initialize a new outline extractor
40
+ #
41
+ # @param font [TrueTypeFont, OpenTypeFont] Font to extract outlines from
42
+ # @raise [ArgumentError] If font is nil or doesn't have required tables
43
+ def initialize(font)
44
+ raise ArgumentError, "Font cannot be nil" if font.nil?
45
+
46
+ unless font.respond_to?(:table)
47
+ raise ArgumentError, "Font must respond to :table method"
48
+ end
49
+
50
+ @font = font
51
+ end
52
+
53
+ # Extract outline for a specific glyph
54
+ #
55
+ # This method automatically detects the font format and delegates to
56
+ # the appropriate extraction strategy. For compound glyphs (TrueType only),
57
+ # it recursively resolves component outlines and combines them with
58
+ # proper transformations.
59
+ #
60
+ # @param glyph_id [Integer] The glyph index (0-based, 0 is .notdef)
61
+ # @return [Models::GlyphOutline, nil] The outline or nil if glyph not
62
+ # found or empty
63
+ # @raise [ArgumentError] If glyph_id is invalid
64
+ # @raise [Fontisan::MissingTableError] If required tables are missing
65
+ #
66
+ # @example Extract a simple glyph
67
+ # outline = extractor.extract(65)
68
+ # puts "Glyph has #{outline.contour_count} contours"
69
+ #
70
+ # @example Handle empty glyphs (like space)
71
+ # outline = extractor.extract(space_glyph_id)
72
+ # # => nil (empty glyphs return nil)
73
+ def extract(glyph_id)
74
+ validate_glyph_id!(glyph_id)
75
+
76
+ if cff_font?
77
+ extract_cff_outline(glyph_id)
78
+ elsif truetype_font?
79
+ extract_truetype_outline(glyph_id)
80
+ else
81
+ raise Fontisan::MissingTableError,
82
+ "Font has neither glyf nor CFF table"
83
+ end
84
+ end
85
+
86
+ private
87
+
88
+ # Check if this is a CFF font
89
+ #
90
+ # @return [Boolean] True if font has CFF table
91
+ def cff_font?
92
+ font.table(Constants::CFF_TAG) != nil
93
+ end
94
+
95
+ # Check if this is a TrueType font
96
+ #
97
+ # @return [Boolean] True if font has glyf table
98
+ def truetype_font?
99
+ font.has_table?("glyf")
100
+ end
101
+
102
+ # Validate glyph ID
103
+ #
104
+ # @param glyph_id [Integer] Glyph ID to validate
105
+ # @raise [ArgumentError] If glyph ID is invalid
106
+ def validate_glyph_id!(glyph_id)
107
+ if glyph_id.nil?
108
+ raise ArgumentError, "glyph_id cannot be nil"
109
+ end
110
+
111
+ if glyph_id.negative?
112
+ raise ArgumentError, "glyph_id must be >= 0, got: #{glyph_id}"
113
+ end
114
+
115
+ maxp = font.table("maxp")
116
+ if maxp && glyph_id >= maxp.num_glyphs
117
+ raise ArgumentError,
118
+ "glyph_id #{glyph_id} exceeds number of glyphs (#{maxp.num_glyphs})"
119
+ end
120
+ end
121
+
122
+ # Extract outline from TrueType glyph
123
+ #
124
+ # Handles both simple and compound glyphs. For compound glyphs,
125
+ # recursively resolves component outlines and applies transformations.
126
+ #
127
+ # @param glyph_id [Integer] Glyph ID
128
+ # @return [Models::GlyphOutline, nil] Outline or nil if empty
129
+ # @raise [Fontisan::MissingTableError] If required tables are missing
130
+ def extract_truetype_outline(glyph_id)
131
+ glyph = get_truetype_glyph(glyph_id)
132
+ return nil unless glyph
133
+
134
+ # Handle empty glyphs (space, etc.)
135
+ return nil if glyph.respond_to?(:empty?) && glyph.empty?
136
+
137
+ if glyph.simple?
138
+ extract_simple_outline(glyph)
139
+ elsif glyph.compound?
140
+ extract_compound_outline(glyph)
141
+ else
142
+ raise Fontisan::Error, "Unknown glyph type: #{glyph.class}"
143
+ end
144
+ end
145
+
146
+ # Extract outline from CFF glyph
147
+ #
148
+ # CFF glyphs don't have compound structures, so extraction is
149
+ # straightforward from the CharString.
150
+ #
151
+ # @param glyph_id [Integer] Glyph ID
152
+ # @return [Models::GlyphOutline, nil] Outline or nil if empty
153
+ # @raise [Fontisan::MissingTableError] If CFF table is missing
154
+ def extract_cff_outline(glyph_id)
155
+ cff = font.table(Constants::CFF_TAG)
156
+ raise_missing_table!(Constants::CFF_TAG) unless cff
157
+
158
+ # Get CharString for glyph
159
+ charstring = cff.charstring_for_glyph(glyph_id)
160
+ return nil unless charstring
161
+
162
+ # CharString has path data
163
+ path = charstring.path
164
+ return nil if path.empty?
165
+
166
+ # Convert CharString path to contours
167
+ contours = convert_cff_path_to_contours(path)
168
+ return nil if contours.empty?
169
+
170
+ # Get bounding box from CharString
171
+ bbox_array = charstring.bounding_box
172
+ return nil unless bbox_array
173
+
174
+ bbox = {
175
+ x_min: bbox_array[0],
176
+ y_min: bbox_array[1],
177
+ x_max: bbox_array[2],
178
+ y_max: bbox_array[3],
179
+ }
180
+
181
+ Models::GlyphOutline.new(
182
+ glyph_id: glyph_id,
183
+ contours: contours,
184
+ bbox: bbox,
185
+ )
186
+ rescue StandardError => e
187
+ warn "Failed to extract CFF outline for glyph #{glyph_id}: #{e.message}"
188
+ nil
189
+ end
190
+
191
+ # Get TrueType glyph from glyf table
192
+ #
193
+ # @param glyph_id [Integer] Glyph ID
194
+ # @return [SimpleGlyph, CompoundGlyph, nil] Glyph object
195
+ # @raise [Fontisan::MissingTableError] If required tables are missing
196
+ def get_truetype_glyph(glyph_id)
197
+ glyf = font.table("glyf")
198
+ raise_missing_table!("glyf") unless glyf
199
+
200
+ loca = font.table("loca")
201
+ raise_missing_table!("loca") unless loca
202
+
203
+ head = font.table("head")
204
+ raise_missing_table!("head") unless head
205
+
206
+ # Ensure loca is parsed
207
+ unless loca.parsed?
208
+ maxp = font.table("maxp")
209
+ raise_missing_table!("maxp") unless maxp
210
+ loca.parse_with_context(head.index_to_loc_format, maxp.num_glyphs)
211
+ end
212
+
213
+ glyf.glyph_for(glyph_id, loca, head)
214
+ end
215
+
216
+ # Extract outline from a simple TrueType glyph
217
+ #
218
+ # @param glyph [SimpleGlyph] Simple glyph object
219
+ # @return [Models::GlyphOutline] Outline object
220
+ def extract_simple_outline(glyph)
221
+ contours = []
222
+
223
+ # Process each contour
224
+ glyph.num_contours.times do |contour_index|
225
+ points = glyph.points_for_contour(contour_index)
226
+ contours << points if points && !points.empty?
227
+ end
228
+
229
+ bbox = {
230
+ x_min: glyph.x_min,
231
+ y_min: glyph.y_min,
232
+ x_max: glyph.x_max,
233
+ y_max: glyph.y_max,
234
+ }
235
+
236
+ Models::GlyphOutline.new(
237
+ glyph_id: glyph.glyph_id,
238
+ contours: contours,
239
+ bbox: bbox,
240
+ )
241
+ end
242
+
243
+ # Extract outline from a compound TrueType glyph
244
+ #
245
+ # Recursively resolves component glyphs and applies transformations
246
+ # to combine them into a single outline.
247
+ #
248
+ # @param glyph [CompoundGlyph] Compound glyph object
249
+ # @return [Models::GlyphOutline] Combined outline object
250
+ def extract_compound_outline(glyph)
251
+ all_contours = []
252
+ combined_bbox = nil
253
+
254
+ # Process each component
255
+ glyph.components.each do |component|
256
+ component_outline = extract(component.glyph_index)
257
+ next unless component_outline
258
+
259
+ # Get transformation matrix
260
+ matrix = component.transformation_matrix
261
+
262
+ # Transform component contours
263
+ transformed_contours = transform_contours(
264
+ component_outline.contours,
265
+ matrix,
266
+ )
267
+ all_contours.concat(transformed_contours)
268
+
269
+ # Update combined bounding box
270
+ component_bbox = component_outline.bbox
271
+ combined_bbox = merge_bboxes(combined_bbox, component_bbox, matrix)
272
+ end
273
+
274
+ # Use original bbox if we couldn't compute one
275
+ combined_bbox ||= {
276
+ x_min: glyph.x_min,
277
+ y_min: glyph.y_min,
278
+ x_max: glyph.x_max,
279
+ y_max: glyph.y_max,
280
+ }
281
+
282
+ Models::GlyphOutline.new(
283
+ glyph_id: glyph.glyph_id,
284
+ contours: all_contours,
285
+ bbox: combined_bbox,
286
+ )
287
+ end
288
+
289
+ # Transform contours using an affine transformation matrix
290
+ #
291
+ # @param contours [Array<Array<Hash>>] Original contours
292
+ # @param matrix [Array<Float>] Transformation matrix [a, b, c, d, e, f]
293
+ # @return [Array<Array<Hash>>] Transformed contours
294
+ def transform_contours(contours, matrix)
295
+ a, b, c, d, e, f = matrix
296
+
297
+ contours.map do |contour|
298
+ contour.map do |point|
299
+ x = point[:x]
300
+ y = point[:y]
301
+
302
+ # Apply affine transformation: x' = a*x + c*y + e, y' = b*x + d*y + f
303
+ new_x = (a * x + c * y + e).round
304
+ new_y = (b * x + d * y + f).round
305
+
306
+ {
307
+ x: new_x,
308
+ y: new_y,
309
+ on_curve: point[:on_curve],
310
+ }
311
+ end
312
+ end
313
+ end
314
+
315
+ # Merge two bounding boxes
316
+ #
317
+ # @param bbox1 [Hash, nil] First bounding box
318
+ # @param bbox2 [Hash] Second bounding box
319
+ # @param matrix [Array<Float>] Transformation matrix for bbox2
320
+ # @return [Hash] Merged bounding box
321
+ def merge_bboxes(bbox1, bbox2, matrix)
322
+ # Transform bbox2 corners
323
+ a, b, c, d, e, f = matrix
324
+
325
+ corners = [
326
+ [bbox2[:x_min], bbox2[:y_min]],
327
+ [bbox2[:x_max], bbox2[:y_min]],
328
+ [bbox2[:x_min], bbox2[:y_max]],
329
+ [bbox2[:x_max], bbox2[:y_max]],
330
+ ]
331
+
332
+ transformed_corners = corners.map do |x, y|
333
+ [
334
+ (a * x + c * y + e).round,
335
+ (b * x + d * y + f).round,
336
+ ]
337
+ end
338
+
339
+ transformed_bbox = {
340
+ x_min: transformed_corners.map(&:first).min,
341
+ y_min: transformed_corners.map(&:last).min,
342
+ x_max: transformed_corners.map(&:first).max,
343
+ y_max: transformed_corners.map(&:last).max,
344
+ }
345
+
346
+ return transformed_bbox unless bbox1
347
+
348
+ # Merge with existing bbox
349
+ {
350
+ x_min: [bbox1[:x_min], transformed_bbox[:x_min]].min,
351
+ y_min: [bbox1[:y_min], transformed_bbox[:y_min]].min,
352
+ x_max: [bbox1[:x_max], transformed_bbox[:x_max]].max,
353
+ y_max: [bbox1[:y_max], transformed_bbox[:y_max]].max,
354
+ }
355
+ end
356
+
357
+ # Convert CFF CharString path to contours
358
+ #
359
+ # CFF paths are stored as arrays of command hashes. We need to
360
+ # convert them to the contour format used by GlyphOutline.
361
+ #
362
+ # @param path [Array<Hash>] CharString path data
363
+ # @return [Array<Array<Hash>>] Contours array
364
+ def convert_cff_path_to_contours(path)
365
+ contours = []
366
+ current_contour = []
367
+
368
+ path.each do |cmd|
369
+ case cmd[:type]
370
+ when :move_to
371
+ # Start new contour
372
+ contours << current_contour unless current_contour.empty?
373
+ current_contour = []
374
+ current_contour << {
375
+ x: cmd[:x].round,
376
+ y: cmd[:y].round,
377
+ on_curve: true,
378
+ }
379
+ when :line_to
380
+ current_contour << {
381
+ x: cmd[:x].round,
382
+ y: cmd[:y].round,
383
+ on_curve: true,
384
+ }
385
+ when :curve_to
386
+ # CFF uses cubic Bézier curves
387
+ # For now, we'll add control points and end point
388
+ # This is a simplification - proper handling would require
389
+ # converting cubic to quadratic or keeping cubic format
390
+ current_contour << {
391
+ x: cmd[:x1].round,
392
+ y: cmd[:y1].round,
393
+ on_curve: false,
394
+ }
395
+ current_contour << {
396
+ x: cmd[:x2].round,
397
+ y: cmd[:y2].round,
398
+ on_curve: false,
399
+ }
400
+ current_contour << {
401
+ x: cmd[:x].round,
402
+ y: cmd[:y].round,
403
+ on_curve: true,
404
+ }
405
+ end
406
+ end
407
+
408
+ # Add final contour
409
+ contours << current_contour unless current_contour.empty?
410
+
411
+ contours
412
+ end
413
+
414
+ # Raise MissingTableError
415
+ #
416
+ # @param table_tag [String] Table tag
417
+ # @raise [Fontisan::MissingTableError]
418
+ def raise_missing_table!(table_tag)
419
+ raise Fontisan::MissingTableError,
420
+ "Required table '#{table_tag}' not found in font"
421
+ end
422
+ end
423
+ end
@@ -0,0 +1,249 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../font_loader"
4
+
5
+ module Fontisan
6
+ module Pipeline
7
+ # Detects font format and capabilities
8
+ #
9
+ # This class analyzes font files to determine:
10
+ # - Format: TTF, OTF, TTC, OTC, WOFF, WOFF2, SVG
11
+ # - Variation type: static, gvar (TrueType variable), CFF2 (OpenType variable)
12
+ # - Capabilities: outline type, variation support, collection support
13
+ #
14
+ # Used by the universal transformation pipeline to determine conversion
15
+ # strategies and validate compatibility.
16
+ #
17
+ # @example Detecting a font's format
18
+ # detector = FormatDetector.new("font.ttf")
19
+ # info = detector.detect
20
+ # puts info[:format] # => :ttf
21
+ # puts info[:variation_type] # => :gvar
22
+ # puts info[:capabilities][:outline] # => :truetype
23
+ class FormatDetector
24
+ # @return [String] Path to font file
25
+ attr_reader :file_path
26
+
27
+ # @return [TrueTypeFont, OpenTypeFont, TrueTypeCollection, OpenTypeCollection, nil] Loaded font
28
+ attr_reader :font
29
+
30
+ # Initialize detector
31
+ #
32
+ # @param file_path [String] Path to font file
33
+ def initialize(file_path)
34
+ @file_path = file_path
35
+ @font = nil
36
+ end
37
+
38
+ # Detect format and capabilities
39
+ #
40
+ # @return [Hash] Detection results with :format, :variation_type, :capabilities
41
+ def detect
42
+ load_font
43
+
44
+ {
45
+ format: detect_format,
46
+ variation_type: detect_variation,
47
+ capabilities: detect_capabilities,
48
+ }
49
+ end
50
+
51
+ # Detect font format
52
+ #
53
+ # @return [Symbol] One of :ttf, :otf, :ttc, :otc, :woff, :woff2, :svg
54
+ def detect_format
55
+ # Check for SVG first (from file extension even if font failed to load)
56
+ return :svg if @file_path.end_with?(".svg")
57
+
58
+ return :unknown unless @font
59
+
60
+ # Use is_a? for proper class checking
61
+ case @font
62
+ when Fontisan::TrueTypeCollection
63
+ :ttc
64
+ when Fontisan::OpenTypeCollection
65
+ :otc
66
+ when Fontisan::TrueTypeFont
67
+ if @file_path.end_with?(".woff")
68
+ :woff
69
+ elsif @file_path.end_with?(".woff2")
70
+ :woff2
71
+ else
72
+ :ttf
73
+ end
74
+ when Fontisan::OpenTypeFont
75
+ if @file_path.end_with?(".woff")
76
+ :woff
77
+ elsif @file_path.end_with?(".woff2")
78
+ :woff2
79
+ else
80
+ :otf
81
+ end
82
+ else
83
+ :unknown
84
+ end
85
+ end
86
+
87
+ # Detect variation type
88
+ #
89
+ # @return [Symbol] One of :static, :gvar, :cff2
90
+ def detect_variation
91
+ return :static unless @font
92
+
93
+ # Collections don't have has_table? method
94
+ # Return :static for collections (variation detection would need to load first font)
95
+ return :static if collection?
96
+
97
+ # Check for variable font tables
98
+ if @font.has_table?("fvar")
99
+ # Variable font detected - check variation type
100
+ if @font.has_table?("gvar")
101
+ :gvar # TrueType variable font
102
+ elsif @font.has_table?("CFF2")
103
+ :cff2 # OpenType variable font (CFF2)
104
+ else
105
+ :static # Has fvar but no variation data (shouldn't happen)
106
+ end
107
+ else
108
+ :static
109
+ end
110
+ end
111
+
112
+ # Detect font capabilities
113
+ #
114
+ # @return [Hash] Capabilities hash
115
+ def detect_capabilities
116
+ return default_capabilities unless @font
117
+
118
+ # Check if this is a collection
119
+ is_collection = collection?
120
+
121
+ font_to_check = if is_collection
122
+ # Collections don't have fonts method, need to load first font
123
+ nil # Will handle in API usage
124
+ else
125
+ @font
126
+ end
127
+
128
+ # For collections, return basic capabilities
129
+ if is_collection
130
+ return {
131
+ outline: :unknown, # Would need to load first font to know
132
+ variation: false, # Would need to load first font to know
133
+ collection: true,
134
+ tables: [],
135
+ }
136
+ end
137
+
138
+ return default_capabilities unless font_to_check
139
+
140
+ {
141
+ outline: detect_outline_type(font_to_check),
142
+ variation: detect_variation != :static,
143
+ collection: false,
144
+ tables: available_tables(font_to_check),
145
+ }
146
+ end
147
+
148
+ # Check if font is a collection
149
+ #
150
+ # @return [Boolean] True if collection (TTC/OTC)
151
+ def collection?
152
+ @font.is_a?(Fontisan::TrueTypeCollection) ||
153
+ @font.is_a?(Fontisan::OpenTypeCollection)
154
+ end
155
+
156
+ # Check if font is variable
157
+ #
158
+ # @return [Boolean] True if variable font
159
+ def variable?
160
+ detect_variation != :static
161
+ end
162
+
163
+ # Check if format is compatible with target
164
+ #
165
+ # @param target_format [Symbol] Target format (:ttf, :otf, etc.)
166
+ # @return [Boolean] True if conversion is possible
167
+ def compatible_with?(target_format)
168
+ current_format = detect_format
169
+ variation_type = detect_variation
170
+
171
+ # Same format is always compatible
172
+ return true if current_format == target_format
173
+
174
+ # Collection formats
175
+ if %i[ttc otc].include?(current_format)
176
+ return %i[ttc otc].include?(target_format)
177
+ end
178
+
179
+ # Variable font constraints
180
+ if variation_type == :static
181
+ # Static fonts can convert to any format
182
+ true
183
+ else
184
+ case variation_type
185
+ when :gvar
186
+ # TrueType variable can convert to TrueType formats
187
+ %i[ttf ttc woff woff2].include?(target_format)
188
+ when :cff2
189
+ # OpenType variable can convert to OpenType formats
190
+ %i[otf otc woff woff2].include?(target_format)
191
+ end
192
+ end
193
+ end
194
+
195
+ private
196
+
197
+ # Load font from file
198
+ def load_font
199
+ # Check if it's a collection first
200
+ @font = if FontLoader.collection?(@file_path)
201
+ FontLoader.load_collection(@file_path)
202
+ else
203
+ FontLoader.load(@file_path, mode: :full)
204
+ end
205
+ rescue StandardError => e
206
+ warn "Failed to load font: #{e.message}"
207
+ @font = nil
208
+ end
209
+
210
+ # Detect outline type
211
+ #
212
+ # @param font [Font] Font object
213
+ # @return [Symbol] :truetype or :cff
214
+ def detect_outline_type(font)
215
+ if font.has_table?("glyf") || font.has_table?("gvar")
216
+ :truetype
217
+ elsif font.has_table?("CFF ") || font.has_table?("CFF2")
218
+ :cff
219
+ else
220
+ :unknown
221
+ end
222
+ end
223
+
224
+ # Get available tables
225
+ #
226
+ # @param font [Font] Font object
227
+ # @return [Array<String>] List of table tags
228
+ def available_tables(font)
229
+ return [] unless font.respond_to?(:table_names)
230
+
231
+ font.table_names
232
+ rescue StandardError
233
+ []
234
+ end
235
+
236
+ # Default capabilities when font cannot be loaded
237
+ #
238
+ # @return [Hash] Default capabilities
239
+ def default_capabilities
240
+ {
241
+ outline: :unknown,
242
+ variation: false,
243
+ collection: false,
244
+ tables: [],
245
+ }
246
+ end
247
+ end
248
+ end
249
+ end