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
@@ -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
 
@@ -130,6 +258,20 @@ module Fontisan
130
258
  true
131
259
  end
132
260
 
261
+ # Check if font is TrueType flavored
262
+ #
263
+ # @return [Boolean] false for OpenType fonts
264
+ def truetype?
265
+ false
266
+ end
267
+
268
+ # Check if font is CFF flavored
269
+ #
270
+ # @return [Boolean] true for OpenType fonts
271
+ def cff?
272
+ true
273
+ end
274
+
133
275
  # Check if font has a specific table
134
276
  #
135
277
  # @param tag [String] The table tag to check for
@@ -138,6 +280,15 @@ module Fontisan
138
280
  tables.any? { |entry| entry.tag == tag }
139
281
  end
140
282
 
283
+ # Check if a table is available in the current loading mode
284
+ #
285
+ # @param tag [String] The table tag to check
286
+ # @return [Boolean] true if table is available in current mode
287
+ def table_available?(tag)
288
+ return false unless has_table?(tag)
289
+ LoadingModes.table_allowed?(@loading_mode, tag)
290
+ end
291
+
141
292
  # Find a table entry by tag
142
293
  #
143
294
  # @param tag [String] The table tag to find
@@ -163,11 +314,30 @@ module Fontisan
163
314
  # Get parsed table instance
164
315
  #
165
316
  # This method parses the raw table data into a structured table object
166
- # and caches the result for subsequent calls.
317
+ # and caches the result for subsequent calls. Enforces mode restrictions.
167
318
  #
168
319
  # @param tag [String] The table tag to retrieve
169
320
  # @return [Tables::*, nil] Parsed table object or nil if not found
321
+ # @raise [ArgumentError] if table is not available in current loading mode
170
322
  def table(tag)
323
+ # Check mode restrictions
324
+ unless table_available?(tag)
325
+ if has_table?(tag)
326
+ raise ArgumentError,
327
+ "Table '#{tag}' is not available in #{@loading_mode} mode. " \
328
+ "Available tables: #{LoadingModes.tables_for(@loading_mode).inspect}"
329
+ else
330
+ return nil
331
+ end
332
+ end
333
+
334
+ return @parsed_tables[tag] if @parsed_tables.key?(tag)
335
+
336
+ # Lazy load table data if enabled
337
+ if @lazy_load_enabled && !@table_data.key?(tag)
338
+ load_table_data(tag)
339
+ end
340
+
171
341
  @parsed_tables[tag] ||= parse_table(tag)
172
342
  end
173
343
 
@@ -179,8 +349,133 @@ module Fontisan
179
349
  head&.units_per_em
180
350
  end
181
351
 
352
+ # Convenience methods for accessing common name table fields
353
+ # These are particularly useful in minimal mode
354
+
355
+ # Get font family name
356
+ #
357
+ # @return [String, nil] Family name or nil if not found
358
+ def family_name
359
+ name_table = table(Constants::NAME_TAG)
360
+ name_table&.english_name(Tables::Name::FAMILY)
361
+ end
362
+
363
+ # Get font subfamily name (e.g., Regular, Bold, Italic)
364
+ #
365
+ # @return [String, nil] Subfamily name or nil if not found
366
+ def subfamily_name
367
+ name_table = table(Constants::NAME_TAG)
368
+ name_table&.english_name(Tables::Name::SUBFAMILY)
369
+ end
370
+
371
+ # Get full font name
372
+ #
373
+ # @return [String, nil] Full name or nil if not found
374
+ def full_name
375
+ name_table = table(Constants::NAME_TAG)
376
+ name_table&.english_name(Tables::Name::FULL_NAME)
377
+ end
378
+
379
+ # Get PostScript name
380
+ #
381
+ # @return [String, nil] PostScript name or nil if not found
382
+ def post_script_name
383
+ name_table = table(Constants::NAME_TAG)
384
+ name_table&.english_name(Tables::Name::POSTSCRIPT_NAME)
385
+ end
386
+
387
+ # Get preferred family name
388
+ #
389
+ # @return [String, nil] Preferred family name or nil if not found
390
+ def preferred_family_name
391
+ name_table = table(Constants::NAME_TAG)
392
+ name_table&.english_name(Tables::Name::PREFERRED_FAMILY)
393
+ end
394
+
395
+ # Get preferred subfamily name
396
+ #
397
+ # @return [String, nil] Preferred subfamily name or nil if not found
398
+ def preferred_subfamily_name
399
+ name_table = table(Constants::NAME_TAG)
400
+ name_table&.english_name(Tables::Name::PREFERRED_SUBFAMILY)
401
+ end
402
+
403
+ # Close the IO source (for lazy loading)
404
+ #
405
+ # @return [void]
406
+ def close
407
+ @io_source&.close
408
+ @io_source = nil
409
+ end
410
+
411
+ # Setup finalizer for cleanup
412
+ #
413
+ # @return [void]
414
+ def setup_finalizer
415
+ ObjectSpace.define_finalizer(self, self.class.finalize(@io_source))
416
+ end
417
+
418
+ # Finalizer proc for closing IO
419
+ #
420
+ # @param io [IO] The IO object to close
421
+ # @return [Proc] The finalizer proc
422
+ def self.finalize(io)
423
+ proc { io&.close }
424
+ end
425
+
182
426
  private
