fontisan 0.4.14 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 47e074b8c2bbdcfa377c95733f56fdd978dfa4d824dbae3e29f3bd75935dddd6
4
- data.tar.gz: a1a348db0bd6dde039c0a43f62ac3ab43792cc7414e995a72c47798663a1cd59
3
+ metadata.gz: 422936ab5bbb785c6030c16efa61be31edd4c9e251828eabaa29e572a570bbb5
4
+ data.tar.gz: ad05bb618741fb0958e1be540af840debb9ffa4d92be469930f8e11f6ccd63fb
5
5
  SHA512:
6
- metadata.gz: e8917cc8c995f0ca2cbdc9e94ad5d5d629ab95d8809b7eb728d360f0743a2b26e5cfd059254497c5a1df10ddb3b53258ee703c441a106d4febd42b26ea3b6e08
7
- data.tar.gz: d35a8138d7cdbc5389b619ed757863cc0d61e1d8d025655d0fb6736a21815f04d24802f24236fdffdc8d430192db79798f77f7b9343193e0c16035d2be8fc5eb
6
+ metadata.gz: f9d938ed00f8bff70e0fe20b06751134faa1cb9c0f536467f1259866876d060f170ac94e0a89818a4f77662d873542c9a6bfb09d4da0de5577c3bda2a33f4efa
7
+ data.tar.gz: e6d7679235dab59a1a26bf5948f0651d147b12fec4218c000626ff0291fbec33593cef8ba0466e189d63c20f6cdc0d6c53b88626f47178656a272248e8e12872
@@ -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
@@ -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.15"
5
5
  end
@@ -0,0 +1,226 @@
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 = read_255_uint16(pos)
137
+ font_entries = Array.new(num_fonts) do
138
+ num_tables_f, pos2 = read_255_uint16(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 = read_255_uint16(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
+
205
+ def read_255_uint16(pos)
206
+ code = @data.getbyte(pos)
207
+ pos += 1
208
+ case code
209
+ when 0..252 then [code, pos]
210
+ when 253
211
+ v = @data.getbyte(pos)
212
+ pos += 1
213
+ [253 + v, pos]
214
+ when 254
215
+ v = @data[pos, 2].unpack1("n")
216
+ pos += 2
217
+ [v, pos]
218
+ when 255
219
+ v = @data[pos, 2].unpack1("n")
220
+ pos += 2
221
+ [v + 506, pos]
222
+ end
223
+ end
224
+ end
225
+ end
226
+ 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"
@@ -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
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.15
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,7 @@ 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_collection.rb
730
733
  - lib/fontisan/woff2_font.rb
731
734
  - lib/fontisan/woff_font.rb
732
735
  - scripts/compare_stack_aware.rb