fontisan 0.1.0 → 0.2.0

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 (185) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +529 -65
  3. data/Gemfile +1 -0
  4. data/LICENSE +5 -1
  5. data/README.adoc +1301 -275
  6. data/Rakefile +27 -2
  7. data/benchmark/variation_quick_bench.rb +47 -0
  8. data/docs/EXTRACT_TTC_MIGRATION.md +549 -0
  9. data/fontisan.gemspec +4 -1
  10. data/lib/fontisan/binary/base_record.rb +22 -1
  11. data/lib/fontisan/cli.rb +309 -0
  12. data/lib/fontisan/collection/builder.rb +260 -0
  13. data/lib/fontisan/collection/offset_calculator.rb +227 -0
  14. data/lib/fontisan/collection/table_analyzer.rb +204 -0
  15. data/lib/fontisan/collection/table_deduplicator.rb +241 -0
  16. data/lib/fontisan/collection/writer.rb +306 -0
  17. data/lib/fontisan/commands/base_command.rb +8 -1
  18. data/lib/fontisan/commands/convert_command.rb +291 -0
  19. data/lib/fontisan/commands/export_command.rb +161 -0
  20. data/lib/fontisan/commands/info_command.rb +40 -6
  21. data/lib/fontisan/commands/instance_command.rb +295 -0
  22. data/lib/fontisan/commands/ls_command.rb +113 -0
  23. data/lib/fontisan/commands/pack_command.rb +241 -0
  24. data/lib/fontisan/commands/subset_command.rb +245 -0
  25. data/lib/fontisan/commands/unpack_command.rb +338 -0
  26. data/lib/fontisan/commands/validate_command.rb +178 -0
  27. data/lib/fontisan/commands/variable_command.rb +30 -1
  28. data/lib/fontisan/config/collection_settings.yml +56 -0
  29. data/lib/fontisan/config/conversion_matrix.yml +212 -0
  30. data/lib/fontisan/config/export_settings.yml +66 -0
  31. data/lib/fontisan/config/subset_profiles.yml +100 -0
  32. data/lib/fontisan/config/svg_settings.yml +60 -0
  33. data/lib/fontisan/config/validation_rules.yml +149 -0
  34. data/lib/fontisan/config/variable_settings.yml +99 -0
  35. data/lib/fontisan/config/woff2_settings.yml +77 -0
  36. data/lib/fontisan/constants.rb +69 -0
  37. data/lib/fontisan/converters/conversion_strategy.rb +96 -0
  38. data/lib/fontisan/converters/format_converter.rb +259 -0
  39. data/lib/fontisan/converters/outline_converter.rb +936 -0
  40. data/lib/fontisan/converters/svg_generator.rb +244 -0
  41. data/lib/fontisan/converters/table_copier.rb +117 -0
  42. data/lib/fontisan/converters/woff2_encoder.rb +416 -0
  43. data/lib/fontisan/converters/woff_writer.rb +391 -0
  44. data/lib/fontisan/error.rb +203 -0
  45. data/lib/fontisan/export/exporter.rb +262 -0
  46. data/lib/fontisan/export/table_serializer.rb +255 -0
  47. data/lib/fontisan/export/transformers/font_to_ttx.rb +172 -0
  48. data/lib/fontisan/export/transformers/head_transformer.rb +96 -0
  49. data/lib/fontisan/export/transformers/hhea_transformer.rb +59 -0
  50. data/lib/fontisan/export/transformers/maxp_transformer.rb +63 -0
  51. data/lib/fontisan/export/transformers/name_transformer.rb +63 -0
  52. data/lib/fontisan/export/transformers/os2_transformer.rb +121 -0
  53. data/lib/fontisan/export/transformers/post_transformer.rb +51 -0
  54. data/lib/fontisan/export/ttx_generator.rb +527 -0
  55. data/lib/fontisan/export/ttx_parser.rb +300 -0
  56. data/lib/fontisan/font_loader.rb +121 -12
  57. data/lib/fontisan/font_writer.rb +301 -0
  58. data/lib/fontisan/formatters/text_formatter.rb +102 -0
  59. data/lib/fontisan/glyph_accessor.rb +503 -0
  60. data/lib/fontisan/hints/hint_converter.rb +177 -0
  61. data/lib/fontisan/hints/postscript_hint_applier.rb +185 -0
  62. data/lib/fontisan/hints/postscript_hint_extractor.rb +254 -0
  63. data/lib/fontisan/hints/truetype_hint_applier.rb +71 -0
  64. data/lib/fontisan/hints/truetype_hint_extractor.rb +162 -0
  65. data/lib/fontisan/loading_modes.rb +113 -0
  66. data/lib/fontisan/metrics_calculator.rb +277 -0
  67. data/lib/fontisan/models/collection_font_summary.rb +52 -0
  68. data/lib/fontisan/models/collection_info.rb +76 -0
  69. data/lib/fontisan/models/collection_list_info.rb +37 -0
  70. data/lib/fontisan/models/font_export.rb +158 -0
  71. data/lib/fontisan/models/font_summary.rb +48 -0
  72. data/lib/fontisan/models/glyph_outline.rb +343 -0
  73. data/lib/fontisan/models/hint.rb +233 -0
  74. data/lib/fontisan/models/outline.rb +664 -0
  75. data/lib/fontisan/models/table_sharing_info.rb +40 -0
  76. data/lib/fontisan/models/ttx/glyph_order.rb +31 -0
  77. data/lib/fontisan/models/ttx/tables/binary_table.rb +67 -0
  78. data/lib/fontisan/models/ttx/tables/head_table.rb +74 -0
  79. data/lib/fontisan/models/ttx/tables/hhea_table.rb +74 -0
  80. data/lib/fontisan/models/ttx/tables/maxp_table.rb +55 -0
  81. data/lib/fontisan/models/ttx/tables/name_table.rb +45 -0
  82. data/lib/fontisan/models/ttx/tables/os2_table.rb +157 -0
  83. data/lib/fontisan/models/ttx/tables/post_table.rb +50 -0
  84. data/lib/fontisan/models/ttx/ttfont.rb +49 -0
  85. data/lib/fontisan/models/validation_report.rb +203 -0
  86. data/lib/fontisan/open_type_collection.rb +156 -2
  87. data/lib/fontisan/open_type_font.rb +296 -10
  88. data/lib/fontisan/optimizers/charstring_rewriter.rb +161 -0
  89. data/lib/fontisan/optimizers/pattern_analyzer.rb +308 -0
  90. data/lib/fontisan/optimizers/stack_tracker.rb +246 -0
  91. data/lib/fontisan/optimizers/subroutine_builder.rb +134 -0
  92. data/lib/fontisan/optimizers/subroutine_generator.rb +207 -0
  93. data/lib/fontisan/optimizers/subroutine_optimizer.rb +107 -0
  94. data/lib/fontisan/outline_extractor.rb +423 -0
  95. data/lib/fontisan/subset/builder.rb +268 -0
  96. data/lib/fontisan/subset/glyph_mapping.rb +215 -0
  97. data/lib/fontisan/subset/options.rb +142 -0
  98. data/lib/fontisan/subset/profile.rb +152 -0
  99. data/lib/fontisan/subset/table_subsetter.rb +461 -0
  100. data/lib/fontisan/svg/font_face_generator.rb +278 -0
  101. data/lib/fontisan/svg/font_generator.rb +264 -0
  102. data/lib/fontisan/svg/glyph_generator.rb +168 -0
  103. data/lib/fontisan/svg/view_box_calculator.rb +137 -0
  104. data/lib/fontisan/tables/cff/cff_glyph.rb +176 -0
  105. data/lib/fontisan/tables/cff/charset.rb +282 -0
  106. data/lib/fontisan/tables/cff/charstring.rb +905 -0
  107. data/lib/fontisan/tables/cff/charstring_builder.rb +322 -0
  108. data/lib/fontisan/tables/cff/charstrings_index.rb +162 -0
  109. data/lib/fontisan/tables/cff/dict.rb +351 -0
  110. data/lib/fontisan/tables/cff/dict_builder.rb +242 -0
  111. data/lib/fontisan/tables/cff/encoding.rb +274 -0
  112. data/lib/fontisan/tables/cff/header.rb +102 -0
  113. data/lib/fontisan/tables/cff/index.rb +237 -0
  114. data/lib/fontisan/tables/cff/index_builder.rb +170 -0
  115. data/lib/fontisan/tables/cff/private_dict.rb +284 -0
  116. data/lib/fontisan/tables/cff/top_dict.rb +236 -0
  117. data/lib/fontisan/tables/cff.rb +487 -0
  118. data/lib/fontisan/tables/cff2/blend_operator.rb +240 -0
  119. data/lib/fontisan/tables/cff2/charstring_parser.rb +591 -0
  120. data/lib/fontisan/tables/cff2/operand_stack.rb +232 -0
  121. data/lib/fontisan/tables/cff2.rb +341 -0
  122. data/lib/fontisan/tables/cvar.rb +242 -0
  123. data/lib/fontisan/tables/fvar.rb +2 -2
  124. data/lib/fontisan/tables/glyf/compound_glyph.rb +483 -0
  125. data/lib/fontisan/tables/glyf/compound_glyph_resolver.rb +136 -0
  126. data/lib/fontisan/tables/glyf/curve_converter.rb +343 -0
  127. data/lib/fontisan/tables/glyf/glyph_builder.rb +450 -0
  128. data/lib/fontisan/tables/glyf/simple_glyph.rb +382 -0
  129. data/lib/fontisan/tables/glyf.rb +235 -0
  130. data/lib/fontisan/tables/gvar.rb +270 -0
  131. data/lib/fontisan/tables/hhea.rb +124 -0
  132. data/lib/fontisan/tables/hmtx.rb +287 -0
  133. data/lib/fontisan/tables/hvar.rb +191 -0
  134. data/lib/fontisan/tables/loca.rb +322 -0
  135. data/lib/fontisan/tables/maxp.rb +192 -0
  136. data/lib/fontisan/tables/mvar.rb +185 -0
  137. data/lib/fontisan/tables/name.rb +99 -30
  138. data/lib/fontisan/tables/variation_common.rb +346 -0
  139. data/lib/fontisan/tables/vvar.rb +234 -0
  140. data/lib/fontisan/true_type_collection.rb +156 -2
  141. data/lib/fontisan/true_type_font.rb +297 -11
  142. data/lib/fontisan/utilities/brotli_wrapper.rb +159 -0
  143. data/lib/fontisan/utilities/checksum_calculator.rb +18 -0
  144. data/lib/fontisan/utils/thread_pool.rb +134 -0
  145. data/lib/fontisan/validation/checksum_validator.rb +170 -0
  146. data/lib/fontisan/validation/consistency_validator.rb +197 -0
  147. data/lib/fontisan/validation/structure_validator.rb +198 -0
  148. data/lib/fontisan/validation/table_validator.rb +158 -0
  149. data/lib/fontisan/validation/validator.rb +152 -0
  150. data/lib/fontisan/variable/axis_normalizer.rb +215 -0
  151. data/lib/fontisan/variable/delta_applicator.rb +313 -0
  152. data/lib/fontisan/variable/glyph_delta_processor.rb +218 -0
  153. data/lib/fontisan/variable/instancer.rb +344 -0
  154. data/lib/fontisan/variable/metric_delta_processor.rb +282 -0
  155. data/lib/fontisan/variable/region_matcher.rb +208 -0
  156. data/lib/fontisan/variable/static_font_builder.rb +213 -0
  157. data/lib/fontisan/variable/table_updater.rb +219 -0
  158. data/lib/fontisan/variation/blend_applier.rb +199 -0
  159. data/lib/fontisan/variation/cache.rb +298 -0
  160. data/lib/fontisan/variation/cache_key_builder.rb +162 -0
  161. data/lib/fontisan/variation/converter.rb +268 -0
  162. data/lib/fontisan/variation/data_extractor.rb +86 -0
  163. data/lib/fontisan/variation/delta_applier.rb +266 -0
  164. data/lib/fontisan/variation/delta_parser.rb +228 -0
  165. data/lib/fontisan/variation/inspector.rb +275 -0
  166. data/lib/fontisan/variation/instance_generator.rb +273 -0
  167. data/lib/fontisan/variation/interpolator.rb +231 -0
  168. data/lib/fontisan/variation/metrics_adjuster.rb +318 -0
  169. data/lib/fontisan/variation/optimizer.rb +418 -0
  170. data/lib/fontisan/variation/parallel_generator.rb +150 -0
  171. data/lib/fontisan/variation/region_matcher.rb +221 -0
  172. data/lib/fontisan/variation/subsetter.rb +463 -0
  173. data/lib/fontisan/variation/table_accessor.rb +105 -0
  174. data/lib/fontisan/variation/validator.rb +345 -0
  175. data/lib/fontisan/variation/variation_context.rb +211 -0
  176. data/lib/fontisan/version.rb +1 -1
  177. data/lib/fontisan/woff2/directory.rb +257 -0
  178. data/lib/fontisan/woff2/header.rb +101 -0
  179. data/lib/fontisan/woff2/table_transformer.rb +163 -0
  180. data/lib/fontisan/woff2_font.rb +712 -0
  181. data/lib/fontisan/woff_font.rb +483 -0
  182. data/lib/fontisan.rb +120 -0
  183. data/scripts/compare_stack_aware.rb +187 -0
  184. data/scripts/measure_optimization.rb +141 -0
  185. metadata +205 -4
