fontisan 0.2.7 → 0.2.8

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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +103 -0
  3. data/.rubocop_todo.yml +106 -319
  4. data/Gemfile +1 -1
  5. data/README.adoc +81 -14
  6. data/Rakefile +12 -7
  7. data/benchmark/variation_quick_bench.rb +1 -1
  8. data/lib/fontisan/cli.rb +45 -13
  9. data/lib/fontisan/collection/dfont_builder.rb +2 -1
  10. data/lib/fontisan/commands/convert_command.rb +2 -4
  11. data/lib/fontisan/commands/info_command.rb +3 -3
  12. data/lib/fontisan/commands/pack_command.rb +2 -1
  13. data/lib/fontisan/commands/validate_command.rb +157 -6
  14. data/lib/fontisan/converters/collection_converter.rb +22 -13
  15. data/lib/fontisan/converters/svg_generator.rb +2 -1
  16. data/lib/fontisan/converters/woff2_encoder.rb +6 -6
  17. data/lib/fontisan/converters/woff_writer.rb +3 -1
  18. data/lib/fontisan/font_loader.rb +7 -6
  19. data/lib/fontisan/formatters/text_formatter.rb +18 -14
  20. data/lib/fontisan/hints/hint_converter.rb +1 -1
  21. data/lib/fontisan/hints/hint_validator.rb +13 -10
  22. data/lib/fontisan/hints/truetype_instruction_analyzer.rb +15 -8
  23. data/lib/fontisan/hints/truetype_instruction_generator.rb +1 -1
  24. data/lib/fontisan/models/collection_validation_report.rb +104 -0
  25. data/lib/fontisan/models/font_report.rb +24 -0
  26. data/lib/fontisan/models/validation_report.rb +7 -2
  27. data/lib/fontisan/open_type_font.rb +2 -3
  28. data/lib/fontisan/optimizers/charstring_rewriter.rb +1 -1
  29. data/lib/fontisan/optimizers/subroutine_optimizer.rb +6 -2
  30. data/lib/fontisan/subset/glyph_mapping.rb +2 -0
  31. data/lib/fontisan/subset/table_subsetter.rb +2 -2
  32. data/lib/fontisan/tables/cblc.rb +8 -4
  33. data/lib/fontisan/tables/cff/index.rb +2 -0
  34. data/lib/fontisan/tables/cff.rb +6 -3
  35. data/lib/fontisan/tables/cff2/private_dict_blend_handler.rb +1 -1
  36. data/lib/fontisan/tables/cff2.rb +1 -1
  37. data/lib/fontisan/tables/cmap.rb +5 -5
  38. data/lib/fontisan/tables/glyf.rb +8 -10
  39. data/lib/fontisan/tables/head.rb +3 -3
  40. data/lib/fontisan/tables/hhea.rb +4 -4
  41. data/lib/fontisan/tables/maxp.rb +2 -2
  42. data/lib/fontisan/tables/name.rb +1 -1
  43. data/lib/fontisan/tables/os2.rb +8 -8
  44. data/lib/fontisan/tables/post.rb +2 -2
  45. data/lib/fontisan/tables/sbix.rb +5 -4
  46. data/lib/fontisan/true_type_font.rb +2 -3
  47. data/lib/fontisan/utilities/checksum_calculator.rb +0 -44
  48. data/lib/fontisan/validation/collection_validator.rb +4 -2
  49. data/lib/fontisan/validators/basic_validator.rb +11 -21
  50. data/lib/fontisan/validators/font_book_validator.rb +29 -50
  51. data/lib/fontisan/validators/opentype_validator.rb +24 -28
  52. data/lib/fontisan/validators/validator.rb +87 -66
  53. data/lib/fontisan/validators/web_font_validator.rb +16 -21
  54. data/lib/fontisan/version.rb +1 -1
  55. data/lib/fontisan/woff2/glyf_transformer.rb +31 -8
  56. data/lib/fontisan/woff2/hmtx_transformer.rb +2 -1
  57. data/lib/fontisan/woff2/table_transformer.rb +4 -2
  58. data/lib/fontisan/woff2_font.rb +4 -2
  59. data/lib/fontisan/woff_font.rb +2 -2
  60. data/lib/fontisan.rb +2 -2
  61. data/scripts/compare_stack_aware.rb +1 -1
  62. data/scripts/measure_optimization.rb +1 -2
  63. metadata +4 -2
