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
@@ -16,6 +17,11 @@ module Fontisan
16
17
  # name_table = otf.table("name")
17
18
  # puts name_table.english_name(Tables::Name::FAMILY)
18
19
  #
20
+ # @example Loading with metadata mode
21
+ # otf = OpenTypeFont.from_file("font.otf", mode: :metadata)
22
+ # puts otf.loading_mode # => :metadata
23
+ # otf.table_available?("GSUB") # => false
24
+ #
19
25
  # @example Writing a font
20
26
  # otf.to_file("output.otf")
21
27
  class OpenTypeFont < BinData::Record
@@ -32,24 +38,55 @@ module Fontisan
32
38
  # Parsed table instances cache
33
39
  attr_accessor :parsed_tables
34
40
 
41
+ # Loading mode for this font (:metadata or :full)
42
+ attr_accessor :loading_mode
43
+
44
+ # IO source for lazy loading
45
+ attr_accessor :io_source
46
+
47
+ # Whether lazy loading is enabled
48
+ attr_accessor :lazy_load_enabled
49
+
50
+ # Page cache for lazy loading (maps page_start_offset => page_data)
51
+ attr_accessor :page_cache
52
+
53
+ # Page size for lazy loading alignment (typical filesystem page size)
54
+ PAGE_SIZE = 4096
55
+
35
56
  # Read OpenType Font from a file
36
57
  #
37
58
  # @param path [String] Path to the OTF file
59
+ # @param mode [Symbol] Loading mode (:metadata or :full, default: :full)
60
+ # @param lazy [Boolean] If true, load tables on demand (default: false for eager loading)
38
61
  # @return [OpenTypeFont] A new instance
39
- # @raise [ArgumentError] if path is nil or empty
62
+ # @raise [ArgumentError] if path is nil or empty, or if mode is invalid
40
63
  # @raise [Errno::ENOENT] if file does not exist
41
64
  # @raise [RuntimeError] if file format is invalid
42
- def self.from_file(path)
65
+ def self.from_file(path, mode: LoadingModes::FULL, lazy: false)
43
66
  if path.nil? || path.to_s.empty?
44
67
  raise ArgumentError,
45
68
  "path cannot be nil or empty"
46
69
  end
47
70
  raise Errno::ENOENT, "File not found: #{path}" unless File.exist?(path)
48
71
 
72
+ # Validate mode
73
+ LoadingModes.validate_mode!(mode)
74
+
49
75
  File.open(path, "rb") do |io|
50
76
  font = read(io)
51
77
  font.initialize_storage
52
- font.read_table_data(io)
78
+ font.loading_mode = mode
79
+ font.lazy_load_enabled = lazy
80
+
81
+ if lazy
82
+ # Keep file handle open for lazy loading
83
+ font.io_source = File.open(path, "rb")
84
+ font.setup_finalizer
85
+ else
86
+ # Read tables upfront
87
+ font.read_table_data(io)
88
+ end
89
+
53
90
  font
54
91
  end
55
92
  rescue BinData::ValidityError, EOFError => e
@@ -60,11 +97,15 @@ module Fontisan
60
97
  #
61
98
  # @param io [IO] Open file handle
62
99
  # @param offset [Integer] Byte offset to the font
100
+ # @param mode [Symbol] Loading mode (:metadata or :full, default: :full)
63
101
  # @return [OpenTypeFont] A new instance
64
- def self.from_collection(io, offset)
102
+ def self.from_collection(io, offset, mode: LoadingModes::FULL)
103
+ LoadingModes.validate_mode!(mode)
104
+
65
105
  io.seek(offset)
66
106
  font = read(io)
67
107
  font.initialize_storage
108
+ font.loading_mode = mode
68
109
  font.read_table_data(io)
69
110
  font
70
111
  end
@@ -75,19 +116,106 @@ module Fontisan
75
116
  def initialize_storage
76
117
  @table_data = {}
77
118
  @parsed_tables = {}
119
+ @loading_mode = LoadingModes::FULL
120
+ @lazy_load_enabled = false
121
+ @io_source = nil
122
+ @page_cache = {}
78
123
  end
79
124
 
80
125
  # Read table data for all tables
81
126
  #
127
+ # In metadata mode, only reads metadata tables. In full mode, reads all tables.
128
+ # In lazy load mode, doesn't read data upfront.
129
+ #
82
130
  # @param io [IO] Open file handle
