fontisan 0.4.13 → 0.4.15

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,400 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+
5
+ module Fontisan
6
+ module Woff2
7
+ # Reconstructs glyf and loca tables from the WOFF2 transformed glyf
8
+ # stream per spec section 5.1. This is the decoder counterpart of
9
+ # {Woff2::GlyfLocaTransform}.
10
+ #
11
+ # The transformed glyf table is split into 7 streams preceded by a
12
+ # 36-byte header (uint16 version + uint16 optionFlags + uint16 numGlyphs
13
+ # + uint16 indexFormat + 7 × uint32 stream sizes). An optional
14
+ # overlapSimpleBitmap follows when optionFlags bit 0 is set.
15
+ #
16
+ # Reference: W3C WOFF2 spec, section 5.1.
17
+ class GlyfLocaReconstruct
18
+ # TrueType simple-glyph flag bits per OpenType spec.
19
+ FLAG_ON_CURVE = 0x01
20
+ FLAG_X_SHORT = 0x02
21
+ FLAG_Y_SHORT = 0x04
22
+ FLAG_REPEAT = 0x08
23
+ FLAG_X_SAME_OR_POS = 0x10
24
+ FLAG_Y_SAME_OR_POS = 0x20
25
+ FLAG_OVERLAP_SIMPLE = 0x40
26
+
27
+ # TrueType composite-glyph flag bits.
28
+ ARG_1_AND_2_ARE_WORDS = 0x0001
29
+ WE_HAVE_A_SCALE = 0x0008
30
+ MORE_COMPONENTS = 0x0020
31
+ WE_HAVE_AN_X_AND_Y_SCALE = 0x0040
32
+ WE_HAVE_A_TWO_BY_TWO = 0x0080
33
+ WE_HAVE_INSTRUCTIONS = 0x0100
34
+
35
+ # 8-byte header + 7 × 4-byte stream sizes.
36
+ HEADER_SIZE = 36
37
+
38
+ attr_reader :num_glyphs, :index_format
39
+
40
+ # @param transformed_glyf [String] bytes of the transformed glyf table
41
+ # @param num_glyphs [Integer] from maxp
42
+ # @param index_format [Integer] 0 (short) or 1 (long), from head
43
+ def initialize(transformed_glyf:, num_glyphs:, index_format:)
44
+ @data = transformed_glyf
45
+ @num_glyphs = num_glyphs
46
+ @index_format = index_format
47
+ end
48
+
49
+ # Reconstruct glyf and loca tables.
50
+ #
51
+ # @return [Hash{Symbol => String}] `{ glyf:, loca: }`
52
+ def reconstruct
53
+ header = parse_header
54
+ streams = read_streams(header)
55
+
56
+ glyf = String.new(encoding: Encoding::BINARY)
57
+ offsets = [0]
58
+
59
+ num_glyphs.times do |glyph_id|
60
+ glyph = decode_glyph(glyph_id, streams)
61
+ glyf << glyph
62
+ offsets << glyf.bytesize
63
+ end
64
+
65
+ { glyf:, loca: build_loca(offsets) }
66
+ end
67
+
68
+ private
69
+
70
+ # Parsed header. Holds the 4 uint16 fields plus the 7 uint32 stream
71
+ # sizes. Stored as a Hash so the keys document themselves.
72
+ def parse_header
73
+ version, option_flags, ng, idx_fmt = @data[0, 8].unpack("S>S>S>S>")
74
+ sizes = @data[8, 28].unpack("L>7")
75
+
76
+ if ng != @num_glyphs
77
+ raise InvalidFontError,
78
+ "WOFF2 glyf numGlyphs mismatch: header says #{ng}, expected #{@num_glyphs}"
79
+ end
80
+ if idx_fmt != @index_format
81
+ raise InvalidFontError,
82
+ "WOFF2 glyf indexFormat mismatch: header says #{idx_fmt}, expected #{@index_format}"
83
+ end
84
+
85
+ {
86
+ version:,
87
+ option_flags:,
88
+ has_overlap_bitmap: (option_flags & 0x01).nonzero?,
89
+ stream_sizes: sizes,
90
+ }
91
+ end
92
+
93
+ # Slice the 7 streams out of the data block and set up per-stream
94
+ # StringIO cursors. The bboxStream is split into bboxBitmap (fixed
95
+ # size per numGlyphs) and bboxStream (variable, conditionally read
96
+ # per glyph). The optional overlapSimpleBitmap is read at the end.
97
+ def read_streams(header)
98
+ pos = HEADER_SIZE
99
+ bitmap_size = ((@num_glyphs + 31) >> 5) << 2
100
+ overlap_size = header[:has_overlap_bitmap] ? ((@num_glyphs + 7) >> 3) : 0
101
+
102
+ names = %i[n_contour n_points flags glyph composite bbox instructions]
103
+ streams = {}
104
+ names.each_with_index do |name, i|
105
+ size = header[:stream_sizes][i]
106
+ streams[name] = StringIO.new(@data[pos, size])
107
+ pos += size
108
+ end
109
+
110
+ # bboxStream begins with bboxBitmap (4 × floor((numGlyphs+31)/32)).
111
+ bbox_blob = streams[:bbox].string
112
+ streams[:bbox_bitmap] = bbox_blob[0, bitmap_size] || ""
113
+ streams[:bbox_entries] = StringIO.new(bbox_blob[bitmap_size..] || "")
114
+
115
+ # overlapSimpleBitmap follows all streams when optionFlags bit 0 set.
116
+ streams[:overlap_bitmap] = header[:has_overlap_bitmap] ? @data[pos, overlap_size] : ""
117
+
118
+ # nContour is read positionally (int16 per glyph).
119
+ nc_data = streams[:n_contour].string
120
+ streams[:n_contour_values] = nc_data.unpack("s>*")
121
+
122
+ streams
123
+ end
124
+
125
+ def decode_glyph(glyph_id, streams)
126
+ num_contours = streams[:n_contour_values][glyph_id]
127
+
128
+ case num_contours
129
+ when 0
130
+ # Empty glyph: just an entry in loca (loca[n] == loca[n-1]).
131
+ ""
132
+ when -1
133
+ decode_composite(glyph_id, streams)
134
+ else
135
+ decode_simple(glyph_id, num_contours, streams)
136
+ end
137
+ end
138
+
139
+ # Decode a simple glyph per spec section 5.1 "Decoding of Simple Glyphs".
140
+ def decode_simple(glyph_id, num_contours, streams)
141
+ # Step 1: read num_contours 255UInt16 values from nPointsStream to
142
+ # build endPtsOfContours[].
143
+ end_pts = []
144
+ cumulative = -1
145
+ num_contours.times do
146
+ pts_of_contour = read_255_uint16(streams[:n_points])
147
+ cumulative += pts_of_contour
148
+ end_pts << cumulative
149
+ end
150
+ total_points = end_pts.last + 1
151
+
152
+ # Step 2: read total_points flag bytes from flagStream (triplet flags).
153
+ triplet_flags = streams[:flags].read(total_points).bytes
154
+
155
+ # Step 3: read coordinate triplets from glyphStream (1-4 bytes per
156
+ # point depending on flag). Decode to (dx, dy) deltas.
157
+ xs = []
158
+ ys = []
159
+ on_curve_flags = []
160
+ x = 0
161
+ y = 0
162
+ triplet_flags.each do |flag|
163
+ dx, dy, oc = TripletCodec.decode(flag, read_triplet_payload(flag, streams[:glyph]))
164
+ x += dx
165
+ y += dy
166
+ xs << x
167
+ ys << y
168
+ on_curve_flags << (oc ? FLAG_ON_CURVE : 0)
169
+ end
170
+
171
+ # Step 4-5: read instructionLength from glyphStream, bytes from instructionStream.
172
+ inst_len = read_255_uint16(streams[:glyph])
173
+ instructions = streams[:instructions].read(inst_len)
174
+
175
+ # Bbox: emit explicit bbox if bboxBitmap bit is set for this glyph,
176
+ # otherwise recalculate from coordinates.
177
+ bbox = if bbox_bit_set?(streams[:bbox_bitmap], glyph_id)
178
+ read_bbox(streams[:bbox_entries])
179
+ else
180
+ calc_bounds(xs, ys) || [0, 0, 0, 0]
181
+ end
182
+
183
+ # Re-encode in standard TrueType format.
184
+ build_simple_glyph_bytes(num_contours:, bbox:, end_pts:,
185
+ on_curve_flags:, xs:, ys:, instructions:,
186
+ overlap: overlap_bit_set?(streams[:overlap_bitmap], glyph_id))
187
+ end
188
+
189
+ def decode_composite(_glyph_id, streams)
190
+ # Composite components are stored verbatim in compositeStream. Walk
191
+ # the component records to find their total byte length, then copy.
192
+ # Per spec section 5.1 "Decoding of Composite Glyphs": each component
193
+ # has a uint16 flags word, then 4-14 argument bytes.
194
+ composite_io = streams[:composite]
195
+ component_start = composite_io.pos
196
+ have_instructions = false
197
+ loop do
198
+ break if composite_io.eof?
199
+
200
+ flags = read_uint16_io(composite_io)
201
+ composite_io.read(2) # glyphIndex
202
+ composite_io.read((flags & ARG_1_AND_2_ARE_WORDS).nonzero? ? 4 : 2)
203
+ composite_io.read(composite_transform_size(flags))
204
+ have_instructions = (flags & WE_HAVE_INSTRUCTIONS).nonzero?
205
+ break if (flags & MORE_COMPONENTS).zero?
206
+ end
207
+ component_end = composite_io.pos
208
+ component_bytes = composite_io.string[component_start...component_end]
209
+
210
+ instructions = String.new(encoding: Encoding::BINARY)
211
+ if have_instructions
212
+ inst_len = read_255_uint16(streams[:glyph])
213
+ instructions = streams[:instructions].read(inst_len)
214
+ end
215
+
216
+ # Composite glyphs always have explicit bbox in bboxStream.
217
+ bbox = read_bbox(streams[:bbox_entries])
218
+
219
+ # num_contours = -1 (int16), bbox 4 × int16, components, optional instructions
220
+ out = String.new(encoding: Encoding::BINARY)
221
+ out << [-1].pack("s>")
222
+ out << bbox.pack("s>*")
223
+ out << component_bytes
224
+ # If the last component had WE_HAVE_INSTRUCTIONS, the standard
225
+ # TrueType composite format expects a uint16 instructionLength
226
+ # followed by the instruction bytes.
227
+ if have_instructions
228
+ out << [instructions.bytesize].pack("n")
229
+ out << instructions
230
+ end
231
+ out
232
+ end
233
+
234
+ def composite_transform_size(flags)
235
+ if (flags & WE_HAVE_A_SCALE).nonzero? then 2
236
+ elsif (flags & WE_HAVE_AN_X_AND_Y_SCALE).nonzero? then 4
237
+ elsif (flags & WE_HAVE_A_TWO_BY_TWO).nonzero? then 8
238
+ else
239
+ 0
240
+ end
241
+ end
242
+
243
+ # Read 1-4 payload bytes for a triplet based on the flag's index bits.
244
+ def read_triplet_payload(flag, io)
245
+ idx = flag & 0x7F
246
+ n_bytes = if idx < 84 then 1
247
+ elsif idx < 120 then 2
248
+ elsif idx < 124 then 3
249
+ else 4
250
+ end
251
+ bytes = io.read(n_bytes)
252
+ bytes ? bytes.bytes : Array.new(n_bytes, 0)
253
+ end
254
+
255
+ def read_255_uint16(io)
256
+ return 0 if io.eof?
257
+
258
+ code = io.read(1)&.unpack1("C")
259
+ return 0 unless code
260
+
261
+ case code
262
+ when 0..252 then code
263
+ when 253
264
+ b = io.read(1)&.unpack1("C") || 0
265
+ 253 + b
266
+ when 254
267
+ io.read(2)&.unpack1("n") || 0
268
+
269
+ when 255
270
+ v = io.read(2)&.unpack1("n") || 0
271
+ v + 506
272
+ end
273
+ end
274
+
275
+ def read_uint16_io(io)
276
+ io.read(2)&.unpack1("n") || 0
277
+ end
278
+
279
+ def bbox_bit_set?(bbox_bitmap, glyph_id)
280
+ return false if bbox_bitmap.nil? || bbox_bitmap.empty?
281
+
282
+ byte_idx = glyph_id >> 3
283
+ bit_idx = glyph_id & 7
284
+ (bbox_bitmap.getbyte(byte_idx) & (0x80 >> bit_idx)).nonzero?
285
+ end
286
+
287
+ def overlap_bit_set?(overlap_bitmap, glyph_id)
288
+ return false unless overlap_bitmap && !overlap_bitmap.empty?
289
+
290
+ byte_idx = glyph_id >> 3
291
+ bit_idx = glyph_id & 7
292
+ (overlap_bitmap.getbyte(byte_idx) & (0x80 >> bit_idx)).nonzero?
293
+ end
294
+
295
+ def read_bbox(io)
296
+ bytes = io.read(8)
297
+ return [0, 0, 0, 0] unless bytes&.bytesize == 8
298
+
299
+ bytes.unpack("s>*")
300
+ end
301
+
302
+ def calc_bounds(xs, ys)
303
+ return nil if xs.empty?
304
+
305
+ [xs.min, ys.min, xs.max, ys.max]
306
+ end
307
+
308
+ # Build a standard TrueType simple-glyph record from per-point arrays.
309
+ # Re-encodes coordinates using the standard TrueType flag scheme based
310
+ # on per-point delta magnitudes (per OFF section 5.3.3 spec rules).
311
+ def build_simple_glyph_bytes(num_contours:, bbox:, end_pts:, on_curve_flags:,
312
+ xs:, ys:, instructions:, overlap:)
313
+ out = String.new(encoding: Encoding::BINARY)
314
+ out << [num_contours].pack("s>")
315
+ out << bbox.pack("s>*")
316
+ out << end_pts.pack("n*")
317
+ out << [instructions.bytesize].pack("n")
318
+ out << instructions
319
+
320
+ prev_x = prev_y = 0
321
+ tt_flags = []
322
+ x_bytes = String.new(encoding: Encoding::BINARY)
323
+ y_bytes = String.new(encoding: Encoding::BINARY)
324
+
325
+ xs.each_with_index do |x, i|
326
+ y = ys[i]
327
+ dx = x - prev_x
328
+ dy = y - prev_y
329
+ prev_x = x
330
+ prev_y = y
331
+
332
+ base = on_curve_flags[i]
333
+ base |= FLAG_OVERLAP_SIMPLE if overlap && i.zero?
334
+
335
+ flag = encode_axis_bits(base, x_bytes, short_bit: FLAG_X_SHORT,
336
+ same_bit: FLAG_X_SAME_OR_POS,
337
+ value: dx)
338
+ flag = encode_axis_bits(flag, y_bytes, short_bit: FLAG_Y_SHORT,
339
+ same_bit: FLAG_Y_SAME_OR_POS,
340
+ value: dy)
341
+ tt_flags << flag
342
+ end
343
+
344
+ out << encode_flags_rle(tt_flags)
345
+ out << x_bytes
346
+ out << y_bytes
347
+ out
348
+ end
349
+
350
+ # Encode one axis delta into standard TrueType byte layout. Returns
351
+ # the updated flag value (X bits if short_bit is FLAG_X_SHORT, Y bits
352
+ # if short_bit is FLAG_Y_SHORT).
353
+ def encode_axis_bits(base, out, short_bit:, same_bit:, value:)
354
+ if value.zero?
355
+ base | same_bit
356
+ elsif value.abs <= 255
357
+ flag = base | short_bit
358
+ flag |= same_bit if value.positive?
359
+ out << (value.abs & 0xFF)
360
+ flag
361
+ else
362
+ out << [value & 0xFFFF].pack("n")
363
+ base
364
+ end
365
+ end
366
+
367
+ # Run-length encode the flags array per OFF spec: when two or more
368
+ # consecutive flags are identical, emit REPEAT_FLAG followed by count.
369
+ def encode_flags_rle(flags)
370
+ out = String.new(encoding: Encoding::BINARY)
371
+ i = 0
372
+ while i < flags.size
373
+ flag = flags[i]
374
+ count = 1
375
+ while i + count < flags.size && flags[i + count] == flag && count < 255
376
+ count += 1
377
+ end
378
+ if count > 1
379
+ out << (flag | FLAG_REPEAT).chr(Encoding::BINARY)
380
+ out << count.chr(Encoding::BINARY)
381
+ i += count
382
+ else
383
+ out << flag.chr(Encoding::BINARY)
384
+ i += 1
385
+ end
386
+ end
387
+ out
388
+ end
389
+
390
+ # Build loca table from accumulated glyph offsets per spec section 5.3.
391
+ def build_loca(offsets)
392
+ if @index_format.zero?
393
+ offsets.map { |o| o / 2 }.pack("n*")
394
+ else
395
+ offsets.pack("N*")
396
+ end
397
+ end
398
+ end
399
+ end
400
+ end
@@ -41,17 +41,21 @@ module Fontisan
41
41
  # Simple glyph records start with int16 numberOfContours + 4 int16 bbox.
