fontisan 0.2.0 → 0.2.2

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 (99) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +119 -308
  3. data/README.adoc +1525 -1323
  4. data/Rakefile +45 -47
  5. data/benchmark/variation_quick_bench.rb +4 -4
  6. data/docs/FONT_HINTING.adoc +562 -0
  7. data/docs/VARIABLE_FONT_OPERATIONS.adoc +599 -0
  8. data/lib/fontisan/cli.rb +92 -34
  9. data/lib/fontisan/collection/builder.rb +82 -0
  10. data/lib/fontisan/collection/offset_calculator.rb +2 -0
  11. data/lib/fontisan/collection/table_deduplicator.rb +76 -0
  12. data/lib/fontisan/commands/base_command.rb +21 -2
  13. data/lib/fontisan/commands/convert_command.rb +96 -165
  14. data/lib/fontisan/commands/info_command.rb +111 -5
  15. data/lib/fontisan/commands/instance_command.rb +77 -85
  16. data/lib/fontisan/commands/validate_command.rb +28 -0
  17. data/lib/fontisan/config/validation_rules.yml +1 -1
  18. data/lib/fontisan/constants.rb +34 -24
  19. data/lib/fontisan/converters/format_converter.rb +154 -1
  20. data/lib/fontisan/converters/outline_converter.rb +101 -34
  21. data/lib/fontisan/converters/woff_writer.rb +9 -4
  22. data/lib/fontisan/font_loader.rb +14 -9
  23. data/lib/fontisan/font_writer.rb +9 -6
  24. data/lib/fontisan/formatters/text_formatter.rb +45 -1
  25. data/lib/fontisan/hints/hint_converter.rb +131 -2
  26. data/lib/fontisan/hints/hint_validator.rb +284 -0
  27. data/lib/fontisan/hints/postscript_hint_applier.rb +219 -140
  28. data/lib/fontisan/hints/postscript_hint_extractor.rb +151 -16
  29. data/lib/fontisan/hints/truetype_hint_applier.rb +90 -44
  30. data/lib/fontisan/hints/truetype_hint_extractor.rb +134 -11
  31. data/lib/fontisan/hints/truetype_instruction_analyzer.rb +261 -0
  32. data/lib/fontisan/hints/truetype_instruction_generator.rb +266 -0
  33. data/lib/fontisan/loading_modes.rb +6 -4
  34. data/lib/fontisan/models/collection_brief_info.rb +31 -0
  35. data/lib/fontisan/models/font_info.rb +3 -30
  36. data/lib/fontisan/models/hint.rb +183 -12
  37. data/lib/fontisan/models/outline.rb +4 -1
  38. data/lib/fontisan/open_type_font.rb +28 -10
  39. data/lib/fontisan/open_type_font_extensions.rb +54 -0
  40. data/lib/fontisan/optimizers/pattern_analyzer.rb +2 -1
  41. data/lib/fontisan/optimizers/subroutine_generator.rb +1 -1
  42. data/lib/fontisan/pipeline/format_detector.rb +249 -0
  43. data/lib/fontisan/pipeline/output_writer.rb +159 -0
  44. data/lib/fontisan/pipeline/strategies/base_strategy.rb +75 -0
  45. data/lib/fontisan/pipeline/strategies/instance_strategy.rb +93 -0
  46. data/lib/fontisan/pipeline/strategies/named_strategy.rb +118 -0
  47. data/lib/fontisan/pipeline/strategies/preserve_strategy.rb +56 -0
  48. data/lib/fontisan/pipeline/transformation_pipeline.rb +416 -0
  49. data/lib/fontisan/pipeline/variation_resolver.rb +165 -0
  50. data/lib/fontisan/subset/table_subsetter.rb +5 -5
  51. data/lib/fontisan/tables/cff/charstring.rb +58 -3
  52. data/lib/fontisan/tables/cff/charstring_builder.rb +34 -0
  53. data/lib/fontisan/tables/cff/charstring_parser.rb +249 -0
  54. data/lib/fontisan/tables/cff/charstring_rebuilder.rb +172 -0
  55. data/lib/fontisan/tables/cff/dict_builder.rb +19 -1
  56. data/lib/fontisan/tables/cff/hint_operation_injector.rb +209 -0
  57. data/lib/fontisan/tables/cff/offset_recalculator.rb +70 -0
  58. data/lib/fontisan/tables/cff/private_dict_writer.rb +131 -0
  59. data/lib/fontisan/tables/cff/table_builder.rb +221 -0
  60. data/lib/fontisan/tables/cff.rb +2 -0
  61. data/lib/fontisan/tables/cff2/charstring_parser.rb +14 -8
  62. data/lib/fontisan/tables/cff2/private_dict_blend_handler.rb +247 -0
  63. data/lib/fontisan/tables/cff2/region_matcher.rb +200 -0
  64. data/lib/fontisan/tables/cff2/table_builder.rb +580 -0
  65. data/lib/fontisan/tables/cff2/table_reader.rb +421 -0
  66. data/lib/fontisan/tables/cff2/variation_data_extractor.rb +212 -0
  67. data/lib/fontisan/tables/cff2.rb +10 -5
  68. data/lib/fontisan/tables/cvar.rb +2 -41
  69. data/lib/fontisan/tables/glyf/compound_glyph_resolver.rb +2 -1
  70. data/lib/fontisan/tables/glyf/curve_converter.rb +10 -4
  71. data/lib/fontisan/tables/glyf/glyph_builder.rb +27 -10
  72. data/lib/fontisan/tables/gvar.rb +2 -41
  73. data/lib/fontisan/tables/name.rb +4 -4
  74. data/lib/fontisan/true_type_font.rb +27 -10
  75. data/lib/fontisan/true_type_font_extensions.rb +54 -0
  76. data/lib/fontisan/utilities/checksum_calculator.rb +42 -0
  77. data/lib/fontisan/validation/checksum_validator.rb +2 -2
  78. data/lib/fontisan/validation/table_validator.rb +1 -1
  79. data/lib/fontisan/validation/variable_font_validator.rb +218 -0
  80. data/lib/fontisan/variation/cache.rb +3 -1
  81. data/lib/fontisan/variation/converter.rb +121 -13
  82. data/lib/fontisan/variation/delta_applier.rb +2 -1
  83. data/lib/fontisan/variation/inspector.rb +2 -1
  84. data/lib/fontisan/variation/instance_generator.rb +2 -1
  85. data/lib/fontisan/variation/instance_writer.rb +341 -0
  86. data/lib/fontisan/variation/optimizer.rb +6 -3
  87. data/lib/fontisan/variation/subsetter.rb +32 -10
  88. data/lib/fontisan/variation/tuple_variation_header.rb +51 -0
  89. data/lib/fontisan/variation/variable_svg_generator.rb +268 -0
  90. data/lib/fontisan/variation/variation_preserver.rb +291 -0
  91. data/lib/fontisan/version.rb +1 -1
  92. data/lib/fontisan/version.rb.orig +9 -0
  93. data/lib/fontisan/woff2/glyf_transformer.rb +693 -0
  94. data/lib/fontisan/woff2/hmtx_transformer.rb +164 -0
  95. data/lib/fontisan/woff2_font.rb +489 -468
  96. data/lib/fontisan/woff_font.rb +16 -11
  97. data/lib/fontisan.rb +54 -2
  98. data/scripts/measure_optimization.rb +15 -7
  99. metadata +37 -2
