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,411 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "format_detector"
4
+ require_relative "variation_resolver"
5
+ require_relative "../converters/format_converter"
6
+ require_relative "../font_loader"
7
+ require_relative "../font_writer"
8
+ require_relative "output_writer"
9
+
10
+ module Fontisan
11
+ module Pipeline
12
+ # Orchestrates universal font transformation pipeline
13
+ #
14
+ # This is the main entry point for font conversion operations. It coordinates:
15
+ # 1. Format detection (via FormatDetector)
16
+ # 2. Font loading (via FontLoader)
17
+ # 3. Variation resolution (via VariationResolver)
18
+ # 4. Format conversion (via FormatConverter)
19
+ # 5. Output writing (via OutputWriter)
20
+ # 6. Validation (optional, via Validation::Validator)
21
+ #
22
+ # The pipeline follows a clear MECE architecture where each phase has a
23
+ # single responsibility and produces well-defined outputs.
24
+ #
25
+ # @example Basic TTF to OTF conversion
26
+ # pipeline = TransformationPipeline.new("input.ttf", "output.otf")
27
+ # result = pipeline.transform
28
+ # puts result[:success] # => true
29
+ #
30
+ # @example Variable font instance generation
31
+ # pipeline = TransformationPipeline.new(
32
+ # "variable.ttf",
33
+ # "bold.ttf",
34
+ # coordinates: { "wght" => 700.0 }
35
+ # )
36
+ # result = pipeline.transform
37
+ class TransformationPipeline
38
+ # @return [String] Input file path
39
+ attr_reader :input_path
40
+
41
+ # @return [String] Output file path
42
+ attr_reader :output_path
43
+
44
+ # @return [Hash] Transformation options
45
+ attr_reader :options
46
+
47
+ # Initialize transformation pipeline
48
+ #
49
+ # @param input_path [String] Path to input font
50
+ # @param output_path [String] Path to output font
51
+ # @param options [Hash] Transformation options
52
+ # @option options [Symbol] :target_format Target format (:ttf, :otf, :woff, :woff2)
53
+ # @option options [Hash] :coordinates Instance coordinates (for variable fonts)
54
+ # @option options [Integer] :instance_index Named instance index
55
+ # @option options [Boolean] :preserve_variation Preserve variation data (default: auto)
56
+ # @option options [Boolean] :validate Validate output (default: true)
57
+ # @option options [Boolean] :verbose Verbose output (default: false)
58
+ def initialize(input_path, output_path, options = {})
59
+ @input_path = input_path
60
+ @output_path = output_path
61
+ @options = default_options.merge(options)
62
+ @variation_strategy = nil
63
+
64
+ validate_paths!
65
+ end
66
+
67
+ # Execute transformation pipeline
68
+ #
69
+ # This is the main entry point. It orchestrates:
70
+ # 1. Format detection
71
+ # 2. Font loading
72
+ # 3. Variation resolution
73
+ # 4. Format conversion
74
+ # 5. Output writing
75
+ # 6. Validation (optional)
76
+ #
77
+ # @return [Hash] Transformation result with :success, :output_path, :details
78
+ # @raise [Error] If transformation fails
79
+ def transform
80
+ log "Starting transformation: #{@input_path} → #{@output_path}"
81
+
82
+ # Phase 1: Detect input format
83
+ detection = detect_input_format
84
+ log "Detected: #{detection[:format]} (#{detection[:variation_type]})"
85
+
86
+ # Phase 2: Load font
87
+ font = load_font(detection)
88
+ log "Loaded: #{font.class.name}"
89
+
90
+ # Phase 3: Resolve variation
91
+ tables = resolve_variation(font, detection)
92
+ log "Resolved variation using #{@variation_strategy} strategy"
93
+
94
+ # Phase 4: Convert format
95
+ tables = convert_format(tables, detection)
96
+ log "Converted to #{target_format}"
97
+
98
+ # Phase 5: Write output
99
+ write_output(tables, detection)
100
+ log "Written to #{@output_path}"
101
+
102
+ # Phase 6: Validate (optional)
103
+ validate_output if @options[:validate] && !same_format_conversion? && !export_only_format?
104
+ log "Validation passed" if @options[:validate] && !export_only_format?
105
+
106
+ {
107
+ success: true,
108
+ output_path: @output_path,
109
+ details: build_details(detection),
110
+ }
111
+ rescue StandardError => e
112
+ handle_error(e)
113
+ end
114
+
115
+ private
116
+
117
+ # Detect input format and capabilities
118
+ #
119
+ # @return [Hash] Detection results from FormatDetector
120
+ def detect_input_format
121
+ detector = FormatDetector.new(@input_path)
122
+ detector.detect
123
+ end
124
+
125
+ # Load font with appropriate mode
126
+ #
127
+ # @param detection [Hash] Detection results
128
+ # @return [Font] Loaded font object
129
+ def load_font(_detection)
130
+ FontLoader.load(@input_path, mode: :full)
131
+ end
132
+
133
+ # Resolve variation data
134
+ #
135
+ # @param font [Font] Loaded font
136
+ # @param detection [Hash] Detection results
137
+ # @return [Hash] Processed font tables
138
+ def resolve_variation(font, detection)
139
+ # Static fonts - use preserve strategy (just copy tables)
140
+ return resolve_static_font(font) if detection[:variation_type] == :static
141
+
142
+ # Variable fonts - determine strategy
143
+ strategy = determine_variation_strategy(detection)
144
+ @variation_strategy = strategy
145
+
146
+ resolver = VariationResolver.new(
147
+ font,
148
+ strategy: strategy,
149
+ **variation_options,
150
+ )
151
+
152
+ resolver.resolve
153
+ end
154
+
155
+ # Resolve static font (just copy tables)
156
+ #
157
+ # @param font [Font] Static font
158
+ # @return [Hash] Font tables
159
+ def resolve_static_font(font)
160
+ @variation_strategy = :preserve
161
+
162
+ # Get all tables from font - use table_data directly
163
+ font.table_data.dup
164
+ end
165
+
166
+ # Determine variation strategy based on options and compatibility
167
+ #
168
+ # @param detection [Hash] Detection results
169
+ # @return [Symbol] Strategy type (:preserve, :instance, :named)
170
+ def determine_variation_strategy(detection)
171
+ # User explicitly requested instance generation
172
+ if @options[:coordinates] || @options[:instance_index]
173
+ return @options[:instance_index] ? :named : :instance
174
+ end
175
+
176
+ # Check if preservation is possible
177
+ if can_preserve_variation?(detection)
178
+ @options.fetch(:preserve_variation, true) ? :preserve : :instance
179
+ else
180
+ # Cannot preserve - must generate instance
181
+ :instance
182
+ end
183
+ end
184
+
185
+ # Check if variation can be preserved for target format
186
+ #
187
+ # @param detection [Hash] Detection results
188
+ # @return [Boolean] True if variation preservable
189
+ def can_preserve_variation?(detection)
190
+ source_format = detection[:format]
191
+ target = target_format
192
+
193
+ # Same format
194
+ return true if source_format == target
195
+
196
+ # Same outline family (packaging change only)
197
+ same_outline_family?(source_format, target)
198
+ end
199
+
200
+ # Check if formats are in same outline family
201
+ #
202
+ # @param source [Symbol] Source format
203
+ # @param target [Symbol] Target format
204
+ # @return [Boolean] True if same family
205
+ def same_outline_family?(source, target)
206
+ truetype_formats = %i[ttf ttc woff woff2]
207
+ opentype_formats = %i[otf otc woff woff2]
208
+
209
+ (truetype_formats.include?(source) && truetype_formats.include?(target)) ||
210
+ (opentype_formats.include?(source) && opentype_formats.include?(target))
211
+ end
212
+
213
+ # Convert format if needed
214
+ #
215
+ # @param tables [Hash] Font tables
216
+ # @param detection [Hash] Detection results
217
+ # @return [Hash] Converted tables
218
+ def convert_format(tables, detection)
219
+ source_format = detection[:format]
220
+ target = target_format
221
+
222
+ # No conversion needed for same format
223
+ return tables if source_format == target
224
+
225
+ # Use FormatConverter for outline conversion
226
+ if needs_outline_conversion?(source_format, target) || target == :svg
227
+ converter = Converters::FormatConverter.new
228
+ # Create temporary font object from tables
229
+ font = build_font_from_tables(tables, source_format)
230
+ converter.convert(font, target, @options)
231
+ else
232
+ # Just packaging change - tables can be used as-is
233
+ tables
234
+ end
235
+ end
236
+
237
+ # Check if outline conversion is needed
238
+ #
239
+ # @param source [Symbol] Source format
240
+ # @param target [Symbol] Target format
241
+ # @return [Boolean] True if outline conversion needed
242
+ def needs_outline_conversion?(source, target)
243
+ # TTF ↔ OTF requires outline conversion
244
+ ttf_formats = %i[ttf ttc woff woff2]
245
+ otf_formats = %i[otf otc]
246
+
247
+ (ttf_formats.include?(source) && otf_formats.include?(target)) ||
248
+ (otf_formats.include?(source) && ttf_formats.include?(target))
249
+ end
250
+
251
+ # Write output font file
252
+ #
253
+ # @param tables [Hash] Font tables
254
+ # @param detection [Hash] Detection results
255
+ def write_output(tables, _detection)
256
+ writer = OutputWriter.new(@output_path, target_format, @options)
257
+ writer.write(tables)
258
+ end
259
+
260
+ # Validate output file
261
+ #
262
+ # @raise [ValidationError] If validation fails
263
+ def validate_output
264
+ return unless File.exist?(@output_path)
265
+
266
+ require_relative "../validation/validator"
267
+
268
+ # Load font for validation
269
+ font = FontLoader.load(@output_path, mode: :full)
270
+ validator = Validation::Validator.new
271
+ result = validator.validate(font, @output_path)
272
+
273
+ return if result.valid
274
+
275
+ error_messages = result.errors.map(&:message).join(", ")
276
+ raise Error, "Output validation failed: #{error_messages}"
277
+ end
278
+
279
+ # Get target format
280
+ #
281
+ # @return [Symbol] Target format
282
+ def target_format
283
+ @options[:target_format] || detect_target_from_extension
284
+ end
285
+
286
+ # Detect target format from output path extension
287
+ #
288
+ # @return [Symbol] Detected format
289
+ def detect_target_from_extension
290
+ ext = File.extname(@output_path).downcase
291
+ case ext
292
+ when ".ttf" then :ttf
293
+ when ".otf" then :otf
294
+ when ".woff" then :woff
295
+ when ".woff2" then :woff2
296
+ else
297
+ raise ArgumentError, "Cannot determine target format from extension: #{ext}"
298
+ end
299
+ end
300
+
301
+ # Get variation options for VariationResolver
302
+ #
303
+ # @return [Hash] Variation options
304
+ def variation_options
305
+ opts = {}
306
+ opts[:coordinates] = @options[:coordinates] if @options[:coordinates]
307
+ opts[:instance_index] = @options[:instance_index] if @options[:instance_index]
308
+ opts
309
+ end
310
+
311
+ # Validate input and output paths
312
+ #
313
+ # @raise [ArgumentError] If paths invalid
314
+ def validate_paths!
315
+ unless File.exist?(@input_path)
316
+ raise ArgumentError, "Input file not found: #{@input_path}"
317
+ end
318
+
319
+ output_dir = File.dirname(@output_path)
320
+ unless File.directory?(output_dir)
321
+ raise ArgumentError, "Output directory not found: #{output_dir}"
322
+ end
323
+ end
324
+
325
+ # Build font object from tables
326
+ #
327
+ # @param tables [Hash] Font tables
328
+ # @param format [Symbol] Font format
329
+ # @return [Font] Font object
330
+ def build_font_from_tables(tables, format)
331
+ # Detect outline type from tables
332
+ has_cff = tables.key?("CFF ") || tables.key?("CFF2")
333
+ has_glyf = tables.key?("glyf")
334
+
335
+ if has_cff
336
+ OpenTypeFont.from_tables(tables)
337
+ elsif has_glyf
338
+ TrueTypeFont.from_tables(tables)
339
+ else
340
+ # Default based on format
341
+ case format
342
+ when :ttf, :woff, :woff2
343
+ TrueTypeFont.from_tables(tables)
344
+ when :otf
345
+ OpenTypeFont.from_tables(tables)
346
+ else
347
+ raise ArgumentError, "Cannot determine font type: format=#{format}, has_cff=#{has_cff}, has_glyf=#{has_glyf}"
348
+ end
349
+ end
350
+ end
351
+
352
+ # Build transformation details
353
+ #
354
+ # @param detection [Hash] Detection results
355
+ # @return [Hash] Transformation details
356
+ def build_details(detection)
357
+ {
358
+ source_format: detection[:format],
359
+ source_variation: detection[:variation_type],
360
+ target_format: target_format,
361
+ variation_strategy: @variation_strategy,
362
+ variation_preserved: @variation_strategy == :preserve,
363
+ }
364
+ end
365
+
366
+ # Handle transformation error
367
+ #
368
+ # @param error [StandardError] Error that occurred
369
+ # @raise [Error] Re-raises with context
370
+ def handle_error(error)
371
+ log "ERROR: #{error.message}"
372
+ log error.backtrace.first(5).join("\n") if @options[:verbose]
373
+
374
+ raise Error, "Transformation failed: #{error.message}"
375
+ end
376
+
377
+ # Log message if verbose
378
+ #
379
+ # @param message [String] Message to log
380
+ def log(message)
381
+ puts "[TransformationPipeline] #{message}" if @options[:verbose]
382
+ end
383
+
384
+ # Default options
385
+ #
386
+ # @return [Hash] Default options
387
+ def default_options
388
+ {
389
+ validate: true,
390
+ verbose: false,
391
+ preserve_variation: nil, # Auto-determine
392
+ }
393
+ end
394
+
395
+ # Check if this is a same-format conversion
396
+ #
397
+ # @return [Boolean] True if source and target formats are the same
398
+ def same_format_conversion?
399
+ detection = detect_input_format
400
+ detection[:format] == target_format
401
+ end
402
+
403
+ # Check if target format is export-only (cannot be validated)
404
+ #
405
+ # @return [Boolean] True if format is export-only
406
+ def export_only_format?
407
+ %i[svg woff woff2].include?(target_format)
408
+ end
409
+ end
410
+ end
411
+ end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "strategies/base_strategy"
4
+ require_relative "strategies/preserve_strategy"
5
+ require_relative "strategies/instance_strategy"
6
+ require_relative "strategies/named_strategy"
7
+
8
+ module Fontisan
9
+ module Pipeline
10
+ # Resolves variation data using strategy pattern
11
+ #
12
+ # This class orchestrates variation resolution during font conversion by
13
+ # selecting and executing the appropriate strategy based on user intent.
14
+ # It follows the Strategy pattern to allow different approaches to handling
15
+ # variable font data.
16
+ #
17
+ # Three strategies are available:
18
+ # - PreserveStrategy: Keep variation data intact (for compatible formats)
19
+ # - InstanceStrategy: Generate static instance at coordinates
20
+ # - NamedStrategy: Use named instance from fvar table
21
+ #
22
+ # Strategy selection is explicit through the :strategy option. Each strategy
23
+ # has its own required and optional parameters.
24
+ #
25
+ # @example Preserve variation data
26
+ # resolver = VariationResolver.new(font, strategy: :preserve)
27
+ # tables = resolver.resolve
28
+ #
29
+ # @example Generate instance at coordinates
30
+ # resolver = VariationResolver.new(
31
+ # font,
32
+ # strategy: :instance,
33
+ # coordinates: { "wght" => 700.0 }
34
+ # )
35
+ # tables = resolver.resolve
36
+ #
37
+ # @example Use named instance
38
+ # resolver = VariationResolver.new(
39
+ # font,
40
+ # strategy: :named,
41
+ # instance_index: 0
42
+ # )
43
+ # tables = resolver.resolve
44
+ class VariationResolver
45
+ # @return [TrueTypeFont, OpenTypeFont] Font to process
46
+ attr_reader :font
47
+
48
+ # @return [Strategies::BaseStrategy] Selected strategy
49
+ attr_reader :strategy
50
+
51
+ # Initialize resolver with font and strategy
52
+ #
53
+ # @param font [TrueTypeFont, OpenTypeFont] Font to process
54
+ # @param options [Hash] Resolution options
55
+ # @option options [Symbol] :strategy Strategy to use (:preserve, :instance, :named)
56
+ # @option options [Hash] :coordinates Design space coordinates (for :instance)
57
+ # @option options [Integer] :instance_index Named instance index (for :named)
58
+ # @raise [ArgumentError] If strategy is missing or invalid
59
+ def initialize(font, options = {})
60
+ @font = font
61
+
62
+ strategy_type = options[:strategy]
63
+ raise ArgumentError, "strategy is required" unless strategy_type
64
+
65
+ @strategy = build_strategy(strategy_type, options)
66
+
67
+ # Validate strategy-specific requirements
68
+ validate_strategy_requirements(strategy_type, options)
69
+ end
70
+
71
+ # Resolve variation data
72
+ #
73
+ # Delegates to the selected strategy to process the font and return
74
+ # the appropriate tables.
75
+ #
76
+ # @return [Hash<String, String>] Font tables after resolution
77
+ def resolve
78
+ @strategy.resolve(@font)
79
+ end
80
+
81
+ # Check if resolution preserves variation data
82
+ #
83
+ # @return [Boolean] True if variation is preserved
84
+ def preserves_variation?
85
+ @strategy.preserves_variation?
86
+ end
87
+
88
+ # Get strategy name
89
+ #
90
+ # @return [Symbol] Strategy identifier
91
+ def strategy_name
92
+ @strategy.strategy_name
93
+ end
94
+
95
+ private
96
+
97
+ # Build strategy instance based on type
98
+ #
99
+ # @param type [Symbol] Strategy type (:preserve, :instance, :named)
100
+ # @param options [Hash] Strategy options
101
+ # @return [Strategies::BaseStrategy] Strategy instance
102
+ # @raise [ArgumentError] If strategy type is unknown
103
+ def build_strategy(type, options)
104
+ case type
105
+ when :preserve
106
+ Strategies::PreserveStrategy.new(options)
107
+ when :instance
108
+ Strategies::InstanceStrategy.new(options)
109
+ when :named
110
+ Strategies::NamedStrategy.new(options)
111
+ else
112
+ raise ArgumentError,
113
+ "Unknown strategy: #{type}. " \
114
+ "Valid strategies: :preserve, :instance, :named"
115
+ end
116
+ end
117
+
118
+ # Validate strategy-specific requirements
119
+ #
120
+ # @param type [Symbol] Strategy type
121
+ # @param options [Hash] Strategy options
122
+ # @raise [ArgumentError, InvalidCoordinatesError] If validation fails
123
+ def validate_strategy_requirements(type, options)
124
+ case type
125
+ when :instance
126
+ validate_instance_coordinates(options[:coordinates]) if options[:coordinates]
127
+ when :named
128
+ validate_named_instance_index(options[:instance_index]) if options[:instance_index]
129
+ end
130
+ end
131
+
132
+ # Validate coordinates for instance strategy
133
+ #
134
+ # @param coordinates [Hash] Coordinates to validate
135
+ # @raise [InvalidCoordinatesError] If coordinates invalid
136
+ def validate_instance_coordinates(coordinates)
137
+ return if coordinates.empty?
138
+
139
+ require_relative "../variation/variation_context"
140
+ context = Variation::VariationContext.new(@font)
141
+ context.validate_coordinates(coordinates)
142
+ end
143
+
144
+ # Validate instance index for named strategy
145
+ #
146
+ # @param instance_index [Integer] Instance index to validate
147
+ # @raise [ArgumentError] If index invalid
148
+ def validate_named_instance_index(instance_index)
149
+ require_relative "../variation/variation_context"
150
+ context = Variation::VariationContext.new(@font)
151
+
152
+ unless context.fvar
153
+ raise ArgumentError, "Font is not a variable font (no fvar table)"
154
+ end
155
+
156
+ instances = context.fvar.instances
157
+ if instance_index.negative? || instance_index >= instances.length
158
+ raise ArgumentError,
159
+ "Invalid instance index #{instance_index}. " \
160
+ "Font has #{instances.length} named instances."
161
+ end
162
+ end
163
+ end
164
+ end
165
+ end