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,306 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../constants"
4
+ require_relative "../utilities/checksum_calculator"
5
+
6
+ module Fontisan
7
+ module Collection
8
+ # CollectionWriter writes binary TTC/OTC files
9
+ #
10
+ # Single responsibility: Write complete TTC/OTC binary structure to disk
11
+ # including header, offset table, font directories, and table data.
12
+ # Handles checksums and proper binary formatting.
13
+ #
14
+ # @example Write collection
15
+ # writer = CollectionWriter.new(fonts, sharing_map, offsets)
16
+ # writer.write_to_file("output.ttc")
17
+ class Writer
18
+ # TTC signature
19
+ TTC_TAG = "ttcf"
20
+
21
+ # TTC version 1.0 (major=1, minor=0)
22
+ VERSION_1_0_MAJOR = 1
23
+ VERSION_1_0_MINOR = 0
24
+
25
+ # Initialize writer
26
+ #
27
+ # @param fonts [Array<TrueTypeFont, OpenTypeFont>] Source fonts
28
+ # @param sharing_map [Hash] Sharing map from TableDeduplicator
29
+ # @param offsets [Hash] Offset map from OffsetCalculator
30
+ # @param format [Symbol] Format type (:ttc or :otc)
31
+ # @raise [ArgumentError] if parameters are invalid
32
+ def initialize(fonts, sharing_map, offsets, format: :ttc)
33
+ if fonts.nil? || fonts.empty?
34
+ raise ArgumentError,
35
+ "fonts cannot be nil or empty"
36
+ end
37
+ raise ArgumentError, "sharing_map cannot be nil" if sharing_map.nil?
38
+ raise ArgumentError, "offsets cannot be nil" if offsets.nil?
39
+ raise ArgumentError, "format must be :ttc or :otc" unless %i[ttc
40
+ otc].include?(format)
41
+
42
+ @fonts = fonts
43
+ @sharing_map = sharing_map
44
+ @offsets = offsets
45
+ @format = format
46
+ end
47
+
48
+ # Write collection to file
49
+ #
50
+ # @param path [String] Output file path
51
+ # @return [Integer] Number of bytes written
52
+ def write_to_file(path)
53
+ binary = write_collection
54
+ File.binwrite(path, binary)
55
+ binary.bytesize
56
+ end
57
+
58
+ # Write collection to binary string
59
+ #
60
+ # @return [String] Complete collection binary
61
+ def write_collection
62
+ binary = String.new(encoding: Encoding::BINARY)
63
+
64
+ # Write TTC header
65
+ binary << write_ttc_header
66
+
67
+ # Write offset table (offsets to each font's directory)
68
+ binary << write_offset_table
69
+
70
+ # Write each font's table directory
71
+ @fonts.each_with_index do |font, font_index|
72
+ # Pad to expected offset
73
+ pad_to_offset(binary, @offsets[:font_directory_offsets][font_index])
74
+
75
+ # Write font directory
76
+ binary << write_font_directory(font, font_index)
77
+ end
78
+
79
+ # Write table data (shared tables first, then unique tables)
80
+ write_table_data(binary)
81
+
82
+ binary
83
+ end
84
+
85
+ private
86
+
87
+ # Write TTC header (12 bytes)
88
+ #
89
+ # Structure:
90
+ # - TAG: 'ttcf' (4 bytes)
91
+ # - Major version: 1 (2 bytes)
92
+ # - Minor version: 0 (2 bytes)
93
+ # - Number of fonts (4 bytes)
94
+ #
95
+ # @return [String] TTC header binary
96
+ def write_ttc_header
97
+ [
98
+ TTC_TAG, # char[4] - tag
99
+ VERSION_1_0_MAJOR, # uint16 - major version
100
+ VERSION_1_0_MINOR, # uint16 - minor version
101
+ @fonts.size, # uint32 - number of fonts
102
+ ].pack("a4 n n N")
103
+ end
104
+
105
+ # Write offset table
106
+ #
107
+ # Contains N uint32 values, one for each font, indicating the byte offset
108
+ # from the beginning of the file to that font's table directory.
109
+ #
110
+ # @return [String] Offset table binary
111
+ def write_offset_table
112
+ @offsets[:font_directory_offsets].pack("N*")
113
+ end
114
+
115
+ # Write font directory for a specific font
116
+ #
117
+ # Structure:
118
+ # - Font directory header (12 bytes: sfnt_version, num_tables, searchRange, entrySelector, rangeShift)
119
+ # - Table directory entries (16 bytes each: tag, checksum, offset, length)
120
+ #
121
+ # @param font [TrueTypeFont, OpenTypeFont] Font object
122
+ # @param font_index [Integer] Font index
123
+ # @return [String] Font directory binary
124
+ def write_font_directory(font, font_index)
125
+ binary = String.new(encoding: Encoding::BINARY)
126
+
127
+ # Get font's table tags
128
+ table_tags = font.table_names.sort
129
+
130
+ # Write directory header
131
+ binary << write_directory_header(font, table_tags.size)
132
+
133
+ # Write table directory entries
134
+ table_tags.each do |tag|
135
+ binary << write_table_directory_entry(font_index, tag)
136
+ end
137
+
138
+ binary
139
+ end
140
+
141
+ # Write font directory header
142
+ #
143
+ # @param font [TrueTypeFont, OpenTypeFont] Font object
144
+ # @param num_tables [Integer] Number of tables
145
+ # @return [String] Directory header binary
146
+ def write_directory_header(font, num_tables)
147
+ # Get sfnt version from font
148
+ sfnt_version = font.header.sfnt_version
149
+
150
+ # Calculate search parameters
151
+ search_params = calculate_search_params(num_tables)
152
+
153
+ [
154
+ sfnt_version, # uint32 - sfnt version
155
+ num_tables, # uint16 - number of tables
156
+ search_params[:search_range], # uint16 - search range
157
+ search_params[:entry_selector], # uint16 - entry selector
158
+ search_params[:range_shift], # uint16 - range shift
159
+ ].pack("N n n n n")
160
+ end
161
+
162
+ # Write table directory entry
163
+ #
164
+ # @param font_index [Integer] Font index
165
+ # @param tag [String] Table tag
166
+ # @return [String] Table directory entry binary
167
+ def write_table_directory_entry(font_index, tag)
168
+ # Get canonical table info from sharing map
169
+ table_info = @sharing_map[font_index][tag]
170
+ canonical_id = table_info[:canonical_id]
171
+
172
+ # Get table offset from offset map
173
+ table_offset = @offsets[:table_offsets][canonical_id]
174
+
175
+ # Calculate checksum
176
+ checksum = calculate_table_checksum(table_info[:data])
177
+
178
+ [
179
+ tag, # char[4] - table tag
180
+ checksum, # uint32 - checksum
181
+ table_offset, # uint32 - offset
182
+ table_info[:size], # uint32 - length
183
+ ].pack("a4 N N N")
184
+ end
185
+
186
+ # Write all table data
187
+ #
188
+ # Writes shared tables first (once each), then unique tables
189
+ # (once per font). Tables are written at their calculated offsets
190
+ # with proper alignment.
191
+ #
192
+ # @param binary [String] Binary string to append to
193
+ # @return [void]
194
+ def write_table_data(binary)
195
+ # Collect all canonical tables with their offsets
196
+ tables_by_offset = {}
197
+
198
+ @offsets[:table_offsets].each do |canonical_id, offset|
199
+ # Find the table data from sharing map
200
+ table_data = find_canonical_table_data(canonical_id)
201
+
202
+ tables_by_offset[offset] = {
203
+ canonical_id: canonical_id,
204
+ data: table_data,
205
+ }
206
+ end
207
+
208
+ # Write tables in order of their offsets
209
+ tables_by_offset.keys.sort.each do |offset|
210
+ table_info = tables_by_offset[offset]
211
+
212
+ # Pad to expected offset
213
+ pad_to_offset(binary, offset)
214
+
215
+ # Write table data
216
+ binary << table_info[:data]
217
+
218
+ # Pad to 4-byte boundary
219
+ padding = calculate_padding(table_info[:data].bytesize)
220
+ binary << ("\x00" * padding) if padding.positive?
221
+ end
222
+ end
223
+
224
+ # Find canonical table data by ID
225
+ #
226
+ # @param canonical_id [String] Canonical table ID
227
+ # @return [String] Table data
228
+ def find_canonical_table_data(canonical_id)
229
+ @sharing_map.each_value do |tables|
230
+ tables.each_value do |info|
231
+ return info[:data] if info[:canonical_id] == canonical_id
232
+ end
233
+ end
234
+
235
+ raise "Canonical table not found: #{canonical_id}"
236
+ end
237
+
238
+ # Pad binary to specific offset
239
+ #
240
+ # @param binary [String] Binary string to pad
241
+ # @param target_offset [Integer] Target offset
242
+ # @return [void]
243
+ def pad_to_offset(binary, target_offset)
244
+ current_size = binary.bytesize
245
+ return if current_size >= target_offset
246
+
247
+ padding_needed = target_offset - current_size
248
+ binary << ("\x00" * padding_needed)
249
+ end
250
+
251
+ # Calculate padding needed for 4-byte alignment
252
+ #
253
+ # @param size [Integer] Current size
254
+ # @return [Integer] Padding bytes needed
255
+ def calculate_padding(size)
256
+ remainder = size % 4
257
+ return 0 if remainder.zero?
258
+
259
+ 4 - remainder
260
+ end
261
+
262
+ # Calculate table checksum
263
+ #
264
+ # @param data [String] Table data
265
+ # @return [Integer] Checksum
266
+ def calculate_table_checksum(data)
267
+ # Pad to 4-byte boundary
268
+ padded_data = data.dup
269
+ padding_length = calculate_padding(data.bytesize)
270
+ padded_data << ("\x00" * padding_length) if padding_length.positive?
271
+
272
+ # Sum all uint32 values
273
+ sum = 0
274
+ (0...padded_data.bytesize).step(4) do |i|
275
+ value = padded_data[i, 4].unpack1("N")
276
+ sum = (sum + value) & 0xFFFFFFFF
277
+ end
278
+
279
+ sum
280
+ end
281
+
282
+ # Calculate search parameters for directory header
283
+ #
284
+ # @param num_tables [Integer] Number of tables
285
+ # @return [Hash] Search parameters
286
+ def calculate_search_params(num_tables)
287
+ max_power = 0
288
+ n = num_tables
289
+ while n > 1
290
+ n >>= 1
291
+ max_power += 1
292
+ end
293
+
294
+ search_range = (1 << max_power) * 16
295
+ entry_selector = max_power
296
+ range_shift = (num_tables * 16) - search_range
297
+
298
+ {
299
+ search_range: search_range,
300
+ entry_selector: entry_selector,
301
+ range_shift: range_shift,
302
+ }
303
+ end
304
+ end
305
+ end
306
+ end
@@ -66,7 +66,14 @@ module Fontisan
66
66
  # @raise [InvalidFontError] for corrupted or unknown formats
