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
@@ -258,6 +258,20 @@ module Fontisan
258
258
  true
259
259
  end
260
260
 
261
+ # Check if font is TrueType flavored
262
+ #
263
+ # @return [Boolean] false for OpenType fonts
264
+ def truetype?
265
+ false
266
+ end
267
+
268
+ # Check if font is CFF flavored
269
+ #
270
+ # @return [Boolean] true for OpenType fonts
271
+ def cff?
272
+ true
273
+ end
274
+
261
275
  # Check if font has a specific table
262
276
  #
263
277
  # @param tag [String] The table tag to check for
@@ -490,6 +504,7 @@ module Fontisan
490
504
  Constants::OS2_TAG => Tables::Os2,
491
505
  Constants::POST_TAG => Tables::Post,
492
506
  Constants::CMAP_TAG => Tables::Cmap,
507
+ Constants::CFF_TAG => Tables::Cff,
493
508
  Constants::FVAR_TAG => Tables::Fvar,
494
509
  Constants::GSUB_TAG => Tables::Gsub,
495
510
  Constants::GPOS_TAG => Tables::Gpos,
@@ -558,18 +573,19 @@ module Fontisan
558
573
  # @param path [String] Path to the OTF file
559
574
  # @return [void]
560
575
  def update_checksum_adjustment_in_file(path)
561
- # Calculate file checksum
562
- checksum = Utilities::ChecksumCalculator.calculate_file_checksum(path)
576
+ # Use tempfile-based checksum calculation for Windows compatibility
577
+ # This keeps the tempfile alive until we're done with the checksum
578
+ File.open(path, "r+b") do |io|
579
+ checksum, _tmpfile = Utilities::ChecksumCalculator.calculate_checksum_from_io_with_tempfile(io)
563
580
 
564
- # Calculate adjustment
565
- adjustment = Utilities::ChecksumCalculator.calculate_adjustment(checksum)
581
+ # Calculate adjustment
582
+ adjustment = Utilities::ChecksumCalculator.calculate_adjustment(checksum)
566
583
 
567
- # Find head table position
568
- head_entry = head_table
569
- return unless head_entry
584
+ # Find head table position
585
+ head_entry = head_table
586
+ return unless head_entry
570
587
 
571
- # Write adjustment to head table (offset 8 within head table)
572
- File.open(path, "r+b") do |io|
588
+ # Write adjustment to head table (offset 8 within head table)
573
589
  io.seek(head_entry.offset + 8)
574
590
  io.write([adjustment].pack("N"))
575
591
  end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ # Extensions to OpenTypeFont for table-based construction