@@ -0,0 +1,338 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_command"
4
+ require_relative "../font_loader"
5
+ require_relative "../font_writer"
6
+ require "fileutils"
7
+
8
+ module Fontisan
9
+ module Commands
10
+ # Command for unpacking fonts from TTC/OTC collections
11
+ #
12
+ # This command extracts individual font files from a TTC (TrueType Collection)
13
+ # or OTC (OpenType Collection) file. It can extract all fonts or a specific
14
+ # font by index, optionally converting to different formats during extraction.
15
+ #
16
+ # @example Extract all fonts
17
+ # command = UnpackCommand.new(
18
+ # 'family.ttc',
19
+ # output_dir: 'fonts/',
20
+ # format: :ttf
21
+ # )
22
+ # result = command.run
23
+ # puts "Extracted #{result[:fonts_extracted]} fonts"
24
+ #
25
+ # @example Extract specific font
26
+ # command = UnpackCommand.new(
27
+ # 'family.ttc',
28
+ # output_dir: 'fonts/',
29
+ # font_index: 2
30
+ # )
31
+ # result = command.run
32
+ class UnpackCommand
33
+ # Initialize unpack command
34
+ #
35
+ # @param collection_path [String] Path to TTC/OTC file
36
+ # @param options [Hash] Command options
37
+ # @option options [String] :output_dir Output directory (required)
38
+ # @option options [Integer] :font_index Extract specific font index (optional)
39
+ # @option options [Symbol, String] :format Output format (ttf, otf, woff, woff2)
40
+ # @option options [String] :prefix Filename prefix for extracted fonts
41
+ # @option options [Boolean] :verbose Enable verbose output (default: false)
42
+ # @raise [ArgumentError] if collection_path or output_dir is invalid
43
+ def initialize(collection_path, options = {})
44
+ @collection_path = collection_path
45
+ @options = options
46
+ @output_dir = options[:output_dir]
47
+ @font_index = options[:font_index]
48
+ @format = parse_format(options[:format])
49
+ @prefix = options[:prefix]
50
+ @verbose = options.fetch(:verbose, false)
51
+
52
+ validate_options!
53
+ end
54
+
55
+ # Execute the unpack command
56
+ #
57
+ # Extracts fonts from the collection and writes them as individual files.
58
+ #
59
+ # @return [Hash] Result information with:
60
+ # - :collection [String] - Input collection path
61
+ # - :output_dir [String] - Output directory
62
+ # - :num_fonts [Integer] - Total fonts in collection
63
+ # - :fonts_extracted [Integer] - Number of fonts extracted
64
+ # - :extracted_files [Array<String>] - Paths to extracted files
65
+ # @raise [Fontisan::Error] if unpacking fails
66
+ def run
67
+ puts "Loading collection from #{File.basename(@collection_path)}..." if @verbose
68
+
69
+ # Load collection
70
+ collection = load_collection
71
+
72
+ # Create output directory
73
+ FileUtils.mkdir_p(@output_dir) unless Dir.exist?(@output_dir)
74
+
75
+ # Determine which fonts to extract
76
+ indices_to_extract = determine_indices(collection)
77
+
78
+ puts "Extracting #{indices_to_extract.size} font(s)..." if @verbose
79
+
80
+ # Extract fonts
81
+ extracted_files = extract_fonts(collection, indices_to_extract)
82
+
83
+ # Display results
84
+ if @verbose
85
+ display_results(collection, extracted_files)
86
+ end
87
+
88
+ {
89
+ collection: @collection_path,
90
+ output_dir: @output_dir,
91
+ num_fonts: collection.font_count,
92
+ fonts_extracted: extracted_files.size,
93
+ extracted_files: extracted_files,
94
+ }
95
+ rescue Fontisan::Error => e
96
+ raise Fontisan::Error, "Collection unpacking failed: #{e.message}"
97
+ rescue ArgumentError
98
+ # Let ArgumentError propagate for validation errors
99
+ raise
100
+ rescue StandardError => e
101
+ raise Fontisan::Error, "Unexpected error during unpacking: #{e.message}"
102
+ end
103
+
104
+ private
105
+
106
+ # Validate command options
107
+ #
108
+ # @raise [ArgumentError] if options are invalid
109
+ def validate_options!
110
+ # Must have output directory
111
+ unless @output_dir
112
+ raise ArgumentError, "Output directory is required (--output-dir)"
113
+ end
114
+
115
+ # Check collection file exists
116
+ unless File.exist?(@collection_path)
117
+ raise ArgumentError, "Collection file not found: #{@collection_path}"
118
+ end
119
+
120
+ # Validate font index if provided
121
+ if @font_index&.negative?
122
+ raise ArgumentError, "Font index must be >= 0, got #{@font_index}"
123
+ end
124
+ end
125
+
126
+ # Load collection file
127
+ #
128
+ # @return [TrueTypeCollection, OpenTypeCollection] Loaded collection
129
+ # @raise [Fontisan::Error] if loading fails
130
+ def load_collection
131
+ # Try to detect format from extension
132
+ ext = File.extname(@collection_path).downcase
133
+
134
+ File.open(@collection_path, "rb") do |io|
135
+ # Read tag to determine type
136
+ tag = io.read(4)
137
+ io.rewind
138
+
139
+ unless tag == "ttcf"
140
+ raise Fontisan::Error,
141
+ "Not a valid TTC/OTC file (invalid signature)"
142
+ end
143
+
144
+ # Load as TTC or OTC based on extension hint
145
+ # Both use same structure, main difference is expected font types
146
+ if ext == ".otc"
147
+ require_relative "../open_type_collection"
148
+ OpenTypeCollection.read(io)
149
+ else
150
+ require_relative "../true_type_collection"
151
+ TrueTypeCollection.read(io)
152
+ end
153
+ end
154
+ rescue Errno::ENOENT
155
+ raise Fontisan::Error, "Collection file not found: #{@collection_path}"
156
+ rescue BinData::ValidityError => e
157
+ raise Fontisan::Error, "Invalid collection file: #{e.message}"
158
+ end
159
+
160
+ # Determine which font indices to extract
161
+ #
162
+ # @param collection [TrueTypeCollection, OpenTypeCollection] Collection
163
+ # @return [Array<Integer>] Array of font indices
164
+ # @raise [ArgumentError] if font_index is out of range
165
+ def determine_indices(collection)
166
+ if @font_index
167
+ # Extract specific font
168
+ if @font_index >= collection.font_count
169
+ raise ArgumentError,
170
+ "Font index #{@font_index} out of range (collection has #{collection.font_count} fonts)"
171
+ end
172
+ [@font_index]
173
+ else
174
+ # Extract all fonts
175
+ (0...collection.font_count).to_a
176
+ end
177
+ end
178
+
179
+ # Extract fonts from collection
180
+ #
181
+ # @param collection [TrueTypeCollection, OpenTypeCollection] Collection
182
+ # @param indices [Array<Integer>] Indices to extract
183
+ # @return [Array<String>] Paths to extracted files
184
+ def extract_fonts(collection, indices)
185
+ extracted_files = []
186
+
187
+ File.open(@collection_path, "rb") do |io|
188
+ fonts = collection.extract_fonts(io)
189
+
190
+ indices.each do |index|
191
+ font = fonts[index]
192
+ filename = generate_filename(font, index)
193
+ output_path = File.join(@output_dir, filename)
194
+
195
+ puts " [#{index + 1}/#{indices.size}] Extracting to #{filename}..." if @verbose
196
+
197
+ # Write font
198
+ write_font(font, output_path)
199
+
200
+ extracted_files << output_path
201
+ end
202
+ end
203
+
204
+ extracted_files
205
+ end
206
+
207
+ # Generate output filename for extracted font
208
+ #
209
+ # @param font [TrueTypeFont, OpenTypeFont] Font object
210
+ # @param index [Integer] Font index
211
+ # @return [String] Filename
212
+ def generate_filename(font, index)
213
+ # Try to get font name from name table
214
+ base_name = nil
215
+ if font.respond_to?(:table) && font.table("name")
216
+ name_table = font.table("name")
217
+ # Try to get PostScript name, then family name
218
+ base_name = name_table.english_name(Tables::Name::POSTSCRIPT_NAME) ||
219
+ name_table.english_name(Tables::Name::FAMILY)
220
+ end
221
+
222
+ # Fallback to prefix or generic name
223
+ base_name ||= @prefix || "font"
224
+ base_name = "#{base_name}_#{index}" unless @font_index
225
+
226
+ # Clean filename
227
+ base_name = base_name.gsub(/[^a-zA-Z0-9_-]/, "_")
228
+
229
+ # Add extension based on format
230
+ ext = format_extension
231
+ "#{base_name}#{ext}"
232
+ end
233
+
234
+ # Write font to file
235
+ #
236
+ # @param font [TrueTypeFont, OpenTypeFont] Font object
237
+ # @param output_path [String] Output file path
238
+ # @return [void]
239
+ def write_font(font, output_path)
240
+ if @format
241
+ # Convert to specified format
242
+ convert_and_write(font, output_path)
243
+ else
244
+ # Write in native format
245
+ font.to_file(output_path)
246
+ end
247
+ end
248
+
249
+ # Convert font and write to file
250
+ #
251
+ # @param font [TrueTypeFont, OpenTypeFont] Font object
252
+ # @param output_path [String] Output file path
253
+ # @return [void]
254
+ def convert_and_write(font, output_path)
255
+ require_relative "../converters/format_converter"
256
+
257
+ converter = Converters::FormatConverter.new
258
+ converter.convert(font, @format, output_path: output_path)
259
+ rescue StandardError => e
260
+ raise Fontisan::Error, "Format conversion failed: #{e.message}"
261
+ end
262
+
263
+ # Parse format option
264
+ #
265
+ # @param format [Symbol, String, nil] Format option
266
+ # @return [Symbol, nil] Parsed format
267
+ def parse_format(format)
268
+ return nil unless format
269
+
270
+ return format if format.is_a?(Symbol)
271
+
272
+ case format.to_s.downcase
273
+ when "ttf"
274
+ :ttf
275
+ when "otf"
276
+ :otf
277
+ when "woff"
278
+ :woff
279
+ when "woff2"
280
+ :woff2
281
+ end
282
+ end
283
+
284
+ # Get file extension for format
285
+ #
286
+ # @return [String] File extension with dot
287
+ def format_extension
288
+ case @format
289
+ when :ttf
290
+ ".ttf"
291
+ when :otf
292
+ ".otf"
293
+ when :woff
294
+ ".woff"
295
+ when :woff2
296
+ ".woff2"
297
+ else
298
+ # Detect from collection type
299
+ ext = File.extname(@collection_path).downcase
300
+ ext == ".otc" ? ".otf" : ".ttf"
301
+ end
302
+ end
303
+
304
+ # Display extraction results
305
+ #
306
+ # @param collection [TrueTypeCollection, OpenTypeCollection] Collection
307
+ # @param extracted_files [Array<String>] Extracted file paths
308
+ # @return [void]
309
+ def display_results(collection, extracted_files)
310
+ puts "\n=== Extraction Complete ==="
311
+ puts "Collection: #{File.basename(@collection_path)}"
312
+ puts "Total fonts: #{collection.font_count}"
313
+ puts "Extracted: #{extracted_files.size}"
314
+ puts "Output directory: #{@output_dir}"
315
+ puts "\nExtracted files:"
316
+ extracted_files.each do |path|
317
+ size = File.size(path)
318
+ puts " - #{File.basename(path)} (#{format_bytes(size)})"
319
+ end
320
+ puts ""
321
+ end
322
+
323
+ # Format bytes for display
324
+ #
325
+ # @param bytes [Integer] Byte count
326
+ # @return [String] Formatted string
327
+ def format_bytes(bytes)
328
+ if bytes < 1024
329
+ "#{bytes} B"
330
+ elsif bytes < 1024 * 1024
331
+ "#{(bytes / 1024.0).round(2)} KB"
332
+ else
333
+ "#{(bytes / (1024.0 * 1024)).round(2)} MB"
334
+ end
335
+ end
336
+ end
337
+ end
338
+ end
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_command"
4
+ require_relative "../validation/validator"
5
+ require_relative "../font_loader"
6
+
7
+ module Fontisan
8
+ module Commands
9
+ # ValidateCommand provides CLI interface for font validation
10
+ #
11
+ # This command validates fonts against quality checks, structural integrity,
12
+ # and OpenType specification compliance. It supports different validation
13
+ # levels and output formats.
14
+ #
15
+ # @example Validating a font
16
+ # command = ValidateCommand.new(
17
+ # input: "font.ttf",
18
+ # level: :standard,
19
+ # format: :text
20
+ # )
21
+ # exit_code = command.run
22
+ class ValidateCommand < BaseCommand
23
+ # Initialize validate command
24
+ #
25
+ # @param input [String] Path to font file
26
+ # @param level [Symbol] Validation level (:strict, :standard, :lenient)
27
+ # @param format [Symbol] Output format (:text, :yaml, :json)
28
+ # @param verbose [Boolean] Show all issues (default: true)
29
+ # @param quiet [Boolean] Only return exit code, no output (default: false)
30
+ def initialize(input:, level: :standard, format: :text, verbose: true,
31
+ quiet: false)
32
+ super()
33
+ @input = input
34
+ @level = level.to_sym
35
+ @format = format.to_sym
36
+ @verbose = verbose
37
+ @quiet = quiet
38
+ end
39
+
40
+ # Run the validation command
41
+ #
42
+ # @return [Integer] Exit code (0 = valid, 1 = errors, 2 = warnings only)
43
+ def run
44
+ validate_params!
45
+
46
+ # Load font
47
+ font = load_font
48
+ return 1 unless font
49
+
50
+ # Create validator
51
+ validator = Validation::Validator.new(level: @level)
52
+
53
+ # Run validation
54
+ report = validator.validate(font, @input)
55
+
56
+ # Output results unless quiet mode
57
+ output_report(report) unless @quiet
58
+
59
+ # Return appropriate exit code
60
+ determine_exit_code(report)
61
+ rescue StandardError => e
62
+ puts "Error: #{e.message}" unless @quiet
63
+ puts e.backtrace.join("\n") if @verbose && !@quiet
64
+ 1
65
+ end
66
+
67
+ private
68
+
69
+ # Validate command parameters
70
+ #
71
+ # @raise [ArgumentError] if parameters are invalid
72
+ # @return [void]
73
+ def validate_params!
74
+ if @input.nil? || @input.empty?
75
+ raise ArgumentError,
76
+ "Input file is required"
77
+ end
78
+ unless File.exist?(@input)
79
+ raise ArgumentError,
80
+ "Input file does not exist: #{@input}"
81
+ end
82
+
83
+ valid_levels = %i[strict standard lenient]
84
+ unless valid_levels.include?(@level)
85
+ raise ArgumentError,
86
+ "Invalid level: #{@level}. Must be one of: #{valid_levels.join(', ')}"
87
+ end
88
+
89
+ valid_formats = %i[text yaml json]
90
+ unless valid_formats.include?(@format)
91
+ raise ArgumentError,
92
+ "Invalid format: #{@format}. Must be one of: #{valid_formats.join(', ')}"
93
+ end
94
+ end
95
+
96
+ # Load the font file
97
+ #
98
+ # @return [TrueTypeFont, OpenTypeFont, nil] The loaded font or nil on error
99
+ def load_font
100
+ FontLoader.load(@input)
101
+ rescue StandardError => e
102
+ puts "Error loading font: #{e.message}" unless @quiet
103
+ nil
104
+ end
105
+
106
+ # Output validation report in requested format
107
+ #
108
+ # @param report [Models::ValidationReport] The validation report
109
+ # @return [void]
110
+ def output_report(report)
111
+ case @format
112
+ when :text
113
+ output_text(report)
114
+ when :yaml
115
+ output_yaml(report)
116
+ when :json
117
+ output_json(report)
118
+ end
119
+ end
120
+
121
+ # Output report in text format
122
+ #
123
+ # @param report [Models::ValidationReport] The validation report
124
+ # @return [void]
125
+ def output_text(report)
126
+ if @verbose
127
+ puts report.text_summary
128
+ else
129
+ # Compact output: just status and error/warning counts
130
+ status = report.valid ? "VALID" : "INVALID"
131
+ puts "#{status}: #{report.summary.errors} errors, #{report.summary.warnings} warnings"
132
+
133
+ # Show errors only in non-verbose mode
134
+ report.errors.each do |error|
135
+ puts " [ERROR] #{error.message}"
136
+ end
137
+ end
138
+ end
139
+
140
+ # Output report in YAML format
141
+ #
142
+ # @param report [Models::ValidationReport] The validation report
143
+ # @return [void]
144
+ def output_yaml(report)
145
+ require "yaml"
146
+ puts report.to_yaml
147
+ end
148
+
149
+ # Output report in JSON format
150
+ #
151
+ # @param report [Models::ValidationReport] The validation report
152
+ # @return [void]
153
+ def output_json(report)
154
+ require "json"
155
+ puts report.to_json
156
+ end
157
+
158
+ # Determine exit code based on validation results
159
+ #
160
+ # Exit codes:
161
+ # - 0: Valid (no errors, or only warnings in lenient mode)
162
+ # - 1: Has errors
163
+ # - 2: Has warnings only (no errors)
164
+ #
165
+ # @param report [Models::ValidationReport] The validation report
166
+ # @return [Integer] Exit code
167
+ def determine_exit_code(report)
168
+ if report.has_errors?
169
+ 1
170
+ elsif report.has_warnings?
171
+ 2
172
+ else
173
+ 0
174
+ end
175
+ end
176
+ end
177
+ end
178
+ end
@@ -1,16 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "base_command"
4
+ require_relative "../variation/inspector"
5
+
3
6
  module Fontisan