@@ -0,0 +1,693 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+
5
+ module Fontisan
6
+ module Woff2
7
+ # Reconstructs glyf and loca tables from WOFF2 transformed format
8
+ #
9
+ # WOFF2 glyf table transformation splits glyph data into separate streams
10
+ # for better compression. This transformer reconstructs the standard
11
+ # `glyf` and `loca` table formats from the transformed data.
12
+ #
13
+ # Transformation format (Section 5 of WOFF2 spec):
14
+ # - Separate streams for nContour, nPoints, flags, x-coords, y-coords
15
+ # - Variable-length integer encoding (255UInt16)
16
+ # - Composite glyph components stored separately
17
+ #
18
+ # See: https://www.w3.org/TR/WOFF2/#glyf_table_format
19
+ #
20
+ # @example Reconstructing tables
21
+ # result = GlyfTransformer.reconstruct(transformed_data, num_glyphs)
22
+ # glyf_data = result[:glyf]
23
+ # loca_data = result[:loca]
24
+ class GlyfTransformer
25
+ # Glyph flags
26
+ ON_CURVE_POINT = 0x01
27
+ X_SHORT_VECTOR = 0x02
28
+ Y_SHORT_VECTOR = 0x04
29
+ REPEAT_FLAG = 0x08
30
+ X_IS_SAME_OR_POSITIVE_X_SHORT_VECTOR = 0x10
31
+ Y_IS_SAME_OR_POSITIVE_Y_SHORT_VECTOR = 0x20
32
+
33
+ # Composite glyph flags
34
+ ARG_1_AND_2_ARE_WORDS = 0x0001
35
+ ARGS_ARE_XY_VALUES = 0x0002
36
+ ROUND_XY_TO_GRID = 0x0004
37
+ WE_HAVE_A_SCALE = 0x0008
38
+ MORE_COMPONENTS = 0x0020
39
+ WE_HAVE_AN_X_AND_Y_SCALE = 0x0040
40
+ WE_HAVE_A_TWO_BY_TWO = 0x0080
41
+ WE_HAVE_INSTRUCTIONS = 0x0100
42
+ USE_MY_METRICS = 0x0200
43
+ OVERLAP_COMPOUND = 0x0400
44
+ HAVE_VARIATIONS = 0x1000 # Variable font variation data follows
45
+
46
+ # Reconstruct glyf and loca tables from transformed data
47
+ #
48
+ # @param transformed_data [String] The transformed glyf table data
49
+ # @param num_glyphs [Integer] Number of glyphs from maxp table
50
+ # @param variable_font [Boolean] Whether this is a variable font with variation data
51
+ # @return [Hash] { glyf: String, loca: String }
52
+ # @raise [InvalidFontError] If data is corrupted or invalid
53
+ def self.reconstruct(transformed_data, num_glyphs, variable_font: false)
54
+ io = StringIO.new(transformed_data)
55
+
56
+ # Check minimum size for header
57
+ if io.size < 8
58
+ raise InvalidFontError,
59
+ "Transformed glyf data too small: #{io.size} bytes"
60
+ end
61
+
62
+ # Read header
63
+ read_uint32(io)
64
+ num_glyphs_in_data = read_uint16(io)
65
+ index_format = read_uint16(io)
66
+
67
+ if num_glyphs_in_data != num_glyphs
68
+ raise InvalidFontError,
69
+ "Glyph count mismatch: expected #{num_glyphs}, got #{num_glyphs_in_data}"
70
+ end
71
+
72
+ # Read nContour stream
73
+ n_contour_data = read_stream_safely(io, "nContour",
74
+ variable_font: variable_font)
75
+
76
+ # Read nPoints stream
77
+ n_points_data = read_stream_safely(io, "nPoints",
78
+ variable_font: variable_font)
79
+
80
+ # Read flag stream
81
+ flag_data = read_stream_safely(io, "flag", variable_font: variable_font)
82
+
83
+ # Read glyph stream (coordinates, instructions, composite data)
84
+ glyph_data = read_stream_safely(io, "glyph",
85
+ variable_font: variable_font)
86
+
87
+ # Read composite stream
88
+ composite_data = read_stream_safely(io, "composite",
89
+ variable_font: variable_font)
90
+
91
+ # Read bbox stream
92
+ bbox_data = read_stream_safely(io, "bbox", variable_font: variable_font)
93
+
94
+ # Read instruction stream
95
+ instruction_data = read_stream_safely(io, "instruction",
96
+ variable_font: variable_font)
97
+
98
+ # Parse streams
99
+ n_contours = parse_n_contour_stream(StringIO.new(n_contour_data),
100
+ num_glyphs)
101
+
102
+ # Reconstruct glyphs
103
+ glyphs = reconstruct_glyphs(
104
+ n_contours,
105
+ StringIO.new(n_points_data),
106
+ StringIO.new(flag_data),
107
+ StringIO.new(glyph_data),
108
+ StringIO.new(composite_data),
109
+ StringIO.new(bbox_data),
110
+ StringIO.new(instruction_data),
111
+ variable_font: variable_font,
112
+ )
113
+
114
+ # Build glyf and loca tables
115
+ build_tables(glyphs, index_format)
116
+ end
117
+
118
+ # Safely read a stream with bounds checking
119
+ #
120
+ # @param io [StringIO] Input stream
121
+ # @param stream_name [String] Name of stream for error messages
122
+ # @param variable_font [Boolean] Whether this is a variable font (allows incomplete streams)
123
+ # @return [String] Stream data (empty if not available)
124
+ def self.read_stream_safely(io, _stream_name, variable_font: false)
125
+ remaining = io.size - io.pos
126
+ if remaining < 4
127
+ # Not enough data for stream size - return empty stream
128
+ return ""
129
+ end
130
+
131
+ # Read stream size safely
132
+ size_bytes = io.read(4)
133
+ return "" unless size_bytes && size_bytes.bytesize == 4
134
+
135
+ stream_size = size_bytes.unpack1("N")
136
+ remaining = io.size - io.pos
137
+
138
+ if remaining < stream_size
139
+ # Stream size extends beyond available data
140
+ # Read what we can
141
+ io.read(remaining) || ""
142
+ # For variable fonts, we may have incomplete streams - just return what we have
143
+
144
+ else
145
+ io.read(stream_size) || ""
146
+ end
147
+ end
148
+
149
+ # Read variable-length 255UInt16 integer
150
+ #
151
+ # Format from WOFF2 spec:
152
+ # - value < 253: one byte
153
+ # - value == 253: 253 + next uint16
154
+ # - value == 254: 253 * 2 + next uint16
155
+ # - value == 255: 253 * 3 + next uint16
156
+ #
157
+ # @param io [StringIO] Input stream
158
+ # @return [Integer] Decoded value, or 0 if not enough data
159
+ def self.read_255_uint16(io)
160
+ return 0 if io.eof? || (io.size - io.pos) < 1
161
+
162
+ code_byte = io.read(1)
163
+ return 0 unless code_byte && code_byte.bytesize == 1
164
+
165
+ code = code_byte.unpack1("C")
166
+
167
+ case code
168
+ when 255
169
+ return 0 if io.eof? || (io.size - io.pos) < 2
170
+
171
+ value_bytes = io.read(2)
172
+ return 0 unless value_bytes && value_bytes.bytesize == 2
173
+
174
+ 759 + value_bytes.unpack1("n") # 253 * 3 + value
175
+ when 254
176
+ return 0 if io.eof? || (io.size - io.pos) < 2
177
+
178
+ value_bytes = io.read(2)
179
+ return 0 unless value_bytes && value_bytes.bytesize == 2
180
+
181
+ 506 + value_bytes.unpack1("n") # 253 * 2 + value
182
+ when 253
183
+ return 0 if io.eof? || (io.size - io.pos) < 2
184
+
185
+ value_bytes = io.read(2)
186
+ return 0 unless value_bytes && value_bytes.bytesize == 2
187
+
188
+ 253 + value_bytes.unpack1("n")
189
+ else
190
+ code
191
+ end
192
+ end
193
+
194
+ # Parse nContour stream
195
+ #
196
+ # @param io [StringIO] Input stream
197
+ # @param num_glyphs [Integer] Number of glyphs
198
+ # @return [Array<Integer>] Number of contours per glyph (-1 for composite)
199
+ def self.parse_n_contour_stream(io, num_glyphs)
200
+ n_contours = []
201
+ num_glyphs.times do
202
+ # For variable fonts, stream may be incomplete
203
+ break if io.eof? || (io.size - io.pos) < 2
204
+
205
+ value = read_int16(io)
206
+ n_contours << value
207
+ end
208
+
209
+ # Pad with zeros if we have fewer contours than glyphs
210
+ while n_contours.size < num_glyphs
211
+ n_contours << 0
212
+ end
213
+
214
+ n_contours
215
+ end
216
+
217
+ # Reconstruct all glyphs
218
+ #
219
+ # @param n_contours [Array<Integer>] Contour counts
220
+ # @param n_points_io [StringIO] Points stream
221
+ # @param flag_io [StringIO] Flag stream
222
+ # @param glyph_io [StringIO] Glyph data stream
223
+ # @param composite_io [StringIO] Composite glyph stream
224
+ # @param bbox_io [StringIO] Bounding box stream
225
+ # @param instruction_io [StringIO] Instruction stream
226
+ # @param variable_font [Boolean] Whether this is a variable font
227
+ # @return [Array<String>] Reconstructed glyph data
228
+ def self.reconstruct_glyphs(n_contours, n_points_io, flag_io, glyph_io,
229
+ composite_io, bbox_io, instruction_io, variable_font: false)
230
+ glyphs = []
231
+
232
+ n_contours.each do |num_contours|
233
+ if num_contours.zero?
234
+ # Empty glyph
235
+ glyphs << ""
236
+ elsif num_contours.positive?
237
+ # Simple glyph
238
+ glyphs << reconstruct_simple_glyph(
239
+ num_contours, n_points_io, flag_io,
240
+ glyph_io, bbox_io, instruction_io
241
+ )
242
+ elsif num_contours == -1
243
+ # Composite glyph
244
+ glyphs << reconstruct_composite_glyph(
245
+ composite_io, bbox_io, instruction_io, variable_font: variable_font
246
+ )
247
+ else
248
+ raise InvalidFontError, "Invalid nContours value: #{num_contours}"
249
+ end
250
+ end
251
+
252
+ glyphs
253
+ end
254
+
255
+ # Reconstruct a simple glyph
256
+ #
257
+ # @param num_contours [Integer] Number of contours
258
+ # @param n_points_io [StringIO] Points stream
259
+ # @param flag_io [StringIO] Flag stream
260
+ # @param glyph_io [StringIO] Glyph data stream
261
+ # @param bbox_io [StringIO] Bounding box stream
262
+ # @param instruction_io [StringIO] Instruction stream
263
+ # @return [String] Glyph data in standard format
264
+ def self.reconstruct_simple_glyph(num_contours, n_points_io, flag_io,
265
+ glyph_io, bbox_io, instruction_io)
266
+ # Read end points of contours
267
+ end_pts_of_contours = []
268
+ num_contours.times do
269
+ if end_pts_of_contours.empty?
270
+ end_pts_of_contours << read_255_uint16(n_points_io)
271
+ else
272
+ delta = read_255_uint16(n_points_io)
273
+ end_pts_of_contours << end_pts_of_contours.last + delta + 1
274
+ end
275
+ end
276
+
277
+ total_points = end_pts_of_contours.last + 1
278
+
279
+ # Read flags
280
+ flags = read_flags(flag_io, total_points)
281
+
282
+ # Read coordinates
283
+ x_coordinates = read_coordinates(glyph_io, flags, X_SHORT_VECTOR,
284
+ X_IS_SAME_OR_POSITIVE_X_SHORT_VECTOR)
285
+ y_coordinates = read_coordinates(glyph_io, flags, Y_SHORT_VECTOR,
286
+ Y_IS_SAME_OR_POSITIVE_Y_SHORT_VECTOR)
287
+
288
+ # Read bounding box safely
289
+ bbox_remaining = bbox_io.size - bbox_io.pos
290
+ if bbox_remaining < 8
291
+ # Not enough data, use default bounding box
292
+ x_min = y_min = x_max = y_max = 0
293
+ else
294
+ bbox_bytes = bbox_io.read(8)
295
+ if bbox_bytes && bbox_bytes.bytesize == 8
296
+ x_min, y_min, x_max, y_max = bbox_bytes.unpack("n4")
297
+ # Convert to signed
298
+ x_min = x_min > 0x7FFF ? x_min - 0x10000 : x_min
299
+ y_min = y_min > 0x7FFF ? y_min - 0x10000 : y_min
300
+ x_max = x_max > 0x7FFF ? x_max - 0x10000 : x_max
301
+ y_max = y_max > 0x7FFF ? y_max - 0x10000 : y_max
302
+ else
303
+ x_min = y_min = x_max = y_max = 0
304
+ end
305
+ end
306
+
307
+ # Read instructions safely
308
+ instruction_length = 0
309
+ instructions = ""
310
+
311
+ inst_remaining = instruction_io.size - instruction_io.pos
312
+ if inst_remaining >= 2
313
+ inst_length_data = read_255_uint16(instruction_io)
314
+ if inst_length_data
315
+ instruction_length = inst_length_data
316
+ if instruction_length.positive?
317
+ inst_remaining = instruction_io.size - instruction_io.pos
318
+ instructions = if inst_remaining >= instruction_length
319
+ instruction_io.read(instruction_length) || ""
320
+ else
321
+ # Read what we can
322
+ instruction_io.read(inst_remaining) || ""
323
+ end
324
+ end
325
+ end
326
+ end
327
+
328
+ # Build glyph data in standard format
329
+ build_simple_glyph_data(num_contours, x_min, y_min, x_max, y_max,
330
+ end_pts_of_contours, instructions, flags,
331
+ x_coordinates, y_coordinates)
332
+ end
333
+
334
+ # Reconstruct a composite glyph
335
+ #
336
+ # @param composite_io [StringIO] Composite stream
337
+ # @param bbox_io [StringIO] Bounding box stream
338
+ # @param instruction_io [StringIO] Instruction stream
339
+ # @param variable_font [Boolean] Whether this is a variable font
340
+ # @return [String] Glyph data in standard format
341
+ def self.reconstruct_composite_glyph(composite_io, bbox_io,
342
+ instruction_io, variable_font: false)
343
+ # Track available bytes to prevent EOF errors
344
+ composite_size = composite_io.size - composite_io.pos
345
+
346
+ # Validate minimum size (at least flags + glyph_index + args)
347
+ return "" if composite_size < 8
348
+
349
+ # Read bounding box safely
350
+ bbox_remaining = bbox_io.size - bbox_io.pos
351
+ if bbox_remaining < 8
352
+ # Not enough data for bounding box, return empty glyph
353
+ return ""
354
+ end
355
+
356
+ bbox_bytes = bbox_io.read(8)
357
+ unless bbox_bytes && bbox_bytes.bytesize == 8
358
+ return ""
359
+ end
360
+
361
+ x_min, y_min, x_max, y_max = bbox_bytes.unpack("n4")
362
+ # Convert to signed
363
+ x_min = x_min > 0x7FFF ? x_min - 0x10000 : x_min
364
+ y_min = y_min > 0x7FFF ? y_min - 0x10000 : y_min
365
+ x_max = x_max > 0x7FFF ? x_max - 0x10000 : x_max
366
+ y_max = y_max > 0x7FFF ? y_max - 0x10000 : y_max
367
+
368
+ # Read composite data
369
+ composite_data = +""
370
+ has_instructions = false
371
+ has_variations = false
372
+
373
+ loop do
374
+ # Check if we have enough bytes for flags and glyph_index
375
+ remaining = composite_io.size - composite_io.pos
376
+ break if composite_io.eof? || remaining < 4
377
+
378
+ # Read flags and glyph_index safely
379
+ component_header = composite_io.read(4)
380
+ break unless component_header && component_header.bytesize == 4
381
+
382
+ flags, glyph_index = component_header.unpack("n2")
383
+
384
+ # Write flags and index
385
+ composite_data << [flags].pack("n")
386
+ composite_data << [glyph_index].pack("n")
387
+
388
+ # Read arguments (depend on flags)
389
+ remaining = composite_io.size - composite_io.pos
390
+ if (flags & ARG_1_AND_2_ARE_WORDS).zero?
391
+ break if composite_io.eof? || remaining < 2
392
+
393
+ arg_bytes = composite_io.read(2)
394
+ break unless arg_bytes && arg_bytes.bytesize == 2
395
+
396
+ arg1, arg2 = arg_bytes.unpack("c2")
397
+ composite_data << [arg1, arg2].pack("c2")
398
+ else
399
+ break if composite_io.eof? || remaining < 4
400
+
401
+ arg_bytes = composite_io.read(4)
402
+ break unless arg_bytes && arg_bytes.bytesize == 4
403
+
404
+ arg1, arg2 = arg_bytes.unpack("n2")
405
+ # Convert to signed
406
+ arg1 = arg1 > 0x7FFF ? arg1 - 0x10000 : arg1
407
+ arg2 = arg2 > 0x7FFF ? arg2 - 0x10000 : arg2
408
+ composite_data << [arg1, arg2].pack("n2")
409
+ end
410
+
411
+ # Read transformation matrix (depends on flags) with bounds checking
412
+ if (flags & WE_HAVE_A_SCALE) != 0
413
+ remaining = composite_io.size - composite_io.pos
414
+ break if composite_io.eof? || remaining < 2
415
+
416
+ scale_bytes = composite_io.read(2)
417
+ break unless scale_bytes && scale_bytes.bytesize == 2
418
+
419
+ scale = scale_bytes.unpack1("n")
420
+ composite_data << [scale].pack("n")
421
+ elsif (flags & WE_HAVE_AN_X_AND_Y_SCALE) != 0
422
+ remaining = composite_io.size - composite_io.pos
423
+ break if composite_io.eof? || remaining < 4
424
+
425
+ scale_bytes = composite_io.read(4)
426
+ break unless scale_bytes && scale_bytes.bytesize == 4
427
+
428
+ x_scale, y_scale = scale_bytes.unpack("n2")
429
+ composite_data << [x_scale, y_scale].pack("n2")
430
+ elsif (flags & WE_HAVE_A_TWO_BY_TWO) != 0
431
+ remaining = composite_io.size - composite_io.pos
432
+ break if composite_io.eof? || remaining < 8
433
+
434
+ matrix_bytes = composite_io.read(8)
435
+ break unless matrix_bytes && matrix_bytes.bytesize == 8
436
+
437
+ x_scale, scale01, scale10, y_scale = matrix_bytes.unpack("n4")
438
+ composite_data << [x_scale, scale01, scale10, y_scale].pack("n4")
439
+ end
440
+
441
+ # Check for variable font variation data
442
+ # Only parse if this is a variable font and the flag is set
443
+ if variable_font && (flags & HAVE_VARIATIONS) != 0
444
+ has_variations = true
445
+ # Read tuple variation count and data
446
+ remaining = composite_io.size - composite_io.pos
447
+ if !composite_io.eof? && remaining >= 2
448
+ # Read tuple count safely
449
+ tuple_bytes = composite_io.read(2)
450
+ if tuple_bytes && tuple_bytes.bytesize == 2
451
+ tuple_count = tuple_bytes.unpack1("n")
452
+ composite_data << [tuple_count].pack("n")
453
+
454
+ # Each tuple has variation data - read and preserve it
455
+ tuple_count.times do
456
+ remaining = composite_io.size - composite_io.pos
457
+ break if composite_io.eof? || remaining < 4
458
+
459
+ # Read variation data (2 int16 values per tuple)
460
+ var_bytes = composite_io.read(4)
461
+ break unless var_bytes && var_bytes.bytesize == 4
462
+
463
+ var1, var2 = var_bytes.unpack("n2")
464
+ # Convert to signed if needed
465
+ var1 = var1 > 0x7FFF ? var1 - 0x10000 : var1
466
+ var2 = var2 > 0x7FFF ? var2 - 0x10000 : var2
467
+ composite_data << [var1, var2].pack("n2")
468
+ end
469
+ end
470
+ end
471
+ end
472
+
473
+ has_instructions = (flags & WE_HAVE_INSTRUCTIONS) != 0
474
+
475
+ break if (flags & MORE_COMPONENTS).zero?
476
+ end
477
+
478
+ # Add instructions if present
479
+ instructions = +""
480
+ if has_instructions
481
+ # Read instruction length safely
482
+ remaining = instruction_io.size - instruction_io.pos
483
+ if !instruction_io.eof? && remaining >= 2
484
+ length_bytes = instruction_io.read(2)
485
+ if length_bytes && length_bytes.bytesize == 2
486
+ instruction_length = length_bytes.unpack1("n")
487
+ if instruction_length.positive?
488
+ remaining = instruction_io.size - instruction_io.pos
489
+ instructions = if remaining >= instruction_length
490
+ instruction_io.read(instruction_length) || ""
491
+ else
492
+ # Read what we can
493
+ instruction_io.read(remaining) || ""
494
+ end
495
+ end
496
+ end
497
+ end
498
+ end
499
+
500
+ # Build composite glyph data
501
+ data = +""
502
+ data << [-1].pack("n") # numberOfContours = -1
503
+ data << [x_min, y_min, x_max, y_max].pack("n4")
504
+ data << composite_data
505
+ data << [instructions.bytesize].pack("n") if has_instructions
506
+ data << instructions if has_instructions
507
+
508
+ data
509
+ end
510
+
511
+ # Read flags with repeat handling
512
+ #
513
+ # @param io [StringIO] Flag stream
514
+ # @param count [Integer] Number of flags to read
515
+ # @return [Array<Integer>] Flag values
516
+ def self.read_flags(io, count)
517
+ flags = []
518
+
519
+ while flags.size < count
520
+ # EOF protection for variable fonts
521
+ break if io.eof? || (io.size - io.pos) < 1
522
+
523
+ flag = read_uint8(io)
524
+ flags << flag
525
+
526
+ if (flag & REPEAT_FLAG) != 0
527
+ break if io.eof? || (io.size - io.pos) < 1
528
+
529
+ repeat_count = read_uint8(io)
530
+ repeat_count.times { flags << flag }
531
+ end
532
+ end
533
+
534
+ # Pad with zero flags if needed
535
+ while flags.size < count
536
+ flags << 0
537
+ end
538
+
539
+ flags
540
+ end
541
+
542
+ # Read coordinates
543
+ #
544
+ # @param io [StringIO] Glyph stream
545
+ # @param flags [Array<Integer>] Flag values
546
+ # @param short_flag [Integer] Flag bit for short vector
547
+ # @param same_or_positive_flag [Integer] Flag bit for same/positive
548
+ # @return [Array<Integer>] Coordinate values
549
+ def self.read_coordinates(io, flags, short_flag, same_or_positive_flag)
550
+ coords = []
551
+ value = 0
552
+
553
+ flags.each do |flag|
554
+ # EOF protection
555
+ if (flag & short_flag) != 0
556
+ break if io.eof? || (io.size - io.pos) < 1
557
+
558
+ # Short vector (one byte)
559
+ delta = read_uint8(io)
560
+ delta = -delta if (flag & same_or_positive_flag).zero?
561
+ elsif (flag & same_or_positive_flag) != 0
562
+ # Same as previous (delta = 0)
563
+ delta = 0
564
+ else
565
+ break if io.eof? || (io.size - io.pos) < 2
566
+
567
+ # Long vector (two bytes, signed)
568
+ delta = read_int16(io)
569
+ end
570
+
571
+ value += delta
572
+ coords << value
573
+ end
574
+
575
+ # Pad with last value if needed
576
+ last_val = coords.last || 0
577
+ while coords.size < flags.size
578
+ coords << last_val
579
+ end
580
+
581
+ coords
582
+ end
583
+
584
+ # Build simple glyph data in standard format
585
+ #
586
+ # @return [String] Glyph data
587
+ def self.build_simple_glyph_data(num_contours, x_min, y_min, x_max, y_max,
588
+ end_pts, instructions, flags, x_coords, y_coords)
589
+ data = +""
590
+ data << [num_contours].pack("n")
591
+ data << [x_min, y_min, x_max, y_max].pack("n4")
592
+
593
+ end_pts.each { |pt| data << [pt].pack("n") }
594
+
595
+ data << [instructions.bytesize].pack("n")
596
+ data << instructions
597
+
598
+ flags.each { |flag| data << [flag].pack("C") }
599
+
600
+ # Write x-coordinates
601
+ prev_x = 0
602
+ x_coords.each do |x|
603
+ delta = x - prev_x
604
+ prev_x = x
605
+
606
+ data << if delta.abs <= 255
607
+ [delta.abs].pack("C")
608
+ else
609
+ [delta].pack("n")
610
+ end
611
+ end
612
+
613
+ # Write y-coordinates
614
+ prev_y = 0
615
+ y_coords.each do |y|
616
+ delta = y - prev_y
617
+ prev_y = y
618
+
619
+ data << if delta.abs <= 255
620
+ [delta.abs].pack("C")
621
+ else
622
+ [delta].pack("n")
623
+ end
624
+ end
625
+
626
+ data
627
+ end
628
+
629
+ # Build glyf and loca tables
630
+ #
631
+ # @param glyphs [Array<String>] Glyph data
632
+ # @param index_format [Integer] Loca format (0 = short, 1 = long)
633
+ # @return [Hash] { glyf: String, loca: String }
634
+ def self.build_tables(glyphs, index_format)
635
+ glyf_data = +""
636
+ loca_offsets = [0]
637
+
638
+ glyphs.each do |glyph|
639
+ glyf_data << glyph
640
+
641
+ # Add padding to 4-byte boundary
642
+ padding = (4 - (glyph.bytesize % 4)) % 4
643
+ glyf_data << ("\x00" * padding)
644
+
645
+ loca_offsets << glyf_data.bytesize
646
+ end
647
+
648
+ # Build loca table
649
+ loca_data = +""
650
+ if index_format.zero?
651
+ # Short format (divide offsets by 2)
652
+ loca_offsets.each do |offset|
653
+ loca_data << [offset / 2].pack("n")
654
+ end
655
+ else
656
+ # Long format
657
+ loca_offsets.each do |offset|
658
+ loca_data << [offset].pack("N")
659
+ end
660
+ end
661
+
662
+ { glyf: glyf_data, loca: loca_data }
663
+ end
664
+
665
+ # Helper methods for reading binary data
666
+
667
+ def self.read_uint8(io)
668
+ io.read(1)&.unpack1("C") || raise(EOFError, "Unexpected end of stream")
669
+ end
670
+
671
+ def self.read_int8(io)
672
+ io.read(1)&.unpack1("c") || raise(EOFError, "Unexpected end of stream")
673
+ end
674
+
675
+ def self.read_uint16(io)
676
+ io.read(2)&.unpack1("n") || raise(EOFError, "Unexpected end of stream")
677
+ end
678
+
679
+ def self.read_int16(io)
680
+ value = read_uint16(io)
681
+ value > 0x7FFF ? value - 0x10000 : value
682
+ end
683
+
684
+ def self.read_uint32(io)
685
+ io.read(4)&.unpack1("N") || raise(EOFError, "Unexpected end of stream")
686
+ end
687
+
688
+ def self.read_f2dot14(io)
689
+ read_uint16(io)
690
+ end
691
+ end
692
+ end
693
+ end