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
data/lib/fontisan/cli.rb CHANGED
@@ -37,6 +37,27 @@ module Fontisan
37
37
  handle_error(e)
38
38
  end
39
39
 
40
+ desc "ls FILE", "List contents (fonts in collection or font summary)"
41
+ # List contents of font files with auto-detection.
42
+ #
43
+ # For collections (TTC/OTC): Lists all fonts in the collection
44
+ # For individual fonts (TTF/OTF): Shows quick font summary
45
+ #
46
+ # @param file [String] Path to the font or collection file
47
+ #
48
+ # @example List fonts in collection
49
+ # fontisan ls fonts.ttc
50
+ #
51
+ # @example Show font summary
52
+ # fontisan ls font.ttf
53
+ def ls(file)
54
+ command = Commands::LsCommand.new(file, options)
55
+ result = command.run
56
+ output_result(result)
57
+ rescue Errno::ENOENT, Error => e
58
+ handle_error(e)
59
+ end
60
+
40
61
  desc "tables FONT_FILE", "List OpenType tables"
41
62
  # List all OpenType tables in the font file.
42
63
  #
@@ -125,6 +146,151 @@ module Fontisan
125
146
  handle_error(e)
126
147
  end
127
148
 
149
+ desc "subset FONT_FILE", "Subset a font to specific glyphs"
150
+ option :text, type: :string,
151
+ desc: "Text to subset (e.g., 'Hello World')",
152
+ aliases: "-t"
153
+ option :glyphs, type: :string,
154
+ desc: "Comma-separated glyph IDs (e.g., '0,1,65,66,67')",
155
+ aliases: "-g"
156
+ option :unicode, type: :string,
157
+ desc: "Comma-separated Unicode codepoints (e.g., 'U+0041,U+0042' or '0x41,0x42')",
158
+ aliases: "-u"
159
+ option :output, type: :string, required: true,
160
+ desc: "Output file path",
161
+ aliases: "-o"
162
+ option :profile, type: :string, default: "pdf",
163
+ desc: "Subsetting profile (pdf, web, minimal)",
164
+ aliases: "-p"
165
+ option :retain_gids, type: :boolean, default: false,
166
+ desc: "Retain original glyph IDs (leave gaps)"
167
+ option :drop_hints, type: :boolean, default: false,
168
+ desc: "Drop hinting instructions"
169
+ option :drop_names, type: :boolean, default: false,
170
+ desc: "Drop glyph names from post table"
171
+ option :unicode_ranges, type: :boolean, default: true,
172
+ desc: "Prune OS/2 Unicode ranges"
173
+ # Subset a font to specific glyphs.
174
+ #
175
+ # You must specify one of --text, --glyphs, or --unicode to define
176
+ # which glyphs to include in the subset.
177
+ #
178
+ # @param font_file [String] Path to the font file
179
+ def subset(font_file)
180
+ command = Commands::SubsetCommand.new(font_file, options)
181
+ result = command.run
182
+
183
+ unless options[:quiet]
184
+ puts "Subset font created:"
185
+ puts " Input: #{result[:input]}"
186
+ puts " Output: #{result[:output]}"
187
+ puts " Original glyphs: #{result[:original_glyphs]}"
188
+ puts " Subset glyphs: #{result[:subset_glyphs]}"
189
+ puts " Profile: #{result[:profile]}"
190
+ puts " Size: #{format_size(result[:size])}"
191
+ end
192
+ rescue Errno::ENOENT, Error => e
193
+ handle_error(e)
194
+ end
195
+
196
+ desc "convert FONT_FILE", "Convert font to different format"
197
+ option :to, type: :string, required: true,
198
+ desc: "Target format (ttf, otf, woff2, svg)",
199
+ aliases: "-t"
200
+ option :output, type: :string, required: true,
201
+ desc: "Output file path",
202
+ aliases: "-o"
203
+ option :optimize, type: :boolean, default: false,
204
+ desc: "Optimize CFF with subroutines (TTF→OTF only)"
205
+ option :min_pattern_length, type: :numeric, default: 10,
206
+ desc: "Minimum pattern length for subroutines"
207
+ option :max_subroutines, type: :numeric, default: 65_535,
208
+ desc: "Maximum number of subroutines"
209
+ option :optimize_ordering, type: :boolean, default: true,
210
+ desc: "Optimize subroutine ordering by frequency"
211
+ # Convert a font to a different format.
212
+ #
213
+ # Supported conversions:
214
+ # - Same format (ttf→ttf, otf→otf): Copy/optimize
215
+ # - TTF ↔ OTF: Outline format conversion (foundation)
216
+ # - Future: WOFF2 compression, SVG export
217
+ #
218
+ # Subroutine Optimization (--optimize):
219
+ # When converting TTF→OTF, you can enable automatic CFF subroutine generation
220
+ # to reduce file size. This analyzes repeated byte patterns across glyphs and
221
+ # creates shared subroutines, typically saving 30-50% in CFF table size.
222
+ #
223
+ # @param font_file [String] Path to the font file
224
+ #
225
+ # @example Convert TTF to OTF
226
+ # fontisan convert font.ttf --to otf --output font.otf
227
+ #
228
+ # @example Convert with optimization
229
+ # fontisan convert font.ttf --to otf --output font.otf --optimize --verbose
230
+ #
231
+ # @example Convert with custom optimization parameters
232
+ # fontisan convert font.ttf --to otf --output font.otf --optimize \
233
+ # --min-pattern-length 15 --max-subroutines 10000
234
+ #
235
+ # @example Copy/optimize TTF
236
+ # fontisan convert font.ttf --to ttf --output optimized.ttf
237
+ def convert(font_file)
238
+ command = Commands::ConvertCommand.new(font_file, options)
239
+ command.run
240
+ rescue Errno::ENOENT, Error => e
241
+ handle_error(e)
242
+ end
243
+
244
+ desc "instance FONT_FILE",
245
+ "Generate static font instance from variable font"
246
+ option :output, type: :string,
247
+ desc: "Output file path",
248
+ aliases: "-o"
249
+ option :wght, type: :numeric,
250
+ desc: "Weight axis value"
251
+ option :wdth, type: :numeric,
252
+ desc: "Width axis value"
253
+ option :slnt, type: :numeric,
254
+ desc: "Slant axis value"
255
+ option :ital, type: :numeric,
256
+ desc: "Italic axis value"
257
+ option :opsz, type: :numeric,
258
+ desc: "Optical size axis value"
259
+ option :named_instance, type: :string,
260
+ desc: "Use named instance (e.g., 'Bold', 'Light')",
261
+ aliases: "-n"
262
+ option :list_instances, type: :boolean, default: false,
263
+ desc: "List available named instances",
264
+ aliases: "-l"
265
+ option :to, type: :string,
266
+ desc: "Convert to format (ttf, otf, woff, woff2, svg)",
267
+ aliases: "-t"
268
+ # Generate static font instance from variable font.
269
+ #
270
+ # You can specify axis coordinates using --wght, --wdth, etc., or use
271
+ # a predefined named instance with --named-instance. Use --list-instances
272
+ # to see available named instances.
273
+ #
274
+ # @param font_file [String] Path to the variable font file
275
+ #
276
+ # @example Generate bold instance
277
+ # fontisan instance variable.ttf --wght=700 --output=bold.ttf
278
+ #
279
+ # @example Use named instance
280
+ # fontisan instance variable.ttf --named-instance="Bold" --output=bold.ttf
281
+ #
282
+ # @example Instance with format conversion
283
+ # fontisan instance variable.ttf --wght=700 --to=woff2 --output=bold.woff2
284
+ #
285
+ # @example List available instances
286
+ # fontisan instance variable.ttf --list-instances
287
+ def instance(font_file)
288
+ command = Commands::InstanceCommand.new
289
+ command.execute(font_file, options)
290
+ rescue Errno::ENOENT, Error => e
291
+ handle_error(e)
292
+ end
293
+
128
294
  desc "dump-table FONT_FILE TABLE_TAG", "Dump raw table data to stdout"
