fontisan 0.4.14 → 0.4.16

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 47e074b8c2bbdcfa377c95733f56fdd978dfa4d824dbae3e29f3bd75935dddd6
4
- data.tar.gz: a1a348db0bd6dde039c0a43f62ac3ab43792cc7414e995a72c47798663a1cd59
3
+ metadata.gz: 1d27d25052549a912eab7392f8f0b34e7917fad9e9344d178b853bfe5a9fb476
4
+ data.tar.gz: 7fe03e99020f0a628efa4bac17236c455143345dbe4aef1dd1df5a8890cf00ad
5
5
  SHA512:
6
- metadata.gz: e8917cc8c995f0ca2cbdc9e94ad5d5d629ab95d8809b7eb728d360f0743a2b26e5cfd059254497c5a1df10ddb3b53258ee703c441a106d4febd42b26ea3b6e08
7
- data.tar.gz: d35a8138d7cdbc5389b619ed757863cc0d61e1d8d025655d0fb6736a21815f04d24802f24236fdffdc8d430192db79798f77f7b9343193e0c16035d2be8fc5eb
6
+ metadata.gz: 5ddb0c714d55d9f0e3b6b2ae02322f210d2392391d604368628cf6a789e2c1633645b08d4ee1edca6e05c0dc87e7c6e6ec550bc412bcc79fe751cc21f1e55f62
7
+ data.tar.gz: f8a646128fbadfd95df57bffc933a17095b3fb228603c941471198186df112bc517d836d09c567c1456b5aa8caed3aec4c14f8c561ba4cc19a4561bf75124940
@@ -299,7 +299,7 @@ module Fontisan
299
299
  # Determine collection type from format
300
300
  #
301
301
  # @param format [Symbol] Target format
302
- # @return [Symbol, nil] Collection type (:ttc, :otc, :dfont) or nil
302
+ # @return [Symbol, nil] Collection type (:ttc, :otc, :dfont, :woff2_collection) or nil
303
303
  def collection_type_from_format(format)
304
304
  case format
305
305
  when :ttc
@@ -308,6 +308,8 @@ module Fontisan
308
308
  :otc
309
309
  when :dfont
310
310
  :dfont
311
+ when :woff2
312
+ :woff2_collection
311
313
  else
312
314
  # Check output extension
313
315
  ext = File.extname(@output_path).downcase
@@ -318,6 +320,8 @@ module Fontisan
318
320
  :otc
319
321
  when ".dfont"
320
322
  :dfont
323
+ when ".woff2"
324
+ :woff2_collection
321
325
  end
322
326
  end
323
327
  end
@@ -377,6 +377,42 @@ conversions:
377
377
  Unpacks dfont suitcase and repacks as OTC. If dfont contains TrueType
378
378
  fonts, they are converted to OpenType/CFF format.
379
379
 
380
+ # WOFF2 collection conversions (spec section 4.2)
381
+ - from: ttc
382
+ to: woff2_collection
383
+ strategy: collection_converter
384
+ description: "Convert TrueType Collection to WOFF2 collection"
385
+ status: implemented
386
+ notes: >
387
+ Unpacks TTC, encodes each font per WOFF2 spec, packs as a single
388
+ WOFF2 collection file (flavor='ttcf'). Tables shared across fonts
389
+ are deduplicated.
390
+
391
+ - from: otc
392
+ to: woff2_collection
393
+ strategy: collection_converter
394
+ description: "Convert OpenType Collection to WOFF2 collection"
395
+ status: implemented
396
+ notes: >
397
+ Unpacks OTC, encodes each font per WOFF2 spec, packs as a single
398
+ WOFF2 collection file (flavor='ttcf').
399
+
400
+ - from: woff2_collection
401
+ to: ttc
402
+ strategy: collection_converter
403
+ description: "Convert WOFF2 collection to TrueType Collection"
404
+ status: implemented
405
+ notes: >
406
+ Decodes WOFF2 collection, extracts each font, repacks as TTC.
407
+
408
+ - from: woff2_collection
409
+ to: otc
410
+ strategy: collection_converter
411
+ description: "Convert WOFF2 collection to OpenType Collection"
412
+ status: implemented
413
+ notes: >
414
+ Decodes WOFF2 collection, extracts each font, repacks as OTC.
415
+
380
416
  # Conversion compatibility matrix
381
417
  #
382
418
  # This section documents which source features are preserved in conversions.
@@ -121,9 +121,10 @@ module Fontisan
121
121
  raise ArgumentError, "Collection file not found: #{collection_path}"
122
122
  end
123
123
 
124
- unless %i[ttc otc dfont].include?(target_type)
124
+ unless %i[ttc otc dfont woff2_collection].include?(target_type)
125
125
  raise ArgumentError,
126
- "Invalid target type: #{target_type}. Must be :ttc, :otc, or :dfont"
126
+ "Invalid target type: #{target_type}. Must be :ttc, :otc, " \
127
+ ":dfont, or :woff2_collection"
127
128
  end
128
129
 
129
130
  unless options[:output]
@@ -145,6 +146,8 @@ module Fontisan
145
146
  unpack_ttc_otc(collection_path)
146
147
  when :dfont
147
148
  unpack_dfont(collection_path)
149
+ when :woff2_collection
150
+ unpack_woff2_collection(collection_path)
148
151
  else
149
152
  raise Error, "Unknown collection type: #{source_type}"
150
153
  end
@@ -155,7 +158,7 @@ module Fontisan
155
158
  # Detect collection type from file
156
159
  #
157
160
  # @param path [String] Collection path
158
- # @return [Symbol] Collection type (:ttc, :otc, or :dfont)
161
+ # @return [Symbol] Collection type (:ttc, :otc, :dfont, or :woff2_collection)
159
162
  def detect_collection_type(path)
160
163
  File.open(path, "rb") do |io|
