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
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../binary/base_record"
4
+ require_relative "variation_common"
5
+
6
+ module Fontisan
7
+ module Tables
8
+ # Parser for the 'MVAR' (Metrics Variations) table
9
+ #
10
+ # The MVAR table provides variation data for global font metrics such as:
11
+ # - Ascender and descender
12
+ # - Line gap
13
+ # - Caret offsets
14
+ # - Strikeout and underline positions/sizes
15
+ # - Subscript and superscript sizes
16
+ #
17
+ # Each metric is identified by a tag and references delta sets in the
18
+ # ItemVariationStore.
19
+ #
20
+ # Reference: OpenType specification, MVAR table
21
+ #
22
+ # @example Reading an MVAR table
23
+ # data = font.table_data("MVAR")
24
+ # mvar = Fontisan::Tables::Mvar.read(data)
25
+ # hasc_deltas = mvar.metric_deltas("hasc")
26
+ class Mvar < Binary::BaseRecord
27
+ uint16 :major_version
28
+ uint16 :minor_version
29
+ uint16 :reserved
30
+ uint16 :value_record_size
31
+ uint16 :value_record_count
32
+ uint32 :item_variation_store_offset
33
+
34
+ # Value tags for standard metrics
35
+ METRIC_TAGS = {
36
+ "hasc" => :horizontal_ascender,
37
+ "hdsc" => :horizontal_descender,
38
+ "hlgp" => :horizontal_line_gap,
39
+ "hcla" => :horizontal_caret_ascender,
40
+ "hcld" => :horizontal_caret_descender,
41
+ "hcof" => :horizontal_caret_offset,
42
+ "vasc" => :vertical_ascender,
43
+ "vdsc" => :vertical_descender,
44
+ "vlgp" => :vertical_line_gap,
45
+ "vcof" => :vertical_caret_offset,
46
+ "xhgt" => :x_height,
47
+ "cpht" => :cap_height,
48
+ "sbxs" => :subscript_em_x_size,
49
+ "sbys" => :subscript_em_y_size,
50
+ "sbxo" => :subscript_em_x_offset,
51
+ "sbyo" => :subscript_em_y_offset,
52
+ "spxs" => :superscript_em_x_size,
53
+ "spys" => :superscript_em_y_size,
54
+ "spxo" => :superscript_em_x_offset,
55
+ "spyo" => :superscript_em_y_offset,
56
+ "strs" => :strikeout_size,
57
+ "stro" => :strikeout_offset,
58
+ "unds" => :underline_size,
59
+ "undo" => :underline_offset,
60
+ }.freeze
61
+
62
+ # Value record structure
63
+ class ValueRecord < Binary::BaseRecord
64
+ string :value_tag, length: 4
65
+ uint32 :delta_set_outer_index
66
+ uint32 :delta_set_inner_index
67
+
68
+ # Get the metric name for this value tag
69
+ #
70
+ # @return [Symbol, nil] Metric name or nil
71
+ def metric_name
72
+ METRIC_TAGS[value_tag]
73
+ end
74
+ end
75
+
76
+ # Get version as a float
77
+ #
78
+ # @return [Float] Version number (e.g., 1.0)
79
+ def version
80
+ major_version + (minor_version / 10.0)
81
+ end
82
+
83
+ # Parse the item variation store
84
+ #
85
+ # @return [VariationCommon::ItemVariationStore, nil] Variation store
86
+ def item_variation_store
87
+ return @item_variation_store if defined?(@item_variation_store)
88
+ return @item_variation_store = nil if item_variation_store_offset.zero?
89
+
90
+ data = raw_data
91
+ offset = item_variation_store_offset
92
+
93
+ return @item_variation_store = nil if offset >= data.bytesize
94
+
95
+ store_data = data.byteslice(offset..-1)
96
+ @item_variation_store = VariationCommon::ItemVariationStore.read(store_data)
97
+ rescue StandardError => e
98
+ warn "Failed to parse MVAR item variation store: #{e.message}"
99
+ @item_variation_store = nil
100
+ end
101
+
102
+ # Parse value records
103
+ #
104
+ # @return [Array<ValueRecord>] Value records
105
+ def value_records
106
+ return @value_records if @value_records
107
+ return @value_records = [] if value_record_count.zero?
108
+
109
+ data = raw_data
110
+ # Value records start after the header (14 bytes: 2+2+2+2+2+4)
111
+ offset = 14
112
+
113
+ @value_records = Array.new(value_record_count) do |i|
114
+ record_offset = offset + (i * value_record_size)
115
+
116
+ next nil if record_offset + value_record_size > data.bytesize
117
+
118
+ record_data = data.byteslice(record_offset, value_record_size)
119
+ ValueRecord.read(record_data)
120
+ end.compact
121
+ end
122
+
123
+ # Get value record by tag
124
+ #
125
+ # @param tag [String] Value tag (e.g., "hasc", "hdsc")
126
+ # @return [ValueRecord, nil] Value record or nil
127
+ def value_record(tag)
128
+ value_records.find { |record| record.value_tag.to_s == tag }
129
+ end
130
+
131
+ # Get delta set for a specific metric tag
132
+ #
133
+ # @param tag [String] Value tag (e.g., "hasc", "hdsc")
134
+ # @return [Array<Integer>, nil] Delta values or nil
135
+ def metric_delta_set(tag)
136
+ return nil unless item_variation_store
137
+
138
+ record = value_record(tag)
139
+ return nil if record.nil?
140
+
141
+ item_variation_store.delta_set(
142
+ record.delta_set_outer_index,
143
+ record.delta_set_inner_index,
144
+ )
145
+ end
146
+
147
+ # Get all metric tags present in this table
148
+ #
149
+ # @return [Array<String>] Array of metric tags
150
+ def metric_tags
151
+ value_records.map { |record| record.value_tag.to_s }
152
+ end
153
+
154
+ # Get all metrics as a hash
155
+ #
156
+ # @return [Hash<String, Hash>] Hash of metric tag to record info
157
+ def metrics
158
+ value_records.each_with_object({}) do |record, hash|
159
+ # Strip trailing nulls from value_tag
160
+ tag = record.value_tag.delete("\x00")
161
+ hash[tag] = {
162
+ name: record.metric_name,
163
+ outer_index: record.delta_set_outer_index,
164
+ inner_index: record.delta_set_inner_index,
165
+ }
166
+ end
167
+ end
168
+
169
+ # Check if a specific metric is present
170
+ #
171
+ # @param tag [String] Value tag
172
+ # @return [Boolean] True if metric is present
173
+ def has_metric?(tag)
174
+ !value_record(tag).nil?
175
+ end
176
+
177
+ # Check if table is valid
178
+ #
179
+ # @return [Boolean] True if valid
180
+ def valid?
181
+ major_version == 1 && minor_version.zero?
182
+ end
183
+ end
184
+ end
185
+ end
@@ -107,9 +107,13 @@ module Fontisan
107
107
  array :name_records, type: :name_record, initial_length: :record_count
