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
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "bindata"
4
4
  require_relative "constants"
5
+ require_relative "loading_modes"
5
6
  require_relative "utilities/checksum_calculator"
6
7
 
7
8
  module Fontisan
@@ -38,6 +39,11 @@ module Fontisan
38
39
  # name_table = ttf.table("name") # Fontisan extension
39
40
  # puts name_table.english_name(Tables::Name::FAMILY)
40
41
  #
42
+ # @example Loading with metadata mode
43
+ # ttf = TrueTypeFont.from_file("font.ttf", mode: :metadata)
44
+ # puts ttf.loading_mode # => :metadata
45
+ # ttf.table_available?("GSUB") # => false
46
+ #
41
47
  # @example Writing a font
42
48
  # ttf.to_file("output.ttf")
43
49
  class TrueTypeFont < BinData::Record
@@ -54,24 +60,55 @@ module Fontisan
54
60
  # Parsed table instances cache (Fontisan extension)
55
61
  attr_accessor :parsed_tables
56
62
 
63
+ # Loading mode for this font (:metadata or :full)
64
+ attr_accessor :loading_mode
65
+
66
+ # IO source for lazy loading
67
+ attr_accessor :io_source
68
+
69
+ # Whether lazy loading is enabled
70
+ attr_accessor :lazy_load_enabled
71
+
72
+ # Page cache for lazy loading (maps page_start_offset => page_data)
73
+ attr_accessor :page_cache
74
+
75
+ # Page size for lazy loading alignment (typical filesystem page size)
76
+ PAGE_SIZE = 4096
77
+
57
78
  # Read TrueType Font from a file
58
79
  #
59
80
  # @param path [String] Path to the TTF file
81
+ # @param mode [Symbol] Loading mode (:metadata or :full, default: :full)
82
+ # @param lazy [Boolean] If true, load tables on demand (default: false for eager loading)
60
83
  # @return [TrueTypeFont] A new instance
61
- # @raise [ArgumentError] if path is nil or empty
84
+ # @raise [ArgumentError] if path is nil or empty, or if mode is invalid
62
85
  # @raise [Errno::ENOENT] if file does not exist
63
86
  # @raise [RuntimeError] if file format is invalid
64
- def self.from_file(path)
87
+ def self.from_file(path, mode: LoadingModes::FULL, lazy: false)
65
88
  if path.nil? || path.to_s.empty?
66
89
  raise ArgumentError,
67
90
  "path cannot be nil or empty"
68
91
  end
69
92
  raise Errno::ENOENT, "File not found: #{path}" unless File.exist?(path)
70
93
 
94
+ # Validate mode
95
+ LoadingModes.validate_mode!(mode)
96
+
71
97
  File.open(path, "rb") do |io|
72
98
  font = read(io)
73
99
  font.initialize_storage
74
- font.read_table_data(io)
100
+ font.loading_mode = mode
101
+ font.lazy_load_enabled = lazy
102
+
103
+ if lazy
104
+ # Keep file handle open for lazy loading
105
+ font.io_source = File.open(path, "rb")
106
+ font.setup_finalizer
107
+ else
108
+ # Read tables upfront
109
+ font.read_table_data(io)
110
+ end
111
+
75
112
  font
76
113
  end
77
114
  rescue BinData::ValidityError, EOFError => e
@@ -82,11 +119,15 @@ module Fontisan
82
119
  #
83
120
  # @param io [IO] Open file handle
84
121
  # @param offset [Integer] Byte offset to the font
122
+ # @param mode [Symbol] Loading mode (:metadata or :full, default: :full)
85
123
  # @return [TrueTypeFont] A new instance
86
- def self.from_ttc(io, offset)
124
+ def self.from_ttc(io, offset, mode: LoadingModes::FULL)
125
+ LoadingModes.validate_mode!(mode)
126
+
87
127
  io.seek(offset)
88
128
  font = read(io)
89
129
  font.initialize_storage
