fontisan 0.2.3 → 0.2.4

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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +92 -40
  3. data/README.adoc +262 -3
  4. data/Rakefile +20 -7
  5. data/lib/fontisan/commands/base_command.rb +2 -19
  6. data/lib/fontisan/commands/convert_command.rb +16 -13
  7. data/lib/fontisan/commands/info_command.rb +88 -0
  8. data/lib/fontisan/config/conversion_matrix.yml +58 -20
  9. data/lib/fontisan/converters/outline_converter.rb +6 -3
  10. data/lib/fontisan/converters/svg_generator.rb +45 -0
  11. data/lib/fontisan/converters/woff2_encoder.rb +106 -13
  12. data/lib/fontisan/models/bitmap_glyph.rb +123 -0
  13. data/lib/fontisan/models/bitmap_strike.rb +94 -0
  14. data/lib/fontisan/models/color_glyph.rb +57 -0
  15. data/lib/fontisan/models/color_layer.rb +53 -0
  16. data/lib/fontisan/models/color_palette.rb +60 -0
  17. data/lib/fontisan/models/font_info.rb +26 -0
  18. data/lib/fontisan/models/svg_glyph.rb +89 -0
  19. data/lib/fontisan/open_type_font.rb +6 -0
  20. data/lib/fontisan/optimizers/charstring_rewriter.rb +19 -8
  21. data/lib/fontisan/optimizers/pattern_analyzer.rb +4 -2
  22. data/lib/fontisan/optimizers/subroutine_builder.rb +6 -5
  23. data/lib/fontisan/optimizers/subroutine_optimizer.rb +5 -2
  24. data/lib/fontisan/pipeline/output_writer.rb +2 -2
  25. data/lib/fontisan/tables/cbdt.rb +169 -0
  26. data/lib/fontisan/tables/cblc.rb +290 -0
  27. data/lib/fontisan/tables/cff.rb +6 -12
  28. data/lib/fontisan/tables/colr.rb +291 -0
  29. data/lib/fontisan/tables/cpal.rb +281 -0
  30. data/lib/fontisan/tables/glyf/glyph_builder.rb +5 -1
  31. data/lib/fontisan/tables/sbix.rb +379 -0
  32. data/lib/fontisan/tables/svg.rb +301 -0
  33. data/lib/fontisan/true_type_font.rb +6 -0
  34. data/lib/fontisan/validation/woff2_header_validator.rb +278 -0
  35. data/lib/fontisan/validation/woff2_table_validator.rb +270 -0
  36. data/lib/fontisan/validation/woff2_validator.rb +248 -0
  37. data/lib/fontisan/version.rb +1 -1
  38. data/lib/fontisan/woff2/directory.rb +40 -11
  39. data/lib/fontisan/woff2/table_transformer.rb +506 -73
  40. data/lib/fontisan/woff2_font.rb +29 -9
  41. data/lib/fontisan/woff_font.rb +17 -4
  42. data/lib/fontisan.rb +12 -0
  43. metadata +17 -2
