fontisan 0.2.1 → 0.2.3

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 (81) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +58 -392
  3. data/README.adoc +1509 -1430
  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/base_collection.rb +296 -0
  9. data/lib/fontisan/cli.rb +10 -3
  10. data/lib/fontisan/collection/builder.rb +2 -1
  11. data/lib/fontisan/collection/offset_calculator.rb +2 -0
  12. data/lib/fontisan/commands/base_command.rb +5 -2
  13. data/lib/fontisan/commands/convert_command.rb +6 -2
  14. data/lib/fontisan/commands/info_command.rb +129 -5
  15. data/lib/fontisan/commands/instance_command.rb +8 -7
  16. data/lib/fontisan/commands/validate_command.rb +4 -1
  17. data/lib/fontisan/constants.rb +24 -24
  18. data/lib/fontisan/converters/format_converter.rb +8 -4
  19. data/lib/fontisan/converters/outline_converter.rb +21 -16
  20. data/lib/fontisan/converters/woff_writer.rb +8 -3
  21. data/lib/fontisan/font_loader.rb +120 -30
  22. data/lib/fontisan/font_writer.rb +2 -0
  23. data/lib/fontisan/formatters/text_formatter.rb +116 -19
  24. data/lib/fontisan/hints/hint_converter.rb +43 -47
  25. data/lib/fontisan/hints/hint_validator.rb +284 -0
  26. data/lib/fontisan/hints/postscript_hint_applier.rb +1 -3
  27. data/lib/fontisan/hints/postscript_hint_extractor.rb +78 -43
  28. data/lib/fontisan/hints/truetype_hint_extractor.rb +22 -26
  29. data/lib/fontisan/hints/truetype_instruction_analyzer.rb +261 -0
  30. data/lib/fontisan/hints/truetype_instruction_generator.rb +266 -0
  31. data/lib/fontisan/loading_modes.rb +4 -4
  32. data/lib/fontisan/models/collection_brief_info.rb +37 -0
  33. data/lib/fontisan/models/collection_info.rb +6 -1
  34. data/lib/fontisan/models/font_export.rb +2 -2
  35. data/lib/fontisan/models/font_info.rb +3 -30
  36. data/lib/fontisan/models/hint.rb +22 -23
  37. data/lib/fontisan/models/outline.rb +4 -1
  38. data/lib/fontisan/models/validation_report.rb +1 -1
  39. data/lib/fontisan/open_type_collection.rb +17 -220
  40. data/lib/fontisan/open_type_font.rb +3 -1
  41. data/lib/fontisan/optimizers/pattern_analyzer.rb +2 -1
  42. data/lib/fontisan/optimizers/subroutine_generator.rb +1 -1
  43. data/lib/fontisan/pipeline/output_writer.rb +8 -3
  44. data/lib/fontisan/pipeline/transformation_pipeline.rb +8 -3
  45. data/lib/fontisan/subset/table_subsetter.rb +5 -5
  46. data/lib/fontisan/tables/cff/charstring.rb +38 -12
  47. data/lib/fontisan/tables/cff/charstring_parser.rb +23 -11
  48. data/lib/fontisan/tables/cff/charstring_rebuilder.rb +14 -14
  49. data/lib/fontisan/tables/cff/dict_builder.rb +4 -1
  50. data/lib/fontisan/tables/cff/hint_operation_injector.rb +6 -4
  51. data/lib/fontisan/tables/cff/offset_recalculator.rb +1 -1
  52. data/lib/fontisan/tables/cff/private_dict_writer.rb +10 -4
  53. data/lib/fontisan/tables/cff/table_builder.rb +1 -1
  54. data/lib/fontisan/tables/cff2/charstring_parser.rb +14 -8
  55. data/lib/fontisan/tables/cff2/private_dict_blend_handler.rb +7 -6
  56. data/lib/fontisan/tables/cff2/region_matcher.rb +2 -2
  57. data/lib/fontisan/tables/cff2/table_builder.rb +26 -20
  58. data/lib/fontisan/tables/cff2/table_reader.rb +35 -33
  59. data/lib/fontisan/tables/cff2/variation_data_extractor.rb +2 -2
  60. data/lib/fontisan/tables/cff2.rb +1 -1
  61. data/lib/fontisan/tables/glyf/compound_glyph_resolver.rb +2 -1
  62. data/lib/fontisan/tables/glyf/curve_converter.rb +10 -4
  63. data/lib/fontisan/tables/glyf/glyph_builder.rb +27 -10
  64. data/lib/fontisan/tables/name.rb +4 -4
  65. data/lib/fontisan/true_type_collection.rb +29 -113
  66. data/lib/fontisan/true_type_font.rb +3 -1
  67. data/lib/fontisan/validation/checksum_validator.rb +2 -2
  68. data/lib/fontisan/variation/cache.rb +3 -1
  69. data/lib/fontisan/variation/converter.rb +2 -1
  70. data/lib/fontisan/variation/delta_applier.rb +2 -1
  71. data/lib/fontisan/variation/inspector.rb +2 -1
  72. data/lib/fontisan/variation/instance_generator.rb +2 -1
  73. data/lib/fontisan/variation/optimizer.rb +6 -3
  74. data/lib/fontisan/variation/subsetter.rb +32 -10
  75. data/lib/fontisan/variation/variation_preserver.rb +4 -1
  76. data/lib/fontisan/version.rb +1 -1
  77. data/lib/fontisan/woff2/glyf_transformer.rb +57 -30
  78. data/lib/fontisan/woff2_font.rb +31 -15
  79. data/lib/fontisan.rb +42 -2
  80. data/scripts/measure_optimization.rb +15 -7
  81. metadata +9 -2
