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
@@ -0,0 +1,296 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bindata"
4
+ require_relative "constants"
5
+
6
+ module Fontisan
7
+ # Abstract base class for font collections (TTC/OTC)
8
+ #
9
+ # This class implements the shared logic for TrueTypeCollection and OpenTypeCollection
10
+ # using the Template Method pattern. Subclasses must implement the abstract methods
11
+ # to specify their font class and collection format.
12
+ #
13
+ # The BinData structure definition is shared between both collection types since
14
+ # both TTC and OTC files use the same "ttcf" tag and binary format. The only
15
+ # differences are:
16
+ # 1. The type of fonts contained (TrueType vs OpenType)
17
+ # 2. The format string used for display ("TTC" vs "OTC")
18
+ #
19
+ # @abstract Subclass and override {font_class} and {collection_format}
20
+ #
21
+ # @example Implementing a collection subclass
22
+ # class TrueTypeCollection < BaseCollection
23
+ # def self.font_class
24
+ # TrueTypeFont
25
+ # end
26
+ #
27
+ # def self.collection_format
28
+ # "TTC"
29
+ # end
30
+ # end
31
+ class BaseCollection < BinData::Record
32
+ endian :big
33
+
34
+ string :tag, length: 4, assert: "ttcf"
35
+ uint16 :major_version
36
+ uint16 :minor_version
37
+ uint32 :num_fonts
38
+ array :font_offsets, type: :uint32, initial_length: :num_fonts
39
+
40
+ # Abstract method: Get the font class for this collection type
41
+ #
42
+ # Subclasses must override this to return their specific font class
43
+ # (TrueTypeFont or OpenTypeFont).
44
+ #
45
+ # @return [Class] The font class (TrueTypeFont or OpenTypeFont)
46
+ # @raise [NotImplementedError] if not overridden by subclass
47
+ def self.font_class
48
+ raise NotImplementedError,
49
+ "#{name} must implement self.font_class"
50
+ end
51
+
52
+ # Abstract method: Get the collection format string
53
+ #
54
+ # Subclasses must override this to return "TTC" or "OTC".
55
+ #
56
+ # @return [String] Collection format ("TTC" or "OTC")
57
+ # @raise [NotImplementedError] if not overridden by subclass
58
+ def self.collection_format
59
+ raise NotImplementedError,
60
+ "#{name} must implement self.collection_format"
61
+ end
62
+
63
+ # Read collection from a file
64
+ #
65
+ # @param path [String] Path to the collection file
66
+ # @return [BaseCollection] A new instance
67
+ # @raise [ArgumentError] if path is nil or empty
68
+ # @raise [Errno::ENOENT] if file does not exist
69
+ # @raise [RuntimeError] if file format is invalid
70
+ def self.from_file(path)
71
+ if path.nil? || path.to_s.empty?
72
+ raise ArgumentError,
73
+ "path cannot be nil or empty"
74
+ end
75
+ raise Errno::ENOENT, "File not found: #{path}" unless File.exist?(path)
76
+
77
+ File.open(path, "rb") { |io| read(io) }
78
+ rescue BinData::ValidityError => e
79
+ raise "Invalid #{collection_format} file: #{e.message}"
80
+ rescue EOFError => e
81
+ raise "Invalid #{collection_format} file: unexpected end of file - #{e.message}"
82
+ end
83
+
84
+ # Extract fonts from the collection
85
+ #
86
+ # Reads each font from the collection file and returns them as font objects.
87
+ #
88
+ # @param io [IO] Open file handle to read fonts from
89
+ # @return [Array] Array of font objects (TrueTypeFont or OpenTypeFont)
90
+ def extract_fonts(io)
91
+ font_class = self.class.font_class
92
+
93
+ font_offsets.map do |offset|
94
+ font_class.from_collection(io, offset)
95
+ end
96
+ end
97
+
98
+ # Get a single font from the collection
99
+ #
100
+ # @param index [Integer] Index of the font (0-based)
101
+ # @param io [IO] Open file handle
102
+ # @param mode [Symbol] Loading mode (:metadata or :full, default: :full)
103
+ # @return [TrueTypeFont, OpenTypeFont, nil] Font object or nil if index out of range
104
+ def font(index, io, mode: LoadingModes::FULL)
105
+ return nil if index >= num_fonts
106
+
107
+ font_class = self.class.font_class
108
+ font_class.from_collection(io, font_offsets[index], mode: mode)
109
+ end
110
+
111
+ # Get font count
112
+ #
113
+ # @return [Integer] Number of fonts in collection
114
+ def font_count
115
+ num_fonts
116
+ end
117
+
118
+ # Validate format correctness
119
+ #
120
+ # @return [Boolean] true if the format is valid, false otherwise
121
+ def valid?
122
+ tag == Constants::TTC_TAG && num_fonts.positive? && font_offsets.length == num_fonts
123
+ rescue StandardError
124
+ false
125
+ end
126
+
127
+ # Get the collection version as a single integer
128
+ #
129
+ # @return [Integer] Version number (e.g., 0x00010000 for version 1.0)
130
+ def version
131
+ (major_version << 16) | minor_version
132
+ end
133
+
134
+ # Get the collection version as a string
135
+ #
136
+ # @return [String] Version string (e.g., "1.0")
137
+ def version_string
138
+ "#{major_version}.#{minor_version}"
139
+ end
140
+
141
+ # List all fonts in the collection with basic metadata
142
+ #
143
+ # Returns a CollectionListInfo model containing summaries of all fonts.
144
+ # This is the API method used by the `ls` command for collections.
145
+ #
146
+ # @param io [IO] Open file handle to read fonts from
147
+ # @return [CollectionListInfo] List of fonts with metadata
148
+ #
149
+ # @example List fonts in collection
150
+ # File.open("fonts.ttc", "rb") do |io|
151
+ # collection = TrueTypeCollection.read(io)
152
+ # list = collection.list_fonts(io)
153
+ # list.fonts.each { |f| puts "#{f.index}: #{f.family_name}" }
154
+ # end
155
+ def list_fonts(io)
156
+ require_relative "models/collection_list_info"
157
+ require_relative "models/collection_font_summary"
158
+ require_relative "tables/name"
159
+
160
+ font_class = self.class.font_class
161
+
162
+ fonts = font_offsets.map.with_index do |offset, index|
163
+ font = font_class.from_collection(io, offset)
164
+
165
+ # Extract basic font info
166
+ name_table = font.table("name")
167
+ post_table = font.table("post")
168
+
169
+ family_name = name_table&.english_name(Tables::Name::FAMILY) || "Unknown"
170
+ subfamily_name = name_table&.english_name(Tables::Name::SUBFAMILY) || "Regular"
171
+ postscript_name = name_table&.english_name(Tables::Name::POSTSCRIPT_NAME) || "Unknown"
172
+
173
+ # Determine font format
174
+ sfnt = font.header.sfnt_version
175
+ font_format = case sfnt
176
+ when 0x00010000, 0x74727565 # 0x74727565 = 'true'
177
+ "TrueType"
178
+ when 0x4F54544F # 'OTTO'
179
+ "OpenType"
180
+ else
181
+ "Unknown"
182
+ end
183
+
184
+ num_glyphs = post_table&.glyph_names&.length || 0
185
+ num_tables = font.table_names.length
186
+
187
+ Models::CollectionFontSummary.new(
188
+ index: index,
189
+ family_name: family_name,
190
+ subfamily_name: subfamily_name,
191
+ postscript_name: postscript_name,
192
+ font_format: font_format,
193
+ num_glyphs: num_glyphs,
194
+ num_tables: num_tables,
195
+ )
196
+ end
197
+
198
+ Models::CollectionListInfo.new(
199
+ collection_path: nil, # Will be set by command
200
+ num_fonts: num_fonts,
201
+ fonts: fonts,
202
+ )
203
+ end
204
+
205
+ # Get comprehensive collection metadata
206
+ #
207
+ # Returns a CollectionInfo model with header information, offsets,
208
+ # and table sharing statistics.
209
+ # This is the API method used by the `info` command for collections.
210
+ #
211
+ # @param io [IO] Open file handle to read fonts from
212
+ # @param path [String] Collection file path (for file size)
213
+ # @return [CollectionInfo] Collection metadata
214
+ #
215
+ # @example Get collection info
216
+ # File.open("fonts.ttc", "rb") do |io|
217
+ # collection = TrueTypeCollection.read(io)
218
+ # info = collection.collection_info(io, "fonts.ttc")
219
+ # puts "Version: #{info.version_string}"
220
+ # end
221
+ def collection_info(io, path)
222
+ require_relative "models/collection_info"
223
+ require_relative "models/table_sharing_info"
224
+
225
+ # Calculate table sharing statistics
226
+ table_sharing = calculate_table_sharing(io)
227
+
228
+ # Get file size
229
+ file_size = path ? File.size(path) : 0
230
+
231
+ Models::CollectionInfo.new(
232
+ collection_path: path,
233
+ collection_format: self.class.collection_format,
234
+ ttc_tag: tag,
235
+ major_version: major_version,
236
+ minor_version: minor_version,
237
+ num_fonts: num_fonts,
238
+ font_offsets: font_offsets.to_a,
239
+ file_size_bytes: file_size,
240
+ table_sharing: table_sharing,
241
+ )
242
+ end
243
+
244
+ private
245
+
246
+ # Calculate table sharing statistics
247
+ #
248
+ # Analyzes which tables are shared between fonts and calculates
249
+ # space savings from deduplication.
250
+ #
251
+ # @param io [IO] Open file handle
252
+ # @return [TableSharingInfo] Sharing statistics
253
+ def calculate_table_sharing(io)
254
+ require_relative "models/table_sharing_info"
255
+
256
+ font_class = self.class.font_class
257
+
258
+ # Extract all fonts
259
+ fonts = font_offsets.map do |offset|
260
+ font_class.from_collection(io, offset)
261
+ end
262
+
263
+ # Build table hash map (checksum -> size)
264
+ table_map = {}
265
+ total_table_size = 0
266
+
267
+ fonts.each do |font|
268
+ font.tables.each do |entry|
269
+ key = entry.checksum
270
+ size = entry.table_length
271
+ table_map[key] ||= size
272
+ total_table_size += size
273
+ end
274
+ end
275
+
276
+ # Count unique vs shared
277
+ unique_tables = table_map.size
278
+ total_tables = fonts.sum { |f| f.tables.length }
279
+ shared_tables = total_tables - unique_tables
280
+
281
+ # Calculate space saved
282
+ unique_size = table_map.values.sum
283
+ space_saved = total_table_size - unique_size
284
+
285
+ # Calculate sharing percentage
286
+ sharing_pct = total_tables.positive? ? (shared_tables.to_f / total_tables * 100).round(2) : 0.0
287
+
288
+ Models::TableSharingInfo.new(
289
+ shared_tables: shared_tables,
290
+ unique_tables: unique_tables,
291
+ sharing_percentage: sharing_pct,
292
+ space_saved_bytes: space_saved,
293
+ )
294
+ end
295
+ end
296
+ end
data/lib/fontisan/cli.rb CHANGED
@@ -26,6 +26,9 @@ module Fontisan
26
26
  aliases: "-q"
