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
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "base_command"
4
4
  require_relative "../collection/builder"
5
+ require_relative "../collection/dfont_builder"
5
6
  require_relative "../font_loader"
6
7
 
7
8
  module Fontisan
@@ -11,6 +12,7 @@ module Fontisan
11
12
  # This command provides CLI access to font collection creation functionality.
12
13
  # It loads multiple font files and combines them into a single TTC (TrueType Collection)
13
14
  # or OTC (OpenType Collection) file with shared table deduplication to save space.
15
+ # It also supports creating dfont (Apple Data Fork Font) suitcases.
14
16
  #
15
17
  # @example Pack fonts into TTC
16
18
  # command = PackCommand.new(
@@ -30,13 +32,21 @@ module Fontisan
30
32
  # analyze: true
31
33
  # )
32
34
  # result = command.run
35
+ #
36
+ # @example Pack into dfont
37
+ # command = PackCommand.new(
38
+ # ['font1.ttf', 'font2.otf'],
39
+ # output: 'family.dfont',
40
+ # format: :dfont
41
+ # )
42
+ # result = command.run
33
43
  class PackCommand
34
44
  # Initialize pack command
35
45
  #
36
46
  # @param font_paths [Array<String>] Paths to input font files
37
47
  # @param options [Hash] Command options
38
48
  # @option options [String] :output Output file path (required)
39
- # @option options [Symbol, String] :format Format type (:ttc or :otc, default: :ttc)
49
+ # @option options [Symbol, String] :format Format type (:ttc, :otc, or :dfont, default: auto-detect)
40
50
  # @option options [Boolean] :optimize Enable table sharing optimization (default: true)
41
51
  # @option options [Boolean] :analyze Show analysis report before building (default: false)
42
52
  # @option options [Boolean] :verbose Enable verbose output (default: false)
@@ -45,7 +55,7 @@ module Fontisan
45
55
  @font_paths = font_paths
46
56
  @options = options
47
57
  @output_path = options[:output]
48
- @format = parse_format(options[:format] || :ttc)
58
+ @format = options[:format] ? parse_format(options[:format]) : nil
49
59
  @optimize = options.fetch(:optimize, true)
50
60
  @analyze = options.fetch(:analyze, false)
51
61
  @verbose = options.fetch(:verbose, false)
@@ -55,16 +65,16 @@ module Fontisan
55
65
 
56
66
  # Execute the pack command
57
67
  #
58
- # Loads all fonts, analyzes tables, and creates a TTC/OTC collection.
68
+ # Loads all fonts, analyzes tables, and creates a TTC/OTC/dfont collection.
59
69
  # Optionally displays analysis before building.
60
70
  #
61
71
  # @return [Hash] Result information with:
62
72
  # - :output [String] - Output file path
63
73
  # - :output_size [Integer] - Output file size in bytes
64
74
  # - :num_fonts [Integer] - Number of fonts packed
65
- # - :format [Symbol] - Collection format (:ttc or :otc)
66
- # - :space_savings [Integer] - Bytes saved through sharing
67
- # - :sharing_percentage [Float] - Percentage of tables shared
75
+ # - :format [Symbol] - Collection format (:ttc, :otc, or :dfont)
76
+ # - :space_savings [Integer] - Bytes saved through sharing (TTC/OTC only)
77
+ # - :sharing_percentage [Float] - Percentage of tables shared (TTC/OTC only)
68
78
  # - :analysis [Hash] - Analysis report (if analyze option enabled)
69
79
  # @raise [ArgumentError] if options are invalid
70
80
  # @raise [Fontisan::Error] if packing fails
@@ -74,6 +84,46 @@ module Fontisan
74
84
  # Load all fonts
75
85
  fonts = load_fonts
76
86
 
87
+ # Auto-detect format if not specified
88
+ @format ||= auto_detect_format(fonts)
89
+ puts "Auto-detected format: #{@format}" if @verbose && !@options[:format]
90
+
91
+ # Build collection based on format
92
+ if @format == :dfont
93
+ build_dfont(fonts)
94
+ else
95
+ build_ttc_otc(fonts)
96
+ end
97
+ rescue Fontisan::Error => e
98
+ raise Fontisan::Error, "Collection packing failed: #{e.message}"
99
+ rescue StandardError => e
100
+ raise Fontisan::Error, "Unexpected error during packing: #{e.message}"
101
+ end
102
+
103
+ private
104
+
105
+ # Build dfont collection
106
+ #
107
+ # @param fonts [Array] Loaded fonts
108
+ # @return [Hash] Build result
109
+ def build_dfont(fonts)
110
+ puts "Building dfont suitcase..." if @verbose
111
+
112
+ builder = Collection::DfontBuilder.new(fonts)
113
+ result = builder.build_to_file(@output_path)
114
+
115
+ if @verbose
116
+ display_dfont_results(result)
117
+ end
118
+
119
+ result
120
+ end
121
+
122
+ # Build TTC/OTC collection
123
+ #
124
+ # @param fonts [Array] Loaded fonts
125
+ # @return [Hash] Build result
126
+ def build_ttc_otc(fonts)
77
127
  # Create builder