129
295
  # Dump raw binary table data to stdout.
130
296
  #
@@ -141,12 +307,141 @@ module Fontisan
141
307
  handle_error(e)
142
308
  end
143
309
 
310
+ desc "validate FONT_FILE", "Validate font file structure and checksums"
311
+ option :verbose, type: :boolean, default: false,
312
+ desc: "Show detailed validation information"
313
+ def validate(font_file)
314
+ command = Commands::ValidateCommand.new(font_file, verbose: options[:verbose])
315
+ exit command.run
316
+ end
317
+
318
+ desc "export FONT_FILE", "Export font to TTX/YAML/JSON format"
319
+ option :output, type: :string,
320
+ desc: "Output file path (default: stdout)",
321
+ aliases: "-o"
322
+ option :format, type: :string, default: "yaml",
323
+ desc: "Export format (yaml, json, ttx)",
324
+ aliases: "-f"
325
+ option :tables, type: :array,
326
+ desc: "Specific tables to export",
327
+ aliases: "-t"
328
+ option :binary_format, type: :string, default: "hex",
329
+ desc: "Binary encoding (hex, base64)",
330
+ aliases: "-b"
331
+
332
+ def export(font_file)
333
+ command = Commands::ExportCommand.new(
334
+ font_file,
335
+ output: options[:output],
336
+ format: options[:format].to_sym,
337
+ tables: options[:tables],
338
+ binary_format: options[:binary_format].to_sym,
339
+ )
340
+ exit command.run
341
+ end
342
+
144
343
  desc "version", "Display version information"