161
164
  signature = io.read(4)
@@ -165,6 +168,12 @@ module Fontisan
165
168
  # TTC or OTC - check extension
166
169
  ext = File.extname(path).downcase
167
170
  ext == ".otc" ? :otc : :ttc
171
+ elsif signature == "wOF2"
172
+ # WOFF2 collection or single font — distinguish by flavor
173
+ io.read(8) # signature + flavor
174
+ io.rewind
175
+ flavor = File.binread(path, 4, 4)&.unpack1("N")
176
+ flavor == Woff2::CollectionDecoder::TTC_FLAVOR ? :woff2_collection : (raise Error, "Not a WOFF2 collection: #{path}")
168
177
  elsif Parsers::DfontParser.dfont?(io)
169
178
  :dfont
170
179
  else
@@ -207,6 +216,15 @@ module Fontisan
207
216
  fonts
208
217
  end
209
218
 
219
+ # Unpack fonts from WOFF2 collection (spec section 4.2).
220
+ #
221
+ # @param path [String] WOFF2 collection path
222
+ # @return [Array<Font>] Unpacked fonts
223
+ def unpack_woff2_collection(path)
224
+ collection = FontLoader.load_collection(path)
225
+ collection.extract_fonts
226
+ end
227
+
210
228
  # Convert fonts if outline format change needed
211
229
  #
212
230
  # @param fonts [Array<Font>] Source fonts
@@ -380,6 +398,8 @@ conv_options = nil)
380
398
  repack_ttc_otc(fonts, target_type, output_path, options)
381
399
  when :dfont
382
400
  repack_dfont(fonts, output_path, options)
401
+ when :woff2_collection
402
+ repack_woff2_collection(fonts, output_path, options)
383
403
  else
384
404
  raise Error, "Unknown target type: #{target_type}"
385
405
  end
@@ -415,6 +435,19 @@ conv_options = nil)
415
435
  builder.build_to_file(output_path)
416
436
  end
417
437
 
438
+ # Repack fonts into a WOFF2 collection (spec section 4.2).
439
+ #
440
+ # @param fonts [Array<Font>] Fonts
441
+ # @param output_path [String] Output path (.woff2)
442
+ # @param options [Hash] Options (looks for :brotli_quality)
443
+ # @return [void]
444
+ def repack_woff2_collection(fonts, output_path, options)
445
+ brotli_quality = options.fetch(:brotli_quality, 11)
446
+ encoder = Woff2::CollectionEncoder.new(brotli_quality:)
447
+ bytes = encoder.encode_fonts(fonts)
448
+ File.binwrite(output_path, bytes)
449
+ end
450
+
418
451
  # Build conversion result
419
452
  #
420
453
  # @param input [String] Input path
@@ -227,6 +227,12 @@ module Fontisan
227
227
  # are kept in `table_data` so its `origLength` is preserved; the
228
228
  # transformed bytes are passed to the entries builder separately.
229
229
  #
230
+ # glyf.origLength is computed by round-tripping the transformed glyf
231
+ # through the decoder — this guarantees the directory matches what
232
+ # any compliant decoder will produce, including padding and minor
233
+ # re-encoding differences (OFF section 5.3.3 allows multiple valid
234
+ # reconstructions of the same glyph data).
235
+ #
230
236
  # @return [Hash{Symbol => Object}, nil]
231
237
  def apply_glyf_loca_transform!(table_data, font)
232
238
  return nil unless font.respond_to?(:has_table?)
@@ -240,17 +246,32 @@ module Fontisan
240
246
  head = font.table("head")
241
247
  return nil unless maxp && head
242
248
 
249
+ index_format = head.index_to_loc_format
250
+
243
251
  transformed = Woff2::GlyfLocaTransform.new(
244
252
  glyf_data:,
245
253
  loca_data:,
246
254
  num_glyphs: maxp.num_glyphs,
247
- index_format: head.index_to_loc_format,
255
+ index_format:,
248
256
  ).transform
249
257
 
250
- loca_orig_length = loca_data.bytesize
258
+ # Round-trip through the decoder to get the exact reconstructed
259
+ # sizes the directory must advertise. fontTools re-compiles glyf
260
+ # during encoding, producing sizes that differ from the source by
261
+ # small amounts (encoding variations per OFF section 5.3.3). Using
262
+ # the source bytesize causes fontTools' decoder to reject the
263
+ # WOFF2 with "not enough 'glyf' table data".
264
+ reconstructed = Woff2::GlyfLocaReconstruct.new(
265
+ transformed_glyf: transformed,
266
+ num_glyphs: maxp.num_glyphs,
267
+ index_format:,
268
+ ).reconstruct
269
+
270
+ glyf_orig_length = reconstructed[:glyf].bytesize
271
+ loca_orig_length = reconstructed[:loca].bytesize
251
272
  table_data.delete("loca")
252
273
 
253
- { transformed_glyf: transformed, loca_orig_length: }
274
+ { transformed_glyf: transformed, glyf_orig_length:, loca_orig_length: }
254
275
  end
255
276
 
256
277
  def get_table_data(font, tag)
@@ -277,7 +298,7 @@ module Fontisan
277
298
 
278
299
  table_data.keys.sort.each do |tag|
279
300
  if tag == "glyf" && glyf_transform