27
27
 
28
28
  desc "info PATH", "Display font information"
29
+ option :brief, type: :boolean, default: false,
30
+ desc: "Brief mode - only essential info (5x faster, uses metadata loading)",
31
+ aliases: "-b"
29
32
  # Extract and display comprehensive font metadata.
30
33
  #
31
34
  # @param path [String] Path to the font file or collection
@@ -33,7 +36,7 @@ module Fontisan
33
36
  command = Commands::InfoCommand.new(path, options)
34
37
  info = command.run
35
38
  output_result(info) unless options[:quiet]
36
- rescue Errno::ENOENT => e
39
+ rescue Errno::ENOENT
37
40
  if options[:verbose]
38
41
  raise
39
42
  else
@@ -270,7 +273,10 @@ module Fontisan
270
273
 
271
274
  # Merge coordinates into options
272
275
  convert_options = options.to_h.dup
273
- convert_options[:instance_coordinates] = instance_coords if instance_coords.any?
276
+ if instance_coords.any?
277
+ convert_options[:instance_coordinates] =
278
+ instance_coords
279
+ end
274
280
 
275
281
  command = Commands::ConvertCommand.new(font_file, convert_options)
276
282
  command.run
@@ -348,7 +354,8 @@ module Fontisan
348
354
  option :verbose, type: :boolean, default: false,