@@ -0,0 +1,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require_relative "../models/validation_report"
5
+ require_relative "woff2_header_validator"
6
+ require_relative "woff2_table_validator"
7
+
8
+ module Fontisan
9
+ module Validation
10
+ # Woff2Validator is the main orchestrator for WOFF2 font validation
11
+ #
12
+ # This class coordinates WOFF2-specific validation checks (header, tables)
13
+ # and produces a comprehensive ValidationReport. It is designed to validate
14
+ # WOFF2 encoding quality and spec compliance.
15
+ #
16
+ # Single Responsibility: Orchestration of WOFF2 validation workflow
17
+ #
18
+ # @example Validating a WOFF2 font
19
+ # validator = Woff2Validator.new(level: :standard)
20
+ # report = validator.validate(woff2_font, font_path)
21
+ # puts report.text_summary
22
+ #
23
+ # @example Validating WOFF2 encoding result
24
+ # woff2_font = Woff2Font.from_file("output.woff2")
25
+ # validator = Woff2Validator.new
26
+ # report = validator.validate(woff2_font, "output.woff2")
27
+ # puts "Valid: #{report.valid}"
28
+ class Woff2Validator
29
+ # Validation levels
30
+ LEVELS = %i[strict standard lenient].freeze
31
+
32
+ # Initialize WOFF2 validator
33
+ #
34
+ # @param level [Symbol] Validation level (:strict, :standard, :lenient)
35
+ # @param rules_path [String, nil] Path to custom rules file
36
+ def initialize(level: :standard, rules_path: nil)
37
+ @level = level
38
+ validate_level!
39
+
40
+ @rules = load_rules(rules_path)
41
+ @header_validator = Woff2HeaderValidator.new(@rules)
42
+ @table_validator = Woff2TableValidator.new(@rules)
43
+ end
44
+
45
+ # Validate a WOFF2 font
46
+ #
47
+ # @param woff2_font [Woff2Font] The WOFF2 font to validate
48
+ # @param font_path [String] Path to the font file
49
+ # @return [Models::ValidationReport] Validation report
50
+ def validate(woff2_font, font_path)
51
+ report = Models::ValidationReport.new(
52
+ font_path: font_path,
53
+ valid: true,
54
+ )
55
+
56
+ begin
57
+ # Run all validation checks
58
+ all_issues = []
59
+
60
+ # 1. Header validation
61
+ all_issues.concat(@header_validator.validate(woff2_font))
62
+
63
+ # 2. Table validation
64
+ all_issues.concat(@table_validator.validate(woff2_font))
65
+
66
+ # 3. WOFF2-specific checks
67
+ all_issues.concat(check_woff2_specific(woff2_font))
68
+
69
+ # Add issues to report
70
+ all_issues.each do |issue|
71
+ case issue[:severity]
72
+ when "error"
73
+ report.add_error(issue[:category], issue[:message],
74
+ issue[:location])
75
+ when "warning"
76
+ report.add_warning(issue[:category], issue[:message],
77
+ issue[:location])
78
+ when "info"
79
+ report.add_info(issue[:category], issue[:message],
80
+ issue[:location])
81
+ end
82
+ end
83
+
84
+ # Determine overall validity based on level
85
+ report.valid = determine_validity(report)
86
+ rescue StandardError => e
87
+ report.add_error("woff2_validation", "WOFF2 validation failed: #{e.message}", nil)
88
+ report.valid = false
89
+ end
90
+
91
+ report
92
+ end
93
+
94
+ # Get the current validation level
95
+ #
96
+ # @return [Symbol] The validation level
97
+ attr_reader :level
98
+
99
+ private
100
+
101
+ # Validate that the level is supported
102
+ #
103
+ # @raise [ArgumentError] if level is invalid
104
+ # @return [void]
105
+ def validate_level!
106
+ unless LEVELS.include?(@level)
107
+ raise ArgumentError,
108
+ "Invalid validation level: #{@level}. Must be one of: #{LEVELS.join(', ')}"
109
+ end
110
+ end
111
+
112
+ # Load validation rules
113
+ #
114
+ # @param rules_path [String, nil] Path to custom rules file
115
+ # @return [Hash] The rules configuration
116
+ def load_rules(rules_path)
117
+ path = rules_path || default_rules_path
118
+ YAML.load_file(path)
119
+ rescue Errno::ENOENT
120
+ # If rules file doesn't exist, use minimal defaults
121
+ {
122
+ "woff2_validation" => {
123
+ "min_compression_ratio" => 0.2,
124
+ "max_compression_ratio" => 0.95,
125
+ "max_table_size" => 104_857_600,
126
+ },
127
+ }
128
+ rescue Psych::SyntaxError => e
129
+ raise "Invalid validation rules YAML: #{e.message}"
130
+ end
131
+
132
+ # Get the default rules path
133
+ #
134
+ # @return [String] Path to default rules file
135
+ def default_rules_path
136
+ File.join(__dir__, "..", "config", "validation_rules.yml")
137
+ end
138
+
139
+ # WOFF2-specific validation checks
140
+ #
141
+ # @param woff2_font [Woff2Font] The WOFF2 font
142
+ # @return [Array<Hash>] Array of WOFF2-specific issues
143
+ def check_woff2_specific(woff2_font)
144
+ issues = []
145
+
146
+ # Check required tables for font type
147
+ issues.concat(check_required_woff2_tables(woff2_font))
148
+
149
+ # Check compression quality
150
+ issues.concat(check_compression_quality(woff2_font))
151
+
152
+ issues
153
+ end
154
+
155
+ # Check required tables based on font flavor
156
+ #
157
+ # @param woff2_font [Woff2Font] The WOFF2 font
158
+ # @return [Array<Hash>] Array of required table issues
159
+ def check_required_woff2_tables(woff2_font)
160
+ issues = []
161
+
162
+ # Basic required tables for all fonts
163
+ required_tables = %w[head hhea maxp name cmap post]
164
+
165
+ # Add flavor-specific tables
166
+ if woff2_font.truetype?
167
+ # For TrueType, we need glyf and hmtx
168
+ # Note: loca is NOT required in WOFF2 table directory because it can be
169
+ # reconstructed from transformed glyf. This is standard WOFF2 behavior.
170
+ required_tables << "glyf"
171
+ required_tables << "hmtx"
172
+ elsif woff2_font.cff?
173
+ required_tables << "CFF "
174
+ end
175
+
176
+ # Check each required table
177
+ required_tables.each do |table_tag|
178
+ unless woff2_font.has_table?(table_tag)
179
+ issues << {
180
+ severity: "error",
181
+ category: "woff2_structure",
182
+ message: "Missing required table: #{table_tag}",
183
+ location: nil,
184
+ }
185
+ end
186
+ end
187
+
188
+ issues
189
+ end
190
+
191
+ # Check compression quality
192
+ #
193
+ # @param woff2_font [Woff2Font] The WOFF2 font
194
+ # @return [Array<Hash>] Array of compression quality issues
195
+ def check_compression_quality(woff2_font)
196
+ issues = []
197
+
198
+ header = woff2_font.header
199
+ return issues unless header
200
+
201
+ # Calculate actual compression ratio
202
+ if header.total_sfnt_size.positive? && header.total_compressed_size.positive?
203
+ ratio = header.total_compressed_size.to_f / header.total_sfnt_size
204
+ percentage = (ratio * 100).round(2)
205
+
206
+ # Info about compression achieved
207
+ issues << {
208
+ severity: "info",
209
+ category: "woff2_compression",
210
+ message: "Compression ratio: #{percentage}% (#{header.total_compressed_size} / #{header.total_sfnt_size} bytes)",
211
+ location: nil,
212
+ }
213
+
214
+ # Warn if compression is poor (> 80%)
215
+ if ratio > 0.80
216
+ issues << {
217
+ severity: "warning",
218
+ category: "woff2_compression",
219
+ message: "Poor compression ratio: #{percentage}% (expected < 80%)",
220
+ location: nil,
221
+ }
222
+ end
223
+ end
224
+
225
+ issues
226
+ end
227
+
228
+ # Determine if WOFF2 font is valid based on validation level
229
+ #
230
+ # @param report [Models::ValidationReport] The validation report
231
+ # @return [Boolean] true if font is valid for the given level
232
+ def determine_validity(report)
233
+ case @level
234
+ when :strict
235
+ # Strict: no errors, no warnings
236
+ !report.has_errors? && !report.has_warnings?
237
+ when :standard
238
+ # Standard: no errors (warnings allowed)
239
+ !report.has_errors?
240
+ when :lenient
241
+ # Lenient: no critical errors (some errors may be acceptable)
242
+ # For WOFF2, treat lenient same as standard
243
+ !report.has_errors?
244
+ end
245
+ end
246
+ end
247
+ end
248
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fontisan
4
- VERSION = "0.2.3"
4
+ VERSION = "0.2.4"
5
5
  end
