fontisan 0.1.0 → 0.2.1

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 (214) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +672 -69
  3. data/Gemfile +1 -0
  4. data/LICENSE +5 -1
  5. data/README.adoc +1477 -297
  6. data/Rakefile +63 -41
  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 +364 -4
  12. data/lib/fontisan/collection/builder.rb +341 -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 +317 -0
  16. data/lib/fontisan/collection/writer.rb +306 -0
  17. data/lib/fontisan/commands/base_command.rb +24 -1
  18. data/lib/fontisan/commands/convert_command.rb +218 -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 +286 -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 +203 -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 +79 -0
  37. data/lib/fontisan/converters/conversion_strategy.rb +96 -0
  38. data/lib/fontisan/converters/format_converter.rb +408 -0
  39. data/lib/fontisan/converters/outline_converter.rb +998 -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 +122 -15
  57. data/lib/fontisan/font_writer.rb +302 -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 +310 -0
  61. data/lib/fontisan/hints/postscript_hint_applier.rb +266 -0
  62. data/lib/fontisan/hints/postscript_hint_extractor.rb +354 -0
  63. data/lib/fontisan/hints/truetype_hint_applier.rb +117 -0
  64. data/lib/fontisan/hints/truetype_hint_extractor.rb +289 -0
  65. data/lib/fontisan/loading_modes.rb +115 -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 +405 -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 +321 -19
  88. data/lib/fontisan/open_type_font_extensions.rb +54 -0
  89. data/lib/fontisan/optimizers/charstring_rewriter.rb +161 -0
  90. data/lib/fontisan/optimizers/pattern_analyzer.rb +308 -0
  91. data/lib/fontisan/optimizers/stack_tracker.rb +246 -0
  92. data/lib/fontisan/optimizers/subroutine_builder.rb +134 -0
  93. data/lib/fontisan/optimizers/subroutine_generator.rb +207 -0
  94. data/lib/fontisan/optimizers/subroutine_optimizer.rb +107 -0
  95. data/lib/fontisan/outline_extractor.rb +423 -0
  96. data/lib/fontisan/pipeline/format_detector.rb +249 -0
  97. data/lib/fontisan/pipeline/output_writer.rb +154 -0
  98. data/lib/fontisan/pipeline/strategies/base_strategy.rb +75 -0
  99. data/lib/fontisan/pipeline/strategies/instance_strategy.rb +93 -0
  100. data/lib/fontisan/pipeline/strategies/named_strategy.rb +118 -0
  101. data/lib/fontisan/pipeline/strategies/preserve_strategy.rb +56 -0
  102. data/lib/fontisan/pipeline/transformation_pipeline.rb +411 -0
  103. data/lib/fontisan/pipeline/variation_resolver.rb +165 -0
  104. data/lib/fontisan/subset/builder.rb +268 -0
  105. data/lib/fontisan/subset/glyph_mapping.rb +215 -0
  106. data/lib/fontisan/subset/options.rb +142 -0
  107. data/lib/fontisan/subset/profile.rb +152 -0
  108. data/lib/fontisan/subset/table_subsetter.rb +461 -0
  109. data/lib/fontisan/svg/font_face_generator.rb +278 -0
  110. data/lib/fontisan/svg/font_generator.rb +264 -0
  111. data/lib/fontisan/svg/glyph_generator.rb +168 -0
  112. data/lib/fontisan/svg/view_box_calculator.rb +137 -0
  113. data/lib/fontisan/tables/cff/cff_glyph.rb +176 -0
  114. data/lib/fontisan/tables/cff/charset.rb +282 -0
  115. data/lib/fontisan/tables/cff/charstring.rb +934 -0
  116. data/lib/fontisan/tables/cff/charstring_builder.rb +356 -0
  117. data/lib/fontisan/tables/cff/charstring_parser.rb +237 -0
  118. data/lib/fontisan/tables/cff/charstring_rebuilder.rb +172 -0
  119. data/lib/fontisan/tables/cff/charstrings_index.rb +162 -0
  120. data/lib/fontisan/tables/cff/dict.rb +351 -0
  121. data/lib/fontisan/tables/cff/dict_builder.rb +257 -0
  122. data/lib/fontisan/tables/cff/encoding.rb +274 -0
  123. data/lib/fontisan/tables/cff/header.rb +102 -0
  124. data/lib/fontisan/tables/cff/hint_operation_injector.rb +207 -0
  125. data/lib/fontisan/tables/cff/index.rb +237 -0
  126. data/lib/fontisan/tables/cff/index_builder.rb +170 -0
  127. data/lib/fontisan/tables/cff/offset_recalculator.rb +70 -0
  128. data/lib/fontisan/tables/cff/private_dict.rb +284 -0
  129. data/lib/fontisan/tables/cff/private_dict_writer.rb +125 -0
  130. data/lib/fontisan/tables/cff/table_builder.rb +221 -0
  131. data/lib/fontisan/tables/cff/top_dict.rb +236 -0
  132. data/lib/fontisan/tables/cff.rb +489 -0
  133. data/lib/fontisan/tables/cff2/blend_operator.rb +240 -0
  134. data/lib/fontisan/tables/cff2/charstring_parser.rb +591 -0
  135. data/lib/fontisan/tables/cff2/operand_stack.rb +232 -0
  136. data/lib/fontisan/tables/cff2/private_dict_blend_handler.rb +246 -0
  137. data/lib/fontisan/tables/cff2/region_matcher.rb +200 -0
  138. data/lib/fontisan/tables/cff2/table_builder.rb +574 -0
  139. data/lib/fontisan/tables/cff2/table_reader.rb +419 -0
  140. data/lib/fontisan/tables/cff2/variation_data_extractor.rb +212 -0
  141. data/lib/fontisan/tables/cff2.rb +346 -0
  142. data/lib/fontisan/tables/cvar.rb +203 -0
  143. data/lib/fontisan/tables/fvar.rb +2 -2
  144. data/lib/fontisan/tables/glyf/compound_glyph.rb +483 -0
  145. data/lib/fontisan/tables/glyf/compound_glyph_resolver.rb +136 -0
  146. data/lib/fontisan/tables/glyf/curve_converter.rb +343 -0
  147. data/lib/fontisan/tables/glyf/glyph_builder.rb +450 -0
  148. data/lib/fontisan/tables/glyf/simple_glyph.rb +382 -0
  149. data/lib/fontisan/tables/glyf.rb +235 -0
  150. data/lib/fontisan/tables/gvar.rb +231 -0
  151. data/lib/fontisan/tables/hhea.rb +124 -0
  152. data/lib/fontisan/tables/hmtx.rb +287 -0
  153. data/lib/fontisan/tables/hvar.rb +191 -0
  154. data/lib/fontisan/tables/loca.rb +322 -0
  155. data/lib/fontisan/tables/maxp.rb +192 -0
  156. data/lib/fontisan/tables/mvar.rb +185 -0
  157. data/lib/fontisan/tables/name.rb +99 -30
  158. data/lib/fontisan/tables/variation_common.rb +346 -0
  159. data/lib/fontisan/tables/vvar.rb +234 -0
  160. data/lib/fontisan/true_type_collection.rb +156 -2
  161. data/lib/fontisan/true_type_font.rb +321 -20
  162. data/lib/fontisan/true_type_font_extensions.rb +54 -0
  163. data/lib/fontisan/utilities/brotli_wrapper.rb +159 -0
  164. data/lib/fontisan/utilities/checksum_calculator.rb +60 -0
  165. data/lib/fontisan/utils/thread_pool.rb +134 -0
  166. data/lib/fontisan/validation/checksum_validator.rb +170 -0
  167. data/lib/fontisan/validation/consistency_validator.rb +197 -0
  168. data/lib/fontisan/validation/structure_validator.rb +198 -0
  169. data/lib/fontisan/validation/table_validator.rb +158 -0
  170. data/lib/fontisan/validation/validator.rb +152 -0
  171. data/lib/fontisan/validation/variable_font_validator.rb +218 -0
  172. data/lib/fontisan/variable/axis_normalizer.rb +215 -0
  173. data/lib/fontisan/variable/delta_applicator.rb +313 -0
  174. data/lib/fontisan/variable/glyph_delta_processor.rb +218 -0
  175. data/lib/fontisan/variable/instancer.rb +344 -0
  176. data/lib/fontisan/variable/metric_delta_processor.rb +282 -0
  177. data/lib/fontisan/variable/region_matcher.rb +208 -0
  178. data/lib/fontisan/variable/static_font_builder.rb +213 -0
  179. data/lib/fontisan/variable/table_updater.rb +219 -0
  180. data/lib/fontisan/variation/blend_applier.rb +199 -0
  181. data/lib/fontisan/variation/cache.rb +298 -0
  182. data/lib/fontisan/variation/cache_key_builder.rb +162 -0
  183. data/lib/fontisan/variation/converter.rb +375 -0
  184. data/lib/fontisan/variation/data_extractor.rb +86 -0
  185. data/lib/fontisan/variation/delta_applier.rb +266 -0
  186. data/lib/fontisan/variation/delta_parser.rb +228 -0
  187. data/lib/fontisan/variation/inspector.rb +275 -0
  188. data/lib/fontisan/variation/instance_generator.rb +273 -0
  189. data/lib/fontisan/variation/instance_writer.rb +341 -0
  190. data/lib/fontisan/variation/interpolator.rb +231 -0
  191. data/lib/fontisan/variation/metrics_adjuster.rb +318 -0
  192. data/lib/fontisan/variation/optimizer.rb +418 -0
  193. data/lib/fontisan/variation/parallel_generator.rb +150 -0
  194. data/lib/fontisan/variation/region_matcher.rb +221 -0
  195. data/lib/fontisan/variation/subsetter.rb +463 -0
  196. data/lib/fontisan/variation/table_accessor.rb +105 -0
  197. data/lib/fontisan/variation/tuple_variation_header.rb +51 -0
  198. data/lib/fontisan/variation/validator.rb +345 -0
  199. data/lib/fontisan/variation/variable_svg_generator.rb +268 -0
  200. data/lib/fontisan/variation/variation_context.rb +211 -0
  201. data/lib/fontisan/variation/variation_preserver.rb +288 -0
  202. data/lib/fontisan/version.rb +1 -1
  203. data/lib/fontisan/version.rb.orig +9 -0
  204. data/lib/fontisan/woff2/directory.rb +257 -0
  205. data/lib/fontisan/woff2/glyf_transformer.rb +666 -0
  206. data/lib/fontisan/woff2/header.rb +101 -0
  207. data/lib/fontisan/woff2/hmtx_transformer.rb +164 -0
  208. data/lib/fontisan/woff2/table_transformer.rb +163 -0
  209. data/lib/fontisan/woff2_font.rb +717 -0
  210. data/lib/fontisan/woff_font.rb +488 -0
  211. data/lib/fontisan.rb +132 -0
  212. data/scripts/compare_stack_aware.rb +187 -0
  213. data/scripts/measure_optimization.rb +141 -0
  214. metadata +234 -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,30 @@ 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
