fontisan 0.2.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 (74) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +270 -131
  3. data/README.adoc +158 -4
  4. data/Rakefile +44 -47
  5. data/lib/fontisan/cli.rb +84 -33
  6. data/lib/fontisan/collection/builder.rb +81 -0
  7. data/lib/fontisan/collection/table_deduplicator.rb +76 -0
  8. data/lib/fontisan/commands/base_command.rb +16 -0
  9. data/lib/fontisan/commands/convert_command.rb +97 -170
  10. data/lib/fontisan/commands/instance_command.rb +71 -80
  11. data/lib/fontisan/commands/validate_command.rb +25 -0
  12. data/lib/fontisan/config/validation_rules.yml +1 -1
  13. data/lib/fontisan/constants.rb +10 -0
  14. data/lib/fontisan/converters/format_converter.rb +150 -1
  15. data/lib/fontisan/converters/outline_converter.rb +80 -18
  16. data/lib/fontisan/converters/woff_writer.rb +1 -1
  17. data/lib/fontisan/font_loader.rb +3 -5
  18. data/lib/fontisan/font_writer.rb +7 -6
  19. data/lib/fontisan/hints/hint_converter.rb +133 -0
  20. data/lib/fontisan/hints/postscript_hint_applier.rb +221 -140
  21. data/lib/fontisan/hints/postscript_hint_extractor.rb +100 -0
  22. data/lib/fontisan/hints/truetype_hint_applier.rb +90 -44
  23. data/lib/fontisan/hints/truetype_hint_extractor.rb +127 -0
  24. data/lib/fontisan/loading_modes.rb +2 -0
  25. data/lib/fontisan/models/font_export.rb +2 -2
  26. data/lib/fontisan/models/hint.rb +173 -1
  27. data/lib/fontisan/models/validation_report.rb +1 -1
  28. data/lib/fontisan/open_type_font.rb +25 -9
  29. data/lib/fontisan/open_type_font_extensions.rb +54 -0
  30. data/lib/fontisan/pipeline/format_detector.rb +249 -0
  31. data/lib/fontisan/pipeline/output_writer.rb +154 -0
  32. data/lib/fontisan/pipeline/strategies/base_strategy.rb +75 -0
  33. data/lib/fontisan/pipeline/strategies/instance_strategy.rb +93 -0
  34. data/lib/fontisan/pipeline/strategies/named_strategy.rb +118 -0
  35. data/lib/fontisan/pipeline/strategies/preserve_strategy.rb +56 -0
  36. data/lib/fontisan/pipeline/transformation_pipeline.rb +411 -0
  37. data/lib/fontisan/pipeline/variation_resolver.rb +165 -0
  38. data/lib/fontisan/tables/cff/charstring.rb +33 -4
  39. data/lib/fontisan/tables/cff/charstring_builder.rb +34 -0
  40. data/lib/fontisan/tables/cff/charstring_parser.rb +237 -0
  41. data/lib/fontisan/tables/cff/charstring_rebuilder.rb +172 -0
  42. data/lib/fontisan/tables/cff/dict_builder.rb +15 -0
  43. data/lib/fontisan/tables/cff/hint_operation_injector.rb +207 -0
  44. data/lib/fontisan/tables/cff/offset_recalculator.rb +70 -0
  45. data/lib/fontisan/tables/cff/private_dict_writer.rb +125 -0
  46. data/lib/fontisan/tables/cff/table_builder.rb +221 -0
  47. data/lib/fontisan/tables/cff.rb +2 -0
  48. data/lib/fontisan/tables/cff2/private_dict_blend_handler.rb +246 -0
  49. data/lib/fontisan/tables/cff2/region_matcher.rb +200 -0
  50. data/lib/fontisan/tables/cff2/table_builder.rb +574 -0
  51. data/lib/fontisan/tables/cff2/table_reader.rb +419 -0
  52. data/lib/fontisan/tables/cff2/variation_data_extractor.rb +212 -0
  53. data/lib/fontisan/tables/cff2.rb +9 -4
  54. data/lib/fontisan/tables/cvar.rb +2 -41
  55. data/lib/fontisan/tables/gvar.rb +2 -41
  56. data/lib/fontisan/true_type_font.rb +24 -9
  57. data/lib/fontisan/true_type_font_extensions.rb +54 -0
  58. data/lib/fontisan/utilities/checksum_calculator.rb +42 -0
  59. data/lib/fontisan/validation/checksum_validator.rb +2 -2
  60. data/lib/fontisan/validation/table_validator.rb +1 -1
  61. data/lib/fontisan/validation/variable_font_validator.rb +218 -0
  62. data/lib/fontisan/variation/converter.rb +120 -13
  63. data/lib/fontisan/variation/instance_writer.rb +341 -0
  64. data/lib/fontisan/variation/tuple_variation_header.rb +51 -0
  65. data/lib/fontisan/variation/variable_svg_generator.rb +268 -0
  66. data/lib/fontisan/variation/variation_preserver.rb +288 -0
  67. data/lib/fontisan/version.rb +1 -1
  68. data/lib/fontisan/version.rb.orig +9 -0
  69. data/lib/fontisan/woff2/glyf_transformer.rb +666 -0
  70. data/lib/fontisan/woff2/hmtx_transformer.rb +164 -0
  71. data/lib/fontisan/woff2_font.rb +475 -470
  72. data/lib/fontisan/woff_font.rb +16 -11
  73. data/lib/fontisan.rb +12 -0
  74. metadata +31 -2
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_strategy"
4
+ require_relative "instance_strategy"
5
+ require_relative "../../variation/variation_context"
6
+
7
+ module Fontisan
8
+ module Pipeline
9
+ module Strategies
10
+ # Strategy for generating instances from named instances
11
+ #
12
+ # This strategy creates a static font instance using coordinates from
13
+ # a named instance defined in the fvar table. It extracts the coordinates
14
+ # from the specified instance and delegates to InstanceStrategy for
15
+ # actual generation.
16
+ #
17
+ # Named instances are predefined design space coordinates stored in the
18
+ # fvar table, typically representing common styles like "Bold", "Light",
19
+ # "Condensed", etc.
20
+ #
21
+ # @example Generate "Bold" instance
22
+ # strategy = NamedStrategy.new(instance_index: 0)
23
+ # tables = strategy.resolve(variable_font)
24
+ #
25
+ # @example Use specific named instance
26
+ # # Find instance by name first, then use index
27
+ # fvar = font.table("fvar")
28
+ # bold_index = fvar.instances.find_index { |i| i[:name] =~ /Bold/ }
29
+ # strategy = NamedStrategy.new(instance_index: bold_index)
30
+ # tables = strategy.resolve(variable_font)
31
+ class NamedStrategy < BaseStrategy
32
+ # @return [Integer] Named instance index
33
+ attr_reader :instance_index
34
+
35
+ # Initialize strategy with instance index
36
+ #
37
+ # @param options [Hash] Strategy options
38
+ # @option options [Integer] :instance_index Index of named instance in fvar
39
+ # @raise [ArgumentError] If instance_index not provided
40
+ def initialize(options = {})
41
+ super
42
+ @instance_index = options[:instance_index]
43
+
44
+ if @instance_index.nil?
45
+ raise ArgumentError, "instance_index is required for NamedStrategy"
46
+ end
47
+ end
48
+
49
+ # Resolve by using named instance coordinates
50
+ #
51
+ # Extracts coordinates from the fvar table's named instance and
52
+ # delegates to InstanceStrategy for actual instance generation.
53
+ #
54
+ # @param font [TrueTypeFont, OpenTypeFont] Variable font
55
+ # @return [Hash<String, String>] Static font tables
56
+ # @raise [ArgumentError] If instance index is invalid
57
+ def resolve(font)
58
+ # Extract coordinates from named instance
59
+ coordinates = extract_coordinates(font)
60
+
61
+ # Use InstanceStrategy to generate instance
62
+ instance_strategy = InstanceStrategy.new(coordinates: coordinates)
63
+ instance_strategy.resolve(font)
64
+ end
65
+
66
+ # Check if strategy preserves variation data
67
+ #
68
+ # @return [Boolean] Always false for this strategy
69
+ def preserves_variation?
70
+ false
71
+ end
72
+
73
+ # Get strategy name
74
+ #
75
+ # @return [Symbol] :named
76
+ def strategy_name
77
+ :named
78
+ end
79
+
80
+ private
81
+
82
+ # Extract coordinates from named instance in fvar table
83
+ #
84
+ # @param font [TrueTypeFont, OpenTypeFont] Variable font
85
+ # @return [Hash<String, Float>] Design space coordinates
86
+ # @raise [ArgumentError] If instance index is invalid
87
+ def extract_coordinates(font)
88
+ context = Variation::VariationContext.new(font)
89
+
90
+ unless context.fvar
91
+ raise ArgumentError, "Font is not a variable font (no fvar table)"
92
+ end
93
+
94
+ instances = context.fvar.instances
95
+ if @instance_index.negative? || @instance_index >= instances.length
96
+ raise ArgumentError,
97
+ "Invalid instance index #{@instance_index}. " \
98
+ "Font has #{instances.length} named instances."
99
+ end
100
+
101
+ instance = instances[@instance_index]
102
+ axes = context.axes
103
+
104
+ # Map instance coordinates to axis tags
105
+ coordinates = {}
106
+ instance[:coordinates].each_with_index do |value, i|
107
+ next if i >= axes.length
108
+
109
+ axis = axes[i]
110
+ coordinates[axis.axis_tag] = value
111
+ end
112
+
113
+ coordinates
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_strategy"
4
+
5
+ module Fontisan
6
+ module Pipeline
7
+ module Strategies
8
+ # Strategy for preserving variation data during conversion
9
+ #
10
+ # This strategy maintains all variation tables intact, making it suitable
11
+ # for conversions between compatible formats:
12
+ # - Variable TTF → Variable TTF (same format)
13
+ # - Variable OTF → Variable OTF (same format)
14
+ # - Variable TTF → Variable WOFF/WOFF2 (packaging change only)
15
+ # - Variable OTF → Variable WOFF/WOFF2 (packaging change only)
16
+ #
17
+ # The strategy copies all font tables including:
18
+ # - Variation tables: fvar, gvar/CFF2, avar, HVAR, VVAR, MVAR
19
+ # - Base tables: All non-variation tables
20
+ #
21
+ # @example Preserve variation data
22
+ # strategy = PreserveStrategy.new
23
+ # tables = strategy.resolve(variable_font)
24
+ # # tables includes fvar, gvar, etc.
25
+ class PreserveStrategy < BaseStrategy
26
+ # Resolve by preserving all variation data
27
+ #
28
+ # Returns all font tables including variation tables. This is a simple
29
+ # copy operation that maintains the variable font's full capabilities.
30
+ #
31
+ # @param font [TrueTypeFont, OpenTypeFont] Variable font
32
+ # @return [Hash<String, String>] All font tables
33
+ def resolve(font)
34
+ # Return a copy of all font tables
35
+ # This preserves variation tables (fvar, gvar, CFF2, avar, HVAR, etc.)
36
+ # and all base tables
37
+ font.table_data.dup
38
+ end
39
+
40
+ # Check if strategy preserves variation data
41
+ #
42
+ # @return [Boolean] Always true for this strategy
43
+ def preserves_variation?
44
+ true
45
+ end
46
+
47
+ # Get strategy name
48
+ #
49
+ # @return [Symbol] :preserve
50
+ def strategy_name
51
+ :preserve
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -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