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
@@ -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
 
@@ -151,6 +279,20 @@ module Fontisan
151
279
  true
152
280
  end
153
281
 
282
+ # Check if font is TrueType flavored
283
+ #
284
+ # @return [Boolean] true for TrueType fonts
285
+ def truetype?
286
+ true
287
+ end
288
+
289
+ # Check if font is CFF flavored
290
+ #
291
+ # @return [Boolean] false for TrueType fonts
292
+ def cff?
293
+ false
294
+ end
295
+
154
296
  # Check if font has a specific table
155
297
  #
156
298
  # @param tag [String] The table tag to check for
@@ -159,6 +301,15 @@ module Fontisan
159
301
  tables.any? { |entry| entry.tag == tag }
160
302
  end
161
303
 
304
+ # Check if a table is available in the current loading mode
305
+ #
306
+ # @param tag [String] The table tag to check
307
+ # @return [Boolean] true if table is available in current mode
308
+ def table_available?(tag)
309
+ return false unless has_table?(tag)
310
+ LoadingModes.table_allowed?(@loading_mode, tag)
311
+ end
312
+
162
313
  # Find a table entry by tag
163
314
  #
164
315
  # @param tag [String] The table tag to find
@@ -184,11 +335,30 @@ module Fontisan
184
335
  # Get parsed table instance (Fontisan extension)
185
336
  #
186
337
  # This method parses the raw table data into a structured table object
187
- # and caches the result for subsequent calls.
338
+ # and caches the result for subsequent calls. Enforces mode restrictions.
188
339
  #
189
340
  # @param tag [String] The table tag to retrieve
190
341
  # @return [Tables::*, nil] Parsed table object or nil if not found
342
+ # @raise [ArgumentError] if table is not available in current loading mode
191
343
  def table(tag)
344
+ # Check mode restrictions
345
+ unless table_available?(tag)
346
+ if has_table?(tag)
347
+ raise ArgumentError,
348
+ "Table '#{tag}' is not available in #{@loading_mode} mode. " \
349
+ "Available tables: #{LoadingModes.tables_for(@loading_mode).inspect}"
350
+ else
351
+ return nil
352
+ end
353
+ end
354
+
355
+ return @parsed_tables[tag] if @parsed_tables.key?(tag)
356
+
357
+ # Lazy load table data if enabled
358
+ if @lazy_load_enabled && !@table_data.key?(tag)
359
+ load_table_data(tag)
360
+ end
361
+
192
362
  @parsed_tables[tag] ||= parse_table(tag)
193
363
  end
194
364
 
@@ -200,8 +370,133 @@ module Fontisan
200
370
  head&.units_per_em
201
371
  end
202
372
 