@@ -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"
@@ -97,6 +102,37 @@ module Fontisan
97
102
  # without extracting individual fonts. Useful for inspecting collection
98
103
  # metadata and structure.
99
104
  #
105
+ # = Collection Format Understanding
106
+ #
107
+ # Both TTC (TrueType Collection) and OTC (OpenType Collection) files use
108
+ # the same "ttcf" signature. The distinction between TTC and OTC is NOT
109
+ # in the collection format itself, but in the fonts contained within:
110
+ #
111
+ # - TTC typically contains TrueType fonts (glyf outlines)
112
+ # - OTC typically contains OpenType fonts (CFF/CFF2 outlines)
113
+ # - Mixed collections are possible (both TTF and OTF in same collection)
114
+ #
115
+ # Each collection can contain multiple SFNT-format font files, with table
116
+ # deduplication to save space. Individual fonts within a collection are
117
+ # stored at different offsets within the file, each with their own table
118
+ # directory and data tables.
119
+ #
120
+ # = Detection Strategy
121
+ #
122
+ # This method scans ALL fonts in the collection to determine the collection
123
+ # type accurately:
124
+ #
125
+ # 1. Reads all font offsets from the collection header
126
+ # 2. Examines the sfnt_version of each font in the collection
127
+ # 3. Counts TrueType fonts (0x00010000 or 0x74727565 "true") vs OpenType fonts (0x4F54544F "OTTO")
128
+ # 4. If ANY font is OpenType (CFF), returns OpenTypeCollection
129
+ # 5. Only returns TrueTypeCollection if ALL fonts are TrueType
130
+ #
131
+ # This approach correctly handles:
132
+ # - Homogeneous collections (all TTF or all OTF)
133
+ # - Mixed collections (both TTF and OTF fonts) - uses OpenTypeCollection
134
+ # - Large collections with many fonts (like NotoSerifCJK.ttc with 35 fonts)
135
+ #
100
136
  # @param path [String] Path to the collection file
101
137
  # @return [TrueTypeCollection, OpenTypeCollection] The collection object
102
138
  # @raise [Errno::ENOENT] if file does not exist
@@ -116,23 +152,43 @@ module Fontisan
116
152
  "File is not a collection (TTC/OTC). Use FontLoader.load instead."
117
153
  end
118
154
 