42
42
  SIMPLE_HEADER_SIZE = 10
43
43
 
44
- attr_reader :num_glyphs, :index_format
44
+ attr_reader :num_glyphs, :source_index_format
45
45
 
46
46
  # @param glyf_data [String] raw glyf table bytes
47
47
  # @param loca_data [String] raw loca table bytes
48
48
  # @param num_glyphs [Integer] from maxp
49
- # @param index_format [Integer] 0 (short) or 1 (long), from head
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).
50
54
  def initialize(glyf_data:, loca_data:, num_glyphs:, index_format:)
51
55
  @glyf_data = glyf_data
52
56
  @loca_data = loca_data
53
57
  @num_glyphs = num_glyphs
54
- @index_format = index_format
58
+ @source_index_format = index_format
55
59
  end
56
60
 
57
61
  # Encode the glyf/loca transform, returning the transformed glyf bytes.
@@ -251,7 +255,10 @@ module Fontisan
251
255
  out << [0].pack("S>") # version
252
256
  out << [(s.has_overlap? ? 1 : 0)].pack("S>") # optionFlags
253
257
  out << [@num_glyphs].pack("S>")
254
- out << [@index_format].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>")
255
262
  out << [s.n_contour.bytesize].pack("L>")
256
263
  out << [s.n_points.bytesize].pack("L>")
