fontisan 0.2.4 → 0.2.6

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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +168 -32
  3. data/README.adoc +673 -1091
  4. data/lib/fontisan/cli.rb +94 -13
  5. data/lib/fontisan/collection/dfont_builder.rb +315 -0
  6. data/lib/fontisan/commands/convert_command.rb +118 -7
  7. data/lib/fontisan/commands/pack_command.rb +129 -22
  8. data/lib/fontisan/commands/validate_command.rb +107 -151
  9. data/lib/fontisan/config/conversion_matrix.yml +175 -1
  10. data/lib/fontisan/constants.rb +8 -0
  11. data/lib/fontisan/converters/collection_converter.rb +438 -0
  12. data/lib/fontisan/converters/woff2_encoder.rb +7 -29
  13. data/lib/fontisan/dfont_collection.rb +185 -0
  14. data/lib/fontisan/font_loader.rb +91 -6
  15. data/lib/fontisan/models/validation_report.rb +227 -0
  16. data/lib/fontisan/parsers/dfont_parser.rb +192 -0
  17. data/lib/fontisan/pipeline/transformation_pipeline.rb +4 -8
  18. data/lib/fontisan/tables/cmap.rb +82 -2
  19. data/lib/fontisan/tables/glyf.rb +118 -0
  20. data/lib/fontisan/tables/head.rb +60 -0
  21. data/lib/fontisan/tables/hhea.rb +74 -0
  22. data/lib/fontisan/tables/maxp.rb +60 -0
  23. data/lib/fontisan/tables/name.rb +76 -0
  24. data/lib/fontisan/tables/os2.rb +113 -0
  25. data/lib/fontisan/tables/post.rb +57 -0
  26. data/lib/fontisan/true_type_font.rb +8 -46
  27. data/lib/fontisan/validation/collection_validator.rb +265 -0
  28. data/lib/fontisan/validators/basic_validator.rb +85 -0
  29. data/lib/fontisan/validators/font_book_validator.rb +130 -0
  30. data/lib/fontisan/validators/opentype_validator.rb +112 -0
  31. data/lib/fontisan/validators/profile_loader.rb +139 -0
  32. data/lib/fontisan/validators/validator.rb +484 -0
  33. data/lib/fontisan/validators/web_font_validator.rb +102 -0
  34. data/lib/fontisan/version.rb +1 -1
  35. data/lib/fontisan.rb +78 -6
  36. metadata +13 -12
  37. data/lib/fontisan/config/validation_rules.yml +0 -149
  38. data/lib/fontisan/validation/checksum_validator.rb +0 -170
  39. data/lib/fontisan/validation/consistency_validator.rb +0 -197
  40. data/lib/fontisan/validation/structure_validator.rb +0 -198
  41. data/lib/fontisan/validation/table_validator.rb +0 -158
  42. data/lib/fontisan/validation/validator.rb +0 -152
  43. data/lib/fontisan/validation/variable_font_validator.rb +0 -218
  44. data/lib/fontisan/validation/woff2_header_validator.rb +0 -278
  45. data/lib/fontisan/validation/woff2_table_validator.rb +0 -270
  46. data/lib/fontisan/validation/woff2_validator.rb +0 -248