108
108
  rest :string_storage
109
109
 
110
+ # Cache for decoded names
111
+ attr_accessor :decoded_names_cache
112
+
110
113
  # Hook that gets called after all fields are read
111
114
  def after_read_hook
112
- decode_all_strings
115
+ # Don't decode anything yet - wait for request
116
+ @decoded_names_cache = {}
113
117
  end
114
118
 
115
119
  # Make sure we call our hook after BinData finishes reading
@@ -125,6 +129,35 @@ module Fontisan
125
129
  record_count
126
130
  end
127
131
 
132
+ # Decode all strings from the string storage area
133
+ #
134
+ # This method can be called explicitly to decode all name records upfront.
135
+ # Useful for testing or when you know you'll need all strings.
136
+ # By default, strings are decoded lazily on demand.
137
+ #
138
+ # @return [void]
139
+ def decode_all_strings
140
+ # Get the raw string storage as a plain Ruby binary string
141
+ storage_bytes = string_storage.to_s.b
142
+
143
+ return if storage_bytes.empty?
144
+
145
+ name_records.each do |record|
146
+ # Extract string data from storage using offset and length
147
+ offset = record.string_offset
148
+ length = record.string_length
149
+
150
+ # Validate bounds
151
+ next if offset.nil? || length.nil?
152
+ next if offset + length > storage_bytes.bytesize
153
+ next if length.zero?
154
+
155
+ # Slice the bytes from storage
156
+ string_data = storage_bytes.byteslice(offset, length)
157
+ record.decode_string(string_data) if string_data && !string_data.empty?
158
+ end
159
+ end
160
+
128
161
  # Find an English name for the given name ID
