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,270 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+ require_relative "../binary/base_record"
5
+
6
+ module Fontisan
7
+ module Tables
8
+ # Parser for the 'gvar' (Glyph Variations) table
9
+ #
10
+ # The gvar table provides variation data for glyph outlines in TrueType
11
+ # variable fonts. It contains delta values for each glyph's control points
12
+ # that are applied based on the current design space coordinates.
13
+ #
14
+ # Unlike HVAR/VVAR/MVAR which use ItemVariationStore, gvar uses a
15
+ # TupleVariationStore structure with packed delta values.
16
+ #
17
+ # Reference: OpenType specification, gvar table
18
+ #
19
+ # @example Reading a gvar table
20
+ # data = font.table_data("gvar")
21
+ # gvar = Fontisan::Tables::Gvar.read(data)
22
+ # deltas = gvar.glyph_variations(glyph_id)
23
+ class Gvar < Binary::BaseRecord
24
+ uint16 :major_version
25
+ uint16 :minor_version
26
+ uint16 :axis_count
27
+ uint16 :shared_tuple_count
28
+ uint32 :shared_tuples_offset
29
+ uint16 :glyph_count
30
+ uint16 :flags
31
+ uint32 :glyph_variation_data_array_offset
32
+
33
+ # Flags
34
+ SHARED_POINT_NUMBERS = 0x8000
35
+ LONG_OFFSETS = 0x0001
36
+
37
+ # Tuple variation header
38
+ class TupleVariationHeader < Binary::BaseRecord
39
+ uint16 :variation_data_size
40
+ uint16 :tuple_index
41
+
42
+ # Tuple index flags
43
+ EMBEDDED_PEAK_TUPLE = 0x8000
44
+ INTERMEDIATE_REGION = 0x4000
45
+ PRIVATE_POINT_NUMBERS = 0x2000
46
+ TUPLE_INDEX_MASK = 0x0FFF
47
+
48
+ # Check if tuple has embedded peak coordinates
49
+ #
50
+ # @return [Boolean] True if embedded
51
+ def embedded_peak_tuple?
52
+ (tuple_index & EMBEDDED_PEAK_TUPLE) != 0
53
+ end
54
+
55
+ # Check if tuple has intermediate region
56
+ #
57
+ # @return [Boolean] True if intermediate region
58
+ def intermediate_region?
59
+ (tuple_index & INTERMEDIATE_REGION) != 0
60
+ end
61
+
62
+ # Check if tuple has private point numbers
63
+ #
64
+ # @return [Boolean] True if private points
65
+ def private_point_numbers?
66
+ (tuple_index & PRIVATE_POINT_NUMBERS) != 0
67
+ end
68
+
69
+ # Get shared tuple index
70
+ #
71
+ # @return [Integer] Tuple index
72
+ def shared_tuple_index
73
+ tuple_index & TUPLE_INDEX_MASK
74
+ end
75
+ end
76
+
77
+ # Get version as a float
78
+ #
79
+ # @return [Float] Version number (e.g., 1.0)
80
+ def version
81
+ major_version + (minor_version / 10.0)
82
+ end
83
+
84
+ # Check if using long offsets
85
+ #
86
+ # @return [Boolean] True if long offsets
87
+ def long_offsets?
88
+ (flags & LONG_OFFSETS) != 0
89
+ end
90
+
91
+ # Check if using shared point numbers
92
+ #
93
+ # @return [Boolean] True if shared points
94
+ def shared_point_numbers?
95
+ (flags & SHARED_POINT_NUMBERS) != 0
96
+ end
97
+
98
+ # Parse shared tuples
99
+ #
100
+ # @return [Array<Array<Integer>>] Shared peak tuples
101
+ def shared_tuples
102
+ return @shared_tuples if @shared_tuples
103
+ return @shared_tuples = [] if shared_tuple_count.zero?
104
+
105
+ data = raw_data
106
+ offset = shared_tuples_offset
107
+
108
+ return @shared_tuples = [] if offset >= data.bytesize
109
+
110
+ @shared_tuples = Array.new(shared_tuple_count) do |i|
111
+ tuple_offset = offset + (i * axis_count * 2)
112
+
113
+ Array.new(axis_count) do |j|
114
+ coord_offset = tuple_offset + (j * 2)
115
+ next nil if coord_offset + 2 > data.bytesize
116
+
117
+ # F2DOT14 format
118
+ value = data.byteslice(coord_offset, 2).unpack1("n")
119
+ signed = value > 0x7FFF ? value - 0x10000 : value
120
+ signed / 16384.0
121
+ end.compact
122
+ end.compact
123
+ end
124
+
125
+ # Parse glyph variation data offsets
126
+ #
127
+ # @return [Array<Integer>] Array of offsets
128
+ def glyph_variation_data_offsets
129
+ return @glyph_offsets if @glyph_offsets
130
+
131
+ data = raw_data
132
+ # Offsets start after the header (20 bytes)
133
+ offset = 20
134
+
135
+ offset_size = long_offsets? ? 4 : 2
136
+ offset_count = glyph_count + 1 # One extra for the end
137
+
138
+ @glyph_offsets = Array.new(offset_count) do |i|
139
+ offset_pos = offset + (i * offset_size)
140
+ next nil if offset_pos + offset_size > data.bytesize
141
+
142
+ raw_offset = if long_offsets?
143
+ data.byteslice(offset_pos, 4).unpack1("N")
144
+ else
145
+ data.byteslice(offset_pos, 2).unpack1("n") * 2
146
+ end
147
+
148
+ glyph_variation_data_array_offset + raw_offset
149
+ end.compact
150
+ end
151
+
152
+ # Get variation data for a specific glyph
153
+ #
154
+ # @param glyph_id [Integer] Glyph ID
155
+ # @return [String, nil] Raw variation data or nil
156
+ def glyph_variation_data(glyph_id)
157
+ return nil if glyph_id >= glyph_count
158
+
159
+ offsets = glyph_variation_data_offsets
160
+ return nil if glyph_id >= offsets.length - 1
161
+
162
+ start_offset = offsets[glyph_id]
163
+ end_offset = offsets[glyph_id + 1]
164
+
165
+ return nil if start_offset == end_offset # No data
166
+
167
+ data = raw_data
168
+ return nil if end_offset > data.bytesize
169
+
170
+ data.byteslice(start_offset, end_offset - start_offset)
171
+ end
172
+
173
+ # Parse tuple variation headers for a glyph
174
+ #
175
+ # @param glyph_id [Integer] Glyph ID
176
+ # @return [Array<Hash>, nil] Array of tuple info or nil
177
+ def glyph_tuple_variations(glyph_id)
178
+ var_data = glyph_variation_data(glyph_id)
179
+ return nil if var_data.nil? || var_data.empty?
180
+
181
+ io = StringIO.new(var_data)
182
+ io.set_encoding(Encoding::BINARY)
183
+
184
+ # Read header
185
+ tuple_count_and_offset = io.read(4).unpack1("N")
186
+ tuple_count = tuple_count_and_offset >> 16
187
+ data_offset = tuple_count_and_offset & 0xFFFF
188
+
189
+ # Check for shared point numbers
190
+ has_shared_points = (tuple_count & 0x8000) != 0
191
+ tuple_count &= 0x0FFF
192
+
193
+ return [] if tuple_count.zero?
194
+
195
+ # Parse each tuple
196
+ tuples = []
197
+ tuple_count.times do
198
+ header_data = io.read(4)
199
+ break if header_data.nil? || header_data.bytesize < 4
200
+
201
+ header = TupleVariationHeader.read(header_data)
202
+
203
+ tuple_info = {
204
+ data_size: header.variation_data_size,
205
+ embedded_peak: header.embedded_peak_tuple?,
206
+ intermediate: header.intermediate_region?,
207
+ private_points: header.private_point_numbers?,
208
+ shared_index: header.shared_tuple_index,
209
+ }
210
+
211
+ # Read peak tuple if embedded
212
+ if header.embedded_peak_tuple?
213
+ peak = Array.new(axis_count) do
214
+ coord_data = io.read(2)
215
+ break nil if coord_data.nil?
216
+
217
+ value = coord_data.unpack1("n")
218
+ signed = value > 0x7FFF ? value - 0x10000 : value
219
+ signed / 16384.0
220
+ end
221
+ tuple_info[:peak] = peak.compact
222
+ end
223
+
224
+ # Read intermediate region if present
225
+ if header.intermediate_region?
226
+ start_tuple = Array.new(axis_count) do
227
+ coord_data = io.read(2)
228
+ break nil if coord_data.nil?
229
+
230
+ value = coord_data.unpack1("n")
231
+ signed = value > 0x7FFF ? value - 0x10000 : value
232
+ signed / 16384.0
233
+ end
234
+
235
+ end_tuple = Array.new(axis_count) do
236
+ coord_data = io.read(2)
237
+ break nil if coord_data.nil?
238
+
239
+ value = coord_data.unpack1("n")
240
+ signed = value > 0x7FFF ? value - 0x10000 : value
241
+ signed / 16384.0
242
+ end
243
+
244
+ tuple_info[:start] = start_tuple.compact
245
+ tuple_info[:end] = end_tuple.compact
246
+ end
247
+
248
+ tuples << tuple_info
249
+ end
250
+
251
+ {
252
+ tuple_count: tuple_count,
253
+ has_shared_points: has_shared_points,
254
+ data_offset: data_offset,
255
+ tuples: tuples,
256
+ }
257
+ rescue StandardError => e
258
+ warn "Failed to parse glyph tuple variations: #{e.message}"
259
+ nil
260
+ end
261
+
262
+ # Check if table is valid
263
+ #
264
+ # @return [Boolean] True if valid
265
+ def valid?
266
+ major_version == 1 && minor_version.zero?
267
+ end
268
+ end
269
+ end
270
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../binary/base_record"
4
+
5
+ module Fontisan
6
+ module Tables
7
+ # BinData structure for the 'hhea' (Horizontal Header) table
8
+ #
9
+ # The hhea table contains horizontal layout metrics for the entire font.
10
+ # It defines font-wide horizontal metrics such as ascent, descent, line
11
+ # gap, and the number of horizontal metrics in the hmtx table.
12
+ #
13
+ # Reference: OpenType specification, hhea table
14
+ # https://docs.microsoft.com/en-us/typography/opentype/spec/hhea
15
+ #
16
+ # @example Reading an hhea table
17
+ # data = File.binread("font.ttf", 36, hhea_offset)
18
+ # hhea = Fontisan::Tables::Hhea.read(data)
19
+ # puts hhea.ascent # => 2048
20
+ # puts hhea.descent # => -512
21
+ # puts hhea.version_number # => 1.0
22
+ class Hhea < Binary::BaseRecord
23
+ # Table size in bytes (fixed size)
24
+ TABLE_SIZE = 36
25
+
26
+ # Version as 16.16 fixed-point (stored as int32)
27
+ # Typically 0x00010000 (1.0)
28
+ int32 :version_raw
29
+
30
+ # Typographic ascent (distance from baseline to highest ascender)
31
+ # Positive value in FUnits
32
+ int16 :ascent
33
+
34
+ # Typographic descent (distance from baseline to lowest descender)
35
+ # Negative value in FUnits
36
+ int16 :descent
37
+
38
+ # Typographic line gap (additional space between lines)
39
+ # Non-negative value in FUnits
40
+ int16 :line_gap
41
+
42
+ # Maximum advance width value in hmtx table
43
+ uint16 :advance_width_max
44
+
45
+ # Minimum left sidebearing value in hmtx table
46
+ int16 :min_left_side_bearing
47
+
48
+ # Minimum right sidebearing value in hmtx table
49
+ int16 :min_right_side_bearing
50
+
51
+ # Maximum extent: max(lsb + (xMax - xMin))
52
+ int16 :x_max_extent
53
+
54
+ # Used to calculate slope of the cursor (rise/run)
55
+ # For vertical text: rise = 1, run = 0
56
+ # For italic text: rise != run
57
+ int16 :caret_slope_rise
58
+
59
+ # Used to calculate slope of the cursor (rise/run)
60
+ # For vertical text: run = 0
61
+ int16 :caret_slope_run
62
+
63
+ # Amount by which slanted highlight should be shifted
64
+ int16 :caret_offset
65
+
66
+ # Reserved fields (must be zero)
67
+ # 4 x int16 = 8 bytes
68
+ skip length: 8
69
+
70
+ # Format of metric data (0 for current format)
71
+ int16 :metric_data_format
72
+
73
+ # Number of hMetric entries in hmtx table
74
+ # Must be >= 1
75
+ uint16 :number_of_h_metrics
76
+
77
+ # Convert version from fixed-point to float
78
+ #
79
+ # @return [Float] Version number (typically 1.0)
80
+ def version
81
+ fixed_to_float(version_raw)
82
+ end
83
+
84
+ # Check if the table is valid
85
+ #
86
+ # @return [Boolean] True if valid, false otherwise
87
+ def valid?
88
+ # Version should be 1.0 (0x00010000)
89
+ return false unless version_raw == 0x00010000
90
+
91
+ # Metric data format must be 0
92
+ return false unless metric_data_format.zero?
93
+
94
+ # Number of metrics must be at least 1
95
+ return false unless number_of_h_metrics >= 1
96
+
97
+ true
98
+ end
99
+
100
+ # Validate the table and raise error if invalid
101
+ #
102
+ # @raise [Fontisan::CorruptedTableError] If table is invalid
103
+ def validate!
104
+ unless version_raw == 0x00010000
105
+ message = "Invalid hhea version: expected 0x00010000 (1.0), " \
106
+ "got 0x#{version_raw.to_i.to_s(16).upcase}"
107
+ raise Fontisan::CorruptedTableError, message
108
+ end
109
+
110
+ unless metric_data_format.zero?
111
+ message = "Invalid metric data format: expected 0, " \
112
+ "got #{metric_data_format.to_i}"
113
+ raise Fontisan::CorruptedTableError, message
114
+ end
115
+
116
+ unless number_of_h_metrics >= 1
117
+ message = "Invalid number of h metrics: must be >= 1, " \
118
+ "got #{number_of_h_metrics.to_i}"
119
+ raise Fontisan::CorruptedTableError, message
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,287 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../binary/base_record"
4
+
5
+ module Fontisan
6
+ module Tables
7
+ # Parser for the 'hmtx' (Horizontal Metrics) table
8
+ #
9
+ # The hmtx table contains horizontal metrics for each glyph in the font.
10
+ # It provides advance width and left sidebearing values needed for proper
11
+ # glyph positioning and text layout.
12
+ #
13
+ # Structure:
14
+ # - hMetrics[numberOfHMetrics]: Array of LongHorMetric records
15
+ # Each record contains:
16
+ # - advanceWidth (uint16): Advance width in FUnits
17
+ # - lsb (int16): Left side bearing in FUnits
18
+ # - leftSideBearings[numGlyphs - numberOfHMetrics]: Array of int16 values
19
+ # Additional LSB values for glyphs beyond numberOfHMetrics
20
+ #
21
+ # The table is context-dependent and requires:
22
+ # - numberOfHMetrics from hhea table
23
+ # - numGlyphs from maxp table
24
+ #
25
+ # Reference: OpenType specification, hmtx table
26
+ # https://docs.microsoft.com/en-us/typography/opentype/spec/hmtx
27
+ #
28
+ # @example Parsing hmtx with context
29
+ # # Get required tables first
30
+ # hhea = font.table('hhea')
31
+ # maxp = font.table('maxp')
32
+ #
33
+ # # Parse hmtx with context
34
+ # data = font.read_table_data('hmtx')
35
+ # hmtx = Fontisan::Tables::Hmtx.read(data)
36
+ # hmtx.parse_with_context(hhea.number_of_h_metrics, maxp.num_glyphs)
37
+ #
38
+ # # Get metrics for a glyph
39
+ # metric = hmtx.metric_for(42)
40
+ # puts "Advance width: #{metric[:advance_width]}"
41
+ # puts "LSB: #{metric[:lsb]}"
42
+ class Hmtx < Binary::BaseRecord
43
+ # LongHorMetric record structure
44
+ #
45
+ # @!attribute advance_width
46
+ # @return [Integer] Advance width in FUnits
47
+ # @!attribute lsb
48
+ # @return [Integer] Left side bearing in FUnits
49
+ class LongHorMetric < Binary::BaseRecord
50
+ uint16 :advance_width
51
+ int16 :lsb
52
+
53
+ # Convert to hash for convenience
54
+ #
55
+ # @return [Hash] Hash with :advance_width and :lsb keys
56
+ def to_h
57
+ { advance_width: advance_width, lsb: lsb }
58
+ end
59
+ end
60
+
61
+ # Store the raw data for deferred parsing
62
+ attr_accessor :raw_data
63
+
64
+ # Parsed horizontal metrics array
65
+ # @return [Array<Hash>] Array of metrics hashes
66
+ attr_reader :h_metrics
67
+
68
+ # Parsed left side bearings array
69
+ # @return [Array<Integer>] Array of LSB values
70
+ attr_reader :left_side_bearings
71
+
72
+ # Number of horizontal metrics from hhea table
73
+ # @return [Integer] Number of LongHorMetric records
74
+ attr_reader :number_of_h_metrics
75
+
76
+ # Total number of glyphs from maxp table
77
+ # @return [Integer] Total glyph count
78
+ attr_reader :num_glyphs
79
+
80
+ # Override read to capture raw data
81
+ #
82
+ # @param io [IO, String] Input data
83
+ # @return [Hmtx] Parsed table instance
84
+ def self.read(io)
85
+ instance = new
86
+
87
+ # Handle nil or empty data gracefully
88
+ instance.raw_data = if io.nil?
89
+ "".b
90
+ elsif io.is_a?(String)
91
+ io
92
+ else
93
+ io.read || "".b
94
+ end
95
+
96
+ instance
97
+ end
98
+
99
+ # Parse the table with font context
100
+ #
101
+ # This method must be called after reading the table data, providing
102
+ # the numberOfHMetrics from hhea and numGlyphs from maxp.
103
+ #
104
+ # @param number_of_h_metrics [Integer] Number of LongHorMetric records (from hhea)
105
+ # @param num_glyphs [Integer] Total number of glyphs (from maxp)
106
+ # @raise [ArgumentError] If context parameters are invalid
107
+ # @raise [Fontisan::CorruptedTableError] If table data is insufficient
108
+ def parse_with_context(number_of_h_metrics, num_glyphs)
109
+ validate_context_params(number_of_h_metrics, num_glyphs)
110
+
111
+ @number_of_h_metrics = number_of_h_metrics
112
+ @num_glyphs = num_glyphs
113
+
114
+ io = StringIO.new(raw_data)
115
+ io.set_encoding(Encoding::BINARY)
116
+
117
+ # Parse hMetrics array
118
+ @h_metrics = parse_h_metrics(io, number_of_h_metrics)
119
+
120
+ # Parse additional left side bearings
121
+ lsb_count = num_glyphs - number_of_h_metrics
122
+ @left_side_bearings = parse_left_side_bearings(io, lsb_count)
123
+
124
+ validate_parsed_data!(io)
125
+ end
126
+
127
+ # Get horizontal metrics for a specific glyph ID
128
+ #
129
+ # For glyph IDs less than numberOfHMetrics, returns the corresponding
130
+ # hMetrics entry. For glyph IDs >= numberOfHMetrics, uses the last
131
+ # advance width from hMetrics with the indexed left side bearing.
132
+ #
133
+ # @param glyph_id [Integer] Glyph ID (0-based)
134
+ # @return [Hash, nil] Hash with :advance_width and :lsb keys, or nil if invalid
135
+ # @raise [RuntimeError] If table has not been parsed with context
136
+ #
137
+ # @example Getting metrics
138
+ # metric = hmtx.metric_for(0) # .notdef glyph
139
+ # metric = hmtx.metric_for(65) # 'A' glyph (if mapped to 65)
140
+ def metric_for(glyph_id)
141
+ raise "Table not parsed. Call parse_with_context first." unless @h_metrics
142
+
143
+ return nil if glyph_id >= num_glyphs || glyph_id.negative?
144
+
145
+ if glyph_id < h_metrics.length
146
+ # Direct lookup in hMetrics array
147
+ h_metrics[glyph_id]
148
+ else
149
+ # Use last advance width with indexed LSB
150
+ lsb_index = glyph_id - h_metrics.length
151
+ {
152
+ advance_width: h_metrics.last[:advance_width],
153
+ lsb: left_side_bearings[lsb_index],
154
+ }
155
+ end
156
+ end
157
+
158
+ # Check if the table has been parsed with context
159
+ #
160
+ # @return [Boolean] True if parsed, false otherwise
161
+ def parsed?
162
+ !@h_metrics.nil?
163
+ end
164
+
165
+ # Get the expected minimum size for this table
166
+ #
167
+ # @return [Integer] Minimum size in bytes, or nil if not parsed
168
+ def expected_min_size
169
+ return nil unless parsed?
170
+
171
+ # numberOfHMetrics × 4 bytes (uint16 + int16)
172
+ # + (numGlyphs - numberOfHMetrics) × 2 bytes (int16)
173
+ (number_of_h_metrics * 4) + ((num_glyphs - number_of_h_metrics) * 2)
174
+ end
175
+
176
+ private
177
+
178
+ # Validate context parameters
179
+ #
180
+ # @param number_of_h_metrics [Integer] Number of hMetrics
181
+ # @param num_glyphs [Integer] Total glyphs
182
+ # @raise [ArgumentError] If parameters are invalid
183
+ def validate_context_params(number_of_h_metrics, num_glyphs)
184
+ if number_of_h_metrics.nil? || number_of_h_metrics < 1
185
+ raise ArgumentError,
186
+ "numberOfHMetrics must be >= 1, got: #{number_of_h_metrics.inspect}"
187
+ end
188
+
189
+ if num_glyphs.nil? || num_glyphs < 1
190
+ raise ArgumentError,
191
+ "numGlyphs must be >= 1, got: #{num_glyphs.inspect}"
192
+ end
193
+
194
+ if number_of_h_metrics > num_glyphs
195
+ raise ArgumentError,
196
+ "numberOfHMetrics (#{number_of_h_metrics}) cannot exceed " \
197
+ "numGlyphs (#{num_glyphs})"
198
+ end
199
+ end
200
+
201
+ # Parse horizontal metrics array
202
+ #
203
+ # @param io [StringIO] Input stream
204
+ # @param count [Integer] Number of metrics to parse
205
+ # @return [Array<Hash>] Array of metric hashes
206
+ # @raise [Fontisan::CorruptedTableError] If insufficient data
207
+ def parse_h_metrics(io, count)
208
+ metrics = []
209
+ count.times do |i|
210
+ advance_width = read_uint16(io)
211
+ lsb = read_int16(io)
212
+
213
+ if advance_width.nil? || lsb.nil?
214
+ raise Fontisan::CorruptedTableError,
215
+ "Insufficient data for hMetric at index #{i}"
216
+ end
217
+
218
+ metrics << { advance_width: advance_width, lsb: lsb }
219
+ end
220
+ metrics
221
+ end
222
+
223
+ # Parse left side bearings array
224
+ #
225
+ # @param io [StringIO] Input stream
226
+ # @param count [Integer] Number of LSBs to parse
227
+ # @return [Array<Integer>] Array of LSB values
228
+ # @raise [Fontisan::CorruptedTableError] If insufficient data
229
+ def parse_left_side_bearings(io, count)
230
+ return [] if count.zero?
231
+
232
+ lsbs = []
233
+ count.times do |i|
234
+ lsb = read_int16(io)
235
+
236
+ if lsb.nil?
237
+ raise Fontisan::CorruptedTableError,
238
+ "Insufficient data for LSB at index #{i}"
239
+ end
240
+
241
+ lsbs << lsb
242
+ end
243
+ lsbs
244
+ end
245
+
246
+ # Validate that all expected data was parsed
247
+ #
248
+ # @param io [StringIO] Input stream
249
+ # @raise [Fontisan::CorruptedTableError] If unexpected data remains
250
+ def validate_parsed_data!(io)
251
+ remaining = io.read
252
+ return if remaining.nil? || remaining.empty?
253
+
254
+ # Some fonts may have padding, which is acceptable
255
+ # Only warn if there's more than 3 bytes of extra data
256
+ if remaining.length > 3
257
+ warn "Warning: hmtx table has #{remaining.length} unexpected " \
258
+ "bytes after parsing"
259
+ end
260
+ end
261
+
262
+ # Read unsigned 16-bit integer
263
+ #
264
+ # @param io [StringIO] Input stream
265
+ # @return [Integer, nil] Value or nil if insufficient data
266
+ def read_uint16(io)
267
+ data = io.read(2)
268
+ return nil if data.nil? || data.length < 2
269
+
270
+ data.unpack1("n") # Big-endian unsigned 16-bit
271
+ end
272
+
273
+ # Read signed 16-bit integer
274
+ #
275
+ # @param io [StringIO] Input stream
276
+ # @return [Integer, nil] Value or nil if insufficient data
277
+ def read_int16(io)
278
+ data = io.read(2)
279
+ return nil if data.nil? || data.length < 2
280
+
281
+ # Unpack as unsigned, then convert to signed
282
+ value = data.unpack1("n")
283
+ value >= 0x8000 ? value - 0x10000 : value
284
+ end
285
+ end
286
+ end
287
+ end