373
+ # Convenience methods for accessing common name table fields
374
+ # These are particularly useful in minimal mode
375
+
376
+ # Get font family name
377
+ #
378
+ # @return [String, nil] Family name or nil if not found
379
+ def family_name
380
+ name_table = table(Constants::NAME_TAG)
381
+ name_table&.english_name(Tables::Name::FAMILY)
382
+ end
383
+
384
+ # Get font subfamily name (e.g., Regular, Bold, Italic)
385
+ #
386
+ # @return [String, nil] Subfamily name or nil if not found
387
+ def subfamily_name
388
+ name_table = table(Constants::NAME_TAG)
389
+ name_table&.english_name(Tables::Name::SUBFAMILY)
390
+ end
391
+
392
+ # Get full font name
393
+ #
394
+ # @return [String, nil] Full name or nil if not found
395
+ def full_name
396
+ name_table = table(Constants::NAME_TAG)
397
+ name_table&.english_name(Tables::Name::FULL_NAME)
398
+ end
399
+
400
+ # Get PostScript name
401
+ #
402
+ # @return [String, nil] PostScript name or nil if not found
403
+ def post_script_name
404
+ name_table = table(Constants::NAME_TAG)
405
+ name_table&.english_name(Tables::Name::POSTSCRIPT_NAME)
406
+ end
407
+
408
+ # Get preferred family name
409
+ #
410
+ # @return [String, nil] Preferred family name or nil if not found
411
+ def preferred_family_name
412
+ name_table = table(Constants::NAME_TAG)
413
+ name_table&.english_name(Tables::Name::PREFERRED_FAMILY)
414
+ end
415
+
416
+ # Get preferred subfamily name
417
+ #
418
+ # @return [String, nil] Preferred subfamily name or nil if not found
419
+ def preferred_subfamily_name
420
+ name_table = table(Constants::NAME_TAG)
421
+ name_table&.english_name(Tables::Name::PREFERRED_SUBFAMILY)
422
+ end
423
+
424
+ # Close the IO source (for lazy loading)
425
+ #
426
+ # @return [void]
427
+ def close
428
+ @io_source&.close
429
+ @io_source = nil
430
+ end
431
+
432
+ # Setup finalizer for cleanup
433
+ #
434
+ # @return [void]
435
+ def setup_finalizer
436
+ ObjectSpace.define_finalizer(self, self.class.finalize(@io_source))
437
+ end
438
+
439
+ # Finalizer proc for closing IO
440
+ #
441
+ # @param io [IO] The IO object to close
442
+ # @return [Proc] The finalizer proc
443
+ def self.finalize(io)
444
+ proc { io&.close }
445
+ end
446
+
203
447
  private
204
448
 
449
+ # Load a single table's data on demand
450
+ #
451
+ # Uses page-aligned reads and caches pages to ensure lazy loading
452
+ # performance is not slower than eager loading.
453
+ #
454
+ # @param tag [String] The table tag to load
455
+ # @return [void]
456
+ def load_table_data(tag)
457
+ return unless @io_source
458
+
459
+ entry = find_table_entry(tag)
460
+ return nil unless entry
461
+
462
+ # Use page-aligned reading with caching
463
+ table_start = entry.offset
464
+ table_end = entry.offset + entry.table_length
465
+
466
+ # Calculate page boundaries
467
+ page_start = (table_start / PAGE_SIZE) * PAGE_SIZE
468
+ page_end = ((table_end + PAGE_SIZE - 1) / PAGE_SIZE) * PAGE_SIZE
469
+
470
+ # Read all required pages (or use cached pages)
471
+ table_data_parts = []
472
+ current_page = page_start
473
+
474
+ while current_page < page_end
475
+ page_data = @page_cache[current_page]
476
+
477
+ unless page_data
478
+ # Read page from disk and cache it
479
+ @io_source.seek(current_page)
480
+ page_data = @io_source.read(PAGE_SIZE) || ""
481
+ @page_cache[current_page] = page_data
482
+ end
483
+
484
+ # Calculate which part of this page we need
485
+ chunk_start = [table_start - current_page, 0].max
486
+ chunk_end = [table_end - current_page, PAGE_SIZE].min
487
+
488
+ if chunk_end > chunk_start
489
+ table_data_parts << page_data[chunk_start...chunk_end]
490
+ end
491
+
492
+ current_page += PAGE_SIZE
493
+ end
494
+
495
+ # Combine parts and store
496
+ tag_key = tag.dup.force_encoding("UTF-8")
497
+ @table_data[tag_key] = table_data_parts.join
498
+ end
499
+
205
500
  # Parse a table from raw data (Fontisan extension)
206
501
  #
207
502
  # @param tag [String] The table tag to parse
@@ -223,6 +518,9 @@ module Fontisan
223
518
  def table_class_for(tag)
224
519
  {
225
520
  Constants::HEAD_TAG => Tables::Head,
521
+ Constants::HHEA_TAG => Tables::Hhea,
522
+ Constants::HMTX_TAG => Tables::Hmtx,
523
+ Constants::MAXP_TAG => Tables::Maxp,
226
524
  Constants::NAME_TAG => Tables::Name,
227
525
  Constants::OS2_TAG => Tables::Os2,
228
526
  Constants::POST_TAG => Tables::Post,
@@ -230,6 +528,8 @@ module Fontisan
230
528
  Constants::FVAR_TAG => Tables::Fvar,
231
529
  Constants::GSUB_TAG => Tables::Gsub,
232
530
  Constants::GPOS_TAG => Tables::Gpos,
531
+ Constants::GLYF_TAG => Tables::Glyf,
532
+ Constants::LOCA_TAG => Tables::Loca,
233
533
  }[tag]