83
131
  # @return [void]
84
132
  def read_table_data(io)
85
133
  @table_data = {}
86
- tables.each do |entry|
87
- io.seek(entry.offset)
88
- # Force UTF-8 encoding on tag for hash key consistency
89
- tag_key = entry.tag.dup.force_encoding("UTF-8")
90
- @table_data[tag_key] = io.read(entry.table_length)
134
+
135
+ if @lazy_load_enabled
136
+ # Don't read data, just keep IO reference
137
+ @io_source = io
138
+ return
139
+ end
140
+
141
+ if @loading_mode == LoadingModes::METADATA
142
+ # Only read metadata tables for performance
143
+ # Use page-aware batched reading to maximize filesystem prefetching
144
+ read_metadata_tables_batched(io)
145
+ else
146
+ # Read all tables
147
+ tables.each do |entry|
148
+ io.seek(entry.offset)
149
+ # Force UTF-8 encoding on tag for hash key consistency
150
+ tag_key = entry.tag.dup.force_encoding("UTF-8")
151
+ @table_data[tag_key] = io.read(entry.table_length)
152
+ end
153
+ end
154
+ end
155
+
156
+ # Read metadata tables using page-aware batching
157
+ #
158
+ # Groups adjacent tables within page boundaries and reads them together
159
+ # to maximize filesystem prefetching and minimize random seeks.
160
+ #
161
+ # @param io [IO] Open file handle
162
+ # @return [void]
163
+ def read_metadata_tables_batched(io)
164
+ # Typical filesystem page size (4KB is common, but 8KB gives better prefetch window)
165
+ page_threshold = 8192
166
+
167
+ # Get metadata tables sorted by offset for sequential access
168
+ metadata_entries = tables.select { |entry| LoadingModes::METADATA_TABLES_SET.include?(entry.tag) }
169
+ metadata_entries.sort_by!(&:offset)
170
+
171
+ return if metadata_entries.empty?
172
+
173
+ # Group adjacent tables within page threshold for batched reading
174
+ i = 0
175
+ while i < metadata_entries.size
176
+ batch_start = metadata_entries[i]
177
+ batch_end = batch_start
178
+ batch_entries = [batch_start]
179
+
180
+ # Extend batch while next table is within page threshold
181
+ j = i + 1
182
+ while j < metadata_entries.size
183
+ next_entry = metadata_entries[j]
184
+ gap = next_entry.offset - (batch_end.offset + batch_end.table_length)
185
+
186
+ # If gap is small (within page threshold), include in batch
187
+ if gap <= page_threshold
188
+ batch_end = next_entry
189
+ batch_entries << next_entry
190
+ j += 1
191
+ else
192
+ break
193
+ end
194
+ end
195
+
196
+ # Read batch
197
+ if batch_entries.size == 1
198
+ # Single table, read normally
199
+ io.seek(batch_start.offset)
200
+ tag_key = batch_start.tag.dup.force_encoding("UTF-8")
201
+ @table_data[tag_key] = io.read(batch_start.table_length)
202
+ else
203
+ # Multiple tables, read contiguous segment
204
+ batch_offset = batch_start.offset
205
+ batch_length = (batch_end.offset + batch_end.table_length) - batch_start.offset
206
+
207
+ io.seek(batch_offset)
208
+ batch_data = io.read(batch_length)
209
+
210
+ # Extract individual tables from batch
211
+ batch_entries.each do |entry|
212
+ relative_offset = entry.offset - batch_offset
213
+ tag_key = entry.tag.dup.force_encoding("UTF-8")
214
+ @table_data[tag_key] = batch_data[relative_offset, entry.table_length]
215
+ end
216
+ end
217
+
218
+ i = j
91
219
  end
92
220
  end
93
221
 
@@ -138,6 +266,15 @@ module Fontisan
138
266
  tables.any? { |entry| entry.tag == tag }
139
267
  end
140
268
 
269
+ # Check if a table is available in the current loading mode
270
+ #
271
+ # @param tag [String] The table tag to check
272
+ # @return [Boolean] true if table is available in current mode
273
+ def table_available?(tag)
274
+ return false unless has_table?(tag)
275
+ LoadingModes.table_allowed?(@loading_mode, tag)
276
+ end
277
+
141
278
  # Find a table entry by tag
142
279
  #
143
280
  # @param tag [String] The table tag to find
@@ -163,11 +300,30 @@ module Fontisan
163
300
  # Get parsed table instance