data/README.adoc CHANGED
@@ -1034,26 +1034,19 @@ structures, usability, instructions, and glyphs.
1034
1034
 
1035
1035
  === Predefined profiles
1036
1036
 
1037
- Fontisan includes several predefined validation profiles for common use cases:
1037
+ Fontisan includes validation profiles for different use cases:
1038
1038
 
1039
- `indexability`:: Fast validation for font discovery and indexing (< 50ms). Uses
1040
- BasicValidator with 8 essential checks. Loading mode: metadata.
1039
+ `indexability`:: Fast font discovery and indexing (~5x faster, 8 checks, metadata-only)
1041
1040
 
1042
- `usability`:: Basic usability for font installation. Uses FontBookValidator with
1043
- 26 checks including macOS Font Book compatibility. Loading mode: full.
1041
+ `usability`:: Font installation compatibility (26 checks, macOS Font Book focused)
1044
1042
 
1045
- `production`:: Comprehensive quality checks for production fonts (default
1046
- profile). Uses OpenTypeValidator with 36 checks for OpenType spec compliance.
1047
- Loading mode: full.
1043
+ `production`:: Comprehensive production quality (36 checks, OpenType spec compliance - default)
1048
1044
 
1049
- `web`:: Web font embedding and optimization validation. Uses WebFontValidator
1050
- with 18 checks for web deployment. Loading mode: full.
1045
+ `web`:: Web font embedding readiness (18 checks for web deployment)
1051
1046
 
1052
- `spec_compliance`:: Full OpenType specification compliance with detailed checks.
1053
- Uses OpenTypeValidator with info-level severity for comprehensive analysis.
1054
- Loading mode: full.
1047
+ `spec_compliance`:: Full OpenType specification compliance (detailed analysis mode)
1055
1048
 
1056
- `default`:: Alias for the production profile.
1049
+ `default`:: Alias for the production profile
1057
1050
 
1058
1051
 
1059
1052
  === Command-line usage
@@ -1154,6 +1147,80 @@ production, web, spec_compliance, default)
1154
1147
 
1155
1148
  -e, --exclude CHECKS:: Exclude specific checks (comma-separated list)
1156
1149
 
1150
+ === Collection validation
1151
+
1152
+ The `validate` command automatically detects and validates font collections (TTC, OTC, dfont).
1153
+ When validating a collection, fontisan validates all fonts in the collection and displays
1154
+ per-font results with an overall summary.
1155
+
1156
+ .Validation profiles with collections
1157
+ [example]
1158
+ ====
1159
+ All validation profiles work with font collections. The selected profile determines:
1160
+
1161
+ * **Which tables are loaded** - metadata vs full mode
1162
+ * **Which checks are performed** - number and type of validations
1163
+ * **Performance characteristics** - indexability is ~5x faster than production
1164
+
1165
+ For large collections (CJK fonts with 30+ fonts), use the `indexability` profile for fast
1166
+ validation when scanning for font discovery, or `production` for comprehensive quality
1167
+ checks.
1168
+
1169
+ [source,shell]
1170
+ ----
1171
+ # Quick validation for indexing (metadata mode, 8 checks, ~5x faster)
1172
+ $ fontisan validate /path/to/font.ttc -t indexability
1173
+
1174
+ # Comprehensive validation (full mode, 37 checks)
1175
+ $ fontisan validate /path/to/font.ttc -t production
1176
+
1177
+ # Web font readiness validation
1178
+ $ fontisan validate /path/to/font.ttc -t web
1179
+ ----
1180
+
1181
+ The collection validation output includes:
1182
+
1183
+ * *Collection header* - Path, type, and number of fonts
1184
+ * *Summary* - Total errors, warnings, and info across all fonts
1185
+ * *Per-font sections* - Individual validation results for each font with:
1186
+ ** Font index and name
1187
+ ** Font path in `collection.ttc:index` format
1188
+ ** Individual font status (VALID/INVALID/VALID_WITH_WARNINGS)
1189
+ ** Font-specific errors and warnings
1190
+ ** Exit codes* - For collections, uses the "worst" status across all fonts:
1191
+ ** 0 = All fonts valid
1192
+ ** 2 = Any font has fatal errors
1193
+ ** 3 = Any font has errors (and no fatal)
1194
+ ** 4 = Any font has warnings (and no errors)
1195
+ ** 5 = Any font has info issues (and no errors or warnings)
1196
+ ====
1197
+
1198
+ .Validate a font collection
1199
+ [example]
1200
+ ====
1201
+ [source,shell]
1202
+ ----
1203
+ $ fontisan validate /path/to/font.ttc
1204
+ Collection: /path/to/font.ttc
1205
+ Type: TTC
1206
+ Fonts: 4
1207
+
1208
+ Summary:
1209
+ Total Errors: 14
1210
+ Total Warnings: 8
1211
+ Total Info: 0
1212
+
1213
+ === Font 0: Lucida Grande ===
1214
+ Font: /path/to/font.ttc:0
1215
+ Status: INVALID
1216
+ ...
1217
+
1218
+ === Font 1: Lucida Grande Bold ===
1219
+ Font: /path/to/font.ttc:1
1220
+ Status: INVALID
1221
+ ...
1222
+ ----
1223
+ ====
1157
1224
 