280
- entries << build_glyf_entry(table_data["glyf"],
301
+ entries << build_glyf_entry(glyf_transform[:glyf_orig_length],
281
302
  glyf_transform[:transformed_glyf])
282
303
  entries << build_loca_entry(glyf_transform[:loca_orig_length])
283
304
  transformed_data["glyf"] = glyf_transform[:transformed_glyf]
@@ -314,13 +335,13 @@ module Fontisan
314
335
  entry
315
336
  end
316
337
 
317
- # Build the glyf directory entry. origLength is the original (input)
318
- # glyf size; transformLength is the size of the WOFF2-transformed
319
- # glyf stream per spec section 5.1.
320
- def build_glyf_entry(orig_data, transformed_data)
338
+ # Build the glyf directory entry. origLength is the reconstructed
339
+ # glyf size (what a decoder will produce); transformLength is the
340
+ # size of the WOFF2-transformed glyf stream per spec section 5.1.
341
+ def build_glyf_entry(glyf_orig_length, transformed_data)
321
342
  entry = Woff2::Directory::Entry.new
322
343
  entry.tag = "glyf"
323
- entry.orig_length = orig_data.bytesize
344
+ entry.orig_length = glyf_orig_length
324
345
  entry.transform_length = transformed_data.bytesize
325
346
  entry.flags = entry.calculate_flags
326
347
  entry
@@ -45,6 +45,7 @@ module Fontisan
45
45
  ttc: TrueTypeCollection,
46
46
  otc: OpenTypeCollection,
47
47
  dfont: DfontCollection,
48
+ woff2_collection: Woff2Collection,
48
49
  }.freeze
49
50
  private_constant :COLLECTION_CLASSES
50
51
 
@@ -83,6 +84,8 @@ module Fontisan
83
84
  lazy: resolved_lazy)
84
85
  when :ttc, :otc then load_from_collection(path, format, font_index,
85
86
  mode: resolved_mode)
87
+ when :woff2_collection then load_from_collection(path, format, font_index,
88
+ mode: resolved_mode)
86
89
  when :dfont then load_dfont(path, font_index: font_index,
87
90
  mode: resolved_mode)
88
91
  when :pfa, :pfb then Type1Font.from_file(path, mode: resolved_mode)
@@ -185,7 +188,7 @@ module Fontisan
185
188
  when Constants::SFNT_OTTO_MAGIC then :otf
186
189
  when Constants::SFNT_TRUETYPE_MAGIC, Constants::SFNT_TRUE_MAGIC then :ttf
187
190
  when Constants::WOFF_MAGIC then :woff
188
- when Constants::WOFF2_MAGIC then :woff2
191
+ when Constants::WOFF2_MAGIC then scan_woff2(io)
189
192
  when Constants::DFONT_RESOURCE_HEADER
190
193
  io.rewind
191
194
  Parsers::DfontParser.dfont?(io) ? :dfont : nil
@@ -238,6 +241,18 @@ module Fontisan
238
241
  nil
239
242
  end
240
243
 
244
+ # Distinguish a WOFF2 collection (flavor='ttcf') from a single-font
245
+ # WOFF2. The 8-byte probe covers the WOFF2 signature + flavor field;
246
+ # spec section 3.2 requires flavor='ttcf' (0x74746366) for collections.
247
+ def self.scan_woff2(io)
248
+ io.rewind
249
+ io.read(4) # signature (already matched)
250
+ flavor = io.read(4)&.unpack1("N")
251
+ flavor == Woff2::CollectionDecoder::TTC_FLAVOR ? :woff2_collection : :woff2
252
+ rescue IOError
253
+ :woff2
254
+ end
255
+
241
256
  # Mode override from FONTISAN_MODE env var, or nil.
242
257
  def self.env_mode
243
258
  env_value = ENV["FONTISAN_MODE"]
@@ -287,6 +302,7 @@ module Fontisan
287
302
  private_class_method :detect,
288
303
  :type1_format_from_header,
289
304
  :scan_collection,
305
+ :scan_woff2,
290
306
  :env_mode,
291
307
  :env_lazy,
