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
@@ -2,10 +2,9 @@
2
2
 
3
3
  require "thor"
4
4
  require_relative "base_command"
5
- require_relative "../variable/instancer"
5
+ require_relative "../variation/instance_generator"
6
+ require_relative "../variation/instance_writer"
6
7
  require_relative "../variation/validator"
7
- require_relative "../variation/parallel_generator"
8
- require_relative "../converters/format_converter"
9
8
  require_relative "../error"
10
9
 
11
10
  module Fontisan
@@ -20,19 +19,18 @@ module Fontisan
20
19
  # - Validation before generation
21
20
  # - Dry-run mode for previewing
22
21
  # - Progress tracking
23
- # - Parallel batch generation
24
22
  #
25
23
  # @example Instance at coordinates
26
24
  # fontisan instance variable.ttf --wght=700 --output=bold.ttf
27
25
  #
26
+ # @example Instance with format conversion
27
+ # fontisan instance variable.ttf --wght=700 --to=otf --output=bold.otf
28
+ #
28
29
  # @example Instance with validation
29
30
  # fontisan instance variable.ttf --wght=700 --validate --output=bold.ttf
30
31
  #
31
32
  # @example Dry-run to preview
32
33
  # fontisan instance variable.ttf --wght=700 --dry-run
33
- #
34
- # @example Instance with progress
35
- # fontisan instance variable.ttf --wght=700 --progress --output=bold.ttf
36
34
  class InstanceCommand < BaseCommand
37
35
  # Instance a variable font at specified coordinates
38
36
  #
@@ -44,18 +42,15 @@ module Fontisan
44
42
  # Validate font if requested
45
43
  validate_font(font) if options[:validate]
46
44
 
47
- # Create instancer
48
- instancer = Variable::Instancer.new(font)
49
-
50
45
  # Handle list-instances option
51
46
  if options[:list_instances]
52
- list_instances(instancer)
47
+ list_instances(font)
53
48
  return
54
49
  end
55
50
 
56
51
  # Handle dry-run mode
57
52
  if options[:dry_run]
58
- preview_instance(instancer, options)
53
+ preview_instance(font, input_path, options)
59
54
  return
60
55
  end
61
56
 
@@ -64,10 +59,9 @@ module Fontisan
64
59
 
65
60
  # Generate instance
66
61
  if options[:named_instance]
67
- instance_named(instancer, options[:named_instance], output_path,
68
- options)
62
+ instance_named(font, options[:named_instance], output_path, options)
69
63
  else
