fontisan 0.2.1 → 0.2.2

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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +57 -385
  3. data/README.adoc +1483 -1435
  4. data/Rakefile +3 -2
  5. data/benchmark/variation_quick_bench.rb +4 -4
  6. data/docs/FONT_HINTING.adoc +562 -0
  7. data/docs/VARIABLE_FONT_OPERATIONS.adoc +599 -0
  8. data/lib/fontisan/cli.rb +10 -3
  9. data/lib/fontisan/collection/builder.rb +2 -1
  10. data/lib/fontisan/collection/offset_calculator.rb +2 -0
  11. data/lib/fontisan/commands/base_command.rb +5 -2
  12. data/lib/fontisan/commands/convert_command.rb +6 -2
  13. data/lib/fontisan/commands/info_command.rb +111 -5
  14. data/lib/fontisan/commands/instance_command.rb +8 -7
  15. data/lib/fontisan/commands/validate_command.rb +4 -1
  16. data/lib/fontisan/constants.rb +24 -24
  17. data/lib/fontisan/converters/format_converter.rb +8 -4
  18. data/lib/fontisan/converters/outline_converter.rb +21 -16
  19. data/lib/fontisan/converters/woff_writer.rb +8 -3
  20. data/lib/fontisan/font_loader.rb +11 -4
  21. data/lib/fontisan/font_writer.rb +2 -0
  22. data/lib/fontisan/formatters/text_formatter.rb +45 -1
  23. data/lib/fontisan/hints/hint_converter.rb +43 -47
  24. data/lib/fontisan/hints/hint_validator.rb +284 -0
  25. data/lib/fontisan/hints/postscript_hint_applier.rb +1 -3
  26. data/lib/fontisan/hints/postscript_hint_extractor.rb +78 -43
  27. data/lib/fontisan/hints/truetype_hint_extractor.rb +22 -26
  28. data/lib/fontisan/hints/truetype_instruction_analyzer.rb +261 -0
  29. data/lib/fontisan/hints/truetype_instruction_generator.rb +266 -0
  30. data/lib/fontisan/loading_modes.rb +4 -4
  31. data/lib/fontisan/models/collection_brief_info.rb +31 -0
  32. data/lib/fontisan/models/font_export.rb +2 -2
  33. data/lib/fontisan/models/font_info.rb +3 -30
  34. data/lib/fontisan/models/hint.rb +22 -23
  35. data/lib/fontisan/models/outline.rb +4 -1
  36. data/lib/fontisan/models/validation_report.rb +1 -1
  37. data/lib/fontisan/open_type_font.rb +3 -1
  38. data/lib/fontisan/optimizers/pattern_analyzer.rb +2 -1
  39. data/lib/fontisan/optimizers/subroutine_generator.rb +1 -1
  40. data/lib/fontisan/pipeline/output_writer.rb +8 -3
  41. data/lib/fontisan/pipeline/transformation_pipeline.rb +8 -3
  42. data/lib/fontisan/subset/table_subsetter.rb +5 -5
  43. data/lib/fontisan/tables/cff/charstring.rb +38 -12
  44. data/lib/fontisan/tables/cff/charstring_parser.rb +23 -11
  45. data/lib/fontisan/tables/cff/charstring_rebuilder.rb +14 -14
  46. data/lib/fontisan/tables/cff/dict_builder.rb +4 -1
  47. data/lib/fontisan/tables/cff/hint_operation_injector.rb +6 -4
  48. data/lib/fontisan/tables/cff/offset_recalculator.rb +1 -1
  49. data/lib/fontisan/tables/cff/private_dict_writer.rb +10 -4
  50. data/lib/fontisan/tables/cff/table_builder.rb +1 -1
  51. data/lib/fontisan/tables/cff2/charstring_parser.rb +14 -8
  52. data/lib/fontisan/tables/cff2/private_dict_blend_handler.rb +7 -6
  53. data/lib/fontisan/tables/cff2/region_matcher.rb +2 -2
  54. data/lib/fontisan/tables/cff2/table_builder.rb +26 -20
  55. data/lib/fontisan/tables/cff2/table_reader.rb +35 -33
  56. data/lib/fontisan/tables/cff2/variation_data_extractor.rb +2 -2
  57. data/lib/fontisan/tables/cff2.rb +1 -1
  58. data/lib/fontisan/tables/glyf/compound_glyph_resolver.rb +2 -1
  59. data/lib/fontisan/tables/glyf/curve_converter.rb +10 -4
  60. data/lib/fontisan/tables/glyf/glyph_builder.rb +27 -10
  61. data/lib/fontisan/tables/name.rb +4 -4
  62. data/lib/fontisan/true_type_font.rb +3 -1
  63. data/lib/fontisan/validation/checksum_validator.rb +2 -2
  64. data/lib/fontisan/variation/cache.rb +3 -1
  65. data/lib/fontisan/variation/converter.rb +2 -1
  66. data/lib/fontisan/variation/delta_applier.rb +2 -1
  67. data/lib/fontisan/variation/inspector.rb +2 -1
  68. data/lib/fontisan/variation/instance_generator.rb +2 -1
  69. data/lib/fontisan/variation/optimizer.rb +6 -3
  70. data/lib/fontisan/variation/subsetter.rb +32 -10
  71. data/lib/fontisan/variation/variation_preserver.rb +4 -1
  72. data/lib/fontisan/version.rb +1 -1
  73. data/lib/fontisan/woff2/glyf_transformer.rb +57 -30
  74. data/lib/fontisan/woff2_font.rb +31 -15
  75. data/lib/fontisan.rb +42 -2
  76. data/scripts/measure_optimization.rb +15 -7
  77. metadata +8 -2