@@ -1,270 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "../woff2/directory"
4
-
5
- module Fontisan
6
- module Validation
7
- # Woff2TableValidator validates WOFF2 table directory entries
8
- #
9
- # This validator checks each table entry for:
10
- # - Valid tag (known or custom)
11
- # - Valid transformation version
12
- # - Transform length consistency
13
- # - Table size validity
14
- # - Flags byte correctness
15
- #
16
- # Single Responsibility: WOFF2 table directory validation
17
- #
18
- # @example Validating WOFF2 tables
19
- # validator = Woff2TableValidator.new(rules)
20
- # issues = validator.validate(woff2_font)
21
- class Woff2TableValidator
22
- # Initialize WOFF2 table validator
23
- #
24
- # @param rules [Hash] Validation rules configuration
25
- def initialize(rules)
26
- @rules = rules
27
- @woff2_config = rules["woff2_validation"] || {}
28
- end
29
-
30
- # Validate WOFF2 table directory
31
- #
32
- # @param woff2_font [Woff2Font] The WOFF2 font to validate
33
- # @return [Array<Hash>] Array of validation issues
34
- def validate(woff2_font)
35
- issues = []
36
-
37
- woff2_font.table_entries.each do |entry|
38
- # Check tag validity
39
- issues.concat(check_tag(entry))
40
-
41
- # Check flags byte
42
- issues.concat(check_flags(entry))
43
-
44
- # Check transformation version
45
- issues.concat(check_transformation(entry))
46
-
47
- # Check table sizes
48
- issues.concat(check_sizes(entry))
49
- end
50
-
51
- # Check for duplicate tables
52
- issues.concat(check_duplicates(woff2_font.table_entries))
53
-
54
- issues
55
- end
56
-
57
- private
58
-
59
- # Check tag validity
60
- #
61
- # @param entry [Woff2TableDirectoryEntry] The table entry
62
- # @return [Array<Hash>] Array of tag issues
63
- def check_tag(entry)
64
- issues = []
65
-
66
- if entry.tag.nil? || entry.tag.empty?
67
- issues << {
68
- severity: "error",
69
- category: "woff2_tables",
70
- message: "Table entry has nil or empty tag",
71
- location: "table directory",
72
- }
73
- elsif entry.tag.bytesize != 4
74
- issues << {
75
- severity: "error",
76
- category: "woff2_tables",
77
- message: "Table tag '#{entry.tag}' must be exactly 4 bytes, got #{entry.tag.bytesize}",
78
- location: entry.tag,
79
- }
80
- end
81
-
82
- issues
83
- end
84
-
85
- # Check flags byte validity
86
- #
87
- # @param entry [Woff2TableDirectoryEntry] The table entry
88
- # @return [Array<Hash>] Array of flags issues
89
- def check_flags(entry)
90
- issues = []
91
-
92
- tag_index = entry.flags & 0x3F
93
- (entry.flags >> 6) & 0x03
94
-
95
- # Check tag index consistency
96
- if tag_index == Woff2::Directory::CUSTOM_TAG_INDEX
97
- # Custom tag - should not be in known tags
98
- if Woff2::Directory::KNOWN_TAGS.include?(entry.tag)
99
- issues << {
100
- severity: "warning",
101
- category: "woff2_tables",
102
- message: "Table '#{entry.tag}' uses custom tag index but is a known tag",
103
- location: entry.tag,
104
- }
105
- end
106
- else
107
- # Known tag - should match index
108
- expected_tag = Woff2::Directory::KNOWN_TAGS[tag_index]
109
- if expected_tag && expected_tag != entry.tag
110
- issues << {
111
- severity: "error",
112
- category: "woff2_tables",
113
- message: "Table tag mismatch: index #{tag_index} should be '#{expected_tag}', got '#{entry.tag}'",
114
- location: entry.tag,
115
- }
116
- end
117
- end
118
-
119
- issues
120
- end
121
-
122
- # Check transformation version
123
- #
124
- # @param entry [Woff2TableDirectoryEntry] The table entry
125
- # @return [Array<Hash>] Array of transformation issues
126
- def check_transformation(entry)
127
- issues = []
128
-
129
- transform_version = (entry.flags >> 6) & 0x03
130
-
131
- # Check transformation consistency for transformable tables
132
- case entry.tag
133
- when "glyf", "loca"
134
- # glyf/loca: version 0 = transformed (needs transform_length)
135
- # version 1-3 = not transformed (no transform_length)
136
- if transform_version.zero?
137
- # Should be transformed
138
- unless entry.transform_length&.positive?
139
- issues << {
140
- severity: "error",
141
- category: "woff2_tables",
142
- message: "Table '#{entry.tag}' has transform version 0 but no transform_length",
143
- location: entry.tag,
144
- }
145
- end
146
- elsif entry.transform_length&.positive?
147
- # Should not be transformed
148
- issues << {
149
- severity: "warning",
150
- category: "woff2_tables",
151
- message: "Table '#{entry.tag}' has transform version #{transform_version} but has transform_length",
152
- location: entry.tag,
153
- }
154
- end
155
-
156
- when "hmtx"
157
- # hmtx: version 1 = transformed (needs transform_length)
158
- # version 0, 2, 3 = not transformed (no transform_length)
159
- if transform_version == 1
160
- # Should be transformed
161
- unless entry.transform_length&.positive?
162
- issues << {
163
- severity: "error",
164
- category: "woff2_tables",
165
- message: "Table '#{entry.tag}' has transform version 1 but no transform_length",
166
- location: entry.tag,
167
- }
168
- end
169
- elsif entry.transform_length&.positive?
170
- # Should not be transformed
171
- issues << {
172
- severity: "warning",
173
- category: "woff2_tables",
174
- message: "Table '#{entry.tag}' has transform version #{transform_version} but has transform_length",
175
- location: entry.tag,
176
- }
177
- end
178
-
179
- else
180
- # Other tables should not be transformed
181
- if entry.transform_length&.positive?
182
- issues << {
183
- severity: "warning",
184
- category: "woff2_tables",
185
- message: "Table '#{entry.tag}' is not transformable but has transform_length",
186
- location: entry.tag,
187
- }
188
- end
189
- end
190
-
191
- issues
192
- end
193
-
194
- # Check table sizes
195
- #
196
- # @param entry [Woff2TableDirectoryEntry] The table entry
197
- # @return [Array<Hash>] Array of size issues
198
- def check_sizes(entry)
199
- issues = []
200
-
201
- # Check orig_length
202
- if entry.orig_length.nil? || entry.orig_length.zero?
203
- issues << {
204
- severity: "error",
205
- category: "woff2_tables",
206
- message: "Table '#{entry.tag}' has invalid orig_length: #{entry.orig_length}",
207
- location: entry.tag,
208
- }
209
- end
210
-
211
- # Check transform_length if present
212
- if entry.transform_length
213
- if entry.transform_length.zero?
214
- issues << {
215
- severity: "warning",
216
- category: "woff2_tables",
217
- message: "Table '#{entry.tag}' has transform_length of zero",
218
- location: entry.tag,
219
- }
220
- elsif entry.transform_length > entry.orig_length
221
- issues << {
222
- severity: "warning",
223
- category: "woff2_tables",
224
- message: "Table '#{entry.tag}' has transform_length (#{entry.transform_length}) " \
225
- "greater than orig_length (#{entry.orig_length})",
226
- location: entry.tag,
227
- }
228
- end
229
- end
230
-
231
- # Check for extremely large tables
232
- max_table_size = @woff2_config["max_table_size"] || 104_857_600 # 100MB
233
- if entry.orig_length > max_table_size
234
- issues << {
235
- severity: "warning",
236
- category: "woff2_tables",
237
- message: "Table '#{entry.tag}' has unusually large size: #{entry.orig_length} bytes",
238
- location: entry.tag,
239
- }
240
- end
241
-
242
- issues
243
- end
244
-
245
- # Check for duplicate table tags
246
- #
247
- # @param entries [Array<Woff2TableDirectoryEntry>] All table entries
248
- # @return [Array<Hash>] Array of duplicate issues
249
- def check_duplicates(entries)
250
- issues = []
251
-
252
- tag_counts = Hash.new(0)
253
- entries.each { |entry| tag_counts[entry.tag] += 1 }
254
-
255
- tag_counts.each do |tag, count|
256
- if count > 1
257
- issues << {
258
- severity: "error",
259
- category: "woff2_tables",
260
- message: "Duplicate table tag '#{tag}' appears #{count} times",
261
- location: tag,
262
- }
263
- end
264
- end
265
-
266
- issues
267
- end
268
- end
269
- end
270
- end
@@ -1,248 +0,0 @@
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