130
+ font.loading_mode = mode
90
131
  font.read_table_data(io)
91
132
  font
92
133
  end
@@ -97,19 +138,106 @@ module Fontisan
97
138
  def initialize_storage
98
139
  @table_data = {}
99
140
  @parsed_tables = {}
141
+ @loading_mode = LoadingModes::FULL
142
+ @lazy_load_enabled = false
143
+ @io_source = nil
144
+ @page_cache = {}
100
145
  end
101
146
 
102
147
  # Read table data for all tables
103
148
  #
149
+ # In metadata mode, only reads metadata tables. In full mode, reads all tables.
150
+ # In lazy load mode, doesn't read data upfront.
151
+ #
104
152
  # @param io [IO] Open file handle
105
153
  # @return [void]
106
154
  def read_table_data(io)
107
155
  @table_data = {}
108
- tables.each do |entry|
109
- io.seek(entry.offset)
110
- # Force UTF-8 encoding on tag for hash key consistency
111
- tag_key = entry.tag.dup.force_encoding("UTF-8")
112
- @table_data[tag_key] = io.read(entry.table_length)
156
+
157
+ if @lazy_load_enabled
158
+ # Don't read data, just keep IO reference
159
+ @io_source = io
160
+ return
161
+ end
162
+
163
+ if @loading_mode == LoadingModes::METADATA
164
+ # Only read metadata tables for performance
165
+ # Use page-aware batched reading to maximize filesystem prefetching
166
+ read_metadata_tables_batched(io)
167
+ else
168
+ # Read all tables
169
+ tables.each do |entry|
170
+ io.seek(entry.offset)
171
+ # Force UTF-8 encoding on tag for hash key consistency
172
+ tag_key = entry.tag.dup.force_encoding("UTF-8")
173
+ @table_data[tag_key] = io.read(entry.table_length)
174
+ end
175
+ end
176
+ end
177
+
178
+ # Read metadata tables using page-aware batching
179
+ #
180
+ # Groups adjacent tables within page boundaries and reads them together
181
+ # to maximize filesystem prefetching and minimize random seeks.
182
+ #
183
+ # @param io [IO] Open file handle
184
+ # @return [void]
185
+ def read_metadata_tables_batched(io)
186
+ # Typical filesystem page size (4KB is common, but 8KB gives better prefetch window)
187
+ page_threshold = 8192
188
+
189
+ # Get metadata tables sorted by offset for sequential access
190
+ metadata_entries = tables.select { |entry| LoadingModes::METADATA_TABLES_SET.include?(entry.tag) }
191
+ metadata_entries.sort_by!(&:offset)
192
+
193
+ return if metadata_entries.empty?
194
+
195
+ # Group adjacent tables within page threshold for batched reading
196
+ i = 0
197
+ while i < metadata_entries.size
198
+ batch_start = metadata_entries[i]
199
+ batch_end = batch_start
200
+ batch_entries = [batch_start]
201
+
202
+ # Extend batch while next table is within page threshold
203
+ j = i + 1
204
+ while j < metadata_entries.size
205
+ next_entry = metadata_entries[j]
206
+ gap = next_entry.offset - (batch_end.offset + batch_end.table_length)
207
+
208
+ # If gap is small (within page threshold), include in batch
209
+ if gap <= page_threshold
210
+ batch_end = next_entry
211
+ batch_entries << next_entry
212
+ j += 1
213
+ else
214
+ break
215
+ end
216
+ end
217
+
218
+ # Read batch
219
+ if batch_entries.size == 1
220
+ # Single table, read normally
221
+ io.seek(batch_start.offset)
222
+ tag_key = batch_start.tag.dup.force_encoding("UTF-8")
223
+ @table_data[tag_key] = io.read(batch_start.table_length)
224
+ else
225
+ # Multiple tables, read contiguous segment
226
+ batch_offset = batch_start.offset
227
+ batch_length = (batch_end.offset + batch_end.table_length) - batch_start.offset
228
+
229
+ io.seek(batch_offset)
230
+ batch_data = io.read(batch_length)
231
+
232
+ # Extract individual tables from batch
233
+ batch_entries.each do |entry|
234
+ relative_offset = entry.offset - batch_offset
235
+ tag_key = entry.tag.dup.force_encoding("UTF-8")
236
+ @table_data[tag_key] = batch_data[relative_offset, entry.table_length]
237
+ end
238
+ end
239
+
240
+ i = j
113
241
  end
