fontisan 0.2.7 → 0.2.9

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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +103 -0
  3. data/.rubocop_todo.yml +65 -361
  4. data/CHANGELOG.md +116 -0
  5. data/Gemfile +1 -1
  6. data/README.adoc +106 -27
  7. data/Rakefile +12 -7
  8. data/benchmark/variation_quick_bench.rb +1 -1
  9. data/docs/APPLE_LEGACY_FONTS.adoc +173 -0
  10. data/docs/COLLECTION_VALIDATION.adoc +143 -0
  11. data/docs/COLOR_FONTS.adoc +127 -0
  12. data/docs/DOCUMENTATION_SUMMARY.md +141 -0
  13. data/docs/FONT_HINTING.adoc +9 -1
  14. data/docs/VALIDATION.adoc +254 -0
  15. data/docs/WOFF_WOFF2_FORMATS.adoc +94 -0
  16. data/lib/fontisan/cli.rb +45 -13
  17. data/lib/fontisan/collection/dfont_builder.rb +2 -1
  18. data/lib/fontisan/commands/convert_command.rb +2 -4
  19. data/lib/fontisan/commands/info_command.rb +3 -3
  20. data/lib/fontisan/commands/pack_command.rb +2 -1
  21. data/lib/fontisan/commands/validate_command.rb +157 -6
  22. data/lib/fontisan/converters/collection_converter.rb +22 -13
  23. data/lib/fontisan/converters/svg_generator.rb +2 -1
  24. data/lib/fontisan/converters/woff2_encoder.rb +6 -6
  25. data/lib/fontisan/converters/woff_writer.rb +3 -1
  26. data/lib/fontisan/font_loader.rb +7 -6
  27. data/lib/fontisan/formatters/text_formatter.rb +18 -14
  28. data/lib/fontisan/hints/hint_converter.rb +1 -1
  29. data/lib/fontisan/hints/hint_validator.rb +13 -10
  30. data/lib/fontisan/hints/truetype_instruction_analyzer.rb +15 -8
  31. data/lib/fontisan/hints/truetype_instruction_generator.rb +1 -1
  32. data/lib/fontisan/models/collection_validation_report.rb +104 -0
  33. data/lib/fontisan/models/font_report.rb +24 -0
  34. data/lib/fontisan/models/validation_report.rb +7 -2
  35. data/lib/fontisan/open_type_font.rb +18 -425
  36. data/lib/fontisan/optimizers/charstring_rewriter.rb +1 -1
  37. data/lib/fontisan/optimizers/subroutine_optimizer.rb +6 -2
  38. data/lib/fontisan/sfnt_font.rb +699 -0
  39. data/lib/fontisan/sfnt_table.rb +264 -0
  40. data/lib/fontisan/subset/glyph_mapping.rb +2 -0
  41. data/lib/fontisan/subset/table_subsetter.rb +2 -2
  42. data/lib/fontisan/tables/cblc.rb +8 -4
  43. data/lib/fontisan/tables/cff/index.rb +2 -0
  44. data/lib/fontisan/tables/cff.rb +6 -3
  45. data/lib/fontisan/tables/cff2/private_dict_blend_handler.rb +1 -1
  46. data/lib/fontisan/tables/cff2.rb +1 -1
  47. data/lib/fontisan/tables/cmap.rb +5 -5
  48. data/lib/fontisan/tables/cmap_table.rb +231 -0
  49. data/lib/fontisan/tables/glyf.rb +8 -10
  50. data/lib/fontisan/tables/glyf_table.rb +255 -0
  51. data/lib/fontisan/tables/head.rb +3 -3
  52. data/lib/fontisan/tables/head_table.rb +111 -0
  53. data/lib/fontisan/tables/hhea.rb +4 -4
  54. data/lib/fontisan/tables/hhea_table.rb +255 -0
  55. data/lib/fontisan/tables/hmtx_table.rb +191 -0
  56. data/lib/fontisan/tables/loca_table.rb +212 -0
  57. data/lib/fontisan/tables/maxp.rb +2 -2
  58. data/lib/fontisan/tables/maxp_table.rb +258 -0
  59. data/lib/fontisan/tables/name.rb +1 -1
  60. data/lib/fontisan/tables/name_table.rb +176 -0
  61. data/lib/fontisan/tables/os2.rb +8 -8
  62. data/lib/fontisan/tables/os2_table.rb +329 -0
  63. data/lib/fontisan/tables/post.rb +2 -2
  64. data/lib/fontisan/tables/post_table.rb +183 -0
  65. data/lib/fontisan/tables/sbix.rb +5 -4
  66. data/lib/fontisan/true_type_font.rb +12 -464
  67. data/lib/fontisan/utilities/checksum_calculator.rb +0 -44
  68. data/lib/fontisan/validation/collection_validator.rb +4 -2
  69. data/lib/fontisan/validators/basic_validator.rb +11 -21
  70. data/lib/fontisan/validators/font_book_validator.rb +29 -50
  71. data/lib/fontisan/validators/opentype_validator.rb +24 -28
  72. data/lib/fontisan/validators/validator.rb +87 -66
  73. data/lib/fontisan/validators/web_font_validator.rb +16 -21
  74. data/lib/fontisan/version.rb +1 -1
  75. data/lib/fontisan/woff2/glyf_transformer.rb +31 -8
  76. data/lib/fontisan/woff2/hmtx_transformer.rb +2 -1
  77. data/lib/fontisan/woff2/table_transformer.rb +4 -2
  78. data/lib/fontisan/woff2_font.rb +4 -2
  79. data/lib/fontisan/woff_font.rb +46 -30
  80. data/lib/fontisan.rb +2 -2
  81. data/scripts/compare_stack_aware.rb +1 -1
  82. data/scripts/measure_optimization.rb +1 -2
  83. metadata +23 -2