1158
1225
  === Ruby API usage
1159
1226
 
data/Rakefile CHANGED
@@ -8,6 +8,7 @@ require "rubocop/rake_task"
8
8
 
9
9
  RuboCop::RakeTask.new
10
10
 
11
+ # rubocop:disable Metrics/BlockLength
11
12
  namespace :fixtures do
12
13
  # Load centralized fixture configuration
13
14
  require_relative "spec/support/fixture_fonts"
@@ -99,8 +100,13 @@ namespace :fixtures do
99
100
  end
100
101
  end
101
102
 
103
+ # Compute download task prerequisites (marker files for non-skipped fonts)
104
+ download_prerequisites = fonts.values.reject do |config|
105
+ config[:skip_download]
106
+ end.map { |config| config[:marker] }
107
+
102
108
  desc "Download all test fixture fonts"
103
- task download: fonts.values.reject { |config| config[:skip_download] }.map { |config| config[:marker] }
109
+ task download: download_prerequisites
104
110
 
105
111
  desc "Clean downloaded fixtures"
106
112
  task :clean do
@@ -112,17 +118,16 @@ namespace :fixtures do
112
118
  FileUtils.rm_f(config[:marker])
113
119
  puts "[fixtures:clean] Removed #{config[:marker]}"
114
120
  end
115
- else
121
+ elsif File.exist?(config[:target_dir])
116
122
  # For archives, delete the entire target directory
117
- if File.exist?(config[:target_dir])
118
- puts "[fixtures:clean] Removing #{config[:target_dir]}..."
119
- FileUtils.rm_rf(config[:target_dir])
120
- puts "[fixtures:clean] Removed #{config[:target_dir]}"
121
- end
123
+ puts "[fixtures:clean] Removing #{config[:target_dir]}..."
124
+ FileUtils.rm_rf(config[:target_dir])
125
+ puts "[fixtures:clean] Removed #{config[:target_dir]}"
122
126
  end
123
127
  end
124
128
  end
125
129
  end
130
+ # rubocop:enable Metrics/BlockLength
126
131
 
127
132
  # RSpec task depends on fixtures
128
133
  RSpec::Core::RakeTask.new(spec: "fixtures:download")
@@ -18,7 +18,7 @@ puts "Font: #{FONT_PATH}"
18
18
  puts
19
19
 
20
20
  # Test coordinates
21
- coords = Array.new(4) { |i| { "wght" => 300 + i * 200 } }
21
+ coords = Array.new(4) { |i| { "wght" => 300 + (i * 200) } }
22
22
  puts "Generating #{coords.size} instances"
23
23
  puts
24
24
 
data/lib/fontisan/cli.rb CHANGED
@@ -367,18 +367,27 @@ module Fontisan
367
367
  handle_error(e)
368
368
  end
369
369
 