145
344
  # Display the Fontisan version.
146
345
  def version
147
346
  puts "Fontisan version #{Fontisan::VERSION}"
148
347
  end
149
348
 
349
+ desc "unpack FONT_FILE", "Unpack fonts from TTC/OTC collection"
350
+ option :output_dir, type: :string, required: true,
351
+ desc: "Output directory for extracted fonts",
352
+ aliases: "-d"
353
+ option :font_index, type: :numeric,
354
+ desc: "Extract specific font by index (default: extract all)",
355
+ aliases: "-i"
356
+ option :format, type: :string,
357
+ desc: "Output format (ttf, otf, woff, woff2)",
358
+ aliases: "-f"
359
+ option :prefix, type: :string,
360
+ desc: "Filename prefix for extracted fonts",
361
+ aliases: "-p"
362
+ # Extract individual fonts from a TTC (TrueType Collection) or OTC (OpenType Collection) file.
363
+ #
364
+ # This command unpacks fonts from collection files, optionally converting them
365
+ # to different formats during extraction.
366
+ #
367
+ # @param font_file [String] Path to the TTC/OTC collection file
368
+ #
369
+ # @example Extract all fonts to directory
370
+ # fontisan unpack family.ttc --output-dir extracted/
371
+ #
372
+ # @example Extract specific font by index
373
+ # fontisan unpack family.ttc --output-dir extracted/ --font-index 0
374
+ #
375
+ # @example Extract with format conversion
376
+ # fontisan unpack family.ttc --output-dir extracted/ --format woff2
377
+ #
378
+ # @example Extract with custom prefix
379
+ # fontisan unpack family.ttc --output-dir extracted/ --prefix "NotoSans"
380
+ def unpack(font_file)
381
+ command = Commands::UnpackCommand.new(font_file, options)
382
+ result = command.run
383
+
384
+ unless options[:quiet]
385
+ puts "Collection unpacked successfully:"
386
+ puts " Input: #{result[:collection]}"
387
+ puts " Output directory: #{result[:output_dir]}"
388
+ puts " Fonts extracted: #{result[:fonts_extracted]}/#{result[:num_fonts]}"
389
+ result[:extracted_files].each do |file|
390
+ size = File.size(file)
391
+ puts " - #{File.basename(file)} (#{format_size(size)})"
392
+ end
393
+ end
394
+ rescue Errno::ENOENT, Error => e
395
+ handle_error(e)
396
+ end
397
+
398
+ desc "pack FONT_FILES...", "Pack multiple fonts into TTC/OTC collection"
399
+ option :output, type: :string, required: true,
400
+ desc: "Output collection file path",
401
+ aliases: "-o"
402
+ option :format, type: :string, default: "ttc",
403
+ desc: "Collection format (ttc, otc)",
404
+ aliases: "-f"
405
+ option :optimize, type: :boolean, default: true,
406
+ desc: "Enable table sharing optimization",
407
+ aliases: "--optimize"
408
+ option :analyze, type: :boolean, default: false,
409
+ desc: "Show analysis report before building",
410
+ aliases: "--analyze"
411
+ # Create a TTC (TrueType Collection) or OTC (OpenType Collection) from multiple font files.
412
+ #
413
+ # This command combines multiple fonts into a single collection file with
414
+ # shared table deduplication to save space. It supports both TTC and OTC formats.
415
+ #
416
+ # @param font_files [Array<String>] Paths to input font files (minimum 2 required)
417
+ #
418
+ # @example Pack fonts into TTC
419
+ # fontisan pack font1.ttf font2.ttf font3.ttf --output family.ttc
420
+ #
421
+ # @example Pack into OTC with analysis
422
+ # fontisan pack Regular.otf Bold.otf Italic.otf --output family.otc --analyze
423
+ #
424
+ # @example Pack without optimization
425
+ # fontisan pack font1.ttf font2.ttf --output collection.ttc --no-optimize
426
+ def pack(*font_files)
427
+ command = Commands::PackCommand.new(font_files, options)
428
+ result = command.run
429
+
430
+ unless options[:quiet]
431
+ puts "Collection created successfully:"
432
+ puts " Output: #{result[:output]}"
433
+ puts " Format: #{result[:format].upcase}"
434
+ puts " Fonts: #{result[:num_fonts]}"
435
+ puts " Size: #{format_size(result[:output_size])}"
436
+ if result[:space_savings].positive?
437
+ puts " Space saved: #{format_size(result[:space_savings])}"
438
+ puts " Sharing: #{result[:sharing_percentage]}%"
439
+ end
440
+ end
441
+ rescue Errno::ENOENT, Error => e
442
+ handle_error(e)
443
+ end
444
+
150
445
  private