@@ -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?
@@ -109,7 +109,7 @@ module Fontisan
109
109
  woff2_binary = assemble_woff2(header, entries, compressed_data)
110
110
 
111
111
  # Prepare result
112
- result = { woff2_binary: woff2_binary }
112
+ { woff2_binary: woff2_binary }
113
113
 
114
114
  # Optional validation
115
115
  # Temporarily disabled - will be reimplemented with new DSL framework
@@ -117,8 +117,6 @@ module Fontisan
117
117
  # validation_report = validate_encoding(woff2_binary, options)
118
118
  # result[:validation_report] = validation_report
119
119
  # end
120
-
121
- result
122
120
  end
123
121
 
124
122
  # Get list of supported conversions
@@ -185,14 +183,16 @@ module Fontisan
185
183
  end
186
184
 
187
185
  # Read table directory
188
- woff2.table_entries = Woff2Font.read_table_directory_from_io(io, woff2.header)
186
+ woff2.table_entries = Woff2Font.read_table_directory_from_io(io,
187
+ woff2.header)
189
188
 
190
189
  # Decompress tables
191
190
  woff2.decompressed_tables = Woff2Font.decompress_tables(io, woff2.header,
192
- woff2.table_entries)
191
+ woff2.table_entries)
193
192
 
194
193
  # Apply transformations
195
- Woff2Font.apply_transformations!(woff2.table_entries, woff2.decompressed_tables)
194
+ Woff2Font.apply_transformations!(woff2.table_entries,
195
+ woff2.decompressed_tables)
196
196
 
197
197
  woff2
198
198
  end
@@ -369,9 +369,11 @@ module Fontisan
369
369
  # Sort tables by tag for consistent output (same order as directory)
370
370
  sorted_tables = compressed_tables.sort_by { |tag, _| tag }
371
371
 
372
- sorted_tables.each do |_tag, table_info|
372
+ # rubocop:disable Style/HashEachMethods - sorted_tables is an Array, not a Hash
373
+ sorted_tables.each do |_, table_info|
373
374
  io.write(table_info[:compressed_data])
374
375
  end
376
+ # rubocop:enable Style/HashEachMethods
375
377
  end
376
378
 
377
379
  # Write metadata to output
@@ -72,7 +72,8 @@ module Fontisan
72
72
  when "wOF2"
73
73
  Woff2Font.from_file(path, mode: resolved_mode, lazy: resolved_lazy)
74
74
  when Constants::DFONT_RESOURCE_HEADER
75
- extract_and_load_dfont(io, path, font_index, resolved_mode, resolved_lazy)
75
+ extract_and_load_dfont(io, path, font_index, resolved_mode,
76
+ resolved_lazy)
76
77
  else