370
- desc "validate FONT_FILE", "Validate font file"
370
+ desc "validate [FONT_FILE]", "Validate font file"
371
371
  long_desc <<-DESC
372
372
  Validate font file against quality checks and standards.
373
373
 
374
- Test lists (-t/--test-list):
375
- indexability - Fast indexing validation
374
+ Supports individual fonts (TTF/OTF) and font collections (TTC/OTC/dfont).
375
+ For collections, validates each font and provides a summary report.
376
+
377
+ Profiles (-p/--profile):
378
+ indexability - Fast indexing validation (metadata only, 8 checks)
376
379
  usability - Installation compatibility
377
380
  production - Comprehensive quality (default)
378
381
  web - Web font readiness
379
382
  spec_compliance - OpenType spec compliance
380
383
  default - Production profile (alias)
381
384
 
385
+ Collection validation with profiles:
386
+ When validating TTC/OTC/dfont collections, the selected profile
387
+ determines which tables are loaded and which checks are performed
388
+ for each font in the collection. Use 'indexability' for quick
389
+ validation or 'production' for comprehensive quality checks.
390
+
382
391
  Return values (with -R/--return-value-results):
383
392
  0 No results
384
393
  1 Execution errors
@@ -389,25 +398,41 @@ module Fontisan
389
398
  DESC
390
399
 
391
400
  option :exclude, aliases: "-e", type: :array, desc: "Tests to exclude"
392
- option :list, aliases: "-l", type: :boolean, desc: "List available tests"
401
+ option :list, aliases: "-l", type: :boolean, desc: "List available profiles"
393
402
  option :output, aliases: "-o", type: :string, desc: "Output file"
394
403
  option :full_report, aliases: "-r", type: :boolean, desc: "Full report"
395
- option :return_value_results, aliases: "-R", type: :boolean, desc: "Use return value for results"
396
- option :summary_report, aliases: "-S", type: :boolean, desc: "Summary report"
397
- option :test_list, aliases: "-t", type: :string, default: "default", desc: "Tests to execute"
404
+ option :return_value_results, aliases: "-R", type: :boolean,
405
+ desc: "Use return value for results"
406
+ option :summary_report, aliases: "-S", type: :boolean,
407
+ desc: "Summary report"
408
+ option :profile, aliases: "-p", type: :string, default: "default",
409
+ desc: "Validation profile"
398
410
  option :table_report, aliases: "-T", type: :boolean, desc: "Tabular report"
399
411
  option :verbose, aliases: "-v", type: :boolean, desc: "Verbose output"
400
- option :suppress_warnings, aliases: "-W", type: :boolean, desc: "Suppress warnings"
412
+ option :suppress_warnings, aliases: "-W", type: :boolean,
413
+ desc: "Suppress warnings"
401
414
 
402
- def validate(font_file)
415
+ def validate(*font_files)
403
416
  if options[:list]
404
417
  list_available_tests
405
418
  return
406
419
  end
407
420
 
421
+ # Validate argument count
422
+ if font_files.empty?
423
+ raise(Thor::Error, "FONT_FILE is required unless using --list")
424
+ elsif font_files.size > 1
425
+ raise(Thor::Error,
426
+ "Too many arguments. validate accepts only one font file.\n" \
427
+ "To validate multiple files, run validate separately for each.\n" \
428
+ "To validate all fonts in a collection, use: fontisan validate collection.ttc")
429
+ end
430
+
431
+ font_file = font_files.first
432
+
408
433
  cmd = Commands::ValidateCommand.new(
409
434
  input: font_file,
410
- profile: options[:test_list],
435
+ profile: options[:profile],
411
436
  exclude: options[:exclude] || [],
412
437
  output: options[:output],
413
438
  format: options[:format].to_sym,
@@ -416,11 +441,18 @@ module Fontisan
416
441
  table_report: options[:table_report],
417
442
  verbose: options[:verbose],
418
443
  suppress_warnings: options[:suppress_warnings],
419
- return_value_results: options[:return_value_results]
444
+ return_value_results: options[:return_value_results],
420
445
  )
421
446
 
422
447
  exit cmd.run
