fontisan 0.4.12 → 0.4.14

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.
@@ -0,0 +1,330 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+
5
+ module Fontisan
6
+ module Woff2
7
+ # Encodes glyf + loca into the WOFF2 transformed glyf table format per
8
+ # WOFF2 spec section 5.1.
9
+ #
10
+ # The loca table is reconstructed by the decoder (its data is omitted
11
+ # from the brotli-compressed stream). Its directory entry keeps
12
+ # origLength and sets transformLength = 0 per spec section 5.3.
13
+ #
14
+ # Output layout (spec section 5.1):
15
+ # uint16 version # always 0
16
+ # uint16 optionFlags # bit 0 = overlapSimpleBitmap present
17
+ # uint16 numGlyphs
18
+ # uint16 indexFormat # loca format (0=short, 1=long)
19
+ # uint32 × 7 stream sizes
20
+ # nContourStream: int16[numGlyphs]
21
+ # nPointsStream: 255UInt16 per contour (point count, not endpoint)
22
+ # flagStream: uint8[numPoints] (triplet flags)
23
+ # glyphStream: triplet payloads + 255UInt16 instructionLength per glyph
24
+ # compositeStream: raw component bytes per composite glyph
25
+ # bboxStream: bboxBitmap + explicit bbox int16[4] entries
26
+ # instructionStream: raw instruction bytes
27
+ # overlapSimpleBitmap (optional)
28
+ class GlyfLocaTransform
29
+ # TrueType simple-glyph flag bits per OpenType spec.
30
+ FLAG_ON_CURVE = 0x01
31
+ FLAG_OVERLAP_SIMPLE = 0x40
32
+
33
+ # TrueType composite-glyph flag bits.
34
+ ARG_1_AND_2_ARE_WORDS = 0x0001
35
+ WE_HAVE_A_SCALE = 0x0008
36
+ MORE_COMPONENTS = 0x0020
37
+ WE_HAVE_AN_X_AND_Y_SCALE = 0x0040
38
+ WE_HAVE_A_TWO_BY_TWO = 0x0080
39
+ WE_HAVE_INSTRUCTIONS = 0x0100
40
+
41
+ # Simple glyph records start with int16 numberOfContours + 4 int16 bbox.
42
+ SIMPLE_HEADER_SIZE = 10
43
+
44
+ attr_reader :num_glyphs, :source_index_format
45
+
46
+ # @param glyf_data [String] raw glyf table bytes
47
+ # @param loca_data [String] raw loca table bytes
48
+ # @param num_glyphs [Integer] from maxp
49
+ # @param index_format [Integer] 0 (short) or 1 (long), from head.
50
+ # Used to parse the source loca; the WOFF2 transformed glyf
51
+ # always emits indexFormat=0 (Chrome OTS silently rejects any
52
+ # other value, even though WOFF2 spec section 5.1 allows the
53
+ # source's indexToLocFormat).
54
+ def initialize(glyf_data:, loca_data:, num_glyphs:, index_format:)
55
+ @glyf_data = glyf_data
56
+ @loca_data = loca_data
57
+ @num_glyphs = num_glyphs
58
+ @source_index_format = index_format
59
+ end
60
+
61
+ # Encode the glyf/loca transform, returning the transformed glyf bytes.
62
+ #
63
+ # @return [String] transformed glyf bytes per spec section 5.1
64
+ def transform
65
+ s = Streams.new(@num_glyphs)
66
+ offsets = parse_loca_offsets
67
+
68
+ num_glyphs.times do |glyph_id|
69
+ start_off = offsets[glyph_id]
70
+ end_off = offsets[glyph_id + 1]
71
+ glyph_bytes = @glyf_data[start_off...end_off]
72
+
73
+ if glyph_bytes.nil? || glyph_bytes.empty? || glyph_bytes.bytesize.zero?
74
+ s.n_contour << [0].pack("s>")
75
+ next
76
+ end
77
+
78
+ num_contours = read_int16(glyph_bytes, 0)
79
+ bbox = read_bbox(glyph_bytes, 2)
80
+
81
+ case num_contours
82
+ when 0
83
+ s.n_contour << [0].pack("s>")
84
+ when -1
85
+ encode_composite(s:, glyph_bytes:, bbox:, glyph_id:)
86
+ else
87
+ encode_simple(s:, glyph_bytes:, num_contours:, bbox:, glyph_id:)
88
+ end
89
+ end
90
+
91
+ assemble(s)
92
+ end
93
+
94
+ # Mutable container for the 7 streams + 2 bitmaps. Avoids passing 9
95
+ # parameters through every helper. Defined at top-level so the Struct
96
+ # class isn't hidden behind `private` (Ruby treats constants as public
97
+ # regardless, but RuboCop flags the modifier as useless).
98
+ Streams = Struct.new(:n_contour, :n_points, :flags, :glyph, :composite,
99
+ :bbox_data, :instructions, :bbox_bitmap,
100
+ :overlap_bitmap) do
101
+ def initialize(num_glyphs)
102
+ super(*Array.new(7) { String.new(encoding: Encoding::BINARY) },
103
+ Array.new(((num_glyphs + 31) >> 5) << 2, 0),
104
+ Array.new((num_glyphs + 7) >> 3, 0))
105
+ end
106
+
107
+ def has_overlap?
108
+ overlap_bitmap.any?(&:nonzero?)
109
+ end
110
+
111
+ # bboxStream on the wire = bboxBitmap || bboxStream (spec section 5.1).
112
+ def bbox_wire
113
+ bbox_bitmap.pack("C*") + bbox_data
114
+ end
115
+ end
116
+
117
+ private
118
+
119
+ def encode_simple(s:, glyph_bytes:, num_contours:, bbox:, glyph_id:)
120
+ s.n_contour << [num_contours].pack("s>")
121
+
122
+ io = StringIO.new(glyph_bytes)
123
+ io.pos = SIMPLE_HEADER_SIZE
124
+ end_pts = Array.new(num_contours) { io.read(2).unpack1("n") }
125
+ total_points = end_pts.last + 1
126
+
127
+ # nPoints stream: per-contour point counts (NOT endPtsOfContours).
128
+ prev_end = -1
129
+ end_pts.each do |ep|
130
+ s.n_points << encode_255_uint16(ep - prev_end)
131
+ prev_end = ep
132
+ end
133
+
134
+ inst_len = io.read(2).unpack1("n")
135
+ instructions = io.read(inst_len)
136
+
137
+ tt_flags = decode_truetype_flags(io, total_points)
138
+ xs = decode_coordinates(io, tt_flags, x_axis: true)
139
+ ys = decode_coordinates(io, tt_flags, x_axis: false)
140
+
141
+ # Re-encode each point as a WOFF2 triplet
142
+ prev_x = prev_y = 0
143
+ total_points.times do |i|
144
+ dx = xs[i] - prev_x
145
+ dy = ys[i] - prev_y
146
+ prev_x = xs[i]
147
+ prev_y = ys[i]
148
+ on_curve = (tt_flags[i] & FLAG_ON_CURVE).nonzero?
149
+ flag, payload = TripletCodec.encode(dx, dy, on_curve:)
150
+ s.flags << [flag].pack("C")
151
+ s.glyph << payload.pack("C*")
152
+ end
153
+
154
+ # Overlap bit lives on the first flag of each simple glyph
155
+ if (tt_flags.first & FLAG_OVERLAP_SIMPLE).nonzero?
156
+ s.overlap_bitmap[glyph_id >> 3] |= (0x80 >> (glyph_id & 7))
157
+ end
158
+
159
+ # glyphStream carries per-glyph instructionLength; bytes go to instructionStream
160
+ s.glyph << encode_255_uint16(instructions.bytesize)
161
+ s.instructions << instructions
162
+
163
+ # Spec: simple glyphs omit bbox when it matches calculated bounds.
164
+ calculated = calc_bounds(xs, ys)
165
+ return if calculated == bbox
166
+
167
+ set_bbox_bit(s, glyph_id)
168
+ s.bbox_data << bbox.pack("s>*")
169
+ end
170
+
171
+ def encode_composite(s:, glyph_bytes:, bbox:, glyph_id:)
172
+ s.n_contour << [-1].pack("s>")
173
+
174
+ io = StringIO.new(glyph_bytes)
175
+ io.pos = SIMPLE_HEADER_SIZE
176
+
177
+ components_end = nil
178
+ have_instructions = false
179
+ loop do
180
+ flags = io.read(2).unpack1("n")
181
+ io.read(2) # glyphIndex
182
+ io.read((flags & ARG_1_AND_2_ARE_WORDS).nonzero? ? 4 : 2)
183
+ io.read(component_transform_size(flags))
184
+ components_end = io.pos
185
+ have_instructions = (flags & WE_HAVE_INSTRUCTIONS).nonzero?
186
+ break if (flags & MORE_COMPONENTS).zero?
187
+ end
188
+
189
+ s.composite << glyph_bytes[SIMPLE_HEADER_SIZE...components_end]
190
+
191
+ if have_instructions
192
+ inst_len = io.read(2).unpack1("n")
193
+ instructions = io.read(inst_len)
194
+ s.glyph << encode_255_uint16(instructions.bytesize)
195
+ s.instructions << instructions
196
+ end
197
+
198
+ # Composite glyphs MUST have explicit bbox per spec section 5.1
199
+ set_bbox_bit(s, glyph_id)
200
+ s.bbox_data << bbox.pack("s>*")
201
+ end
202
+
203
+ def component_transform_size(flags)
204
+ if (flags & WE_HAVE_A_SCALE).nonzero? then 2
205
+ elsif (flags & WE_HAVE_AN_X_AND_Y_SCALE).nonzero? then 4
206
+ elsif (flags & WE_HAVE_A_TWO_BY_TWO).nonzero? then 8
207
+ else
208
+ 0
209
+ end
210
+ end
211
+
212
+ def decode_truetype_flags(io, total_points)
213
+ flags = []
214
+ while flags.size < total_points
215
+ flag = io.read(1).unpack1("C")
216
+ flags << flag
217
+ if (flag & 0x08).nonzero? # REPEAT_FLAG
218
+ repeat = io.read(1).unpack1("C")
219
+ repeat.times { flags << flag }
220
+ end
221
+ end
222
+ flags
223
+ end
224
+
225
+ def decode_coordinates(io, flags, x_axis:)
226
+ short_flag = x_axis ? 0x02 : 0x04
227
+ same_flag = x_axis ? 0x10 : 0x20
228
+
229
+ coord = 0
230
+ coords = []
231
+ flags.each do |flag|
232
+ if (flag & short_flag).nonzero?
233
+ delta = io.read(1).unpack1("C")
234
+ delta = -delta if (flag & same_flag).zero?
235
+ elsif (flag & same_flag).nonzero?
236
+ delta = 0
237
+ else
238
+ delta = read_int16_io(io)
239
+ end
240
+ coord += delta
241
+ coords << coord
242
+ end
243
+ coords
244
+ end
245
+
246
+ def assemble(s)
247
+ bbox_bitmap_bytes = ((@num_glyphs + 31) >> 5) << 2
248
+ # Pad bbox_bitmap to required size (struct may have fewer bytes allocated)
249
+ bb = s.bbox_bitmap
250
+ if bb.size < bbox_bitmap_bytes
251
+ bb.concat(Array.new(bbox_bitmap_bytes - bb.size, 0))
252
+ end
253
+
254
+ out = String.new(encoding: Encoding::BINARY)
255
+ out << [0].pack("S>") # version
256
+ out << [(s.has_overlap? ? 1 : 0)].pack("S>") # optionFlags
257
+ out << [@num_glyphs].pack("S>")
258
+ # Always emit indexFormat=0 per Chrome OTS requirement. WOFF2 spec
259
+ # section 5.1 allows the source's indexToLocFormat, but Chrome's
260
+ # OTS silently rejects any value other than 0.
261
+ out << [0].pack("S>")
262
+ out << [s.n_contour.bytesize].pack("L>")
263
+ out << [s.n_points.bytesize].pack("L>")
264
+ out << [s.flags.bytesize].pack("L>")
265
+ out << [s.glyph.bytesize].pack("L>")
266
+ out << [s.composite.bytesize].pack("L>")
267
+ out << [s.bbox_wire.bytesize].pack("L>")
268
+ out << [s.instructions.bytesize].pack("L>")
269
+ out << s.n_contour
270
+ out << s.n_points
271
+ out << s.flags
272
+ out << s.glyph
273
+ out << s.composite
274
+ out << s.bbox_wire
275
+ out << s.instructions
276
+ out << s.overlap_bitmap.pack("C*") if s.has_overlap?
277
+ out
278
+ end
279
+
280
+ def parse_loca_offsets
281
+ if @source_index_format.zero?
282
+ @loca_data.unpack("n*").map { |v| v * 2 }
283
+ else
284
+ @loca_data.unpack("N*")
285
+ end
286
+ end
287
+
288
+ def set_bbox_bit(s, glyph_id)
289
+ s.bbox_bitmap[glyph_id >> 3] |= (0x80 >> (glyph_id & 7))
290
+ end
291
+
292
+ def read_int16(bytes, offset)
293
+ v = (bytes.getbyte(offset) << 8) | bytes.getbyte(offset + 1)
294
+ v > 0x7FFF ? v - 0x10000 : v
295
+ end
296
+
297
+ def read_int16_io(io)
298
+ v = io.read(2).unpack1("n")
299
+ v > 0x7FFF ? v - 0x10000 : v
300
+ end
301
+
302
+ def read_bbox(bytes, offset)
303
+ [
304
+ read_int16(bytes, offset),
305
+ read_int16(bytes, offset + 2),
306
+ read_int16(bytes, offset + 4),
307
+ read_int16(bytes, offset + 6),
308
+ ]
309
+ end
310
+
311
+ def calc_bounds(xs, ys)
312
+ return nil if xs.empty?
313
+
314
+ [xs.min, ys.min, xs.max, ys.max]
315
+ end
316
+
317
+ def encode_255_uint16(value)
318
+ if value < 253
319
+ [value].pack("C")
320
+ elsif value < 506
321
+ [253, value - 253].pack("CC")
322
+ elsif value < 65_536
323
+ [254].pack("C") + [value].pack("n")
324
+ else
325
+ [255].pack("C") + [value - 506].pack("n")
326
+ end
327
+ end
328
+ end
329
+ end
330
+ end
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Woff2
5
+ # Variable-length coordinate codec for the WOFF2 glyf transform.
6
+ #
7
+ # Per WOFF2 spec section 5.2, each (dx, dy, on_curve) point is encoded as
8
+ # one flag byte plus 1-4 payload bytes ("triplets"). The flag byte's high
9
+ # bit is the on-curve flag; the low 7 bits select one of 128 encoding
10
+ # variants. Variants are chosen by coordinate magnitude so small deltas
11
+ # cost 1-2 bytes and only very large deltas cost 4.
12
+ #
13
+ # Reference: WOFF2 spec section 5.2 (triplet encoding table).
14
+ module TripletCodec
15
+ ON_CURVE_BIT = 0x00
16
+ OFF_CURVE_BIT = 0x80
17
+
18
+ # Encode a single (dx, dy) delta with the given on-curve flag.
19
+ #
20
+ # @param dx [Integer] X delta (signed, -65535..65535)
21
+ # @param dy [Integer] Y delta (signed, -65535..65535)
22
+ # @param on_curve [Boolean] true if this is an on-curve point
23
+ # @return [Array(Integer, Array<Integer>)] flag byte and payload bytes
24
+ def self.encode(dx, dy, on_curve:)
25
+ on_curve_bit = on_curve ? ON_CURVE_BIT : OFF_CURVE_BIT
26
+ abs_x = dx.abs
27
+ abs_y = dy.abs
28
+ x_sign_bit = dx.negative? ? 0 : 1
29
+ y_sign_bit = dy.negative? ? 0 : 1
30
+ xy_sign_bits = x_sign_bit + (2 * y_sign_bit)
31
+
32
+ if dx.zero? && abs_y < 1280
33
+ encode_y_only(on_curve_bit, abs_y, y_sign_bit)
34
+ elsif dy.zero? && abs_x < 1280
35
+ encode_x_only(on_curve_bit, abs_x, x_sign_bit)
36
+ elsif abs_x < 65 && abs_y < 65
37
+ encode_nibble_pair(on_curve_bit, abs_x, abs_y, xy_sign_bits)
38
+ elsif abs_x < 769 && abs_y < 769
39
+ encode_byte_pair(on_curve_bit, abs_x, abs_y, xy_sign_bits)
40
+ elsif abs_x < 4096 && abs_y < 4096
41
+ encode_12_bit_pair(on_curve_bit, abs_x, abs_y, xy_sign_bits)
42
+ else
43
+ encode_16_bit_pair(on_curve_bit, abs_x, abs_y, xy_sign_bits)
44
+ end
45
+ end
46
+
47
+ # Decode one triplet given the flag byte and a cursor over payload bytes.
48
+ #
49
+ # @param flag [Integer] flag byte
50
+ # @param payload [Array<Integer>] payload bytes following the flag
51
+ # @return [Array(Integer, Integer, Boolean)] dx, dy, on_curve
52
+ def self.decode(flag, payload)
53
+ on_curve = (flag & OFF_CURVE_BIT).zero?
54
+ idx = flag & 0x7F
55
+
56
+ dx, dy = if idx < 10
57
+ decode_y_only(idx, payload)
58
+ elsif idx < 20
59
+ decode_x_only(idx, payload)
60
+ elsif idx < 84
61
+ decode_nibble_pair(idx, payload)
62
+ elsif idx < 120
63
+ decode_byte_pair(idx, payload)
64
+ elsif idx < 124
65
+ decode_12_bit_pair(idx, payload)
66
+ else
67
+ decode_16_bit_pair(idx, payload)
68
+ end
69
+ [dx, dy, on_curve]
70
+ end
71
+
72
+ class << self
73
+ private
74
+
75
+ # --- Encoders ---
76
+
77
+ # Variants 0-9: dy only (1 byte payload), dx=0, |dy| < 1280.
78
+ def encode_y_only(on_curve_bit, abs_y, y_sign_bit)
79
+ flag = on_curve_bit + ((abs_y & 0xF00) >> 7) + y_sign_bit
80
+ [flag, [abs_y & 0xFF]]
81
+ end
82
+
83
+ # Variants 10-19: dx only (1 byte payload), dy=0, |dx| < 1280.
84
+ def encode_x_only(on_curve_bit, abs_x, x_sign_bit)
85
+ flag = on_curve_bit + 10 + ((abs_x & 0xF00) >> 7) + x_sign_bit
86
+ [flag, [abs_x & 0xFF]]
87
+ end
88
+
89
+ # Variants 20-83: 4-bit X + 4-bit Y deltas (1 byte payload).
90
+ # Encodes (abs - 1) so the range 1..64 maps to nibble values 0..63.
91
+ def encode_nibble_pair(on_curve_bit, abs_x, abs_y, xy_sign_bits)
92
+ flag = on_curve_bit + 20 +
93
+ ((abs_x - 1) & 0x30) +
94
+ (((abs_y - 1) & 0x30) >> 2) +
95
+ xy_sign_bits
96
+ payload = (((abs_x - 1) & 0x0F) << 4) | ((abs_y - 1) & 0x0F)
97
+ [flag, [payload]]
98
+ end
99
+
100
+ # Variants 84-119: 8-bit X + 8-bit Y deltas (2 byte payload).
101
+ # Encodes (abs - 1) so the range 1..768 maps to byte values 0..767.
102
+ def encode_byte_pair(on_curve_bit, abs_x, abs_y, xy_sign_bits)
103
+ flag = on_curve_bit + 84 +
104
+ (12 * (((abs_x - 1) & 0x300) >> 8)) +
105
+ (((abs_y - 1) & 0x300) >> 6) +
106
+ xy_sign_bits
107
+ [flag, [(abs_x - 1) & 0xFF, (abs_y - 1) & 0xFF]]
108
+ end
109
+
110
+ # Variants 120-123: 12-bit X + 12-bit Y deltas (3 byte payload).
111
+ def encode_12_bit_pair(on_curve_bit, abs_x, abs_y, xy_sign_bits)
112
+ flag = on_curve_bit + 120 + xy_sign_bits
113
+ payload = [
114
+ (abs_x >> 4) & 0xFF,
115
+ ((abs_x & 0x0F) << 4) | ((abs_y >> 8) & 0x0F),
116
+ abs_y & 0xFF,
117
+ ]
118
+ [flag, payload]
119
+ end
120
+
121
+ # Variants 124-127: 16-bit X + 16-bit Y deltas (4 byte payload).
122
+ def encode_16_bit_pair(on_curve_bit, abs_x, abs_y, xy_sign_bits)
123
+ flag = on_curve_bit + 124 + xy_sign_bits
124
+ payload = [
125
+ (abs_x >> 8) & 0xFF, abs_x & 0xFF,
126
+ (abs_y >> 8) & 0xFF, abs_y & 0xFF
127
+ ]
128
+ [flag, payload]
129
+ end
130
+
131
+ # --- Decoders ---
132
+
133
+ def decode_y_only(idx, payload)
134
+ sign = (idx & 1).zero? ? -1 : 1
135
+ magnitude = ((idx & 0x0E) << 7) + payload.fetch(0)
136
+ [0, sign * magnitude]
137
+ end
138
+
139
+ def decode_x_only(idx, payload)
140
+ sign = (idx & 1).zero? ? -1 : 1
141
+ magnitude = (((idx - 10) & 0x0E) << 7) + payload.fetch(0)
142
+ [sign * magnitude, 0]
143
+ end
144
+
145
+ def decode_nibble_pair(idx, payload)
146
+ x_sign = ((idx - 20) & 0x01).zero? ? -1 : 1
147
+ y_sign = ((idx - 20) & 0x02).zero? ? -1 : 1
148
+ x_mag_base = ((idx - 20) & 0x30) +
149
+ ((payload.fetch(0) >> 4) & 0x0F) + 1
150
+ y_mag_base = (((idx - 20) & 0x0C) << 2) +
151
+ (payload.fetch(0) & 0x0F) + 1
152
+ [x_sign * x_mag_base, y_sign * y_mag_base]
153
+ end
154
+
155
+ def decode_byte_pair(idx, payload)
156
+ x_sign = (idx & 1).zero? ? -1 : 1
157
+ y_sign = ((idx >> 1) & 1).zero? ? -1 : 1
158
+ x_mag = 1 + (((idx - 84) / 12) << 8) + payload.fetch(0)
159
+ y_mag = 1 + ((((idx - 84) % 12) >> 2) << 8) + payload.fetch(1)
160
+ [x_sign * x_mag, y_sign * y_mag]
161
+ end
162
+
163
+ def decode_12_bit_pair(idx, payload)
164
+ x_sign = (idx & 1).zero? ? -1 : 1
165
+ y_sign = ((idx >> 1) & 1).zero? ? -1 : 1
166
+ x_mag = (payload.fetch(0) << 4) + (payload.fetch(1) >> 4)
167
+ y_mag = ((payload.fetch(1) & 0x0F) << 8) + payload.fetch(2)
168
+ [x_sign * x_mag, y_sign * y_mag]
169
+ end
170
+
171
+ def decode_16_bit_pair(idx, payload)
172
+ x_sign = (idx & 1).zero? ? -1 : 1
173
+ y_sign = ((idx >> 1) & 1).zero? ? -1 : 1
174
+ x_mag = (payload.fetch(0) << 8) + payload.fetch(1)
175
+ y_mag = (payload.fetch(2) << 8) + payload.fetch(3)
176
+ [x_sign * x_mag, y_sign * y_mag]
177
+ end
178
+ end
179
+ end
180
+ end
181
+ end
@@ -4,10 +4,14 @@
4
4
 
