fontisan 0.2.3 → 0.2.5

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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +221 -49
  3. data/README.adoc +519 -5
  4. data/Rakefile +20 -7
  5. data/lib/fontisan/cli.rb +67 -6
  6. data/lib/fontisan/commands/base_command.rb +2 -19
  7. data/lib/fontisan/commands/convert_command.rb +16 -13
  8. data/lib/fontisan/commands/info_command.rb +88 -0
  9. data/lib/fontisan/commands/validate_command.rb +107 -151
  10. data/lib/fontisan/config/conversion_matrix.yml +58 -20
  11. data/lib/fontisan/converters/outline_converter.rb +6 -3
  12. data/lib/fontisan/converters/svg_generator.rb +45 -0
  13. data/lib/fontisan/converters/woff2_encoder.rb +84 -13
  14. data/lib/fontisan/models/bitmap_glyph.rb +123 -0
  15. data/lib/fontisan/models/bitmap_strike.rb +94 -0
  16. data/lib/fontisan/models/color_glyph.rb +57 -0
  17. data/lib/fontisan/models/color_layer.rb +53 -0
  18. data/lib/fontisan/models/color_palette.rb +60 -0
  19. data/lib/fontisan/models/font_info.rb +26 -0
  20. data/lib/fontisan/models/svg_glyph.rb +89 -0
  21. data/lib/fontisan/models/validation_report.rb +227 -0
  22. data/lib/fontisan/open_type_font.rb +6 -0
  23. data/lib/fontisan/optimizers/charstring_rewriter.rb +19 -8
  24. data/lib/fontisan/optimizers/pattern_analyzer.rb +4 -2
  25. data/lib/fontisan/optimizers/subroutine_builder.rb +6 -5
  26. data/lib/fontisan/optimizers/subroutine_optimizer.rb +5 -2
  27. data/lib/fontisan/pipeline/output_writer.rb +2 -2
  28. data/lib/fontisan/pipeline/transformation_pipeline.rb +4 -8
  29. data/lib/fontisan/tables/cbdt.rb +169 -0
  30. data/lib/fontisan/tables/cblc.rb +290 -0
  31. data/lib/fontisan/tables/cff.rb +6 -12
  32. data/lib/fontisan/tables/cmap.rb +82 -2
  33. data/lib/fontisan/tables/colr.rb +291 -0
  34. data/lib/fontisan/tables/cpal.rb +281 -0
  35. data/lib/fontisan/tables/glyf/glyph_builder.rb +5 -1
  36. data/lib/fontisan/tables/glyf.rb +118 -0
  37. data/lib/fontisan/tables/head.rb +60 -0
  38. data/lib/fontisan/tables/hhea.rb +74 -0
  39. data/lib/fontisan/tables/maxp.rb +60 -0
  40. data/lib/fontisan/tables/name.rb +76 -0
  41. data/lib/fontisan/tables/os2.rb +113 -0
  42. data/lib/fontisan/tables/post.rb +57 -0
  43. data/lib/fontisan/tables/sbix.rb +379 -0
  44. data/lib/fontisan/tables/svg.rb +301 -0
  45. data/lib/fontisan/true_type_font.rb +6 -0
  46. data/lib/fontisan/validators/basic_validator.rb +85 -0
  47. data/lib/fontisan/validators/font_book_validator.rb +130 -0
  48. data/lib/fontisan/validators/opentype_validator.rb +112 -0
  49. data/lib/fontisan/validators/profile_loader.rb +139 -0
  50. data/lib/fontisan/validators/validator.rb +484 -0
  51. data/lib/fontisan/validators/web_font_validator.rb +102 -0
  52. data/lib/fontisan/version.rb +1 -1
  53. data/lib/fontisan/woff2/directory.rb +40 -11
  54. data/lib/fontisan/woff2/table_transformer.rb +506 -73
  55. data/lib/fontisan/woff2_font.rb +29 -9
  56. data/lib/fontisan/woff_font.rb +17 -4
  57. data/lib/fontisan.rb +90 -6
  58. metadata +20 -9
  59. data/lib/fontisan/config/validation_rules.yml +0 -149
  60. data/lib/fontisan/validation/checksum_validator.rb +0 -170
  61. data/lib/fontisan/validation/consistency_validator.rb +0 -197
  62. data/lib/fontisan/validation/structure_validator.rb +0 -198
  63. data/lib/fontisan/validation/table_validator.rb +0 -158
  64. data/lib/fontisan/validation/validator.rb +0 -152
  65. data/lib/fontisan/validation/variable_font_validator.rb +0 -218