423
- rescue => e
448
+ rescue Thor::Error => e
449
+ unless options[:quiet]
450
+ warn "ERROR: #{e.message}"
451
+ warn
452
+ help("validate")
453
+ end
454
+ exit 1
455
+ rescue StandardError => e
424
456
  error "Validation failed: #{e.message}"
425
457
  exit 1
426
458
  end
@@ -546,7 +578,7 @@ module Fontisan
546
578
  puts " Format: #{result[:format].upcase}"
547
579
  puts " Fonts: #{result[:num_fonts]}"
548
580
  puts " Size: #{format_size(result[:output_size] || result[:total_size])}"
549
- if result[:space_savings] && result[:space_savings].positive?
581
+ if result[:space_savings]&.positive?
550
582
  puts " Space saved: #{format_size(result[:space_savings])}"
551
583
  puts " Sharing: #{result[:statistics][:sharing_percentage]}%"
552
584
  end
@@ -86,7 +86,8 @@ module Fontisan
86
86
  map_size = 28 + 2 + 8 + (sfnt_binaries.size * 12)
87
87
 
88
88
  # Step 4: Build resource map
89
- resource_map = build_resource_map(sfnt_binaries, resource_data.bytesize, map_size)
89
+ resource_map = build_resource_map(sfnt_binaries,
90
+ resource_data.bytesize, map_size)
90
91
 
91
92
  # Step 5: Build header
92
93
  header = build_header(resource_data.bytesize, resource_map.bytesize)
@@ -73,7 +73,7 @@ module Fontisan
73
73
  @instance_index = opts[:instance_index]
74
74
  @preserve_variation = opts[:preserve_variation]
75
75
  @preserve_hints = opts.fetch(:preserve_hints, false)
76
- @collection_target_format = opts.fetch(:target_format, 'preserve').to_s
76
+ @collection_target_format = opts.fetch(:target_format, "preserve").to_s
77
77
  @validate = !opts[:no_validate]
78
78
  end
79
79
 
@@ -196,7 +196,7 @@ module Fontisan
196
196
  output: @output_path,
197
197
  target_format: @collection_target_format,
198
198
  verbose: @options[:verbose],
199
- }
199
+ },
200
200
  )
201
201
 
202
202
  # Display results
@@ -245,8 +245,6 @@ module Fontisan
245
245
  :otc
246
246
  when ".dfont"
247
247
  :dfont
248
- else
249
- nil
250
248
  end
251
249
  end
252
250
  end
@@ -311,7 +311,7 @@ module Fontisan
311
311
  start_glyph_id: strike_rec.start_glyph_index,
312
312
  end_glyph_id: strike_rec.end_glyph_index,
313
313
  bit_depth: strike_rec.bit_depth,
314
- num_glyphs: strike_rec.glyph_range.size
314
+ num_glyphs: strike_rec.glyph_range.size,
315
315
  )
316
316
  end
317
317
  formats << "PNG" # CBDT typically contains PNG data
@@ -329,8 +329,8 @@ module Fontisan
329
329
  ppem: strike[:ppem],
330
330
  start_glyph_id: 0,
331
331
  end_glyph_id: strike[:num_glyphs] - 1,
332
- bit_depth: 32, # sbix is typically 32-bit
333
- num_glyphs: strike[:num_glyphs]
332
+ bit_depth: 32, # sbix is typically 32-bit
333
+ num_glyphs: strike[:num_glyphs],
334
334
  )
335
335
  end
336
336
  end
@@ -260,7 +260,8 @@ module Fontisan
260
260
  # @return [Symbol] Parsed format (:ttc, :otc, or :dfont)
261
261
  # @raise [ArgumentError] if format is invalid
262
262
  def parse_format(format)
263
- return format if format.is_a?(Symbol) && %i[ttc otc dfont].include?(format)
263
+ return format if format.is_a?(Symbol) && %i[ttc otc
264
+ dfont].include?(format)
264
265
 
265
266
  case format.to_s.downcase
266
267
  when "ttc"
@@ -3,6 +3,7 @@
3
3
  require_relative "base_command"
4
4
  require_relative "../validators/profile_loader"
5
5
  require_relative "../font_loader"
6
+ require_relative "../tables/name"
6
7
 
7
8
  module Fontisan
8
9
  module Commands