119
- # Read first font offset to detect collection type
120
- io.seek(12) # Skip tag (4) + versions (4) + num_fonts (4)
121
- first_offset = io.read(4).unpack1("N")
155
+ # Read version and num_fonts
156
+ io.seek(8) # Skip tag (4) + version (4)
157
+ num_fonts = io.read(4).unpack1("N")
158
+
159
+ # Read all font offsets
160
+ font_offsets = num_fonts.times.map { io.read(4).unpack1("N") }
161
+
162
+ # Scan all fonts to determine collection type (not just first)
163
+ truetype_count = 0
164
+ opentype_count = 0
165
+
166
+ font_offsets.each do |offset|
167
+ io.rewind
168
+ io.seek(offset)
169
+ sfnt_version = io.read(4).unpack1("N")
170
+
171
+ case sfnt_version
172
+ when Constants::SFNT_VERSION_TRUETYPE, 0x74727565 # 0x74727565 = 'true'
173
+ truetype_count += 1
174
+ when Constants::SFNT_VERSION_OTTO
175
+ opentype_count += 1
176
+ else
177
+ raise InvalidFontError,
178
+ "Unknown font type in collection at offset #{offset} (sfnt version: 0x#{sfnt_version.to_s(16)})"
179
+ end
180
+ end
122
181
 
123
- # Peek at first font's sfnt_version
124
- io.seek(first_offset)
125
- sfnt_version = io.read(4).unpack1("N")
126
182
  io.rewind
127
183
 
128
- case sfnt_version
129
- when Constants::SFNT_VERSION_TRUETYPE
130
- TrueTypeCollection.from_file(path)
131
- when Constants::SFNT_VERSION_OTTO
184
+ # Determine collection type based on what fonts are inside
185
+ # If ANY font is OpenType, use OpenTypeCollection (more general format)
186
+ # Only use TrueTypeCollection if ALL fonts are TrueType
187
+ if opentype_count > 0
132
188
  OpenTypeCollection.from_file(path)
133
189
  else
134
- raise InvalidFontError,
135
- "Unknown font type in collection (sfnt version: 0x#{sfnt_version.to_s(16)})"
190
+ # All fonts are TrueType
191
+ TrueTypeCollection.from_file(path)
136
192
  end
137
193
  end
138
194
  end
@@ -162,6 +218,23 @@ module Fontisan
162
218
 
163
219
  # Load from a collection file (TTC or OTC)
164
220
  #
221
+ # This is the internal method that handles loading individual fonts from
222
+ # collection files. It reads the collection header to determine the type
223
+ # (TTC vs OTC) and extracts the requested font.
224
+ #
225
+ # = Collection Header Structure
226
+ #
227
+ # TTC/OTC files start with:
228
+ # - Bytes 0-3: "ttcf" tag (4 bytes)
229
+ # - Bytes 4-7: version (2 bytes major + 2 bytes minor)
230
+ # - Bytes 8-11: num_fonts (4 bytes, big-endian uint32)
231
+ # - Bytes 12+: font offset array (4 bytes per font, big-endian uint32)
232
+ #
233
+ # CRITICAL: The method seeks to position 8 (after tag and version) to read
234
+ # num_fonts, NOT position 12 which is where the offset array starts. This
235
+ # was a bug that caused "Unknown font type" errors when the first offset
236
+ # was misread as num_fonts.
237
+ #
165
238
  # @param io [IO] Open file handle
166
239
  # @param path [String] Path to the collection file
167
240
  # @param font_index [Integer] Index of font to extract
@@ -169,9 +242,10 @@ module Fontisan
169
242
  # @param lazy [Boolean] If true, load tables on demand
170
243
  # @return [TrueTypeFont, OpenTypeFont] The loaded font object
171
244
  # @raise [InvalidFontError] if collection type cannot be determined
172
- def self.load_from_collection(io, path, font_index, mode: LoadingModes::FULL, lazy: true)
245
+ def self.load_from_collection(io, path, font_index,
246
+ mode: LoadingModes::FULL, lazy: true)
173
247
  # Read collection header to get font offsets
174
- io.seek(12) # Skip tag (4) + major_version (2) + minor_version (2) + num_fonts marker (4)
248
+ io.seek(8) # Skip tag (4) + version (4)
175
249
  num_fonts = io.read(4).unpack1("N")
176
250
 
177
251
  if font_index >= num_fonts
@@ -179,26 +253,41 @@ module Fontisan
179
253
  "Font index #{font_index} out of range (collection has #{num_fonts} fonts)"