292
308
  :load_from_collection,
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+
5
+ module Fontisan
6
+ # Builds an SFNT (TrueType/OpenType) binary from a flavor + per-tag table
7
+ # data. Used whenever WOFF2-derived table data needs to be reassembled
8
+ # into a parseable SFNT (single-font decoding, WOFF2 collection → font).
9
+ #
10
+ # Single source of truth for the SFNT byte layout: 12-byte offset table,
11
+ # 16-byte table directory entries, padded table data.
12
+ module SfntBuilder
13
+ # @param flavor [Integer] SFNT version (Constants::SFNT_VERSION_TRUETYPE
14
+ # or Constants::SFNT_VERSION_OTTO)
15
+ # @param tables [Hash<String => String>] tag → table bytes
16
+ # @return [String] SFNT binary
17
+ def self.build(flavor:, tables:)
18
+ num_tables = tables.length
19
+ entry_selector = (Math.log(num_tables) / Math.log(2)).floor
20
+ search_range = (2**entry_selector) * 16
21
+ range_shift = (num_tables * 16) - search_range
22
+
23
+ out = String.new(encoding: Encoding::BINARY)
24
+ # Offset table (12 bytes)
25
+ out << [flavor].pack("N")
26
+ out << [num_tables].pack("n")
27
+ out << [search_range].pack("n")
28
+ out << [entry_selector].pack("n")
29
+ out << [range_shift].pack("n")
30
+
31
+ # Compute layout for table directory + table data with 4-byte padding
32
+ data_start = 12 + (num_tables * 16)
33
+ cursor = data_start
34
+ records = tables.map do |tag, data|
35
+ length = data.bytesize
36
+ checksum = Utilities::ChecksumCalculator.calculate_table_checksum(data)
37
+ padding = (4 - (length % 4)) % 4
38
+ record = { tag:, checksum:, offset: cursor, length:, data:, padding: }
39
+ cursor += length + padding
40
+ record
41
+ end
42
+
43
+ # Write all table directory entries first (16 bytes each, sorted
44
+ # alphabetically per SFNT convention for stable output), then all
45
+ # table data with 4-byte padding. The directory entries' offset
46
+ # fields were precomputed assuming this layout.
47
+ sorted = records.sort_by { |r| r[:tag] }
48
+ # rubocop:disable Style/CombinableLoops
49
+ # SFNT requires all directory entries before any table data
50
+ sorted.each do |r|
51
+ out << r[:tag].ljust(4, "\x00")
52
+ out << [r[:checksum]].pack("N")
53
+ out << [r[:offset]].pack("N")
54
+ out << [r[:length]].pack("N")
55
+ end
56
+
57
+ sorted.each do |r|
58
+ out << r[:data]
59
+ out << ("\x00" * r[:padding])
60
+ end
61
+ # rubocop:enable Style/CombinableLoops
62
+
63
+ out
64
+ end
65
+ end
66
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fontisan
4
- VERSION = "0.4.14"
4
+ VERSION = "0.4.16"
5
5
  end
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+
5
+ module Fontisan
6
+ module Woff2
7
+ # Decodes a WOFF2 font collection per spec section 4.2. This is the
8
+ # decoder counterpart of {Woff2::CollectionEncoder}.
9
+ #
10
+ # Layout parsed:
11
+ # [WOFF2Header (48 bytes, flavor = 'ttcf')]
12
+ # [TableDirectory] # one entry per unique table
13
+ # [CollectionDirectory] # CollectionHeader + N CollectionFontEntry
14
+ # [CompressedFontData] # single brotli stream of all tables
15
+ #
16
+ # Each CollectionFontEntry references tables in the TableDirectory via
17
+ # indices. The decoder yields one reconstructed font per entry, with
18
+ # glyf/loca reconstruction per spec section 5.1/5.3 applied.
19
+ #
20
+ # Reference: W3C WOFF2 spec, section 4.2.
21
+ class CollectionDecoder
22
+ TTC_FLAVOR = 0x74746366 # 'ttcf'
23
+
24
+ # Parsed table directory entry: tag + raw bytes + transform state.
25
+ TableEntry = Struct.new(:tag, :raw_bytes, :orig_length, :transform_length,
26
+ keyword_init: true)
27
+
28
+ # Parsed CollectionFontEntry: per-font table indices + flavor.
29
+ FontEntry = Struct.new(:flavor, :table_indices, keyword_init: true)
30
+
31
+ # @param data [String] WOFF2 collection binary
32
+ def initialize(data)
33
+ @data = data.dup.force_encoding(Encoding::BINARY)
34
+ end
35
+
36
+ # Decode the WOFF2 collection, returning one Hash per font containing
37
+ # the reconstructed table data ready to assemble into an SFNT.
38
+ #
39
+ # @return [Array<Hash{Symbol => Object}>] per-font:
40
+ # {flavor:, tables: {tag ⇒ bytes}}
41
+ def decode
42
+ validate_signature!
43
+ validate_collection_flavor!
44
+
45
+ table_entries, post_dir_pos = read_table_directory
46
+ font_entries, post_coll_pos = read_collection_directory(post_dir_pos)
47
+ decompressed = decompress_table_data(post_coll_pos)
48
+
49
+ # Replace each table entry's raw_bytes by slicing from the
50
+ # decompressed stream at its cumulative offset.
51
+ cursor = 0
52
+ table_entries.each do |e|
53
+ len = e.transform_length || e.orig_length
54
+ e.raw_bytes = decompressed[cursor, len]
55
+ cursor += len
56
+ end
57
+
58
+ # For each font, gather its tables and reconstruct glyf/loca when
59
+ # the glyf was transformed.
60
+ font_entries.map do |fe|
61
+ font_tables = {}
62
+ fe.table_indices.each do |idx|
63
+ entry = table_entries.fetch(idx)
64
+ font_tables[entry.tag] = entry.raw_bytes
65
+ end
66
+ reconstruct_glyf_loca!(font_tables) if font_tables.key?("glyf")
67
+ { flavor: fe.flavor, tables: font_tables }
68
+ end
69
+ end
70
+
71
+ private
72
+
73
+ def validate_signature!
74
+ sig = @data[0, 4].unpack1("N")
75
+ return if sig == Woff2Header::SIGNATURE
76
+
77
+ raise InvalidFontError,
78
+ "Invalid WOFF2 signature: 0x#{sig.to_s(16)}"
79
+ end
80
+
81
+ def validate_collection_flavor!
82
+ flavor = @data[4, 4].unpack1("N")
83
+ return if flavor == TTC_FLAVOR
84
+
85
+ raise InvalidFontError,
86
+ "WOFF2 file is not a collection (flavor 0x#{flavor.to_s(16)}; " \
87
+ "expected 0x#{TTC_FLAVOR.to_s(16)} 'ttcf')"
88
+ end
89
+
90
+ def num_tables = @data[12, 2].unpack1("n")
91
+
92
+ def total_compressed_size = @data[20, 4].unpack1("N")
93
+
94
+ # Walk num_tables entries, returning the array of TableEntry and the
95
+ # byte position immediately after the table directory.
96
+ def read_table_directory
97
+ entries = []
98
+ pos = 48
99
+ num_tables.times do
100
+ entry, pos = read_one_directory_entry(pos)
101
+ entries << entry
102
+ end
103
+ [entries, pos]
104
+ end
105
+
106
+ def read_one_directory_entry(pos)
107
+ flags = @data.getbyte(pos)
108
+ pos += 1
109
+ tag_index = flags & 0x3F
110
+ transform_version = (flags >> 6) & 0x03
111
+ tag = if tag_index == 0x3F
112
+ t = @data[pos, 4]
113
+ pos += 4
114
+ t
115
+ else
116
+ Directory::KNOWN_TAGS.fetch(tag_index)
117
+ end
118
+ orig_length, pos = read_uint_base128(pos)
119
+ transform_length = nil
120
+ is_glyf_loca = ["glyf", "loca"].include?(tag)
121
+ is_transformed_hmtx = tag == "hmtx" && transform_version == 1
122
+ if (is_glyf_loca && transform_version.zero?) || is_transformed_hmtx
123
+ transform_length, pos = read_uint_base128(pos)
124
+ end
125
+ entry = TableEntry.new(tag:, raw_bytes: nil, orig_length:,
126
+ transform_length:)
127
+ [entry, pos]
128
+ end
129
+
130
+ # Read CollectionHeader + N CollectionFontEntry records.
131
+ def read_collection_directory(pos)
132
+ version = @data[pos, 4].unpack1("N")
133
+ pos += 4
134
+ raise InvalidFontError, "Unsupported CollectionHeader version 0x#{version.to_s(16)}" unless version == 0x00010000
135
+
136
+ num_fonts, pos = UInt255.decode_at(@data, pos)
137
+ font_entries = Array.new(num_fonts) do
138
+ num_tables_f, pos2 = UInt255.decode_at(@data, pos)
139
+ pos = pos2
140
+ flavor = @data[pos, 4].unpack1("N")
141
+ pos += 4
142
+ indices = Array.new(num_tables_f) do
143
+ idx, pos2 = UInt255.decode_at(@data, pos)
144
+ pos = pos2
145
+ idx
146
+ end
147
+ FontEntry.new(flavor:, table_indices: indices)
148
+ end
149
+ [font_entries, pos]
150
+ end
151
+
152
+ def decompress_table_data(start_pos)
153
+ compressed = @data[start_pos, total_compressed_size]
154
+ Utilities::BrotliWrapper.decompress(compressed)
155
+ end
156
+
157
+ # Reconstruct glyf + loca per spec section 5.1/5.3 when glyf is in
158
+ # transformed format. Replaces font_tables["glyf"] with reconstructed
159
+ # bytes and adds font_tables["loca"].
160
+ def reconstruct_glyf_loca!(font_tables)
161
+ glyf_bytes = font_tables["glyf"]
162
+ return unless glyf_bytes && glyf_bytes.bytesize >= 36
163
+
164
+ num_glyphs = parse_num_glyphs(font_tables)
165
+ index_format = parse_index_format(font_tables)
166
+ return unless num_glyphs && index_format
167
+
168
+ result = GlyfLocaReconstruct.new(
169
+ transformed_glyf: glyf_bytes,
170
+ num_glyphs:,
171
+ index_format:,
172
+ ).reconstruct
173
+ font_tables["glyf"] = result[:glyf]
174
+ font_tables["loca"] = result[:loca]
175
+ end
176
+
177
+ def parse_num_glyphs(font_tables)
178
+ maxp = font_tables["maxp"]
179
+ return nil unless maxp && maxp.bytesize >= 6
180
+
181
+ maxp[4, 2].unpack1("n")
182
+ end
183
+
184
+ def parse_index_format(font_tables)
185
+ head = font_tables["head"]
186
+ return nil unless head && head.bytesize >= 52
187
+
188
+ head[50, 2].unpack1("n")
189
+ end
190
+
191
+ def read_uint_base128(pos)
192
+ value = 0
193
+ 5.times do
194
+ byte = @data.getbyte(pos)
195
+ pos += 1
196
+ if (byte & 0x80).zero?
197
+ value = (value << 7) | byte
198
+ return [value, pos]
199
+ end
200
+ value = (value << 7) | (byte & 0x7F)
201
+ end
202
+ raise InvalidFontError, "UIntBase128 sequence exceeds 5 bytes"
203
+ end
204
+ end
205
+ end
206
+ end
@@ -238,7 +238,7 @@ module Fontisan
238
238
  def write_collection_directory(io, fonts, font_to_ckeys, ckey_to_index)