@@ -70,12 +71,40 @@ module Fontisan
70
71
  # Load font with appropriate mode
71
72
  profile_config = Validators::ProfileLoader.profile_info(@profile)
72
73
  unless profile_config
73
- puts "Error: Unknown profile '#{@profile}'" unless @suppress_warnings
74
+ unless @suppress_warnings
75
+ puts "Error: Unknown profile '#{@profile}'"
76
+ puts ""
77
+ puts "Available profiles:"
78
+ Validators::ProfileLoader.all_profiles.each do |name, config|
79
+ puts " #{name.to_s.ljust(20)} - #{config[:description]}"
80
+ end
81
+ puts ""
82
+ puts "Use --list to see all available profiles"
83
+ end
74
84
  return 1
75
85
  end
76
86
 
77
87
  mode = profile_config[:loading_mode].to_sym
78
88
 
89
+ # Check if input is a collection
90
+ if FontLoader.collection?(@input)
91
+ validate_collection(mode)
92
+ else
93
+ validate_single_font(mode)
94
+ end
95
+ rescue StandardError => e
96
+ puts "Error: #{e.message}" unless @suppress_warnings
97
+ puts e.backtrace.join("\n") if @verbose && !@suppress_warnings
98
+ 1
99
+ end
100
+
101
+ private
102
+
103
+ # Validate a single font file
104
+ #
105
+ # @param mode [Symbol] Loading mode
106
+ # @return [Integer] Exit code
107
+ def validate_single_font(mode)
79
108
  font = FontLoader.load(@input, mode: mode)
80
109
 
81
110
  # Select validator
@@ -102,13 +131,134 @@ module Fontisan
102
131
 
103
132
  # Return exit code
104
133
  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
109
134
  end
110
135
 
111
- private
136
+ # Validate all fonts in a collection
137
+ #
138
+ # @param mode [Symbol] Loading mode
139
+ # @return [Integer] Exit code
140
+ def validate_collection(mode)
141
+ require_relative "../models/collection_validation_report"
142
+ require_relative "../models/font_report"
143
+
144
+ # Load collection metadata
145
+ collection = FontLoader.load_collection(@input)
146
+
147
+ # Create collection report
148
+ collection_report = Models::CollectionValidationReport.new(
149
+ collection_path: @input,
150
+ collection_type: collection.class.collection_format,
151
+ num_fonts: collection.num_fonts,
152
+ )
153
+
154
+ # Get validator
155
+ validator = Validators::ProfileLoader.load(@profile)
156
+
157
+ # Validate each font
158
+ collection.num_fonts.times do |index|
159
+ font = FontLoader.load(@input, font_index: index, mode: mode)
160
+ font_report = validator.validate(font)
161
+
162
+ # Set font_path to indicate collection file with index
163
+ # Fonts in collections don't have individual file paths
164
+ font_report.font_path = "#{@input}:#{index}"
165
+
166
+ # Extract font name
167
+ font_name = extract_font_name(font, index)
168
+
169
+ # Create and add font report
170
+ collection_report.add_font_report(
171
+ Models::FontReport.new(
172
+ font_index: index,
173
+ font_name: font_name,
174
+ report: font_report,
175
+ ),
176
+ )
177
+ rescue StandardError => e
178
+ # Create error report for failed font loading
179
+ # Use collection file path with index for fonts in collections
180
+ error_report = Models::ValidationReport.new(
181
+ font_path: "#{@input}:#{index}",
182
+ valid: false,
183
+ )
184
+ error_report.add_error("font_loading",
185
+ "Failed to load font #{index}: #{e.message}", nil)
186
+
187
+ collection_report.add_font_report(
188
+ Models::FontReport.new(
189
+ font_index: index,
190
+ font_name: "Font #{index}",
191
+ report: error_report,
192
+ ),
193
+ )
194
+ end
195
+
196
+ # Generate output based on format
197
+ output = case @format
198
+ when :yaml
199
+ collection_report.to_yaml
200
+ when :json
201
+ collection_report.to_json
202
+ else
203
+ collection_report.text_summary
204
+ end
205
+
206
+ # Write to file or stdout
207
+ if @output
208
+ File.write(@output, output)
209
+ puts "Validation report written to #{@output}" if @verbose && !@suppress_warnings
210
+ else
211
+ puts output unless @suppress_warnings
212
+ end
213
+
214
+ # Return exit code based on worst status
215
+ collection_exit_code(collection_report)
216
+ end
217
+
218
+ # Extract font name from font object
219
+ #
220
+ # @param font [TrueTypeFont, OpenTypeFont] Font object
221
+ # @param index [Integer] Font index (fallback)
222
+ # @return [String] Font name
223
+ def extract_font_name(font, index)
224
+ return "Font #{index}" unless font.respond_to?(:table)
225
+
226
+ name_table = font.table("name")
227
+ return "Font #{index}" unless name_table
228
+
229
+ full_name = name_table.english_name(Tables::Name::FULL_NAME)
230
+ return full_name if full_name && !full_name.empty?
231
+
232
+ postscript_name = name_table.english_name(Tables::Name::POSTSCRIPT_NAME)
233
+ return postscript_name if postscript_name && !postscript_name.empty?
234
+
235
+ "Font #{index}"
236
+ end
237
+
238
+ # Calculate exit code for collection validation
239
+ #
240
+ # Uses worst status across all fonts
241
+ #
242
+ # @param report [CollectionValidationReport] Collection report
243
+ # @return [Integer] Exit code
244
+ def collection_exit_code(report)
245
+ return 0 unless @return_value_results
246
+
247
+ # Check for fatal errors first
248
+ return 2 if report.font_reports.any? do |fr|
249
+ fr.report.fatal_errors.any?
250
+ end
251
+ # Then check for errors
252
+ return 3 if report.font_reports.any? { |fr| fr.report.errors_only.any? }
253
+ # Then check for warnings
254
+ return 4 if report.font_reports.any? do |fr|
255
+ fr.report.warnings_only.any?
256
+ end
257
+ # Then check for info
258
+ return 5 if report.font_reports.any? { |fr| fr.report.info_only.any? }
259
+
260
+ 0
261
+ end
112
262
 