@@ -41,12 +41,70 @@ module Fontisan
41
41
 
42
42
  # Get collection information
43
43
  #
44
- # @return [Models::CollectionInfo] Collection metadata
44
+ # @return [Models::CollectionInfo, Models::CollectionBriefInfo] Collection metadata
45
45
  def collection_info
46
46
  collection = FontLoader.load_collection(@font_path)
47
47
 
48
48
  File.open(@font_path, "rb") do |io|
49
- collection.collection_info(io, @font_path)
49
+ if @options[:brief]
50
+ # Brief mode: load each font and populate brief info
51
+ brief_info = Models::CollectionBriefInfo.new
52
+ brief_info.collection_path = @font_path
53
+ brief_info.num_fonts = collection.num_fonts
54
+ brief_info.fonts = []
55
+
56
+ collection.num_fonts.times do |index|
57
+ # Load individual font from collection
58
+ font = FontLoader.load(@font_path, font_index: index, mode: LoadingModes::METADATA)
59
+
60
+ # Populate brief info for this font
61
+ info = Models::FontInfo.new
62
+
63
+ # Font format and variable status
64
+ info.font_format = case font
65
+ when TrueTypeFont
66
+ "truetype"
67
+ when OpenTypeFont
68
+ "cff"
69
+ else
70
+ "unknown"
71
+ end
72
+ info.is_variable = font.has_table?(Constants::FVAR_TAG)
73
+
74
+ # Collection offset (only populated for fonts in collections)
75
+ info.collection_offset = collection.font_offsets[index]
76
+
77
+ # Essential names
78
+ if font.has_table?(Constants::NAME_TAG)
79
+ name_table = font.table(Constants::NAME_TAG)
80
+ info.family_name = name_table.english_name(Tables::Name::FAMILY)
81
+ info.subfamily_name = name_table.english_name(Tables::Name::SUBFAMILY)
82
+ info.full_name = name_table.english_name(Tables::Name::FULL_NAME)
83
+ info.postscript_name = name_table.english_name(Tables::Name::POSTSCRIPT_NAME)
84
+ info.version = name_table.english_name(Tables::Name::VERSION)
85
+ end
86
+
87
+ # Essential metrics
88
+ if font.has_table?(Constants::HEAD_TAG)
89
+ head = font.table(Constants::HEAD_TAG)
90
+ info.font_revision = head.font_revision
91
+ info.units_per_em = head.units_per_em
92
+ end
93
+
94
+ # Vendor ID
95
+ if font.has_table?(Constants::OS2_TAG)
96
+ os2_table = font.table(Constants::OS2_TAG)
97
+ info.vendor_id = os2_table.vendor_id
98
+ end
99
+
100
+ brief_info.fonts << info
101
+ end
102
+
103
+ brief_info
104
+ else
105
+ # Full mode: show detailed sharing statistics
106
+ collection.collection_info(io, @font_path)
107
+ end
50
108
  end