239
239
  # CollectionHeader
240
240
  io.write([0x00010000].pack("N")) # version 1.0
241
- io.write(encode_255_uint16(fonts.size))
241
+ io.write(UInt255.encode(fonts.size))
242
242
 
243
243
  # One CollectionFontEntry per font
244
244
  fonts.each_with_index do |font, fi|
@@ -254,12 +254,12 @@ module Fontisan
254
254
  ordered << "loca" if has_placeholder
255
255
  ordered = ordered.sort
256
256
 
257
- io.write(encode_255_uint16(ordered.size))
257
+ io.write(UInt255.encode(ordered.size))
258
258
  io.write([font_flavor(font)].pack("N"))
259
259
  ordered.each do |tag|
260
260
  ckey = font_keys[tag] || placeholder_key
261
261
  index = ckey_to_index.fetch(ckey)
262
- io.write(encode_255_uint16(index))
262
+ io.write(UInt255.encode(index))
263
263
  end
264
264
  end
265
265
  end
@@ -271,18 +271,6 @@ module Fontisan
271
271
  Constants::SFNT_VERSION_TRUETYPE
272
272
  end
273
273
  end
274
-
275
- def encode_255_uint16(value)
276
- if value < 253
277
- [value].pack("C")
278
- elsif value < 506
279
- [253, value - 253].pack("CC")
280
- elsif value < 65_536
281
- [254].pack("C") + [value].pack("n")
282
- else
283
- [255].pack("C") + [value - 506].pack("n")
284
- end
285
- end
286
274
  end
287
275
  end
288
276
  end
@@ -235,48 +235,20 @@ module Fontisan
235
235
  raise Fontisan::Error, "Invalid UIntBase128 encoding"
236
236
  end
237
237
 
238
- # Encode 255UInt16 value
239
- #
240
- # Used in transformed glyf table:
241
- # - 0-252: value itself (1 byte)
242
- # - 253: next byte + 253 (2 bytes)
243
- # - 254: next 2 bytes as big-endian (3 bytes)
244
- # - 255: next 2 bytes + 506 (3 bytes)
238
+ # Encode 255UInt16. Delegates to {UInt255} (single source of truth).
245
239
  #
246
240
  # @param value [Integer] Value to encode (0-65535)