113
263
  # Generate output based on requested format
114
264
  #
@@ -155,6 +305,7 @@ module Fontisan
155
305
  return 3 if report.errors_only.any?
156
306
  return 4 if report.warnings_only.any?
157
307
  return 5 if report.info_only.any?
308
+
158
309
  0
159
310
  end
160
311
  end
@@ -59,11 +59,12 @@ module Fontisan
59
59
 
60
60
  verbose = options.fetch(:verbose, false)
61
61
  output_path = options[:output]
62
- target_format = options.fetch(:target_format, 'preserve').to_s
62
+ target_format = options.fetch(:target_format, "preserve").to_s
63
63
 
64
64
  # Validate target_format
65
65
  unless %w[preserve ttf otf].include?(target_format)
66
- raise ArgumentError, "Invalid target_format: #{target_format}. Must be 'preserve', 'ttf', or 'otf'"
66
+ raise ArgumentError,
67
+ "Invalid target_format: #{target_format}. Must be 'preserve', 'ttf', or 'otf'"
67
68
  end
68
69
 
69
70
  puts "Converting collection to #{target_type.to_s.upcase}..." if verbose
@@ -76,19 +77,22 @@ module Fontisan
76
77
  if source_type == target_type
77
78
  puts " Source and target formats are the same, copying collection..." if verbose
78
79
  FileUtils.cp(collection_path, output_path)
79
- return build_result(collection_path, output_path, source_type, target_type, fonts.size, [])
80
+ return build_result(collection_path, output_path, source_type,
81
+ target_type, fonts.size, [])
80
82
  end
81
83
 
82
84
  # Step 2: Convert - transform fonts if requested
83
85
  puts " Converting #{fonts.size} font(s)..." if verbose
84
- converted_fonts, conversions = convert_fonts(fonts, source_type, target_type, options.merge(target_format: target_format))
86
+ converted_fonts, conversions = convert_fonts(fonts, source_type,
87
+ target_type, options.merge(target_format: target_format))
85
88
 
86
89
  # Step 3: Repack - build target collection
87
90
  puts " Repacking into #{target_type.to_s.upcase} format..." if verbose