70
- instance_coords(instancer, extract_coordinates(options), output_path,
64
+ instance_coords(font, extract_coordinates(options), output_path,
71
65
  options)
72
66
  end
73
67
 
@@ -105,9 +99,10 @@ module Fontisan
105
99
 
106
100
  # Preview instance without generating
107
101
  #
108
- # @param instancer [Variable::Instancer] Instancer object
102
+ # @param font [Object] Font object
103
+ # @param input_path [String] Input file path
109
104
  # @param options [Hash] Command options
110
- def preview_instance(instancer, options)
105
+ def preview_instance(font, input_path, options)
111
106
  coords = extract_coordinates(options)
112
107
 
113
108
  if coords.empty?
@@ -122,18 +117,19 @@ module Fontisan
122
117
  puts " #{axis}: #{value}"
123
118
  end
124
119
  puts
125
- puts "Output would be written to: #{determine_output_path(@input_path, options)}"
120
+ puts "Output would be written to: #{determine_output_path(input_path, options)}"
121
+ puts "Output format: #{options[:to] || 'same as input'}"
126
122
  puts
127
123
  puts "Use without --dry-run to actually generate the instance."
128
124
  end
129
125
 
130
126
  # Instance at specific coordinates
131
127
  #
132
- # @param instancer [Variable::Instancer] Instancer object
128
+ # @param font [Object] Font object
133
129
  # @param coords [Hash] User coordinates
134
130
  # @param output_path [String] Output file path
135
131
  # @param options [Hash] Command options
136
- def instance_coords(instancer, coords, output_path, options)
132
+ def instance_coords(font, coords, output_path, options)
137
133
  if coords.empty?
138
134
  raise ArgumentError,
139
135
  "No coordinates specified. Use --wght=700, --wdth=100, etc."
@@ -142,47 +138,64 @@ module Fontisan
142
138
  # Show progress if requested
143
139
  print "Generating instance..." if options[:progress]
144
140
 
145
- # Generate instance
146
- binary = instancer.instance(coords)
141
+ # Generate instance tables using InstanceGenerator
142
+ generator = Variation::InstanceGenerator.new(font, coords)
143
+ tables = generator.generate
147
144
 
148
145
  puts " done" if options[:progress]
149
146
 
150
- # Convert format if requested
151
- if options[:to]
152
- print "Converting format..." if options[:progress]
153
- binary = convert_format(binary, options)
154
- puts " done" if options[:progress]
155
- end
156
-
157
- # Write to file
147
+ # Write instance using InstanceWriter
158
148
  print "Writing output..." if options[:progress]
159
- File.binwrite(output_path, binary)
149
+
150
+ # Detect source format for conversion
151
+ source_format = detect_source_format(font)
152
+
153
+ Variation::InstanceWriter.write(
154
+ tables,
155
+ output_path,
156
+ format: options[:to]&.to_sym,
157
+ source_format: source_format,
158
+ optimize: options[:optimize] || false,
159
+ )
160
+
160
161
  puts " done" if options[:progress]
161
162
  end
162
163
 
163
164
  # Instance using named instance
164
165
  #
165
- # @param instancer [Variable::Instancer] Instancer object
166
- # @param instance_name [String] Named instance name
166
+ # @param font [Object] Font object
167
+ # @param instance_index [Integer] Named instance index
167
168
  # @param output_path [String] Output file path
168
169
  # @param options [Hash] Command options
169
- def instance_named(instancer, instance_name, output_path, options)
170
- # Generate instance
171
- binary = instancer.instance_named(instance_name)
172
-
173
- # Convert format if requested
174
- binary = convert_format(binary, options) if options[:to]
175
-
176
- # Write to file
177
- File.binwrite(output_path, binary)
170
+ def instance_named(font, instance_index, output_path, options)
171
+ # Generate instance using named instance
172
+ generator = Variation::InstanceGenerator.new(font)
173
+ tables = generator.generate_named_instance(instance_index)
174
+
175
+ # Detect source format
176
+ source_format = detect_source_format(font)
177
+
178
+ # Write instance
179
+ Variation::InstanceWriter.write(
180
+ tables,
181
+ output_path,
182
+ format: options[:to]&.to_sym,
183
+ source_format: source_format,
184
+ optimize: options[:optimize] || false,
185
+ )
178
186
  end
179
187
 
180
188
  # List available named instances
181
189
  #
182
- # @param instancer [Variable::Instancer] Instancer object
183
- def list_instances(instancer)
184
- instances = instancer.named_instances
190
+ # @param font [Object] Font object
191
+ def list_instances(font)
192
+ fvar = font.table("fvar")
193
+ unless fvar
194
+ puts "Not a variable font - no named instances available."
195
+ return
196
+ end
185
197
 
198
+ instances = fvar.instances
186
199
  if instances.empty?
187
200
  puts "No named instances defined in font."
188
201
  return
@@ -191,11 +204,15 @@ module Fontisan
191
204
  puts "Available named instances:"
192
205
  puts
193
206
 
194
- instances.each do |instance|
195
- puts " #{instance[:name]}"
207
+ instances.each_with_index do |instance, index|
208
+ name_id = instance[:subfamily_name_id]
209
+ puts " [#{index}] Instance #{name_id}"
196
210
  puts " Coordinates:"
197
- instance[:coordinates].each do |axis, value|
198
- puts " #{axis}: #{value}"
211
+ instance[:coordinates].each_with_index do |value, axis_index|
212
+ next if axis_index >= fvar.axes.length
213
+
214
+ axis = fvar.axes[axis_index]
215
+ puts " #{axis.axis_tag}: #{value}"
199
216
  end
200
217
  puts
201
218
  end
@@ -243,38 +260,12 @@ module Fontisan
243
260
  "#{dir}/#{base}-instance.#{ext}"
244
261
  end
245
262
 
246
- # Convert format using FormatConverter
263
+ # Detect source format from font
247
264
  #
248
- # @param binary [String] Font binary
249
- # @param options [Hash] Command options
250
- # @return [String] Converted binary
251
- def convert_format(binary, options)
252
- target_format = options[:to].to_sym
253
-
254
- # Load font from binary
255
- require "tempfile"
256
- Tempfile.create(["instance", ".ttf"]) do |temp_file|
257
- temp_file.binmode
258
- temp_file.write(binary)
259
- temp_file.flush
260
-
261
- font = FontLoader.load(temp_file.path)
262
- converter = Converters::FormatConverter.new
263
-
264
- result = converter.convert(font, target_format)
265
-
266
- case target_format
267
- when :woff, :woff2
268
- result[:font_data]
269
- when :svg
270
- result[:svg_xml]
271
- else
272
- binary
273
- end
274
- end
275
- rescue StandardError => e
276
- warn "Format conversion failed: #{e.message}"
277
- binary
265
+ # @param font [Object] Font object
266
+ # @return [Symbol] Source format (:ttf or :otf)
267
+ def detect_source_format(font)
268
+ font.has_table?("CFF ") || font.has_table?("CFF2") ? :otf : :ttf
278
269
  end
279
270
 
280
271
  # Load font from file
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "base_command"
4
4
  require_relative "../validation/validator"
5
+ require_relative "../validation/variable_font_validator"
5
6
  require_relative "../font_loader"
6
7
 
7
8
  module Fontisan
@@ -53,6 +54,9 @@ quiet: false)
53
54
  # Run validation
