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,712 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bindata"
4
+ require "brotli"
5
+ require_relative "constants"
6
+ require_relative "utilities/checksum_calculator"
7
+
8
+ module Fontisan
9
+ # WOFF2 Header structure
10
+ #
11
+ # WOFF2 header is more compact than WOFF, using variable-length integers
12
+ # for some fields and omitting redundant information.
13
+ class Woff2Header < BinData::Record
14
+ endian :big
15
+ uint32 :signature # 0x774F4632 'wOF2'
16
+ uint32 :flavor # sfnt version (0x00010000 for TTF, 'OTTO' for CFF)
17
+ uint32 :woff2_length # Total size of WOFF2 file
18
+ uint16 :num_tables # Number of entries in directory
19
+ uint16 :reserved # Reserved, must be zero
20
+ uint32 :total_sfnt_size # Total size needed for uncompressed font
21
+ uint32 :total_compressed_size # Total size of compressed data block
22
+ uint16 :major_version # Major version of WOFF file
23
+ uint16 :minor_version # Minor version of WOFF file
24
+ uint32 :meta_offset # Offset to metadata block
25
+ uint32 :meta_length # Length of compressed metadata block
26
+ uint32 :meta_orig_length # Length of uncompressed metadata block
27
+ uint32 :priv_offset # Offset to private data block
28
+ uint32 :priv_length # Length of private data block
29
+ end
30
+
31
+ # WOFF2 Table Directory Entry structure
32
+ #
33
+ # WOFF2 table directory entries are more complex than WOFF,
34
+ # with transformation flags and variable-length sizes.
35
+ class Woff2TableDirectoryEntry
36
+ attr_accessor :tag, :flags, :transform_version, :orig_length,
37
+ :transform_length, :offset
38
+
39
+ # Transformation version flags
40
+ TRANSFORM_NONE = 0
41
+ TRANSFORM_GLYF_LOCA = 0
42
+ TRANSFORM_LOCA = 1
43
+ TRANSFORM_HMTX = 2
44
+
45
+ # Known table tags with assigned indices (0-62)
46
+ KNOWN_TAGS = [
47
+ "cmap", "head", "hhea", "hmtx", "maxp", "name", "OS/2", "post",
48
+ "cvt ", "fpgm", "glyf", "loca", "prep", "CFF ", "VORG", "EBDT",
49
+ "EBLC", "gasp", "hdmx", "kern", "LTSH", "PCLT", "VDMX", "vhea",
50
+ "vmtx", "BASE", "GDEF", "GPOS", "GSUB", "EBSC", "JSTF", "MATH",
51
+ "CBDT", "CBLC", "COLR", "CPAL", "SVG ", "sbix", "acnt", "avar",
52
+ "bdat", "bloc", "bsln", "cvar", "fdsc", "feat", "fmtx", "fvar",
53
+ "gvar", "hsty", "just", "lcar", "mort", "morx", "opbd", "prop",
54
+ "trak", "Zapf", "Silf", "Glat", "Gloc", "Feat", "Sill"
55
+ ].freeze
56
+
57
+ def initialize
58
+ @flags = 0
59
+ @transform_version = TRANSFORM_NONE
60
+ end
61
+
62
+ # Check if table is transformed
63
+ def transformed?
64
+ (@flags & 0x3F) != 0x3F && KNOWN_TAGS[tag_index]&.start_with?(/glyf|loca|hmtx/)
65
+ end
66
+
67
+ # Get transform version for this table
68
+ def transform_version
69
+ return TRANSFORM_NONE unless transformed?
70
+
71
+ case tag
72
+ when "glyf", "loca"
73
+ TRANSFORM_GLYF_LOCA
74
+ when "hmtx"
75
+ TRANSFORM_HMTX
76
+ else
77
+ TRANSFORM_NONE
78
+ end
79
+ end
80
+
81
+ private
82
+
83
+ def tag_index
84
+ @flags & 0x3F
85
+ end
86
+ end
87
+
88
+ # Web Open Font Format 2.0 (WOFF2) font domain object
89
+ #
90
+ # Represents a WOFF2 font file that uses Brotli compression and table
91
+ # transformations. WOFF2 is significantly more complex than WOFF.
92
+ #
93
+ # According to the WOFF2 specification (https://www.w3.org/TR/WOFF2/):
94
+ # - Tables can be transformed (glyf, loca, hmtx have special formats)
95
+ # - All compressed data in a single Brotli stream
96
+ # - Variable-length integer encoding (UIntBase128, 255UInt16)
97
+ # - More efficient compression than WOFF
98
+ #
99
+ # @example Reading a WOFF2 font
100
+ # woff2 = Woff2Font.from_file("font.woff2")
101
+ # puts woff2.header.num_tables
102
+ # name_table = woff2.table("name")
103
+ # puts name_table.english_name(Tables::Name::FAMILY)
104
+ #
105
+ # @example Converting to TTF/OTF
106
+ # woff2 = Woff2Font.from_file("font.woff2")
107
+ # woff2.to_ttf("output.ttf") # if TrueType flavored
108
+ # woff2.to_otf("output.otf") # if CFF flavored
109
+ class Woff2Font
110
+ attr_accessor :header, :table_entries, :decompressed_tables,
111
+ :parsed_tables, :io_source
112
+
113
+ # WOFF2 signature constant
114
+ WOFF2_SIGNATURE = 0x774F4632 # 'wOF2'
115
+
116
+ # Read WOFF2 font from a file
117
+ #
118
+ # @param path [String] Path to the WOFF2 file
119
+ # @return [Woff2Font] A new instance
120
+ # @raise [ArgumentError] if path is nil or empty
121
+ # @raise [Errno::ENOENT] if file does not exist
122
+ # @raise [InvalidFontError] if file format is invalid
123
+ def self.from_file(path)
124
+ if path.nil? || path.to_s.empty?
125
+ raise ArgumentError, "path cannot be nil or empty"
126
+ end
127
+ raise Errno::ENOENT, "File not found: #{path}" unless File.exist?(path)
128
+
129
+ File.open(path, "rb") do |io|
130
+ font = new
131
+ font.read_from_io(io)
132
+ font.validate_signature!
133
+ font.initialize_storage
134
+ font.decompress_and_parse_tables(io)
135
+ font.io_source = io
136
+ font
137
+ end
138
+ rescue BinData::ValidityError, EOFError => e
139
+ raise InvalidFontError, "Invalid WOFF2 file: #{e.message}"
140
+ end
141
+
142
+ def initialize
143
+ @header = nil
144
+ @table_entries = []
145
+ @decompressed_tables = {}
146
+ @parsed_tables = {}
147
+ @io_source = nil
148
+ end
149
+
150
+ # Read header and table directory from IO
151
+ #
152
+ # @param io [IO] Open file handle
153
+ # @return [void]
154
+ def read_from_io(io)
155
+ @header = Woff2Header.read(io)
156
+ read_table_directory(io)
157
+ end
158
+
159
+ # Initialize storage hashes
160
+ #
161
+ # @return [void]
162
+ def initialize_storage
163
+ @decompressed_tables ||= {}
164
+ @initialize_storage ||= {}
165
+ end
166
+
167
+ # Validate WOFF2 signature
168
+ #
169
+ # @raise [InvalidFontError] if signature is invalid
170
+ # @return [void]
171
+ def validate_signature!
172
+ signature_value = header.signature.to_i
173
+ unless signature_value == WOFF2_SIGNATURE
174
+ Kernel.raise(::Fontisan::InvalidFontError,
175
+ "Invalid WOFF2 signature: expected 0x#{WOFF2_SIGNATURE.to_s(16)}, " \
176
+ "got 0x#{signature_value.to_s(16)}")
177
+ end
178
+ end
179
+
180
+ # Check if font is TrueType flavored
181
+ #
182
+ # @return [Boolean] true if TrueType, false if CFF
183
+ def truetype?
184
+ [Constants::SFNT_VERSION_TRUETYPE, 0x00010000].include?(header.flavor)
185
+ end
186
+
187
+ # Check if font is CFF flavored (OpenType with CFF outlines)
188
+ #
189
+ # @return [Boolean] true if CFF, false if TrueType
190
+ def cff?
191
+ [Constants::SFNT_VERSION_OTTO, 0x4F54544F].include?(header.flavor) # 'OTTO'
192
+ end
193
+
194
+ # Get decompressed table data
195
+ #
196
+ # Provides unified interface compatible with WoffFont
197
+ #
198
+ # @param tag [String] The table tag
199
+ # @return [String, nil] Decompressed table data or nil if not found
200
+ def table_data(tag)
201
+ @decompressed_tables[tag]
202
+ end
203
+
204
+ # Check if font has a specific table
205
+ #
206
+ # @param tag [String] The table tag to check for
207
+ # @return [Boolean] true if table exists, false otherwise
208
+ def has_table?(tag)
209
+ table_entries.any? { |entry| entry.tag == tag }
210
+ end
211
+
212
+ # Find a table entry by tag
213
+ #
214
+ # @param tag [String] The table tag to find
215
+ # @return [Woff2TableDirectoryEntry, nil] The table entry or nil
216
+ def find_table_entry(tag)
217
+ table_entries.find { |entry| entry.tag == tag }
218
+ end
219
+
220
+ # Get list of all table tags
221
+ #
222
+ # @return [Array<String>] Array of table tag strings
223
+ def table_names
224
+ table_entries.map(&:tag)
225
+ end
226
+
227
+ # Get parsed table instance
228
+ #
229
+ # This method decompresses and parses the raw table data into a
230
+ # structured table object and caches the result for subsequent calls.
231
+ #
232
+ # @param tag [String] The table tag to retrieve
233
+ # @return [Tables::*, nil] Parsed table object or nil if not found
234
+ def table(tag)
235
+ @parsed_tables[tag] ||= parse_table(tag)
236
+ end
237
+
238
+ # Get units per em from head table
239
+ #
240
+ # @return [Integer, nil] Units per em value
241
+ def units_per_em
242
+ head = table(Constants::HEAD_TAG)
243
+ head&.units_per_em
244
+ end
245
+
246
+ # Get WOFF2 metadata if present
247
+ #
248
+ # @return [String, nil] Decompressed metadata XML or nil
249
+ def metadata
250
+ return nil if header.meta_length.zero?
251
+ return @metadata if defined?(@metadata)
252
+
253
+ File.open(io_source.path, "rb") do |io|
254
+ io.seek(header.meta_offset)
255
+ compressed_meta = io.read(header.meta_length)
256
+ @metadata = Brotli.inflate(compressed_meta)
257
+
258
+ # Verify decompressed size
259
+ if @metadata.bytesize != header.meta_orig_length
260
+ raise InvalidFontError,
261
+ "Metadata size mismatch: expected #{header.meta_orig_length}, got #{@metadata.bytesize}"
262
+ end
263
+
264
+ @metadata
265
+ end
266
+ rescue StandardError => e
267
+ warn "Failed to decompress WOFF2 metadata: #{e.message}"
268
+ @metadata = nil
269
+ end
270
+
271
+ # Convert WOFF2 to TTF format
272
+ #
273
+ # Decompresses and reconstructs tables, then builds a standard TTF file
274
+ #
275
+ # @param output_path [String] Path where TTF file will be written
276
+ # @return [Integer] Number of bytes written
277
+ # @raise [InvalidFontError] if font is not TrueType flavored
278
+ def to_ttf(output_path)
279
+ unless truetype?
280
+ raise InvalidFontError,
281
+ "Cannot convert to TTF: font is CFF flavored (use to_otf)"
282
+ end
283
+
284
+ build_sfnt_font(output_path, Constants::SFNT_VERSION_TRUETYPE)
285
+ end
286
+
287
+ # Convert WOFF2 to OTF format
288
+ #
289
+ # Decompresses and reconstructs tables, then builds a standard OTF file
290
+ #
291
+ # @param output_path [String] Path where OTF file will be written
292
+ # @return [Integer] Number of bytes written
293
+ # @raise [InvalidFontError] if font is not CFF flavored
294
+ def to_otf(output_path)
295
+ unless cff?
296
+ raise InvalidFontError,
297
+ "Cannot convert to OTF: font is TrueType flavored (use to_ttf)"
298
+ end
299
+
300
+ build_sfnt_font(output_path, Constants::SFNT_VERSION_OTTO)
301
+ end
302
+
303
+ # Validate format correctness
304
+ #
305
+ # @return [Boolean] true if the WOFF2 format is valid, false otherwise
306
+ def valid?
307
+ return false unless header
308
+ return false unless header.signature == WOFF2_SIGNATURE
309
+ return false unless table_entries.respond_to?(:length)
310
+ return false if table_entries.length != header.num_tables
311
+ return false unless has_table?(Constants::HEAD_TAG)
312
+
313
+ true
314
+ end
315
+
316
+ private
317
+
318
+ # Read variable-length UIntBase128 integer
319
+ #
320
+ # WOFF2 uses a variable-length encoding for table sizes:
321
+ # - If high bit is 0, it's a single byte value
322
+ # - If high bit is 1, continue reading bytes
323
+ # - Maximum 5 bytes for a 32-bit value
324
+ #
325
+ # @param io [IO] Open file handle
326
+ # @return [Integer] The decoded integer value
327
+ def read_uint_base128(io)
328
+ result = 0
329
+ 5.times do
330
+ byte = io.read(1).unpack1("C")
331
+ return nil unless byte
332
+
333
+ # Continue if high bit is set
334
+ if (byte & 0x80).zero?
335
+ return (result << 7) | byte
336
+ else
337
+ result = (result << 7) | (byte & 0x7F)
338
+ end
339
+ end
340
+
341
+ # If we're here, the encoding is invalid
342
+ raise InvalidFontError, "Invalid UIntBase128 encoding"
343
+ end
344
+
345
+ # Read 255UInt16 variable-length integer
346
+ #
347
+ # Used in transformed glyf table:
348
+ # - If value < 253, it's the value itself (1 byte)
349
+ # - If value == 253, read next byte + 253 (2 bytes)
350
+ # - If value == 254, read next 2 bytes as big-endian (3 bytes)
351
+ # - If value == 255, read next 2 bytes + 506 (3 bytes special)
352
+ #
353
+ # @param io [IO] Open file handle
354
+ # @return [Integer] The decoded integer value
355
+ def read_255_uint16(io)
356
+ first = io.read(1).unpack1("C")
357
+ return nil unless first
358
+
359
+ case first
360
+ when 0..252
361
+ first
362
+ when 253
363
+ second = io.read(1).unpack1("C")
364
+ 253 + second
365
+ when 254
366
+ io.read(2).unpack1("n")
367
+ when 255
368
+ value = io.read(2).unpack1("n")
369
+ value + 506
370
+ end
371
+ end
372
+
373
+ # Read WOFF2 table directory
374
+ #
375
+ # The table directory in WOFF2 is more compact than WOFF,
376
+ # using variable-length integers and known table indices.
377
+ #
378
+ # @param io [IO] Open file handle
379
+ # @return [void]
380
+ def read_table_directory(io)
381
+ @table_entries = []
382
+
383
+ header.num_tables.times do
384
+ entry = Woff2TableDirectoryEntry.new
385
+
386
+ # Read flags byte
387
+ flags = io.read(1).unpack1("C")
388
+ entry.flags = flags
389
+
390
+ # Determine tag
391
+ tag_index = flags & 0x3F
392
+ if tag_index == 0x3F
393
+ # Custom tag (4 bytes)
394
+ entry.tag = io.read(4).force_encoding("UTF-8")
395
+ else
396
+ # Known tag from table
397
+ entry.tag = Woff2TableDirectoryEntry::KNOWN_TAGS[tag_index]
398
+ unless entry.tag
399
+ raise InvalidFontError, "Invalid table tag index: #{tag_index}"
400
+ end
401
+ end
402
+
403
+ # Read orig_length (UIntBase128)
404
+ entry.orig_length = read_uint_base128(io)
405
+
406
+ # For transformed tables, read transform_length
407
+ transform_version = (flags >> 6) & 0x03
408
+ if transform_version != 0 && ["glyf", "loca",
409
+ "hmtx"].include?(entry.tag)
410
+ entry.transform_length = read_uint_base128(io)
411
+ entry.transform_version = transform_version
412
+ end
413
+
414
+ @table_entries << entry
415
+ end
416
+ end
417
+
418
+ # Decompress table data block and reconstruct tables
419
+ #
420
+ # WOFF2 stores all table data in a single Brotli-compressed block.
421
+ # After decompression, we need to:
422
+ # 1. Split into individual tables
423
+ # 2. Reconstruct transformed tables (glyf, loca, hmtx)
424
+ #
425
+ # @param io [IO] Open file handle
426
+ # @return [void]
427
+ def decompress_and_parse_tables(io)
428
+ # Position after table directory
429
+ # The compressed data starts immediately after the table directory
430
+ compressed_offset = header.to_binary_s.bytesize +
431
+ calculate_table_directory_size
432
+
433
+ io.seek(compressed_offset)
434
+ compressed_data = io.read(header.total_compressed_size)
435
+
436
+ # Decompress entire data block with Brotli
437
+ decompressed_data = Brotli.inflate(compressed_data)
438
+
439
+ # Split decompressed data into individual tables
440
+ offset = 0
441
+ table_entries.each do |entry|
442
+ table_size = entry.transform_length || entry.orig_length
443
+
444
+ table_data = decompressed_data[offset, table_size]
445
+ offset += table_size
446
+
447
+ # Reconstruct transformed tables
448
+ if entry.transform_version && entry.transform_version != Woff2TableDirectoryEntry::TRANSFORM_NONE
449
+ table_data = reconstruct_transformed_table(entry, table_data)
450
+ end
451
+
452
+ @decompressed_tables[entry.tag] = table_data
453
+ end
454
+ end
455
+
456
+ # Calculate size of table directory
457
+ #
458
+ # Variable-length encoding makes this non-trivial
459
+ #
460
+ # @return [Integer] Size in bytes
461
+ def calculate_table_directory_size
462
+ size = 0
463
+ table_entries.each do |entry|
464
+ size += 1 # flags byte
465
+
466
+ # Tag (4 bytes if custom, 0 if known)
467
+ tag_index = entry.flags & 0x3F
468
+ size += 4 if tag_index == 0x3F
469
+
470
+ # orig_length (UIntBase128) - estimate
471
+ size += uint_base128_size(entry.orig_length)
472
+
473
+ # transform_length if present
474
+ if entry.transform_version && entry.transform_version != Woff2TableDirectoryEntry::TRANSFORM_NONE
475
+ size += uint_base128_size(entry.transform_length)
476
+ end
477
+ end
478
+ size
479
+ end
480
+
481
+ # Estimate size of UIntBase128 encoded value
482
+ #
483
+ # @param value [Integer] The value to encode
484
+ # @return [Integer] Estimated size in bytes
485
+ def uint_base128_size(value)
486
+ return 1 if value < 128
487
+
488
+ bytes = 0
489
+ v = value
490
+ while v.positive?
491
+ bytes += 1
492
+ v >>= 7
493
+ end
494
+ [bytes, 5].min # Max 5 bytes
495
+ end
496
+
497
+ # Reconstruct transformed table from WOFF2 format
498
+ #
499
+ # WOFF2 can transform certain tables for better compression:
500
+ # - glyf/loca: Complex transformation with multiple streams
501
+ # - hmtx: Can omit redundant data
502
+ #
503
+ # @param entry [Woff2TableDirectoryEntry] Table entry
504
+ # @param data [String] Transformed table data
505
+ # @return [String] Reconstructed standard table data
506
+ def reconstruct_transformed_table(entry, data)
507
+ case entry.tag
508
+ when "glyf", "loca"
509
+ reconstruct_glyf_loca(entry, data)
510
+ when "hmtx"
511
+ reconstruct_hmtx(entry, data)
512
+ else
513
+ # Unknown transformation, return as-is
514
+ data
515
+ end
516
+ end
517
+
518
+ # Reconstruct glyf/loca tables from WOFF2 transformed format
519
+ #
520
+ # This is the most complex WOFF2 transformation. The transformed
521
+ # glyf table contains multiple streams that need to be reconstructed.
522
+ #
523
+ # @param entry [Woff2TableDirectoryEntry] Table entry
524
+ # @param data [String] Transformed data
525
+ # @return [String] Reconstructed glyf or loca table data
526
+ def reconstruct_glyf_loca(_entry, _data)
527
+ # TODO: Implement full glyf/loca reconstruction
528
+ # This is extremely complex and requires:
529
+ # 1. Parse glyph streams (nContour, nPoints, flags, coords, etc.)
530
+ # 2. Reconstruct standard glyf format
531
+ # 3. Build loca table with proper offsets
532
+ #
533
+ # For now, return empty data to prevent crashes
534
+ # This will need proper implementation for production use
535
+ warn "WOFF2 transformed glyf/loca reconstruction not yet implemented"
536
+ ""
537
+ end
538
+
539
+ # Reconstruct hmtx table from WOFF2 transformed format
540
+ #
541
+ # WOFF2 can store hmtx in a more compact format by:
542
+ # - Omitting redundant advance widths
543
+ # - Using flags to indicate presence of LSB array
544
+ #
545
+ # @param entry [Woff2TableDirectoryEntry] Table entry
546
+ # @param data [String] Transformed data
547
+ # @return [String] Reconstructed hmtx table data
548
+ def reconstruct_hmtx(_entry, data)
549
+ # TODO: Implement hmtx reconstruction
550
+ # This requires:
551
+ # 1. Parse flags
552
+ # 2. Reconstruct advance width array
553
+ # 3. Reconstruct LSB array (if present) or derive from glyf
554
+ #
555
+ # For now, return as-is
556
+ warn "WOFF2 transformed hmtx reconstruction not yet implemented"
557
+ data
558
+ end
559
+
560
+ # Parse a table from decompressed data
561
+ #
562
+ # @param tag [String] The table tag to parse
563
+ # @return [Tables::*, nil] Parsed table object or nil
564
+ def parse_table(tag)
565
+ raw_data = table_data(tag)
566
+ return nil unless raw_data
567
+
568
+ table_class = table_class_for(tag)
569
+ return nil unless table_class
570
+
571
+ table_class.read(raw_data)
572
+ end
573
+
574
+ # Map table tag to parser class
575
+ #
576
+ # @param tag [String] The table tag
577
+ # @return [Class, nil] Table parser class or nil
578
+ def table_class_for(tag)
579
+ {
580
+ Constants::HEAD_TAG => Tables::Head,
581
+ Constants::HHEA_TAG => Tables::Hhea,
582
+ Constants::HMTX_TAG => Tables::Hmtx,
583
+ Constants::MAXP_TAG => Tables::Maxp,
584
+ Constants::NAME_TAG => Tables::Name,
585
+ Constants::OS2_TAG => Tables::Os2,
586
+ Constants::POST_TAG => Tables::Post,
587
+ Constants::CMAP_TAG => Tables::Cmap,
588
+ Constants::FVAR_TAG => Tables::Fvar,
589
+ Constants::GSUB_TAG => Tables::Gsub,
590
+ Constants::GPOS_TAG => Tables::Gpos,
591
+ }[tag]
592
+ end
593
+
594
+ # Build an SFNT font file (TTF or OTF) from decompressed WOFF2 data
595
+ #
596
+ # @param output_path [String] Path where font will be written
597
+ # @param sfnt_version [Integer] SFNT version
598
+ # @return [Integer] Number of bytes written
599
+ def build_sfnt_font(output_path, sfnt_version)
600
+ File.open(output_path, "wb") do |io|
601
+ # Calculate offset table fields
602
+ num_tables = table_entries.length
603
+ search_range, entry_selector, range_shift = calculate_offset_table_fields(num_tables)
604
+
605
+ # Write offset table
606
+ io.write([sfnt_version].pack("N"))
607
+ io.write([num_tables].pack("n"))
608
+ io.write([search_range].pack("n"))
609
+ io.write([entry_selector].pack("n"))
610
+ io.write([range_shift].pack("n"))
611
+
612
+ # Calculate table offsets
613
+ offset = 12 + (num_tables * 16) # Header + directory
614
+ table_records = []
615
+
616
+ table_entries.each do |entry|
617
+ tag = entry.tag
618
+ data = @decompressed_tables[tag]
619
+ next unless data
620
+
621
+ length = data.bytesize
622
+
623
+ # Calculate checksum
624
+ checksum = Utilities::ChecksumCalculator.calculate_table_checksum(data)
625
+
626
+ table_records << {
627
+ tag: tag,
628
+ checksum: checksum,
629
+ offset: offset,
630
+ length: length,
631
+ data: data,
632
+ }
633
+
634
+ # Update offset for next table (with padding)
635
+ offset += length
636
+ padding = (Constants::TABLE_ALIGNMENT - (length % Constants::TABLE_ALIGNMENT)) %
637
+ Constants::TABLE_ALIGNMENT
638
+ offset += padding
639
+ end
640
+
641
+ # Write table directory
642
+ table_records.each do |record|
643
+ io.write(record[:tag].ljust(4, "\x00"))
644
+ io.write([record[:checksum]].pack("N"))
645
+ io.write([record[:offset]].pack("N"))
646
+ io.write([record[:length]].pack("N"))
647
+
648
+ # Write table data
649
+ io.write(record[:data])
650
+
651
+ # Add padding
652
+ padding = (Constants::TABLE_ALIGNMENT - (record[:length] % Constants::TABLE_ALIGNMENT)) %
653
+ Constants::TABLE_ALIGNMENT
654
+ io.write("\x00" * padding) if padding.positive?
655
+ end
656
+
657
+ io.pos
658
+ end
659
+
660
+ # Update checksum adjustment in head table
661
+ update_checksum_adjustment_in_file(output_path)
662
+
663
+ File.size(output_path)
664
+ end
665
+
666
+ # Calculate offset table fields
667
+ #
668
+ # @param num_tables [Integer] Number of tables
669
+ # @return [Array<Integer>] [searchRange, entrySelector, rangeShift]
670
+ def calculate_offset_table_fields(num_tables)
671
+ entry_selector = (Math.log(num_tables) / Math.log(2)).floor
672
+ search_range = (2**entry_selector) * 16
673
+ range_shift = num_tables * 16 - search_range
674
+ [search_range, entry_selector, range_shift]
675
+ end
676
+
677
+ # Update checksumAdjustment field in head table
678
+ #
679
+ # @param path [String] Path to the font file
680
+ # @return [void]
681
+ def update_checksum_adjustment_in_file(path)
682
+ # Calculate file checksum
683
+ checksum = Utilities::ChecksumCalculator.calculate_file_checksum(path)
684
+
685
+ # Calculate adjustment
686
+ adjustment = Utilities::ChecksumCalculator.calculate_adjustment(checksum)
687
+
688
+ # Find head table position in output file
689
+ File.open(path, "rb") do |io|
690
+ io.seek(4) # Skip sfnt_version
691
+ num_tables = io.read(2).unpack1("n")
692
+ io.seek(12) # Start of table directory
693
+
694
+ num_tables.times do
695
+ tag = io.read(4)
696
+ io.read(4) # checksum
697
+ offset = io.read(4).unpack1("N")
698
+ io.read(4) # length
699
+
700
+ if tag == Constants::HEAD_TAG
701
+ # Write adjustment to head table (offset 8 within head table)
702
+ File.open(path, "r+b") do |write_io|
703
+ write_io.seek(offset + 8)
704
+ write_io.write([adjustment].pack("N"))
705
+ end
706
+ break
707
+ end
708
+ end
709
+ end
710
+ end
711
+ end
712
+ end