77
78
  raise InvalidFontError,
78
79
  "Unknown font format. Expected TTF, OTF, TTC, OTC, WOFF, or WOFF2 file."
@@ -185,7 +186,7 @@ module Fontisan
185
186
  num_fonts = io.read(4).unpack1("N")
186
187
 
187
188
  # Read all font offsets
188
- font_offsets = num_fonts.times.map { io.read(4).unpack1("N") }
189
+ font_offsets = Array.new(num_fonts) { io.read(4).unpack1("N") }
189
190
 
190
191
  # Scan all fonts to determine collection type (not just first)
191
192
  truetype_count = 0
@@ -212,7 +213,7 @@ module Fontisan
212
213
  # Determine collection type based on what fonts are inside
213
214
  # If ANY font is OpenType, use OpenTypeCollection (more general format)
214
215
  # Only use TrueTypeCollection if ALL fonts are TrueType
215
- if opentype_count > 0
216
+ if opentype_count.positive?
216
217
  OpenTypeCollection.from_file(path)
217
218
  else
218
219
  # All fonts are TrueType
@@ -282,7 +283,7 @@ mode: LoadingModes::FULL, lazy: true)
282
283
  end
283
284
 
284
285
  # Read all font offsets
285
- font_offsets = num_fonts.times.map { io.read(4).unpack1("N") }
286
+ font_offsets = Array.new(num_fonts) { io.read(4).unpack1("N") }
286
287
 
287
288
  # Scan all fonts to determine collection type (not just first)
288
289
  truetype_count = 0
@@ -308,7 +309,7 @@ mode: LoadingModes::FULL, lazy: true)
308
309
 
309
310
  # If ANY font is OpenType, use OpenTypeCollection (more general format)
310
311
  # Only use TrueTypeCollection if ALL fonts are TrueType
311
- if opentype_count > 0
312
+ if opentype_count.positive?
312
313
  # OpenType Collection
313
314
  otc = OpenTypeCollection.from_file(path)
314
315
  File.open(path, "rb") { |f| otc.font(font_index, f, mode: mode) }
@@ -328,7 +329,7 @@ mode: LoadingModes::FULL, lazy: true)
328
329
  # @param lazy [Boolean] Lazy loading flag
329
330
  # @return [TrueTypeFont, OpenTypeFont] Loaded font
330
331
  # @api private
331
- def self.extract_and_load_dfont(io, path, font_index, mode, lazy)
332
+ def self.extract_and_load_dfont(io, _path, font_index, mode, lazy)
332
333
  require_relative "parsers/dfont_parser"
333
334
 
334
335
  # Extract SFNT data from resource fork
@@ -410,15 +410,16 @@ module Fontisan
410
410
 
411
411
  info.fonts.each_with_index do |font_info, index|
412
412
  # Show font index with offset
413
- if font_info.collection_offset
414
- lines << "Font #{index} (offset: #{font_info.collection_offset}):"
415
- else
416
- lines << "Font #{index}:"
417
- end
413
+ lines << if font_info.collection_offset
414
+ "Font #{index} (offset: #{font_info.collection_offset}):"
415
+ else
416
+ "Font #{index}:"
417
+ end
418
418
  lines << ""
419
419
 
420
420
  # Format each font using same structure as brief mode
421
- font_type_display = format_font_type_display(font_info.font_format, font_info.is_variable)
421
+ font_type_display = format_font_type_display(font_info.font_format,
422
+ font_info.is_variable)
422
423
  add_line(lines, "Font type", font_type_display)
423
424
  add_line(lines, "Family", font_info.family_name)
424
425
  add_line(lines, "Subfamily", font_info.subfamily_name)
@@ -426,7 +427,8 @@ module Fontisan
426
427
  add_line(lines, "PostScript name", font_info.postscript_name)
427
428
  add_line(lines, "Version", font_info.version)
428
429
  add_line(lines, "Vendor ID", font_info.vendor_id)
429
- add_line(lines, "Font revision", format_float(font_info.font_revision))
430
+ add_line(lines, "Font revision",
431
+ format_float(font_info.font_revision))
430
432
  add_line(lines, "Units per em", font_info.units_per_em)
431
433
 
432
434
  # Blank line between fonts (except after last)
@@ -467,15 +469,16 @@ module Fontisan
467
469
  # Each font's brief info