247
241
  # @return [String] Binary encoded data
248
242
  def self.encode_255_uint16(value)
249
- if value < 253
250
- [value].pack("C")
251
- elsif value < 506
252
- [253, value - 253].pack("C*")
253
- elsif value < 65536
254
- [254].pack("C") + [value].pack("n")
255
- else
256
- [255].pack("C") + [value - 506].pack("n")
257
- end
243
+ UInt255.encode(value)
258
244
  end
259
245
 
260
- # Decode 255UInt16 from IO
246
+ # Decode 255UInt16 from IO. Delegates to {UInt255}.
261
247
  #
262
248
  # @param io [IO] Input stream
263
249
  # @return [Integer] Decoded value
264
250
  def self.decode_255_uint16(io)
265
- first = io.read(1)&.unpack1("C")
266
- return nil unless first
267
-
268
- case first
269
- when 0..252
270
- first
271
- when 253
272
- second = io.read(1)&.unpack1("C")
273
- 253 + second
274
- when 254
275
- io.read(2)&.unpack1("n")
276
- when 255
277
- value = io.read(2)&.unpack1("n")
278
- value + 506
279
- end
251
+ UInt255.decode(io)
280
252
  end
281
253
  end
282
254
  end
@@ -52,12 +52,6 @@ module Fontisan
52
52
  # the 'flags' field of the head table ... to indicate that a recreated
53
53
  # font file was subjected to lossless modifying transform."
54
54
  #
55
- # Also forces head.indexToLocFormat=0 when the glyf/loca tables are
56
- # transformed, because Chrome's OTS only accepts WOFF2 glyf
57
- # indexFormat=0 (see GlyfLocaTransform). For the reconstructed font
58
- # to be self-consistent, head.indexToLocFormat must match the loca
59
- # format that the decoder will produce (always short, format=0).
60
- #
61
55
  # Uses the model-driven Head BinData record so the bits are set via a
62
56
  # named attribute on a typed object, not via raw byte slicing.
63
57
  def self.mark_lossless_modifying!(table_data)
@@ -65,7 +59,6 @@ module Fontisan
65
59
 
66
60
  head = Tables::Head.read(table_data["head"])
67
61
  head.flags |= Tables::Head::FLAG_LOSSLESS_MODIFYING
68
- head.index_to_loc_format = 0
69
62
  table_data["head"] = head.to_binary_s
70
63
  end
71
64
  end
@@ -143,7 +143,7 @@ module Fontisan
143
143
  end_pts = []
144
144
  cumulative = -1
145
145
  num_contours.times do
146
- pts_of_contour = read_255_uint16(streams[:n_points])
146
+ pts_of_contour = UInt255.decode(streams[:n_points])
147
147
  cumulative += pts_of_contour
148
148
  end_pts << cumulative
149
149
  end
@@ -169,7 +169,7 @@ module Fontisan
169
169
  end
170
170
 
171
171
  # Step 4-5: read instructionLength from glyphStream, bytes from instructionStream.
172
- inst_len = read_255_uint16(streams[:glyph])
172
+ inst_len = UInt255.decode(streams[:glyph])
173
173
  instructions = streams[:instructions].read(inst_len)
174
174
 
175
175
  # Bbox: emit explicit bbox if bboxBitmap bit is set for this glyph,
@@ -209,7 +209,7 @@ module Fontisan
209
209
 
210
210
  instructions = String.new(encoding: Encoding::BINARY)
211
211
  if have_instructions
212
- inst_len = read_255_uint16(streams[:glyph])
212
+ inst_len = UInt255.decode(streams[:glyph])
213
213
  instructions = streams[:instructions].read(inst_len)
214
214
  end
215
215
 
@@ -252,26 +252,6 @@ module Fontisan
252
252
  bytes ? bytes.bytes : Array.new(n_bytes, 0)
253
253
  end
254
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
255
  def read_uint16_io(io)
276
256
  io.read(2)&.unpack1("n") || 0
277
257
  end
@@ -127,7 +127,7 @@ module Fontisan
127
127
  # nPoints stream: per-contour point counts (NOT endPtsOfContours).
128
128
  prev_end = -1
129
129
  end_pts.each do |ep|
130
- s.n_points << encode_255_uint16(ep - prev_end)
130
+ s.n_points << UInt255.encode(ep - prev_end)
131
131
  prev_end = ep
132
132
  end
133
133
 
@@ -157,7 +157,7 @@ module Fontisan
157
157
  end
158
158
 
159
159
  # glyphStream carries per-glyph instructionLength; bytes go to instructionStream
160
- s.glyph << encode_255_uint16(instructions.bytesize)
160
+ s.glyph << UInt255.encode(instructions.bytesize)
161
161
  s.instructions << instructions
162
162
 
163
163
  # Spec: simple glyphs omit bbox when it matches calculated bounds.
@@ -191,7 +191,7 @@ module Fontisan
191
191
  if have_instructions
192
192
  inst_len = io.read(2).unpack1("n")
193
193
  instructions = io.read(inst_len)
194
- s.glyph << encode_255_uint16(instructions.bytesize)
194
+ s.glyph << UInt255.encode(instructions.bytesize)
195
195
  s.instructions << instructions
196
196
  end
197
197
 
@@ -255,10 +255,11 @@ module Fontisan
255
255
  out << [0].pack("S>") # version
256
256
  out << [(s.has_overlap? ? 1 : 0)].pack("S>") # optionFlags
257
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>")
258
+ # Emit the source's indexFormat. The earlier "always 0" override
259
+ # (commit 5f7e32d) was based on a misdiagnosis Chrome's OTS
260
+ # accepts both 0 and 1, but short loca (0) can't address glyf
261
+ # offsets > 0x20000 bytes, so large fonts MUST use 1.
262
+ out << [@source_index_format].pack("S>")
262
263
  out << [s.n_contour.bytesize].pack("L>")