151
446
 
152
447
  # Output the result in the requested format.
@@ -174,6 +469,20 @@ module Fontisan
174
469
  formatter.format(result)
175
470
  end
176
471
 
472
+ # Format file size in human-readable form
473
+ #
474
+ # @param size [Integer] Size in bytes
475
+ # @return [String] Formatted size
476
+ def format_size(size)
477
+ if size < 1024
478
+ "#{size} bytes"
479
+ elsif size < 1024 * 1024
480
+ "#{(size / 1024.0).round(2)} KB"
481
+ else
482
+ "#{(size / (1024.0 * 1024)).round(2)} MB"
483
+ end
484
+ end
485
+
177
486
  # Handle errors based on verbosity settings.
178
487
  #
179
488
  # @param error [Error, Errno::ENOENT] The error to handle
@@ -0,0 +1,260 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "table_analyzer"
4
+ require_relative "table_deduplicator"
5
+ require_relative "offset_calculator"
6
+ require_relative "writer"
7
+ require "yaml"
8
+
9
+ module Fontisan
10
+ module Collection
11
+ # CollectionBuilder orchestrates TTC/OTC creation
12
+ #
13
+ # Main responsibility: Coordinate the entire collection creation process
14
+ # including analysis, deduplication, offset calculation, and writing.
15
+ # Implements builder pattern for flexible configuration.
16
+ #
17
+ # @example Create TTC with default options
18
+ # builder = CollectionBuilder.new([font1, font2, font3])
19
+ # builder.build_to_file("family.ttc")
20
+ #
21
+ # @example Create OTC with optimization
22
+ # builder = CollectionBuilder.new([font1, font2, font3])
23
+ # builder.format = :otc
24
+ # builder.optimize = true
25
+ # result = builder.build
26
+ # puts "Saved #{result[:space_savings]} bytes"
27
+ class Builder
28
+ # Source fonts
29
+ # @return [Array<TrueTypeFont, OpenTypeFont>]
30
+ attr_reader :fonts
31
+
32
+ # Collection format (:ttc or :otc)
33
+ # @return [Symbol]
34
+ attr_accessor :format
35
+
36
+ # Enable table sharing optimization
37
+ # @return [Boolean]
38
+ attr_accessor :optimize
39
+
40
+ # Configuration settings
41
+ # @return [Hash]
42
+ attr_accessor :config
43
+
44
+ # Build result (populated after build)
45
+ # @return [Hash, nil]
46
+ attr_reader :result
47
+
48
+ # Initialize builder with fonts
49
+ #
50
+ # @param fonts [Array<TrueTypeFont, OpenTypeFont>] Fonts to pack
51
+ # @param options [Hash] Builder options
52
+ # @option options [Symbol] :format Format type (:ttc or :otc, default: :ttc)
53
+ # @option options [Boolean] :optimize Enable optimization (default: true)
54
+ # @option options [Hash] :config Configuration overrides
55
+ # @raise [ArgumentError] if fonts array is invalid
56
+ def initialize(fonts, options = {})
57
+ if fonts.nil? || fonts.empty?
58
+ raise ArgumentError,
59
+ "fonts cannot be nil or empty"
60
+ end
61
+ raise ArgumentError, "fonts must be an array" unless fonts.is_a?(Array)
62
+
63
+ unless fonts.all? do |f|
64
+ f.respond_to?(:table_data)
65
+ end
66
+ raise ArgumentError,
67
+ "all fonts must respond to table_data"
68
+ end
69
+
70
+ @fonts = fonts
71
+ @format = options[:format] || :ttc
72
+ @optimize = options.fetch(:optimize, true)
73
+ @config = load_config.merge(options[:config] || {})
74
+ @result = nil
75
+
76
+ validate_format!
77
+ end
78
+
79
+ # Build collection and return binary
80
+ #
81
+ # Executes the complete collection creation process:
82
+ # 1. Analyze tables across fonts
83
+ # 2. Deduplicate identical tables
84
+ # 3. Calculate file offsets
85
+ # 4. Write binary structure
86
+ #
87
+ # @return [Hash] Build result with:
88
+ # - :binary [String] - Complete collection binary
89
+ # - :space_savings [Integer] - Bytes saved by sharing
90
+ # - :analysis [Hash] - Analysis report
91
+ # - :statistics [Hash] - Deduplication statistics
92
+ def build
93
+ # Step 1: Analyze tables
94
+ analyzer = TableAnalyzer.new(@fonts)
95
+ analysis_report = analyzer.analyze
96
+
97
+ # Step 2: Deduplicate tables
98
+ deduplicator = TableDeduplicator.new(@fonts)
99
+ sharing_map = deduplicator.build_sharing_map
100
+ statistics = deduplicator.statistics
101
+
102
+ # Step 3: Calculate offsets
103
+ calculator = OffsetCalculator.new(sharing_map, @fonts)
104
+ offsets = calculator.calculate
105
+
106
+ # Step 4: Write collection
107
+ writer = Writer.new(@fonts, sharing_map, offsets, format: @format)
108
+ binary = writer.write_collection
109
+
110
+ # Store result
111
+ @result = {
112
+ binary: binary,
113
+ space_savings: analysis_report[:space_savings],
114
+ analysis: analysis_report,
115
+ statistics: statistics,
116
+ format: @format,
117
+ num_fonts: @fonts.size,
118
+ }
119
+
120
+ @result
121
+ end
122
+
123
+ # Build collection and write to file
124
+ #
125
+ # @param path [String] Output file path
126
+ # @return [Hash] Build result (same as build method)
127
+ def build_to_file(path)
128
+ result = build
129
+ File.binwrite(path, result[:binary])
130
+ result[:output_path] = path
131
+ result[:output_size] = result[:binary].bytesize
132
+ result
133
+ end
134
+
135
+ # Get analysis report
136
+ #
137
+ # Runs analysis without building the full collection.
138
+ # Useful for previewing space savings before committing to build.
139
+ #
140
+ # @return [Hash] Analysis report
141
+ def analyze
142
+ analyzer = TableAnalyzer.new(@fonts)
143
+ analyzer.analyze
144
+ end
145
+
146
+ # Get potential space savings without building
147
+ #
148
+ # @return [Integer] Bytes that can be saved
149
+ def potential_savings
150
+ analyze[:space_savings]
151
+ end
152
+
153
+ # Validate collection can be built
154
+ #
155
+ # @return [Boolean] true if valid, raises error otherwise
156
+ # @raise [Error] if validation fails
157
+ def validate!
158
+ # Check minimum fonts
159
+ raise Error, "Collection requires at least 2 fonts" if @fonts.size < 2
160
+
161
+ # Check format compatibility
162
+ incompatible = check_format_compatibility
163
+ if incompatible.any?
164
+ raise Error, "Format mismatch: #{incompatible.join(', ')}"
165
+ end
166
+
167
+ # Check all fonts have required tables
168
+ @fonts.each_with_index do |font, index|
169
+ required_tables = %w[head hhea maxp]
170
+ missing = required_tables.reject { |tag| font.has_table?(tag) }
171
+ unless missing.empty?
172
+ raise Error,
173
+ "Font #{index} missing required tables: #{missing.join(', ')}"
174
+ end
175
+ end
176
+
177
+ true
178
+ end
179
+
180
+ private
181
+
182
+ # Load configuration from file
183
+ #
184
+ # @return [Hash] Configuration hash
185
+ def load_config
186
+ config_path = File.join(__dir__, "..", "config",
187
+ "collection_settings.yml")
188
+ if File.exist?(config_path)
189
+ YAML.load_file(config_path)
190
+ else
191
+ default_config
192
+ end
193
+ rescue StandardError => e
194
+ warn "Failed to load config: #{e.message}, using defaults"
195
+ default_config
196
+ end
197
+
198
+ # Default configuration
199
+ #
200
+ # @return [Hash] Default settings
201
+ def default_config
202
+ {
203
+ "table_sharing_strategy" => "conservative",
204
+ "alignment" => 4,
205
+ "optimize_table_order" => true,
206
+ "verify_checksums" => true,
207
+ }
208
+ end
209
+
210
+ # Validate format is supported
211
+ #
212
+ # @return [void]
213
+ # @raise [ArgumentError] if format is invalid
214
+ def validate_format!
215
+ valid_formats = %i[ttc otc]
216
+ return if valid_formats.include?(@format)
217
+
218
+ raise ArgumentError,
219
+ "Invalid format: #{@format}. Must be one of: #{valid_formats.join(', ')}"
220
+ end
221
+
222
+ # Check if all fonts are compatible with selected format
223
+ #
224
+ # @return [Array<String>] Array of incompatibility messages
225
+ def check_format_compatibility
226
+ incompatible = []
227
+
228
+ if @format == :ttc
229
+ # TTC requires TrueType fonts (sfnt version 0x00010000 or 'true')
230
+ @fonts.each_with_index do |font, index|
231
+ sfnt = font.header.sfnt_version
232
+ unless [0x00010000, 0x74727565].include?(sfnt) # 0x74727565 = 'true'
233
+ incompatible << "Font #{index} is not TrueType (sfnt: 0x#{sfnt.to_s(16)})"
234
+ end
235
+ end
236
+ elsif @format == :otc
237
+ # OTC can contain both TrueType and OpenType/CFF fonts
238
+ # No strict validation needed, but warn about mixing
239
+ has_truetype = false
240
+ has_opentype = false
241
+
242
+ @fonts.each do |font|
243
+ sfnt = font.header.sfnt_version
244
+ if [0x00010000, 0x74727565].include?(sfnt)
245
+ has_truetype = true
246
+ elsif sfnt == 0x4F54544F # 'OTTO'
247
+ has_opentype = true
248
+ end
249
+ end
250
+
251
+ if has_truetype && has_opentype
252
+ warn "Warning: Mixing TrueType and OpenType/CFF fonts in OTC"
253
+ end
254
+ end
255
+
256
+ incompatible
257
+ end
258
+ end
259
+ end
260
+ end