5
5
  module Fontisan
6
6
  module Woff2
7
+ autoload :CollectionEncoder, "fontisan/woff2/collection_encoder"
7
8
  autoload :Directory, "fontisan/woff2/directory"
8
- autoload :GlyfTransformer, "fontisan/woff2/glyf_transformer"
9
+ autoload :EncoderRules, "fontisan/woff2/encoder_rules"
10
+ autoload :GlyfLocaReconstruct, "fontisan/woff2/glyf_loca_reconstruct"
11
+ autoload :GlyfLocaTransform, "fontisan/woff2/glyf_loca_transform"
9
12
  autoload :HmtxTransformer, "fontisan/woff2/hmtx_transformer"
10
13
  autoload :TableTransformer, "fontisan/woff2/table_transformer"
14
+ autoload :TripletCodec, "fontisan/woff2/triplet_codec"
11
15
  autoload :Woff2Header, "fontisan/woff2/header"
12
16
  end
13
17
  end
@@ -538,59 +538,42 @@ module Fontisan
538
538
  # @param decompressed_tables [Hash<String, String>] Decompressed tables
539
539
  # @return [void] Modifies decompressed_tables in place
540
540
  def self.apply_transformations!(table_entries, decompressed_tables)
541
- # Find entries that need transformation
542
541
  glyf_entry = table_entries.find { |e| e.tag == "glyf" }