4
7
  module Commands
5
8
  # Command to extract variable font information.
6
9
  #
7
10
  # This command extracts variation axes and named instances from variable
8
- # fonts using the fvar (Font Variations) table.
11
+ # fonts using the fvar (Font Variations) table. It also provides
12
+ # detailed inspection capabilities through the Inspector class.
9
13
  #
10
14
  # @example Extract variable font information
11
15
  # command = VariableCommand.new("path/to/variable-font.ttf")
12
16
  # info = command.run
13
17
  # puts info.axes.first.tag
18
+ #
19
+ # @example Inspect variable font structure
20
+ # command = VariableCommand.new("path/to/variable-font.ttf")
21
+ # command.inspect(format: "json")
14
22
  class VariableCommand < BaseCommand
15
23
  # Extract variable font information from the fvar table.
16
24
  #
@@ -56,6 +64,27 @@ module Fontisan
56
64
 
57
65
  result
58
66
  end
67
+
68
+ # Inspect variable font structure
69
+ #
70
+ # Provides detailed analysis of variable font structure including
71
+ # axes, instances, regions, and statistics.
72
+ #
73
+ # @param options [Hash] Inspection options
74
+ # @option options [String] :format Output format ("json" or "yaml")
75
+ # @return [String] Formatted inspection output
76
+ def inspect(options = {})
77
+ inspector = Variation::Inspector.new(font)
78
+
79
+ format = options[:format] || "json"
80
+
81
+ case format.downcase
82
+ when "yaml"
83
+ inspector.export_yaml
84
+ else
85
+ inspector.export_json
86
+ end
87
+ end
59
88
  end