+ # BaseCommand is for inspection - reject compressed formats first
70
+ # Check file signature before attempting to load
71
+ File.open(@font_path, "rb") do |io|
72
+ signature = io.read(4)
73
+
74
+ if signature == "wOFF"
75
+ raise UnsupportedFormatError,
76
+ "Unsupported font format: WOFF files must be decompressed first. " \
77
+ "Use ConvertCommand to convert WOFF to TTF/OTF."
78
+ elsif signature == "wOF2"
79
+ raise UnsupportedFormatError,
80
+ "Unsupported font format: WOFF2 files must be decompressed first. " \
81
+ "Use ConvertCommand to convert WOFF2 to TTF/OTF."
82
+ end
83
+ end
84
+
85
+ # ConvertCommand and similar commands need all tables loaded upfront
86
+ # Use mode and lazy from options, or sensible defaults
87
+ FontLoader.load(
88
+ @font_path,
89
+ font_index: @options[:font_index] || 0,
90
+ mode: @options[:mode] || LoadingModes::FULL,
91
+ lazy: @options.key?(:lazy) ? @options[:lazy] : false
92
+ )
70
93
  rescue Errno::ENOENT
71
94
  # Re-raise file not found as-is
72
95
  raise
@@ -0,0 +1,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_command"
4
+ require_relative "../pipeline/transformation_pipeline"
5
+
6
+ module Fontisan
7
+ module Commands
8
+ # Command for converting fonts between formats
9
+ #
10
+ # [`ConvertCommand`](lib/fontisan/commands/convert_command.rb) provides
11
+ # CLI interface for font format conversion operations using the universal
12
+ # transformation pipeline. It supports:
13
+ # - Same-format operations (copy/optimize)
14
+ # - TTF ↔ OTF outline format conversion
15
+ # - Variable font operations (preserve/instance generation)
16
+ # - WOFF/WOFF2 compression
17
+ #
18
+ # The command uses [`TransformationPipeline`](lib/fontisan/pipeline/transformation_pipeline.rb)
19
+ # to orchestrate conversions with appropriate strategies.
20
+ #
21
+ # @example Convert TTF to OTF
22
+ # command = ConvertCommand.new(
23
+ # 'input.ttf',
24
+ # to: 'otf',
25
+ # output: 'output.otf'
26
+ # )
27
+ # command.run
28
+ #
29
+ # @example Generate instance at coordinates
30
+ # command = ConvertCommand.new(
31
+ # 'variable.ttf',
32
+ # to: 'ttf',
33
+ # output: 'bold.ttf',
34
+ # coordinates: 'wght=700,wdth=100'
35
+ # )
36
+ # command.run
37
+ class ConvertCommand < BaseCommand
38
+ # Initialize convert command
39
+ #
40
+ # @param font_path [String] Path to input font file
41
+ # @param options [Hash] Conversion options
42
+ # @option options [String] :to Target format (ttf, otf, woff, woff2)
43
+ # @option options [String] :output Output file path (required)
44
+ # @option options [Integer] :font_index Index for TTC/OTC (default: 0)
45
+ # @option options [String] :coordinates Coordinate string (e.g., "wght=700,wdth=100")
46
+ # @option options [Hash] :instance_coordinates Axis coordinates hash (e.g., {"wght" => 700.0})
47
+ # @option options [Integer] :instance_index Named instance index
48
+ # @option options [Boolean] :preserve_variation Preserve variation data (default: auto)
49
+ # @option options [Boolean] :preserve_hints Preserve rendering hints (default: false)
50
+ # @option options [Boolean] :no_validate Skip output validation
51
+ # @option options [Boolean] :verbose Verbose output
52
+ def initialize(font_path, options = {})
53
+ super(font_path, options)
54
+ @output_path = options[:output]
55
+
56
+ # Parse target format
57
+ @target_format = parse_target_format(options[:to])
58
+
59
+ # Parse coordinates if string provided
60
+ @coordinates = if options[:coordinates]
61
+ parse_coordinates(options[:coordinates])
62
+ elsif options[:instance_coordinates]
63
+ options[:instance_coordinates]
64
+ end
65
+
66
+ @instance_index = options[:instance_index]
67
+ @preserve_variation = options[:preserve_variation]
68
+ @preserve_hints = options.fetch(:preserve_hints, false)
69
+ @validate = !options[:no_validate]
70
+ end
71
+
72
+ # Execute the conversion
73
+ #
74
+ # @return [Hash] Result information
75
+ # @raise [ArgumentError] If output path is not specified
76
+ # @raise [Error] If conversion fails
77
+ def run
78
+ validate_options!
79
+
80
+ puts "Converting #{File.basename(font_path)} to #{@target_format}..." unless @options[:quiet]
81
+
82
+ # Build pipeline options
83
+ pipeline_options = {
84
+ target_format: @target_format,
85
+ validate: @validate,
86
+ verbose: @options[:verbose],
87
+ }
88
+
89
+ # Add variation options if specified
90
+ pipeline_options[:coordinates] = @coordinates if @coordinates
91
+ pipeline_options[:instance_index] = @instance_index if @instance_index
92
+ pipeline_options[:preserve_variation] = @preserve_variation unless @preserve_variation.nil?
93
+
94
+ # Add hint preservation option
95
+ pipeline_options[:preserve_hints] = @preserve_hints if @preserve_hints
96
+
97
+ # Use TransformationPipeline for universal conversion
98
+ pipeline = Pipeline::TransformationPipeline.new(
99
+ font_path,
100
+ @output_path,
101
+ pipeline_options,
102
+ )
103
+
104
+ result = pipeline.transform
105
+
106
+ # Display results
107
+ unless @options[:quiet]
108
+ output_size = File.size(@output_path)
109
+ input_size = File.size(font_path)
110
+
111
+ puts "Conversion complete!"
112
+ puts " Input: #{font_path} (#{format_size(input_size)})"
113
+ puts " Output: #{@output_path} (#{format_size(output_size)})"
114
+ puts " Format: #{result[:details][:source_format]} → #{result[:details][:target_format]}"
115
+
116
+ if result[:details][:variation_preserved]
117
+ puts " Variation: Preserved (#{result[:details][:variation_strategy]})"
118
+ elsif result[:details][:variation_strategy] != :preserve
119
+ puts " Variation: Instance generated (#{result[:details][:variation_strategy]})"
120
+ end
121
+ end
122
+
123
+ {
124
+ success: true,
125
+ input_path: font_path,
126
+ output_path: @output_path,
127
+ source_format: result[:details][:source_format],
128
+ target_format: result[:details][:target_format],
129
+ input_size: File.size(font_path),
130
+ output_size: File.size(@output_path),
131
+ variation_strategy: result[:details][:variation_strategy],
132
+ }
133
+ rescue ArgumentError
134
+ # Let ArgumentError propagate for validation errors
135
+ raise
136
+ rescue StandardError => e
137
+ raise Error, "Conversion failed: #{e.message}"
138
+ end
139
+
140
+ private
141
+
142
+ # Parse coordinates string to hash
143
+ #
144
+ # Parses strings like "wght=700,wdth=100" into {"wght" => 700.0, "wdth" => 100.0}
145
+ #
146
+ # @param coord_string [String] Coordinate string
147
+ # @return [Hash] Parsed coordinates
148
+ def parse_coordinates(coord_string)
149
+ coords = {}
150
+ coord_string.split(",").each do |pair|
151
+ key, value = pair.split("=")
152
+ next unless key && value
153
+
154
+ coords[key.strip] = value.to_f
155
+ end
156
+ coords
157
+ rescue StandardError => e
158
+ raise ArgumentError, "Invalid coordinates format '#{coord_string}': #{e.message}"
159
+ end
160
+
161
+ # Validate command options
162
+ #
163
+ # @raise [ArgumentError] If required options are missing
164
+ def validate_options!
165
+ unless @output_path
166
+ raise ArgumentError,
167
+ "Output path is required. Use --output option."
168
+ end
169
+
170
+ unless @target_format
171
+ raise ArgumentError,
172
+ "Target format is required. Use --to option."
173
+ end
174
+ end
175
+
176
+ # Parse target format from string/symbol
177
+ #
178
+ # @param format [String, Symbol, nil] Target format
179
+ # @return [Symbol, nil] Parsed format symbol
180
+ def parse_target_format(format)
181
+ return nil if format.nil?
182
+
183
+ format_str = format.to_s.downcase
184
+ case format_str
185
+ when "ttf", "truetype"
186
+ :ttf
187
+ when "otf", "opentype", "cff"
188
+ :otf
189
+ when "svg"
190
+ :svg
191
+ when "woff"
192
+ raise ArgumentError,
193
+ "WOFF format conversion is not supported yet. Use woff2 instead."
194
+ when "woff2"
195
+ :woff2
196
+ else
197
+ raise ArgumentError,
198
+ "Unknown target format: #{format}. " \
199
+ "Supported: ttf, otf, svg, woff2"
200
+ end
201
+ end
202
+
203
+ # Format file size for display
204
+ #
205
+ # @param bytes [Integer] Size in bytes
206
+ # @return [String] Formatted size
207
+ def format_size(bytes)
208
+ if bytes < 1024
209
+ "#{bytes} bytes"
210
+ elsif bytes < 1024 * 1024
211
+ "#{(bytes / 1024.0).round(1)} KB"
212
+ else
213
+ "#{(bytes / (1024.0 * 1024)).round(1)} MB"
214
+ end
215
+ end
216
+ end
217
+ end
218
+ end