180
254
  end
181
255
 
182
- # Read first offset to detect collection type
183
- first_offset = io.read(4).unpack1("N")
256
+ # Read all font offsets
257
+ font_offsets = num_fonts.times.map { io.read(4).unpack1("N") }
258
+
259
+ # Scan all fonts to determine collection type (not just first)
260
+ truetype_count = 0
261
+ opentype_count = 0
262
+
263
+ font_offsets.each do |offset|
264
+ io.rewind
265
+ io.seek(offset)
266
+ sfnt_version = io.read(4).unpack1("N")
267
+
268
+ case sfnt_version
269
+ when Constants::SFNT_VERSION_TRUETYPE, 0x74727565 # 0x74727565 = 'true'
270
+ truetype_count += 1
271
+ when Constants::SFNT_VERSION_OTTO
272
+ opentype_count += 1
273
+ else
274
+ raise InvalidFontError,
275
+ "Unknown font type in collection at offset #{offset} (sfnt version: 0x#{sfnt_version.to_s(16)})"
276
+ end
277
+ end
184
278
 
185
- # Peek at first font's sfnt_version to determine TTC vs OTC
186
- io.seek(first_offset)
187
- sfnt_version = io.read(4).unpack1("N")
188
279
  io.rewind
189
280
 
190
- case sfnt_version
191
- when Constants::SFNT_VERSION_TRUETYPE
192
- # TrueType Collection
193
- ttc = TrueTypeCollection.from_file(path)
194
- File.open(path, "rb") { |f| ttc.font(font_index, f, mode: mode) }
195
- when Constants::SFNT_VERSION_OTTO
281
+ # If ANY font is OpenType, use OpenTypeCollection (more general format)
282
+ # Only use TrueTypeCollection if ALL fonts are TrueType
283
+ if opentype_count > 0
196
284
  # OpenType Collection
197
285
  otc = OpenTypeCollection.from_file(path)
198
286
  File.open(path, "rb") { |f| otc.font(font_index, f, mode: mode) }
199
287
  else
200
- raise InvalidFontError,
201
- "Unknown font type in collection (sfnt version: 0x#{sfnt_version.to_s(16)})"
288
+ # TrueType Collection (all fonts are TrueType)
289
+ ttc = TrueTypeCollection.from_file(path)
290
+ File.open(path, "rb") { |f| ttc.font(font_index, f, mode: mode) }
202
291
  end
203
292
  end
204
293
 
@@ -211,6 +300,7 @@ module Fontisan
211
300
  [value].pack("N")
212
301
  end
213
302
 
214
- private_class_method :load_from_collection, :pack_uint32, :env_mode, :env_lazy
303
+ private_class_method :load_from_collection, :pack_uint32, :env_mode,
304
+ :env_lazy
215
305
  end
216
306
  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
 
@@ -361,20 +364,36 @@ module Fontisan
361
364
  def format_collection_info(info)
362
365
  lines = []
363
366
 
364
- # Header section
365
- lines << "=== Collection Information ==="
366
- lines << ""
367
- lines << "File: #{info.collection_path}"
368
- lines << "Format: #{info.collection_format}"
367
+ # Header section with type and version (like brief mode)
368
+ lines << "Collection: #{info.collection_path}"
369
+
370
+ # Format collection type for display with OpenType version
371
+ if info.collection_format
372
+ collection_type_display = case info.collection_format
373
+ when "TTC"
374
+ "TrueType Collection (OpenType 1.4)"
375
+ when "OTC"
376
+ "OpenType Collection (OpenType 1.8)"
377
+ else
378
+ info.collection_format
379
+ end
380
+ lines << "Type: #{collection_type_display}"
381
+ end
382
+
383
+ lines << "Version: #{info.version_string}"
369
384
  lines << "Size: #{format_bytes(info.file_size_bytes)}"
385
+ lines << "Fonts: #{info.num_fonts}"
370
386
  lines << ""
371
387
 