114
242
  end
115
243
 
@@ -159,6 +287,15 @@ module Fontisan
159
287
  tables.any? { |entry| entry.tag == tag }
160
288
  end
161
289
 
290
+ # Check if a table is available in the current loading mode
291
+ #
292
+ # @param tag [String] The table tag to check
293
+ # @return [Boolean] true if table is available in current mode
294
+ def table_available?(tag)
295
+ return false unless has_table?(tag)
296
+ LoadingModes.table_allowed?(@loading_mode, tag)
297
+ end
298
+
162
299
  # Find a table entry by tag
163
300
  #
164
301
  # @param tag [String] The table tag to find
@@ -184,11 +321,30 @@ module Fontisan
184
321
  # Get parsed table instance (Fontisan extension)
185
322
  #
186
323
  # This method parses the raw table data into a structured table object
187
- # and caches the result for subsequent calls.
324
+ # and caches the result for subsequent calls. Enforces mode restrictions.
188
325
  #
189
326
  # @param tag [String] The table tag to retrieve
190
327
  # @return [Tables::*, nil] Parsed table object or nil if not found
328
+ # @raise [ArgumentError] if table is not available in current loading mode
191
329
  def table(tag)
330
+ # Check mode restrictions
331
+ unless table_available?(tag)
332
+ if has_table?(tag)
333
+ raise ArgumentError,
334
+ "Table '#{tag}' is not available in #{@loading_mode} mode. " \
335
+ "Available tables: #{LoadingModes.tables_for(@loading_mode).inspect}"
336
+ else
337
+ return nil
338
+ end
339
+ end
340
+
341
+ return @parsed_tables[tag] if @parsed_tables.key?(tag)
342
+
343
+ # Lazy load table data if enabled
344
+ if @lazy_load_enabled && !@table_data.key?(tag)
345
+ load_table_data(tag)
346
+ end
347
+
192
348
  @parsed_tables[tag] ||= parse_table(tag)
193
349
  end
194
350
 
@@ -200,8 +356,133 @@ module Fontisan
200
356
  head&.units_per_em
201
357
  end
202
358
 
359
+ # Convenience methods for accessing common name table fields
360
+ # These are particularly useful in minimal mode
361
+
362
+ # Get font family name
363
+ #
364
+ # @return [String, nil] Family name or nil if not found
365
+ def family_name
366
+ name_table = table(Constants::NAME_TAG)
367
+ name_table&.english_name(Tables::Name::FAMILY)
368
+ end
369
+
370
+ # Get font subfamily name (e.g., Regular, Bold, Italic)
371
+ #
372
+ # @return [String, nil] Subfamily name or nil if not found
373
+ def subfamily_name
374
+ name_table = table(Constants::NAME_TAG)
375
+ name_table&.english_name(Tables::Name::SUBFAMILY)
376
+ end
377
+
378
+ # Get full font name
379
+ #
380
+ # @return [String, nil] Full name or nil if not found
381
+ def full_name
382
+ name_table = table(Constants::NAME_TAG)
383
+ name_table&.english_name(Tables::Name::FULL_NAME)
384
+ end
385
+
386
+ # Get PostScript name
387
+ #
388
+ # @return [String, nil] PostScript name or nil if not found
389
+ def post_script_name
390
+ name_table = table(Constants::NAME_TAG)
391
+ name_table&.english_name(Tables::Name::POSTSCRIPT_NAME)
392
+ end
393
+
394
+ # Get preferred family name
395
+ #
396
+ # @return [String, nil] Preferred family name or nil if not found
397
+ def preferred_family_name
398
+ name_table = table(Constants::NAME_TAG)
399
+ name_table&.english_name(Tables::Name::PREFERRED_FAMILY)
400
+ end
401
+
402
+ # Get preferred subfamily name
403
+ #
404
+ # @return [String, nil] Preferred subfamily name or nil if not found
405
+ def preferred_subfamily_name
406
+ name_table = table(Constants::NAME_TAG)
407
+ name_table&.english_name(Tables::Name::PREFERRED_SUBFAMILY)
408
+ end
409
+
410
+ # Close the IO source (for lazy loading)
411
+ #
412
+ # @return [void]
413
+ def close
414
+ @io_source&.close
415
+ @io_source = nil
416
+ end
417
+
418
+ # Setup finalizer for cleanup
419
+ #
420
+ # @return [void]
421
+ def setup_finalizer
422
+ ObjectSpace.define_finalizer(self, self.class.finalize(@io_source))
423
+ end
424
+
425
+ # Finalizer proc for closing IO
426
+ #
427
+ # @param io [IO] The IO object to close
428
+ # @return [Proc] The finalizer proc
429
+ def self.finalize(io)
430
+ proc { io&.close }
431
+ end
432
+
203
433
  private
