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,391 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zlib"
4
+ require_relative "conversion_strategy"
5
+ require_relative "../utilities/checksum_calculator"
6
+
7
+ module Fontisan
8
+ module Converters
9
+ # WOFF font writer for creating WOFF files from TTF/OTF fonts
10
+ #
11
+ # [`WoffWriter`](lib/fontisan/converters/woff_writer.rb) handles conversion
12
+ # from TrueType/OpenType fonts to WOFF format using zlib compression.
13
+ # This implements the WOFF 1.0 specification for web font optimization.
14
+ #
15
+ # **WOFF Format Features:**
16
+ # - Individual table compression with zlib
17
+ # - Optional metadata block (compressed XML)
18
+ # - Optional private data block
19
+ # - Proper header and table directory structure
20
+ # - Cross-platform compatibility
21
+ #
22
+ # **Compression Strategy:**
23
+ # - Each table is compressed individually for optimal ratios
24
+ # - Tables smaller than compression threshold remain uncompressed
25
+ # - Metadata and private data are compressed when present
26
+ # - All data is properly aligned and padded
27
+ #
28
+ # @example Converting TTF to WOFF
29
+ # writer = Fontisan::Converters::WoffWriter.new
30
+ # woff_data = writer.write_font(ttf_font, metadata: xml_metadata)
31
+ # File.write("output.woff", woff_data)
32
+ #
33
+ # @example With compression options
34
+ # writer = Fontisan::Converters::WoffWriter.new(
35
+ # compression_level: 9, # Maximum compression
36
+ # compression_threshold: 100 # Bytes - tables smaller than this stay uncompressed
37
+ # )
38
+ # woff_data = writer.write_font(ttf_font)
39
+ class WoffWriter
40
+ include ConversionStrategy
41
+
42
+ # WOFF signature constant
43
+ WOFF_SIGNATURE = 0x774F4646 # 'wOFF'
44
+
45
+ # WOFF version 1.0
46
+ WOFF_VERSION_MAJOR = 1
47
+ WOFF_VERSION_MINOR = 0
48
+
49
+ # Default compression settings
50
+ DEFAULT_COMPRESSION_LEVEL = 6
51
+ DEFAULT_COMPRESSION_THRESHOLD = 100 # bytes - don't compress smaller tables
52
+
53
+ # Compression level (0-9, where 9 is maximum)
54
+ attr_accessor :compression_level
55
+
56
+ # Minimum table size to compress (bytes)
57
+ attr_accessor :compression_threshold
58
+
59
+ # Optional metadata XML
60
+ attr_accessor :metadata_xml
61
+
62
+ # Optional private data
63
+ attr_accessor :private_data
64
+
65
+ # Initialize writer with compression options
66
+ #
67
+ # @param options [Hash] Writer options
68
+ # @option options [Integer] :compression_level zlib compression level (0-9)
69
+ # @option options [Integer] :compression_threshold minimum table size to compress
70
+ # @option options [String] :metadata_xml optional metadata XML
71
+ # @option options [String] :private_data optional private data
72
+ def initialize(options = {})
73
+ @compression_level = options[:compression_level] || DEFAULT_COMPRESSION_LEVEL
74
+ @compression_threshold = options[:compression_threshold] || DEFAULT_COMPRESSION_THRESHOLD
75
+ @metadata_xml = options[:metadata_xml]
76
+ @private_data = options[:private_data]
77
+
78
+ validate_compression_level!
79
+ end
80
+
81
+ # Convert font to WOFF format
82
+ #
83
+ # @param font [TrueTypeFont, OpenTypeFont] Source font
84
+ # @param options [Hash] Additional options for this conversion
85
+ # @return [String] WOFF file data as binary string
86
+ # @raise [ArgumentError] if font is invalid
87
+ def convert(font, options = {})
88
+ validate_font(font)
89
+
90
+ # Override instance options with per-conversion options
91
+ metadata = options[:metadata_xml] || @metadata_xml
92
+ private_data = options[:private_data] || @private_data
93
+
94
+ write_font(font, metadata: metadata, private_data: private_data)
95
+ end
96
+
97
+ # Get supported conversions
98
+ #
99
+ # @return [Array<Array<Symbol>>] Supported conversion pairs
100
+ def supported_conversions
101
+ [
102
+ %i[ttf woff],
103
+ %i[otf woff],
104
+ ]
105
+ end
106
+
107
+ # Write font data to WOFF format
108
+ #
109
+ # @param font [TrueTypeFont, OpenTypeFont] Source font
110
+ # @param metadata [String, nil] Optional metadata XML
111
+ # @param private_data [String, nil] Optional private data
112
+ # @return [String] WOFF file data
113
+ def write_font(font, metadata: nil, private_data: nil)
114
+ # Collect all table data from font
115
+ tables_data = collect_tables_data(font)
116
+
117
+ # Compress tables
118
+ compressed_tables = compress_tables(tables_data)
119
+
120
+ # Build WOFF file
121
+ build_woff_file(compressed_tables, font, metadata, private_data)
122
+ end
123
+
124
+ private
125
+
126
+ # Validate compression level
127
+ #
128
+ # @raise [ArgumentError] if compression level is invalid
129
+ def validate_compression_level!
130
+ unless @compression_level.between?(0, 9)
131
+ raise ArgumentError, "Compression level must be between 0 and 9, got #{@compression_level}"
132
+ end
133
+ end
134
+
135
+ # Validate font for conversion
136
+ #
137
+ # @param font [TrueTypeFont, OpenTypeFont] Font to validate
138
+ # @raise [ArgumentError] if font is invalid
139
+ def validate_font(font)
140
+ raise ArgumentError, "Font cannot be nil" if font.nil?
141
+
142
+ unless font.respond_to?(:tables) && font.respond_to?(:table_data)
143
+ raise ArgumentError, "Font must respond to :tables and :table_data"
144
+ end
145
+ end
146
+
147
+ # Collect all table data from font
148
+ #
149
+ # @param font [TrueTypeFont, OpenTypeFont] Source font
150
+ # @return [Hash<String, String>] Map of table tags to binary data
151
+ def collect_tables_data(font)
152
+ tables_data = {}
153
+
154
+ font.table_names.each do |tag|
155
+ data = font.table_data[tag]
156
+ tables_data[tag] = data if data
157
+ end
158
+
159
+ tables_data
160
+ end
161
+
162
+ # Compress tables with zlib
163
+ #
164
+ # @param tables_data [Hash<String, String>] Original table data
165
+ # @return [Hash<String, Hash>] Compressed table info with original/compressed sizes
166
+ def compress_tables(tables_data)
167
+ compressed_tables = {}
168
+
169
+ tables_data.each do |tag, data|
170
+ original_size = data.bytesize
171
+
172
+ # Only compress if table is large enough and compression is beneficial
173
+ if original_size >= @compression_threshold
174
+ compressed_data = Zlib::Deflate.deflate(data, @compression_level)
175
+ compressed_size = compressed_data.bytesize
176
+
177
+ # Only use compression if it actually reduces size
178
+ compressed_tables[tag] = if compressed_size < original_size
179
+ {
180
+ original_data: data,
181
+ compressed_data: compressed_data,
182
+ original_length: original_size,
183
+ compressed_length: compressed_size,
184
+ is_compressed: true,
185
+ }
186
+ else
187
+ # Compression didn't help, store uncompressed
188
+ {
189
+ original_data: data,
190
+ compressed_data: data,
191
+ original_length: original_size,
192
+ compressed_length: original_size,
193
+ is_compressed: false,
194
+ }
195
+ end
196
+ else
197
+ # Table too small to compress
198
+ compressed_tables[tag] = {
199
+ original_data: data,
200
+ compressed_data: data,
201
+ original_length: original_size,
202
+ compressed_length: original_size,
203
+ is_compressed: false,
204
+ }
205
+ end
206
+ end
207
+
208
+ compressed_tables
209
+ end
210
+
211
+ # Build complete WOFF file
212
+ #
213
+ # @param compressed_tables [Hash] Compressed table information
214
+ # @param font [TrueTypeFont, OpenTypeFont] Source font
215
+ # @param metadata [String, nil] Optional metadata XML
216
+ # @param private_data [String, nil] Optional private data
217
+ # @return [String] Complete WOFF file data
218
+ def build_woff_file(compressed_tables, font, metadata, private_data)
219
+ io = StringIO.new
220
+ io.set_encoding(Encoding::BINARY)
221
+
222
+ # Compress metadata if provided
223
+ compressed_metadata = compress_metadata(metadata)
224
+
225
+ # Calculate offsets and sizes
226
+ header_size = 44 # WOFF header size
227
+ num_tables = compressed_tables.length
228
+ table_dir_size = num_tables * 20 # Each table directory entry is 20 bytes
229
+
230
+ # Calculate data offset (after header + table directory)
231
+ data_offset = header_size + table_dir_size
232
+
233
+ # Calculate metadata and private data offsets
234
+ metadata_offset = data_offset
235
+ metadata_size = compressed_metadata ? compressed_metadata[:compressed_length] : 0
236
+
237
+ # Calculate total compressed data size
238
+ total_compressed_size = compressed_tables.values.sum { |table| table[:compressed_length] }
239
+
240
+ # Calculate private data offset (after table data + metadata)
241
+ private_offset = data_offset + total_compressed_size + metadata_size
242
+ private_size = private_data ? private_data.bytesize : 0
243
+
244
+ # Calculate total WOFF file size
245
+ total_size = private_offset + private_size
246
+
247
+ # Calculate total SFNT size (uncompressed)
248
+ total_sfnt_size = compressed_tables.values.sum { |table| table[:original_length] } +
249
+ header_size + table_dir_size
250
+
251
+ # Write WOFF header
252
+ write_woff_header(io, font, total_size, total_sfnt_size, num_tables,
253
+ compressed_metadata, metadata_offset, metadata_size,
254
+ private_offset, private_size)
255
+
256
+ # Write table directory
257
+ write_table_directory(io, compressed_tables, data_offset)
258
+
259
+ # Write compressed table data
260
+ write_compressed_table_data(io, compressed_tables)
261
+
262
+ # Write compressed metadata if present
263
+ write_metadata(io, compressed_metadata) if compressed_metadata
264
+
265
+ # Write private data if present
266
+ write_private_data(io, private_data) if private_data
267
+
268
+ io.string
269
+ end
270
+
271
+ # Compress metadata with zlib
272
+ #
273
+ # @param metadata [String, nil] Metadata XML
274
+ # @return [Hash, nil] Compressed metadata info or nil
275
+ def compress_metadata(metadata)
276
+ return nil unless metadata
277
+
278
+ original_length = metadata.bytesize
279
+ compressed_data = Zlib::Deflate.deflate(metadata, @compression_level)
280
+ compressed_length = compressed_data.bytesize
281
+
282
+ {
283
+ original_data: metadata,
284
+ compressed_data: compressed_data,
285
+ original_length: original_length,
286
+ compressed_length: compressed_length,
287
+ }
288
+ end
289
+
290
+ # Write WOFF header
291
+ #
292
+ # @param io [StringIO] Output stream
293
+ # @param font [TrueTypeFont, OpenTypeFont] Source font
294
+ # @param total_size [Integer] Total WOFF file size
295
+ # @param total_sfnt_size [Integer] Uncompressed SFNT size
296
+ # @param num_tables [Integer] Number of tables
297
+ # @param compressed_metadata [Hash, nil] Compressed metadata info
298
+ # @param metadata_offset [Integer] Metadata offset
299
+ # @param metadata_size [Integer] Compressed metadata size
300
+ # @param private_offset [Integer] Private data offset
301
+ # @param private_size [Integer] Private data size
302
+ # @return [void]
303
+ def write_woff_header(io, font, total_size, total_sfnt_size, num_tables,
304
+ compressed_metadata, metadata_offset, metadata_size,
305
+ private_offset, private_size)
306
+ # Determine flavor from font
307
+ flavor = if font.respond_to?(:cff?) && font.cff?
308
+ Constants::SFNT_VERSION_OTTO
309
+ else
310
+ # Default to TrueType for TrueType fonts and unknown types
311
+ Constants::SFNT_VERSION_TRUETYPE
312
+ end
313
+
314
+ # Write WOFF header (44 bytes total)
315
+ io.write([WOFF_SIGNATURE].pack("N")) # signature (4 bytes)
316
+ io.write([flavor].pack("N")) # flavor (4 bytes)
317
+ io.write([total_size].pack("N")) # length (4 bytes)
318
+ io.write([num_tables].pack("n")) # numTables (2 bytes)
319
+ io.write([0].pack("n")) # reserved (2 bytes)
320
+ io.write([total_sfnt_size].pack("N")) # totalSfntSize (4 bytes)
321
+ io.write([WOFF_VERSION_MAJOR].pack("n")) # majorVersion (2 bytes)
322
+ io.write([WOFF_VERSION_MINOR].pack("n")) # minorVersion (2 bytes)
323
+ io.write([metadata_offset].pack("N")) # metaOffset (4 bytes)
324
+ io.write([metadata_size].pack("N")) # metaLength (4 bytes)
325
+ io.write([compressed_metadata ? compressed_metadata[:original_length] : 0].pack("N")) # metaOrigLength (4 bytes)
326
+ io.write([private_offset].pack("N")) # privOffset (4 bytes)
327
+ io.write([private_size].pack("N")) # privLength (4 bytes)
328
+ end
329
+
330
+ # Write table directory
331
+ #
332
+ # @param io [StringIO] Output stream
333
+ # @param compressed_tables [Hash] Compressed table information
334
+ # @param data_offset [Integer] Starting offset for table data
335
+ # @return [void]
336
+ def write_table_directory(io, compressed_tables, data_offset)
337
+ current_offset = data_offset
338
+
339
+ # Sort tables by tag for consistent output
340
+ sorted_tables = compressed_tables.sort_by { |tag, _| tag }
341
+
342
+ sorted_tables.each do |tag, table_info|
343
+ # Calculate checksum of original table data
344
+ checksum = Utilities::ChecksumCalculator.calculate_table_checksum(table_info[:original_data])
345
+
346
+ # Write table directory entry (20 bytes)
347
+ io.write(tag) # tag (4 bytes)
348
+ io.write([current_offset].pack("N")) # offset (4 bytes)
349
+ io.write([table_info[:compressed_length]].pack("N")) # compLength (4 bytes)
350
+ io.write([table_info[:original_length]].pack("N")) # origLength (4 bytes)
351
+ io.write([checksum].pack("N")) # origChecksum (4 bytes)
352
+
353
+ # Update offset for next table
354
+ current_offset += table_info[:compressed_length]
355
+ end
356
+ end
357
+
358
+ # Write compressed table data
359
+ #
360
+ # @param io [StringIO] Output stream
361
+ # @param compressed_tables [Hash] Compressed table information
362
+ # @return [void]
363
+ def write_compressed_table_data(io, compressed_tables)
364
+ # Sort tables by tag for consistent output (same order as directory)
365
+ sorted_tables = compressed_tables.sort_by { |tag, _| tag }
366
+
367
+ sorted_tables.each_value do |table_info|
368
+ io.write(table_info[:compressed_data])
369
+ end
370
+ end
371
+
372
+ # Write metadata to output
373
+ #
374
+ # @param io [StringIO] Output stream
375
+ # @param compressed_metadata [Hash] Compressed metadata info
376
+ # @return [void]
377
+ def write_metadata(io, compressed_metadata)
378
+ io.write(compressed_metadata[:compressed_data])
379
+ end
380
+
381
+ # Write private data to output
382
+ #
383
+ # @param io [StringIO] Output stream
384
+ # @param private_data [String] Private data
385
+ # @return [void]
386
+ def write_private_data(io, private_data)
387
+ io.write(private_data)
388
+ end
389
+ end
390
+ end
391
+ end
@@ -12,4 +12,207 @@ module Fontisan
12
12
  class MissingTableError < Error; end
