fontisan 0.2.3 → 0.2.4

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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +92 -40
  3. data/README.adoc +262 -3
  4. data/Rakefile +20 -7
  5. data/lib/fontisan/commands/base_command.rb +2 -19
  6. data/lib/fontisan/commands/convert_command.rb +16 -13
  7. data/lib/fontisan/commands/info_command.rb +88 -0
  8. data/lib/fontisan/config/conversion_matrix.yml +58 -20
  9. data/lib/fontisan/converters/outline_converter.rb +6 -3
  10. data/lib/fontisan/converters/svg_generator.rb +45 -0
  11. data/lib/fontisan/converters/woff2_encoder.rb +106 -13
  12. data/lib/fontisan/models/bitmap_glyph.rb +123 -0
  13. data/lib/fontisan/models/bitmap_strike.rb +94 -0
  14. data/lib/fontisan/models/color_glyph.rb +57 -0
  15. data/lib/fontisan/models/color_layer.rb +53 -0
  16. data/lib/fontisan/models/color_palette.rb +60 -0
  17. data/lib/fontisan/models/font_info.rb +26 -0
  18. data/lib/fontisan/models/svg_glyph.rb +89 -0
  19. data/lib/fontisan/open_type_font.rb +6 -0
  20. data/lib/fontisan/optimizers/charstring_rewriter.rb +19 -8
  21. data/lib/fontisan/optimizers/pattern_analyzer.rb +4 -2
  22. data/lib/fontisan/optimizers/subroutine_builder.rb +6 -5
  23. data/lib/fontisan/optimizers/subroutine_optimizer.rb +5 -2
  24. data/lib/fontisan/pipeline/output_writer.rb +2 -2
  25. data/lib/fontisan/tables/cbdt.rb +169 -0
  26. data/lib/fontisan/tables/cblc.rb +290 -0
  27. data/lib/fontisan/tables/cff.rb +6 -12
  28. data/lib/fontisan/tables/colr.rb +291 -0
  29. data/lib/fontisan/tables/cpal.rb +281 -0
  30. data/lib/fontisan/tables/glyf/glyph_builder.rb +5 -1
  31. data/lib/fontisan/tables/sbix.rb +379 -0
  32. data/lib/fontisan/tables/svg.rb +301 -0
  33. data/lib/fontisan/true_type_font.rb +6 -0
  34. data/lib/fontisan/validation/woff2_header_validator.rb +278 -0
  35. data/lib/fontisan/validation/woff2_table_validator.rb +270 -0
  36. data/lib/fontisan/validation/woff2_validator.rb +248 -0
  37. data/lib/fontisan/version.rb +1 -1
  38. data/lib/fontisan/woff2/directory.rb +40 -11
  39. data/lib/fontisan/woff2/table_transformer.rb +506 -73
  40. data/lib/fontisan/woff2_font.rb +29 -9
  41. data/lib/fontisan/woff_font.rb +17 -4
  42. data/lib/fontisan.rb +12 -0
  43. metadata +17 -2
@@ -8,15 +8,9 @@ module Fontisan
8
8
  # handles table transformations that improve compression in WOFF2.
9
9
  # The WOFF2 spec defines transformations for glyf/loca and hmtx tables.
10
10
  #
11
- # For Phase 2 Milestone 2.1:
12
- # - Architecture is in place for transformations
13
- # - Actual transformation implementations are marked as TODO
14
- # - Tables are copied as-is without transformation
15
- # - This allows valid WOFF2 generation while leaving room for optimization
16
- #
17
- # Future milestones will implement:
18
- # - glyf/loca transformation (combined stream, delta encoding)
19
- # - hmtx transformation (compact representation)
11
+ # Transformations implemented:
12
+ # - glyf/loca: Combined stream format with specialized encoding
13
+ # - hmtx: Delta encoding with 255UInt16 compression
20
14
  #
21
15
  # Reference: https://www.w3.org/TR/WOFF2/#table_tranforms
22
16
  #
@@ -36,12 +30,8 @@ module Fontisan
36
30
 
37
31
  # Transform a table for WOFF2 encoding
38
32
  #
39
- # For Milestone 2.1, this returns the original table data
40
- # without transformation. The architecture supports future
41
- # implementation of actual transformations.
42
- #
43
33
  # @param tag [String] Table tag
