fontisan 0.2.2 → 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.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +94 -48
- data/README.adoc +293 -3
- data/Rakefile +20 -7
- data/lib/fontisan/base_collection.rb +296 -0
- data/lib/fontisan/commands/base_command.rb +2 -19
- data/lib/fontisan/commands/convert_command.rb +16 -13
- data/lib/fontisan/commands/info_command.rb +156 -50
- data/lib/fontisan/config/conversion_matrix.yml +58 -20
- data/lib/fontisan/converters/outline_converter.rb +6 -3
- data/lib/fontisan/converters/svg_generator.rb +45 -0
- data/lib/fontisan/converters/woff2_encoder.rb +106 -13
- data/lib/fontisan/font_loader.rb +109 -26
- data/lib/fontisan/formatters/text_formatter.rb +72 -19
- data/lib/fontisan/models/bitmap_glyph.rb +123 -0
- data/lib/fontisan/models/bitmap_strike.rb +94 -0
- data/lib/fontisan/models/collection_brief_info.rb +6 -0
- data/lib/fontisan/models/collection_info.rb +6 -1
- data/lib/fontisan/models/color_glyph.rb +57 -0
- data/lib/fontisan/models/color_layer.rb +53 -0
- data/lib/fontisan/models/color_palette.rb +60 -0
- data/lib/fontisan/models/font_info.rb +26 -0
- data/lib/fontisan/models/svg_glyph.rb +89 -0
- data/lib/fontisan/open_type_collection.rb +17 -220
- data/lib/fontisan/open_type_font.rb +6 -0
- data/lib/fontisan/optimizers/charstring_rewriter.rb +19 -8
- data/lib/fontisan/optimizers/pattern_analyzer.rb +4 -2
- data/lib/fontisan/optimizers/subroutine_builder.rb +6 -5
- data/lib/fontisan/optimizers/subroutine_optimizer.rb +5 -2
- data/lib/fontisan/pipeline/output_writer.rb +2 -2
- data/lib/fontisan/tables/cbdt.rb +169 -0
- data/lib/fontisan/tables/cblc.rb +290 -0
- data/lib/fontisan/tables/cff.rb +6 -12
- data/lib/fontisan/tables/colr.rb +291 -0
- data/lib/fontisan/tables/cpal.rb +281 -0
- data/lib/fontisan/tables/glyf/glyph_builder.rb +5 -1
- data/lib/fontisan/tables/sbix.rb +379 -0
- data/lib/fontisan/tables/svg.rb +301 -0
- data/lib/fontisan/true_type_collection.rb +29 -113
- data/lib/fontisan/true_type_font.rb +6 -0
- data/lib/fontisan/validation/woff2_header_validator.rb +278 -0
- data/lib/fontisan/validation/woff2_table_validator.rb +270 -0
- data/lib/fontisan/validation/woff2_validator.rb +248 -0
- data/lib/fontisan/version.rb +1 -1
- data/lib/fontisan/woff2/directory.rb +40 -11
- data/lib/fontisan/woff2/table_transformer.rb +506 -73
- data/lib/fontisan/woff2_font.rb +29 -9
- data/lib/fontisan/woff_font.rb +17 -4
- data/lib/fontisan.rb +12 -0
- metadata +18 -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
|
data/lib/fontisan/version.rb
CHANGED
|
@@ -47,9 +47,14 @@ module Fontisan
|
|
|
47
47
|
].freeze
|
|
48
48
|
|
|
49
49
|
# Transformation versions
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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
|
|
116
|
+
# Determine transformation version for this table
|
|
112
117
|
#
|
|
113
|
-
#
|
|
114
|
-
#
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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)
|