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,717 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bindata"
4
+ require "brotli"
5
+ require "stringio"
6
+ require_relative "constants"
7
+ require_relative "loading_modes"
8
+ require_relative "utilities/checksum_calculator"
9
+ require_relative "woff2/header"
10
+ require_relative "woff2/glyf_transformer"
11
+ require_relative "woff2/hmtx_transformer"
12
+ require_relative "true_type_font"
13
+ require_relative "open_type_font"
14
+ require_relative "error"
15
+
16
+ module Fontisan
17
+ # WOFF2 Table Directory Entry structure
18
+ #
19
+ # WOFF2 table directory entries are more complex than WOFF,
20
+ # with transformation flags and variable-length sizes.
21
+ class Woff2TableDirectoryEntry
22
+ attr_accessor :tag, :flags, :transform_version, :orig_length,
23
+ :transform_length, :offset
24
+
25
+ # Transformation version flags
26
+ TRANSFORM_NONE = 0
27
+ TRANSFORM_GLYF_LOCA = 0
28
+ TRANSFORM_LOCA = 1
29
+ TRANSFORM_HMTX = 2
30
+
31
+ # Known table tags with assigned indices (0-62)
32
+ KNOWN_TAGS = [
33
+ "cmap", "head", "hhea", "hmtx", "maxp", "name", "OS/2", "post",
34
+ "cvt ", "fpgm", "glyf", "loca", "prep", "CFF ", "VORG", "EBDT",
35
+ "EBLC", "gasp", "hdmx", "kern", "LTSH", "PCLT", "VDMX", "vhea",
36
+ "vmtx", "BASE", "GDEF", "GPOS", "GSUB", "EBSC", "JSTF", "MATH",
37
+ "CBDT", "CBLC", "COLR", "CPAL", "SVG ", "sbix", "acnt", "avar",
38
+ "bdat", "bloc", "bsln", "cvar", "fdsc", "feat", "fmtx", "fvar",
39
+ "gvar", "hsty", "just", "lcar", "mort", "morx", "opbd", "prop",
40
+ "trak", "Zapf", "Silf", "Glat", "Gloc", "Feat", "Sill"
41
+ ].freeze
42
+
43
+ def initialize
44
+ @flags = 0
45
+ # Don't initialize transform_version - leave it nil
46
+ # It will be set during parsing if table is transformed
47
+ end
48
+
49
+ # Check if table is transformed
50
+ def transformed?
51
+ (@flags & 0x3F) != 0x3F && KNOWN_TAGS[tag_index]&.start_with?(/glyf|loca|hmtx/)
52
+ end
53
+
54
+ # Get transform version for this table
55
+ def transform_version
56
+ return TRANSFORM_NONE unless transformed?
57
+
58
+ case tag
59
+ when "glyf", "loca"
60
+ TRANSFORM_GLYF_LOCA
61
+ when "hmtx"
62
+ TRANSFORM_HMTX
63
+ else
64
+ TRANSFORM_NONE
65
+ end
66
+ end
67
+
68
+ private
69
+
70
+ def tag_index
71
+ @flags & 0x3F
72
+ end
73
+ end
74
+
75
+ # Web Open Font Format 2.0 (WOFF2) font loader
76
+ #
77
+ # This class manages WOFF2 font files and provides access to
78
+ # decompressed tables and transformed data.
79
+ #
80
+ # @example Reading a WOFF2 font
81
+ # font = Woff2Font.from_file("font.woff2")
82
+ # puts font.header.flavor
83
+ # puts font.table_names
84
+ class Woff2Font
85
+ # Simple struct for storing file path
86
+ IOSource = Struct.new(:path)
87
+
88
+ attr_accessor :header, :table_entries, :decompressed_tables, :parsed_tables, :io_source
89
+ attr_accessor :underlying_font # Allow both reading and setting for table delegation
90
+
91
+ def initialize
92
+ @header = nil
93
+ @table_entries = []
94
+ @decompressed_tables = {}
95
+ @parsed_tables = {}
96
+ @io_source = nil
97
+ @underlying_font = nil # Store the actual TrueTypeFont/OpenTypeFont
98
+ end
99
+
100
+ # Initialize storage hashes
101
+ def initialize_storage
102
+ @decompressed_tables ||= {}
103
+ @initialize_storage ||= {}
104
+ end
105
+
106
+ # Check if font has TrueType flavor
107
+ def truetype?
108
+ return false unless @header
109
+
110
+ [Constants::SFNT_VERSION_TRUETYPE, 0x00010000].include?(@header.flavor)
111
+ end
112
+
113
+ # Check if font has CFF flavor
114
+ def cff?
115
+ return false unless @header
116
+
117
+ [Constants::SFNT_VERSION_OTTO, 0x4F54544F].include?(@header.flavor)
118
+ end
119
+
120
+ # Check if font is a variable font
121
+ #
122
+ # @return [Boolean] true if font has fvar table (variable font)
123
+ def variable_font?
124
+ has_table?("fvar")
125
+ end
126
+
127
+ # Validate WOFF2 signature
128
+ def validate_signature!
129
+ unless @header && @header.signature == Woff2::Woff2Header::SIGNATURE
130
+ raise InvalidFontError, "Invalid WOFF2 signature"
131
+ end
132
+ end
133
+
134
+ # Check if font is valid
135
+ def valid?
136
+ return false unless @header
137
+ return false unless @header.signature == Woff2::Woff2Header::SIGNATURE
138
+ return false unless @header.num_tables == @table_entries.length
139
+ return false unless has_table?("head")
140
+
141
+ true
142
+ end
143
+
144
+ # Check if table exists
145
+ def has_table?(tag)
146
+ @table_entries.any? { |entry| entry.tag == tag }
147
+ end
148
+
149
+ # Find table entry by tag
150
+ def find_table_entry(tag)
151
+ @table_entries.find { |entry| entry.tag == tag }
152
+ end
153
+
154
+ # Get list of table tags
155
+ def table_names
156
+ @table_entries.map(&:tag)
157
+ end
158
+
159
+ # Get decompressed table data
160
+ def table_data(tag)
161
+ # First try underlying font's table data if available
162
+ if @underlying_font && @underlying_font.respond_to?(:table_data)
163
+ underlying_data = @underlying_font.table_data[tag]
164
+ return underlying_data if underlying_data
165
+ end
166
+
167
+ # Fallback to decompressed_tables
168
+ @decompressed_tables[tag]
169
+ end
170
+
171
+ # Get parsed table object
172
+ def table(tag)
173
+ # Delegate to underlying font if available
174
+ return @underlying_font.table(tag) if @underlying_font
175
+
176
+ # Fallback to parsed_tables hash
177
+ # Normalize tag to UTF-8 string for hash lookup
178
+ # Use dup to create mutable copy since force_encoding modifies in place
179
+ tag_key = tag.to_s.dup.force_encoding("UTF-8")
180
+ @parsed_tables[tag_key]
181
+ end
182
+
183
+ # Convert to TTF
184
+ def to_ttf(output_path)
185
+ unless truetype?
186
+ raise InvalidFontError, "Cannot convert to TTF: font is not TrueType flavored"
187
+ end
188
+
189
+ # Build SFNT and create TrueTypeFont
190
+ sfnt_data = self.class.build_sfnt_in_memory(@header, @table_entries, @decompressed_tables)
191
+ sfnt_io = StringIO.new(sfnt_data)
192
+
193
+ # Create actual TrueTypeFont and save for table delegation
194
+ @underlying_font = TrueTypeFont.read(sfnt_io)
195
+ @underlying_font.initialize_storage
196
+ @underlying_font.read_table_data(sfnt_io)
197
+
198
+ FontWriter.write_to_file(@underlying_font.tables, output_path)
199
+ end
200
+
201
+ # Convert to OTF
202
+ def to_otf(output_path)
203
+ unless cff?
204
+ raise InvalidFontError, "Cannot convert to OTF: font is not CFF flavored"
205
+ end
206
+
207
+ # Build SFNT and create OpenTypeFont
208
+ sfnt_data = self.class.build_sfnt_in_memory(@header, @table_entries, @decompressed_tables)
209
+ sfnt_io = StringIO.new(sfnt_data)
210
+
211
+ # Create actual OpenTypeFont and save for table delegation
212
+ @underlying_font = OpenTypeFont.read(sfnt_io)
213
+ @underlying_font.initialize_storage
214
+ @underlying_font.read_table_data(sfnt_io)
215
+
216
+ FontWriter.write_to_file(@underlying_font.tables, output_path)
217
+ end
218
+
219
+ # Get metadata (if present)
220
+ def metadata
221
+ return nil unless @header&.meta_length&.positive?
222
+ return nil unless @io_source
223
+
224
+ begin
225
+ File.open(@io_source.path, "rb") do |io|
226
+ io.seek(@header.meta_offset)
227
+ compressed_meta = io.read(@header.meta_length)
228
+ Brotli.inflate(compressed_meta)
229
+ end
230
+ rescue StandardError => e
231
+ warn "Failed to decompress metadata: #{e.message}"
232
+ nil
233
+ end
234
+ end
235
+
236
+ # Convenience methods for accessing common name table fields
237
+
238
+ # Get font family name
239
+ def family_name
240
+ name_table = table("name")
241
+ name_table&.english_name(Tables::Name::FAMILY)
242
+ end
243
+
244
+ # Get font subfamily name
245
+ def subfamily_name
246
+ name_table = table("name")
247
+ name_table&.english_name(Tables::Name::SUBFAMILY)
248
+ end
249
+
250
+ # Get full font name
251
+ def full_name
252
+ name_table = table("name")
253
+ name_table&.english_name(Tables::Name::FULL_NAME)
254
+ end
255
+
256
+ # Get PostScript name
257
+ def post_script_name
258
+ name_table = table("name")
259
+ name_table&.english_name(Tables::Name::POSTSCRIPT_NAME)
260
+ end
261
+
262
+ # Get preferred family name
263
+ def preferred_family_name
264
+ name_table = table("name")
265
+ name_table&.english_name(Tables::Name::PREFERRED_FAMILY)
266
+ end
267
+
268
+ # Get preferred subfamily name
269
+ def preferred_subfamily_name
270
+ name_table = table("name")
271
+ name_table&.english_name(Tables::Name::PREFERRED_SUBFAMILY)
272
+ end
273
+
274
+ # Get units per em
275
+ def units_per_em
276
+ head = table("head")
277
+ head&.units_per_em
278
+ end
279
+
280
+ # Read WOFF2 font from a file and return Woff2Font instance
281
+ #
282
+ # @param path [String] Path to the WOFF2 file
283
+ # @param mode [Symbol] Loading mode (:metadata or :full)
284
+ # @param lazy [Boolean] If true, load tables on demand
285
+ # @return [Woff2Font] The WOFF2 font object
286
+ # @raise [ArgumentError] if path is nil or empty
287
+ # @raise [Errno::ENOENT] if file does not exist
288
+ # @raise [InvalidFontError] if file format is invalid
289
+ def self.from_file(path, mode: LoadingModes::FULL, lazy: false)
290
+ if path.nil? || path.to_s.empty?
291
+ raise ArgumentError, "path cannot be nil or empty"
292
+ end
293
+ raise Errno::ENOENT, "File not found: #{path}" unless File.exist?(path)
294
+
295
+ woff2 = new
296
+ woff2.io_source = IOSource.new(path)
297
+
298
+ File.open(path, "rb") do |io|
299
+ # Read header to determine font flavor
300
+ woff2.header = Woff2::Woff2Header.read(io)
301
+
302
+ # Validate signature
303
+ unless woff2.header.signature == Woff2::Woff2Header::SIGNATURE
304
+ raise InvalidFontError,
305
+ "Invalid WOFF2 signature: expected 0x#{Woff2::Woff2Header::SIGNATURE.to_s(16)}, " \
306
+ "got 0x#{woff2.header.signature.to_i.to_s(16)}"
307
+ end
308
+
309
+ # Read table directory
310
+ woff2.table_entries = read_table_directory_from_io(io, woff2.header)
311
+
312
+ # Decompress table data
313
+ woff2.decompressed_tables = decompress_tables(io, woff2.header, woff2.table_entries)
314
+
315
+ # Apply table transformations if present
316
+ apply_transformations!(woff2.table_entries, woff2.decompressed_tables)
317
+
318
+ # Build SFNT structure in memory
319
+ sfnt_data = build_sfnt_in_memory(woff2.header, woff2.table_entries, woff2.decompressed_tables)
320
+
321
+ # Create StringIO for reading
322
+ sfnt_io = StringIO.new(sfnt_data)
323
+ sfnt_io.rewind
324
+
325
+ # Parse tables based on font type
326
+ if woff2.truetype?
327
+ font = TrueTypeFont.read(sfnt_io)
328
+ font.initialize_storage
329
+ font.loading_mode = mode
330
+ font.lazy_load_enabled = lazy
331
+
332
+ # Create fresh StringIO for table data reading
333
+ table_io = StringIO.new(sfnt_data)
334
+ font.read_table_data(table_io)
335
+
336
+ # Store underlying font for table access delegation
337
+ woff2.underlying_font = font
338
+ woff2.parsed_tables = font.parsed_tables
339
+ elsif woff2.cff?
340
+ font = OpenTypeFont.read(sfnt_io)
341
+ font.initialize_storage
342
+ font.loading_mode = mode
343
+ font.lazy_load_enabled = lazy
344
+
345
+ # Create fresh StringIO for table data reading
346
+ table_io = StringIO.new(sfnt_data)
347
+ font.read_table_data(table_io)
348
+
349
+ # Store underlying font for table access delegation
350
+ woff2.underlying_font = font
351
+ woff2.parsed_tables = font.parsed_tables
352
+ else
353
+ raise InvalidFontError,
354
+ "Unknown WOFF2 flavor: 0x#{woff2.header.flavor.to_s(16)}"
355
+ end
356
+ end
357
+
358
+ woff2
359
+ rescue BinData::ValidityError, EOFError => e
360
+ raise InvalidFontError, "Invalid WOFF2 file: #{e.message}"
361
+ end
362
+
363
+ # Read table directory from IO
364
+ #
365
+ # @param io [IO] Open file handle
366
+ # @param header [Woff2::Woff2Header] WOFF2 header
367
+ # @return [Array<Woff2TableDirectoryEntry>] Table entries
368
+ def self.read_table_directory_from_io(io, header)
369
+ table_entries = []
370
+
371
+ header.num_tables.times do
372
+ entry = Woff2TableDirectoryEntry.new
373
+
374
+ # Read flags byte with nil check
375
+ flags_data = io.read(1)
376
+ raise EOFError, "Unexpected EOF while reading table directory flags" if flags_data.nil?
377
+
378
+ flags = flags_data.unpack1("C")
379
+ entry.flags = flags
380
+
381
+ # Determine tag
382
+ tag_index = flags & 0x3F
383
+ if tag_index == 0x3F
384
+ # Custom tag (4 bytes)
385
+ tag_data = io.read(4)
386
+ raise EOFError, "Unexpected EOF while reading custom tag" if tag_data.nil? || tag_data.bytesize < 4
387
+ entry.tag = tag_data.force_encoding("UTF-8")
388
+ else
389
+ # Known tag from table
390
+ entry.tag = Woff2TableDirectoryEntry::KNOWN_TAGS[tag_index]
391
+ unless entry.tag
392
+ raise InvalidFontError, "Invalid table tag index: #{tag_index}"
393
+ end
394
+ end
395
+
396
+ # Read orig_length (UIntBase128)
397
+ entry.orig_length = read_uint_base128_from_io(io)
398
+
399
+ # Determine if transformLength should be read
400
+ # According to WOFF2 spec section 4.2:
401
+ # - glyf/loca with version 0: TRANSFORMED (transformLength present)
402
+ # - hmtx with non-zero version: TRANSFORMED (transformLength present)
403
+ # - all other tables: transformation version is 0 (no transformLength)
404
+ transform_version = (flags >> 6) & 0x03
405
+ has_transform_length = if ["glyf", "loca"].include?(entry.tag) && transform_version.zero?
406
+ true
407
+ elsif entry.tag == "hmtx" && transform_version != 0
408
+ true
409
+ else
410
+ false
411
+ end
412
+
413
+ if has_transform_length
414
+ entry.transform_length = read_uint_base128_from_io(io)
415
+ entry.transform_version = transform_version
416
+ end
417
+
418
+ table_entries << entry
419
+ end
420
+
421
+ table_entries
422
+ end
423
+
424
+ # Read variable-length UIntBase128 integer from IO
425
+ #
426
+ # @param io [IO] Open file handle
427
+ # @return [Integer] The decoded integer value
428
+ def self.read_uint_base128_from_io(io)
429
+ result = 0
430
+ 5.times do
431
+ byte_data = io.read(1)
432
+ raise EOFError, "Unexpected EOF while reading UIntBase128" if byte_data.nil?
433
+
434
+ byte = byte_data.unpack1("C")
435
+
436
+ # Continue if high bit is set
437
+ if (byte & 0x80).zero?
438
+ return (result << 7) | byte
439
+ else
440
+ result = (result << 7) | (byte & 0x7F)
441
+ end
442
+ end
443
+
444
+ # If we're here, the encoding is invalid
445
+ raise InvalidFontError, "Invalid UIntBase128 encoding"
446
+ end
447
+
448
+ # Decompress tables from WOFF2 compressed data block
449
+ #
450
+ # @param io [IO] Open file handle
451
+ # @param header [Woff2::Woff2Header] WOFF2 header
452
+ # @param table_entries [Array<Woff2TableDirectoryEntry>] Table entries
453
+ # @return [Hash<String, String>] Map of tag to decompressed data
454
+ def self.decompress_tables(io, header, table_entries)
455
+ # IO stream is already positioned at compressed data after reading table directory
456
+ # No need to seek - just read from current position
457
+ compressed_data = io.read(header.total_compressed_size)
458
+
459
+ # Decompress entire data block with Brotli
460
+ decompressed_data = Brotli.inflate(compressed_data)
461
+
462
+ # Split decompressed data into individual tables
463
+ decompressed_tables = {}
464
+ offset = 0
465
+
466
+ table_entries.each do |entry|
467
+ table_size = entry.transform_length || entry.orig_length
468
+ table_data = decompressed_data[offset, table_size]
469
+ offset += table_size
470
+
471
+ decompressed_tables[entry.tag] = table_data
472
+ end
473
+
474
+ decompressed_tables
475
+ end
476
+
477
+ # Apply table transformations for glyf/loca/hmtx tables
478
+ #
479
+ # @param table_entries [Array<Woff2TableDirectoryEntry>] Table entries
480
+ # @param decompressed_tables [Hash<String, String>] Decompressed tables
481
+ # @return [void] Modifies decompressed_tables in place
482
+ def self.apply_transformations!(table_entries, decompressed_tables)
483
+ # Find entries that need transformation
484
+ glyf_entry = table_entries.find { |e| e.tag == "glyf" }
485
+ hmtx_entry = table_entries.find { |e| e.tag == "hmtx" }
486
+
487
+ # Get required metadata for transformations
488
+ maxp_data = decompressed_tables["maxp"]
489
+ hhea_data = decompressed_tables["hhea"]
490
+
491
+ return unless maxp_data && hhea_data
492
+
493
+ # Parse num_glyphs from maxp table
494
+ # maxp format: version(4) + numGlyphs(2) + ...
495
+ num_glyphs = maxp_data[4, 2].unpack1("n")
496
+
497
+ # Parse numberOfHMetrics from hhea table
498
+ # hhea format: ... + numberOfHMetrics(2) at offset 34
499
+ number_of_h_metrics = hhea_data[34, 2].unpack1("n")
500
+
501
+ # Check if this is a variable font by checking for fvar table
502
+ variable_font = table_entries.any? { |e| e.tag == "fvar" }
503
+
504
+ # Transform glyf/loca if needed
505
+ # transform_length is only set when table is actually transformed
506
+ # Check that transform_length exists and is greater than 0
507
+ if glyf_entry&.instance_variable_defined?(:@transform_length) &&
508
+ glyf_entry.transform_length&.positive?
509
+ transformed_glyf = decompressed_tables["glyf"]
510
+
511
+ if transformed_glyf
512
+ result = Woff2::GlyfTransformer.reconstruct(
513
+ transformed_glyf,
514
+ num_glyphs,
515
+ variable_font: variable_font
516
+ )
517
+ decompressed_tables["glyf"] = result[:glyf]
518
+ decompressed_tables["loca"] = result[:loca]
519
+ end
520
+ end
521
+
522
+ # Transform hmtx if needed
523
+ # transform_length is only set when table is actually transformed
524
+ # Check that transform_length exists and is greater than 0
525
+ if hmtx_entry&.instance_variable_defined?(:@transform_length) &&
526
+ hmtx_entry.transform_length&.positive?
527
+ transformed_hmtx = decompressed_tables["hmtx"]
528
+
529
+ if transformed_hmtx
530
+ decompressed_tables["hmtx"] = Woff2::HmtxTransformer.reconstruct(
531
+ transformed_hmtx,
532
+ num_glyphs,
533
+ number_of_h_metrics,
534
+ )
535
+ end
536
+ end
537
+ end
538
+
539
+ # Calculate size of table directory
540
+ #
541
+ # @param table_entries [Array<Woff2TableDirectoryEntry>] Table entries
542
+ # @return [Integer] Size in bytes
543
+ def self.calculate_table_directory_size(table_entries)
544
+ size = 0
545
+ table_entries.each do |entry|
546
+ size += 1 # flags byte
547
+
548
+ # Tag (4 bytes if custom, 0 if known)
549
+ tag_index = entry.flags & 0x3F
550
+ size += 4 if tag_index == 0x3F
551
+
552
+ # orig_length (UIntBase128) - estimate
553
+ size += uint_base128_size(entry.orig_length)
554
+
555
+ # transform_length if present
556
+ if entry.transform_version && !entry.transform_version.nil?
557
+ size += uint_base128_size(entry.transform_length)
558
+ end
559
+ end
560
+ size
561
+ end
562
+
563
+ # Estimate size of UIntBase128 encoded value
564
+ #
565
+ # @param value [Integer] The value to encode
566
+ # @return [Integer] Estimated size in bytes
567
+ def self.uint_base128_size(value)
568
+ return 1 if value < 128
569
+
570
+ bytes = 0
571
+ v = value
572
+ while v.positive?
573
+ bytes += 1
574
+ v >>= 7
575
+ end
576
+ [
577
+ bytes,
578
+ 5,
579
+ ].min # Max 5 bytes
580
+ end
581
+
582
+ # Build SFNT binary structure in memory
583
+ #
584
+ # @param header [Woff2::Woff2Header] WOFF2 header
585
+ # @param table_entries [Array<Woff2TableDirectoryEntry>] Table entries
586
+ # @param decompressed_tables [Hash<String, String>] Decompressed table data
587
+ # @return [String] Complete SFNT binary data
588
+ def self.build_sfnt_in_memory(header, table_entries, decompressed_tables)
589
+ sfnt_data = +""
590
+
591
+ # Calculate offset table fields
592
+ num_tables = table_entries.length
593
+ entry_selector = (Math.log(num_tables) / Math.log(2)).floor
594
+ search_range = (2**entry_selector) * 16
595
+ range_shift = num_tables * 16 - search_range
596
+
597
+ # Write offset table
598
+ sfnt_data << [header.flavor].pack("N")
599
+ sfnt_data << [num_tables].pack("n")
600
+ sfnt_data << [search_range].pack("n")
601
+ sfnt_data << [entry_selector].pack("n")
602
+ sfnt_data << [range_shift].pack("n")
603
+
604
+ # Calculate table offsets
605
+ offset = 12 + (num_tables * 16) # Header + directory
606
+ table_records = []
607
+
608
+ table_entries.each do |entry|
609
+ tag = entry.tag
610
+ data = decompressed_tables[tag]
611
+ next unless data
612
+
613
+ length = data.bytesize
614
+
615
+ # Calculate checksum
616
+ checksum = Utilities::ChecksumCalculator.calculate_table_checksum(data)
617
+
618
+ table_records << {
619
+ tag: tag,
620
+ checksum: checksum,
621
+ offset: offset,
622
+ length: length,
623
+ data: data,
624
+ }
625
+
626
+ # Update offset for next table (with padding)
627
+ offset += length
628
+ padding = (Constants::TABLE_ALIGNMENT - (length % Constants::TABLE_ALIGNMENT)) %
629
+ Constants::TABLE_ALIGNMENT
630
+ offset += padding
631
+ end
632
+
633
+ # Write table directory
634
+ table_records.each do |record|
635
+ sfnt_data << record[:tag].ljust(4, "\x00")
636
+ sfnt_data << [record[:checksum]].pack("N")
637
+ sfnt_data << [record[:offset]].pack("N")
638
+ sfnt_data << [record[:length]].pack("N")
639
+ end
640
+
641
+ # Write table data with padding
642
+ table_records.each do |record|
643
+ sfnt_data << record[:data]
644
+
645
+ # Add padding
646
+ padding = (Constants::TABLE_ALIGNMENT - (record[:length] % Constants::TABLE_ALIGNMENT)) %
647
+ Constants::TABLE_ALIGNMENT
648
+ sfnt_data << ("\x00" * padding) if padding.positive?
649
+ end
650
+
651
+ # Update checksumAdjustment in head table
652
+ update_checksum_in_memory(sfnt_data, table_records)
653
+
654
+ sfnt_data
655
+ end
656
+
657
+ # Update checksumAdjustment field in head table in memory
658
+ #
659
+ # @param sfnt_data [String] The SFNT binary data
660
+ # @param table_records [Array<Hash>] Table records with offsets
661
+ # @return [void]
662
+ def self.update_checksum_in_memory(sfnt_data, table_records)
663
+ # Find head table record
664
+ head_record = table_records.find { |r| r[:tag] == Constants::HEAD_TAG }
665
+ return unless head_record
666
+
667
+ # Zero out checksumAdjustment field first
668
+ head_offset = head_record[:offset]
669
+ sfnt_data[head_offset + 8, 4] = "\x00\x00\x00\x00"
670
+
671
+ # Calculate file checksum
672
+ checksum = 0
673
+ sfnt_data.bytes.each_slice(4) do |bytes|
674
+ word = bytes.pack("C*").ljust(4, "\x00").unpack1("N")
675
+ checksum = (checksum + word) & 0xFFFFFFFF
676
+ end
677
+
678
+ # Calculate adjustment
679
+ adjustment = (0xB1B0AFBA - checksum) & 0xFFFFFFFF
680
+
681
+ # Write adjustment to head table
682
+ sfnt_data[head_offset + 8, 4] = [adjustment].pack("N")
683
+ end
684
+
685
+ private
686
+
687
+ # Read variable-length UIntBase128 integer from IO
688
+ def read_uint_base128(io)
689
+ self.class.read_uint_base128_from_io(io)
690
+ end
691
+
692
+ # Read 255UInt16 variable-length integer
693
+ def read_255_uint16(io)
694
+ code = io.read(1).unpack1("C")
695
+
696
+ case code
697
+ when 0..252
698
+ code
699
+ when 253
700
+ 253 + io.read(1).unpack1("C")
701
+ when 254
702
+ io.read(2).unpack1("n")
703
+ when 255
704
+ io.read(2).unpack1("n") + 506
705
+ end
706
+ end
707
+
708
+ # Calculate offset table fields
709
+ def calculate_offset_table_fields(num_tables)
710
+ entry_selector = (Math.log(num_tables) / Math.log(2)).floor
711
+ search_range = (2**entry_selector) * 16
712
+ range_shift = num_tables * 16 - search_range
713
+
714
+ [search_range, entry_selector, range_shift]
715
+ end
716
+ end
717
+ end