5
+ class OpenTypeFont
6
+ # Create font from hash of tables
7
+ #
8
+ # This is used during font conversion when we have tables but not a file.
9
+ #
10
+ # @param tables [Hash<String, String>] Map of table tag to binary data
11
+ # @return [OpenTypeFont] New font instance
12
+ def self.from_tables(tables)
13
+ # Create minimal header structure
14
+ font = new
15
+ font.initialize_storage
16
+ font.loading_mode = LoadingModes::FULL
17
+
18
+ # Store table data
19
+ font.table_data = tables
20
+
21
+ # Build header from tables
22
+ num_tables = tables.size
23
+ max_power = 0
24
+ n = num_tables
25
+ while n > 1
26
+ n >>= 1
27
+ max_power += 1
28
+ end
29
+
30
+ search_range = (1 << max_power) * 16
31
+ entry_selector = max_power
32
+ range_shift = (num_tables * 16) - search_range
33
+
34
+ font.header.sfnt_version = 0x4F54544F # 'OTTO' for OpenType/CFF
35
+ font.header.num_tables = num_tables
36
+ font.header.search_range = search_range
37
+ font.header.entry_selector = entry_selector
38
+ font.header.range_shift = range_shift
39
+
40
+ # Build table directory
41
+ font.tables.clear
42
+ tables.each_key do |tag|
43
+ entry = TableDirectory.new
44
+ entry.tag = tag
45
+ entry.checksum = 0 # Will be calculated on write
46
+ entry.offset = 0 # Will be calculated on write
47
+ entry.table_length = tables[tag].bytesize
48
+ font.tables << entry
49
+ end
50
+
51
+ font
52
+ end
53
+ end
54
+ 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
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../font_writer"
4
+
5
+ module Fontisan
6
+ module Pipeline
7
+ # Handles writing font tables to various output formats
8
+ #
9
+ # This class abstracts the complexity of writing different font formats:
10
+ # - SFNT formats (TTF, OTF) via FontWriter
11
+ # - WOFF via WoffWriter
12
+ # - WOFF2 via Woff2Encoder
13
+ #
14
+ # Single Responsibility: Coordinate output writing for different formats
15
+ #
16
+ # @example Write TTF font
17
+ # writer = OutputWriter.new("output.ttf", :ttf)
18
+ # writer.write(tables)
19
+ #
20
+ # @example Write OTF font
21
+ # writer = OutputWriter.new("output.otf", :otf)
22
+ # writer.write(tables)
23
+ class OutputWriter
24
+ # @return [String] Output file path
25
+ attr_reader :output_path
26
+
27
+ # @return [Symbol] Target format
28
+ attr_reader :format
29
+
30
+ # @return [Hash] Writing options
31
+ attr_reader :options
32
+
33
+ # Initialize output writer
34
+ #
35
+ # @param output_path [String] Path to write output
36
+ # @param format [Symbol] Target format (:ttf, :otf, :woff, :woff2)
37
+ # @param options [Hash] Writing options
38
+ def initialize(output_path, format, options = {})
39
+ @output_path = output_path
40
+ @format = format
41
+ @options = options
42
+ end
43
+
44
+ # Write font tables to output file
45
+ #
46
+ # @param tables [Hash<String, String>, Hash] Font tables (tag => binary data) or special format result
47
+ # @return [Integer] Number of bytes written
48
+ # @raise [ArgumentError] If format is unsupported
49
+ def write(tables)
50
+ case @format
51
+ when :ttf, :otf
52
+ write_sfnt(tables)
53
+ when :woff
54
+ write_woff(tables)
55
+ when :woff2
56
+ write_woff2(tables)
57
+ when :svg
58
+ write_svg(tables)
59
+ else
60
+ raise ArgumentError, "Unsupported output format: #{@format}"
61
+ end
62
+ end
63
+
64
+ private
65
+
66
+ # Write SVG format
67
+ #
68
+ # @param result [Hash] Result with :svg_xml key
69
+ # @return [Integer] Number of bytes written
70
+ def write_svg(result)
71
+ svg_xml = result[:svg_xml] || result["svg_xml"]
72
+ raise ArgumentError, "SVG result must contain :svg_xml key" unless svg_xml
73
+
74
+ File.write(@output_path, svg_xml)
75
+ end
76
+
77
+ # Write SFNT format (TTF or OTF)
78
+ #
79
+ # @param tables [Hash<String, String>] Font tables
80
+ # @return [Integer] Number of bytes written
81
+ def write_sfnt(tables)
82
+ sfnt_version = determine_sfnt_version
83
+ FontWriter.write_to_file(tables, @output_path, sfnt_version: sfnt_version)
84
+ end
85
+
86
+ # Write WOFF format
87
+ #
88
+ # @param tables [Hash<String, String>] Font tables
89
+ # @return [Integer] Number of bytes written
90
+ def write_woff(tables)
91
+ require_relative "../converters/woff_writer"
92
+
93
+ writer = Converters::WoffWriter.new
94
+ font = build_font_from_tables(tables)
95
+ result = writer.convert(font, @options)
96
+
97
+ File.binwrite(@output_path, result[:woff_data])
98
+ end
99
+
100
+ # Write WOFF2 format
101
+ #
102
+ # @param tables [Hash<String, String>] Font tables
103
+ # @return [Integer] Number of bytes written
104
+ def write_woff2(tables)
105
+ require_relative "../converters/woff2_encoder"
106
+
107
+ encoder = Converters::Woff2Encoder.new
108
+ font = build_font_from_tables(tables)
109
+ result = encoder.convert(font, @options)
110
+
111
+ File.binwrite(@output_path, result[:woff2_binary])
112
+ end
113
+
114
+ # Determine SFNT version based on format and tables
115
+ #
116
+ # @return [Integer] SFNT version (0x00010000 for TTF, 0x4F54544F for OTF)
117
+ def determine_sfnt_version
118
+ case @format
119
+ when :ttf, :woff, :woff2 then 0x00010000
120
+ when :otf then 0x4F54544F # 'OTTO'
121
+ else raise ArgumentError, "Unsupported format: #{@format}"
122
+ end
123
+ end
124
+
125
+ # Build font object from tables
126
+ #
127
+ # Helper to create font object from tables for converters that need it.
128
+ #
129
+ # @param tables [Hash<String, String>] Font tables
130
+ # @return [Font] Font object
131
+ def build_font_from_tables(tables)
132
+ # Detect font type from tables
133
+ has_cff = tables.key?("CFF ") || tables.key?("CFF2")
134
+ has_glyf = tables.key?("glyf")
135
+
136
+ if has_cff
137
+ OpenTypeFont.from_tables(tables)
138
+ elsif has_glyf
139
+ TrueTypeFont.from_tables(tables)
140
+ else
141
+ # Default based on format
142
+ case @format
143
+ when :ttf, :woff, :woff2
144
+ TrueTypeFont.from_tables(tables)
145
+ when :otf
146
+ OpenTypeFont.from_tables(tables)
147
+ else
148
+ raise ArgumentError, "Cannot determine font type for format: #{@format}"
149
+ end
150
+ end
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Pipeline
5
+ module Strategies
6
+ # Base class for variation resolution strategies
7
+ #
8
+ # This abstract class defines the interface that all variation resolution
9
+ # strategies must implement. It follows the Strategy pattern to allow
10
+ # different approaches to handling variable font data during conversion.
11
+ #
12
+ # Subclasses must implement:
13
+ # - resolve(font): Process the font and return tables
14
+ # - preserves_variation?: Indicate if variation data is preserved
15
+ # - strategy_name: Return the strategy identifier
16
+ #
17
+ # @example Implementing a strategy
18
+ # class MyStrategy < BaseStrategy
19
+ # def resolve(font)
20
+ # # Implementation
21
+ # end
22
+ #
23
+ # def preserves_variation?
24
+ # false
25
+ # end
26
+ #
27
+ # def strategy_name
28
+ # :my_strategy
29
+ # end
30
+ # end
31
+ class BaseStrategy
32
+ # @return [Hash] Strategy options
33
+ attr_reader :options
34
+
35
+ # Initialize strategy with options
36
+ #
37
+ # @param options [Hash] Strategy-specific options
38
+ def initialize(options = {})
39
+ @options = options
40
+ end
41
+
42
+ # Resolve variation data
43
+ #
44
+ # This method must be implemented by subclasses to process the font
45
+ # and return the appropriate tables based on the strategy.
46
+ #
47
+ # @param font [TrueTypeFont, OpenTypeFont] Font to process
48
+ # @return [Hash<String, String>] Map of table tags to binary data
49
+ # @raise [NotImplementedError] If not implemented by subclass
50
+ def resolve(font)
51
+ raise NotImplementedError,
52
+ "#{self.class.name} must implement #resolve"
53
+ end
54
+
55
+ # Check if strategy preserves variation data
56
+ #
57
+ # @return [Boolean] True if variation data is preserved
58
+ # @raise [NotImplementedError] If not implemented by subclass
59
+ def preserves_variation?
60
+ raise NotImplementedError,
61
+ "#{self.class.name} must implement #preserves_variation?"
62
+ end
63
+
64
+ # Get strategy name
65
+ #
66
+ # @return [Symbol] Strategy identifier
67
+ # @raise [NotImplementedError] If not implemented by subclass
68
+ def strategy_name
69
+ raise NotImplementedError,
70
+ "#{self.class.name} must implement #strategy_name"
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_strategy"
4
+ require_relative "../../variation/instance_generator"
5
+ require_relative "../../variation/variation_context"
6
+
7
+ module Fontisan
8
+ module Pipeline
9
+ module Strategies
10
+ # Strategy for generating static instances from variable fonts
11
+ #
12
+ # This strategy creates a static font instance at specific design space
13
+ # coordinates by applying variation deltas and removing variation tables.
14
+ # It's used for:
15
+ # - Variable TTF → Static TTF at specific weight
16
+ # - Variable OTF → Static OTF at specific coordinates
17
+ # - Variable → Static for any format conversion
18
+ #
19
+ # The strategy uses the InstanceGenerator to:
20
+ # 1. Apply variation deltas (gvar or CFF2 blend)
21
+ # 2. Apply metrics variations (HVAR, VVAR, MVAR)
22
+ # 3. Remove variation tables (fvar, gvar, CFF2, avar, etc.)
23
+ #
24
+ # If no coordinates are provided, uses default coordinates (axis default values).
25
+ #
26
+ # @example Generate instance at specific weight
27
+ # strategy = InstanceStrategy.new(coordinates: { "wght" => 700.0 })
28
+ # tables = strategy.resolve(variable_font)
29
+ # # tables has no variation tables
30
+ #
31
+ # @example Generate instance at default coordinates
32
+ # strategy = InstanceStrategy.new
33
+ # tables = strategy.resolve(variable_font)
34
+ class InstanceStrategy < BaseStrategy
35
+ # @return [Hash<String, Float>] Design space coordinates
36
+ attr_reader :coordinates
37
+
38
+ # Initialize strategy with coordinates
39
+ #
40
+ # @param options [Hash] Strategy options
41
+ # @option options [Hash<String, Float>] :coordinates Design space coordinates
42
+ # (axis tag => value). If not provided, uses default coordinates.
43
+ def initialize(options = {})
44
+ super
45
+ @coordinates = options[:coordinates] || {}
46
+ end
47
+
48
+ # Resolve by generating static instance
49
+ #
50
+ # Creates a static font instance at the specified coordinates using
51
+ # the InstanceGenerator. If coordinates are not provided, uses the
52
+ # default coordinates from the font's axes.
53
+ #
54
+ # @param font [TrueTypeFont, OpenTypeFont] Variable font
55
+ # @return [Hash<String, String>] Static font tables
56
+ # @raise [Variation::InvalidCoordinatesError] If coordinates out of range
57
+ def resolve(font)
58
+ # Validate coordinates if provided
59
+ validate_coordinates(font) unless @coordinates.empty?
60
+
61
+ # Use InstanceGenerator to create static instance
62
+ generator = Variation::InstanceGenerator.new(font, @coordinates)
63
+ generator.generate
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] :instance
76
+ def strategy_name
77
+ :instance
78
+ end
79
+
80
+ private
81
+
82
+ # Validate coordinates against font axes
83
+ #
84
+ # @param font [TrueTypeFont, OpenTypeFont] Variable font
85
+ # @raise [Variation::InvalidCoordinatesError] If invalid
86
+ def validate_coordinates(font)
87
+ context = Variation::VariationContext.new(font)
88
+ context.validate_coordinates(@coordinates)
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end