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,416 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "conversion_strategy"
4
+ require_relative "../woff2/header"
5
+ require_relative "../woff2/directory"
6
+ require_relative "../woff2/table_transformer"
7
+ require_relative "../utilities/brotli_wrapper"
8
+ require_relative "../utilities/checksum_calculator"
9
+ require "yaml"
10
+
11
+ module Fontisan
12
+ module Converters
13
+ # WOFF2 encoder conversion strategy
14
+ #
15
+ # [`Woff2Encoder`](lib/fontisan/converters/woff2_encoder.rb) implements
16
+ # the ConversionStrategy interface to convert TTF or OTF fonts to WOFF2
17
+ # format with Brotli compression.
18
+ #
19
+ # WOFF2 encoding process:
20
+ # 1. Load configuration settings
21
+ # 2. Determine font flavor (TTF or CFF)
22
+ # 3. Collect and order tables
23
+ # 4. Transform tables (placeholder for glyf/loca/hmtx optimization)
24
+ # 5. Compress all tables with single Brotli stream
25
+ # 6. Build WOFF2 header and table directory
26
+ # 7. Assemble complete WOFF2 binary
27
+ #
28
+ # For Phase 2 Milestone 2.1:
29
+ # - Basic WOFF2 structure generation
30
+ # - Brotli compression of table data
31
+ # - Valid WOFF2 files for web font delivery
32
+ # - Table transformations are architectural placeholders
33
+ #
34
+ # @example Convert TTF to WOFF2
35
+ # encoder = Woff2Encoder.new
36
+ # woff2_binary = encoder.convert(font)
37
+ # File.binwrite('font.woff2', woff2_binary)
38
+ class Woff2Encoder
39
+ include ConversionStrategy
40
+
41
+ # @return [Hash] Configuration settings
42
+ attr_reader :config
43
+
44
+ # Initialize encoder with configuration
45
+ #
46
+ # @param config_path [String, nil] Path to config file
47
+ def initialize(config_path: nil)
48
+ @config = load_configuration(config_path)
49
+ end
50
+
51
+ # Convert font to WOFF2 format
52
+ #
53
+ # Returns a hash with :woff2_binary key containing complete WOFF2 file.
54
+ # This is different from other converters that return table data.
55
+ #
56
+ # @param font [TrueTypeFont, OpenTypeFont] Source font
57
+ # @param options [Hash] Conversion options
58
+ # @option options [Integer] :quality Brotli quality (0-11)
59
+ # @option options [Boolean] :transform_tables Apply table transformations
60
+ # @return [Hash] Hash with :woff2_binary key containing WOFF2 binary
61
+ # @raise [Error] If encoding fails
62
+ def convert(font, options = {})
63
+ validate(font, :woff2)
64
+
65
+ # Get Brotli quality from options or config
66
+ quality = options[:quality] || config["brotli"]["quality"]
67
+
68
+ # Detect font flavor
69
+ flavor = detect_flavor(font)
70
+
71
+ # Collect all tables
72
+ table_data = collect_tables(font, options)
73
+
74
+ # Transform tables (if enabled)
75
+ transformer = Woff2::TableTransformer.new(font)
76
+ transform_enabled = options.fetch(:transform_tables, false)
77
+
78
+ # Build table directory entries
79
+ entries = build_table_entries(table_data, transformer,
80
+ transform_enabled)
81
+
82
+ # Compress all table data into single stream
83
+ compressed_data = compress_tables(entries, table_data, quality)
84
+
85
+ # Calculate sizes
86
+ total_sfnt_size = calculate_sfnt_size(table_data)
87
+ total_compressed_size = compressed_data.bytesize
88
+
89
+ # Build WOFF2 header
90
+ header = build_header(
91
+ flavor: flavor,
92
+ num_tables: entries.size,
93
+ total_sfnt_size: total_sfnt_size,
94
+ total_compressed_size: total_compressed_size,
95
+ )
96
+
97
+ # Assemble WOFF2 binary
98
+ woff2_binary = assemble_woff2(header, entries, compressed_data)
99
+
100
+ # Return in special format for ConvertCommand to handle
101
+ { woff2_binary: woff2_binary }
102
+ end
103
+
104
+ # Get list of supported conversions
105
+ #
106
+ # @return [Array<Array<Symbol>>] Supported conversion pairs
107
+ def supported_conversions
108
+ [
109
+ %i[ttf woff2],
110
+ %i[otf woff2],
111
+ ]
112
+ end
113
+
114
+ # Validate that conversion is possible
115
+ #
116
+ # @param font [TrueTypeFont, OpenTypeFont] Font to validate
117
+ # @param target_format [Symbol] Target format
118
+ # @return [Boolean] True if valid
119
+ # @raise [Error] If conversion is not possible
120
+ def validate(font, target_format)
121
+ unless target_format == :woff2
122
+ raise Fontisan::Error,
123
+ "Woff2Encoder only supports conversion to woff2, " \
124
+ "got: #{target_format}"
125
+ end
126
+
127
+ # Verify font has required tables
128
+ required_tables = %w[head hhea maxp]
129
+ required_tables.each do |tag|
130
+ unless font.table(tag)
131
+ raise Fontisan::Error,
132
+ "Font is missing required table: #{tag}"
133
+ end
134
+ end
135
+
136
+ # Verify font has either glyf or CFF table
137
+ unless font.has_table?("glyf") || font.has_table?("CFF ") || font.has_table?("CFF2")
138
+ raise Fontisan::Error,
139
+ "Font must have either glyf or CFF/CFF2 table"
140
+ end
141
+
142
+ true
143
+ end
144
+
145
+ private
146
+
147
+ # Load configuration from YAML file
148
+ #
149
+ # @param path [String, nil] Path to config file
150
+ # @return [Hash] Configuration settings
151
+ def load_configuration(path)
152
+ config_path = path || default_config_path
153
+
154
+ if File.exist?(config_path)
155
+ YAML.load_file(config_path)
156
+ else
157
+ default_configuration
158
+ end
159
+ rescue StandardError => e
160
+ warn "Failed to load WOFF2 configuration: #{e.message}"
161
+ default_configuration
162
+ end
163
+
164
+ # Get default configuration path
165
+ #
166
+ # @return [String] Path to config file
167
+ def default_config_path
168
+ File.join(
169
+ __dir__,
170
+ "..",
171
+ "config",
172
+ "woff2_settings.yml",
173
+ )
174
+ end
175
+
176
+ # Get default configuration
177
+ #
178
+ # @return [Hash] Default settings
179
+ def default_configuration
180
+ {
181
+ "brotli" => {
182
+ "quality" => 11,
183
+ "mode" => "font",
184
+ },
185
+ "transformations" => {
186
+ "enabled" => false, # Disabled for Milestone 2.1
187
+ "glyf_loca" => false,
188
+ "hmtx" => false,
189
+ },
190
+ "metadata" => {
191
+ "include" => false,
192
+ },
193
+ }
194
+ end
195
+
196
+ # Detect font flavor (TTF or CFF)
197
+ #
198
+ # @param font [TrueTypeFont, OpenTypeFont] Font to detect
199
+ # @return [Integer] Flavor value
200
+ def detect_flavor(font)
201
+ if font.has_table?("CFF ") || font.has_table?("CFF2")
202
+ 0x4F54544F # 'OTTO' for CFF
203
+ elsif font.has_table?("glyf")
204
+ 0x00010000 # TrueType
205
+ else
206
+ raise Fontisan::Error,
207
+ "Cannot determine font flavor: missing glyf and CFF tables"
208
+ end
209
+ end
210
+
211
+ # Collect all tables from font
212
+ #
213
+ # @param font [TrueTypeFont, OpenTypeFont] Source font
214
+ # @param options [Hash] Conversion options
215
+ # @return [Hash<String, String>] Map of tag to table data
216
+ def collect_tables(font, _options = {})
217
+ tables = {}
218
+
219
+ # Get all table names from font
220
+ table_names = if font.respond_to?(:table_names)
221
+ font.table_names
222
+ else
223
+ # Fallback: try common tables
224
+ %w[head hhea maxp OS/2 name cmap post hmtx glyf loca
225
+ CFF]
226
+ end
227
+
228
+ table_names.each do |tag|
229
+ data = get_table_data(font, tag)
230
+ tables[tag] = data if data && !data.empty?
231
+ end
232
+
233
+ tables
234
+ end
235
+
236
+ # Get table data from font
237
+ #
238
+ # @param font [Object] Font object
239
+ # @param tag [String] Table tag
240
+ # @return [String, nil] Table data
241
+ def get_table_data(font, tag)
242
+ if font.respond_to?(:table_data)
243
+ font.table_data[tag]
244
+ elsif font.respond_to?(:table)
245
+ table = font.table(tag)
246
+ table&.to_binary_s if table.respond_to?(:to_binary_s)
247
+ end
248
+ end
249
+
250
+ # Build table directory entries
251
+ #
252
+ # @param table_data [Hash<String, String>] Table data map
253
+ # @param transformer [Woff2::TableTransformer] Table transformer
254
+ # @param transform_enabled [Boolean] Enable transformations
255
+ # @return [Array<Woff2::Directory::Entry>] Table entries
256
+ def build_table_entries(table_data, transformer, transform_enabled)
257
+ entries = []
258
+
259
+ # Sort tables by tag for consistent output
260
+ sorted_tags = table_data.keys.sort
261
+
262
+ sorted_tags.each do |tag|
263
+ entry = Woff2::Directory::Entry.new
264
+ entry.tag = tag
265
+
266
+ # Get original table data
267
+ data = table_data[tag]
268
+ entry.orig_length = data.bytesize
269
+
270
+ # Apply transformation if enabled and supported
271
+ if transform_enabled && transformer.transformable?(tag)
272
+ transformed = transformer.transform_table(tag)
273
+ if transformed && transformed.bytesize < data.bytesize
274
+ entry.transform_length = transformed.bytesize
275
+ end
276
+ end
277
+
278
+ # Calculate flags
279
+ entry.flags = entry.calculate_flags
280
+
281
+ entries << entry
282
+ end
283
+
284
+ entries
285
+ end
286
+
287
+ # Compress all tables into single Brotli stream
288
+ #
289
+ # @param entries [Array<Woff2::Directory::Entry>] Table entries
290
+ # @param table_data [Hash<String, String>] Original table data
291
+ # @param quality [Integer] Brotli quality
292
+ # @return [String] Compressed data
293
+ def compress_tables(entries, table_data, quality)
294
+ # Concatenate all table data in entry order
295
+ combined_data = String.new(encoding: Encoding::BINARY)
296
+
297
+ entries.each do |entry|
298
+ # Get table data
299
+ data = table_data[entry.tag]
300
+ next unless data
301
+
302
+ # For this milestone, we don't have transformed data yet
303
+ # Use original table data
304
+ combined_data << data
305
+ end
306
+
307
+ # Compress with Brotli
308
+ Utilities::BrotliWrapper.compress(
309
+ combined_data,
310
+ quality: quality,
311
+ )
312
+ end
313
+
314
+ # Calculate total SFNT size (uncompressed)
315
+ #
316
+ # @param table_data [Hash<String, String>] Table data map
317
+ # @return [Integer] Total size in bytes
318
+ def calculate_sfnt_size(table_data)
319
+ # Header size (offset table)
320
+ size = 12
321
+
322
+ # Table directory size
323
+ size += table_data.size * 16
324
+
325
+ # Table data size (with padding)
326
+ table_data.each_value do |data|
327
+ size += data.bytesize
328
+ # Add padding to 4-byte boundary
329
+ padding = (4 - (data.bytesize % 4)) % 4
330
+ size += padding
331
+ end
332
+
333
+ size
334
+ end
335
+
336
+ # Build WOFF2 header
337
+ #
338
+ # @param flavor [Integer] Font flavor
339
+ # @param num_tables [Integer] Number of tables
340
+ # @param total_sfnt_size [Integer] Uncompressed size
341
+ # @param total_compressed_size [Integer] Compressed size
342
+ # @return [Woff2::Woff2Header] WOFF2 header
343
+ def build_header(flavor:, num_tables:, total_sfnt_size:,
344
+ total_compressed_size:)
345
+ header = Woff2::Woff2Header.new
346
+ header.signature = Woff2::Woff2Header::SIGNATURE
347
+ header.flavor = flavor
348
+ header.file_length = 0 # Will be updated later
349
+ header.num_tables = num_tables
350
+ header.reserved = 0
351
+ header.total_sfnt_size = total_sfnt_size
352
+ header.total_compressed_size = total_compressed_size
353
+ header.major_version = 1
354
+ header.minor_version = 0
355
+ header.meta_offset = 0
356
+ header.meta_length = 0
357
+ header.meta_orig_length = 0
358
+ header.priv_offset = 0
359
+ header.priv_length = 0
360
+
361
+ header
362
+ end
363
+
364
+ # Assemble complete WOFF2 binary
365
+ #
366
+ # @param header [Woff2::Woff2Header] WOFF2 header
367
+ # @param entries [Array<Woff2::Directory::Entry>] Table entries
368
+ # @param compressed_data [String] Compressed table data
369
+ # @return [String] Complete WOFF2 binary
370
+ def assemble_woff2(header, entries, compressed_data)
371
+ woff2_data = String.new(encoding: Encoding::BINARY)
372
+
373
+ # Write header (placeholder, we'll update file_length later)
374
+ header_binary = header.to_binary_s
375
+ woff2_data << header_binary
376
+
377
+ # Write table directory
378
+ entries.each do |entry|
379
+ woff2_data << [entry.flags].pack("C")
380
+
381
+ # Write custom tag if needed
382
+ unless entry.known_tag?
383
+ woff2_data << entry.tag.ljust(4, "\x00")
384
+ end
385
+
386
+ # Write orig_length (UIntBase128)
387
+ woff2_data << Woff2::Directory.encode_uint_base128(entry.orig_length)
388
+
389
+ # Write transform_length if present
390
+ if entry.transformed?
391
+ woff2_data << Woff2::Directory.encode_uint_base128(entry.transform_length)
392
+ end
393
+ end
394
+
395
+ # Write compressed data
396
+ woff2_data << compressed_data
397
+
398
+ # Update header file_length field
399
+ update_woff2_length!(woff2_data)
400
+
401
+ woff2_data
402
+ end
403
+
404
+ # Update WOFF2 file length in header
405
+ #
406
+ # @param woff2_data [String] WOFF2 binary (modified in place)
407
+ # @return [void]
408
+ def update_woff2_length!(woff2_data)
409
+ total_length = woff2_data.bytesize
410
+
411
+ # file_length field is at offset 8 in header (uint32)
412
+ woff2_data[8, 4] = [total_length].pack("N")
413
+ end
414
+ end
415
+ end
416
+ end