@@ -58,30 +58,13 @@ module Fontisan
58
58
  # Load the font using FontLoader.
59
59
  #
60
60
  # Uses FontLoader for automatic format detection and loading.
61
- # Returns either TrueTypeFont or OpenTypeFont depending on file format.
61
+ # Returns TrueTypeFont, OpenTypeFont, WoffFont, or Woff2Font depending on file format.
62
62
  #
63
- # @return [TrueTypeFont, OpenTypeFont] The loaded font
63
+ # @return [TrueTypeFont, OpenTypeFont, WoffFont, Woff2Font] The loaded font
64
64
  # @raise [Errno::ENOENT] if file does not exist
65
- # @raise [UnsupportedFormatError] for WOFF/WOFF2 or other unsupported formats
66
65
  # @raise [InvalidFontError] for corrupted or unknown formats
67
66
  # @raise [Error] for other loading failures
68
67
  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
-
85
68
  # Brief mode uses metadata loading for 5x faster parsing
86
69
  mode = @options[:brief] ? LoadingModes::METADATA : (@options[:mode] || LoadingModes::FULL)
87
70
 
@@ -51,22 +51,26 @@ module Fontisan
51
51
  # @option options [Boolean] :verbose Verbose output
52
52
  def initialize(font_path, options = {})
53
53
  super(font_path, options)
54
- @output_path = options[:output]
54
+
55
+ # Convert string keys to symbols for Thor compatibility
56
+ opts = options.transform_keys(&:to_sym)
57
+
58
+ @output_path = opts[:output]
55
59
 
56
60
  # Parse target format
57
- @target_format = parse_target_format(options[:to])
61
+ @target_format = parse_target_format(opts[:to])
58
62
 
59
63
  # 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
+ @coordinates = if opts[:coordinates]
65
+ parse_coordinates(opts[:coordinates])
66
+ elsif opts[:instance_coordinates]
67
+ opts[:instance_coordinates]
64
68
  end
65
69
 
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]
70
+ @instance_index = opts[:instance_index]
71
+ @preserve_variation = opts[:preserve_variation]
72
+ @preserve_hints = opts.fetch(:preserve_hints, false)
73
+ @validate = !opts[:no_validate]
70
74
  end
71
75
 
72
76
  # Execute the conversion
@@ -193,14 +197,13 @@ module Fontisan
193
197
  when "svg"
194
198
  :svg
195
199
  when "woff"
196
- raise ArgumentError,
197
- "WOFF format conversion is not supported yet. Use woff2 instead."
200
+ :woff
198
201
  when "woff2"
199
202
  :woff2
200
203
  else
201
204
  raise ArgumentError,
202
205
  "Unknown target format: #{format}. " \
203
- "Supported: ttf, otf, svg, woff2"
206
+ "Supported: ttf, otf, svg, woff, woff2"
204
207
  end
205
208
  end
206
209
 
@@ -202,6 +202,9 @@ module Fontisan
202
202
  populate_from_name_table(info) if font.has_table?(Constants::NAME_TAG)
203
203
  populate_from_os2_table(info) if font.has_table?(Constants::OS2_TAG)
204
204
  populate_from_head_table(info) if font.has_table?(Constants::HEAD_TAG)
205
+ populate_color_info(info) if font.has_table?("COLR") && font.has_table?("CPAL")
206
+ populate_svg_info(info) if font.has_table?("SVG ")
207
+ populate_bitmap_info(info) if font.has_table?("CBLC") || font.has_table?("sbix")
205
208
  end
206
209
 
207
210
  # Populate FontInfo from the name table.
@@ -255,6 +258,91 @@ module Fontisan
255
258
  info.units_per_em = head_table.units_per_em
256
259
  end
257
260
 