44
- # @return [String, nil] Transformed (or original) table data
34
+ # @return [String, nil] Transformed table data
45
35
  def transform_table(tag)
46
36
  case tag
47
37
  when "glyf"
@@ -66,87 +56,92 @@ module Fontisan
66
56
 
67
57
  # Determine transformation version for a table
68
58
  #
69
- # For Milestone 2.1, always returns TRANSFORM_NONE since
70
- # we don't implement transformations yet.
71
- #
72
59
  # @param tag [String] Table tag
73
- # @return [Integer] Transformation version (0 = none)
74
- def transformation_version(_tag)
75
- # For this milestone, no transformations are applied
76
- Directory::TRANSFORM_NONE
60
+ # @return [Integer] Transformation version
61
+ def transformation_version(tag)
62
+ case tag
63
+ when "glyf", "loca"
64
+ Directory::TRANSFORM_GLYF_LOCA
65
+ when "hmtx"
66
+ Directory::TRANSFORM_HMTX
67
+ else
68
+ Directory::TRANSFORM_NONE
69
+ end
77
70
  end
78
71
 
79
72
  private
80
73
 
81
74
  # Transform glyf table
82
75
  #
83
- # The WOFF2 glyf transformation combines glyf and loca into a
84
- # single stream with delta-encoded coordinates and flags.
76
+ # Implements WOFF2 glyf transformation by splitting glyph data into 8 streams:
77
+ # 1. nContour stream - number of contours per glyph
78
+ # 2. nPoints stream - end points of contours (255UInt16)
79
+ # 3. Flag stream - point flags with run-length encoding
80
+ # 4. Glyph stream - x/y coordinates (delta-encoded)
81
+ # 5. Composite stream - composite glyph data
82
+ # 6. Bbox stream - bounding boxes
83
+ # 7. Instruction stream - hinting instructions
84
+ # 8. Composite bbox stream - not used in current implementation
85
85
  #
86
- # TODO: Implement full glyf transformation for better compression.
87
- # For now, returns original table data.
88
- #
89
- # @return [String, nil] Transformed glyf data
86
+ # @return [String] Transformed glyf data
90
87
  def transform_glyf
91
- # TODO: Implement glyf transformation
92
- # This would involve:
93
- # 1. Parse all glyphs from glyf table
94
- # 2. Combine with loca offsets
95
- # 3. Create transformed stream with:
96
- # - nContour values
97
- # - nPoints values
98
- # - Flag bytes (with run-length encoding)
99
- # - x-coordinates (delta-encoded)
100
- # - y-coordinates (delta-encoded)
101
- # - Instruction bytes
102
- # 4. Use 255UInt16 encoding for variable-length integers
103
- #
104
- # Reference: https://www.w3.org/TR/WOFF2/#glyf_table_format
105
-
106
- get_table_data("glyf")
88
+ glyf_data = get_table_data("glyf")
89
+ loca_data = get_table_data("loca")
90
+
91
+ return glyf_data unless glyf_data && loca_data
92
+
93
+ # Get number of glyphs from maxp table
94
+ maxp_table = font.table("maxp")
95
+ return glyf_data unless maxp_table
96
+
97
+ num_glyphs = maxp_table.num_glyphs
98
+
99
+ # Get head table to determine loca format
100
+ head_table = font.table("head")
101
+ return glyf_data unless head_table
102
+
103
+ index_format = head_table.index_to_loc_format
104
+
105
+ # Parse glyphs from glyf/loca tables
106
+ glyphs = parse_glyphs(glyf_data, loca_data, num_glyphs, index_format)
107
+
108
+ # Build transformed streams
109
+ build_transformed_glyf(glyphs, num_glyphs, index_format)
107
110
  end
108
111
 
109
112
  # Transform loca table
110
113
  #
111
114
  # In WOFF2, loca is combined with glyf during transformation.
112
- # When glyf is transformed, loca table is omitted from output.
115
+ # Return nil to indicate loca should be omitted from output.
113
116
  #
114
- # TODO: Implement loca transformation (combined with glyf).
115
- # For now, returns original table data.
116
- #
117
- # @return [String, nil] Transformed loca data
117
+ # @return [nil]
118
118
  def transform_loca
119
- # TODO: Implement loca transformation
120
- # When glyf transformation is implemented, loca will be:
121
- # 1. Combined into the transformed glyf stream
122
- # 2. Reconstructed during decompression
123
- # 3. Not present as separate table in WOFF2
124
-
125
- get_table_data("loca")
119
+ # loca is combined into transformed glyf, so return nil
120
+ nil
126
121
  end