372
- # Header details
373
- lines << "=== Header ==="
374
- lines << "Tag: #{info.ttc_tag}"
375
- lines << "Version: #{info.version_string} (#{info.version_hex})"
376
- lines << "Number of fonts: #{info.num_fonts}"
377
- lines << ""
388
+ # Table sharing statistics
389
+ if info.table_sharing
390
+ lines << "=== Table Sharing ==="
391
+ lines << "Shared tables: #{info.table_sharing.shared_tables}"
392
+ lines << "Unique tables: #{info.table_sharing.unique_tables}"
393
+ lines << "Sharing: #{format_float(info.table_sharing.sharing_percentage)}%"
394
+ lines << "Space saved: #{format_bytes(info.table_sharing.space_saved_bytes)}"
395
+ lines << ""
396
+ end
378
397
 
379
398
  # Font offsets
380
399
  lines << "=== Font Offsets ==="
@@ -384,13 +403,91 @@ module Fontisan
384
403
  end
385
404
  lines << ""
386
405
 
387
- # Table sharing statistics
388
- if info.table_sharing
389
- lines << "=== Table Sharing ==="
390
- lines << "Shared tables: #{info.table_sharing.shared_tables}"
391
- lines << "Unique tables: #{info.table_sharing.unique_tables}"
392
- lines << "Sharing: #{format_float(info.table_sharing.sharing_percentage)}%"
393
- lines << "Space saved: #{format_bytes(info.table_sharing.space_saved_bytes)}"
406
+ # Individual font information (like brief mode)
407
+ if info.fonts && !info.fonts.empty?
408
+ lines << "=== Fonts ==="
409
+ lines << ""
410
+
411
+ info.fonts.each_with_index do |font_info, index|
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
418
+ lines << ""
419
+
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)
422
+ add_line(lines, "Font type", font_type_display)
423
+ add_line(lines, "Family", font_info.family_name)
424
+ add_line(lines, "Subfamily", font_info.subfamily_name)
425
+ add_line(lines, "Full name", font_info.full_name)
426
+ add_line(lines, "PostScript name", font_info.postscript_name)
427
+ add_line(lines, "Version", font_info.version)
428
+ 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, "Units per em", font_info.units_per_em)
431
+
432
+ # Blank line between fonts (except after last)
433
+ lines << "" unless index == info.num_fonts - 1
434
+ end
435
+ end
436
+
437
+ lines.join("\n")
438
+ end
439
+
440
+ # Format CollectionBriefInfo as human-readable text.
441
+ #
442
+ # @param info [Models::CollectionBriefInfo] Collection brief information to format
443
+ # @return [String] Formatted text with collection header and each font's brief info
444
+ def format_collection_brief_info(info)
445
+ lines = []
446
+
447
+ # Collection header with type and version
448
+ lines << "Collection: #{info.collection_path}"
449
+
450
+ # Format collection type for display with OpenType version
451
+ if info.collection_type
452
+ collection_type_display = case info.collection_type
453
+ when "TTC"
454
+ "TrueType Collection (OpenType 1.4)"
455
+ when "OTC"
456
+ "OpenType Collection (OpenType 1.8)"
457
+ else
458
+ info.collection_type
459
+ end
460
+ lines << "Type: #{collection_type_display}"
461
+ end
462
+
463
+ lines << "Version: #{info.collection_version}" if info.collection_version
464
+ lines << "Fonts: #{info.num_fonts}"
465
+ lines << ""
466
+
467
+ # Each font's brief info
468
+ info.fonts.each_with_index do |font_info, index|
469
+ # 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
475
+ lines << ""
476
+
477
+ # 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)
479
+ add_line(lines, "Font type", font_type_display)
480
+ add_line(lines, "Family", font_info.family_name)
481
+ add_line(lines, "Subfamily", font_info.subfamily_name)
482
+ add_line(lines, "Full name", font_info.full_name)
483
+ add_line(lines, "PostScript name", font_info.postscript_name)
484
+ add_line(lines, "Version", font_info.version)
485
+ add_line(lines, "Vendor ID", font_info.vendor_id)
486
+ add_line(lines, "Font revision", format_float(font_info.font_revision))
487
+ add_line(lines, "Units per em", font_info.units_per_em)
488
+
489
+ # Blank line between fonts (except after last)
490
+ lines << "" unless index == info.num_fonts - 1
394
491
  end
395
492
 
396
493
  lines.join("\n")