164
301
  #
165
302
  # This method parses the raw table data into a structured table object
166
- # and caches the result for subsequent calls.
303
+ # and caches the result for subsequent calls. Enforces mode restrictions.
167
304
  #
168
305
  # @param tag [String] The table tag to retrieve
169
306
  # @return [Tables::*, nil] Parsed table object or nil if not found
307
+ # @raise [ArgumentError] if table is not available in current loading mode
170
308
  def table(tag)
309
+ # Check mode restrictions
310
+ unless table_available?(tag)
311
+ if has_table?(tag)
312
+ raise ArgumentError,
313
+ "Table '#{tag}' is not available in #{@loading_mode} mode. " \
314
+ "Available tables: #{LoadingModes.tables_for(@loading_mode).inspect}"
315
+ else
316
+ return nil
317
+ end
318
+ end
319
+
320
+ return @parsed_tables[tag] if @parsed_tables.key?(tag)
321
+
322
+ # Lazy load table data if enabled
323
+ if @lazy_load_enabled && !@table_data.key?(tag)
324
+ load_table_data(tag)
325
+ end
326
+
171
327
  @parsed_tables[tag] ||= parse_table(tag)
172
328
  end
173
329
 
@@ -179,8 +335,133 @@ module Fontisan
179
335
  head&.units_per_em
180
336
  end
181
337
 
338
+ # Convenience methods for accessing common name table fields
339
+ # These are particularly useful in minimal mode
340
+
341
+ # Get font family name
342
+ #
343
+ # @return [String, nil] Family name or nil if not found
344
+ def family_name
345
+ name_table = table(Constants::NAME_TAG)
346
+ name_table&.english_name(Tables::Name::FAMILY)
347
+ end
348
+
349
+ # Get font subfamily name (e.g., Regular, Bold, Italic)
350
+ #
351
+ # @return [String, nil] Subfamily name or nil if not found
352
+ def subfamily_name
353
+ name_table = table(Constants::NAME_TAG)
354
+ name_table&.english_name(Tables::Name::SUBFAMILY)
355
+ end
356
+
357
+ # Get full font name
358
+ #
359
+ # @return [String, nil] Full name or nil if not found
360
+ def full_name
361
+ name_table = table(Constants::NAME_TAG)
362
+ name_table&.english_name(Tables::Name::FULL_NAME)
363
+ end
364
+
365
+ # Get PostScript name
366
+ #
367
+ # @return [String, nil] PostScript name or nil if not found
368
+ def post_script_name
369
+ name_table = table(Constants::NAME_TAG)
370
+ name_table&.english_name(Tables::Name::POSTSCRIPT_NAME)
371
+ end
372
+
373
+ # Get preferred family name
374
+ #
375
+ # @return [String, nil] Preferred family name or nil if not found
376
+ def preferred_family_name
377
+ name_table = table(Constants::NAME_TAG)
378
+ name_table&.english_name(Tables::Name::PREFERRED_FAMILY)
379
+ end
380
+
381
+ # Get preferred subfamily name
382
+ #
383
+ # @return [String, nil] Preferred subfamily name or nil if not found
384
+ def preferred_subfamily_name
385
+ name_table = table(Constants::NAME_TAG)
386
+ name_table&.english_name(Tables::Name::PREFERRED_SUBFAMILY)
387
+ end
388
+
389
+ # Close the IO source (for lazy loading)
390
+ #
391
+ # @return [void]
392
+ def close
393
+ @io_source&.close
394
+ @io_source = nil
395
+ end
396
+
397
+ # Setup finalizer for cleanup
398
+ #
399
+ # @return [void]
400
+ def setup_finalizer
401
+ ObjectSpace.define_finalizer(self, self.class.finalize(@io_source))
402
+ end
403
+
404
+ # Finalizer proc for closing IO
405
+ #
406
+ # @param io [IO] The IO object to close
407
+ # @return [Proc] The finalizer proc
408
+ def self.finalize(io)
409
+ proc { io&.close }
410
+ end
411
+
182
412
  private
183
413
 