543
542
  hmtx_entry = table_entries.find { |e| e.tag == "hmtx" }
544
543
 
545
- # Get required metadata for transformations
546
544
  maxp_data = decompressed_tables["maxp"]
547
545
  hhea_data = decompressed_tables["hhea"]
546
+ head_data = decompressed_tables["head"]
548
547
 
549
- return unless maxp_data && hhea_data
548
+ return unless maxp_data && hhea_data && head_data
550
549
 
551
- # Parse num_glyphs from maxp table
552
550
  # maxp format: version(4) + numGlyphs(2) + ...
553
551
  num_glyphs = maxp_data[4, 2].unpack1("n")
554
-
555
- # Parse numberOfHMetrics from hhea table
556
552
  # hhea format: ... + numberOfHMetrics(2) at offset 34
557
553
  number_of_h_metrics = hhea_data[34, 2].unpack1("n")
558
-
559
- # Check if this is a variable font by checking for fvar table
560
- variable_font = table_entries.any? { |e| e.tag == "fvar" }
561
-
562
- # Transform glyf/loca if needed
563
- # transform_length is only set when table is actually transformed
564
- # Check that transform_length exists and is greater than 0
565
- if glyf_entry&.instance_variable_defined?(:@transform_length) &&
566
- glyf_entry.transform_length&.positive?
567
- transformed_glyf = decompressed_tables["glyf"]
568
-
569
- if transformed_glyf
570
- result = Woff2::GlyfTransformer.reconstruct(
571
- transformed_glyf,
572
- num_glyphs,
573
- variable_font: variable_font,
574
- )
575
- decompressed_tables["glyf"] = result[:glyf]
576
- decompressed_tables["loca"] = result[:loca]
577
- end
554
+ # head format: ... + indexToLocFormat(2) at offset 50
555
+ index_format = head_data[50, 2].unpack1("n")
556
+
557
+ # Reconstruct glyf/loca per spec section 5.1/5.3 when glyf is
558
+ # transformed. Per spec section 4.1, transformLength is present IFF
559
+ # the table is transformed, so a non-nil value indicates transform.
560
+ if glyf_entry&.transform_length && decompressed_tables["glyf"]
561
+ result = Woff2::GlyfLocaReconstruct.new(
562
+ transformed_glyf: decompressed_tables["glyf"],
563
+ num_glyphs:,
564
+ index_format:,
565
+ ).reconstruct
566
+ decompressed_tables["glyf"] = result[:glyf]
567
+ decompressed_tables["loca"] = result[:loca]
578
568
  end