129
162
  #
130
163
  # Priority: Platform 3 (Windows) with language 0x0409 (US English)
@@ -133,21 +166,28 @@ module Fontisan
133
166
  # @param name_id [Integer] The name ID to search for
134
167
  # @return [String, nil] The decoded string or nil if not found
135
168
  def english_name(name_id)
136
- # First try Windows English
137
- record = name_records.find do |rec|
138
- rec.name_id == name_id &&
139
- rec.platform_id == PLATFORM_WINDOWS &&
140
- rec.language_id == WINDOWS_LANGUAGE_EN_US
141
- end
169
+ # Check cache first
170
+ return @decoded_names_cache[name_id] if @decoded_names_cache.key?(name_id)
142
171
 
143
- # Fallback to Mac English
144
- record ||= name_records.find do |rec|
145
- rec.name_id == name_id &&
146
- rec.platform_id == PLATFORM_MACINTOSH &&
147
- rec.language_id == MAC_LANGUAGE_ENGLISH
148
- end
172
+ # Find record (don't decode yet)
173
+ record = find_name_record(
174
+ name_id,
175
+ platform: PLATFORM_WINDOWS,
176
+ language: WINDOWS_LANGUAGE_EN_US
177
+ )
149
178
 
150
- record&.string
179
+ record ||= find_name_record(
180
+ name_id,
181
+ platform: PLATFORM_MACINTOSH,
182
+ language: MAC_LANGUAGE_ENGLISH
183
+ )
184
+
185
+ return nil unless record
186
+
187
+ # Decode only this one record
188
+ decoded = decode_name_record(record)
189
+ @decoded_names_cache[name_id] = decoded
190
+ decoded
151
191
  end
152
192
 
153
193
  # Validate the table
@@ -161,27 +201,56 @@ module Fontisan
161
201
 
162
202
  private
163
203
 
164
- # Decode all strings from the string storage area
165
- def decode_all_strings
166
- # Get the raw string storage as a plain Ruby binary string
204
+ # Find a name record matching the criteria
205
+ #
206
+ # @param name_id [Integer] The name ID to search for
207
+ # @param platform [Integer] The platform ID
208
+ # @param language [Integer] The language ID
209
+ # @return [NameRecord, nil] The matching record or nil
210
+ def find_name_record(name_id, platform:, language:)
211
+ name_records.find do |rec|
212
+ rec.name_id == name_id &&
213
+ rec.platform_id == platform &&
214
+ rec.language_id == language
215
+ end
216
+ end
217
+
218
+ # Decode a single name record on demand
219
+ #
220
+ # @param record [NameRecord] The record to decode
221
+ # @return [String] The decoded string
222
+ def decode_name_record(record)
223
+ # Get raw string storage
167
224
  storage_bytes = string_storage.to_s.b
168
225
 
169
- return if storage_bytes.empty?
226
+ # Extract this record's string
227
+ offset = record.string_offset
228
+ length = record.string_length
170
229
 
171
- name_records.each do |record|
172
- # Extract string data from storage using offset and length
173
- offset = record.string_offset
174
- length = record.string_length
230
+ return nil if offset + length > storage_bytes.bytesize
231
+ return nil if length.zero?
175
232
 
176
- # Validate bounds
177
- next if offset.nil? || length.nil?
178
- next if offset + length > storage_bytes.bytesize
179
- next if length.zero?
233
+ string_data = storage_bytes.byteslice(offset, length)
180
234
 
181
- # Slice the bytes from storage
182
- string_data = storage_bytes.byteslice(offset, length)
183
- record.decode_string(string_data) if string_data && !string_data.empty?
184
- end
235
+ # Decode based on platform
236
+ decoded = case record.platform_id
237
+ when PLATFORM_WINDOWS, PLATFORM_UNICODE
238
+ string_data.dup.force_encoding("UTF-16BE")
239
+ .encode("UTF-8", invalid: :replace, undef: :replace)
240
+ when PLATFORM_MACINTOSH
241
+ string_data.dup.force_encoding("ASCII-8BIT")
242
+ .encode("UTF-8", invalid: :replace, undef: :replace)
243
+ else
244
+ string_data.dup.force_encoding("UTF-8")
245
+ end
246
+
247
+ # Intern common strings to reduce memory usage
248
+ interned = Fontisan::Constants.intern_string(decoded)
249
+
250
+ # Also populate the record's string attribute for backward compatibility
251
+ record.string = interned
252
+
253
+ interned
185
254
  end