414
+ # Load a single table's data on demand
415
+ #
416
+ # Uses page-aligned reads and caches pages to ensure lazy loading
417
+ # performance is not slower than eager loading.
418
+ #
419
+ # @param tag [String] The table tag to load
420
+ # @return [void]
421
+ def load_table_data(tag)
422
+ return unless @io_source
423
+
424
+ entry = find_table_entry(tag)
425
+ return nil unless entry
426
+
427
+ # Use page-aligned reading with caching
428
+ table_start = entry.offset
429
+ table_end = entry.offset + entry.table_length
430
+
431
+ # Calculate page boundaries
432
+ page_start = (table_start / PAGE_SIZE) * PAGE_SIZE
433
+ page_end = ((table_end + PAGE_SIZE - 1) / PAGE_SIZE) * PAGE_SIZE
434
+
435
+ # Read all required pages (or use cached pages)
436
+ table_data_parts = []
437
+ current_page = page_start
438
+
439
+ while current_page < page_end
440
+ page_data = @page_cache[current_page]
441
+
442
+ unless page_data
443
+ # Read page from disk and cache it
444
+ @io_source.seek(current_page)
445
+ page_data = @io_source.read(PAGE_SIZE) || ""
446
+ @page_cache[current_page] = page_data
447
+ end
448
+
449
+ # Calculate which part of this page we need
450
+ chunk_start = [table_start - current_page, 0].max
451
+ chunk_end = [table_end - current_page, PAGE_SIZE].min
452
+
453
+ if chunk_end > chunk_start
454
+ table_data_parts << page_data[chunk_start...chunk_end]
455
+ end
456
+
457
+ current_page += PAGE_SIZE
458
+ end
459
+
460
+ # Combine parts and store
461
+ tag_key = tag.dup.force_encoding("UTF-8")
462
+ @table_data[tag_key] = table_data_parts.join
463
+ end
464
+
184
465
  # Parse a table from raw data
185
466
  #
186
467
  # @param tag [String] The table tag to parse
@@ -202,6 +483,9 @@ module Fontisan
202
483
  def table_class_for(tag)
203
484
  {
204
485
  Constants::HEAD_TAG => Tables::Head,
486
+ Constants::HHEA_TAG => Tables::Hhea,
487
+ Constants::HMTX_TAG => Tables::Hmtx,
488
+ Constants::MAXP_TAG => Tables::Maxp,
205
489
  Constants::NAME_TAG => Tables::Name,
206
490
  Constants::OS2_TAG => Tables::Os2,
207
491
  Constants::POST_TAG => Tables::Post,
@@ -209,6 +493,8 @@ module Fontisan
209
493
  Constants::FVAR_TAG => Tables::Fvar,
210
494
  Constants::GSUB_TAG => Tables::Gsub,
211
495
  Constants::GPOS_TAG => Tables::Gpos,
496
+ Constants::GLYF_TAG => Tables::Glyf,
497
+ Constants::LOCA_TAG => Tables::Loca,
212
498
  }[tag]
213
499
  end
