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
@@ -15,6 +15,12 @@ module Fontisan
15
15
  # sharing_map = deduplicator.build_sharing_map
16
16
  # canonical_tables = deduplicator.canonical_tables
17
17
  class TableDeduplicator
18
+ # Tables that can be shared in variable font collections if identical
19
+ VARIATION_SHAREABLE_TABLES = %w[fvar avar STAT HVAR VVAR MVAR].freeze
20
+
21
+ # Tables that must remain font-specific in variable fonts
22
+ VARIATION_FONT_SPECIFIC_TABLES = %w[gvar CFF2].freeze
23
+
18
24
  # Canonical tables (unique table data)
19
25
  # @return [Hash<String, Hash>] Map of table tag to canonical versions
20
26
  attr_reader :canonical_tables
@@ -62,6 +68,9 @@ module Fontisan
62
68
  # First pass: collect all unique tables
63
69
  collect_canonical_tables
64
70
 
71
+ # Handle variable font table deduplication
72
+ deduplicate_variation_tables if has_variable_fonts?
73
+
65
74
  # Second pass: build sharing map for each font
66
75
  build_font_sharing_references
67
76
 
@@ -115,6 +124,73 @@ module Fontisan
115
124
 
116
125
  private
117
126
 
127
+ # Check if any font is a variable font
128
+ #
129
+ # @return [Boolean] true if any font has fvar table
130
+ def has_variable_fonts?
131
+ @fonts.any? { |font| font.has_table?("fvar") }
132
+ end
133
+
134
+ # Deduplicate variable font tables
135
+ #
136
+ # Handles special logic for variable font tables:
137
+ # - Share tables that are identical across fonts (fvar, avar, etc.)
138
+ # - Keep font-specific tables separate (gvar, CFF2)
139
+ #
140
+ # @return [void]
141
+ def deduplicate_variation_tables
142
+ # Share tables that are identical across fonts
143
+ VARIATION_SHAREABLE_TABLES.each do |tag|
144
+ share_if_identical(tag)
145
+ end
146
+
147
+ # Never share font-specific variation tables
148
+ VARIATION_FONT_SPECIFIC_TABLES.each do |tag|
149
+ keep_separate(tag)
150
+ end
151
+ end
152
+
153
+ # Share table if identical across all fonts that have it
154
+ #
155
+ # @param tag [String] Table tag
156
+ # @return [void]
157
+ def share_if_identical(tag)
158
+ # Get all instances of this table
159
+ tables = @fonts.map { |f| f.table_data[tag] }.compact
160
+ return if tables.empty?
161
+
162
+ # Check if all instances are identical
163
+ nil if tables.uniq.length > 1
164
+
165
+ # All instances are identical, mark as shareable
166
+ # The normal deduplication logic will handle this
167
+ end
168
+
169
+ # Ensure table stays separate for each font
170
+ #
171
+ # @param tag [String] Table tag
172
+ # @return [void]
173
+ def keep_separate(tag)
174
+ # Mark each font's instance as non-shareable
175
+ @fonts.each_with_index do |font, _font_index|
176
+ next unless font.has_table?(tag)
177
+
178
+ # Find this font's canonical table for this tag
179
+ table_data = font.table_data[tag]
180
+ checksum = calculate_checksum(table_data)
181
+
182
+ # Ensure canonical table exists
183
+ @canonical_tables[tag] ||= {}
184
+ canonical_id = @checksum_to_canonical.dig(tag, checksum)
185
+
186
+ next unless canonical_id && @canonical_tables[tag][canonical_id]
187
+
188
+ # Mark as non-shareable
189
+ @canonical_tables[tag][canonical_id][:shared] = false
190
+ @canonical_tables[tag][canonical_id][:font_specific] = true
191
+ end
192
+ end
193
+
118
194
  # Collect all unique (canonical) tables across all fonts
119
195
  #
120
196
  # Identifies unique table content based on checksum and stores one