183
427
 
428
+ # Load a single table's data on demand
429
+ #
430
+ # Uses page-aligned reads and caches pages to ensure lazy loading
431
+ # performance is not slower than eager loading.
432
+ #
433
+ # @param tag [String] The table tag to load
434
+ # @return [void]
435
+ def load_table_data(tag)
436
+ return unless @io_source
437
+
438
+ entry = find_table_entry(tag)
439
+ return nil unless entry
440
+
441
+ # Use page-aligned reading with caching
442
+ table_start = entry.offset
443
+ table_end = entry.offset + entry.table_length
444
+
445
+ # Calculate page boundaries
446
+ page_start = (table_start / PAGE_SIZE) * PAGE_SIZE
447
+ page_end = ((table_end + PAGE_SIZE - 1) / PAGE_SIZE) * PAGE_SIZE
448
+
449
+ # Read all required pages (or use cached pages)
450
+ table_data_parts = []
451
+ current_page = page_start
452
+
453
+ while current_page < page_end
454
+ page_data = @page_cache[current_page]
455
+
456
+ unless page_data
457
+ # Read page from disk and cache it
458
+ @io_source.seek(current_page)
459
+ page_data = @io_source.read(PAGE_SIZE) || ""
460
+ @page_cache[current_page] = page_data
461
+ end
462
+
463
+ # Calculate which part of this page we need
464
+ chunk_start = [table_start - current_page, 0].max
465
+ chunk_end = [table_end - current_page, PAGE_SIZE].min
466
+
467
+ if chunk_end > chunk_start
468
+ table_data_parts << page_data[chunk_start...chunk_end]
469
+ end
470
+
471
+ current_page += PAGE_SIZE
472
+ end
473
+
474
+ # Combine parts and store
475
+ tag_key = tag.dup.force_encoding("UTF-8")
476
+ @table_data[tag_key] = table_data_parts.join
477
+ end
478
+
184
479
  # Parse a table from raw data
185
480
  #
186
481
  # @param tag [String] The table tag to parse
@@ -202,13 +497,19 @@ module Fontisan
202
497
  def table_class_for(tag)
203
498
  {
204
499
  Constants::HEAD_TAG => Tables::Head,
500
+ Constants::HHEA_TAG => Tables::Hhea,
501
+ Constants::HMTX_TAG => Tables::Hmtx,
502
+ Constants::MAXP_TAG => Tables::Maxp,
205
503
  Constants::NAME_TAG => Tables::Name,
206
504
  Constants::OS2_TAG => Tables::Os2,
207
505
  Constants::POST_TAG => Tables::Post,
208
506
  Constants::CMAP_TAG => Tables::Cmap,
507
+ Constants::CFF_TAG => Tables::Cff,
209
508
  Constants::FVAR_TAG => Tables::Fvar,
210
509
  Constants::GSUB_TAG => Tables::Gsub,
211
510
  Constants::GPOS_TAG => Tables::Gpos,
511
+ Constants::GLYF_TAG => Tables::Glyf,
512
+ Constants::LOCA_TAG => Tables::Loca,
212
513
  }[tag]
213
514
  end
214
515
 
@@ -272,18 +573,19 @@ module Fontisan
272
573
  # @param path [String] Path to the OTF file
273
574
  # @return [void]
274
575
  def update_checksum_adjustment_in_file(path)
275
- # Calculate file checksum
276
- checksum = Utilities::ChecksumCalculator.calculate_file_checksum(path)
576
+ # Use tempfile-based checksum calculation for Windows compatibility
577
+ # This keeps the tempfile alive until we're done with the checksum
578
+ File.open(path, "r+b") do |io|
579
+ checksum, _tmpfile = Utilities::ChecksumCalculator.calculate_checksum_from_io_with_tempfile(io)
277
580
 
278
- # Calculate adjustment
279
- adjustment = Utilities::ChecksumCalculator.calculate_adjustment(checksum)
581
+ # Calculate adjustment
582
+ adjustment = Utilities::ChecksumCalculator.calculate_adjustment(checksum)
280
583
 
281
- # Find head table position
282
- head_entry = head_table
283
- return unless head_entry
584
+ # Find head table position
585
+ head_entry = head_table
586
+ return unless head_entry
284
587
 
285
- # Write adjustment to head table (offset 8 within head table)
286
- File.open(path, "r+b") do |io|
588
+ # Write adjustment to head table (offset 8 within head table)
287
589
  io.seek(head_entry.offset + 8)
288
590
  io.write([adjustment].pack("N"))
289
591
  end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ # Extensions to OpenTypeFont for table-based construction
5
+ class OpenTypeFont
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 [OpenTypeFont] 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 = 0x4F54544F # 'OTTO' for OpenType/CFF
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,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