349
355
  desc: "Show detailed validation information"
350
356
  def validate(font_file)
351
- command = Commands::ValidateCommand.new(font_file, verbose: options[:verbose])
357
+ command = Commands::ValidateCommand.new(font_file,
358
+ verbose: options[:verbose])
352
359
  exit command.run
353
360
  end
354
361
 
@@ -291,7 +291,8 @@ module Fontisan
291
291
  otf_count = variable_fonts.count { |f| f.has_table?("CFF2") }
292
292
 
293
293
  if ttf_count.positive? && otf_count.positive?
294
- raise Error, "Cannot mix TrueType and CFF2 variable fonts in collection"
294
+ raise Error,
295
+ "Cannot mix TrueType and CFF2 variable fonts in collection"
295
296
  end
296
297
  end
297
298
 
@@ -165,6 +165,7 @@ module Fontisan
165
165
  end
166
166
  end
167
167
 
168
+ # rubocop:disable Style/CombinableLoops
168
169
  # First, assign offsets to shared tables
169
170
  # Shared tables are stored once and referenced by multiple fonts
170
171
  canonical_tables.each do |canonical_id, info|
@@ -182,6 +183,7 @@ module Fontisan
182
183
  @offsets[:table_offsets][canonical_id] = current_offset
183
184
  current_offset = align_offset(current_offset + info[:size])