54
55
  report = validator.validate(font, @input)
55
56
 
57
+ # Add variable font validation if applicable
58
+ validate_variable_font(font, report) if font.has_table?("fvar")
59
+
56
60
  # Output results unless quiet mode
57
61
  output_report(report) unless @quiet
58
62
 
@@ -66,6 +70,27 @@ quiet: false)
66
70
 
67
71
  private
68
72
 
73
+ # Validate variable font structure
74
+ #
75
+ # @param font [TrueTypeFont, OpenTypeFont] The font to validate
76
+ # @param report [Models::ValidationReport] The validation report to update
77
+ # @return [void]
78
+ def validate_variable_font(font, report)
79
+ var_validator = Validation::VariableFontValidator.new(font)
80
+ errors = var_validator.validate
81
+
82
+ if errors.any?
83
+ puts "\nVariable font validation:" if @verbose && !@quiet
84
+ errors.each do |error|
85
+ puts " ERROR: #{error}" if @verbose && !@quiet
86
+ # Add to report if report supports adding errors
87
+ report.errors << { message: error, category: "variable_font" } if report.respond_to?(:errors)
88
+ end
89
+ elsif @verbose && !@quiet
90
+ puts "\n✓ Variable font structure valid"
91
+ end
92
+ end
93
+
69
94
  # Validate command parameters
70
95
  #
71
96
  # @raise [ArgumentError] if parameters are invalid
@@ -23,7 +23,7 @@ required_tables:
23
23
 
24
24
  # Additional tables required for OpenType/CFF fonts (.otf)
25
25
  opentype_cff:
26
- - CFF # or CFF2 for CFF2 format
26
+ - "CFF " # or CFF2 for CFF2 format (note: CFF has trailing space per OpenType spec)
27
27
 
28
28
  # Additional tables required for variable fonts
29
29
  variable:
@@ -87,6 +87,16 @@ module Fontisan
87
87
  # CFF2 table tag identifier (CFF version 2 with variations)
88
88
  CFF2_TAG = "CFF2"
89
89
 