204
434
 
435
+ # Load a single table's data on demand
436
+ #
437
+ # Uses page-aligned reads and caches pages to ensure lazy loading
438
+ # performance is not slower than eager loading.
439
+ #
440
+ # @param tag [String] The table tag to load
441
+ # @return [void]
442
+ def load_table_data(tag)
443
+ return unless @io_source
444
+
445
+ entry = find_table_entry(tag)
446
+ return nil unless entry
447
+
448
+ # Use page-aligned reading with caching
449
+ table_start = entry.offset
450
+ table_end = entry.offset + entry.table_length
451
+
452
+ # Calculate page boundaries
453
+ page_start = (table_start / PAGE_SIZE) * PAGE_SIZE
454
+ page_end = ((table_end + PAGE_SIZE - 1) / PAGE_SIZE) * PAGE_SIZE
455
+
456
+ # Read all required pages (or use cached pages)
457
+ table_data_parts = []
458
+ current_page = page_start
459
+
460
+ while current_page < page_end
461
+ page_data = @page_cache[current_page]
462
+
463
+ unless page_data
464
+ # Read page from disk and cache it
465
+ @io_source.seek(current_page)
466
+ page_data = @io_source.read(PAGE_SIZE) || ""
467
+ @page_cache[current_page] = page_data
468
+ end
469
+
470
+ # Calculate which part of this page we need
471
+ chunk_start = [table_start - current_page, 0].max
472
+ chunk_end = [table_end - current_page, PAGE_SIZE].min
473
+
474
+ if chunk_end > chunk_start
475
+ table_data_parts << page_data[chunk_start...chunk_end]
476
+ end
477
+
478
+ current_page += PAGE_SIZE
479
+ end
480
+
481
+ # Combine parts and store
482
+ tag_key = tag.dup.force_encoding("UTF-8")
483
+ @table_data[tag_key] = table_data_parts.join
484
+ end
485
+
205
486
  # Parse a table from raw data (Fontisan extension)
206
487
  #
207
488
  # @param tag [String] The table tag to parse
@@ -223,6 +504,9 @@ module Fontisan
223
504
  def table_class_for(tag)
224
505
  {
225
506
  Constants::HEAD_TAG => Tables::Head,
507
+ Constants::HHEA_TAG => Tables::Hhea,
508
+ Constants::HMTX_TAG => Tables::Hmtx,
509
+ Constants::MAXP_TAG => Tables::Maxp,
226
510
  Constants::NAME_TAG => Tables::Name,
227
511
  Constants::OS2_TAG => Tables::Os2,
228
512
  Constants::POST_TAG => Tables::Post,
@@ -230,6 +514,8 @@ module Fontisan
230
514
  Constants::FVAR_TAG => Tables::Fvar,
231
515
  Constants::GSUB_TAG => Tables::Gsub,
232
516
  Constants::GPOS_TAG => Tables::Gpos,
517
+ Constants::GLYF_TAG => Tables::Glyf,
518
+ Constants::LOCA_TAG => Tables::Loca,
233
519
  }[tag]