51
109
  end
52
110
 
@@ -56,9 +114,14 @@ module Fontisan
56
114
  def font_info
57
115
  info = Models::FontInfo.new
58
116
  populate_font_format(info)
59
- populate_from_name_table(info) if font.has_table?(Constants::NAME_TAG)
60
- populate_from_os2_table(info) if font.has_table?(Constants::OS2_TAG)
61
- populate_from_head_table(info) if font.has_table?(Constants::HEAD_TAG)
117
+
118
+ # In brief mode, only populate essential fields for fast identification
119
+ if @options[:brief]
120
+ populate_brief_fields(info)
121
+ else
122
+ populate_full_fields(info)
123
+ end
124
+
62
125
  info
63
126
  end
64
127
 
@@ -80,6 +143,49 @@ module Fontisan
80
143
  info.is_variable = font.has_table?(Constants::FVAR_TAG)
81
144
  end
82
145
 
146
+ # Populate essential fields for brief mode (metadata tables only).
147
+ #
148
+ # Brief mode provides fast font identification by loading only 13 essential
149
+ # attributes from metadata tables (name, head, OS/2). This is 5x faster than
150
+ # full mode and optimized for font indexing systems.
151
+ #
152
+ # @param info [Models::FontInfo] FontInfo instance to populate
153
+ def populate_brief_fields(info)
154
+ # Essential names from name table
155
+ if font.has_table?(Constants::NAME_TAG)
156
+ name_table = font.table(Constants::NAME_TAG)
157
+ info.family_name = name_table.english_name(Tables::Name::FAMILY)
158
+ info.subfamily_name = name_table.english_name(Tables::Name::SUBFAMILY)
159
+ info.full_name = name_table.english_name(Tables::Name::FULL_NAME)
160
+ info.postscript_name = name_table.english_name(Tables::Name::POSTSCRIPT_NAME)
161
+ info.version = name_table.english_name(Tables::Name::VERSION)
162
+ end
163
+
164
+ # Essential metrics from head table
165
+ if font.has_table?(Constants::HEAD_TAG)
166
+ head = font.table(Constants::HEAD_TAG)
167
+ info.font_revision = head.font_revision
168
+ info.units_per_em = head.units_per_em
169
+ end
170
+
171
+ # Vendor ID from OS/2 table
172
+ if font.has_table?(Constants::OS2_TAG)
173
+ os2_table = font.table(Constants::OS2_TAG)
174
+ info.vendor_id = os2_table.vendor_id
175
+ end
176
+ end
177
+
178
+ # Populate all fields for full mode.
179
+ #
180
+ # Full mode extracts comprehensive metadata from all available tables.
181
+ #
182
+ # @param info [Models::FontInfo] FontInfo instance to populate
183
+ def populate_full_fields(info)
184
+ populate_from_name_table(info) if font.has_table?(Constants::NAME_TAG)
185
+ populate_from_os2_table(info) if font.has_table?(Constants::OS2_TAG)
186
+ populate_from_head_table(info) if font.has_table?(Constants::HEAD_TAG)
187
+ end
188
+
83
189
  # Populate FontInfo from the name table.
84
190
  #
85
191
  # @param info [Models::FontInfo] FontInfo instance to populate
@@ -67,11 +67,11 @@ module Fontisan
67
67
 
68
68
  puts "Static font instance written to: #{output_path}"
69
69
  rescue VariationError => e
70
- $stderr.puts "Variation Error: #{e.detailed_message}"
70
+ warn "Variation Error: #{e.detailed_message}"
71
71
  exit 1