468
470
  info.fonts.each_with_index do |font_info, index|
469
471
  # Show font index with offset
470
- if font_info.collection_offset
471
- lines << "Font #{index} (offset: #{font_info.collection_offset}):"
472
- else
473
- lines << "Font #{index}:"
474
- end
472
+ lines << if font_info.collection_offset
473
+ "Font #{index} (offset: #{font_info.collection_offset}):"
474
+ else
475
+ "Font #{index}:"
476
+ end
475
477
  lines << ""
476
478
 
477
479
  # Format each font using same structure as individual fonts
478
- font_type_display = format_font_type_display(font_info.font_format, font_info.is_variable)
480
+ font_type_display = format_font_type_display(font_info.font_format,
481
+ font_info.is_variable)
479
482
  add_line(lines, "Font type", font_type_display)
480
483
  add_line(lines, "Family", font_info.family_name)
481
484
  add_line(lines, "Subfamily", font_info.subfamily_name)
@@ -483,7 +486,8 @@ module Fontisan
483
486
  add_line(lines, "PostScript name", font_info.postscript_name)
484
487
  add_line(lines, "Version", font_info.version)
485
488
  add_line(lines, "Vendor ID", font_info.vendor_id)
486
- add_line(lines, "Font revision", format_float(font_info.font_revision))
489
+ add_line(lines, "Font revision",
490
+ format_float(font_info.font_revision))
487
491
  add_line(lines, "Units per em", font_info.units_per_em)
488
492
 
489
493
  # Blank line between fonts (except after last)
@@ -244,7 +244,7 @@ module Fontisan
244
244
  # CVT values typically contain standard widths at the beginning
245
245
  if cvt && !cvt.empty?
246
246
  # First CVT value often represents standard horizontal stem
247
- hints[:std_hw] = cvt[0].abs if cvt.length > 0
247
+ hints[:std_hw] = cvt[0].abs if cvt.length.positive?
248
248
  # Second CVT value often represents standard vertical stem
249
249
  hints[:std_vw] = cvt[1].abs if cvt.length > 1
250
250
  end
@@ -40,7 +40,10 @@ module Fontisan
40
40
  # @param instructions [String] Binary instruction bytes
41
41
  # @return [Hash] Validation result with :valid, :errors, :warnings keys
42
42
  def validate_truetype_instructions(instructions)
43
- return { valid: true, errors: [], warnings: [] } if instructions.nil? || instructions.empty?
43
+ if instructions.nil? || instructions.empty?
44
+ return { valid: true, errors: [],
45
+ warnings: [] }
46
+ end
44
47
 
45
48
  errors = []
46
49
  warnings = []
@@ -114,7 +117,6 @@ module Fontisan
114
117
  if stack_depth != 0
115
118
  warnings << "Stack not neutral: #{stack_depth} value(s) remaining"
116
119
  end
117
-
118
120
  rescue StandardError => e
119
121
  errors << "Exception during validation: #{e.message}"
120
122
  end
@@ -162,14 +164,14 @@ module Fontisan
162
164
  end
163
165
 
164
166
  # Validate stem widths
165
- [:std_hw, :std_vw].each do |key|
167
+ %i[std_hw std_vw].each do |key|
166
168
  if hints[key] && hints[key] <= 0
167
169
  errors << "#{key} must be positive: #{hints[key]}"
168
170
  end
169
171
  end
170
172
 
171
173
  # Validate stem snaps
172
- [:stem_snap_h, :stem_snap_v].each do |key|
174
+ %i[stem_snap_h stem_snap_v].each do |key|
173
175
  if hints[key]
174
176
  if hints[key].length > MAX_STEM_SNAP
175
177
  errors << "#{key} exceeds maximum (#{MAX_STEM_SNAP}): #{hints[key].length}"
@@ -191,10 +193,8 @@ module Fontisan
191
193
  end
192
194
 
193
195
  # Validate language_group
194
- if hints[:language_group]
195
- unless [0, 1].include?(hints[:language_group])
196
- errors << "language_group must be 0 (Latin) or 1 (CJK): #{hints[:language_group]}"
197
- end
196
+ if hints[:language_group] && ![0, 1].include?(hints[:language_group])
197
+ errors << "language_group must be 0 (Latin) or 1 (CJK): #{hints[:language_group]}"
198
198
  end
