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 +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/converters/woff2_encoder.rb +30 -9
- 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 +206 -0
- data/lib/fontisan/woff2/collection_encoder.rb +3 -15
- data/lib/fontisan/woff2/directory.rb +4 -32
- data/lib/fontisan/woff2/encoder_rules.rb +0 -7
- data/lib/fontisan/woff2/glyf_loca_reconstruct.rb +3 -23
- data/lib/fontisan/woff2/glyf_loca_transform.rb +8 -19
- data/lib/fontisan/woff2/hmtx_transformer.rb +2 -27
- data/lib/fontisan/woff2/table_transformer.rb +3 -19
- data/lib/fontisan/woff2/uint255.rb +101 -0
- data/lib/fontisan/woff2.rb +2 -0
- data/lib/fontisan/woff2_collection.rb +125 -0
- data/lib/fontisan/woff2_font.rb +0 -16
- data/lib/fontisan.rb +2 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1d27d25052549a912eab7392f8f0b34e7917fad9e9344d178b853bfe5a9fb476
|
|
4
|
+
data.tar.gz: 7fe03e99020f0a628efa4bac17236c455143345dbe4aef1dd1df5a8890cf00ad
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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,
|
|
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
|
|
@@ -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
|
|
255
|
+
index_format:,
|
|
248
256
|
).transform
|
|
249
257
|
|
|
250
|
-
|
|
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(
|
|
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
|
|
318
|
-
# glyf size; transformLength is the
|
|
319
|
-
# glyf stream per spec section 5.1.
|
|
320
|
-
def build_glyf_entry(
|
|
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 =
|
|
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
|
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,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(
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 <<
|
|
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 <<
|
|
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 <<
|
|
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
|
-
#
|
|
259
|
-
#
|
|
260
|
-
#
|
|
261
|
-
|
|
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 =
|
|
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 <<
|
|
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 <<
|
|
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 <<
|
|
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 <<
|
|
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
|
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"
|
|
@@ -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
|
data/lib/fontisan/woff2_font.rb
CHANGED
|
@@ -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.
|
|
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
|