261
+ # Populate FontInfo with color font information from COLR/CPAL tables
262
+ #
263
+ # @param info [Models::FontInfo] FontInfo instance to populate
264
+ def populate_color_info(info)
265
+ colr_table = font.table("COLR")
266
+ cpal_table = font.table("CPAL")
267
+
268
+ return unless colr_table && cpal_table
269
+
270
+ info.is_color_font = true
271
+ info.color_glyphs = colr_table.num_color_glyphs
272
+ info.color_palettes = cpal_table.num_palettes
273
+ info.colors_per_palette = cpal_table.num_palette_entries
274
+ rescue StandardError => e
275
+ warn "Failed to populate color font info: #{e.message}"
276
+ info.is_color_font = false
277
+ end
278
+
279
+ # Populate FontInfo with SVG table information
280
+ #
281
+ # @param info [Models::FontInfo] FontInfo instance to populate
282
+ def populate_svg_info(info)
283
+ svg_table = font.table("SVG ")
284
+
285
+ return unless svg_table
286
+
287
+ info.has_svg_table = true
288
+ info.svg_glyph_count = svg_table.glyph_ids_with_svg.length
289
+ rescue StandardError => e
290
+ warn "Failed to populate SVG info: #{e.message}"
291
+ info.has_svg_table = false
292
+ end
293
+
294
+ # Populate FontInfo with bitmap table information (CBDT/CBLC, sbix)
295
+ #
296
+ # @param info [Models::FontInfo] FontInfo instance to populate
297
+ def populate_bitmap_info(info)
298
+ bitmap_strikes = []
299
+ ppem_sizes = []
300
+ formats = []
301
+
302
+ # Check for CBDT/CBLC (Google format)
303
+ if font.has_table?("CBLC") && font.has_table?("CBDT")
304
+ cblc = font.table("CBLC")
305
+ info.has_bitmap_glyphs = true
306
+ ppem_sizes.concat(cblc.ppem_sizes)
307
+
308
+ cblc.strikes.each do |strike_rec|
309
+ bitmap_strikes << Models::BitmapStrike.new(
310
+ ppem: strike_rec.ppem,
311
+ start_glyph_id: strike_rec.start_glyph_index,
312
+ end_glyph_id: strike_rec.end_glyph_index,
313
+ bit_depth: strike_rec.bit_depth,
314
+ num_glyphs: strike_rec.glyph_range.size
315
+ )
316
+ end
317
+ formats << "PNG" # CBDT typically contains PNG data
318
+ end
319
+
320
+ # Check for sbix (Apple format)
321
+ if font.has_table?("sbix")
322
+ sbix = font.table("sbix")
323
+ info.has_bitmap_glyphs = true
324
+ ppem_sizes.concat(sbix.ppem_sizes)
325
+ formats.concat(sbix.supported_formats)
326
+
327
+ sbix.strikes.each do |strike|
328
+ bitmap_strikes << Models::BitmapStrike.new(
329
+ ppem: strike[:ppem],
330
+ start_glyph_id: 0,
331
+ end_glyph_id: strike[:num_glyphs] - 1,
332
+ bit_depth: 32, # sbix is typically 32-bit
333
+ num_glyphs: strike[:num_glyphs]
334
+ )
335
+ end
336
+ end
337
+
338
+ info.bitmap_strikes = bitmap_strikes unless bitmap_strikes.empty?
339
+ info.bitmap_ppem_sizes = ppem_sizes.uniq.sort
340
+ info.bitmap_formats = formats.uniq
341
+ rescue StandardError => e
342
+ warn "Failed to populate bitmap info: #{e.message}"
343
+ info.has_bitmap_glyphs = false
344
+ end
345
+
258
346
  # Format OS/2 embedding permission flags into a human-readable string.
259
347
  #
260
348
  # @param flags [Integer] OS/2 fsType flags
@@ -1,8 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "base_command"
4
- require_relative "../validation/validator"
5
- require_relative "../validation/variable_font_validator"
4
+ require_relative "../validators/profile_loader"
6
5
  require_relative "../font_loader"
7
6
 
8
7
  module Fontisan
@@ -11,195 +10,152 @@ module Fontisan
11
10
  #
12
11
  # This command validates fonts against quality checks, structural integrity,
13
12
  # and OpenType specification compliance. It supports different validation
14
- # levels and output formats.
13
+ # profiles and output formats, with ftxvalidator-compatible options.
15
14
  #
