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,382 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Tables
5
+ # Represents a simple TrueType glyph with contours
6
+ #
7
+ # A simple glyph is defined by one or more contours, where each contour
8
+ # is a closed path made up of on-curve and off-curve points. The points
9
+ # are stored using delta encoding to save space.
10
+ #
11
+ # The glyph structure consists of:
12
+ # - Header: numberOfContours, xMin, yMin, xMax, yMax (10 bytes)
13
+ # - endPtsOfContours: array marking the last point of each contour
14
+ # - instructions: TrueType hinting instructions (optional)
15
+ # - flags: array of point flags (compressed with repeat counts)
16
+ # - xCoordinates: x-coordinates (delta-encoded, variable byte length)
17
+ # - yCoordinates: y-coordinates (delta-encoded, variable byte length)
18
+ #
19
+ # Point flags (8-bit) indicate:
20
+ # - Bit 0 (0x01): ON_CURVE_POINT - point is on the curve
21
+ # - Bit 1 (0x02): X_SHORT_VECTOR - x-coordinate is 1 byte
22
+ # - Bit 2 (0x04): Y_SHORT_VECTOR - y-coordinate is 1 byte
23
+ # - Bit 3 (0x08): REPEAT_FLAG - repeat this flag n times
24
+ # - Bit 4 (0x10): X_IS_SAME_OR_POSITIVE_X_SHORT - x value interpretation
25
+ # - Bit 5 (0x20): Y_IS_SAME_OR_POSITIVE_Y_SHORT - y value interpretation
26
+ #
27
+ # Reference: OpenType specification, glyf table - Simple Glyph Description
28
+ # https://docs.microsoft.com/en-us/typography/opentype/spec/glyf#simple-glyph-description
29
+ class SimpleGlyph
30
+ # Flag constants
31
+ ON_CURVE_POINT = 0x01
32
+ X_SHORT_VECTOR = 0x02
33
+ Y_SHORT_VECTOR = 0x04
34
+ REPEAT_FLAG = 0x08
35
+ X_IS_SAME_OR_POSITIVE_X_SHORT = 0x10
36
+ Y_IS_SAME_OR_POSITIVE_Y_SHORT = 0x20
37
+
38
+ # Glyph header fields
39
+ attr_reader :glyph_id
40
+ attr_reader :num_contours, :x_min, :y_min, :x_max, :y_max,
41
+ :instruction_length, :instructions, :flags, :x_coordinates, :y_coordinates
42
+
43
+ # Glyph data fields
44
+ attr_reader :end_pts_of_contours
45
+
46
+ # Parse simple glyph data
47
+ #
48
+ # @param data [String] Binary glyph data
49
+ # @param glyph_id [Integer] Glyph ID for error reporting
50
+ # @return [SimpleGlyph] Parsed simple glyph
51
+ # @raise [Fontisan::CorruptedTableError] If data is insufficient or invalid
52
+ def self.parse(data, glyph_id)
53
+ glyph = new(glyph_id)
54
+ glyph.parse_data(data)
55
+ glyph
56
+ end
57
+
58
+ # Initialize a new simple glyph
59
+ #
60
+ # @param glyph_id [Integer] Glyph ID
61
+ def initialize(glyph_id)
62
+ @glyph_id = glyph_id
63
+ end
64
+
65
+ # Parse glyph data
66
+ #
67
+ # @param data [String] Binary glyph data
68
+ # @raise [Fontisan::CorruptedTableError] If parsing fails
69
+ def parse_data(data)
70
+ io = StringIO.new(data)
71
+ io.set_encoding(Encoding::BINARY)
72
+
73
+ parse_header(io)
74
+ parse_contour_ends(io)
75
+ parse_instructions(io)
76
+ parse_flags(io)
77
+ parse_coordinates(io)
78
+
79
+ validate_parsed_data!
80
+ end
81
+
82
+ # Check if this is a simple glyph
83
+ #
84
+ # @return [Boolean] Always true for SimpleGlyph
85
+ def simple?
86
+ true
87
+ end
88
+
89
+ # Check if this is a compound glyph
90
+ #
91
+ # @return [Boolean] Always false for SimpleGlyph
92
+ def compound?
93
+ false
94
+ end
95
+
96
+ # Check if glyph has no outline data
97
+ #
98
+ # @return [Boolean] True if no contours
99
+ def empty?
100
+ num_contours.zero?
101
+ end
102
+
103
+ # Get bounding box as array
104
+ #
105
+ # @return [Array<Integer>] Bounding box [xMin, yMin, xMax, yMax]
106
+ def bounding_box
107
+ [x_min, y_min, x_max, y_max]
108
+ end
109
+
110
+ # Get total number of points
111
+ #
112
+ # @return [Integer] Total points in all contours
113
+ def num_points
114
+ return 0 if empty?
115
+
116
+ end_pts_of_contours.last + 1
117
+ end
118
+
119
+ # Check if a specific point is on the curve
120
+ #
121
+ # @param index [Integer] Point index (0-based)
122
+ # @return [Boolean, nil] True if on curve, false if off curve, nil if invalid
123
+ def on_curve?(index)
124
+ return nil if index.negative? || index >= num_points
125
+
126
+ (flags[index] & ON_CURVE_POINT) != 0
127
+ end
128
+
129
+ # Get contour for a specific point
130
+ #
131
+ # @param point_index [Integer] Point index (0-based)
132
+ # @return [Integer, nil] Contour index (0-based) or nil if invalid
133
+ def contour_for_point(point_index)
134
+ return nil if point_index.negative? || point_index >= num_points
135
+
136
+ end_pts_of_contours.index { |end_pt| point_index <= end_pt }
137
+ end
138
+
139
+ # Get all points for a specific contour
140
+ #
141
+ # @param contour_index [Integer] Contour index (0-based)
142
+ # @return [Array<Hash>, nil] Array of point hashes or nil if invalid
143
+ def points_for_contour(contour_index)
144
+ return nil if contour_index.negative? || contour_index >= num_contours
145
+
146
+ start_pt = contour_index.zero? ? 0 : end_pts_of_contours[contour_index - 1] + 1
147
+ end_pt = end_pts_of_contours[contour_index]
148
+
149
+ (start_pt..end_pt).map do |i|
150
+ {
151
+ x: x_coordinates[i],
152
+ y: y_coordinates[i],
153
+ on_curve: on_curve?(i),
154
+ }
155
+ end
156
+ end
157
+
158
+ private
159
+
160
+ # Parse glyph header (10 bytes)
161
+ #
162
+ # @param io [StringIO] Input stream
163
+ # @raise [Fontisan::CorruptedTableError] If insufficient data
164
+ def parse_header(io)
165
+ header = io.read(10)
166
+ if header.nil? || header.length < 10
167
+ raise Fontisan::CorruptedTableError,
168
+ "Insufficient header data for simple glyph #{glyph_id}"
169
+ end
170
+
171
+ values = header.unpack("n5")
172
+ @num_contours = to_signed_16(values[0])
173
+ @x_min = to_signed_16(values[1])
174
+ @y_min = to_signed_16(values[2])
175
+ @x_max = to_signed_16(values[3])
176
+ @y_max = to_signed_16(values[4])
177
+
178
+ if @num_contours.negative?
179
+ raise Fontisan::CorruptedTableError,
180
+ "Simple glyph #{glyph_id} has negative contour count: #{@num_contours}"
181
+ end
182
+ end
183
+
184
+ # Parse contour end points
185
+ #
186
+ # @param io [StringIO] Input stream
187
+ # @raise [Fontisan::CorruptedTableError] If insufficient data
188
+ def parse_contour_ends(io)
189
+ return if num_contours.zero?
190
+
191
+ data = io.read(num_contours * 2)
192
+ if data.nil? || data.length < num_contours * 2
193
+ raise Fontisan::CorruptedTableError,
194
+ "Insufficient contour end data for simple glyph #{glyph_id}"
195
+ end
196
+
197
+ @end_pts_of_contours = data.unpack("n*")
198
+ end
199
+
200
+ # Parse TrueType instructions
201
+ #
202
+ # @param io [StringIO] Input stream
203
+ # @raise [Fontisan::CorruptedTableError] If insufficient data
204
+ def parse_instructions(io)
205
+ length_data = io.read(2)
206
+ if length_data.nil? || length_data.length < 2
207
+ raise Fontisan::CorruptedTableError,
208
+ "Insufficient instruction length data for simple glyph #{glyph_id}"
209
+ end
210
+
211
+ @instruction_length = length_data.unpack1("n")
212
+
213
+ if @instruction_length.positive?
214
+ @instructions = io.read(@instruction_length)
215
+ if @instructions.nil? || @instructions.length < @instruction_length
216
+ raise Fontisan::CorruptedTableError,
217
+ "Insufficient instruction data for simple glyph #{glyph_id}"
218
+ end
219
+ else
220
+ @instructions = "".b
221
+ end
222
+ end
223
+
224
+ # Parse flags with repeat compression
225
+ #
226
+ # Flags use run-length encoding: when REPEAT_FLAG is set,
227
+ # the next byte indicates how many times to repeat the flag.
228
+ #
229
+ # @param io [StringIO] Input stream
230
+ # @raise [Fontisan::CorruptedTableError] If insufficient data
231
+ def parse_flags(io)
232
+ return if num_contours.zero?
233
+
234
+ total_points = num_points
235
+ @flags = []
236
+
237
+ while @flags.length < total_points
238
+ flag_byte = io.read(1)
239
+ if flag_byte.nil? || flag_byte.empty?
240
+ raise Fontisan::CorruptedTableError,
241
+ "Insufficient flag data for simple glyph #{glyph_id}"
242
+ end
243
+
244
+ flag = flag_byte.unpack1("C")
245
+ @flags << flag
246
+
247
+ # Check for repeat flag
248
+ if (flag & REPEAT_FLAG) != 0
249
+ repeat_count = io.read(1)
250
+ if repeat_count.nil? || repeat_count.empty?
251
+ raise Fontisan::CorruptedTableError,
252
+ "Missing repeat count for simple glyph #{glyph_id}"
253
+ end
254
+
255
+ count = repeat_count.unpack1("C")
256
+ count.times { @flags << flag }
257
+ end
258
+ end
259
+
260
+ if @flags.length != total_points
261
+ raise Fontisan::CorruptedTableError,
262
+ "Flag count mismatch for simple glyph #{glyph_id}: " \
263
+ "expected #{total_points}, got #{@flags.length}"
264
+ end
265
+ end
266
+
267
+ # Parse x and y coordinates with delta encoding
268
+ #
269
+ # Coordinates are delta-encoded from the previous point.
270
+ # The flag indicates whether coordinates are 1 byte (short) or 2 bytes.
271
+ # For short coordinates, another flag bit indicates sign.
272
+ # For long coordinates, a flag bit indicates if the value is same as previous (delta=0).
273
+ #
274
+ # @param io [StringIO] Input stream
275
+ # @raise [Fontisan::CorruptedTableError] If insufficient data
276
+ def parse_coordinates(io)
277
+ return if num_contours.zero?
278
+
279
+ @x_coordinates = parse_coordinate_array(io, :x)
280
+ @y_coordinates = parse_coordinate_array(io, :y)
281
+ end
282
+
283
+ # Parse a coordinate array (x or y)
284
+ #
285
+ # @param io [StringIO] Input stream
286
+ # @param axis [:x, :y] Which axis to parse
287
+ # @return [Array<Integer>] Absolute coordinates
288
+ # @raise [Fontisan::CorruptedTableError] If insufficient data
289
+ def parse_coordinate_array(io, axis)
290
+ short_flag = axis == :x ? X_SHORT_VECTOR : Y_SHORT_VECTOR
291
+ same_or_positive_flag = axis == :x ? X_IS_SAME_OR_POSITIVE_X_SHORT : Y_IS_SAME_OR_POSITIVE_Y_SHORT
292
+
293
+ coordinates = []
294
+ current = 0
295
+
296
+ flags.each_with_index do |flag, i|
297
+ if (flag & short_flag) != 0
298
+ # Short coordinate (1 byte, unsigned)
299
+ byte = io.read(1)
300
+ if byte.nil? || byte.empty?
301
+ raise Fontisan::CorruptedTableError,
302
+ "Insufficient #{axis} coordinate data for simple glyph #{glyph_id} at point #{i}"
303
+ end
304
+
305
+ value = byte.unpack1("C")
306
+ # Sign determination: if same_or_positive_flag is set, value is positive; otherwise negative
307
+ delta = (flag & same_or_positive_flag).zero? ? -value : value
308
+ elsif (flag & same_or_positive_flag) != 0
309
+ # Same as previous (delta = 0)
310
+ delta = 0
311
+ else
312
+ # Long coordinate (2 bytes, signed)
313
+ bytes = io.read(2)
314
+ if bytes.nil? || bytes.length < 2
315
+ raise Fontisan::CorruptedTableError,
316
+ "Insufficient #{axis} coordinate data for simple glyph #{glyph_id} at point #{i}"
317
+ end
318
+
319
+ delta = to_signed_16(bytes.unpack1("n"))
320
+ end
321
+
322
+ current += delta
323
+ coordinates << current
324
+ end
325
+
326
+ coordinates
327
+ end
328
+
329
+ # Validate parsed data consistency
330
+ #
331
+ # @raise [Fontisan::CorruptedTableError] If validation fails
332
+ def validate_parsed_data!
333
+ return if num_contours.zero?
334
+
335
+ # Check that we have correct number of points
336
+ expected_points = num_points
337
+ if flags.length != expected_points
338
+ raise Fontisan::CorruptedTableError,
339
+ "Point count mismatch for simple glyph #{glyph_id}: " \
340
+ "expected #{expected_points} points, got #{flags.length} flags"
341
+ end
342
+
343
+ if x_coordinates.length != expected_points
344
+ raise Fontisan::CorruptedTableError,
345
+ "X coordinate count mismatch for simple glyph #{glyph_id}: " \
346
+ "expected #{expected_points}, got #{x_coordinates.length}"
347
+ end
348
+
349
+ if y_coordinates.length != expected_points
350
+ raise Fontisan::CorruptedTableError,
351
+ "Y coordinate count mismatch for simple glyph #{glyph_id}: " \
352
+ "expected #{expected_points}, got #{y_coordinates.length}"
353
+ end
354
+
355
+ # Check that contour end points are monotonically increasing
356
+ end_pts_of_contours.each_cons(2) do |prev, curr|
357
+ if curr <= prev
358
+ raise Fontisan::CorruptedTableError,
359
+ "Invalid contour end points for simple glyph #{glyph_id}: " \
360
+ "not monotonically increasing"
361
+ end
362
+ end
363
+
364
+ # Check that last contour end point matches total points
365
+ last_end_pt = end_pts_of_contours.last
366
+ if last_end_pt != expected_points - 1
367
+ raise Fontisan::CorruptedTableError,
368
+ "Last contour end point mismatch for simple glyph #{glyph_id}: " \
369
+ "expected #{expected_points - 1}, got #{last_end_pt}"
370
+ end
371
+ end
372
+
373
+ # Convert unsigned 16-bit value to signed
374
+ #
375
+ # @param value [Integer] Unsigned 16-bit value
376
+ # @return [Integer] Signed 16-bit value
377
+ def to_signed_16(value)
378
+ value > 0x7FFF ? value - 0x10000 : value
379
+ end
380
+ end
381
+ end
382
+ end
@@ -0,0 +1,235 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../binary/base_record"
4
+ require_relative "glyf/simple_glyph"
5
+ require_relative "glyf/compound_glyph"
6
+ require_relative "glyf/curve_converter"
7
+ require_relative "glyf/glyph_builder"
8
+
9
+ module Fontisan
10
+ module Tables
11
+ # Parser for the 'glyf' (Glyph Data) table
12
+ #
13
+ # The glyf table contains TrueType glyph outline data. Each glyph is
14
+ # described by either a simple glyph (with contours and points) or a
15
+ # compound glyph (composed of other glyphs with transformations).
16
+ #
17
+ # The glyf table is accessed via offsets from the loca table, which
18
+ # provides the byte offset and size for each glyph. Empty glyphs
19
+ # (e.g., space characters) have zero size in loca.
20
+ #
21
+ # Glyph types are determined by numberOfContours:
22
+ # - numberOfContours >= 0: Simple glyph with that many contours
23
+ # - numberOfContours == -1: Compound glyph composed of other glyphs
24
+ #
25
+ # The glyf table is context-dependent and requires:
26
+ # - loca table (for glyph offsets and sizes)
27
+ # - head table (for coordinate interpretation and flags)
28
+ #
29
+ # Reference: OpenType specification, glyf table
30
+ # https://docs.microsoft.com/en-us/typography/opentype/spec/glyf
31
+ #
32
+ # @example Accessing a glyph
33
+ # # Get required tables first
34
+ # head = font.table('head')
35
+ # loca = font.table('loca')
36
+ # loca.parse_with_context(head.index_to_loc_format, maxp.num_glyphs)
37
+ #
38
+ # # Parse glyf table
39
+ # data = font.read_table_data('glyf')
40
+ # glyf = Fontisan::Tables::Glyf.read(data)
41
+ #
42
+ # # Get a specific glyph
43
+ # glyph = glyf.glyph_for(42, loca, head)
44
+ # puts glyph.simple? ? "Simple glyph" : "Compound glyph"
45
+ # puts glyph.bounding_box # => [xMin, yMin, xMax, yMax]
46
+ class Glyf < Binary::BaseRecord
47
+ # Store the raw data for deferred parsing
48
+ attr_accessor :raw_data
49
+
50
+ # Cache for parsed glyphs
51
+ # @return [Hash<Integer, SimpleGlyph|CompoundGlyph>]
52
+ attr_reader :glyphs_cache
53
+
54
+ # Override read to capture raw data
55
+ #
56
+ # @param io [IO, String] Input data
57
+ # @return [Glyf] Parsed table instance
58
+ def self.read(io)
59
+ instance = new
60
+
61
+ # Initialize cache
62
+ instance.instance_variable_set(:@glyphs_cache, {})
63
+
64
+ # Handle nil or empty data gracefully
65
+ instance.raw_data = if io.nil?
66
+ "".b
67
+ elsif io.is_a?(String)
68
+ io
69
+ else
70
+ io.read || "".b
71
+ end
72
+
73
+ instance
74
+ end
75
+
76
+ # Get glyph data for a specific glyph ID
77
+ #
78
+ # This method retrieves and parses the glyph at the specified ID.
79
+ # It uses the loca table to determine the offset and size, then
80
+ # parses the glyph data to create either a SimpleGlyph or CompoundGlyph.
81
+ #
82
+ # Results are cached to avoid re-parsing the same glyph multiple times.
83
+ #
84
+ # @param glyph_id [Integer] Glyph ID (0-based, 0 is .notdef)
85
+ # @param loca [Loca] Parsed loca table with offsets
86
+ # @param head [Head] Parsed head table for coordinate interpretation
87
+ # @return [SimpleGlyph, CompoundGlyph, nil] Parsed glyph or nil if empty/invalid
88
+ # @raise [ArgumentError] If loca is not parsed or tables are invalid
89
+ # @raise [Fontisan::CorruptedTableError] If glyph data is corrupted
90
+ #
91
+ # @example Getting a simple glyph
92
+ # glyph = glyf.glyph_for(65, loca, head) # 'A' character
93
+ # if glyph.simple?
94
+ # puts "Contours: #{glyph.num_contours}"
95
+ # puts "Points: #{glyph.x_coordinates.length}"
96
+ # end
97
+ def glyph_for(glyph_id, loca, head)
98
+ # Return cached glyph if available
99
+ return glyphs_cache[glyph_id] if glyphs_cache.key?(glyph_id)
100
+
101
+ # Validate inputs
102
+ validate_context!(loca, head, glyph_id)
103
+
104
+ # Get offset and size from loca table
105
+ offset = loca.offset_for(glyph_id)
106
+ size = loca.size_of(glyph_id)
107
+
108
+ # Empty glyph (e.g., space character)
109
+ if size.nil? || size.zero?
110
+ glyphs_cache[glyph_id] = nil
111
+ return nil
112
+ end
113
+
114
+ # Validate offset and size
115
+ if offset + size > raw_data.length
116
+ raise Fontisan::CorruptedTableError,
117
+ "Glyph #{glyph_id} extends beyond glyf table: " \
118
+ "offset=#{offset}, size=#{size}, table_size=#{raw_data.length}"
119
+ end
120
+
121
+ # Extract glyph data
122
+ glyph_data = raw_data[offset, size]
123
+
124
+ # Parse glyph
125
+ glyph = parse_glyph_data(glyph_data, glyph_id)
126
+ glyphs_cache[glyph_id] = glyph
127
+ end
128
+
129
+ # Clear the glyph cache to free memory
130
+ #
131
+ # This is useful for long-running processes that parse many glyphs
132
+ # but don't need to keep them all in memory.
133
+ #
134
+ # @return [void]
135
+ def clear_cache
136
+ glyphs_cache.clear
137
+ end
138
+
139
+ # Get the number of cached glyphs
140
+ #
141
+ # @return [Integer] Number of glyphs in cache
142
+ def cache_size
143
+ glyphs_cache.size
144
+ end
145
+
146
+ # Check if a glyph is cached
147
+ #
148
+ # @param glyph_id [Integer] Glyph ID to check
149
+ # @return [Boolean] True if glyph is cached
150
+ def cached?(glyph_id)
151
+ glyphs_cache.key?(glyph_id)
152
+ end
153
+
154
+ # Lazy initialization of glyphs cache
155
+ #
156
+ # @return [Hash] The glyphs cache
157
+ def glyphs_cache
158
+ @glyphs_cache ||= {}
159
+ end
160
+
161
+ private
162
+
163
+ # Validate context and glyph ID
164
+ #
165
+ # @param loca [Loca] Loca table
166
+ # @param head [Head] Head table
167
+ # @param glyph_id [Integer] Glyph ID
168
+ # @raise [ArgumentError] If validation fails
169
+ def validate_context!(loca, head, glyph_id)
170
+ unless loca.respond_to?(:offset_for) && loca.respond_to?(:size_of)
171
+ raise ArgumentError,
172
+ "loca must be a parsed Loca table with offset_for and size_of methods"
173
+ end
174
+
175
+ unless loca.parsed?
176
+ raise ArgumentError,
177
+ "loca table must be parsed with parse_with_context before use"
178
+ end
179
+
180
+ unless head.respond_to?(:units_per_em)
181
+ raise ArgumentError,
182
+ "head must be a parsed Head table"
183
+ end
184
+
185
+ if glyph_id.nil? || glyph_id.negative?
186
+ raise ArgumentError,
187
+ "glyph_id must be >= 0, got: #{glyph_id.inspect}"
188
+ end
189
+
190
+ if glyph_id >= loca.num_glyphs
191
+ raise ArgumentError,
192
+ "glyph_id #{glyph_id} exceeds number of glyphs (#{loca.num_glyphs})"
193
+ end
194
+ end
195
+
196
+ # Parse glyph data into SimpleGlyph or CompoundGlyph
197
+ #
198
+ # @param data [String] Binary glyph data
199
+ # @param glyph_id [Integer] Glyph ID
200
+ # @return [SimpleGlyph, CompoundGlyph] Parsed glyph
201
+ # @raise [Fontisan::CorruptedTableError] If data is insufficient or invalid
202
+ def parse_glyph_data(data, glyph_id)
203
+ # Need at least 10 bytes for glyph header
204
+ if data.length < 10
205
+ raise Fontisan::CorruptedTableError,
206
+ "Insufficient glyph data for glyph #{glyph_id}: " \
207
+ "need at least 10 bytes, got #{data.length}"
208
+ end
209
+
210
+ # Parse numberOfContours (signed 16-bit) at offset 0
211
+ num_contours_raw = data[0, 2].unpack1("n")
212
+ num_contours = to_signed_16(num_contours_raw)
213
+
214
+ # Determine glyph type and parse accordingly
215
+ if num_contours >= 0
216
+ SimpleGlyph.parse(data, glyph_id)
217
+ elsif num_contours == -1
218
+ CompoundGlyph.parse(data, glyph_id)
219
+ else
220
+ raise Fontisan::CorruptedTableError,
221
+ "Invalid numberOfContours for glyph #{glyph_id}: #{num_contours}. " \
222
+ "Must be >= 0 for simple glyphs or -1 for compound glyphs."
223
+ end
224
+ end
225
+
226
+ # Convert unsigned 16-bit value to signed
227
+ #
228
+ # @param value [Integer] Unsigned 16-bit value
229
+ # @return [Integer] Signed 16-bit value
230
+ def to_signed_16(value)
231
+ value > 0x7FFF ? value - 0x10000 : value
232
+ end
233
+ end
234
+ end
235
+ end