184
185
  end
186
+ # rubocop:enable Style/CombinableLoops
185
187
  end
186
188
 
187
189
  # Align offset to TABLE_ALIGNMENT boundary
@@ -82,13 +82,16 @@ module Fontisan
82
82
  end
83
83
  end
84
84
 
85
+ # Brief mode uses metadata loading for 5x faster parsing
86
+ mode = @options[:brief] ? LoadingModes::METADATA : (@options[:mode] || LoadingModes::FULL)
87
+
85
88
  # ConvertCommand and similar commands need all tables loaded upfront
86
89
  # Use mode and lazy from options, or sensible defaults
87
90
  FontLoader.load(
88
91
  @font_path,
89
92
  font_index: @options[:font_index] || 0,
90
- mode: @options[:mode] || LoadingModes::FULL,
91
- lazy: @options.key?(:lazy) ? @options[:lazy] : false
93
+ mode: mode,
94
+ lazy: @options.key?(:lazy) ? @options[:lazy] : false,
92
95
  )
93
96
  rescue Errno::ENOENT
94
97
  # Re-raise file not found as-is
@@ -89,7 +89,10 @@ module Fontisan
89
89
  # Add variation options if specified
90
90
  pipeline_options[:coordinates] = @coordinates if @coordinates
91
91
  pipeline_options[:instance_index] = @instance_index if @instance_index
92
- pipeline_options[:preserve_variation] = @preserve_variation unless @preserve_variation.nil?
92
+ unless @preserve_variation.nil?
93
+ pipeline_options[:preserve_variation] =
94
+ @preserve_variation
95
+ end
93
96
 
94
97
  # Add hint preservation option
95
98
  pipeline_options[:preserve_hints] = @preserve_hints if @preserve_hints
@@ -155,7 +158,8 @@ module Fontisan
155
158
  end
156
159
  coords
157
160
  rescue StandardError => e
158
- raise ArgumentError, "Invalid coordinates format '#{coord_string}': #{e.message}"
161
+ raise ArgumentError,
162
+ "Invalid coordinates format '#{coord_string}': #{e.message}"
159
163
  end
160
164
 
161
165
  # Validate command options
@@ -41,24 +41,105 @@ 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.collection_type = collection.class.collection_format
54
+ brief_info.collection_version = collection.version_string
55
+ brief_info.num_fonts = collection.num_fonts
56
+ brief_info.fonts = load_collection_fonts(collection, @font_path)
57
+
58
+ brief_info
59
+ else
60
+ # Full mode: show detailed sharing statistics AND font information
61
+ full_info = collection.collection_info(io, @font_path)
62
+
63
+ # Add font information to full mode
64
+ full_info.fonts = load_collection_fonts(collection, @font_path)
65
+
66
+ full_info
67
+ end
50
68
  end
51
69
  end
52
70
 