13
13
 
14
14
  class ParseError < Error; end
15
+
16
+ class SubsettingError < Error; end
17
+
18
+ # Base variation error with context and suggestions
19
+ #
20
+ # Provides detailed error information including context hash and
21
+ # actionable suggestions for resolution.
22
+ class VariationError < Error
23
+ # @return [Hash] Error context (axis, value, range, etc.)
24
+ attr_reader :context
25
+
26
+ # @return [String, nil] Suggested fix
27
+ attr_reader :suggestion
28
+
29
+ # Initialize variation error
30
+ #
31
+ # @param message [String] Error message
32
+ # @param context [Hash] Error context
33
+ # @param suggestion [String, nil] Suggested fix
34
+ def initialize(message, context: {}, suggestion: nil)
35
+ super(message)
36
+ @context = context
37
+ @suggestion = suggestion
38
+ end
39
+
40
+ # Get detailed error message with context and suggestion
41
+ #
42
+ # @return [String] Formatted error message
43
+ def detailed_message
44
+ msg = message
45
+ msg += "\nContext: #{@context.inspect}" if @context.any?
46
+ msg += "\nSuggestion: #{@suggestion}" if @suggestion
47
+ msg
48
+ end
49
+ end
50
+
51
+ # Invalid coordinate value for variation axis
52
+ #
53
+ # Raised when coordinate is outside valid axis range.
54
+ class InvalidCoordinatesError < VariationError
55
+ # Initialize with axis details
56
+ #
57
+ # @param axis [String] Axis tag
58
+ # @param value [Float] Invalid value
59
+ # @param range [Range, Array] Valid range
60
+ # @param message [String, nil] Custom message
61
+ def initialize(axis: nil, value: nil, range: nil, message: nil)
62
+ if message
63
+ super(message, context: { axis: axis, value: value, range: range })
64
+ else
65
+ min_val = range.is_a?(Range) ? range.min : range.first
66
+ max_val = range.is_a?(Range) ? range.max : range.last
67
+
68
+ super(
69
+ "Invalid coordinate for axis '#{axis}': #{value}",
70
+ context: { axis: axis, value: value, range: range },
71
+ suggestion: "Use value between #{min_val} and #{max_val}"
72
+ )
73
+ end
74
+ end
75
+ end
76
+
77
+ # Missing required variation table
78
+ #
79
+ # Raised when font lacks required variation tables.
80
+ class MissingVariationTableError < VariationError
81
+ # Initialize with table tag
82
+ #
83
+ # @param table [String] Missing table tag
84
+ # @param message [String, nil] Custom message
85
+ def initialize(table: nil, message: nil)
86
+ if message
87
+ super(message, context: { table: table })
88
+ else
89
+ super(
90
+ "Missing required variation table: #{table}",
91
+ context: { table: table },
92
+ suggestion: "This font is not a variable font or lacks #{table} table"
93
+ )
94
+ end
95
+ end
96
+ end
97
+
98
+ # Invalid variation axis specification
99
+ #
100
+ # Raised when axis definition is malformed or invalid.
101
+ class InvalidAxisError < VariationError
102
+ # Initialize with axis details
103
+ #
104
+ # @param axis [String] Axis tag
105
+ # @param reason [String] Why axis is invalid
106
+ def initialize(axis:, reason:)
107
+ super(
108
+ "Invalid variation axis '#{axis}': #{reason}",
109
+ context: { axis: axis, reason: reason },
110
+ suggestion: "Check axis definition in fvar table"
111
+ )
112
+ end
113
+ end
114
+
115
+ # Overlapping variation regions detected
116
+ #
117
+ # Raised when variation regions overlap improperly.
118
+ class RegionOverlapError < VariationError
119
+ # Initialize with region details
120
+ #
121
+ # @param region1 [Integer] First region index
122
+ # @param region2 [Integer] Second region index
123
+ def initialize(region1:, region2:)
124
+ super(
125
+ "Overlapping variation regions: #{region1} and #{region2}",
126
+ context: { region1: region1, region2: region2 },
127
+ suggestion: "Check variation region definitions for conflicts"
128
+ )
129
+ end
130
+ end
131
+
132
+ # Delta count mismatch
133
+ #
134
+ # Raised when delta arrays have mismatched lengths.
135
+ class DeltaMismatchError < VariationError
136
+ # Initialize with delta details
137
+ #
138
+ # @param expected [Integer] Expected delta count
139
+ # @param actual [Integer] Actual delta count
140
+ # @param location [String] Where mismatch occurred
141
+ def initialize(expected:, actual:, location:)
142
+ super(
143
+ "Delta count mismatch at #{location}: expected #{expected}, got #{actual}",
144
+ context: { expected: expected, actual: actual, location: location },
145
+ suggestion: "Verify variation data integrity in #{location}"
146
+ )
147
+ end
148
+ end
149
+
150
+ # Invalid instance index
151
+ #
152
+ # Raised when named instance index is out of range.
153
+ class InvalidInstanceIndexError < VariationError
154
+ # Initialize with instance details
155
+ #
156
+ # @param index [Integer] Requested index
157
+ # @param max [Integer] Maximum valid index
158
+ def initialize(index:, max:)
159
+ super(
160
+ "Invalid instance index: #{index} (max: #{max})",
161
+ context: { index: index, max: max },
162
+ suggestion: "Use index between 0 and #{max}"
163
+ )
164
+ end
165
+ end
166
+
167
+ # Variation data corruption
168
+ #
169
+ # Raised when variation data appears corrupted or invalid.
170
+ class CorruptedVariationDataError < VariationError
171
+ # Initialize with corruption details
172
+ #
173
+ # @param table [String] Table with corrupted data
174
+ # @param details [String] Corruption details
175
+ def initialize(table:, details:)
176
+ super(
177
+ "Corrupted variation data in #{table}: #{details}",
178
+ context: { table: table, details: details },
179
+ suggestion: "Font file may be damaged, try re-downloading or using original"
180
+ )
181
+ end
182
+ end
183
+
184
+ # Invalid variation data
185
+ #
186
+ # Raised when variation data is invalid but not necessarily corrupted.
187
+ # Used for validation failures.
188
+ class InvalidVariationDataError < VariationError
189
+ # Initialize with validation details
190
+ #
191
+ # @param message [String] Error message
192
+ # @param details [Hash] Error details
193
+ def initialize(message:, details: {})
194
+ super(
195
+ message,
196
+ context: details,
197
+ suggestion: "Check font variation data and structure"
198
+ )
199
+ end
200
+ end
201
+
202
+ # Variation data corrupted (for use in data_extractor)
203
+ #
204
+ # Raised when extracted variation data appears corrupted.
205
+ class VariationDataCorruptedError < VariationError
206
+ # Initialize with corruption details
207
+ #
208
+ # @param message [String] Error message
209
+ # @param details [Hash] Corruption details
210
+ def initialize(message:, details: {})
211
+ super(
212
+ message,
213
+ context: details,
214
+ suggestion: "Font variation data may be corrupted"
215
+ )
216
+ end
217
+ end
15
218
  end