16
- # @example Validating a font
15
+ # @example Validating a font with default profile
16
+ # command = ValidateCommand.new(input: "font.ttf")
17
+ # exit_code = command.run
18
+ #
19
+ # @example Validating with specific profile
17
20
  # command = ValidateCommand.new(
18
21
  # input: "font.ttf",
19
- # level: :standard,
20
- # format: :text
22
+ # profile: :web,
23
+ # format: :json
21
24
  # )
22
25
  # exit_code = command.run
23
26
  class ValidateCommand < BaseCommand
24
27
  # Initialize validate command
25
28
  #
26
29
  # @param input [String] Path to font file
27
- # @param level [Symbol] Validation level (:strict, :standard, :lenient)
30
+ # @param profile [Symbol, String, nil] Validation profile (default: :default)
31
+ # @param exclude [Array<String>] Tests to exclude
32
+ # @param output [String, nil] Output file path
28
33
  # @param format [Symbol] Output format (:text, :yaml, :json)
29
- # @param verbose [Boolean] Show all issues (default: true)
30
- # @param quiet [Boolean] Only return exit code, no output (default: false)
31
- def initialize(input:, level: :standard, format: :text, verbose: true,
32
- quiet: false)
33
- super()
34
+ # @param full_report [Boolean] Generate full detailed report
35
+ # @param summary_report [Boolean] Generate brief summary report
36
+ # @param table_report [Boolean] Generate tabular format report
37
+ # @param verbose [Boolean] Show verbose output
38
+ # @param suppress_warnings [Boolean] Suppress warning output
39
+ # @param return_value_results [Boolean] Use return values to indicate results
40
+ def initialize(
41
+ input:,
42
+ profile: nil,
43
+ exclude: [],
44
+ output: nil,
45
+ format: :text,
46
+ full_report: false,
47
+ summary_report: false,
48
+ table_report: false,
49
+ verbose: false,
50
+ suppress_warnings: false,
51
+ return_value_results: false
52
+ )
34
53
  @input = input
35
- @level = level.to_sym
36
- @format = format.to_sym
54
+ @profile = profile || :default
55
+ @exclude = exclude
56
+ @output = output
57
+ @format = format
58
+ @full_report = full_report
59
+ @summary_report = summary_report
60
+ @table_report = table_report
37
61
  @verbose = verbose
38
- @quiet = quiet
62
+ @suppress_warnings = suppress_warnings
63
+ @return_value_results = return_value_results
39
64
  end
40
65
 
41
66
  # Run the validation command
42
67
  #
43
- # @return [Integer] Exit code (0 = valid, 1 = errors, 2 = warnings only)
68
+ # @return [Integer] Exit code (0 = valid, 2 = fatal, 3 = errors, 4 = warnings, 5 = info)
44
69
  def run
45
- validate_params!
46
-
47
- # Load font
48
- font = load_font
49
- return 1 unless font
50
-
51
- # Create validator
52
- validator = Validation::Validator.new(level: @level)
53
-
54
- # Run validation
55
- report = validator.validate(font, @input)
70
+ # Load font with appropriate mode
71
+ profile_config = Validators::ProfileLoader.profile_info(@profile)
72
+ unless profile_config
73
+ puts "Error: Unknown profile '#{@profile}'" unless @suppress_warnings
74
+ return 1
75
+ end
56
76
 
57
- # Add variable font validation if applicable
58
- validate_variable_font(font, report) if font.has_table?("fvar")
77
+ mode = profile_config[:loading_mode].to_sym
59
78
 
60
- # Output results unless quiet mode
61
- output_report(report) unless @quiet
79
+ font = FontLoader.load(@input, mode: mode)
62
80
 
63
- # Return appropriate exit code
64
- determine_exit_code(report)
65
- rescue StandardError => e
66
- puts "Error: #{e.message}" unless @quiet
67
- puts e.backtrace.join("\n") if @verbose && !@quiet
68
- 1
69
- end
81
+ # Select validator
82
+ validator = Validators::ProfileLoader.load(@profile)
70
83
 