257
264
  out << [s.flags.bytesize].pack("L>")
@@ -271,7 +278,7 @@ module Fontisan
271
278
  end
272
279
 
273
280
  def parse_loca_offsets
274
- if @index_format.zero?
281
+ if @source_index_format.zero?
275
282
  @loca_data.unpack("n*").map { |v| v * 2 }
276
283
  else
277
284
  @loca_data.unpack("N*")
@@ -4,10 +4,12 @@
4
4
 
5
5
  module Fontisan
6
6
  module Woff2
7
+ autoload :CollectionDecoder, "fontisan/woff2/collection_decoder"
8
+ autoload :CollectionEncoder, "fontisan/woff2/collection_encoder"
7
9
  autoload :Directory, "fontisan/woff2/directory"
8
10
  autoload :EncoderRules, "fontisan/woff2/encoder_rules"
11
+ autoload :GlyfLocaReconstruct, "fontisan/woff2/glyf_loca_reconstruct"
9
12
  autoload :GlyfLocaTransform, "fontisan/woff2/glyf_loca_transform"
10
- autoload :GlyfTransformer, "fontisan/woff2/glyf_transformer"
11
13
  autoload :HmtxTransformer, "fontisan/woff2/hmtx_transformer"
12
14
  autoload :TableTransformer, "fontisan/woff2/table_transformer"