@@ -66,6 +66,22 @@ module Fontisan
66
66
  # @raise [InvalidFontError] for corrupted or unknown formats
67
67
  # @raise [Error] for other loading failures
68
68
  def load_font
69
+ # BaseCommand is for inspection - reject compressed formats first
70
+ # Check file signature before attempting to load
71
+ File.open(@font_path, "rb") do |io|
72
+ signature = io.read(4)
73
+
74
+ if signature == "wOFF"
75
+ raise UnsupportedFormatError,
76
+ "Unsupported font format: WOFF files must be decompressed first. " \
77
+ "Use ConvertCommand to convert WOFF to TTF/OTF."
78
+ elsif signature == "wOF2"
79
+ raise UnsupportedFormatError,
80
+ "Unsupported font format: WOFF2 files must be decompressed first. " \
81
+ "Use ConvertCommand to convert WOFF2 to TTF/OTF."
82
+ end
83
+ end
84
+
69
85
  # ConvertCommand and similar commands need all tables loaded upfront
70
86
  # Use mode and lazy from options, or sensible defaults
71
87
  FontLoader.load(
@@ -1,20 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "base_command"
4
- require_relative "../converters/format_converter"
5
- require_relative "../font_writer"
4
+ require_relative "../pipeline/transformation_pipeline"
6
5
 
7
6
  module Fontisan
8
7
  module Commands
9
8
  # Command for converting fonts between formats
10
9
  #
11
10
  # [`ConvertCommand`](lib/fontisan/commands/convert_command.rb) provides
12
- # CLI interface for font format conversion operations. It supports:
11
+ # CLI interface for font format conversion operations using the universal
12
+ # transformation pipeline. It supports:
13
13
  # - Same-format operations (copy/optimize)
14
- # - TTF ↔ OTF outline format conversion (foundation)
15
- # - Future: WOFF/WOFF2 compression, SVG export
14
+ # - TTF ↔ OTF outline format conversion
15
+ # - Variable font operations (preserve/instance generation)
16
+ # - WOFF/WOFF2 compression
16
17
  #
17
- # The command uses [`FormatConverter`](lib/fontisan/converters/format_converter.rb)
18
+ # The command uses [`TransformationPipeline`](lib/fontisan/pipeline/transformation_pipeline.rb)
18
19
  # to orchestrate conversions with appropriate strategies.
19
20
  #
20
21
  # @example Convert TTF to OTF
@@ -25,11 +26,12 @@ module Fontisan
25
26
  # )
26
27
  # command.run
27
28
  #
28
- # @example Copy/optimize same format
29
+ # @example Generate instance at coordinates
29
30
  # command = ConvertCommand.new(
30
- # 'input.ttf',
31
+ # 'variable.ttf',
31
32
  # to: 'ttf',
32
- # output: 'optimized.ttf'
33
+ # output: 'bold.ttf',
34
+ # coordinates: 'wght=700,wdth=100'
33
35
  # )
34
36
  # command.run
35
37
  class ConvertCommand < BaseCommand
@@ -37,24 +39,34 @@ module Fontisan
37
39
  #
38
40
  # @param font_path [String] Path to input font file
39
41
  # @param options [Hash] Conversion options
40
- # @option options [String] :to Target format (ttf, otf, woff2, svg)
42
+ # @option options [String] :to Target format (ttf, otf, woff, woff2)
41
43
  # @option options [String] :output Output file path (required)
42
44
  # @option options [Integer] :font_index Index for TTC/OTC (default: 0)
43
- # @option options [Boolean] :optimize Enable subroutine optimization (TTF→OTF only)
44
- # @option options [Integer] :min_pattern_length Minimum pattern length for subroutines
45
- # @option options [Integer] :max_subroutines Maximum number of subroutines
46
- # @option options [Boolean] :optimize_ordering Optimize subroutine ordering
45
+ # @option options [String] :coordinates Coordinate string (e.g., "wght=700,wdth=100")
46
+ # @option options [Hash] :instance_coordinates Axis coordinates hash (e.g., {"wght" => 700.0})
47
+ # @option options [Integer] :instance_index Named instance index
48
+ # @option options [Boolean] :preserve_variation Preserve variation data (default: auto)
49
+ # @option options [Boolean] :preserve_hints Preserve rendering hints (default: false)
50
+ # @option options [Boolean] :no_validate Skip output validation
51
+ # @option options [Boolean] :verbose Verbose output
47
52
  def initialize(font_path, options = {})
48
53
  super(font_path, options)
49
- @target_format = parse_target_format(options[:to])
50
54
  @output_path = options[:output]
51
- @converter = Converters::FormatConverter.new
52
55
 
53
- # Optimization options
54
- @optimize = options[:optimize] || false
55
- @min_pattern_length = options[:min_pattern_length] || 10
56
- @max_subroutines = options[:max_subroutines] || 65_535
57
- @optimize_ordering = options[:optimize_ordering] != false
56
+ # Parse target format
57
+ @target_format = parse_target_format(options[:to])
58
+
59
+ # Parse coordinates if string provided
60
+ @coordinates = if options[:coordinates]
61
+ parse_coordinates(options[:coordinates])
62
+ elsif options[:instance_coordinates]
63
+ options[:instance_coordinates]
64
+ end
65
+
66
+ @instance_index = options[:instance_index]
67
+ @preserve_variation = options[:preserve_variation]
68
+ @preserve_hints = options.fetch(:preserve_hints, false)
69
+ @validate = !options[:no_validate]
58
70
  end
59
71
 
60
72
  # Execute the conversion
@@ -65,65 +77,59 @@ module Fontisan
65
77
  def run
66
78
  validate_options!
67
79
 
68
- puts "Converting #{File.basename(font_path)} to #{@target_format}..."
80
+ puts "Converting #{File.basename(font_path)} to #{@target_format}..." unless @options[:quiet]
69
81
 
70
- # Build converter options
71
- converter_options = {
82
+ # Build pipeline options
83
+ pipeline_options = {
72
84
  target_format: @target_format,
73
- optimize_subroutines: @optimize,
74
- min_pattern_length: @min_pattern_length,
75
- max_subroutines: @max_subroutines,
76
- optimize_ordering: @optimize_ordering,
77
- verbose: options[:verbose],
85
+ validate: @validate,
86
+ verbose: @options[:verbose],
78
87
  }
79
88
 
80
- # Perform conversion with options
81
- result = @converter.convert(font, @target_format, converter_options)
82
-
83
- # Handle special formats that return complete binary/text
84
- if @target_format == :woff && result.is_a?(String)
85
- # WOFF returns complete binary
86
- File.binwrite(@output_path, result)
87
- elsif @target_format == :woff2 && result.is_a?(Hash) && result[:woff2_binary]
88
- File.binwrite(@output_path, result[:woff2_binary])
89
- elsif @target_format == :svg && result.is_a?(Hash) && result[:svg_xml]
90
- File.write(@output_path, result[:svg_xml])
91
- else
92
- # Standard table-based conversion
93
- tables = result
94
-
95
- # Determine sfnt version for output
96
- sfnt_version = determine_sfnt_version(@target_format)
97
-
98
- # Write output font
99
- FontWriter.write_to_file(tables, @output_path,
100
- sfnt_version: sfnt_version)
101
-
102
- # Display optimization results if available
103
- display_optimization_results(tables) if @optimize && options[:verbose]
89
+ # Add variation options if specified
90
+ pipeline_options[:coordinates] = @coordinates if @coordinates
91
+ pipeline_options[:instance_index] = @instance_index if @instance_index
92
+ pipeline_options[:preserve_variation] = @preserve_variation unless @preserve_variation.nil?
93
+
94
+ # Add hint preservation option
95
+ pipeline_options[:preserve_hints] = @preserve_hints if @preserve_hints
96
+
97
+ # Use TransformationPipeline for universal conversion
98
+ pipeline = Pipeline::TransformationPipeline.new(
99
+ font_path,
100
+ @output_path,
101
+ pipeline_options,
102
+ )
103
+
104
+ result = pipeline.transform
105
+
106
+ # Display results
107
+ unless @options[:quiet]
108
+ output_size = File.size(@output_path)
109
+ input_size = File.size(font_path)
110
+
111
+ puts "Conversion complete!"
112
+ puts " Input: #{font_path} (#{format_size(input_size)})"
113
+ puts " Output: #{@output_path} (#{format_size(output_size)})"
114
+ puts " Format: #{result[:details][:source_format]} → #{result[:details][:target_format]}"
115
+
116
+ if result[:details][:variation_preserved]
117
+ puts " Variation: Preserved (#{result[:details][:variation_strategy]})"
118
+ elsif result[:details][:variation_strategy] != :preserve
119
+ puts " Variation: Instance generated (#{result[:details][:variation_strategy]})"
120
+ end
104
121
  end
105
122
 
106
- output_size = File.size(@output_path)
107
- input_size = File.size(font_path)
108
-
109
- puts "Conversion complete!"
110
- puts " Input: #{font_path} (#{format_size(input_size)})"
111
- puts " Output: #{@output_path} (#{format_size(output_size)})"
112
-
113
123
  {
114
124
  success: true,
115
125
  input_path: font_path,
116
126
  output_path: @output_path,
117
- source_format: detect_source_format,
118
- target_format: @target_format,
119
- input_size: input_size,
120
- output_size: output_size,
127
+ source_format: result[:details][:source_format],
128
+ target_format: result[:details][:target_format],
129
+ input_size: File.size(font_path),
130
+ output_size: File.size(@output_path),
131
+ variation_strategy: result[:details][:variation_strategy],
121
132
  }
122
- rescue NotImplementedError
123
- # Let NotImplementedError propagate for tests that expect it
124
- raise
125
- rescue Converters::ConversionStrategy => e
126
- handle_conversion_error(e)
127
133
  rescue ArgumentError
128
134
  # Let ArgumentError propagate for validation errors
129
135
  raise
@@ -131,26 +137,27 @@ module Fontisan
131
137
  raise Error, "Conversion failed: #{e.message}"
132
138
  end
133
139
 
134
- # Get list of supported conversions
135
- #
136
- # @return [Array<Hash>] List of supported conversions
137
- def self.supported_conversions
138
- converter = Converters::FormatConverter.new
139
- converter.all_conversions
140
- end
140
+ private
141
141
 
142
- # Check if a conversion is supported
142
+ # Parse coordinates string to hash
143
+ #
144
+ # Parses strings like "wght=700,wdth=100" into {"wght" => 700.0, "wdth" => 100.0}
143
145
  #
144
- # @param source [Symbol] Source format
145
- # @param target [Symbol] Target format
146
- # @return [Boolean] True if supported
147
- def self.supported?(source, target)
148
- converter = Converters::FormatConverter.new
149
- converter.supported?(source, target)
146
+ # @param coord_string [String] Coordinate string
147
+ # @return [Hash] Parsed coordinates
148
+ def parse_coordinates(coord_string)
149
+ coords = {}
150
+ coord_string.split(",").each do |pair|
151
+ key, value = pair.split("=")
152
+ next unless key && value
153
+
154
+ coords[key.strip] = value.to_f
155
+ end
156
+ coords
157
+ rescue StandardError => e
158
+ raise ArgumentError, "Invalid coordinates format '#{coord_string}': #{e.message}"
150
159
  end
151
160
 
152
- private
153
-
154
161
  # Validate command options
155
162
  #
156
163
  # @raise [ArgumentError] If required options are missing
@@ -164,18 +171,6 @@ module Fontisan
164
171
  raise ArgumentError,
165
172
  "Target format is required. Use --to option."
166
173
  end
167
-
168
- # Check if conversion is supported
169
- source_format = detect_source_format
170
- unless @converter.supported?(source_format, @target_format)
171
- available = @converter.supported_targets(source_format)
172
- message = "Conversion from #{source_format} to #{@target_format} " \
173
- "is not supported."
174
- if available.any?
175
- message += " Available targets: #{available.join(', ')}"
176
- end
177
- raise ArgumentError, message
178
- end
179
174
  end
180
175
 
181
176
  # Parse target format from string/symbol
@@ -191,46 +186,17 @@ module Fontisan
191
186
  :ttf
192
187
  when "otf", "opentype", "cff"
193
188
  :otf
189
+ when "svg"
190
+ :svg
194
191
  when "woff"
195
- :woff
192
+ raise ArgumentError,
193
+ "WOFF format conversion is not supported yet. Use woff2 instead."
196
194
  when "woff2"
197
195
  :woff2
198
- when "svg"
199
- :svg
200
196
  else
201
197
  raise ArgumentError,
202
198
  "Unknown target format: #{format}. " \
203
- "Supported: ttf, otf, woff2, svg"
204
- end
205
- end
206
-
207
- # Detect source font format
208
- #
209
- # @return [Symbol] Source format
210
- def detect_source_format
211
- # Check for CFF/CFF2 tables (OpenType/CFF)
212
- if font.has_table?("CFF ") || font.has_table?("CFF2")
213
- :otf
214
- # Check for glyf table (TrueType)
215
- elsif font.has_table?("glyf")
216
- :ttf
217
- else
218
- :unknown
219
- end
220
- end
221
-
222
- # Determine sfnt version for target format
223
- #
224
- # @param format [Symbol] Target format
225
- # @return [Integer] sfnt version
226
- def determine_sfnt_version(format)
227
- case format
228
- when :otf
229
- 0x4F54544F # 'OTTO' for OpenType/CFF
230
- when :ttf
231
- 0x00010000 # 1.0 for TrueType
232
- else
233
- 0x00010000 # Default to TrueType
199
+ "Supported: ttf, otf, svg, woff2"
234
200
  end
235
201
  end
236
202
 
@@ -247,45 +213,6 @@ module Fontisan
247
213
  "#{(bytes / (1024.0 * 1024)).round(1)} MB"
248
214
  end
249
215
  end
250
-
251
- # Handle conversion errors with helpful messages
252
- #
253
- # @param error [StandardError] The error that occurred
254
- # @raise [Error] Wrapped error with helpful message
255
- def handle_conversion_error(error)
256
- message = "Conversion failed: #{error.message}"
257
-
258
- # Add helpful hints based on error type
259
- if error.is_a?(NotImplementedError)
260
- message += "\n\nNote: Some conversions are not yet fully " \
261
- "implemented. Check the conversion matrix configuration " \
262
- "for implementation status."
263
- end
264
-
265
- raise Error, message
266
- end
267
-
268
- # Display optimization results from subroutine generation
269
- #
270
- # @param tables [Hash] Table data with optimization metadata
271
- def display_optimization_results(tables)
272
- optimization = tables.instance_variable_get(:@subroutine_optimization)
273
- return unless optimization
274
-
275
- puts "\n=== Subroutine Optimization Results ==="
276
- puts " Patterns found: #{optimization[:pattern_count]}"
277
- puts " Patterns selected: #{optimization[:selected_count]}"
278
- puts " Subroutines generated: #{optimization[:local_subrs].length}"
279
- puts " Estimated bytes saved: #{optimization[:savings]}"
280
- puts " CFF bias: #{optimization[:bias]}"
281
-
282
- if optimization[:selected_count].zero?
283
- puts " Note: No beneficial patterns found for optimization"
284
- elsif optimization[:savings].positive?
285
- savings_kb = (optimization[:savings] / 1024.0).round(1)
286
- puts " Estimated space savings: #{savings_kb} KB"
287
- end
288
- end
289
216
  end
290
217
  end
291
218
  end