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
@@ -0,0 +1,231 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+ require_relative "../binary/base_record"
5
+ require_relative "../variation/tuple_variation_header"
6
+
7
+ module Fontisan
8
+ module Tables
9
+ # Parser for the 'gvar' (Glyph Variations) table
10
+ #
11
+ # The gvar table provides variation data for glyph outlines in TrueType
12
+ # variable fonts. It contains delta values for each glyph's control points
13
+ # that are applied based on the current design space coordinates.
14
+ #
15
+ # Unlike HVAR/VVAR/MVAR which use ItemVariationStore, gvar uses a
16
+ # TupleVariationStore structure with packed delta values.
17
+ #
18
+ # Reference: OpenType specification, gvar table
19
+ #
20
+ # @example Reading a gvar table
21
+ # data = font.table_data("gvar")
22
+ # gvar = Fontisan::Tables::Gvar.read(data)
23
+ # deltas = gvar.glyph_variations(glyph_id)
24
+ class Gvar < Binary::BaseRecord
25
+ uint16 :major_version
26
+ uint16 :minor_version
27
+ uint16 :axis_count
28
+ uint16 :shared_tuple_count
29
+ uint32 :shared_tuples_offset
30
+ uint16 :glyph_count
31
+ uint16 :flags
32
+ uint32 :glyph_variation_data_array_offset
33
+
34
+ # Flags
35
+ SHARED_POINT_NUMBERS = 0x8000
36
+ LONG_OFFSETS = 0x0001
37
+
38
+ # Get version as a float
39
+ #
40
+ # @return [Float] Version number (e.g., 1.0)
41
+ def version
42
+ major_version + (minor_version / 10.0)
43
+ end
44
+
45
+ # Check if using long offsets
46
+ #
47
+ # @return [Boolean] True if long offsets
48
+ def long_offsets?
49
+ (flags & LONG_OFFSETS) != 0
50
+ end
51
+
52
+ # Check if using shared point numbers
53
+ #
54
+ # @return [Boolean] True if shared points
55
+ def shared_point_numbers?
56
+ (flags & SHARED_POINT_NUMBERS) != 0
57
+ end
58
+
59
+ # Parse shared tuples
60
+ #
61
+ # @return [Array<Array<Integer>>] Shared peak tuples
62
+ def shared_tuples
63
+ return @shared_tuples if @shared_tuples
64
+ return @shared_tuples = [] if shared_tuple_count.zero?
65
+
66
+ data = raw_data
67
+ offset = shared_tuples_offset
68
+
69
+ return @shared_tuples = [] if offset >= data.bytesize
70
+
71
+ @shared_tuples = Array.new(shared_tuple_count) do |i|
72
+ tuple_offset = offset + (i * axis_count * 2)
73
+
74
+ Array.new(axis_count) do |j|
75
+ coord_offset = tuple_offset + (j * 2)
76
+ next nil if coord_offset + 2 > data.bytesize
77
+
78
+ # F2DOT14 format
79
+ value = data.byteslice(coord_offset, 2).unpack1("n")
80
+ signed = value > 0x7FFF ? value - 0x10000 : value
81
+ signed / 16384.0
82
+ end.compact
83
+ end.compact
84
+ end
85
+
86
+ # Parse glyph variation data offsets
87
+ #
88
+ # @return [Array<Integer>] Array of offsets
89
+ def glyph_variation_data_offsets
90
+ return @glyph_offsets if @glyph_offsets
91
+
92
+ data = raw_data
93
+ # Offsets start after the header (20 bytes)
94
+ offset = 20
95
+
96
+ offset_size = long_offsets? ? 4 : 2
97
+ offset_count = glyph_count + 1 # One extra for the end
98
+
99
+ @glyph_offsets = Array.new(offset_count) do |i|
100
+ offset_pos = offset + (i * offset_size)
101
+ next nil if offset_pos + offset_size > data.bytesize
102
+
103
+ raw_offset = if long_offsets?
104
+ data.byteslice(offset_pos, 4).unpack1("N")
105
+ else
106
+ data.byteslice(offset_pos, 2).unpack1("n") * 2
107
+ end
108
+
109
+ glyph_variation_data_array_offset + raw_offset
110
+ end.compact
111
+ end
112
+
113
+ # Get variation data for a specific glyph
114
+ #
115
+ # @param glyph_id [Integer] Glyph ID
116
+ # @return [String, nil] Raw variation data or nil
117
+ def glyph_variation_data(glyph_id)
118
+ return nil if glyph_id >= glyph_count
119
+
120
+ offsets = glyph_variation_data_offsets
121
+ return nil if glyph_id >= offsets.length - 1
122
+
123
+ start_offset = offsets[glyph_id]
124
+ end_offset = offsets[glyph_id + 1]
125
+
126
+ return nil if start_offset == end_offset # No data
127
+
128
+ data = raw_data
129
+ return nil if end_offset > data.bytesize
130
+
131
+ data.byteslice(start_offset, end_offset - start_offset)
132
+ end
133
+
134
+ # Parse tuple variation headers for a glyph
135
+ #
136
+ # @param glyph_id [Integer] Glyph ID
137
+ # @return [Array<Hash>, nil] Array of tuple info or nil
138
+ def glyph_tuple_variations(glyph_id)
139
+ var_data = glyph_variation_data(glyph_id)
140
+ return nil if var_data.nil? || var_data.empty?
141
+
142
+ io = StringIO.new(var_data)
143
+ io.set_encoding(Encoding::BINARY)
144
+
145
+ # Read header
146
+ tuple_count_and_offset = io.read(4).unpack1("N")
147
+ tuple_count = tuple_count_and_offset >> 16
148
+ data_offset = tuple_count_and_offset & 0xFFFF
149
+
150
+ # Check for shared point numbers
151
+ has_shared_points = (tuple_count & 0x8000) != 0
152
+ tuple_count &= 0x0FFF
153
+
154
+ return [] if tuple_count.zero?
155
+
156
+ # Parse each tuple
157
+ tuples = []
158
+ tuple_count.times do
159
+ header_data = io.read(4)
160
+ break if header_data.nil? || header_data.bytesize < 4
161
+
162
+ header = Variation::TupleVariationHeader.read(header_data)
163
+
164
+ tuple_info = {
165
+ data_size: header.variation_data_size,
166
+ embedded_peak: header.embedded_peak_tuple?,
167
+ intermediate: header.intermediate_region?,
168
+ private_points: header.private_point_numbers?,
169
+ shared_index: header.shared_tuple_index,
170
+ }
171
+
172
+ # Read peak tuple if embedded
173
+ if header.embedded_peak_tuple?
174
+ peak = Array.new(axis_count) do
175
+ coord_data = io.read(2)
176
+ break nil if coord_data.nil?
177
+
178
+ value = coord_data.unpack1("n")
179
+ signed = value > 0x7FFF ? value - 0x10000 : value
180
+ signed / 16384.0
181
+ end
182
+ tuple_info[:peak] = peak.compact
183
+ end
184
+
185
+ # Read intermediate region if present
186
+ if header.intermediate_region?
187
+ start_tuple = Array.new(axis_count) do
188
+ coord_data = io.read(2)
189
+ break nil if coord_data.nil?
190
+
191
+ value = coord_data.unpack1("n")
192
+ signed = value > 0x7FFF ? value - 0x10000 : value
193
+ signed / 16384.0
194
+ end
195
+
196
+ end_tuple = Array.new(axis_count) do
197
+ coord_data = io.read(2)
198
+ break nil if coord_data.nil?
199
+
200
+ value = coord_data.unpack1("n")
201
+ signed = value > 0x7FFF ? value - 0x10000 : value
202
+ signed / 16384.0
203
+ end
204
+
205
+ tuple_info[:start] = start_tuple.compact
206
+ tuple_info[:end] = end_tuple.compact
207
+ end
208
+
209
+ tuples << tuple_info
210
+ end
211
+
212
+ {
213
+ tuple_count: tuple_count,
214
+ has_shared_points: has_shared_points,
215
+ data_offset: data_offset,
216
+ tuples: tuples,
217
+ }
218
+ rescue StandardError => e
219
+ warn "Failed to parse glyph tuple variations: #{e.message}"
220
+ nil
221
+ end
222
+
223
+ # Check if table is valid
224
+ #
225
+ # @return [Boolean] True if valid
226
+ def valid?
227
+ major_version == 1 && minor_version.zero?
228
+ end
229
+ end
230
+ end
231
+ 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