90
+ # TrueType hinting tables
91
+ # Font Program table (TrueType bytecode executed once at font load)
92
+ FPGM_TAG = "fpgm"
93
+
94
+ # Control Value Program table (TrueType bytecode for initialization)
95
+ PREP_TAG = "prep"
96
+
97
+ # Control Value Table (metrics used by TrueType hinting)
98
+ CVT_TAG = "cvt "
99
+
90
100
  # Magic number used for font file checksum adjustment calculation.
91
101
  # This constant is used in conjunction with the file checksum to compute
92
102
  # the checksumAdjustment value stored in the 'head' table.
@@ -73,20 +73,50 @@ module Fontisan
73
73
  # @param font [TrueTypeFont, OpenTypeFont] Source font
74
74
  # @param target_format [Symbol] Target format (:ttf, :otf, :woff2, :svg)
75
75
  # @param options [Hash] Additional conversion options
76
+ # @option options [Boolean] :preserve_variation Preserve variation data
77
+ # (default: true)
78
+ # @option options [Boolean] :preserve_hints Preserve rendering hints
79
+ # (default: false)
80
+ # @option options [Hash] :instance_coordinates Coordinates for variable→SVG
81
+ # @option options [Integer] :instance_index Named instance index for variable→SVG
76
82
  # @return [Hash<String, String>] Map of table tags to binary data
77
83
  # @raise [ArgumentError] If parameters are invalid
78
84
  # @raise [Error] If conversion is not supported
79
85
  #
80
86
  # @example
81
87
  # tables = converter.convert(font, :otf)
88
+ #
89
+ # @example Variable font to SVG at specific weight
90
+ # result = converter.convert(variable_font, :svg, instance_coordinates: { "wght" => 700.0 })
91
+ #
92
+ # @example Convert with hint preservation
93
+ # tables = converter.convert(font, :otf, preserve_hints: true)
82
94
  def convert(font, target_format, options = {})
83
95
  validate_parameters!(font, target_format)
84
96
 
85
97
  source_format = detect_format(font)
86
98
  validate_conversion_supported!(source_format, target_format)
87
99
 
100
+ # Special case: Variable font to SVG
101
+ if variable_font?(font) && target_format == :svg
102
+ return convert_variable_to_svg(font, options)
103
+ end
104
+
88
105
  strategy = select_strategy(source_format, target_format)
89
- strategy.convert(font, options.merge(target_format: target_format))
106
+ tables = strategy.convert(font, options.merge(target_format: target_format))
107
+
108
+ # Preserve variation data if requested and font is variable
109
+ if options.fetch(:preserve_variation, true) && variable_font?(font)
110
+ tables = preserve_variation_data(
111
+ font,
112
+ tables,
113
+ source_format,
114
+ target_format,
115
+ options,
116
+ )
117
+ end
118
+
119
+ tables
90
120
  end
91
121
 
92
122
  # Check if a conversion is supported
@@ -137,6 +167,125 @@ module Fontisan
137
167
 
138
168
  private
139
169
 