71
- private
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
84
+ # Run validation
85
+ report = validator.validate(font)
81
86
 
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
- if report.respond_to?(:errors)
88
- report.errors << { message: error,
89
- category: "variable_font" }
90
- end
91
- end
92
- elsif @verbose && !@quiet
93
- puts "\n✓ Variable font structure valid"
87
+ # Filter excluded checks if specified
88
+ if @exclude.any?
89
+ report.check_results.reject! { |cr| @exclude.include?(cr.check_id) }
94
90
  end
95
- end
96
91
 
97
- # Validate command parameters
98
- #
99
- # @raise [ArgumentError] if parameters are invalid
100
- # @return [void]
101
- def validate_params!
102
- if @input.nil? || @input.empty?
103
- raise ArgumentError,
104
- "Input file is required"
105
- end
106
- unless File.exist?(@input)
107
- raise ArgumentError,
108
- "Input file does not exist: #{@input}"
109
- end
92
+ # Generate output
93
+ output = generate_output(report)
110
94
 
111
- valid_levels = %i[strict standard lenient]
112
- unless valid_levels.include?(@level)
113
- raise ArgumentError,
114
- "Invalid level: #{@level}. Must be one of: #{valid_levels.join(', ')}"
115
- end
116
-
117
- valid_formats = %i[text yaml json]
118
- unless valid_formats.include?(@format)
119
- raise ArgumentError,
120
- "Invalid format: #{@format}. Must be one of: #{valid_formats.join(', ')}"
95
+ # Write to file or stdout
96
+ if @output
97
+ File.write(@output, output)
98
+ puts "Validation report written to #{@output}" if @verbose && !@suppress_warnings
99
+ else
100
+ puts output unless @suppress_warnings
121
101
  end
122
- end
123
102
 
124
- # Load the font file
125
- #
126
- # @return [TrueTypeFont, OpenTypeFont, nil] The loaded font or nil on error
127
- def load_font
128
- FontLoader.load(@input)
129
- rescue StandardError => e
130
- puts "Error loading font: #{e.message}" unless @quiet
131
- nil
103
+ # Return exit code
104
+ exit_code(report)
105
+ rescue => e
106
+ puts "Error: #{e.message}" unless @suppress_warnings
107
+ puts e.backtrace.join("\n") if @verbose && !@suppress_warnings
108
+ 1
132
109
  end
133
110
 
134
- # Output validation report in requested format
135
- #
136
- # @param report [Models::ValidationReport] The validation report
137
- # @return [void]
138
- def output_report(report)
139
- case @format
140
- when :text
141
- output_text(report)
142
- when :yaml
143
- output_yaml(report)
144
- when :json
145
- output_json(report)
146
- end
147
- end
111
+ private
148
112
 
149
- # Output report in text format
113
+ # Generate output based on requested format
150
114
  #
151
- # @param report [Models::ValidationReport] The validation report
152
- # @return [void]
153
- def output_text(report)
154
- if @verbose
155
- puts report.text_summary
115
+ # @param report [ValidationReport] The validation report
116
+ # @return [String] Formatted output
117
+ def generate_output(report)
118
+ if @table_report
119
+ report.to_table_format
120
+ elsif @summary_report
121
+ report.to_summary
122
+ elsif @full_report
123
+ report.to_text_report
156
124
  else
157
- # Compact output: just status and error/warning counts
158
- status = report.valid ? "VALID" : "INVALID"
159
- puts "#{status}: #{report.summary.errors} errors, #{report.summary.warnings} warnings"
160
-
161
- # Show errors only in non-verbose mode
162
- report.errors.each do |error|
163
- puts " [ERROR] #{error.message}"
125
+ # Default: format-specific output
126
+ case @format
127
+ when :yaml
128
+ require "yaml"
129
+ report.to_yaml
130
+ when :json
131
+ require "json"
132
+ report.to_json
133
+ else
134
+ report.text_summary
164
135
  end
165
136
  end
166
137
  end
167
138
 
168
- # Output report in YAML format
169
- #
170
- # @param report [Models::ValidationReport] The validation report
171
- # @return [void]
172
- def output_yaml(report)
173
- require "yaml"
174
- puts report.to_yaml
175
- end
176
-
177
- # Output report in JSON format
178
- #
179
- # @param report [Models::ValidationReport] The validation report
180
- # @return [void]
181
- def output_json(report)
182
- require "json"
183
- puts report.to_json
184
- end
185
-
186
139
  # Determine exit code based on validation results