72
72
  rescue StandardError => e
73
- $stderr.puts "Error: #{e.message}"
74
- $stderr.puts e.backtrace.first(5).join("\n") if options[:verbose]
73
+ warn "Error: #{e.message}"
74
+ warn e.backtrace.first(5).join("\n") if options[:verbose]
75
75
  exit 1
76
76
  end
77
77
 
@@ -87,9 +87,9 @@ module Fontisan
87
87
  errors = validator.validate
88
88
 
89
89
  if errors.any?
90
- $stderr.puts "Validation errors found:"
90
+ warn "Validation errors found:"
91
91
  errors.each do |error|
92
- $stderr.puts " - #{error}"
92
+ warn " - #{error}"
93
93
  end
94
94
  exit 1
95
95
  end
@@ -102,7 +102,7 @@ module Fontisan
102
102
  # @param font [Object] Font object
103
103
  # @param input_path [String] Input file path
104
104
  # @param options [Hash] Command options
105
- def preview_instance(font, input_path, options)
105
+ def preview_instance(_font, input_path, options)
106
106
  coords = extract_coordinates(options)
107
107
 
108
108
  if coords.empty?
@@ -117,7 +117,8 @@ module Fontisan
117
117
  puts " #{axis}: #{value}"
118
118
  end
119
119
  puts
120
- puts "Output would be written to: #{determine_output_path(input_path, options)}"
120
+ puts "Output would be written to: #{determine_output_path(input_path,
121
+ options)}"
121
122
  puts "Output format: #{options[:to] || 'same as input'}"
122
123
  puts
123
124
  puts "Use without --dry-run to actually generate the instance."
@@ -84,7 +84,10 @@ quiet: false)
84
84
  errors.each do |error|
85
85
  puts " ERROR: #{error}" if @verbose && !@quiet
86
86
  # Add to report if report supports adding errors
87
- report.errors << { message: error, category: "variable_font" } if report.respond_to?(:errors)
87
+ if report.respond_to?(:errors)
88
+ report.errors << { message: error,
89
+ category: "variable_font" }
90
+ end
88
91
  end
89
92
  elsif @verbose && !@quiet
90
93
  puts "\n✓ Variable font structure valid"
@@ -117,30 +117,30 @@ module Fontisan
117
117
  # These strings are frozen and reused to reduce memory allocations
118
118
  # when parsing fonts with common subfamily names.
119
119
  STRING_POOL = {
120
- "Regular" => "Regular".freeze,
121
- "Bold" => "Bold".freeze,
122
- "Italic" => "Italic".freeze,
123
- "Bold Italic" => "Bold Italic".freeze,
124
- "BoldItalic" => "BoldItalic".freeze,
125
- "Light" => "Light".freeze,
126
- "Medium" => "Medium".freeze,
127
- "Semibold" => "Semibold".freeze,
128
- "SemiBold" => "SemiBold".freeze,
129
- "Black" => "Black".freeze,
130
- "Thin" => "Thin".freeze,
131
- "ExtraLight" => "ExtraLight".freeze,
132
- "Extra Light" => "Extra Light".freeze,
133
- "ExtraBold" => "ExtraBold".freeze,
134
- "Extra Bold" => "Extra Bold".freeze,
135
- "Heavy" => "Heavy".freeze,
136
- "Book" => "Book".freeze,
137
- "Roman" => "Roman".freeze,
138
- "Normal" => "Normal".freeze,
139
- "Oblique" => "Oblique".freeze,
140
- "Light Italic" => "Light Italic".freeze,
141
- "Medium Italic" => "Medium Italic".freeze,
142
- "Semibold Italic" => "Semibold Italic".freeze,
143
- "Bold Oblique" => "Bold Oblique".freeze,
120
+ "Regular" => "Regular",
121
+ "Bold" => "Bold",
122
+ "Italic" => "Italic",
123
+ "Bold Italic" => "Bold Italic",
124
+ "BoldItalic" => "BoldItalic",
125
+ "Light" => "Light",
126
+ "Medium" => "Medium",
127
+ "Semibold" => "Semibold",
128
+ "SemiBold" => "SemiBold",
129
+ "Black" => "Black",
130
+ "Thin" => "Thin",
131
+ "ExtraLight" => "ExtraLight",
132
+ "Extra Light" => "Extra Light",
133
+ "ExtraBold" => "ExtraBold",
134
+ "Extra Bold" => "Extra Bold",
135
+ "Heavy" => "Heavy",
136
+ "Book" => "Book",
137
+ "Roman" => "Roman",
138
+ "Normal" => "Normal",
139
+ "Oblique" => "Oblique",
140
+ "Light Italic" => "Light Italic",
141
+ "Medium Italic" => "Medium Italic",
142
+ "Semibold Italic" => "Semibold Italic",
143
+ "Bold Oblique" => "Bold Oblique",
144
144
  }.freeze