170
+ # Convert variable font to SVG at specific coordinates
171
+ #
172
+ # @param font [TrueTypeFont, OpenTypeFont] Variable font
173
+ # @param options [Hash] Conversion options
174
+ # @option options [Hash] :instance_coordinates Design space coordinates
175
+ # @option options [Integer] :instance_index Named instance index
176
+ # @return [Hash] Hash with :svg_xml key
177
+ def convert_variable_to_svg(font, options = {})
178
+ require_relative "../variation/variable_svg_generator"
179
+
180
+ coordinates = options[:instance_coordinates] || {}
181
+ generator = Variation::VariableSvgGenerator.new(font, coordinates)
182
+
183
+ # Use named instance if specified
184
+ if options[:instance_index]
185
+ generator.generate_named_instance(options[:instance_index], options)
186
+ else
187
+ generator.generate(options)
188
+ end
189
+ end
190
+
191
+ # Check if font is a variable font
192
+ #
193
+ # @param font [TrueTypeFont, OpenTypeFont] Font to check
194
+ # @return [Boolean] True if font has fvar table
195
+ def variable_font?(font)
196
+ font.has_table?("fvar")
197
+ end
198
+
199
+ # Preserve variation data from source to target
200
+ #
201
+ # @param font [TrueTypeFont, OpenTypeFont] Source font
202
+ # @param tables [Hash<String, String>] Target tables
203
+ # @param source_format [Symbol] Source format
204
+ # @param target_format [Symbol] Target format
205
+ # @param options [Hash] Preservation options
206
+ # @return [Hash<String, String>] Tables with variation preserved
207
+ def preserve_variation_data(font, tables, source_format, target_format, options)
208
+ # Case 1: Compatible formats (same outline format) - just copy tables
209
+ if compatible_variation_formats?(source_format, target_format)
210
+ require_relative "../variation/variation_preserver"
211
+ Variation::VariationPreserver.preserve(font, tables, options)
212
+
213
+ # Case 2: Different outline formats - convert variation data
214
+ elsif convertible_variation_formats?(source_format, target_format)
215
+ convert_variation_data(font, tables, source_format, target_format, options)
216
+
217
+ # Case 3: Unsupported conversion
218
+ else
219
+ if options[:preserve_variation]
220
+ raise Fontisan::Error,
221
+ "Cannot preserve variation data for " \
222
+ "#{source_format} → #{target_format}"
223
+ end
224
+ tables
225
+ end
226
+ end
227
+
228
+ # Check if formats have compatible variation (same outline format)
229
+ #
230
+ # @param source [Symbol] Source format
231
+ # @param target [Symbol] Target format
232
+ # @return [Boolean] True if compatible
233
+ def compatible_variation_formats?(source, target)
234
+ # Same format (copy operation)
235
+ return true if source == target
236
+
237
+ # Same outline format (just packaging change)
238
+ (source == :ttf && target == :woff) ||
239
+ (source == :otf && target == :woff) ||
240
+ (source == :woff && target == :ttf) ||
241
+ (source == :woff && target == :otf) ||
242
+ (source == :ttf && target == :woff2) ||
243
+ (source == :otf && target == :woff2)
244
+ end
245
+
246
+ # Check if formats allow variation conversion (different outline formats)
247
+ #
248
+ # @param source [Symbol] Source format
249
+ # @param target [Symbol] Target format
250
+ # @return [Boolean] True if convertible
251
+ def convertible_variation_formats?(source, target)
252
+ # Different outline formats (need variation conversion)
253
+ (source == :ttf && target == :otf) ||
254
+ (source == :otf && target == :ttf)
255
+ end
256
+
257
+ # Convert variation data between outline formats
258
+ #
259
+ # This is a placeholder for full TTF↔OTF variation conversion.
260
+ # Full implementation would:
261
+ # 1. Use Variation::Converter to convert gvar ↔ CFF2 blend
262
+ # 2. Build appropriate variation tables for target format
263
+ # 3. Preserve common tables (fvar, avar, STAT, metrics)
264
+ #
265
+ # @param font [TrueTypeFont, OpenTypeFont] Source font
266
+ # @param tables [Hash<String, String>] Target tables
267
+ # @param source_format [Symbol] Source format
268
+ # @param target_format [Symbol] Target format
269
+ # @param options [Hash] Conversion options
270
+ # @return [Hash<String, String>] Tables with converted variation
271
+ def convert_variation_data(font, tables, source_format, target_format, _options)
272
+ require_relative "../variation/variation_preserver"
273
+ require_relative "../variation/converter"
274
+
275
+ # For now, just preserve common tables and warn about conversion
276
+ warn "WARNING: Full variation conversion (#{source_format} → " \
277
+ "#{target_format}) not yet implemented. " \
278
+ "Preserving common variation tables only."
279
+
280
+ # Preserve common tables (fvar, avar, STAT) but not format-specific
281
+ Variation::VariationPreserver.preserve(
282
+ font,
283
+ tables,
284
+ preserve_format_specific: false,
285
+ preserve_metrics: true,
286
+ )
287
+ end
288
+
140
289
  # Load conversion matrix from YAML config