60
89
  end
61
90
  end
@@ -0,0 +1,56 @@
1
+ # Collection Creation Settings
2
+ # Configuration for TTC/OTC (TrueType/OpenType Collection) creation
3
+
4
+ # Table sharing strategy determines how aggressive the deduplication is
5
+ # Options:
6
+ # - conservative: Only share tables with exact checksum match (safest)
7
+ # - aggressive: Consider sharing even with minor differences (not implemented yet)
8
+ table_sharing_strategy: conservative
9
+
10
+ # Alignment requirement for tables in bytes
11
+ # Must be 4 for TrueType/OpenType compliance
12
+ alignment: 4
13
+
14
+ # Optimize table order for better performance
15
+ # When true, tables are ordered according to recommended order
16
+ optimize_table_order: true
17
+
18
+ # Verify checksums after writing
19
+ # When true, verifies all checksums match expected values
20
+ verify_checksums: true
21
+
22
+ # Priority tables for sharing
23
+ # These tables are commonly identical across font families
24
+ # and should be prioritized for sharing to maximize space savings
25
+ priority_tables:
26
+ - head # Font header
27
+ - hhea # Horizontal header
28
+ - maxp # Maximum profile
29
+ - OS/2 # OS/2 and Windows metrics
30
+ - name # Naming table
31
+ - post # PostScript information
32
+ - cvt # Control value table
33
+ - fpgm # Font program
34
+ - prep # Control value program
35
+
36
+ # Tables that should typically not be shared
37
+ # These tables often differ between fonts in a family
38
+ unique_tables:
39
+ - cmap # Character to glyph mapping (can vary)
40
+ - glyf # Glyph data (usually unique per font)
41
+ - loca # Index to location (tied to glyf)
42
+ - hmtx # Horizontal metrics (varies by font)
43
+ - GSUB # Glyph substitution (may vary)
44
+ - GPOS # Glyph positioning (may vary)
45
+
46
+ # Minimum space savings threshold (in bytes)
47
+ # If potential savings are below this, a warning is issued
48
+ min_savings_threshold: 1024
49
+
50
+ # Maximum collection size (in bytes)
51
+ # Collections larger than this will trigger a warning
52
+ # Set to 0 to disable
53
+ max_collection_size: 104857600 # 100 MB
54
+
55
+ # Enable verbose logging during collection creation
56
+ verbose: false