145
145
 
146
146
  # Intern a string using the string pool
@@ -103,7 +103,8 @@ module Fontisan
103
103
  end
104
104
 
105
105
  strategy = select_strategy(source_format, target_format)
106
- tables = strategy.convert(font, options.merge(target_format: target_format))
106
+ tables = strategy.convert(font,
107
+ options.merge(target_format: target_format))
107
108
 
108
109
  # Preserve variation data if requested and font is variable
109
110
  if options.fetch(:preserve_variation, true) && variable_font?(font)
@@ -204,7 +205,8 @@ module Fontisan
204
205
  # @param target_format [Symbol] Target format
205
206
  # @param options [Hash] Preservation options
206
207
  # @return [Hash<String, String>] Tables with variation preserved
207
- def preserve_variation_data(font, tables, source_format, target_format, options)
208
+ def preserve_variation_data(font, tables, source_format, target_format,
209
+ options)
208
210
  # Case 1: Compatible formats (same outline format) - just copy tables
209
211
  if compatible_variation_formats?(source_format, target_format)
210
212
  require_relative "../variation/variation_preserver"
@@ -212,7 +214,8 @@ module Fontisan
212
214
 
213
215
  # Case 2: Different outline formats - convert variation data
214
216
  elsif convertible_variation_formats?(source_format, target_format)
215
- convert_variation_data(font, tables, source_format, target_format, options)
217
+ convert_variation_data(font, tables, source_format, target_format,
218
+ options)
216
219
 
217
220
  # Case 3: Unsupported conversion
218
221
  else
@@ -268,7 +271,8 @@ module Fontisan
268
271
  # @param target_format [Symbol] Target format
269
272
  # @param options [Hash] Conversion options
270
273
  # @return [Hash<String, String>] Tables with converted variation
271
- def convert_variation_data(font, tables, source_format, target_format, _options)
274
+ def convert_variation_data(font, tables, source_format, target_format,
275
+ _options)
272
276
  require_relative "../variation/variation_preserver"
273
277
  require_relative "../variation/converter"
274
278
 
@@ -131,7 +131,7 @@ module Fontisan
131
131
  # @param font [TrueTypeFont] Source font
132
132
  # @param options [Hash] Conversion options (currently unused)
133
133
  # @return [Hash<String, String>] Target tables
134
- def convert_ttf_to_otf(font, options = {})
134
+ def convert_ttf_to_otf(font, _options = {})
135
135
  # Extract all glyphs from glyf table
136
136
  outlines = extract_ttf_outlines(font)
137
137
 
@@ -184,7 +184,8 @@ module Fontisan
184
184
  hints_per_glyph = @preserve_hints ? extract_cff_hints(font) : {}
185
185
 
186
186
  # Build glyf and loca tables
187
- glyf_data, loca_data, loca_format = build_glyf_loca_tables(outlines, hints_per_glyph)
187
+ glyf_data, loca_data, loca_format = build_glyf_loca_tables(outlines,
188
+ hints_per_glyph)
188
189
 
189
190
  # Copy all tables except CFF
190
191
  tables = copy_tables(font, ["CFF ", "CFF2"])
