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 +4 -4
- data/lib/fontisan/commands/convert_command.rb +5 -1
- data/lib/fontisan/config/conversion_matrix.yml +36 -0
- data/lib/fontisan/converters/collection_converter.rb +36 -3
- data/lib/fontisan/font_loader.rb +17 -1
- data/lib/fontisan/sfnt_builder.rb +66 -0
- data/lib/fontisan/version.rb +1 -1
- data/lib/fontisan/woff2/collection_decoder.rb +226 -0
- data/lib/fontisan/woff2.rb +1 -0
- data/lib/fontisan/woff2_collection.rb +125 -0
- data/lib/fontisan.rb +2 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 422936ab5bbb785c6030c16efa61be31edd4c9e251828eabaa29e572a570bbb5
|
|
4
|
+
data.tar.gz: ad05bb618741fb0958e1be540af840debb9ffa4d92be469930f8e11f6ccd63fb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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,
|
|
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 :
|
|
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
|
data/lib/fontisan/font_loader.rb
CHANGED
|
@@ -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
|
|
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
|
data/lib/fontisan/version.rb
CHANGED
|
@@ -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
|
data/lib/fontisan/woff2.rb
CHANGED
|
@@ -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.
|
|
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
|