263
264
  out << [s.n_points.bytesize].pack("L>")
264
265
  out << [s.flags.bytesize].pack("L>")
@@ -313,18 +314,6 @@ module Fontisan
313
314
 
314
315
  [xs.min, ys.min, xs.max, ys.max]
315
316
  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
317
  end
329
318
  end
330
319
  end
@@ -46,7 +46,7 @@ glyf_lsbs = nil)
46
46
  if (flags & HMTX_FLAG_EXPLICIT_ADVANCE_WIDTHS).zero?
47
47
  # Proportional encoding - read deltas
48
48
  # First advance width is explicit
49
- first_advance = read_255_uint16(io)
49
+ first_advance = UInt255.decode(io)
50
50
  advance_widths << first_advance
51
51
 
52
52
  # Remaining are deltas from previous
@@ -57,7 +57,7 @@ glyf_lsbs = nil)
57
57
  else
58
58
  # Explicit advance widths in transformed format
59
59
  num_h_metrics.times do
60
- advance_widths << read_255_uint16(io)
60
+ advance_widths << UInt255.decode(io)
61
61
  end
62
62
  end
63
63
 
@@ -88,31 +88,6 @@ glyf_lsbs = nil)
88
88
  build_hmtx_table(advance_widths, lsbs, num_h_metrics, num_glyphs)
89
89
  end
90
90
 
91
- # Read variable-length 255UInt16 integer
92
- #
93
- # Format from WOFF2 spec:
94
- # - value < 253: one byte
95
- # - value == 253: 253 + next uint16
96
- # - value == 254: 253 * 2 + next uint16
97
- # - value == 255: 253 * 3 + next uint16
98
- #
99
- # @param io [StringIO] Input stream
100
- # @return [Integer] Decoded value
101
- def self.read_255_uint16(io)
102
- code = read_uint8(io)
103
-
104
- case code
105
- when 255
106
- 759 + read_uint16(io) # 253 * 3 + value
107
- when 254
108
- 506 + read_uint16(io) # 253 * 2 + value
109
- when 253
110
- 253 + read_uint16(io)
111
- else
112
- code
113
- end
114
- end
115
-
116
91
  # Build standard hmtx table format
117
92
  #
118
93
  # Standard hmtx format:
@@ -401,7 +401,7 @@ num_glyphs)
401
401
 
402
402
  # Write advance widths using proportional encoding
403
403
  # First advance width is explicit
404
- data << encode_255_uint16(advance_widths[0])
404
+ data << UInt255.encode(advance_widths[0])
405
405
 
406
406
  # Remaining advance widths as deltas
407
407
  (1...num_h_metrics).each do |i|
@@ -473,7 +473,7 @@ num_glyphs)
473
473
  prev_pt = -1
474
474
  glyph[:end_pts].each do |pt|
475
475
  delta = pt - prev_pt - 1
476
- n_points_stream << encode_255_uint16(delta)
476
+ n_points_stream << UInt255.encode(delta)
477
477
  prev_pt = pt
478
478
  end
479
479
 
@@ -488,7 +488,7 @@ num_glyphs)
488
488
  glyph[:bbox].each { |v| bbox_stream << [v].pack("n") }
489
489
 
490
490
  # Write instructions
491
- instruction_stream << encode_255_uint16(glyph[:instructions].bytesize)
491
+ instruction_stream << UInt255.encode(glyph[:instructions].bytesize)
492
492
  instruction_stream << glyph[:instructions] if glyph[:instructions].bytesize.positive?
493
493
 
494
494
  when :composite
@@ -569,22 +569,6 @@ num_glyphs)
569
569
  end
570
570
  end
571
571
 
572
- # Encode 255UInt16 value
573
- #
574
- # @param value [Integer] Value to encode
575
- # @return [String] Encoded bytes
576
- def encode_255_uint16(value)
577
- if value < 253
578
- [value].pack("C")
579
- elsif value < 506
580
- [253, value - 253].pack("CC")
581
- elsif value < 65536
582
- [254].pack("C") + [value].pack("n")
583
- else
584
- [255].pack("C") + [value - 506].pack("n")
585
- end
586
- end
587
-
588
572
  # Read signed 16-bit integer
589
573
  #