@@ -47,9 +47,14 @@ module Fontisan
47
47
  ].freeze
48
48
 
49
49
  # Transformation versions
50
- TRANSFORM_NONE = 0
51
- TRANSFORM_GLYF_LOCA = 0 # Applied to both glyf and loca
52
- TRANSFORM_HMTX = 0 # Applied to hmtx
50
+ # According to WOFF2 spec:
51
+ # - glyf/loca: version 0 or 3 WITH transformLength = transformed
52
+ # - glyf/loca: version 1 or 2 WITHOUT transformLength = not transformed
53
+ # - hmtx: version 1 WITH transformLength = transformed
54
+ # - hmtx: version 0, 2, or 3 WITHOUT transformLength = not transformed
55
+ TRANSFORM_NONE = 3 # Use version 3 when not transformed (works for all tables)
56
+ TRANSFORM_GLYF_LOCA = 0 # glyf/loca use version 0 when transformed
57
+ TRANSFORM_HMTX = 1 # hmtx uses version 1 when transformed
53
58
 
54
59
  # Custom tag indicator
55
60
  CUSTOM_TAG_INDEX = 0x3F
@@ -91,7 +96,7 @@ module Fontisan
91
96
  #
92
97
  # @return [Boolean] True if transformed
93
98
  def transformed?
94
- transform_version != TRANSFORM_NONE && transform_length
99
+ !transform_length.nil? && transform_length.positive?
95
100
  end
96
101
 
97
102
  # Get transformation version from flags
@@ -108,16 +113,40 @@ module Fontisan
108
113
  flags & 0x3F
109
114
  end
110
115
 
111
- # Determine if this table should be transformed
116
+ # Determine transformation version for this table
112
117
  #
113
- # For Phase 2 Milestone 2.1, we support transformation flags
114
- # but don't implement the actual transformations yet.
118
+ # Returns the appropriate version based on:
119
+ # 1. Whether table has transform_length set (is transformed)
120
+ # 2. Which table it is (glyf/loca vs hmtx vs other)
115
121
  #
116
- # @return [Integer] Transform version
122
+ # @return [Integer] Transform version (0-3)
117
123
  def determine_transform_version
118
- # For this milestone, we don't apply transformations
119
- # but we recognize which tables could be transformed
120
- TRANSFORM_NONE
124
+ if transformed?
125
+ # Table IS transformed - use appropriate transform version
126
+ case tag
127
+ when "glyf", "loca"
128
+ TRANSFORM_GLYF_LOCA # Version 0 for transformed glyf/loca
129
+ when "hmtx"
130
+ TRANSFORM_HMTX # Version 1 for transformed hmtx
131
+ else
132
+ TRANSFORM_NONE # Shouldn't happen, but use safe default
133
+ end
134
+ else
135
+ # Table is NOT transformed - use version that indicates no transformation
136
+ case tag
137
+ when "glyf", "loca"
138
+ # For glyf/loca, version 0 means transformed
139
+ # so use version 3 to indicate NOT transformed
140
+ TRANSFORM_NONE # Version 3
141
+ when "hmtx"
142
+ # For hmtx, version 1 means transformed
143
+ # so use version 0 to indicate NOT transformed
144
+ 0
145
+ else
146
+ # All other tables use version 0 (no transformation)
147
+ 0
148
+ end
149
+ end
121
150
  end
122
151
 
123
152
  # Check if table can be transformed (glyf, loca, hmtx)