234
534
  end
235
535
 
@@ -283,7 +583,7 @@ module Fontisan
283
583
  directory_offset_position = 12 + (index * 16) + 8
284
584
  current_pos = io.pos
285
585
  io.seek(directory_offset_position)
286
- io.write([current_position].pack("N"))
586
+ io.write([current_position].pack("N")) # Offset is now known
287
587
  io.seek(current_pos)
288
588
  end
289
589
  end
@@ -293,18 +593,19 @@ module Fontisan
293
593
  # @param path [String] Path to the TTF file
294
594
  # @return [void]
295
595
  def update_checksum_adjustment_in_file(path)
296
- # Calculate file checksum
297
- checksum = Utilities::ChecksumCalculator.calculate_file_checksum(path)
596
+ # Use tempfile-based checksum calculation for Windows compatibility
597
+ # This keeps the tempfile alive until we're done with the checksum
598
+ File.open(path, "r+b") do |io|
599
+ checksum, _tmpfile = Utilities::ChecksumCalculator.calculate_checksum_from_io_with_tempfile(io)
298
600
 
299
- # Calculate adjustment
300
- adjustment = Utilities::ChecksumCalculator.calculate_adjustment(checksum)
601
+ # Calculate adjustment
602
+ adjustment = Utilities::ChecksumCalculator.calculate_adjustment(checksum)
301
603
 
302
- # Find head table position
303
- head_entry = head_table
304
- return unless head_entry
604
+ # Find head table position
605
+ head_entry = head_table
606
+ return unless head_entry
305
607
 
306
- # Write adjustment to head table (offset 8 within head table)
307
- File.open(path, "r+b") do |io|
608
+ # Write adjustment to head table (offset 8 within head table)
308
609
  io.seek(head_entry.offset + 8)
309
610
  io.write([adjustment].pack("N"))
310
611
  end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ # Extensions to TrueTypeFont for table-based construction
5
+ class TrueTypeFont
6
+ # Create font from hash of tables
7
+ #
8
+ # This is used during font conversion when we have tables but not a file.
9
+ #
10
+ # @param tables [Hash<String, String>] Map of table tag to binary data
11
+ # @return [TrueTypeFont] New font instance
12
+ def self.from_tables(tables)
13
+ # Create minimal header structure
14
+ font = new
15
+ font.initialize_storage
16
+ font.loading_mode = LoadingModes::FULL
17
+
18
+ # Store table data
19
+ font.table_data = tables
20
+
21
+ # Build header from tables
22
+ num_tables = tables.size
23
+ max_power = 0
24
+ n = num_tables
25
+ while n > 1
26
+ n >>= 1
27
+ max_power += 1
28
+ end
29
+
30
+ search_range = (1 << max_power) * 16
31
+ entry_selector = max_power
32
+ range_shift = (num_tables * 16) - search_range
33
+
34
+ font.header.sfnt_version = 0x00010000 # TrueType
35
+ font.header.num_tables = num_tables
36
+ font.header.search_range = search_range
37
+ font.header.entry_selector = entry_selector
38
+ font.header.range_shift = range_shift
39
+
40
+ # Build table directory
41
+ font.tables.clear
42
+ tables.each_key do |tag|
43
+ entry = TableDirectory.new
44
+ entry.tag = tag
45
+ entry.checksum = 0 # Will be calculated on write
46
+ entry.offset = 0 # Will be calculated on write
47
+ entry.table_length = tables[tag].bytesize
48
+ font.tables << entry
49
+ end
50
+
51
+ font
52
+ end
53
+ end
54
+ 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