127
122
 
128
123
  # Transform hmtx table
129
124
  #
130
- # The WOFF2 hmtx transformation stores advance widths more efficiently
131
- # by exploiting redundancy (many glyphs have same advance width).
125
+ # Implements WOFF2 hmtx transformation using delta encoding and 255UInt16.
132
126
  #
133
- # TODO: Implement hmtx transformation for better compression.
134
- # For now, returns original table data.
135
- #
136
- # @return [String, nil] Transformed hmtx data
127
+ # @return [String] Transformed hmtx data
137
128
  def transform_hmtx
138
- # TODO: Implement hmtx transformation
139
- # This would involve:
140
- # 1. Parse hmtx table
141
- # 2. Extract common advance widths
142
- # 3. Identify proportional vs monospace sections
143
- # 4. Use flags to indicate structure
144
- # 5. Store only unique advance widths
145
- # 6. Store LSB array separately
146
- #
147
- # Reference: https://www.w3.org/TR/WOFF2/#hmtx_table_format
129
+ hmtx_data = get_table_data("hmtx")
130
+ return hmtx_data unless hmtx_data
131
+
132
+ # Get required metadata
133
+ hhea_table = font.table("hhea")
134
+ maxp_table = font.table("maxp")
135
+ return hmtx_data unless hhea_table && maxp_table
136
+
137
+ num_h_metrics = hhea_table.number_of_h_metrics
138
+ num_glyphs = maxp_table.num_glyphs
148
139
 
149
- get_table_data("hmtx")
140
+ # Parse hmtx table
141
+ advance_widths, lsbs = parse_hmtx_table(hmtx_data, num_h_metrics, num_glyphs)
142
+
143
+ # Build transformed hmtx table
144
+ build_transformed_hmtx(advance_widths, lsbs, num_h_metrics, num_glyphs)
150
145
  end
151
146
 
152
147
  # Get raw table data from font
@@ -156,7 +151,445 @@ module Fontisan
156
151
  def get_table_data(tag)
157
152
  return nil unless font.respond_to?(:table_data)
158
153
 