@@ -357,7 +358,7 @@ module Fontisan
357
358
  # @param outlines [Array<Outline>] Glyph outlines
358
359
  # @param font [TrueTypeFont] Source font (for metadata)
359
360
  # @return [String] CFF table binary data
360
- def build_cff_table(outlines, font, hints_per_glyph)
361
+ def build_cff_table(outlines, font, _hints_per_glyph)
361
362
  # Build CharStrings INDEX from outlines
362
363
  begin
363
364
  charstrings = outlines.map do |outline|
@@ -478,7 +479,8 @@ module Fontisan
478
479
  top_dict_data = Tables::Cff::DictBuilder.build(top_dict_hash)
479
480
  top_dict_index_data = Tables::Cff::IndexBuilder.build([top_dict_data])
480
481
  rescue StandardError => e
481
- raise Fontisan::Error, "Failed to calculate CFF table offsets: #{e.message}"
482
+ raise Fontisan::Error,
483
+ "Failed to calculate CFF table offsets: #{e.message}"
482
484
  end
483
485
 
484
486
  # Build CFF Header
@@ -512,7 +514,7 @@ module Fontisan
512
514
  #
513
515
  # @param outlines [Array<Outline>] Glyph outlines
514
516
  # @return [Array<String, String, Integer>] [glyf_data, loca_data, loca_format]
515
- def build_glyf_loca_tables(outlines, hints_per_glyph)
517
+ def build_glyf_loca_tables(outlines, _hints_per_glyph)
516
518
  glyf_data = "".b
517
519
  offsets = []
518
520
 
@@ -687,7 +689,7 @@ module Fontisan
687
689
  # Analyze patterns
688
690
  analyzer = Optimizers::PatternAnalyzer.new(
689
691
  min_length: 10,
690
- stack_aware: true
692
+ stack_aware: true,
691
693
  )
692
694
  patterns = analyzer.analyze(charstrings_hash)
693
695
 
@@ -695,7 +697,8 @@ module Fontisan
695
697
  return [charstrings, []] if patterns.empty?
696
698
 
697
699
  # Optimize selection
698
- optimizer = Optimizers::SubroutineOptimizer.new(patterns, max_subrs: 65_535)
700
+ optimizer = Optimizers::SubroutineOptimizer.new(patterns,
701
+ max_subrs: 65_535)
699
702
  selected_patterns = optimizer.optimize_selection
700
703
 
701
704
  # Optimize ordering
@@ -705,7 +708,8 @@ module Fontisan
705
708
  return [charstrings, []] if selected_patterns.empty?
706
709
 
707
710
  # Build subroutines
708
- builder = Optimizers::SubroutineBuilder.new(selected_patterns, type: :local)
711
+ builder = Optimizers::SubroutineBuilder.new(selected_patterns,
712
+ type: :local)
709
713
  local_subrs = builder.build
710
714
 
711
715
  # Build subroutine map
@@ -718,7 +722,9 @@ module Fontisan
718
722
  rewriter = Optimizers::CharstringRewriter.new(subroutine_map, builder)
719
723
  optimized_charstrings = charstrings.map.with_index do |charstring, glyph_id|
720
724
  # Find patterns for this glyph
721
- glyph_patterns = selected_patterns.select { |p| p.glyphs.include?(glyph_id) }
725
+ glyph_patterns = selected_patterns.select do |p|
726
+ p.glyphs.include?(glyph_id)
727
+ end
722
728
 
723
729
  if glyph_patterns.empty?
724
730
  charstring
@@ -743,13 +749,16 @@ module Fontisan
743
749
  def generate_static_instance(font, source_format, target_format)
744
750
  # Generate instance at specified coordinates
745
751
  fvar = font.table("fvar")
746
- axes = fvar ? fvar.axes : []
752
+ fvar ? fvar.axes : []
747
753
 
748
- generator = Variation::InstanceGenerator.new(font, @instance_coordinates)
754
+ generator = Variation::InstanceGenerator.new(font,
755
+ @instance_coordinates)
749
756
  instance_tables = generator.generate
750
757
 
751
758
  # If target format differs from source, convert outlines