78
128
  builder = Collection::Builder.new(fonts, {
79
129
  format: @format,
@@ -98,13 +148,53 @@ module Fontisan
98
148
  end
99
149
 
100
150
  result
101
- rescue Fontisan::Error => e
102
- raise Fontisan::Error, "Collection packing failed: #{e.message}"
103
- rescue StandardError => e
104
- raise Fontisan::Error, "Unexpected error during packing: #{e.message}"
105
151
  end
106
152
 
107
- private
153
+ # Auto-detect collection format based on fonts
154
+ #
155
+ # @param fonts [Array<TrueTypeFont, OpenTypeFont>] Loaded fonts
156
+ # @return [Symbol] Detected format (:ttc, :otc, or :dfont)
157
+ def auto_detect_format(fonts)
158
+ # Check output extension first
159
+ ext = File.extname(@output_path).downcase
160
+ return :ttc if ext == ".ttc"
161
+ return :otc if ext == ".otc"
162
+ return :dfont if ext == ".dfont"
163
+
164
+ # Detect based on font types
165
+ has_truetype = fonts.any? { |f| truetype_font?(f) }
166
+ has_opentype = fonts.any? { |f| opentype_font?(f) }
167
+
168
+ if has_truetype && !has_opentype
169
+ :ttc # All TrueType
170
+ elsif has_opentype
171
+ :otc # Has OpenType/CFF fonts
172
+ else
173
+ :ttc # Default to TTC
174
+ end
175
+ end
176
+
177
+ # Check if font is TrueType
178
+ #
179
+ # @param font [Object] Font object
180
+ # @return [Boolean]
181
+ def truetype_font?(font)
182
+ return false unless font.respond_to?(:header)
183
+
184
+ sfnt = font.header.sfnt_version
185
+ [0x00010000, 0x74727565].include?(sfnt) # 0x74727565 = 'true'
186
+ end
187
+
188
+ # Check if font is OpenType/CFF
189
+ #
190
+ # @param font [Object] Font object
191
+ # @return [Boolean]
192
+ def opentype_font?(font)
193
+ return false unless font.respond_to?(:header)
194
+
195
+ sfnt = font.header.sfnt_version
196
+ sfnt == 0x4F54544F # 'OTTO'
197
+ end
108
198
 
109
199
  # Validate command options
110
200
  #
@@ -125,17 +215,19 @@ module Fontisan
125
215
  "Collection requires at least 2 fonts, got #{@font_paths.size}"
126
216
  end
127
217
 
128
- # Validate format
129
- unless %i[ttc otc].include?(@format)
218
+ # Validate format if specified
219
+ if @format && !%i[ttc otc dfont].include?(@format)
130
220
  raise ArgumentError,
131
- "Invalid format: #{@format}. Must be :ttc or :otc"
221
+ "Invalid format: #{@format}. Must be :ttc, :otc, or :dfont"
132
222
  end
133
223
 
134
- # Check output extension matches format
135
- ext = File.extname(@output_path).downcase
136
- expected_ext = @format == :ttc ? ".ttc" : ".otc"
137
- if ext != expected_ext
138
- warn "Warning: Output extension '#{ext}' doesn't match format '#{@format}' (expected '#{expected_ext}')"
224
+ # Warn if output extension doesn't match format (if format specified)
225
+ if @format
226
+ ext = File.extname(@output_path).downcase
227
+ expected_ext = ".#{@format}"
228
+ if ext != expected_ext
229
+ warn "Warning: Output extension '#{ext}' doesn't match format '#{@format}' (expected '#{expected_ext}')"
230
+ end
139
231
  end
140
232
  end
141
233
 
@@ -165,19 +257,21 @@ module Fontisan
165
257
  # Parse format option
166
258
  #
167
259
  # @param format [Symbol, String] Format option
168
- # @return [Symbol] Parsed format (:ttc or :otc)
260
+ # @return [Symbol] Parsed format (:ttc, :otc, or :dfont)
169
261
  # @raise [ArgumentError] if format is invalid
170
262
  def parse_format(format)
171
- return format if format.is_a?(Symbol) && %i[ttc otc].include?(format)
263
+ return format if format.is_a?(Symbol) && %i[ttc otc dfont].include?(format)
172
264
 
173
265
  case format.to_s.downcase
174
266
  when "ttc"
175
267
  :ttc
176
268
  when "otc"
177
269
  :otc
270
+ when "dfont"
271
+ :dfont
178
272
  else
179
273
  raise ArgumentError,
180
- "Invalid format: #{format}. Must be 'ttc' or 'otc'"
274
+ "Invalid format: #{format}. Must be 'ttc', 'otc', or 'dfont'"
181
275
  end
182
276
  end
183
277
 
@@ -223,6 +317,19 @@ module Fontisan
223
317
  puts ""
224
318
  end
225
319
 
320
+ # Display dfont build results
321
+ #
322
+ # @param result [Hash] Build result
323
+ # @return [void]
324
+ def display_dfont_results(result)
325
+ puts "\n=== dfont Suitcase Created ==="
326
+ puts "Output: #{result[:output_path]}"
327
+ puts "Format: #{result[:format].upcase}"
328
+ puts "Fonts: #{result[:num_fonts]}"
329
+ puts "Size: #{format_bytes(result[:total_size])}"
330
+ puts ""
331
+ end
332
+
226
333
  # Format bytes for display
227
334
  #
228
335
  # @param bytes [Integer] Byte count
@@ -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