13
15
  autoload :TripletCodec, "fontisan/woff2/triplet_codec"
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+
5
+ module Fontisan
6
+ # WOFF2 font collection (spec section 4.2).
7
+ #
8
+ # Wraps a WOFF2 collection file (flavor='ttcf') and exposes the same
9
+ # interface as `TrueTypeCollection`/`OpenTypeCollection` so collection
10
+ # consumers don't need to know which container format they're holding.
11
+ #
12
+ # Unlike TTC/OTC (which subclass `BaseCollection` for BinData parsing),
13
+ # WOFF2 collections are decoded eagerly via `Woff2::CollectionDecoder`
14
+ # because the per-font tables require Brotli decompression +
15
+ # glyf/loca reconstruction before they can be returned.
16
+ class Woff2Collection
17
+ include Collection::SharedLogic
18
+
19
+ # @return [String] Source WOFF2 binary
20
+ attr_reader :data
21
+
22
+ # @param data [String] WOFF2 collection binary
23
+ def initialize(data)
24
+ @data = data.force_encoding(Encoding::BINARY)
25
+ end
26
+
27
+ # Load a WOFF2 collection from a file path.
28
+ #
29
+ # @param path [String]
30
+ # @return [Woff2Collection]
31
+ def self.from_file(path)
32
+ new(File.binread(path))
33
+ end
34
+
35
+ # Number of fonts in the collection.
36
+ #
37
+ # @return [Integer]
38
+ def num_fonts
39
+ decoded.length
40
+ end
41
+ alias font_count num_fonts
42
+
43
+ # Per-font synthetic offsets. WOFF2 collections don't have byte offsets
44
+ # like TTC (each font is reconstructed in memory); the index is the
45
+ # only identifier. Returned for compatibility with `BaseCollection`'s
46
+ # interface.
47
+ #
48
+ # @return [Array<Integer>] 0..num_fonts-1
49
+ def font_offsets
50
+ (0...num_fonts).to_a
51
+ end
52
+
53
+ # Extract every font in the collection.
54
+ #
55
+ # @param _io [IO, nil] Ignored — WOFF2 collections decode in memory.
56
+ # @return [Array<TrueTypeFont, OpenTypeFont>]
57
+ def extract_fonts(_io = nil)
58
+ decoded.map { |entry| build_font(entry) }
59
+ end
60
+
61
+ # Get a single font by index.
62
+ #
63
+ # @param index [Integer] 0-based font index
64
+ # @param _io [IO, nil] Ignored.
65
+ # @param _mode [Symbol] Ignored — full mode only (WOFF2 collections are
66
+ # decoded eagerly).
67
+ # @return [TrueTypeFont, OpenTypeFont, nil] nil if index out of range
68
+ def font(index, _io = nil, _mode: LoadingModes::FULL)
69
+ return nil if index >= num_fonts
70
+
71
+ build_font(decoded[index])
72
+ end
73
+
74
+ # Whether this object represents a font collection. WOFF2 collections
75
+ # are always collections.
76
+ #
77
+ # @return [Boolean]
78
+ def collection? = true
79
+
80
+ # Collections have no single SFNT table directory.
81
+ #
82
+ # @return [Array<String>] empty
83
+ def table_names = []
84
+
85
+ # Validate format correctness.
86
+ #
87
+ # @return [Boolean]
88
+ def valid?
89
+ flavor = @data[4, 4].unpack1("N")
90
+ flavor == Woff2::CollectionDecoder::TTC_FLAVOR
91
+ rescue StandardError
92
+ false
93
+ end
94
+
95
+ # High-level pipeline format identifier. Owned by the collection class
96
+ # so the conversion pipeline can dispatch without case statements.
97
+ #
98
+ # @return [Symbol] :woff2_collection
99
+ def format = :woff2_collection
100
+
101
+ private
102
+
103
+ # Lazily decoded collection entries (one Hash per font).
104
+ def decoded
105
+ @decoded ||= Woff2::CollectionDecoder.new(@data).decode
106
+ end
107
+
108
+ # Build a TrueTypeFont or OpenTypeFont from a decoded font entry.
109
+ # Constructs an in-memory SFNT binary from the per-table data and
110
+ # loads it through the normal font reader path.
111
+ def build_font(entry)
112
+ sfnt = SfntBuilder.build(flavor: entry[:flavor], tables: entry[:tables])
113
+ sfnt_io = StringIO.new(sfnt)
114
+ font_class = truetype_flavor?(entry[:flavor]) ? TrueTypeFont : OpenTypeFont
115
+ font = font_class.read(sfnt_io)
116
+ font.initialize_storage
117
+ font.read_table_data(StringIO.new(sfnt))
118
+ font
119
+ end
120
+
121
+ def truetype_flavor?(flavor)
122
+ [Constants::SFNT_VERSION_TRUETYPE, 0x00010000].include?(flavor)
123
+ end
124
+ end
125
+ 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
 
data/lib/fontisan.rb CHANGED
@@ -116,6 +116,7 @@ module Fontisan
116
116
  autoload :OutlineExtractor, "fontisan/outline_extractor"
117
117
  autoload :SfntFont, "fontisan/sfnt_font"
118
118
  autoload :SfntTable, "fontisan/sfnt_table"
119
+ autoload :SfntBuilder, "fontisan/sfnt_builder"
119
120
  autoload :Stitcher, "fontisan/stitcher"
120
121
  autoload :StitcherCli, "fontisan/stitcher_cli"
121
122
  autoload :Tasks, "fontisan/tasks"
@@ -123,6 +124,7 @@ module Fontisan
123
124
  autoload :TrueTypeFont, "fontisan/true_type_font"
124
125
  autoload :TrueTypeFontExtensions, "fontisan/true_type_font_extensions"
125
126
  autoload :Type1Font, "fontisan/type1_font"
127
+ autoload :Woff2Collection, "fontisan/woff2_collection"
126
128
  autoload :Woff2Font, "fontisan/woff2_font"
127
129
  autoload :WoffFont, "fontisan/woff_font"
128
130