579
569
 
580
- # Transform hmtx if needed
581
- # transform_length is only set when table is actually transformed
582
- # Check that transform_length exists and is greater than 0
583
- if hmtx_entry&.instance_variable_defined?(:@transform_length) &&
584
- hmtx_entry.transform_length&.positive?
585
- transformed_hmtx = decompressed_tables["hmtx"]
586
-
587
- if transformed_hmtx
588
- decompressed_tables["hmtx"] = Woff2::HmtxTransformer.reconstruct(
589
- transformed_hmtx,
590
- num_glyphs,
591
- number_of_h_metrics,
592
- )
593
- end
570
+ # Reconstruct hmtx per spec section 5.4 when hmtx is transformed.
571
+ if hmtx_entry&.transform_length && decompressed_tables["hmtx"]
572
+ decompressed_tables["hmtx"] = Woff2::HmtxTransformer.reconstruct(
573
+ decompressed_tables["hmtx"],
574
+ num_glyphs,
575
+ number_of_h_metrics,
576
+ )
594
577
  end
595
578
  end
596
579
 
@@ -248,9 +248,12 @@ module Fontisan
248
248
 
249
249
  # Get list of all table tags
250
250
  #
251
- # @return [Array<String>] Array of table tag strings
251
+ # Returns plain UTF-8 Ruby Strings so callers can compare with literals
252
+ # without BinData::String encoding-sensitive eql? failures.
253
+ #
254
+ # @return [Array<String>] Array of table tag strings (UTF-8)
252
255
  def table_names