186
255
  end
187
256
  end
@@ -0,0 +1,346 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+ require_relative "../binary/base_record"
5
+
6
+ module Fontisan
7
+ module Tables
8
+ # Shared structures for OpenType variation tables (HVAR, VVAR, MVAR, etc.)
9
+ #
10
+ # These structures are used across multiple variation tables to define
11
+ # variation regions in design space and organize delta values that are
12
+ # applied based on axis coordinates.
13
+ #
14
+ # Reference: OpenType specification, Variation Common Table Formats
15
+ module VariationCommon
16
+ # Variation region in design space
17
+ #
18
+ # A region is defined by ranges on one or more axes. Each region has a
19
+ # scalar value (0.0 to 1.0) that determines how much its deltas contribute
20
+ # based on the current design coordinates.
21
+ class RegionAxisCoordinates < Binary::BaseRecord
22
+ int16 :start_coord # Start of region range (F2DOT14)
23
+ int16 :peak_coord # Peak value in range (F2DOT14)
24
+ int16 :end_coord # End of region range (F2DOT14)
25
+
26
+ # Convert start coordinate from F2DOT14 to float
27
+ #
28
+ # @return [Float] Start coordinate value
29
+ def start
30
+ f2dot14_to_float(start_coord)
31
+ end
32
+
33
+ # Convert peak coordinate from F2DOT14 to float
34
+ #
35
+ # @return [Float] Peak coordinate value
36
+ def peak
37
+ f2dot14_to_float(peak_coord)
38
+ end
39
+
40
+ # Convert end coordinate from F2DOT14 to float
41
+ #
42
+ # @return [Float] End coordinate value
43
+ def end_value
44
+ f2dot14_to_float(end_coord)
45
+ end
46
+
47
+ private
48
+
49
+ # Convert F2DOT14 fixed-point (2.14) to float
50
+ #
51
+ # @param value [Integer] F2DOT14 value
52
+ # @return [Float] Floating-point value
53
+ def f2dot14_to_float(value)
54
+ # Handle signed 16-bit value
55
+ signed = value > 0x7FFF ? value - 0x10000 : value
56
+ signed / 16384.0
57
+ end
58
+ end
59
+
60
+ # Variation region definition
61
+ #
62
+ # Defines a region in design space with coordinate ranges for each axis.
63
+ class VariationRegion < Binary::BaseRecord
64
+ # Region axis coordinates array - length determined by axis count
65
+ # This is manually parsed by the parent VariationRegionList
66
+
67
+ # Parse region axis coordinates
68
+ #
69
+ # @param data [String] Binary data
70
+ # @param axis_count [Integer] Number of axes
71
+ # @return [Array<RegionAxisCoordinates>] Axis coordinates
72
+ def self.parse_coordinates(data, axis_count)
73
+ io = StringIO.new(data)
74
+ io.set_encoding(Encoding::BINARY)
75
+
76
+ Array.new(axis_count) do
77
+ coord_data = io.read(6) # 3 * int16
78
+ next nil if coord_data.nil? || coord_data.bytesize < 6
79
+
80
+ RegionAxisCoordinates.read(coord_data)
81
+ end.compact
82
+ end
83
+ end
84
+
85
+ # Variation region list
86
+ #
87
+ # Contains the regions used by variation data. Multiple variation tables
88
+ # can reference the same region list.
89
+ class VariationRegionList < Binary::BaseRecord
90
+ uint16 :axis_count
91
+ uint16 :region_count
92
+
93
+ # Parse all variation regions
94
+ #
95
+ # @return [Array<Array<RegionAxisCoordinates>>] Array of regions
96
+ def regions
97
+ return @regions if @regions
98
+ return @regions = [] if region_count.zero?
99
+
100
+ data = raw_data
101
+ offset = 4 # After axis_count and region_count
102
+
103
+ @regions = Array.new(region_count) do |i|
104
+ region_offset = offset + (i * axis_count * 6)
105
+ region_size = axis_count * 6
106
+
107
+ next nil if region_offset + region_size > data.bytesize
108
+
109
+ region_data = data.byteslice(region_offset, region_size)
110
+ VariationRegion.parse_coordinates(region_data, axis_count)
111
+ end.compact
112
+ end
113
+ end
114
+
115
+ # Item variation data
116
+ #
117
+ # Contains delta values for a set of items. Each item can have deltas
118
+ # for multiple regions.
119
+ class ItemVariationData < Binary::BaseRecord
120
+ uint16 :item_count
121
+ uint16 :short_delta_count
122
+ uint16 :region_index_count
123
+
124
+ # Parse region indices
125
+ #
126
+ # @return [Array<Integer>] Region indices
127
+ def region_indices
128
+ return @region_indices if @region_indices
129
+ return @region_indices = [] if region_index_count.zero?
130
+
131
+ data = raw_data
132
+ offset = 6 # After header fields
133
+
134
+ @region_indices = Array.new(region_index_count) do |i|
135
+ idx_offset = offset + (i * 2)
136
+ next nil if idx_offset + 2 > data.bytesize
137
+
138
+ data.byteslice(idx_offset, 2).unpack1("n")
139
+ end.compact
140
+ end
141
+
142
+ # Parse delta sets for all items
143
+ #
144
+ # @return [Array<Array<Integer>>] Delta sets for each item
145
+ def delta_sets
146
+ return @delta_sets if @delta_sets
147
+ return @delta_sets = [] if item_count.zero?
148
+
149
+ data = raw_data
150
+ # Delta data starts after header and region indices
151
+ offset = 6 + (region_index_count * 2)
152
+
153
+ # Each item has region_index_count deltas
154
+ # short_delta_count are int16, rest are int8
155
+ long_count = region_index_count - short_delta_count
156
+
157
+ # Safety check: long_count should not be negative
158
+ if long_count.negative?
159
+ warn "ItemVariationData parsing error: short_delta_count (#{short_delta_count}) > region_index_count (#{region_index_count})"
160
+ return @delta_sets = []
161
+ end
162
+
163
+ @delta_sets = Array.new(item_count) do |i|
164
+ item_offset = offset + (i * (short_delta_count * 2 + long_count))
165
+
166
+ # Read short deltas (int16)
167
+ shorts = Array.new(short_delta_count) do |j|
168
+ delta_offset = item_offset + (j * 2)
169
+ next nil if delta_offset + 2 > data.bytesize
170
+
171
+ # Signed 16-bit
172
+ value = data.byteslice(delta_offset, 2).unpack1("n")
173
+ value > 0x7FFF ? value - 0x10000 : value
174
+ end.compact
175
+
176
+ # Read long deltas (int8)
177
+ longs = Array.new(long_count) do |j|
178
+ delta_offset = item_offset + (short_delta_count * 2) + j
179
+ next nil if delta_offset + 1 > data.bytesize
180
+
181
+ # Signed 8-bit
182
+ value = data.byteslice(delta_offset, 1).unpack1("C")
183
+ value > 0x7F ? value - 0x100 : value
184
+ end.compact
185
+
186
+ shorts + longs
187
+ end
188
+ end
189
+ end
190
+
191
+ # Item variation store
192
+ #
193
+ # Hierarchical storage for delta values. Contains variation data entries
194
+ # and a region list that defines variation regions in design space.
195
+ #
196
+ # Used by: HVAR, VVAR, MVAR tables
197
+ class ItemVariationStore < Binary::BaseRecord
198
+ uint16 :format
199
+ uint32 :variation_region_list_offset
200
+ uint16 :item_variation_data_count
201
+
202
+ # Parse variation region list
203
+ #
204
+ # @return [VariationRegionList, nil] Region list or nil
205
+ def variation_region_list
206
+ return @variation_region_list if defined?(@variation_region_list)
207
+ return @variation_region_list = nil if variation_region_list_offset.zero?
208
+
209
+ data = raw_data
210
+ offset = variation_region_list_offset
211
+
212
+ return @variation_region_list = nil if offset >= data.bytesize
213
+
214
+ region_data = data.byteslice(offset..-1)
215
+ @variation_region_list = VariationRegionList.read(region_data)
216
+ rescue StandardError
217
+ @variation_region_list = nil
218
+ end
219
+
220
+ # Parse item variation data offsets
221
+ #
222
+ # @return [Array<Integer>] Offsets to ItemVariationData
223
+ def item_variation_data_offsets
224
+ return @data_offsets if @data_offsets
225
+ return @data_offsets = [] if item_variation_data_count.zero?
226
+
227
+ data = raw_data
228
+ offset = 8 # After header fields
229
+
230
+ @data_offsets = Array.new(item_variation_data_count) do |i|
231
+ offset_pos = offset + (i * 4)
232
+ next nil if offset_pos + 4 > data.bytesize
233
+
234
+ data.byteslice(offset_pos, 4).unpack1("N")
235
+ end.compact
236
+ end
237
+
238
+ # Parse all item variation data entries
239
+ #
240
+ # @return [Array<ItemVariationData>] Variation data entries
241
+ def item_variation_data_entries
242
+ return @data_entries if @data_entries
243
+ return @data_entries = [] if item_variation_data_count.zero?
244
+
245
+ data = raw_data
246
+ offsets = item_variation_data_offsets
247
+
248
+ @data_entries = offsets.map do |data_offset|
249
+ next nil if data_offset >= data.bytesize
250
+
251
+ entry_data = data.byteslice(data_offset..-1)
252
+ ItemVariationData.read(entry_data)
253
+ end.compact
254
+ rescue StandardError
255
+ @data_entries = []
256
+ end
257
+
258
+ # Get delta set for specific item
259
+ #
260
+ # @param outer_index [Integer] Outer index (data entry)
261
+ # @param inner_index [Integer] Inner index (item within entry)
262
+ # @return [Array<Integer>, nil] Delta values or nil
263
+ def delta_set(outer_index, inner_index)
264
+ return nil if outer_index >= item_variation_data_count
265
+
266
+ entry = item_variation_data_entries[outer_index]
267
+ return nil if entry.nil? || inner_index >= entry.item_count
268
+
269
+ entry.delta_sets[inner_index]
270
+ end
271
+ end
272
+
273
+ # Delta set index mapping
274
+ #
275
+ # Maps glyph IDs to delta set indices in an ItemVariationStore.
276
+ # Used for efficient lookup of variation data.
277
+ class DeltaSetIndexMap < Binary::BaseRecord
278
+ uint8 :format
279
+ uint8 :entry_format
280
+
281
+ # Get map data based on format
282
+ #
283
+ # @return [Array<Integer>] Map data
284
+ def map_data
285
+ return @map_data if @map_data
286
+
287
+ data = raw_data
288
+
289
+ case format
290
+ when 0
291
+ parse_format0(data)
292
+ when 1
293
+ parse_format1(data)
294
+ else
295
+ @map_data = []
296
+ end
297
+ end
298
+
299
+ private
300
+
301
+ # Parse format 0 map data
302
+ def parse_format0(data)
303
+ # Format 0: mapCount + mapData array
304
+ return [] if data.bytesize < 4
305
+
306
+ map_count = data.byteslice(2, 2).unpack1("n")
307
+
308
+ # entry_format bits 4-5: outer size - 1, bits 0-3: inner size - 1
309
+ outer_size = ((entry_format >> 4) & 0x3) + 1
310
+ inner_size = (entry_format & 0xF) + 1
311
+ entry_size = outer_size + inner_size
312
+
313
+ @map_data = Array.new(map_count) do |i|
314
+ offset = 4 + (i * entry_size)
315
+ next nil if offset + entry_size > data.bytesize
316
+
317
+ # Read entry and combine outer and inner indices
318
+ # For simplicity, treat as combined integer
319
+ case entry_size
320
+ when 1
321
+ data.byteslice(offset, 1).unpack1("C")
322
+ when 2
323
+ data.byteslice(offset, 2).unpack1("n")
324
+ when 3
325
+ bytes = data.byteslice(offset, 3).unpack("C3")
326
+ (bytes[0] << 16) | (bytes[1] << 8) | bytes[2]
327
+ when 4
328
+ data.byteslice(offset, 4).unpack1("N")
329
+ else
330
+ # For larger sizes, read as big-endian integer
331
+ bytes = data.byteslice(offset, entry_size).unpack("C*")
332
+ bytes.reduce(0) { |acc, b| (acc << 8) | b }
333
+ end
334
+ end.compact
335
+ end
336
+
337
+ # Parse format 1 map data
338
+ def parse_format1(_data)
339
+ # Format 1: More complex with map count and data
340
+ # Simplified implementation
341
+ @map_data = []
342
+ end
343
+ end
344
+ end
345
+ end
346
+ end