fontisan 0.4.12 → 0.4.14
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/converters/woff2_encoder.rb +147 -242
- data/lib/fontisan/converters/woff_writer.rb +94 -59
- data/lib/fontisan/sfnt_font.rb +7 -2
- data/lib/fontisan/tables/head.rb +10 -0
- data/lib/fontisan/version.rb +1 -1
- data/lib/fontisan/woff2/collection_encoder.rb +288 -0
- data/lib/fontisan/woff2/directory.rb +37 -40
- data/lib/fontisan/woff2/encoder_rules.rb +73 -0
- data/lib/fontisan/woff2/glyf_loca_reconstruct.rb +400 -0
- data/lib/fontisan/woff2/glyf_loca_transform.rb +330 -0
- data/lib/fontisan/woff2/triplet_codec.rb +181 -0
- data/lib/fontisan/woff2.rb +5 -1
- data/lib/fontisan/woff2_font.rb +23 -40
- data/lib/fontisan/woff_font.rb +5 -2
- metadata +7 -3
- data/lib/fontisan/woff2/glyf_transformer.rb +0 -716
|
@@ -12,7 +12,8 @@ module Fontisan
|
|
|
12
12
|
#
|
|
13
13
|
# - `zlib_level` (0–9) — zlib compression level
|
|
14
14
|
# - `uncompressed` (bool) — store tables uncompressed (legal per WOFF 1.0
|
|
15
|
-
# §5.1; `compLength == origLength`)
|
|
15
|
+
# §5.1; `compLength == origLength`). Metadata is still compressed per
|
|
16
|
+
# spec §7 ("it is never stored in uncompressed form").
|
|
16
17
|
# - `compression_threshold` (bytes) — skip compression for tables smaller
|
|
17
18
|
# than N bytes (rarely needed; keeps tiny tables uncompressed)
|
|
18
19
|
# - `metadata_xml` (string) — optional metadata block
|
|
@@ -119,24 +120,35 @@ module Fontisan
|
|
|
119
120
|
|
|
120
121
|
# Write font to WOFF binary.
|
|
121
122
|
#
|
|
123
|
+
# Layout per WOFF 1.0 spec:
|
|
124
|
+
# [header (44)] [table directory (20 × N)] [font tables, 4-byte aligned]
|
|
125
|
+
# [optional metadata, 4-byte aligned] [optional private data, 4-byte aligned]
|
|
126
|
+
#
|
|
127
|
+
# Each font table is padded with 0-3 null bytes to a 4-byte boundary
|
|
128
|
+
# (spec §5/§6: "Font data tables in the WOFF file have the same
|
|
129
|
+
# requirement: they MUST begin on 4-byte boundaries and be zero-padded
|
|
130
|
+
# to the next 4-byte boundary"). Padding aligns the next block.
|
|
131
|
+
#
|
|
122
132
|
# @param font [TrueTypeFont, OpenTypeFont] Source font
|
|
123
133
|
# @param zlib_level [Integer] 0–9
|
|
124
|
-
# @param uncompressed [Boolean] skip zlib; store as-is
|
|
134
|
+
# @param uncompressed [Boolean] skip zlib; store tables as-is
|
|
125
135
|
# @param compression_threshold [Integer] skip compression below N bytes
|
|
126
|
-
# @param metadata [String, nil] optional metadata XML
|
|
136
|
+
# @param metadata [String, nil] optional metadata XML (always compressed
|
|
137
|
+
# per spec §7 — "it is never stored in uncompressed form")
|
|
127
138
|
# @param private_data [String, nil] optional private data
|
|
128
139
|
# @return [Hash{Symbol => String}] `{ woff_binary: <bytes> }`
|
|
129
140
|
def write_font(font, zlib_level:, uncompressed:, compression_threshold:,
|
|
130
141
|
metadata: nil, private_data: nil)
|
|
131
|
-
|
|
142
|
+
tables = collect_tables_data(font)
|
|
132
143
|
compressed_tables = compress_tables(
|
|
133
|
-
|
|
144
|
+
tables,
|
|
134
145
|
zlib_level: uncompressed ? 0 : zlib_level,
|
|
135
146
|
skip_compression: uncompressed,
|
|
136
147
|
compression_threshold: compression_threshold,
|
|
137
148
|
)
|
|
138
|
-
|
|
139
|
-
|
|
149
|
+
# Per spec §7, metadata MUST always be zlib-compressed regardless of
|
|
150
|
+
# the per-table `uncompressed` flag.
|
|
151
|
+
compressed_metadata = compress_metadata(metadata, zlib_level:)
|
|
140
152
|
binary = build_woff_file(compressed_tables, font, compressed_metadata,
|
|
141
153
|
private_data)
|
|
142
154
|
{ woff_binary: binary }
|
|
@@ -152,14 +164,16 @@ module Fontisan
|
|
|
152
164
|
options.select { |k, _| names.include?(k.to_sym) }
|
|
153
165
|
end
|
|
154
166
|
|
|
155
|
-
# Collect all table data from font
|
|
167
|
+
# Collect all table data from font in input-font order (spec §6: tables
|
|
168
|
+
# MUST be stored in the same order as the well-formed input font).
|
|
156
169
|
#
|
|
157
170
|
# @param font [TrueTypeFont, OpenTypeFont]
|
|
158
171
|
# @return [Hash<String, String>]
|
|
159
172
|
def collect_tables_data(font)
|
|
160
|
-
font.table_names.
|
|
161
|
-
|
|
162
|
-
|
|
173
|
+
font.table_names.each_with_object({}) do |tag, h|
|
|
174
|
+
data = font.table_data[tag]
|
|
175
|
+
h[tag] = data if data
|
|
176
|
+
end
|
|
163
177
|
end
|
|
164
178
|
|
|
165
179
|
# Compress tables with zlib (or skip compression entirely).
|
|
@@ -197,71 +211,92 @@ module Fontisan
|
|
|
197
211
|
end
|
|
198
212
|
end
|
|
199
213
|
|
|
200
|
-
# Compress metadata with zlib.
|
|
214
|
+
# Compress metadata with zlib. Per spec §7, metadata MUST always be
|
|
215
|
+
# zlib-compressed — `skip_compression` is intentionally not exposed
|
|
216
|
+
# here. The zlib output is used even when it is larger than the input
|
|
217
|
+
# (e.g. tiny XML payloads where zlib header+checksum overhead exceeds
|
|
218
|
+
# any compression gain); the spec mandates compression, not optimality.
|
|
219
|
+
# Returns nil if no metadata was supplied.
|
|
201
220
|
#
|
|
202
221
|
# @param metadata [String, nil]
|
|
203
222
|
# @param zlib_level [Integer]
|
|
204
|
-
# @param skip_compression [Boolean]
|
|
205
223
|
# @return [Hash, nil]
|
|
206
|
-
def compress_metadata(metadata, zlib_level
|
|
224
|
+
def compress_metadata(metadata, zlib_level:)
|
|
207
225
|
return nil unless metadata
|
|
208
226
|
|
|
209
227
|
original_length = metadata.bytesize
|
|
210
|
-
if skip_compression
|
|
211
|
-
return {
|
|
212
|
-
original_data: metadata,
|
|
213
|
-
compressed_data: metadata,
|
|
214
|
-
original_length: original_length,
|
|
215
|
-
compressed_length: original_length,
|
|
216
|
-
}
|
|
217
|
-
end
|
|
218
|
-
|
|
219
228
|
compressed = Zlib::Deflate.deflate(metadata, zlib_level)
|
|
220
|
-
use_compressed = compressed.bytesize < original_length
|
|
221
229
|
{
|
|
222
230
|
original_data: metadata,
|
|
223
|
-
compressed_data:
|
|
231
|
+
compressed_data: compressed,
|
|
224
232
|
original_length: original_length,
|
|
225
|
-
compressed_length:
|
|
233
|
+
compressed_length: compressed.bytesize,
|
|
226
234
|
}
|
|
227
235
|
end
|
|
228
236
|
|
|
237
|
+
# Padding bytes needed to bring `length` up to a 4-byte boundary.
|
|
238
|
+
def padding_to_4(length)
|
|
239
|
+
(4 - (length % 4)) % 4
|
|
240
|
+
end
|
|
241
|
+
|
|
229
242
|
# Assemble complete WOFF binary.
|
|
230
243
|
#
|
|
231
|
-
#
|
|
244
|
+
# Computes the full layout (offsets + padding) up front so the header
|
|
245
|
+
# fields (metaOffset, privOffset) point to the correct locations, then
|
|
246
|
+
# emits header → directory → tables → optional metadata → optional
|
|
247
|
+
# private data.
|
|
248
|
+
#
|
|
249
|
+
# @param compressed_tables [Hash<String, Hash>] in input-font order
|
|
232
250
|
# @param font [TrueTypeFont, OpenTypeFont]
|
|
233
251
|
# @param compressed_metadata [Hash, nil]
|
|
234
252
|
# @param private_data [String, nil]
|
|
235
253
|
# @return [String]
|
|
236
254
|
def build_woff_file(compressed_tables, font, compressed_metadata,
|
|
237
255
|
private_data)
|
|
238
|
-
io = StringIO.new
|
|
239
|
-
io.set_encoding(Encoding::BINARY)
|
|
240
|
-
|
|
241
256
|
header_size = 44
|
|
242
257
|
num_tables = compressed_tables.length
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
258
|
+
data_start = header_size + (num_tables * 20)
|
|
259
|
+
|
|
260
|
+
# Lay out tables in input-font order with 4-byte alignment padding.
|
|
261
|
+
# Each entry: { tag:, info:, offset:, pad_bytes: }
|
|
262
|
+
entries = []
|
|
263
|
+
cursor = data_start
|
|
264
|
+
compressed_tables.each do |tag, info|
|
|
265
|
+
pad = padding_to_4(info[:compressed_length])
|
|
266
|
+
entries << { tag:, info:, offset: cursor, pad_bytes: "\x00" * pad }
|
|
267
|
+
cursor += info[:compressed_length] + pad
|
|
249
268
|
end
|
|
250
|
-
|
|
269
|
+
tables_end = cursor
|
|
270
|
+
|
|
271
|
+
metadata_size = compressed_metadata ? compressed_metadata[:compressed_length] : 0
|
|
272
|
+
# metaOffset/privOffset are 0 when their block is absent (spec §4:
|
|
273
|
+
# offsets to optional blocks; convention is 0 for "not present").
|
|
274
|
+
metadata_offset = compressed_metadata ? tables_end : 0
|
|
275
|
+
metadata_end = tables_end + metadata_size
|
|
276
|
+
|
|
251
277
|
private_size = private_data ? private_data.bytesize : 0
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
278
|
+
private_offset = private_data ? metadata_end : 0
|
|
279
|
+
total_size = metadata_end + private_size
|
|
280
|
+
|
|
281
|
+
# totalSfntSize: reconstructed SFNT size with per-table 4-byte padding.
|
|
282
|
+
# spec §4: "Total size needed for the uncompressed font data, including
|
|
283
|
+
# the sfnt header, directory, and font tables (including padding)."
|
|
284
|
+
sfnt_header_size = 12
|
|
285
|
+
sfnt_dir_size = num_tables * 16
|
|
286
|
+
sfnt_tables_size = compressed_tables.values.sum do |t|
|
|
287
|
+
t[:original_length] + padding_to_4(t[:original_length])
|
|
288
|
+
end
|
|
289
|
+
total_sfnt_size = sfnt_header_size + sfnt_dir_size + sfnt_tables_size
|
|
257
290
|
|
|
291
|
+
io = StringIO.new
|
|
292
|
+
io.set_encoding(Encoding::BINARY)
|
|
258
293
|
write_woff_header(
|
|
259
294
|
io, font, total_size, total_sfnt_size, num_tables,
|
|
260
295
|
compressed_metadata, metadata_offset, metadata_size,
|
|
261
296
|
private_offset, private_size
|
|
262
297
|
)
|
|
263
|
-
write_table_directory(io,
|
|
264
|
-
write_compressed_table_data(io,
|
|
298
|
+
write_table_directory(io, entries)
|
|
299
|
+
write_compressed_table_data(io, entries)
|
|
265
300
|
write_metadata(io, compressed_metadata) if compressed_metadata
|
|
266
301
|
write_private_data(io, private_data) if private_data
|
|
267
302
|
|
|
@@ -293,25 +328,25 @@ module Fontisan
|
|
|
293
328
|
io.write([private_size].pack("N")) # privLength
|
|
294
329
|
end
|
|
295
330
|
|
|
296
|
-
# Write table directory entries (20 bytes each).
|
|
297
|
-
def write_table_directory(io,
|
|
298
|
-
|
|
299
|
-
compressed_tables.sort_by { |tag, _| tag }.each do |tag, info|
|
|
331
|
+
# Write table directory entries (20 bytes each), one per laid-out entry.
|
|
332
|
+
def write_table_directory(io, entries)
|
|
333
|
+
entries.each do |e|
|
|
300
334
|
checksum = Utilities::ChecksumCalculator
|
|
301
|
-
.calculate_table_checksum(info[:original_data])
|
|
302
|
-
io.write(tag)
|
|
303
|
-
io.write([
|
|
304
|
-
io.write([info[:compressed_length]].pack("N"))
|
|
305
|
-
io.write([info[:original_length]].pack("N"))
|
|
306
|
-
io.write([checksum].pack("N"))
|
|
307
|
-
current_offset += info[:compressed_length]
|
|
335
|
+
.calculate_table_checksum(e[:info][:original_data])
|
|
336
|
+
io.write(e[:tag]) # tag
|
|
337
|
+
io.write([e[:offset]].pack("N")) # offset
|
|
338
|
+
io.write([e[:info][:compressed_length]].pack("N")) # compLength
|
|
339
|
+
io.write([e[:info][:original_length]].pack("N")) # origLength
|
|
340
|
+
io.write([checksum].pack("N")) # origChecksum
|
|
308
341
|
end
|
|
309
342
|
end
|
|
310
343
|
|
|
311
|
-
# Write
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
344
|
+
# Write each table's compressed data followed by 4-byte alignment
|
|
345
|
+
# padding (spec §5/§6: tables MUST be zero-padded to next boundary).
|
|
346
|
+
def write_compressed_table_data(io, entries)
|
|
347
|
+
entries.each do |e|
|
|
348
|
+
io.write(e[:info][:compressed_data])
|
|
349
|
+
io.write(e[:pad_bytes])
|
|
315
350
|
end
|
|
316
351
|
end
|
|
317
352
|
|
data/lib/fontisan/sfnt_font.rb
CHANGED
|
@@ -431,9 +431,14 @@ module Fontisan
|
|
|
431
431
|
|
|
432
432
|
# Get list of all table tags (cached for performance)
|
|
433
433
|
#
|
|
434
|
-
#
|
|
434
|
+
# Returns plain UTF-8 Ruby Strings, not BinData::String instances —
|
|
435
|
+
# callers can rely on `eql?`/`==`/Hash lookup working against literal
|
|
436
|
+
# strings. BinData::String is encoding-sensitive and breaks Hash key
|
|
437
|
+
# comparison silently.
|
|
438
|
+
#
|
|
439
|
+
# @return [Array<String>] Array of table tag strings (UTF-8)
|
|
435
440
|
def table_names
|
|
436
|
-
@table_names ||= tables.map(
|
|
441
|
+
@table_names ||= tables.map { |entry| normalize_tag(entry.tag) }
|
|
437
442
|
end
|
|
438
443
|
|
|
439
444
|
# Get OOP SfntTable instance for a table
|
data/lib/fontisan/tables/head.rb
CHANGED
|
@@ -18,6 +18,16 @@ module Fontisan
|
|
|
18
18
|
# Magic number that must be present in the head table
|
|
19
19
|
MAGIC_NUMBER = 0x5F0F3CF5
|
|
20
20
|
|
|
21
|
+
# Bit flags for the `flags` field (uint16), per OpenType spec.
|
|
22
|
+
# Only bits we currently reference are named here; see the OpenType
|
|
23
|
+
# `head` table specification for the full list.
|
|
24
|
+
#
|
|
25
|
+
# Bit 11 indicates the font data has been losslessly modified
|
|
26
|
+
# (e.g. by subsetting or WOFF2 encoding). Per WOFF2 spec section 5,
|
|
27
|
+
# encoders MUST set this bit. Chrome's OpenType Sanitizer (OTS)
|
|
28
|
+
# rejects WOFF2 files where it is not set.
|
|
29
|
+
FLAG_LOSSLESS_MODIFYING = 0x0800
|
|
30
|
+
|
|
21
31
|
# Version as 16.16 fixed-point (stored as int32)
|
|
22
32
|
int32 :version_raw
|
|
23
33
|
|
data/lib/fontisan/version.rb
CHANGED
|
@@ -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
|
|
@@ -46,15 +46,22 @@ module Fontisan
|
|
|
46
46
|
"trak", "Zapf", "Silf", "Glat", "Gloc", "Feat", "Sill"
|
|
47
47
|
].freeze
|
|
48
48
|
|
|
49
|
-
# Transformation versions
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
49
|
+
# Transformation versions per WOFF2 spec section 5.1, 5.3, 5.4.
|
|
50
|
+
#
|
|
51
|
+
# glyf/loca:
|
|
52
|
+
# - version 0: transformed format (spec section 5.1 / 5.3)
|
|
53
|
+
# - version 3: null transform — original bytes passed to brotli as-is
|
|
54
|
+
# (Chrome's OTS does not accept version 3 for glyf/loca; encoders
|
|
55
|
+
# that want to interoperate with browsers MUST use version 0).
|
|
56
|
+
# hmtx:
|
|
57
|
+
# - version 0: null transform (default)
|
|
58
|
+
# - version 1: transformed format (spec section 5.4)
|
|
59
|
+
# All other tables use version 0 for the null transform.
|
|
60
|
+
TRANSFORM_NULL = 3 # glyf/loca null transform
|
|
61
|
+
TRANSFORM_NONE = TRANSFORM_NULL # deprecated alias kept for older callers
|
|
62
|
+
TRANSFORM_GLYF_LOCA = 0 # glyf/loca transformed format
|
|
63
|
+
TRANSFORM_HMTX = 1 # hmtx transformed format
|
|
64
|
+
TRANSFORM_HMTX_NULL = 0 # hmtx null transform
|
|
58
65
|
|
|
59
66
|
# Custom tag indicator
|
|
60
67
|
CUSTOM_TAG_INDEX = 0x3F
|
|
@@ -92,11 +99,18 @@ module Fontisan
|
|
|
92
99
|
KNOWN_TAGS.include?(tag)
|
|
93
100
|
end
|
|
94
101
|
|
|
95
|
-
#
|
|
102
|
+
# Whether this entry will be serialized with a transformLength field.
|
|
103
|
+
#
|
|
104
|
+
# Per WOFF2 spec section 4.1, transformLength is encoded when the
|
|
105
|
+
# table is in a transformed format. For loca the value is always 0
|
|
106
|
+
# (spec section 5.3), so we cannot use `.positive?` to detect the
|
|
107
|
+
# transformed state — the encoder explicitly sets `transform_length`
|
|
108
|
+
# (even to 0) when emitting a transformed entry, and leaves it nil
|
|
109
|
+
# otherwise.
|
|
96
110
|
#
|
|
97
|
-
# @return [Boolean] True if
|
|
111
|
+
# @return [Boolean] True if a transformLength field should be written
|
|
98
112
|
def transformed?
|
|
99
|
-
!transform_length.nil?
|
|
113
|
+
!transform_length.nil?
|
|
100
114
|
end
|
|
101
115
|
|
|
102
116
|
# Get transformation version from flags
|
|
@@ -113,39 +127,22 @@ module Fontisan
|
|
|
113
127
|
flags & 0x3F
|
|
114
128
|
end
|
|
115
129
|
|
|
116
|
-
# Determine
|
|
130
|
+
# Determine the transform version to encode in the flags byte.
|
|
117
131
|
#
|
|
118
|
-
#
|
|
119
|
-
#
|
|
120
|
-
#
|
|
132
|
+
# Per WOFF2 spec section 5.1, glyf/loca use version 0 for the
|
|
133
|
+
# transformed format and version 3 for the null transform; hmtx
|
|
134
|
+
# uses version 1 for transformed and version 0 for null. The
|
|
135
|
+
# encoder sets `transform_length` (or leaves it nil) to indicate
|
|
136
|
+
# which case applies — see `transformed?`.
|
|
121
137
|
#
|
|
122
138
|
# @return [Integer] Transform version (0-3)
|
|
123
139
|
def determine_transform_version
|
|
124
|
-
if
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
TRANSFORM_GLYF_LOCA # Version 0 for transformed glyf/loca
|
|
129
|
-
when "hmtx"
|
|
130
|
-
TRANSFORM_HMTX # Version 1 for transformed hmtx
|
|
131
|
-
else
|
|
132
|
-
TRANSFORM_NONE # Shouldn't happen, but use safe default
|
|
133
|
-
end
|
|
140
|
+
if ["glyf", "loca"].include?(tag)
|
|
141
|
+
transformed? ? TRANSFORM_GLYF_LOCA : TRANSFORM_NULL
|
|
142
|
+
elsif tag == "hmtx"
|
|
143
|
+
transformed? ? TRANSFORM_HMTX : TRANSFORM_HMTX_NULL
|
|
134
144
|
else
|
|
135
|
-
|
|
136
|
-
case tag
|
|
137
|
-
when "glyf", "loca"
|
|
138
|
-
# For glyf/loca, version 0 means transformed
|
|
139
|
-
# so use version 3 to indicate NOT transformed
|
|
140
|
-
TRANSFORM_NONE # Version 3
|
|
141
|
-
when "hmtx"
|
|
142
|
-
# For hmtx, version 1 means transformed
|
|
143
|
-
# so use version 0 to indicate NOT transformed
|
|
144
|
-
0
|
|
145
|
-
else
|
|
146
|
-
# All other tables use version 0 (no transformation)
|
|
147
|
-
0
|
|
148
|
-
end
|
|
145
|
+
0
|
|
149
146
|
end
|
|
150
147
|
end
|
|
151
148
|
|