199
199
 
200
200
  {
@@ -212,7 +212,10 @@ module Fontisan
212
212
  # @param instructions [String] Binary instruction bytes
213
213
  # @return [Hash] Result with :neutral, :stack_depth, :errors keys
214
214
  def validate_stack_neutrality(instructions)
215
- return { neutral: true, stack_depth: 0, errors: [] } if instructions.nil? || instructions.empty?
215
+ if instructions.nil? || instructions.empty?
216
+ return { neutral: true, stack_depth: 0,
217
+ errors: [] }
218
+ end
216
219
 
217
220
  errors = []
218
221
  stack_depth = 0
@@ -257,7 +260,7 @@ module Fontisan
257
260
  end
258
261
 
259
262
  {
260
- neutral: stack_depth == 0,
263
+ neutral: stack_depth.zero?,
261
264
  stack_depth: stack_depth,
262
265
  errors: errors,
263
266
  }
@@ -29,7 +29,7 @@ module Fontisan
29
29
  RCVT = 0x45 # Read CVT
30
30
  WCVTP = 0x44 # Write CVT (in Pixels)
31
31
  WCVTF = 0x70 # Write CVT (in FUnits)
32
- MDAP = [0x2E, 0x2F].freeze # Move Direct Absolute Point
32
+ MDAP = [0x2E, 0x2F].freeze # Move Direct Absolute Point
33
33
  SCVTCI = 0x1D # Set Control Value Table Cut In
34
34
  SSWCI = 0x1E # Set Single Width Cut In
35
35
  SSW = 0x1F # Set Single Width
@@ -106,7 +106,7 @@ module Fontisan
106
106
  # Pattern: value cvt_index WCVTP (stack top to bottom)
107
107
  if stack.length >= 2
108
108
  value = stack.pop
109
- cvt_index = stack.pop
109
+ stack.pop
110
110
  # Track CVT modifications (useful for understanding setup)
111
111
  end
112
112
 
@@ -168,7 +168,7 @@ module Fontisan
168
168
 
169
169
  {
170
170
  fpgm_size: size,
171
- has_functions: size > 0,
171
+ has_functions: size.positive?,
172
172
  complexity: complexity,
173
173
  }
174
174
  rescue StandardError
@@ -208,13 +208,19 @@ module Fontisan
208
208
  xheight_min = (450 * scale_factor).to_i
209
209
  xheight_max = (600 * scale_factor).to_i
210
210
  capheight_min = (650 * scale_factor).to_i
211
- capheight_max = (1500 * scale_factor).to_i # Wider range for large UPM
211
+ capheight_max = (1500 * scale_factor).to_i # Wider range for large UPM
212
212
 
213
213
  # Group CVT values by typical alignment zones
214
- descender_values = cvt.select { |v| v < descender_max && v > descender_min }
215
- baseline_values = cvt.select { |v| v >= -baseline_range && v <= baseline_range }
216
- xheight_values = cvt.select { |v| v >= xheight_min && v <= xheight_max }
217
- capheight_values = cvt.select { |v| v >= capheight_min && v <= capheight_max }
214
+ descender_values = cvt.select do |v|
215
+ v < descender_max && v > descender_min
216
+ end
217
+ baseline_values = cvt.select do |v|
218
+ v >= -baseline_range && v <= baseline_range
219
+ end
220
+ cvt.select { |v| v >= xheight_min && v <= xheight_max }
221
+ capheight_values = cvt.select do |v|
222
+ v >= capheight_min && v <= capheight_max
223
+ end
218
224
 
219
225
  # Build blue_values (baseline and top zones)
220
226
  blue_values = []
@@ -254,6 +260,7 @@ module Fontisan
254
260
  def estimate_complexity(bytes)
255
261
  return :simple if bytes.length < 50
256
262
  return :moderate if bytes.length < 200
263
+
257
264
  :complex
258
265
  end
259
266
  end
@@ -65,7 +65,7 @@ module Fontisan
65
65
  {
66
66
  fpgm: generate_fpgm(ps_params),
67
67
  prep: generate_prep(ps_params),
68
- cvt: generate_cvt(ps_params)
68
+ cvt: generate_cvt(ps_params),
69
69
  }
70
70
  end
71
71