234
520
  end
235
521
 
@@ -283,7 +569,7 @@ module Fontisan
283
569
  directory_offset_position = 12 + (index * 16) + 8
284
570
  current_pos = io.pos
285
571
  io.seek(directory_offset_position)
286
- io.write([current_position].pack("N"))
572
+ io.write([current_position].pack("N")) # Offset is now known
287
573
  io.seek(current_pos)
288
574
  end
289
575
  end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "brotli"
4
+
5
+ module Fontisan
6
+ module Utilities
7
+ # Wrapper for Brotli compression with consistent settings
8
+ #
9
+ # [`BrotliWrapper`](lib/fontisan/utilities/brotli_wrapper.rb) provides
10
+ # a consistent interface for Brotli compression with configurable quality
11
+ # and error handling. Used primarily for WOFF2 encoding.
12
+ #
13
+ # Brotli compression is significantly more effective than zlib (used in WOFF),
14
+ # typically achieving 20-30% better compression ratios on font data.
15
+ #
16
+ # @example Compress table data
17
+ # compressed = BrotliWrapper.compress(table_data, quality: 11)
18
+ #
19
+ # @example Decompress data
20
+ # decompressed = BrotliWrapper.decompress(compressed_data)
21
+ class BrotliWrapper
22
+ # Default compression quality (0-11, higher = better but slower)
23
+ # Quality 11 gives best compression for WOFF2
24
+ DEFAULT_QUALITY = 11
25
+
26
+ # Minimum quality level
27
+ MIN_QUALITY = 0
28
+
29
+ # Maximum quality level
30
+ MAX_QUALITY = 11
31
+
32
+ # Compress data using Brotli
33
+ #
34
+ # @param data [String] Data to compress
35
+ # @param quality [Integer] Compression quality (0-11)
36
+ # @param mode [Symbol] Compression mode (:generic, :text, :font)
37
+ # @return [String] Compressed data
38
+ # @raise [ArgumentError] If quality is out of range
39
+ # @raise [Error] If compression fails
40
+ #
41
+ # @example Compress with default quality
42
+ # compressed = BrotliWrapper.compress(data)
43
+ #
44
+ # @example Compress with specific quality
45
+ # compressed = BrotliWrapper.compress(data, quality: 9)
46
+ def self.compress(data, quality: DEFAULT_QUALITY, mode: :font)
47
+ validate_quality!(quality)
48
+ validate_data!(data)
49
+
50
+ begin
51
+ # Use Brotli gem with specified quality
52
+ # The brotli gem doesn't expose mode constants, only quality
53
+ Brotli.deflate(data, quality: quality)
54
+ rescue StandardError => e
55
+ raise Fontisan::Error,
56
+ "Brotli compression failed: #{e.message}"
57
+ end
58
+ end
59
+
60
+ # Decompress Brotli-compressed data
61
+ #
62
+ # @param data [String] Compressed data
63
+ # @return [String] Decompressed data
64
+ # @raise [Error] If decompression fails
65
+ #
66
+ # @example
67
+ # decompressed = BrotliWrapper.decompress(compressed_data)
68
+ def self.decompress(data)
69
+ validate_data!(data)
70
+
71
+ begin
72
+ Brotli.inflate(data)
73
+ rescue StandardError => e
74
+ raise Fontisan::Error,
75
+ "Brotli decompression failed: #{e.message}"
76
+ end
77
+ end
78
+
79
+ # Calculate compression ratio
80
+ #
81
+ # @param original_size [Integer] Original data size
82
+ # @param compressed_size [Integer] Compressed data size
83
+ # @return [Float] Compression ratio (0.0-1.0)
84
+ #
85
+ # @example
86
+ # ratio = BrotliWrapper.compression_ratio(1000, 300)
87
+ # # => 0.3 (30% of original size)
88
+ def self.compression_ratio(original_size, compressed_size)
89
+ return 0.0 if original_size.zero?
90
+
91
+ compressed_size.to_f / original_size
92
+ end
93
+
94
+ # Calculate compression percentage
95
+ #
96
+ # @param original_size [Integer] Original data size
97
+ # @param compressed_size [Integer] Compressed data size
98
+ # @return [Float] Compression percentage reduction
99
+ #
100
+ # @example
101
+ # pct = BrotliWrapper.compression_percentage(1000, 300)
102
+ # # => 70.0 (70% reduction)
103
+ def self.compression_percentage(original_size, compressed_size)
104
+ return 0.0 if original_size.zero?
105
+
106
+ ((original_size - compressed_size).to_f / original_size * 100).round(1)
107
+ end
108
+
109
+ class << self
110
+ private
111
+
112
+ # Validate compression quality parameter
113
+ #
114
+ # @param quality [Integer] Quality level
115
+ # @raise [ArgumentError] If quality is invalid
116
+ def validate_quality!(quality)
117
+ unless quality.is_a?(Integer)
118
+ raise ArgumentError,
119
+ "Quality must be an Integer, got #{quality.class}"
120
+ end
121
+
122
+ unless (MIN_QUALITY..MAX_QUALITY).cover?(quality)
123
+ raise ArgumentError,
124
+ "Quality must be between #{MIN_QUALITY} and #{MAX_QUALITY}, " \
125
+ "got #{quality}"
126
+ end
127
+ end
128
+
129
+ # Validate data parameter
130
+ #
131
+ # @param data [String] Data to validate
132
+ # @raise [ArgumentError] If data is invalid
133
+ def validate_data!(data)
134
+ if data.nil?
135
+ raise ArgumentError, "Data cannot be nil"
136
+ end
137
+
138
+ unless data.respond_to?(:bytesize)
139
+ raise ArgumentError,
140
+ "Data must be a String-like object, got #{data.class}"
141
+ end
142
+ end
143
+
144
+ # Convert mode symbol to Brotli constant
145
+ #
146
+ # NOTE: The brotli gem doesn't expose mode constants
147
+ # This method is kept for API compatibility but unused
148
+ #
149
+ # @param mode [Symbol] Mode symbol
150
+ # @return [Integer] Mode value (unused)
151
+ def brotli_mode(_mode)
152
+ # The brotli gem only accepts quality parameter
153
+ # Mode is not configurable in current version
154
+ 0
155
+ end
156
+ end
157
+ end
158
+ end
159
+ end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "stringio"
3
4
  require_relative "../constants"
4
5
 
5
6
  module Fontisan
@@ -55,6 +56,23 @@ module Fontisan
55
56
  (Constants::CHECKSUM_ADJUSTMENT_MAGIC - file_checksum) & 0xFFFFFFFF
56
57
  end
57
58
 
59
+ # Calculate checksum for raw table data.
60
+ #
61
+ # This method calculates the checksum for a binary string of table data.
62
+ # Used when creating WOFF files or validating table integrity.
63
+ #
64
+ # @param data [String] binary table data
65
+ # @return [Integer] the calculated uint32 checksum
66
+ #
67
+ # @example
68
+ # checksum = ChecksumCalculator.calculate_table_checksum(table_data)
69
+ # # => 1234567890
70
+ def self.calculate_table_checksum(data)
71
+ io = StringIO.new(data)
72
+ io.set_encoding(Encoding::BINARY)
73
+ calculate_checksum_from_io(io)
74
+ end
75
+
58
76
  # Calculate checksum from an IO object.
59
77
  #
60
78
  # Reads the IO stream in 4-byte chunks and calculates the uint32 checksum.