88
91
  repack_fonts(converted_fonts, target_type, output_path, options)
89
92
 
90
93
  # Build result
91
- result = build_result(collection_path, output_path, source_type, target_type, fonts.size, conversions)
94
+ result = build_result(collection_path, output_path, source_type,
95
+ target_type, fonts.size, conversions)
92
96
 
93
97
  if verbose
94
98
  display_result(result)
@@ -111,7 +115,8 @@ module Fontisan
111
115
  end
112
116
 
113
117
  unless %i[ttc otc dfont].include?(target_type)
114
- raise ArgumentError, "Invalid target type: #{target_type}. Must be :ttc, :otc, or :dfont"
118
+ raise ArgumentError,
119
+ "Invalid target type: #{target_type}. Must be :ttc, :otc, or :dfont"
115
120
  end
116
121
 
117
122
  unless options[:output]
@@ -202,20 +207,21 @@ module Fontisan
202
207
  # @param target_type [Symbol] Target collection type
203
208
  # @param options [Hash] Conversion options
204
209
  # @return [Array<(Array<Font>, Array<Hash>)>] [converted_fonts, conversions]
205
- def convert_fonts(fonts, source_type, target_type, options)
210
+ def convert_fonts(fonts, _source_type, target_type, options)
206
211
  converted_fonts = []
207
212
  conversions = []
208
213
 
209
214
  # Determine if outline conversion is needed
210
- target_format = options.fetch(:target_format, 'preserve').to_s
215
+ target_format = options.fetch(:target_format, "preserve").to_s
211
216
 
212
217
  fonts.each_with_index do |font, index|
213
218
  source_format = detect_font_format(font)
214
- needs_conversion = outline_conversion_needed?(source_format, target_format)
219
+ needs_conversion = outline_conversion_needed?(source_format,
220
+ target_format)
215
221
 
216
222
  if needs_conversion
217
223
  # Convert outline format
218
- desired_format = target_format == 'preserve' ? source_format : target_format.to_sym
224
+ desired_format = target_format == "preserve" ? source_format : target_format.to_sym
219
225
  converter = FormatConverter.new
220
226
 
221
227
  begin
@@ -266,7 +272,7 @@ module Fontisan
266
272
  # @return [Boolean] true if conversion needed
267
273
  def outline_conversion_needed?(source_format, target_format)
268
274
  # 'preserve' means keep original format
269
- return false if target_format == 'preserve'
275
+ return false if target_format == "preserve"
270
276
 
271
277
  # Convert if target format differs from source
272
278
  target_format.to_sym != source_format
@@ -401,7 +407,8 @@ module Fontisan
401
407
  # @param num_fonts [Integer] Number of fonts
402
408
  # @param conversions [Array<Hash>] Conversion details
403
409
  # @return [Hash] Result
404
- def build_result(input, output, source_type, target_type, num_fonts, conversions)
410
+ def build_result(input, output, source_type, target_type, num_fonts,
411
+ conversions)
405
412
  {
406
413
  input: input,
407
414
  output: output,
@@ -425,7 +432,9 @@ module Fontisan
425
432
  puts "Fonts: #{result[:num_fonts]}"
426
433
 
427
434
  if result[:conversions].any?
428
- converted_count = result[:conversions].count { |c| c[:status] == :converted }
435
+ converted_count = result[:conversions].count do |c|
436
+ c[:status] == :converted
437
+ end
429
438
  if converted_count.positive?
430
439
  puts "Outline conversions: #{converted_count}"
431
440
  end
@@ -107,7 +107,8 @@ module Fontisan
107
107
  # @param extractor [OutlineExtractor] Outline extractor for layer glyphs
108
108
  # @param palette_index [Integer] Palette index to use (default: 0)
109
109
  # @return [String, nil] SVG path data with color layers, or nil if not color glyph
110
- def generate_color_glyph(glyph_id, colr_table, cpal_table, extractor, palette_index: 0)
110
+ def generate_color_glyph(glyph_id, colr_table, cpal_table, extractor,
111
+ palette_index: 0)
111
112
  # Get layers for this glyph
112
113
  layers = colr_table.layers_for_glyph(glyph_id)
113
114
  return nil if layers.empty?