71
+ # Load font information for all fonts in a collection
72
+ #
73
+ # @param collection [TrueTypeCollection, OpenTypeCollection] The collection
74
+ # @param collection_path [String] Path to the collection file
75
+ # @return [Array<Models::FontInfo>] Array of font info objects
76
+ def load_collection_fonts(collection, collection_path)
77
+ fonts = []
78
+
79
+ collection.num_fonts.times do |index|
80
+ # Load individual font from collection
81
+ font = FontLoader.load(collection_path, font_index: index, mode: LoadingModes::METADATA)
82
+
83
+ # Populate font info
84
+ info = Models::FontInfo.new
85
+
86
+ # Font format and variable status
87
+ info.font_format = case font
88
+ when TrueTypeFont
89
+ "truetype"
90
+ when OpenTypeFont
91
+ "cff"
92
+ else
93
+ "unknown"
94
+ end
95
+ info.is_variable = font.has_table?(Constants::FVAR_TAG)
96
+
97
+ # Collection offset (only populated for fonts in collections)
98
+ info.collection_offset = collection.font_offsets[index]
99
+
100
+ # Essential names
101
+ if font.has_table?(Constants::NAME_TAG)
102
+ name_table = font.table(Constants::NAME_TAG)
103
+ info.family_name = name_table.english_name(Tables::Name::FAMILY)
104
+ info.subfamily_name = name_table.english_name(Tables::Name::SUBFAMILY)
105
+ info.full_name = name_table.english_name(Tables::Name::FULL_NAME)
106
+ info.postscript_name = name_table.english_name(Tables::Name::POSTSCRIPT_NAME)
107
+ info.version = name_table.english_name(Tables::Name::VERSION)
108
+ end
109
+
110
+ # Essential metrics
111
+ if font.has_table?(Constants::HEAD_TAG)
112
+ head = font.table(Constants::HEAD_TAG)
113
+ info.font_revision = head.font_revision
114
+ info.units_per_em = head.units_per_em
115
+ end
116
+
117
+ # Vendor ID
118
+ if font.has_table?(Constants::OS2_TAG)
119
+ os2_table = font.table(Constants::OS2_TAG)
120
+ info.vendor_id = os2_table.vendor_id
121
+ end
122
+
123
+ fonts << info
124
+ end
125
+
126
+ fonts
127
+ end
128
+
53
129
  # Get individual font information
54
130
  #
55
131
  # @return [Models::FontInfo] Font metadata
56
132
  def font_info
57
133
  info = Models::FontInfo.new
58
134
  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)
135
+
136
+ # In brief mode, only populate essential fields for fast identification
137
+ if @options[:brief]
138
+ populate_brief_fields(info)
139
+ else
140
+ populate_full_fields(info)
141
+ end
142
+
62
143
  info
63
144
  end
64
145
 
@@ -80,6 +161,49 @@ module Fontisan
80
161
  info.is_variable = font.has_table?(Constants::FVAR_TAG)
81
162
  end
82
163
 
164
+ # Populate essential fields for brief mode (metadata tables only).
165
+ #
166
+ # Brief mode provides fast font identification by loading only 13 essential
167
+ # attributes from metadata tables (name, head, OS/2). This is 5x faster than
168
+ # full mode and optimized for font indexing systems.
169
+ #
170
+ # @param info [Models::FontInfo] FontInfo instance to populate
171
+ def populate_brief_fields(info)
172
+ # Essential names from name table
173
+ if font.has_table?(Constants::NAME_TAG)
174
+ name_table = font.table(Constants::NAME_TAG)
175
+ info.family_name = name_table.english_name(Tables::Name::FAMILY)
176
+ info.subfamily_name = name_table.english_name(Tables::Name::SUBFAMILY)
177
+ info.full_name = name_table.english_name(Tables::Name::FULL_NAME)
178
+ info.postscript_name = name_table.english_name(Tables::Name::POSTSCRIPT_NAME)
179
+ info.version = name_table.english_name(Tables::Name::VERSION)
180
+ end
181
+
182
+ # Essential metrics from head table
183
+ if font.has_table?(Constants::HEAD_TAG)
184
+ head = font.table(Constants::HEAD_TAG)
185
+ info.font_revision = head.font_revision
186
+ info.units_per_em = head.units_per_em
187
+ end
188
+
189
+ # Vendor ID from OS/2 table
190
+ if font.has_table?(Constants::OS2_TAG)
191
+ os2_table = font.table(Constants::OS2_TAG)
192
+ info.vendor_id = os2_table.vendor_id
193
+ end
194
+ end
195
+
196
+ # Populate all fields for full mode.
197
+ #
198
+ # Full mode extracts comprehensive metadata from all available tables.
199
+ #
200
+ # @param info [Models::FontInfo] FontInfo instance to populate
201
+ def populate_full_fields(info)
202
+ populate_from_name_table(info) if font.has_table?(Constants::NAME_TAG)
203
+ populate_from_os2_table(info) if font.has_table?(Constants::OS2_TAG)
204
+ populate_from_head_table(info) if font.has_table?(Constants::HEAD_TAG)
205
+ end
206
+
83
207
  # Populate FontInfo from the name table.
84
208
  #
85
209
  # @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