67
67
  # @raise [Error] for other loading failures
68
68
  def load_font
69
- FontLoader.load(@font_path, font_index: @options[:font_index] || 0)
69
+ # ConvertCommand and similar commands need all tables loaded upfront
70
+ # Use mode and lazy from options, or sensible defaults
71
+ FontLoader.load(
72
+ @font_path,
73
+ font_index: @options[:font_index] || 0,
74
+ mode: @options[:mode] || LoadingModes::FULL,
75
+ lazy: @options.key?(:lazy) ? @options[:lazy] : false
76
+ )
70
77
  rescue Errno::ENOENT
71
78
  # Re-raise file not found as-is
72
79
  raise
@@ -0,0 +1,291 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_command"
4
+ require_relative "../converters/format_converter"
5
+ require_relative "../font_writer"
6
+
7
+ module Fontisan
8
+ module Commands
9
+ # Command for converting fonts between formats
10
+ #
11
+ # [`ConvertCommand`](lib/fontisan/commands/convert_command.rb) provides
12
+ # CLI interface for font format conversion operations. It supports:
13
+ # - Same-format operations (copy/optimize)
14
+ # - TTF ↔ OTF outline format conversion (foundation)
15
+ # - Future: WOFF/WOFF2 compression, SVG export
16
+ #
17
+ # The command uses [`FormatConverter`](lib/fontisan/converters/format_converter.rb)
18
+ # to orchestrate conversions with appropriate strategies.
19
+ #
20
+ # @example Convert TTF to OTF
21
+ # command = ConvertCommand.new(
22
+ # 'input.ttf',
23
+ # to: 'otf',
24
+ # output: 'output.otf'
25
+ # )
26
+ # command.run
27
+ #
28
+ # @example Copy/optimize same format
29
+ # command = ConvertCommand.new(
30
+ # 'input.ttf',
31
+ # to: 'ttf',
32
+ # output: 'optimized.ttf'
33
+ # )
34
+ # command.run
35
+ class ConvertCommand < BaseCommand
36
+ # Initialize convert command
37
+ #
38
+ # @param font_path [String] Path to input font file
39
+ # @param options [Hash] Conversion options
40
+ # @option options [String] :to Target format (ttf, otf, woff2, svg)
41
+ # @option options [String] :output Output file path (required)
42
+ # @option options [Integer] :font_index Index for TTC/OTC (default: 0)
43
+ # @option options [Boolean] :optimize Enable subroutine optimization (TTF→OTF only)
44
+ # @option options [Integer] :min_pattern_length Minimum pattern length for subroutines
45
+ # @option options [Integer] :max_subroutines Maximum number of subroutines
46
+ # @option options [Boolean] :optimize_ordering Optimize subroutine ordering
47
+ def initialize(font_path, options = {})
48
+ super(font_path, options)
49
+ @target_format = parse_target_format(options[:to])
50
+ @output_path = options[:output]
51
+ @converter = Converters::FormatConverter.new
52
+
53
+ # Optimization options
54
+ @optimize = options[:optimize] || false
55
+ @min_pattern_length = options[:min_pattern_length] || 10
56
+ @max_subroutines = options[:max_subroutines] || 65_535
57
+ @optimize_ordering = options[:optimize_ordering] != false
58
+ end
59
+
60
+ # Execute the conversion
61
+ #
62
+ # @return [Hash] Result information
63
+ # @raise [ArgumentError] If output path is not specified
64
+ # @raise [Error] If conversion fails
65
+ def run
66
+ validate_options!
67
+
68
+ puts "Converting #{File.basename(font_path)} to #{@target_format}..."
69
+
70
+ # Build converter options
71
+ converter_options = {
72
+ target_format: @target_format,
73
+ optimize_subroutines: @optimize,
74
+ min_pattern_length: @min_pattern_length,
75
+ max_subroutines: @max_subroutines,
76
+ optimize_ordering: @optimize_ordering,
77
+ verbose: options[:verbose],
78
+ }
79
+
80
+ # Perform conversion with options
81
+ result = @converter.convert(font, @target_format, converter_options)
82
+
83
+ # Handle special formats that return complete binary/text
84
+ if @target_format == :woff && result.is_a?(String)
85
+ # WOFF returns complete binary
86
+ File.binwrite(@output_path, result)
87
+ elsif @target_format == :woff2 && result.is_a?(Hash) && result[:woff2_binary]
88
+ File.binwrite(@output_path, result[:woff2_binary])
89
+ elsif @target_format == :svg && result.is_a?(Hash) && result[:svg_xml]
90
+ File.write(@output_path, result[:svg_xml])
91
+ else
92
+ # Standard table-based conversion
93
+ tables = result
94
+
95
+ # Determine sfnt version for output
96
+ sfnt_version = determine_sfnt_version(@target_format)
97
+
98
+ # Write output font
99
+ FontWriter.write_to_file(tables, @output_path,
100
+ sfnt_version: sfnt_version)
101
+
102
+ # Display optimization results if available
103
+ display_optimization_results(tables) if @optimize && options[:verbose]
104
+ end
105
+
106
+ output_size = File.size(@output_path)
107
+ input_size = File.size(font_path)
108
+
109
+ puts "Conversion complete!"
110
+ puts " Input: #{font_path} (#{format_size(input_size)})"
111
+ puts " Output: #{@output_path} (#{format_size(output_size)})"
112
+
113
+ {
114
+ success: true,
115
+ input_path: font_path,
116
+ output_path: @output_path,
117
+ source_format: detect_source_format,
118
+ target_format: @target_format,
119
+ input_size: input_size,
120
+ output_size: output_size,
121
+ }
122
+ rescue NotImplementedError
123
+ # Let NotImplementedError propagate for tests that expect it
124
+ raise
125
+ rescue Converters::ConversionStrategy => e
126
+ handle_conversion_error(e)
127
+ rescue ArgumentError
128
+ # Let ArgumentError propagate for validation errors
129
+ raise
130
+ rescue StandardError => e
131
+ raise Error, "Conversion failed: #{e.message}"
132
+ end
133
+
134
+ # Get list of supported conversions
135
+ #
136
+ # @return [Array<Hash>] List of supported conversions
137
+ def self.supported_conversions
138
+ converter = Converters::FormatConverter.new
139
+ converter.all_conversions
140
+ end
141
+
142
+ # Check if a conversion is supported
143
+ #
144
+ # @param source [Symbol] Source format
145
+ # @param target [Symbol] Target format
146
+ # @return [Boolean] True if supported
147
+ def self.supported?(source, target)
148
+ converter = Converters::FormatConverter.new
149
+ converter.supported?(source, target)
150
+ end
151
+
152
+ private
153
+
154
+ # Validate command options
155
+ #
156
+ # @raise [ArgumentError] If required options are missing
157
+ def validate_options!
158
+ unless @output_path
159
+ raise ArgumentError,
160
+ "Output path is required. Use --output option."
161
+ end
162
+
163
+ unless @target_format
164
+ raise ArgumentError,
165
+ "Target format is required. Use --to option."
166
+ end
167
+
168
+ # Check if conversion is supported
169
+ source_format = detect_source_format
170
+ unless @converter.supported?(source_format, @target_format)
171
+ available = @converter.supported_targets(source_format)
172
+ message = "Conversion from #{source_format} to #{@target_format} " \
173
+ "is not supported."
174
+ if available.any?
175
+ message += " Available targets: #{available.join(', ')}"
176
+ end
177
+ raise ArgumentError, message
178
+ end
179
+ end
180
+
181
+ # Parse target format from string/symbol
182
+ #
183
+ # @param format [String, Symbol, nil] Target format
184
+ # @return [Symbol, nil] Parsed format symbol
185
+ def parse_target_format(format)
186
+ return nil if format.nil?
187
+
188
+ format_str = format.to_s.downcase
189
+ case format_str
190
+ when "ttf", "truetype"
191
+ :ttf
192
+ when "otf", "opentype", "cff"
193
+ :otf
194
+ when "woff"
195
+ :woff
196
+ when "woff2"
197
+ :woff2
198
+ when "svg"
199
+ :svg
200
+ else
201
+ raise ArgumentError,
202
+ "Unknown target format: #{format}. " \
203
+ "Supported: ttf, otf, woff2, svg"
204
+ end
205
+ end
206
+
207
+ # Detect source font format
208
+ #
209
+ # @return [Symbol] Source format
210
+ def detect_source_format
211
+ # Check for CFF/CFF2 tables (OpenType/CFF)
212
+ if font.has_table?("CFF ") || font.has_table?("CFF2")
213
+ :otf
214
+ # Check for glyf table (TrueType)
215
+ elsif font.has_table?("glyf")
216
+ :ttf
217
+ else
218
+ :unknown
219
+ end
220
+ end
221
+
222
+ # Determine sfnt version for target format
223
+ #
224
+ # @param format [Symbol] Target format
225
+ # @return [Integer] sfnt version
226
+ def determine_sfnt_version(format)
227
+ case format
228
+ when :otf
229
+ 0x4F54544F # 'OTTO' for OpenType/CFF
230
+ when :ttf
231
+ 0x00010000 # 1.0 for TrueType
232
+ else
233
+ 0x00010000 # Default to TrueType
234
+ end
235
+ end
236
+
237
+ # Format file size for display
238
+ #
239
+ # @param bytes [Integer] Size in bytes
240
+ # @return [String] Formatted size
241
+ def format_size(bytes)
242
+ if bytes < 1024
243
+ "#{bytes} bytes"
244
+ elsif bytes < 1024 * 1024
245
+ "#{(bytes / 1024.0).round(1)} KB"
246
+ else
247
+ "#{(bytes / (1024.0 * 1024)).round(1)} MB"
248
+ end
249
+ end
250
+
251
+ # Handle conversion errors with helpful messages
252
+ #
253
+ # @param error [StandardError] The error that occurred
254
+ # @raise [Error] Wrapped error with helpful message
255
+ def handle_conversion_error(error)
256
+ message = "Conversion failed: #{error.message}"
257
+
258
+ # Add helpful hints based on error type
259
+ if error.is_a?(NotImplementedError)
260
+ message += "\n\nNote: Some conversions are not yet fully " \
261
+ "implemented. Check the conversion matrix configuration " \
262
+ "for implementation status."
263
+ end
264
+
265
+ raise Error, message
266
+ end
267
+
268
+ # Display optimization results from subroutine generation
269
+ #
270
+ # @param tables [Hash] Table data with optimization metadata
271
+ def display_optimization_results(tables)
272
+ optimization = tables.instance_variable_get(:@subroutine_optimization)
273
+ return unless optimization
274
+
275
+ puts "\n=== Subroutine Optimization Results ==="
276
+ puts " Patterns found: #{optimization[:pattern_count]}"
277
+ puts " Patterns selected: #{optimization[:selected_count]}"
278
+ puts " Subroutines generated: #{optimization[:local_subrs].length}"
279
+ puts " Estimated bytes saved: #{optimization[:savings]}"
280
+ puts " CFF bias: #{optimization[:bias]}"
281
+
282
+ if optimization[:selected_count].zero?
283
+ puts " Note: No beneficial patterns found for optimization"
284
+ elsif optimization[:savings].positive?
285
+ savings_kb = (optimization[:savings] / 1024.0).round(1)
286
+ puts " Estimated space savings: #{savings_kb} KB"
287
+ end
288
+ end
289
+ end
290
+ end
291
+ end