752
- if source_format != target_format
759
+ if source_format == target_format
760
+ instance_tables
761
+ else
753
762
  # Create temporary font with instance tables
754
763
  temp_font = font.class.new
755
764
  temp_font.instance_variable_set(:@table_data, instance_tables)
@@ -763,8 +772,6 @@ module Fontisan
763
772
  else
764
773
  instance_tables
765
774
  end
766
- else
767
- instance_tables
768
775
  end
769
776
  end
770
777
 
@@ -801,8 +808,6 @@ module Fontisan
801
808
  when %i[otf ttf], %i[cff2 ttf]
802
809
  # blend → gvar
803
810
  converter.blend_to_gvar(glyph_id)
804
- else
805
- nil
806
811
  end
807
812
 
808
813
  variation_data[glyph_id] = data if data
@@ -128,7 +128,8 @@ module Fontisan
128
128
  # @raise [ArgumentError] if compression level is invalid
129
129
  def validate_compression_level!
130
130
  unless @compression_level.between?(0, 9)
131
- raise ArgumentError, "Compression level must be between 0 and 9, got #{@compression_level}"
131
+ raise ArgumentError,
132
+ "Compression level must be between 0 and 9, got #{@compression_level}"
132
133
  end
133
134
  end
134
135
 
@@ -235,7 +236,9 @@ module Fontisan
235
236
  metadata_size = compressed_metadata ? compressed_metadata[:compressed_length] : 0
236
237
 
237
238
  # Calculate total compressed data size
238
- total_compressed_size = compressed_tables.values.sum { |table| table[:compressed_length] }
239
+ total_compressed_size = compressed_tables.values.sum do |table|
240
+ table[:compressed_length]
241
+ end
239
242
 
240
243
  # Calculate private data offset (after table data + metadata)
241
244
  private_offset = data_offset + total_compressed_size + metadata_size
@@ -245,7 +248,9 @@ module Fontisan
245
248
  total_size = private_offset + private_size
246
249
 
247
250
  # Calculate total SFNT size (uncompressed)
248
- total_sfnt_size = compressed_tables.values.sum { |table| table[:original_length] } +
251
+ total_sfnt_size = compressed_tables.values.sum do |table|
252
+ table[:original_length]
253
+ end +
249
254
  header_size + table_dir_size
250
255
 
251
256
  # Write WOFF header
@@ -46,7 +46,11 @@ module Fontisan
46
46
 
47
47
  # Resolve mode and lazy parameters with environment variables
48
48
  resolved_mode = mode || env_mode || LoadingModes::FULL
49
- resolved_lazy = lazy.nil? ? (env_lazy.nil? ? false : env_lazy) : lazy
49
+ resolved_lazy = if lazy.nil?
50
+ env_lazy.nil? ? false : env_lazy
51
+ else
52
+ lazy
53
+ end
50
54
 
51
55
  # Validate mode
52
56
  LoadingModes.validate_mode!(resolved_mode)
@@ -57,7 +61,8 @@ module Fontisan
57
61
 
58
62
  case signature
59
63
  when Constants::TTC_TAG
60
- load_from_collection(io, path, font_index, mode: resolved_mode, lazy: resolved_lazy)
64
+ load_from_collection(io, path, font_index, mode: resolved_mode,
65
+ lazy: resolved_lazy)
61
66
  when pack_uint32(Constants::SFNT_VERSION_TRUETYPE)
62
67
  TrueTypeFont.from_file(path, mode: resolved_mode, lazy: resolved_lazy)
63
68
  when "OTTO"
@@ -169,7 +174,8 @@ module Fontisan
169
174
  # @param lazy [Boolean] If true, load tables on demand
170
175
  # @return [TrueTypeFont, OpenTypeFont] The loaded font object
171
176
  # @raise [InvalidFontError] if collection type cannot be determined
172
- def self.load_from_collection(io, path, font_index, mode: LoadingModes::FULL, lazy: true)
177
+ def self.load_from_collection(io, path, font_index,
178
+ mode: LoadingModes::FULL, lazy: true)
173
179
  # Read collection header to get font offsets