187
140
  #
188
- # Exit codes:
189
- # - 0: Valid (no errors, or only warnings in lenient mode)
190
- # - 1: Has errors
191
- # - 2: Has warnings only (no errors)
141
+ # Exit codes (ftxvalidator compatible):
142
+ # 0 = No issues found
143
+ # 1 = Execution errors
144
+ # 2 = Fatal errors found
145
+ # 3 = Major errors found
146
+ # 4 = Minor errors (warnings) found
147
+ # 5 = Spec violations (info) found
192
148
  #
193
- # @param report [Models::ValidationReport] The validation report
149
+ # @param report [ValidationReport] The validation report
194
150
  # @return [Integer] Exit code
195
- def determine_exit_code(report)
196
- if report.has_errors?
197
- 1
198
- elsif report.has_warnings?
199
- 2
200
- else
201
- 0
202
- end
151
+ def exit_code(report)
152
+ return 0 unless @return_value_results
153
+
154
+ return 2 if report.fatal_errors.any?
155
+ return 3 if report.errors_only.any?
156
+ return 4 if report.warnings_only.any?
157
+ return 5 if report.info_only.any?
158
+ 0
203
159
  end
204
160
  end
205
161
  end
@@ -78,26 +78,34 @@ conversions:
78
78
  are architectural placeholders for future optimization.
79
79
 
80
80
  # Phase 2 conversions (Milestone 2.3) - WOFF
81
- # NOTE: Temporarily disabled until WOFF writer bugs are fixed
82
- # - from: ttf
83
- # to: woff
84
- # strategy: woff_writer
85
- # description: "Compress TrueType to WOFF format with zlib"
86
- # status: implemented
87
- # notes: >
88
- # WOFF 1.0 encoding with zlib compression. Supports optional metadata
89
- # and private data blocks. Individual table compression for optimal
90
- # file size reduction.
91
-
92
- # - from: otf
93
- # to: woff
94
- # strategy: woff_writer
95
- # description: "Compress OpenType/CFF to WOFF format with zlib"
96
- # status: implemented
97
- # notes: >
98
- # WOFF 1.0 encoding with zlib compression. Supports optional metadata
99
- # and private data blocks. Individual table compression for optimal
100
- # file size reduction.
81
+ - from: ttf
82
+ to: woff
83
+ strategy: woff_writer
84
+ description: "Compress TrueType to WOFF format with zlib"
85
+ status: implemented
86
+ notes: >
87
+ WOFF 1.0 encoding with zlib compression. Supports optional metadata
88
+ and private data blocks. Individual table compression for optimal
89
+ file size reduction.
90
+
91
+ - from: otf
92
+ to: woff
93
+ strategy: woff_writer
94
+ description: "Compress OpenType/CFF to WOFF format with zlib"
95
+ status: implemented
96
+ notes: >
97
+ WOFF 1.0 encoding with zlib compression. Supports optional metadata
98
+ and private data blocks. Individual table compression for optimal
99
+ file size reduction.
100
+
101
+ - from: woff
102
+ to: woff
103
+ strategy: woff_recompress
104
+ description: "Copy WOFF font with re-compression"
105
+ status: implemented
106
+ notes: >
107
+ Same-format copy operation. Decompresses and re-compresses WOFF data,
108
+ useful for normalizing compression or updating metadata.
101
109
 
102
110
  # Reverse WOFF conversions (WOFF → TTF/OTF)
103
111
  - from: woff
@@ -139,6 +147,36 @@ conversions:
139
147
  transformation. Note: SVG fonts are deprecated in favor of WOFF2, but
140
148
  useful for fallback, conversion, and inspection purposes.
141
149
 
