fontisan 0.4.13 → 0.4.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.adoc +1 -1
- data/docs/WOFF_WOFF2_FORMATS.adoc +73 -0
- 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/woff_writer.rb +94 -59
- 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/collection_encoder.rb +288 -0
- data/lib/fontisan/woff2/encoder_rules.rb +8 -1
- data/lib/fontisan/woff2/glyf_loca_reconstruct.rb +400 -0
- data/lib/fontisan/woff2/glyf_loca_transform.rb +12 -5
- data/lib/fontisan/woff2.rb +3 -1
- data/lib/fontisan/woff2_collection.rb +125 -0
- data/lib/fontisan/woff2_font.rb +23 -40
- data/lib/fontisan.rb +2 -0
- metadata +7 -3
- data/lib/fontisan/woff2/glyf_transformer.rb +0 -716
|
@@ -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
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stringio"
|
|
4
|
+
require "digest/sha2"
|
|
5
|
+
|
|
6
|
+
module Fontisan
|
|
7
|
+
module Woff2
|
|
8
|
+
# Encodes a TTC/OTC font collection into WOFF2 collection format per
|
|
9
|
+
# spec section 4.2.
|
|
10
|
+
#
|
|
11
|
+
# Output layout:
|
|
12
|
+
# [WOFF2Header (48 bytes, flavor = 'ttcf')]
|
|
13
|
+
# [TableDirectory] # one entry per unique table
|
|
14
|
+
# [CollectionDirectory] # CollectionHeader + N CollectionFontEntry
|
|
15
|
+
# [CompressedFontData] # single brotli stream of all tables
|
|
16
|
+
#
|
|
17
|
+
# Each font references tables via indices in its CollectionFontEntry.
|
|
18
|
+
# Identical tables across fonts share a single directory entry.
|
|
19
|
+
#
|
|
20
|
+
# Per spec section 5.5, glyf/loca pairs from the same font MUST be
|
|
21
|
+
# adjacent (loca immediately follows glyf) in the directory.
|
|
22
|
+
class CollectionEncoder
|
|
23
|
+
TTC_FLAVOR = 0x74746366 # 'ttcf'
|
|
24
|
+
|
|
25
|
+
# @param brotli_quality [Integer] 0-11
|
|
26
|
+
def initialize(brotli_quality: 11)
|
|
27
|
+
@brotli_quality = brotli_quality
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Encode an array of fonts as a WOFF2 collection.
|
|
31
|
+
#
|
|
32
|
+
# @param fonts [Array<TrueTypeFont, OpenTypeFont>] Source fonts
|
|
33
|
+
# @return [String] WOFF2 collection binary
|
|
34
|
+
def encode_fonts(fonts)
|
|
35
|
+
raise ArgumentError, "fonts cannot be empty" if fonts.nil? || fonts.empty?
|
|
36
|
+
|
|
37
|
+
prepared = fonts.map.with_index { |font, i| prepare_font(font, i) }
|
|
38
|
+
_, font_to_ckeys = deduplicate(prepared)
|
|
39
|
+
layout = build_layout(prepared, font_to_ckeys)
|
|
40
|
+
compressed = compress_stream(layout)
|
|
41
|
+
assemble(fonts:, layout:, font_to_ckeys:, compressed:)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
# Prepare a single font: collect tables, apply encoder rules, run
|
|
47
|
+
# glyf/loca transform. Returns a Hash:
|
|
48
|
+
# {index:, tables: {tag ⇒ bytes}, loca_orig_length: (or nil)}
|
|
49
|
+
def prepare_font(font, index)
|
|
50
|
+
tables = {}
|
|
51
|
+
font.table_names.each do |tag|
|
|
52
|
+
data = font.table_data[tag]
|
|
53
|
+
next unless data && !data.empty?
|
|
54
|
+
|
|
55
|
+
tables[tag] = data
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
EncoderRules.apply!(tables)
|
|
59
|
+
loca_orig_length = apply_glyf_loca_transform!(tables, font)
|
|
60
|
+
|
|
61
|
+
{ index:, tables:, loca_orig_length: }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Apply the glyf/loca transform in place. Removes "loca" from
|
|
65
|
+
# `tables`. Returns the original loca length, or nil if not applied.
|
|
66
|
+
def apply_glyf_loca_transform!(tables, font)
|
|
67
|
+
return nil unless tables.key?("glyf") && tables.key?("loca")
|
|
68
|
+
|
|
69
|
+
maxp = font.table("maxp")
|
|
70
|
+
head = font.table("head")
|
|
71
|
+
return nil unless maxp && head
|
|
72
|
+
|
|
73
|
+
loca_orig_length = tables["loca"].bytesize
|
|
74
|
+
tables["glyf"] = GlyfLocaTransform.new(
|
|
75
|
+
glyf_data: tables["glyf"],
|
|
76
|
+
loca_data: tables["loca"],
|
|
77
|
+
num_glyphs: maxp.num_glyphs,
|
|
78
|
+
index_format: head.index_to_loc_format,
|
|
79
|
+
).transform
|
|
80
|
+
tables.delete("loca")
|
|
81
|
+
loca_orig_length
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Deduplicate identical tables across fonts. Returns:
|
|
85
|
+
# canonical: ckey → {tag, data}
|
|
86
|
+
# font_to_ckeys: Array (per font) of {tag ⇒ ckey}
|
|
87
|
+
def deduplicate(prepared)
|
|
88
|
+
canonical = {}
|
|
89
|
+
font_to_ckeys = Array.new(prepared.size) { {} }
|
|
90
|
+
seen = {} # "tag:sha256" → ckey
|
|
91
|
+
|
|
92
|
+
prepared.each do |pf|
|
|
93
|
+
pf[:tables].each do |tag, data|
|
|
94
|
+
cache_key = "#{tag}:#{Digest::SHA256.digest(data)}"
|
|
95
|
+
if (existing = seen[cache_key])
|
|
96
|
+
font_to_ckeys[pf[:index]][tag] = existing
|
|
97
|
+
else
|
|
98
|
+
ckey = "#{tag}@#{pf[:index]}"
|
|
99
|
+
canonical[ckey] = { tag:, data: }
|
|
100
|
+
seen[cache_key] = ckey
|
|
101
|
+
font_to_ckeys[pf[:index]][tag] = ckey
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
[canonical, font_to_ckeys]
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Walk fonts in order; for each font, walk tags in spec-mandated
|
|
110
|
+
# order (glyf → loca placeholder → rest alphabetical). Each unique
|
|
111
|
+
# ckey appears once in the layout.
|
|
112
|
+
#
|
|
113
|
+
# Loca placeholders are keyed by the glyf ckey they pair with, so
|
|
114
|
+
# fonts that share a transformed glyf (via deduplication) also
|
|
115
|
+
# share the loca placeholder. Per spec section 5.5, loca MUST
|
|
116
|
+
# immediately follow glyf in the directory.
|
|
117
|
+
def build_layout(prepared, font_to_ckeys)
|
|
118
|
+
seen = {}
|
|
119
|
+
entries = []
|
|
120
|
+
|
|
121
|
+
prepared.each do |pf|
|
|
122
|
+
tags = pf[:tables].keys
|
|
123
|
+
ordered = order_tags_for_font(tags, has_loca: pf[:loca_orig_length])
|
|
124
|
+
|
|
125
|
+
ordered.each do |tag|
|
|
126
|
+
if tag == "loca_placeholder"
|
|
127
|
+
# Handled below as part of the glyf branch.
|
|
128
|
+
next
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
ckey = font_to_ckeys[pf[:index]][tag]
|
|
132
|
+
unless seen[ckey]
|
|
133
|
+
data = pf[:tables][tag]
|
|
134
|
+
seen[ckey] = entries.size
|
|
135
|
+
entries << {
|
|
136
|
+
ckey:, tag:,
|
|
137
|
+
data:,
|
|
138
|
+
orig_length: data.bytesize,
|
|
139
|
+
transform_length: (tag == "glyf" ? data.bytesize : nil)
|
|
140
|
+
}
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# loca placeholder immediately after glyf. Tied to glyf's ckey
|
|
144
|
+
# so fonts that share a glyf also share its loca placeholder.
|
|
145
|
+
if tag == "glyf" && pf[:loca_orig_length]
|
|
146
|
+
placeholder_key = "loca_placeholder_for:#{ckey}"
|
|
147
|
+
unless seen[placeholder_key]
|
|
148
|
+
seen[placeholder_key] = entries.size
|
|
149
|
+
entries << {
|
|
150
|
+
ckey: placeholder_key,
|
|
151
|
+
tag: "loca",
|
|
152
|
+
data: nil,
|
|
153
|
+
orig_length: pf[:loca_orig_length],
|
|
154
|
+
transform_length: 0,
|
|
155
|
+
}
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
entries
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Per-font tag order: glyf first, loca placeholder immediately after,
|
|
165
|
+
# rest alphabetical. Spec section 5.5.
|
|
166
|
+
def order_tags_for_font(tags, has_loca:)
|
|
167
|
+
return tags.sort unless has_loca
|
|
168
|
+
|
|
169
|
+
%w[glyf loca_placeholder] + (tags - ["glyf"]).sort
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Concatenate unique table data in directory order. Placeholder
|
|
173
|
+
# entries (loca after transform) contribute 0 bytes per spec 5.3.
|
|
174
|
+
def compress_stream(layout)
|
|
175
|
+
combined = String.new(encoding: Encoding::BINARY)
|
|
176
|
+
layout.each do |e|
|
|
177
|
+
combined << e[:data] if e[:data]
|
|
178
|
+
end
|
|
179
|
+
Utilities::BrotliWrapper.compress(combined, quality: @brotli_quality)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def assemble(fonts:, layout:, font_to_ckeys:, compressed:)
|
|
183
|
+
io = StringIO.new
|
|
184
|
+
io.set_encoding(Encoding::BINARY)
|
|
185
|
+
|
|
186
|
+
io.write("\x00" * 48) # reserve header
|
|
187
|
+
layout.each { |e| write_table_directory_entry(io, e) }
|
|
188
|
+
|
|
189
|
+
ckey_to_index = layout.each_with_index.to_h { |e, i| [e[:ckey], i] }
|
|
190
|
+
write_collection_directory(io, fonts, font_to_ckeys, ckey_to_index)
|
|
191
|
+
|
|
192
|
+
io.write(compressed)
|
|
193
|
+
|
|
194
|
+
# Pad to a 4-byte boundary. The W3C reference decoder validates that
|
|
195
|
+
# the file's `length` field matches the actual byte count, and many
|
|
196
|
+
# decoders expect a 4-byte-aligned file length for collections.
|
|
197
|
+
pad = (4 - (io.pos % 4)) % 4
|
|
198
|
+
io.write("\x00" * pad) if pad.positive?
|
|
199
|
+
total_size = io.size
|
|
200
|
+
|
|
201
|
+
io.pos = 0
|
|
202
|
+
write_header(io, num_tables: layout.size,
|
|
203
|
+
total_compressed_size: compressed.bytesize, total_size:)
|
|
204
|
+
io.string
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def write_header(io, num_tables:, total_compressed_size:, total_size:)
|
|
208
|
+
io.write([Woff2::Woff2Header::SIGNATURE].pack("N"))
|
|
209
|
+
io.write([TTC_FLAVOR].pack("N"))
|
|
210
|
+
io.write([total_size].pack("N"))
|
|
211
|
+
io.write([num_tables].pack("n"))
|
|
212
|
+
io.write([0].pack("n"))
|
|
213
|
+
io.write([0].pack("N")) # totalSfntSize (ref only)
|
|
214
|
+
io.write([total_compressed_size].pack("N"))
|
|
215
|
+
io.write([1].pack("n")) # majorVersion
|
|
216
|
+
io.write([0].pack("n")) # minorVersion
|
|
217
|
+
io.write([0].pack("N")) # metaOffset
|
|
218
|
+
io.write([0].pack("N")) # metaLength
|
|
219
|
+
io.write([0].pack("N")) # metaOrigLength
|
|
220
|
+
io.write([0].pack("N")) # privOffset
|
|
221
|
+
io.write([0].pack("N")) # privLength
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def write_table_directory_entry(io, entry)
|
|
225
|
+
dir_entry = Directory::Entry.new
|
|
226
|
+
dir_entry.tag = entry[:tag]
|
|
227
|
+
dir_entry.orig_length = entry[:orig_length]
|
|
228
|
+
dir_entry.transform_length = entry[:transform_length]
|
|
229
|
+
flags = dir_entry.calculate_flags
|
|
230
|
+
io.write([flags].pack("C"))
|
|
231
|
+
io.write(entry[:tag].ljust(4, "\x00")) unless dir_entry.known_tag?
|
|
232
|
+
io.write(Directory.encode_uint_base128(entry[:orig_length]))
|
|
233
|
+
return unless dir_entry.transformed?
|
|
234
|
+
|
|
235
|
+
io.write(Directory.encode_uint_base128(entry[:transform_length] || 0))
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def write_collection_directory(io, fonts, font_to_ckeys, ckey_to_index)
|
|
239
|
+
# CollectionHeader
|
|
240
|
+
io.write([0x00010000].pack("N")) # version 1.0
|
|
241
|
+
io.write(encode_255_uint16(fonts.size))
|
|
242
|
+
|
|
243
|
+
# One CollectionFontEntry per font
|
|
244
|
+
fonts.each_with_index do |font, fi|
|
|
245
|
+
font_keys = font_to_ckeys[fi]
|
|
246
|
+
glyf_ckey = font_keys["glyf"]
|
|
247
|
+
placeholder_key = glyf_ckey && "loca_placeholder_for:#{glyf_ckey}"
|
|
248
|
+
has_placeholder = ckey_to_index.key?(placeholder_key)
|
|
249
|
+
# CollectionFontEntry index list is in font's natural alphabetical
|
|
250
|
+
# order (including "loca" if a placeholder exists). The glyf/loca
|
|
251
|
+
# adjacency constraint applies to the table directory, not the
|
|
252
|
+
# entry index list per spec section 5.5.
|
|
253
|
+
ordered = font_keys.keys
|
|
254
|
+
ordered << "loca" if has_placeholder
|
|
255
|
+
ordered = ordered.sort
|
|
256
|
+
|
|
257
|
+
io.write(encode_255_uint16(ordered.size))
|
|
258
|
+
io.write([font_flavor(font)].pack("N"))
|
|
259
|
+
ordered.each do |tag|
|
|
260
|
+
ckey = font_keys[tag] || placeholder_key
|
|
261
|
+
index = ckey_to_index.fetch(ckey)
|
|
262
|
+
io.write(encode_255_uint16(index))
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def font_flavor(font)
|
|
268
|
+
if font.has_table?("CFF ") || font.has_table?("CFF2")
|
|
269
|
+
Constants::SFNT_VERSION_OTTO
|
|
270
|
+
else
|
|
271
|
+
Constants::SFNT_VERSION_TRUETYPE
|
|
272
|
+
end
|
|
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
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
|
@@ -52,13 +52,20 @@ 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
|
-
#
|
|
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
|
+
# Uses the model-driven Head BinData record so the bits are set via a
|
|
56
62
|
# named attribute on a typed object, not via raw byte slicing.
|
|
57
63
|
def self.mark_lossless_modifying!(table_data)
|
|
58
64
|
return unless table_data.key?("head")
|
|
59
65
|
|
|
60
66
|
head = Tables::Head.read(table_data["head"])
|
|
61
67
|
head.flags |= Tables::Head::FLAG_LOSSLESS_MODIFYING
|
|
68
|
+
head.index_to_loc_format = 0
|
|
62
69
|
table_data["head"] = head.to_binary_s
|
|
63
70
|
end
|
|
64
71
|
end
|