253
- table_entries.map(&:tag)
256
+ table_entries.map { |entry| entry.tag.to_s.force_encoding("UTF-8") }
254
257
  end
255
258
 
256
259
  # Get parsed table instance
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fontisan
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.12
4
+ version: 0.4.14
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-07-05 00:00:00.000000000 Z
11
+ date: 2026-07-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64
@@ -718,11 +718,15 @@ files:
718
718
  - lib/fontisan/version.rb
719
719
  - lib/fontisan/version.rb.orig
720
720
  - lib/fontisan/woff2.rb
721
+ - lib/fontisan/woff2/collection_encoder.rb
721
722
  - lib/fontisan/woff2/directory.rb
722
- - lib/fontisan/woff2/glyf_transformer.rb
723
+ - lib/fontisan/woff2/encoder_rules.rb
724
+ - lib/fontisan/woff2/glyf_loca_reconstruct.rb
725
+ - lib/fontisan/woff2/glyf_loca_transform.rb
723
726
  - lib/fontisan/woff2/header.rb
724
727
  - lib/fontisan/woff2/hmtx_transformer.rb
725
728
  - lib/fontisan/woff2/table_transformer.rb
729
+ - lib/fontisan/woff2/triplet_codec.rb
726
730
  - lib/fontisan/woff2_font.rb
727
731
  - lib/fontisan/woff_font.rb
728
732
  - scripts/compare_stack_aware.rb