150
+ # WOFF2 decompression (reverse conversions)
151
+ - from: woff2
152
+ to: ttf
153
+ strategy: woff2_decoder
154
+ description: "Decompress WOFF2 to TrueType format"
155
+ status: implemented
156
+ notes: >
157
+ WOFF2 decompression with Brotli. Reverses table transformations
158
+ (glyf/loca, hmtx) if present. Preserves all font data and metadata.
159
+ Automatically detects font flavor from WOFF2 header.
160
+
161
+ - from: woff2
162
+ to: otf
163
+ strategy: woff2_decoder
164
+ description: "Decompress WOFF2 to OpenType/CFF format"
165
+ status: implemented
166
+ notes: >
167
+ WOFF2 decompression with Brotli. Reverses table transformations
168
+ if present. Preserves all font data and metadata. Automatically
169
+ detects font flavor from WOFF2 header.
170
+
171
+ - from: woff2
172
+ to: woff2
173
+ strategy: woff2_recompress
174
+ description: "Copy WOFF2 font with re-compression"
175
+ status: implemented
176
+ notes: >
177
+ Same-format copy operation. Decompresses and re-compresses WOFF2 data
178
+ with Brotli, useful for normalizing compression or updating metadata.
179
+
142
180
  # Conversion compatibility matrix
143
181
  #
144
182
  # This section documents which source features are preserved in conversions.
@@ -415,11 +415,14 @@ module Fontisan
415
415
  # If we have local subroutines, add Subrs offset
416
416
  # Subrs offset is relative to Private DICT start
417
417
  if local_subrs.any?
418
- # Calculate size of Private DICT itself to know where Subrs starts
418
+ # Add a placeholder Subrs offset first to get accurate size
419
+ private_dict_hash[:subrs] = 0
420
+
421
+ # Calculate size of Private DICT with Subrs entry
419
422
  temp_private_dict_data = Tables::Cff::DictBuilder.build(private_dict_hash)
420
423
  subrs_offset = temp_private_dict_data.bytesize
421
424
 
422
- # Add Subrs offset to DICT
425
+ # Update with actual Subrs offset
423
426
  private_dict_hash[:subrs] = subrs_offset
424
427
  end
425
428
 
@@ -729,7 +732,7 @@ module Fontisan
729
732
  if glyph_patterns.empty?
730
733
  charstring
731
734
  else
732
- rewriter.rewrite(charstring, glyph_patterns)
735
+ rewriter.rewrite(charstring, glyph_patterns, glyph_id)
733
736
  end
734
737
  end
735
738
 
@@ -99,6 +99,51 @@ module Fontisan
99
99
 
100
100
  private
101
101
 
102
+ # Generate SVG for a color glyph from COLR/CPAL tables
103
+ #
104
+ # @param glyph_id [Integer] Glyph ID
105
+ # @param colr_table [Tables::Colr] COLR table
106
+ # @param cpal_table [Tables::Cpal] CPAL table
107
+ # @param extractor [OutlineExtractor] Outline extractor for layer glyphs
108
+ # @param palette_index [Integer] Palette index to use (default: 0)
109
+ # @return [String, nil] SVG path data with color layers, or nil if not color glyph
110
+ def generate_color_glyph(glyph_id, colr_table, cpal_table, extractor, palette_index: 0)
111
+ # Get layers for this glyph
112
+ layers = colr_table.layers_for_glyph(glyph_id)
113
+ return nil if layers.empty?
114
+
115
+ # Get palette colors
116
+ palette = cpal_table.palette(palette_index)
117
+ return nil unless palette
118
+
119
+ # Generate SVG for each layer
120
+ svg_paths = []
121
+ layers.each do |layer|
122
+ # Get outline for layer glyph
123
+ outline = extractor.extract(layer.glyph_id)
124
+ next unless outline
125
+
126
+ # Get color for this layer
127
+ color = if layer.uses_foreground_color?
128
+ "currentColor" # Use CSS currentColor for foreground
129
+ else
130
+ palette[layer.palette_index] || "#000000FF"
131
+ end
132
+
133
+ # Convert outline to SVG path
134
+ path_data = outline.to_svg_path
135
+ next if path_data.empty?
136
+
137
+ # Create colored path element
138
+ svg_paths << %(<path d="#{path_data}" fill="#{color}" />)
139
+ end
140
+
141
+ svg_paths.empty? ? nil : svg_paths.join("\n")
142
+ rescue StandardError => e
143
+ warn "Failed to generate color glyph #{glyph_id}: #{e.message}"
144
+ nil
145
+ end
146
+
102
147
  # Extract glyph data from font
103
148
  #
104
149
  # @param font [TrueTypeFont, OpenTypeFont] Source font