174
180
  io.seek(12) # Skip tag (4) + major_version (2) + minor_version (2) + num_fonts marker (4)
175
181
  num_fonts = io.read(4).unpack1("N")
@@ -211,6 +217,7 @@ module Fontisan
211
217
  [value].pack("N")
212
218
  end
213
219
 
214
- private_class_method :load_from_collection, :pack_uint32, :env_mode, :env_lazy
220
+ private_class_method :load_from_collection, :pack_uint32, :env_mode,
221
+ :env_lazy
215
222
  end
216
223
  end
@@ -113,6 +113,7 @@ module Fontisan
113
113
  # Write offset table (sfnt header)
114
114
  font_data << write_offset_table(table_entries.size)
115
115
 
116
+ # rubocop:disable Style/CombinableLoops
116
117
  # Write table directory (ALL entries first)
117
118
  table_entries.each do |entry|
118
119
  font_data << write_table_entry(entry)
@@ -123,6 +124,7 @@ module Fontisan
123
124
  font_data << entry[:data]
124
125
  font_data << entry[:padding]
125
126
  end
127
+ # rubocop:enable Style/CombinableLoops
126
128
 
127
129
  # Calculate and update head table checksum adjustment
128
130
  update_checksum_adjustment!(font_data, table_entries)
@@ -42,6 +42,8 @@ module Fontisan
42
42
  format_font_summary(model)
43
43
  when Models::CollectionInfo
44
44
  format_collection_info(model)
45
+ when Models::CollectionBriefInfo
46
+ format_collection_brief_info(model)
45
47
  else
46
48
  model.to_s
47
49
  end
@@ -312,7 +314,8 @@ module Fontisan
312
314
  font_format
313
315
  end
314
316
 
315
- type += " (Variable)" if is_variable
317
+ # Always show variable status explicitly
318
+ type += is_variable ? " (Variable)" : " (Not Variable)"
316
319
  type
317
320
  end
318
321
 
@@ -396,6 +399,47 @@ module Fontisan
396
399
  lines.join("\n")
397
400
  end
398
401
 
402
+ # Format CollectionBriefInfo as human-readable text.
403
+ #
404
+ # @param info [Models::CollectionBriefInfo] Collection brief information to format
405
+ # @return [String] Formatted text with collection header and each font's brief info
406
+ def format_collection_brief_info(info)
407
+ lines = []
408
+
409
+ # Collection header
410
+ lines << "Collection: #{info.collection_path}"
411
+ lines << "Fonts: #{info.num_fonts}"
412
+ lines << ""
413
+
414
+ # Each font's brief info
415
+ info.fonts.each_with_index do |font_info, index|
416
+ # Show font index with offset
417
+ if font_info.collection_offset
418
+ lines << "Font #{index} (offset: #{font_info.collection_offset}):"
419
+ else
420
+ lines << "Font #{index}:"
421
+ end
422
+ lines << ""
423
+
424
+ # Format each font using same structure as individual fonts
425
+ font_type_display = format_font_type_display(font_info.font_format, font_info.is_variable)
426
+ add_line(lines, "Font type", font_type_display)
427
+ add_line(lines, "Family", font_info.family_name)
428
+ add_line(lines, "Subfamily", font_info.subfamily_name)
429
+ add_line(lines, "Full name", font_info.full_name)
430
+ add_line(lines, "PostScript name", font_info.postscript_name)
431
+ add_line(lines, "Version", font_info.version)
432
+ add_line(lines, "Vendor ID", font_info.vendor_id)
433
+ add_line(lines, "Font revision", format_float(font_info.font_revision))
434
+ add_line(lines, "Units per em", font_info.units_per_em)
435
+
436
+ # Blank line between fonts (except after last)
437
+ lines << "" unless index == info.num_fonts - 1
438
+ end
439
+
440
+ lines.join("\n")
441
+ end
442
+
399
443
  # Format bytes for human-readable display.
400
444
  #
401
445
  # @param bytes [Integer] Number of bytes