141
290
  #
142
291
  # @param path [String, nil] Path to config file
@@ -153,6 +153,22 @@ module Fontisan
153
153
  # Update head table for CFF
154
154
  tables["head"] = update_head_for_cff(font)
155
155
 
156
+ # Convert and apply hints if preservation is enabled
157
+ if @preserve_hints && hints_per_glyph.any?
158
+ # Extract font-level hints separately
159
+ hint_set = extract_ttf_hint_set(font)
160
+
161
+ unless hint_set.empty?
162
+ # Convert TrueType hints to PostScript format
163
+ converter = Hints::HintConverter.new
164
+ ps_hint_set = converter.convert_hint_set(hint_set, :postscript)
165
+
166
+ # Apply PostScript hints (validation mode - CFF modification pending)
167
+ applier = Hints::PostScriptHintApplier.new
168
+ tables = applier.apply(ps_hint_set, tables)
169
+ end
170
+ end
171
+
156
172
  tables
157
173
  end
158
174
 
@@ -183,6 +199,22 @@ module Fontisan
183
199
  # Update head table for TrueType
184
200
  tables["head"] = update_head_for_truetype(font, loca_format)
185
201
 
202
+ # Convert and apply hints if preservation is enabled
203
+ if @preserve_hints && hints_per_glyph.any?
204
+ # Extract font-level hints separately
205
+ hint_set = extract_cff_hint_set(font)
206
+
207
+ unless hint_set.empty?
208
+ # Convert PostScript hints to TrueType format
209
+ converter = Hints::HintConverter.new
210
+ tt_hint_set = converter.convert_hint_set(hint_set, :truetype)
211
+
212
+ # Apply TrueType hints (writes fpgm/prep/cvt tables)
213
+ applier = Hints::TrueTypeHintApplier.new
214
+ tables = applier.apply(tt_hint_set, tables)
215
+ end
216
+ end
217
+
186
218
  tables
187
219
  end
188
220
 
@@ -493,19 +525,8 @@ module Fontisan
493
525
  next
494
526
  end
495
527
 
496
- # Convert outline to TrueType contours
497
- contours = outline.to_truetype_contours
498
-
499
- # Build glyph data
500
- builder = Tables::Glyf::GlyphBuilder.new(
501
- contours: contours,
502
- x_min: outline.bbox[:x_min],
503
- y_min: outline.bbox[:y_min],
504
- x_max: outline.bbox[:x_max],
505
- y_max: outline.bbox[:y_max],
506
- )
507
-
508
- glyph_data = builder.build
528
+ # Build glyph data using GlyphBuilder class method
529
+ glyph_data = Fontisan::Tables::GlyphBuilder.build_simple_glyph(outline)
509
530
  glyf_data << glyph_data
510
531
 
511
532
  # Add padding to 4-byte boundary
@@ -835,19 +856,31 @@ module Fontisan
835
856
  def validate_source_tables(font, format)
836
857
  case format
837
858
  when :ttf
838
- unless font.has_table?("glyf") && font.has_table?("loca") &&
839
- font.table("glyf") && font.table("loca")
859
+ unless font.has_table?("glyf") && font.has_table?("loca")
860
+ raise Fontisan::MissingTableError,
861
+ "TrueType font missing required glyf or loca table"
862
+ end
863
+ # Also verify tables can actually be loaded
864
+ unless font.table("glyf") && font.table("loca")
840
865
  raise Fontisan::MissingTableError,
841
866
  "TrueType font missing required glyf or loca table"
842
867
  end
843
868
  when :cff2
844
- unless font.has_table?("CFF2") && font.table("CFF2")
869
+ unless font.has_table?("CFF2")
870
+ raise Fontisan::MissingTableError,
871
+ "CFF2 font missing required CFF2 table"
872
+ end
873
+ unless font.table("CFF2")
845
874
  raise Fontisan::MissingTableError,
