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,278 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Validation
5
+ # Woff2HeaderValidator validates the WOFF2 header structure
6
+ #
7
+ # This validator checks the WOFF2 header for:
8
+ # - Valid signature (0x774F4632 'wOF2')
9
+ # - Valid flavor (TrueType or CFF)
10
+ # - Reserved field is zero
11
+ # - File length consistency
12
+ # - Valid table count
13
+ # - Valid compressed size
14
+ # - Metadata offset/length consistency
15
+ # - Private data offset/length consistency
16
+ #
17
+ # Single Responsibility: WOFF2 header validation
18
+ #
19
+ # @example Validating a WOFF2 header
20
+ # validator = Woff2HeaderValidator.new(rules)
21
+ # issues = validator.validate(woff2_font)
22
+ class Woff2HeaderValidator
23
+ # Valid WOFF2 flavors
24
+ VALID_FLAVORS = [
25
+ 0x00010000, # TrueType
26
+ 0x74727565, # 'true' (TrueType)
27
+ 0x4F54544F, # 'OTTO' (CFF/OpenType)
28
+ ].freeze
29
+
30
+ # Initialize WOFF2 header validator
31
+ #
32
+ # @param rules [Hash] Validation rules configuration
33
+ def initialize(rules)
34
+ @rules = rules
35
+ @woff2_config = rules["woff2_validation"] || {}
36
+ end
37
+
38
+ # Validate WOFF2 header
39
+ #
40
+ # @param woff2_font [Woff2Font] The WOFF2 font to validate
41
+ # @return [Array<Hash>] Array of validation issues
42
+ def validate(woff2_font)
43
+ issues = []
44
+
45
+ header = woff2_font.header
46
+ return issues unless header
47
+
48
+ # Check signature
49
+ issues.concat(check_signature(header))
50
+
51
+ # Check flavor
52
+ issues.concat(check_flavor(header))
53
+
54
+ # Check reserved field
55
+ issues.concat(check_reserved_field(header))
56
+
57
+ # Check table count
58
+ issues.concat(check_table_count(header, woff2_font))
59
+
60
+ # Check compressed size
61
+ issues.concat(check_compressed_size(header))
62
+
63
+ # Check metadata consistency
64
+ issues.concat(check_metadata(header))
65
+
66
+ # Check private data consistency
67
+ issues.concat(check_private_data(header))
68
+
69
+ issues
70
+ end
71
+
72
+ private
73
+
74
+ # Check signature validity
75
+ #
76
+ # @param header [Woff2::Woff2Header] The header
77
+ # @return [Array<Hash>] Array of signature issues
78
+ def check_signature(header)
79
+ issues = []
80
+
81
+ unless header.signature == Woff2::Woff2Header::SIGNATURE
82
+ issues << {
83
+ severity: "error",
84
+ category: "woff2_header",
85
+ message: "Invalid WOFF2 signature: expected 0x#{Woff2::Woff2Header::SIGNATURE.to_s(16)}, " \
86
+ "got 0x#{header.signature.to_s(16)}",
87
+ location: "header",
88
+ }
89
+ end
90
+
91
+ issues
92
+ end
93
+
94
+ # Check flavor validity
95
+ #
96
+ # @param header [Woff2::Woff2Header] The header
97
+ # @return [Array<Hash>] Array of flavor issues
98
+ def check_flavor(header)
99
+ issues = []
100
+
101
+ unless VALID_FLAVORS.include?(header.flavor)
102
+ issues << {
103
+ severity: "error",
104
+ category: "woff2_header",
105
+ message: "Invalid WOFF2 flavor: 0x#{header.flavor.to_s(16)} " \
106
+ "(expected TrueType 0x00010000 or CFF 0x4F54544F)",
107
+ location: "header",
108
+ }
109
+ end
110
+
111
+ issues
112
+ end
113
+
114
+ # Check reserved field
115
+ #
116
+ # @param header [Woff2::Woff2Header] The header
117
+ # @return [Array<Hash>] Array of reserved field issues
118
+ def check_reserved_field(header)
119
+ issues = []
120
+
121
+ if header.reserved != 0
122
+ issues << {
123
+ severity: "warning",
124
+ category: "woff2_header",
125
+ message: "Reserved field should be 0, got #{header.reserved}",
126
+ location: "header",
127
+ }
128
+ end
129
+
130
+ issues
131
+ end
132
+
133
+ # Check table count validity
134
+ #
135
+ # @param header [Woff2::Woff2Header] The header
136
+ # @param woff2_font [Woff2Font] The WOFF2 font
137
+ # @return [Array<Hash>] Array of table count issues
138
+ def check_table_count(header, woff2_font)
139
+ issues = []
140
+
141
+ if header.num_tables.zero?
142
+ issues << {
143
+ severity: "error",
144
+ category: "woff2_header",
145
+ message: "Number of tables cannot be zero",
146
+ location: "header",
147
+ }
148
+ end
149
+
150
+ # Check consistency with actual table entries
151
+ actual_count = woff2_font.table_entries.length
152
+ if header.num_tables != actual_count
153
+ issues << {
154
+ severity: "error",
155
+ category: "woff2_header",
156
+ message: "Table count mismatch: header=#{header.num_tables}, actual=#{actual_count}",
157
+ location: "header",
158
+ }
159
+ end
160
+
161
+ issues
162
+ end
163
+
164
+ # Check compressed size validity
165
+ #
166
+ # @param header [Woff2::Woff2Header] The header
167
+ # @return [Array<Hash>] Array of compressed size issues
168
+ def check_compressed_size(header)
169
+ issues = []
170
+
171
+ if header.total_compressed_size.zero?
172
+ issues << {
173
+ severity: "error",
174
+ category: "woff2_header",
175
+ message: "Total compressed size cannot be zero",
176
+ location: "header",
177
+ }
178
+ end
179
+
180
+ # Check compression ratio
181
+ if header.total_sfnt_size.positive? && header.total_compressed_size.positive?
182
+ ratio = header.total_compressed_size.to_f / header.total_sfnt_size
183
+ min_ratio = @woff2_config["min_compression_ratio"] || 0.2
184
+ max_ratio = @woff2_config["max_compression_ratio"] || 0.95
185
+
186
+ if ratio < min_ratio
187
+ issues << {
188
+ severity: "warning",
189
+ category: "woff2_header",
190
+ message: "Compression ratio (#{(ratio * 100).round(2)}%) is unusually low",
191
+ location: "header",
192
+ }
193
+ elsif ratio > max_ratio
194
+ issues << {
195
+ severity: "warning",
196
+ category: "woff2_header",
197
+ message: "Compression ratio (#{(ratio * 100).round(2)}%) is unusually high",
198
+ location: "header",
199
+ }
200
+ end
201
+ end
202
+
203
+ issues
204
+ end
205
+
206
+ # Check metadata consistency
207
+ #
208
+ # @param header [Woff2::Woff2Header] The header
209
+ # @return [Array<Hash>] Array of metadata issues
210
+ def check_metadata(header)
211
+ issues = []
212
+
213
+ # If metadata offset is set, length must be positive
214
+ if header.meta_offset.positive? && header.meta_length.zero?
215
+ issues << {
216
+ severity: "warning",
217
+ category: "woff2_header",
218
+ message: "Metadata offset is set but length is zero",
219
+ location: "header",
220
+ }
221
+ end
222
+
223
+ # If metadata length is set, offset must be positive
224
+ if header.meta_length.positive? && header.meta_offset.zero?
225
+ issues << {
226
+ severity: "warning",
227
+ category: "woff2_header",
228
+ message: "Metadata length is set but offset is zero",
229
+ location: "header",
230
+ }
231
+ end
232
+
233
+ # Original length should be >= compressed length
234
+ if header.meta_orig_length.positive? && header.meta_length.positive? && (header.meta_orig_length < header.meta_length)
235
+ issues << {
236
+ severity: "error",
237
+ category: "woff2_header",
238
+ message: "Metadata original length (#{header.meta_orig_length}) " \
239
+ "is less than compressed length (#{header.meta_length})",
240
+ location: "header",
241
+ }
242
+ end
243
+
244
+ issues
245
+ end
246
+
247
+ # Check private data consistency
248
+ #
249
+ # @param header [Woff2::Woff2Header] The header
250
+ # @return [Array<Hash>] Array of private data issues
251
+ def check_private_data(header)
252
+ issues = []
253
+
254
+ # If private offset is set, length must be positive
255
+ if header.priv_offset.positive? && header.priv_length.zero?
256
+ issues << {
257
+ severity: "warning",
258
+ category: "woff2_header",
259
+ message: "Private data offset is set but length is zero",
260
+ location: "header",
261
+ }
262
+ end
263
+
264
+ # If private length is set, offset must be positive
265
+ if header.priv_length.positive? && header.priv_offset.zero?
266
+ issues << {
267
+ severity: "warning",
268
+ category: "woff2_header",
269
+ message: "Private data length is set but offset is zero",
270
+ location: "header",
271
+ }
272
+ end
273
+
274
+ issues
275
+ end
276
+ end
277
+ end
278
+ end
@@ -0,0 +1,270 @@
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