214
500
 
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Optimizers
5
+ # Rewrites CharStrings by replacing repeated patterns with subroutine calls.
6
+ # Uses position-aware replacement to handle multiple patterns per glyph
7
+ # without offset corruption.
8
+ #
9
+ # @example Basic usage
10
+ # builder = SubroutineBuilder.new(patterns, type: :local)
11
+ # builder.build
12
+ # subroutine_map = patterns.each_with_index.to_h { |p, i| [p.bytes, i] }
13
+ # rewriter = CharstringRewriter.new(subroutine_map, builder)
14
+ # rewritten = rewriter.rewrite(charstring, patterns_for_glyph)
15
+ # valid = rewriter.validate(rewritten)
16
+ #
17
+ # @see docs/SUBROUTINE_ARCHITECTURE.md
18
+ class CharstringRewriter
19
+ # Initialize rewriter with subroutine map and builder
20
+ # @param subroutine_map [Hash<String, Integer>] pattern bytes => subroutine_id
21
+ # @param builder [SubroutineBuilder] builder for creating calls
22
+ def initialize(subroutine_map, builder)
23
+ @subroutine_map = subroutine_map
24
+ @builder = builder
25
+ end
26
+
27
+ # Rewrite a CharString by replacing patterns with subroutine calls
28
+ # Sorts patterns by position (descending) to avoid offset issues when
29
+ # replacing multiple patterns in the same CharString.
30
+ #
31
+ # @param charstring [String] original CharString bytes
32
+ # @param patterns [Array<Pattern>] patterns to replace in this CharString
33
+ # @return [String] rewritten CharString with subroutine calls
34
+ def rewrite(charstring, patterns)
35
+ return charstring if patterns.empty?
36
+
37
+ # Build list of all replacements: [position, pattern]
38
+ replacements = build_replacement_list(charstring, patterns)
39
+
40
+ # Remove overlapping replacements
41
+ replacements = remove_overlaps(replacements)
42
+
43
+ # Sort by position (descending) to avoid offset corruption
44
+ replacements.sort_by! { |pos, _pattern| -pos }
45
+
46
+ # Apply each replacement
47
+ rewritten = charstring.dup
48
+ replacements.each do |position, pattern|
49
+ subroutine_id = @subroutine_map[pattern.bytes]
50
+ next if subroutine_id.nil?
51
+
52
+ call = @builder.create_call(subroutine_id)
53
+
54
+ # Replace pattern with call at position
55
+ rewritten[position, pattern.length] = call
56
+ end
57
+
58
+ rewritten
59
+ end
60
+
61
+ # Validate rewritten CharString for structural correctness
62
+ # For now, performs basic validation. Future: full CFF parsing.
63
+ #
64
+ # @param charstring [String] CharString to validate
65
+ # @return [Boolean] true if valid, false otherwise
66
+ def validate(charstring)
67
+ return false if charstring.nil? || charstring.empty?
68
+
69
+ # Basic validation: check for return operator at end
70
+ # and reasonable length
71
+ return false if charstring.empty?
72
+
73
+ # More comprehensive validation can be added later
74
+ true
75
+ end
76
+
77
+ private
78
+
79
+ # Remove overlapping replacements, keeping higher-value patterns
80
+ # When two patterns occupy overlapping byte positions, we keep the one
81
+ # with higher savings to maximize total optimization benefit.
82
+ #
83
+ # @param replacements [Array<Array>] array of [position, pattern] pairs
84
+ # @return [Array<Array>] non-overlapping replacements
85
+ def remove_overlaps(replacements)
86
+ return replacements if replacements.empty?
87
+
88
+ # Sort by position (ascending) then by savings (descending)
89
+ sorted = replacements.sort_by { |pos, pattern| [pos, -pattern.savings] }
90
+
91
+ non_overlapping = []
92
+ last_end = 0
93
+
94
+ sorted.each do |position, pattern|
95
+ pattern_end = position + pattern.length
96
+
97
+ # Check if this replacement starts after the last one ended
98
+ if position >= last_end
99
+ # No overlap - add this replacement
100
+ non_overlapping << [position, pattern]
101
+ last_end = pattern_end
102
+ elsif non_overlapping.any?
103
+ # Overlap detected - compare with previous
104
+ prev_position, prev_pattern = non_overlapping.last
105
+ prev_position + prev_pattern.length
106
+
107
+ # If current pattern has higher savings, replace the previous one
108
+ if pattern.savings > prev_pattern.savings
109
+ # Current pattern is more valuable - replace previous
110
+ non_overlapping[-1] = [position, pattern]
111
+ last_end = pattern_end
112
+ end
113
+ # else: keep previous, skip current
114
+ end
115
+ end
116
+
117
+ non_overlapping
118
+ end
119
+
120
+ # Build list of all pattern replacements with their positions
121
+ # @param charstring [String] CharString being rewritten
122
+ # @param patterns [Array<Pattern>] patterns to find
123
+ # @return [Array<Array>] array of [position, pattern] pairs
124
+ def build_replacement_list(charstring, patterns)
125
+ replacements = []
126
+
127
+ patterns.each do |pattern|
128
+ # Find all positions where this pattern occurs
129
+ positions = find_pattern_positions(charstring, pattern)
130
+
131
+ positions.each do |position|
132
+ replacements << [position, pattern]
133
+ end
134
+ end
135
+
136
+ replacements
137
+ end
138
+
139
+ # Find all positions where a pattern occurs in the CharString
140
+ # @param charstring [String] CharString to search
141
+ # @param pattern [Pattern] pattern to find
142
+ # @return [Array<Integer>] array of start positions
143
+ def find_pattern_positions(charstring, pattern)
144
+ positions = []
145
+ offset = 0
146
+
147
+ while offset <= charstring.length - pattern.length
148
+ if charstring[offset, pattern.length] == pattern.bytes
149
+ positions << offset
150
+ # Move past this occurrence
151
+ offset += pattern.length
152
+ else
153
+ offset += 1
154
+ end
155
+ end
156
+
157
+ positions
158
+ end
159
+ end
160
+ end
161
+ end