846
875
  "CFF2 font missing required CFF2 table"
847
876
  end
848
877
  when :otf
849
- unless (font.has_table?("CFF ") && font.table("CFF ")) ||
850
- (font.has_table?("CFF2") && font.table("CFF2"))
878
+ unless font.has_table?("CFF ") || font.has_table?("CFF2")
879
+ raise Fontisan::MissingTableError,
880
+ "OpenType font missing required CFF or CFF2 table"
881
+ end
882
+ # Verify at least one can be loaded
883
+ unless font.table("CFF ") || font.table("CFF2")
851
884
  raise Fontisan::MissingTableError,
852
885
  "OpenType font missing required CFF or CFF2 table"
853
886
  end
@@ -855,6 +888,11 @@ module Fontisan
855
888
 
856
889
  # Common required tables
857
890
  %w[head hhea maxp].each do |tag|
891
+ unless font.has_table?(tag)
892
+ raise Fontisan::MissingTableError,
893
+ "Font missing required #{tag} table"
894
+ end
895
+ # Verify table can actually be loaded
858
896
  unless font.table(tag)
859
897
  raise Fontisan::MissingTableError,
860
898
  "Font missing required #{tag} table"
@@ -924,6 +962,30 @@ module Fontisan
924
962
  {}
925
963
  end
926
964
 
965
+ # Extract complete TrueType hint set from font
966
+ #
967
+ # @param font [TrueTypeFont] Source font
968
+ # @return [HintSet] Complete hint set
969
+ def extract_ttf_hint_set(font)
970
+ extractor = Hints::TrueTypeHintExtractor.new
971
+ extractor.extract_from_font(font)
972
+ rescue StandardError => e
973
+ warn "Failed to extract TrueType hint set: #{e.message}"
974
+ Models::HintSet.new(format: :truetype)
975
+ end
976
+
977
+ # Extract complete PostScript hint set from font
978
+ #
979
+ # @param font [OpenTypeFont] Source font
980
+ # @return [HintSet] Complete hint set
981
+ def extract_cff_hint_set(font)
982
+ extractor = Hints::PostScriptHintExtractor.new
983
+ extractor.extract_from_font(font)
984
+ rescue StandardError => e
985
+ warn "Failed to extract PostScript hint set: #{e.message}"
986
+ Models::HintSet.new(format: :postscript)
987
+ end
988
+
927
989
  # Check if font is a variable font
928
990
  #
929
991
  # @param font [TrueTypeFont, OpenTypeFont] Font to check
@@ -364,7 +364,7 @@ module Fontisan
364
364
  # Sort tables by tag for consistent output (same order as directory)
365
365
  sorted_tables = compressed_tables.sort_by { |tag, _| tag }
366
366
 
367
- sorted_tables.each_value do |table_info|
367
+ sorted_tables.each do |_tag, table_info|
368
368
  io.write(table_info[:compressed_data])
369
369
  end
370
370
  end
@@ -63,14 +63,12 @@ module Fontisan
63
63
  when "OTTO"
64
64
  OpenTypeFont.from_file(path, mode: resolved_mode, lazy: resolved_lazy)
65
65
  when "wOFF"
66
- raise UnsupportedFormatError,
67
- "Unsupported font format: WOFF. Please convert to TTF/OTF first."
66
+ WoffFont.from_file(path, mode: resolved_mode, lazy: resolved_lazy)
68
67
  when "wOF2"
69
- raise UnsupportedFormatError,
70
- "Unsupported font format: WOFF2. Please convert to TTF/OTF first."
68
+ Woff2Font.from_file(path, mode: resolved_mode, lazy: resolved_lazy)
71
69
  else
72
70
  raise InvalidFontError,
73
- "Unknown font format. Expected TTF, OTF, TTC, or OTC file."
71
+ "Unknown font format. Expected TTF, OTF, TTC, OTC, WOFF, or WOFF2 file."
74
72
  end
75
73
  end
76
74
  end