159
- font.table_data(tag)
154
+ font.table_data[tag]
155
+ end
156
+
157
+ # Parse glyphs from glyf and loca tables
158
+ #
159
+ # @param glyf_data [String] glyf table data
160
+ # @param loca_data [String] loca table data
161
+ # @param num_glyphs [Integer] Number of glyphs
162
+ # @param index_format [Integer] Loca format (0=short, 1=long)
163
+ # @return [Array<Hash>] Array of glyph hashes
164
+ def parse_glyphs(glyf_data, loca_data, num_glyphs, index_format)
165
+ # Parse loca offsets
166
+ offsets = parse_loca_offsets(loca_data, num_glyphs, index_format)
167
+
168
+ glyphs = []
169
+ num_glyphs.times do |i|
170
+ start_offset = offsets[i]
171
+ end_offset = offsets[i + 1]
172
+
173
+ if start_offset == end_offset
174
+ # Empty glyph
175
+ glyphs << { type: :empty, data: nil }
176
+ else
177
+ glyph_data = glyf_data[start_offset...end_offset]
178
+ glyphs << parse_glyph(glyph_data)
179
+ end
180
+ end
181
+
182
+ glyphs
183
+ end
184
+
185
+ # Parse loca table to get glyph offsets
186
+ #
187
+ # @param loca_data [String] loca table data
188
+ # @param num_glyphs [Integer] Number of glyphs
189
+ # @param index_format [Integer] Format (0=short, 1=long)
190
+ # @return [Array<Integer>] Glyph offsets
191
+ def parse_loca_offsets(loca_data, num_glyphs, index_format)
192
+ offsets = []
193
+ io = StringIO.new(loca_data)
194
+
195
+ (num_glyphs + 1).times do
196
+ offsets << if index_format.zero?
197
+ # Short format (uint16, actual offset = value * 2)
198
+ (io.read(2)&.unpack1("n") || 0) * 2
199
+ else
200
+ # Long format (uint32)
201
+ io.read(4)&.unpack1("N") || 0
202
+ end
203
+ end
204
+
205
+ offsets
206
+ end
207
+
208
+ # Parse a single glyph
209
+ #
210
+ # @param data [String] Glyph data
211
+ # @return [Hash] Glyph information
212
+ def parse_glyph(data)
213
+ io = StringIO.new(data)
214
+
215
+ num_contours = io.read(2)&.unpack1("n") || 0
216
+ num_contours = num_contours > 0x7FFF ? num_contours - 0x10000 : num_contours
217
+
218
+ if num_contours.zero?
219
+ { type: :empty, data: nil }
220
+ elsif num_contours.positive?
221
+ parse_simple_glyph(io, num_contours, data)
222
+ else
223
+ parse_composite_glyph(io, data)
224
+ end
225
+ end
226
+
227
+ # Parse simple glyph
228
+ #
229
+ # @param io [StringIO] Data stream
230
+ # @param num_contours [Integer] Number of contours
231
+ # @param data [String] Full glyph data
232
+ # @return [Hash] Glyph information
233
+ def parse_simple_glyph(io, num_contours, _data)
234
+ # Read bounding box
235
+ x_min = read_int16(io)
236
+ y_min = read_int16(io)
237
+ x_max = read_int16(io)
238
+ y_max = read_int16(io)
239
+
240
+ # Read end points of contours
241
+ end_pts = []
242
+ num_contours.times { end_pts << io.read(2)&.unpack1("n") }
243
+
244
+ total_points = end_pts.last + 1
245
+
246
+ # Read instruction length and instructions
247
+ inst_length = io.read(2)&.unpack1("n") || 0
248
+ instructions = inst_length.positive? ? io.read(inst_length) : ""
249
+
250
+ # Read flags
251
+ flags = []
252
+ while flags.size < total_points
253
+ flag = io.read(1)&.unpack1("C")
254
+ flags << flag
255
+
256
+ if (flag & 0x08) != 0 # REPEAT_FLAG
257
+ repeat_count = io.read(1)&.unpack1("C")
258
+ repeat_count.times { flags << flag }
259
+ end
260
+ end
261
+
262
+ # Read x-coordinates
263
+ x_coords = read_coordinates(io, flags, 0x02, 0x10)
264
+
265
+ # Read y-coordinates
266
+ y_coords = read_coordinates(io, flags, 0x04, 0x20)
267
+
268
+ {
269
+ type: :simple,
270
+ num_contours: num_contours,
271
+ bbox: [x_min, y_min, x_max, y_max],
272
+ end_pts: end_pts,
273
+ instructions: instructions,
274
+ flags: flags,
275
+ x_coords: x_coords,
276
+ y_coords: y_coords,
277
+ }
278
+ end
279
+
280
+ # Parse composite glyph
281
+ #
282
+ # @param io [StringIO] Data stream
283
+ # @param data [String] Full glyph data
284
+ # @return [Hash] Glyph information
285
+ def parse_composite_glyph(io, data)
286
+ # Read bounding box at start
287
+ x_min = read_int16(io)
288
+ y_min = read_int16(io)
289
+ x_max = read_int16(io)
290
+ y_max = read_int16(io)
291
+
292
+ # Read composite components
293
+ components = []
294
+ instructions = ""
295
+
296
+ loop do
297
+ start_pos = io.pos
298
+ flags = io.read(2)&.unpack1("n")
299
+ glyph_index = io.read(2)&.unpack1("n")
300
+
301
+ component = { flags: flags, glyph_index: glyph_index }
302
+
303
+ # Read arguments based on flags
304
+ if (flags & 0x0001).zero?
305
+ arg1 = io.read(1)&.unpack1("c")
306
+ arg2 = io.read(1)&.unpack1("c")
307
+ else # ARG_1_AND_2_ARE_WORDS
308
+ arg1 = read_int16(io)
309
+ arg2 = read_int16(io)
310
+ end
311
+ component[:arg1] = arg1
312
+ component[:arg2] = arg2
313
+
314
+ # Read transformation based on flags
315
+ if (flags & 0x0008) != 0 # WE_HAVE_A_SCALE
316
+ component[:scale] = io.read(2)&.unpack1("n")
317
+ elsif (flags & 0x0040) != 0 # WE_HAVE_AN_X_AND_Y_SCALE
318
+ component[:x_scale] = io.read(2)&.unpack1("n")
319
+ component[:y_scale] = io.read(2)&.unpack1("n")
320
+ elsif (flags & 0x0080) != 0 # WE_HAVE_A_TWO_BY_TWO
321
+ component[:x_scale] = io.read(2)&.unpack1("n")
322
+ component[:scale01] = io.read(2)&.unpack1("n")
323
+ component[:scale10] = io.read(2)&.unpack1("n")
324
+ component[:y_scale] = io.read(2)&.unpack1("n")
325
+ end
326
+
327
+ # Store raw component data
328
+ end_pos = io.pos
329
+ component[:raw_data] = data[start_pos...end_pos]
330
+
331
+ components << component
332
+
333
+ (flags & 0x0100) != 0
334
+
335
+ break if (flags & 0x0020).zero? # MORE_COMPONENTS
336
+ end
337
+
338
+ # Read instructions if present
339
+ if components.last && (components.last[:flags] & 0x0100) != 0
340
+ inst_length = io.read(2)&.unpack1("n") || 0
341
+ instructions = inst_length.positive? ? io.read(inst_length) : ""
342
+ end
343
+
344
+ {
345
+ type: :composite,
346
+ bbox: [x_min, y_min, x_max, y_max],
347
+ components: components,
348
+ instructions: instructions,
349
+ }
350
+ end
351
+
352
+ # Parse hmtx table
353
+ #
354
+ # @param hmtx_data [String] hmtx table data
355
+ # @param num_h_metrics [Integer] Number of hMetric entries
356
+ # @param num_glyphs [Integer] Total number of glyphs
357
+ # @return [Array<Array<Integer>, Array<Integer>>] [advance_widths, lsbs]
358
+ def parse_hmtx_table(hmtx_data, num_h_metrics, num_glyphs)
359
+ io = StringIO.new(hmtx_data)
360
+ advance_widths = []
361
+ lsbs = []
362
+
363
+ # Read longHorMetric array (advance width + LSB pairs)
364
+ num_h_metrics.times do
365
+ advance_width = io.read(2)&.unpack1("n") || 0
366
+ lsb = read_int16(io)
367
+
368
+ advance_widths << advance_width
369
+ lsbs << lsb
370
+ end
371
+
372
+ # Read remaining LSB values (these glyphs share last advance width)
373
+ (num_glyphs - num_h_metrics).times do
374
+ lsb = read_int16(io)
375
+ lsbs << lsb
376
+ end
377
+
378
+ [advance_widths, lsbs]
379
+ end
380
+
381
+ # Build transformed hmtx table
382
+ #
383
+ # Uses proportional encoding with deltas for advance widths
384
+ # and explicit LSB values.
385
+ #
386
+ # @param advance_widths [Array<Integer>] Advance widths
387
+ # @param lsbs [Array<Integer>] Left side bearings
388
+ # @param num_h_metrics [Integer] Number of hMetric entries
389
+ # @param num_glyphs [Integer] Total number of glyphs
390
+ # @return [String] Transformed hmtx data
391
+ def build_transformed_hmtx(advance_widths, lsbs, num_h_metrics, num_glyphs)
392
+ data = String.new(encoding: Encoding::BINARY)
393
+
394
+ # Flags: Use proportional encoding (not explicit) and explicit LSBs
395
+ # 0x00 = proportional advance widths
396
+ # 0x02 = explicit LSB values
397
+ flags = 0x02
398
+ data << [flags].pack("C")
399
+
400
+ # Write advance widths using proportional encoding
401
+ # First advance width is explicit
402
+ data << encode_255_uint16(advance_widths[0])
403
+
404
+ # Remaining advance widths as deltas
405
+ (1...num_h_metrics).each do |i|
406
+ delta = advance_widths[i] - advance_widths[i - 1]
407
+ data << [delta].pack("n") # int16 delta
408
+ end
409
+
410
+ # Write all LSB values explicitly
411
+ num_glyphs.times do |i|
412
+ lsb = lsbs[i] || 0
413
+ data << [lsb].pack("n") # int16 LSB
414
+ end
415
+
416
+ data
417
+ end
418
+
419
+ # Read coordinates from glyph data
420
+ #
421
+ # @param io [StringIO] Data stream
422
+ # @param flags [Array<Integer>] Point flags
423
+ # @param short_flag [Integer] Flag for short vector
424
+ # @param same_or_pos_flag [Integer] Flag for same/positive
425
+ # @return [Array<Integer>] Coordinates
426
+ def read_coordinates(io, flags, short_flag, same_or_pos_flag)
427
+ coords = []
428
+ value = 0
429
+
430
+ flags.each do |flag|
431
+ if (flag & short_flag) != 0
432
+ delta = io.read(1)&.unpack1("C")
433
+ delta = -delta if (flag & same_or_pos_flag).zero?
434
+ elsif (flag & same_or_pos_flag) != 0
435
+ delta = 0
436
+ else
437
+ delta = read_int16(io)
438
+ end
439
+
440
+ value += delta
441
+ coords << value
442
+ end
443
+
444
+ coords
445
+ end
446
+
447
+ # Build transformed glyf data from parsed glyphs
448
+ #
449
+ # @param glyphs [Array<Hash>] Parsed glyphs
450
+ # @param num_glyphs [Integer] Number of glyphs
451
+ # @param index_format [Integer] Loca format
452
+ # @return [String] Transformed glyf data
453
+ def build_transformed_glyf(glyphs, num_glyphs, index_format)
454
+ # Build 8 streams
455
+ n_contour_stream = String.new(encoding: Encoding::BINARY)
456
+ n_points_stream = String.new(encoding: Encoding::BINARY)
457
+ flag_stream = String.new(encoding: Encoding::BINARY)
458
+ glyph_stream = String.new(encoding: Encoding::BINARY)
459
+ composite_stream = String.new(encoding: Encoding::BINARY)
460
+ bbox_stream = String.new(encoding: Encoding::BINARY)
461
+ instruction_stream = String.new(encoding: Encoding::BINARY)
462
+
463
+ glyphs.each do |glyph|
464
+ case glyph[:type]
465
+ when :empty
466
+ n_contour_stream << [0].pack("n")
467
+ when :simple
468
+ n_contour_stream << [glyph[:num_contours]].pack("n")
469
+
470
+ # Write end points as deltas (255UInt16)
471
+ prev_pt = -1
472
+ glyph[:end_pts].each do |pt|
473
+ delta = pt - prev_pt - 1
474
+ n_points_stream << encode_255_uint16(delta)
475
+ prev_pt = pt
476
+ end
477
+
478
+ # Write flags with run-length encoding
479
+ write_flags_rle(flag_stream, glyph[:flags])
480
+
481
+ # Write coordinates as deltas
482
+ write_coordinates(glyph_stream, glyph[:x_coords])
483
+ write_coordinates(glyph_stream, glyph[:y_coords])
484
+
485
+ # Write bounding box
486
+ glyph[:bbox].each { |v| bbox_stream << [v].pack("n") }
487
+
488
+ # Write instructions
489
+ instruction_stream << encode_255_uint16(glyph[:instructions].bytesize)
490
+ instruction_stream << glyph[:instructions] if glyph[:instructions].bytesize.positive?
491
+
492
+ when :composite
493
+ n_contour_stream << [-1].pack("n")
494
+
495
+ # Write all component data
496
+ glyph[:components].each { |c| composite_stream << c[:raw_data] }
497
+
498
+ # Write bounding box
499
+ glyph[:bbox].each { |v| bbox_stream << [v].pack("n") }
500
+
501
+ # Write instructions if present
502
+ if glyph[:instructions].bytesize.positive?
503
+ instruction_stream << [glyph[:instructions].bytesize].pack("n")
504
+ instruction_stream << glyph[:instructions]
505
+ end
506
+ end
507
+ end
508
+
509
+ # Build header and combine streams
510
+ data = String.new(encoding: Encoding::BINARY)
511
+ data << [0].pack("N") # version
512
+ data << [num_glyphs].pack("n")
513
+ data << [index_format].pack("n")
514
+
515
+ # Write stream sizes and data
516
+ [n_contour_stream, n_points_stream, flag_stream, glyph_stream,
517
+ composite_stream, bbox_stream, instruction_stream, ""].each do |stream|
518
+ data << [stream.bytesize].pack("N")
519
+ data << stream
520
+ end
521
+
522
+ data
523
+ end
524
+
525
+ # Write flags with run-length encoding
526
+ #
527
+ # @param stream [String] Output stream
528
+ # @param flags [Array<Integer>] Flags to encode
529
+ def write_flags_rle(stream, flags)
530
+ i = 0
531
+ while i < flags.size
532
+ flag = flags[i]
533
+ count = 1
534
+
535
+ # Count repeats
536
+ while i + count < flags.size && flags[i + count] == flag && count < 255
537
+ count += 1
538
+ end
539
+
540
+ if count > 1
541
+ stream << [flag | 0x08].pack("C") # Set REPEAT_FLAG
542
+ stream << [count - 1].pack("C") # Repeat count (not including first)
543
+ i += count
544
+ else
545
+ stream << [flag].pack("C")
546
+ i += 1
547
+ end
548
+ end
549
+ end
550
+
551
+ # Write coordinates as deltas
552
+ #
553
+ # @param stream [String] Output stream
554
+ # @param coords [Array<Integer>] Coordinates
555
+ def write_coordinates(stream, coords)
556
+ prev = 0
557
+ coords.each do |coord|
558
+ delta = coord - prev
559
+
560
+ stream << if delta.abs <= 255
561
+ [delta.abs].pack("C")
562
+ else
563
+ [delta].pack("n")
564
+ end
565
+
566
+ prev = coord
567
+ end
568
+ end
569
+
570
+ # Encode 255UInt16 value
571
+ #
572
+ # @param value [Integer] Value to encode
573
+ # @return [String] Encoded bytes
574
+ def encode_255_uint16(value)
575
+ if value < 253
576
+ [value].pack("C")
577
+ elsif value < 506
578
+ [253, value - 253].pack("CC")
579
+ elsif value < 65536
580
+ [254].pack("C") + [value].pack("n")
581
+ else
582
+ [255].pack("C") + [value - 506].pack("n")
583
+ end
584
+ end
585
+
586
+ # Read signed 16-bit integer
587
+ #
588
+ # @param io [StringIO] Input stream
589
+ # @return [Integer] Signed value
590
+ def read_int16(io)
591
+ value = io.read(2)&.unpack1("n") || 0
592
+ value > 0x7FFF ? value - 0x10000 : value
160
593
  end