590
574
  # @param io [StringIO] Input stream
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+
5
+ module Fontisan
6
+ module Woff2
7
+ # 255UInt16 variable-length encoding per WOFF2 spec section 3.1.
8
+ #
9
+ # Single source of truth for encoding and decoding this data type. All
10
+ # other WOFF2 code MUST delegate to this module — the previous inline
11
+ # copies had the code-byte mapping inverted for values ≥ 253.
12
+ #
13
+ # Encoding table (per W3C spec pseudocode):
14
+ #
15
+ # value range | encoding | bytes
16
+ # ----------------|-----------------------|------
17
+ # 0 .. 252 | literal | 1
18
+ # 253 .. 505 | code 255 + (v - 253) | 2
19
+ # 506 .. 761 | code 254 + (v - 506) | 2
20
+ # 762 .. 65535 | code 253 + uint16 | 3
21
+ #
22
+ # Decoding (inverse):
23
+ #
24
+ # code byte | meaning
25
+ # ----------|--------------------------------
26
+ # 0 .. 252 | literal value
27
+ # 253 | read uint16 → value
28
+ # 254 | read 1 byte → value + 506
29
+ # 255 | read 1 byte → value + 253
30
+ module UInt255
31
+ WORD_CODE = 253
32
+ ONE_MORE_BYTE_CODE1 = 255 # adds 253
33
+ ONE_MORE_BYTE_CODE2 = 254 # adds 506
34
+
35
+ MAX_VALUE = 65_535
36
+
37
+ # Encode a value (0..65535) into a 1-3 byte binary string.
38
+ #
39
+ # @param value [Integer]
40
+ # @return [String] binary-encoded bytes
41
+ # @raise [ArgumentError] if value is out of range
42
+ def self.encode(value)
43
+ case value
44
+ when 0..252
45
+ [value].pack("C")
46
+ when 253..505
47
+ [ONE_MORE_BYTE_CODE1, value - 253].pack("C*")
48
+ when 506..761
49
+ [ONE_MORE_BYTE_CODE2, value - 506].pack("C*")
50
+ when 762..MAX_VALUE
51
+ [WORD_CODE].pack("C") + [value].pack("n")
52
+ else
53
+ raise ArgumentError,
54
+ "255UInt16 requires 0 <= value <= #{MAX_VALUE}, got #{value}"
55
+ end
56
+ end
57
+
58
+ # Decode a 255UInt16 value from an IO stream. Reads 1-3 bytes.
59
+ #
60
+ # @param io [IO, StringIO] input stream positioned at the code byte
61
+ # @return [Integer, nil] decoded value, or nil if the stream is empty
62
+ def self.decode(io)
63
+ code = io.read(1)&.unpack1("C")
64
+ return nil unless code
65
+
66
+ case code
67
+ when 0..252
68
+ code
69
+ when WORD_CODE
70
+ io.read(2)&.unpack1("n")
71
+ when ONE_MORE_BYTE_CODE2
72
+ 506 + (io.read(1)&.unpack1("C") || 0)
73
+ when ONE_MORE_BYTE_CODE1
74
+ 253 + (io.read(1)&.unpack1("C") || 0)
75
+ end
76
+ end
77
+
78
+ # Decode from a binary String at a given position. Returns the value
79
+ # and the new cursor position. Used by callers that index into a
80
+ # flat String rather than wrapping it in a StringIO.
81
+ #
82
+ # @param data [String] binary data
83
+ # @param pos [Integer] byte offset of the code byte
84
+ # @return [Array(Integer, Integer)] [value, new_pos]
85
+ def self.decode_at(data, pos)
86
+ code = data.getbyte(pos)
87
+ pos += 1
88
+ case code
89
+ when 0..252
90
+ [code, pos]
91
+ when WORD_CODE
92
+ [data[pos, 2].unpack1("n"), pos + 2]
93
+ when ONE_MORE_BYTE_CODE2
94
+ [506 + data.getbyte(pos), pos + 1]
95
+ when ONE_MORE_BYTE_CODE1
96
+ [253 + data.getbyte(pos), pos + 1]
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -4,6 +4,7 @@
4
4
 
5
5
  module Fontisan
6
6
  module Woff2
7
+ autoload :CollectionDecoder, "fontisan/woff2/collection_decoder"
7
8
  autoload :CollectionEncoder, "fontisan/woff2/collection_encoder"
8
9
  autoload :Directory, "fontisan/woff2/directory"
9
10
  autoload :EncoderRules, "fontisan/woff2/encoder_rules"
@@ -12,6 +13,7 @@ module Fontisan
12
13
  autoload :HmtxTransformer, "fontisan/woff2/hmtx_transformer"
13
14
  autoload :TableTransformer, "fontisan/woff2/table_transformer"
14
15
  autoload :TripletCodec, "fontisan/woff2/triplet_codec"
16
+ autoload :UInt255, "fontisan/woff2/uint255"
15
17
  autoload :Woff2Header, "fontisan/woff2/header"
16
18
  end
17
19
  end
@@ -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
@@ -732,22 +732,6 @@ module Fontisan
732
732
  self.class.read_uint_base128_from_io(io)
733
733
  end
734
734
 
735
- # Read 255UInt16 variable-length integer
736
- def read_255_uint16(io)
737
- code = io.read(1).unpack1("C")
738
-
739
- case code
740
- when 0..252
741
- code
742
- when 253
743
- 253 + io.read(1).unpack1("C")
744
- when 254
745
- io.read(2).unpack1("n")
746
- when 255
747
- io.read(2).unpack1("n") + 506
748
- end
749
- end
750
-
751
735
  # Calculate offset table fields
752
736
  def calculate_offset_table_fields(num_tables)
753
737
  entry_selector = (Math.log(num_tables) / Math.log(2)).floor
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
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fontisan
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.14
4
+ version: 0.4.16
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -432,6 +432,7 @@ files:
432
432
  - lib/fontisan/pipeline/strategies/preserve_strategy.rb
433
433
  - lib/fontisan/pipeline/transformation_pipeline.rb
434
434
  - lib/fontisan/pipeline/variation_resolver.rb
435
+ - lib/fontisan/sfnt_builder.rb
435
436
  - lib/fontisan/sfnt_font.rb
436
437
  - lib/fontisan/sfnt_table.rb
437
438
  - lib/fontisan/stitcher.rb
@@ -718,6 +719,7 @@ files:
718
719
  - lib/fontisan/version.rb
719
720
  - lib/fontisan/version.rb.orig
720
721
  - lib/fontisan/woff2.rb
722
+ - lib/fontisan/woff2/collection_decoder.rb
721
723
  - lib/fontisan/woff2/collection_encoder.rb
722
724
  - lib/fontisan/woff2/directory.rb
723
725
  - lib/fontisan/woff2/encoder_rules.rb
@@ -727,6 +729,8 @@ files:
727
729
  - lib/fontisan/woff2/hmtx_transformer.rb
728
730
  - lib/fontisan/woff2/table_transformer.rb
729
731
  - lib/fontisan/woff2/triplet_codec.rb
732
+ - lib/fontisan/woff2/uint255.rb
733
+ - lib/fontisan/woff2_collection.rb
730
734
  - lib/fontisan/woff2_font.rb
731
735
  - lib/fontisan/woff_font.rb
732
736
  - scripts/compare_stack_aware.rb