161
594
  end
162
595
  end
@@ -156,7 +156,22 @@ module Fontisan
156
156
  end
157
157
 
158
158
  # Get decompressed table data
159
- def table_data(tag)
159
+ #
160
+ # @param tag [String, nil] The table tag (optional)
161
+ # @return [String, Hash, nil] Table data if tag provided, or hash of all tables if no tag
162
+ def table_data(tag = nil)
163
+ # If no tag provided, return all tables
164
+ if tag.nil?
165
+ # First try underlying font's table data if available
166
+ if @underlying_font.respond_to?(:table_data)
167
+ return @underlying_font.table_data
168
+ end
169
+
170
+ # Fallback to decompressed_tables
171
+ return @decompressed_tables.dup
172
+ end
173
+
174
+ # Tag provided - return specific table
160
175
  # First try underlying font's table data if available
161
176
  if @underlying_font.respond_to?(:table_data)
162
177
  underlying_data = @underlying_font.table_data[tag]
@@ -410,15 +425,20 @@ module Fontisan
410
425
 
411
426
  # Determine if transformLength should be read
412
427
  # According to WOFF2 spec section 4.2:
413
- # - glyf/loca with version 0: TRANSFORMED (transformLength present)
414
- # - hmtx with non-zero version: TRANSFORMED (transformLength present)
415
- # - all other tables: transformation version is 0 (no transformLength)
428
+ # - transformLength is ONLY present when table is actually transformed
429
+ # - For glyf/loca: transformation is indicated by transform_version = 0
430
+ # - For hmtx: transformation is indicated by transform_version = 1
431
+ # - For all other tables: no transformation, no transformLength
416
432
  transform_version = (flags >> 6) & 0x03
417
- has_transform_length = if ["glyf",
418
- "loca"].include?(entry.tag) && transform_version.zero?
419
- true
420
- elsif entry.tag == "hmtx" && transform_version != 0
421
- true
433
+
434
+ # transformLength is present when table is actually transformed
435
+ # glyf/loca use version 0 for transformation, hmtx uses version 1
436
+ has_transform_length = if ["glyf", "loca"].include?(entry.tag)
437
+ # For glyf/loca, version 0 means transformed
438
+ transform_version.zero?
439
+ elsif entry.tag == "hmtx"
440
+ # For hmtx, version 1 means transformed
441
+ transform_version == 1
422
442
  else
423
443
  false
424
444
  end