fontisan 0.4.13 → 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/woff_writer.rb +94 -59
- data/lib/fontisan/version.rb +1 -1
- 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 +2 -1
- data/lib/fontisan/woff2_font.rb +23 -40
- metadata +4 -3
- data/lib/fontisan/woff2/glyf_transformer.rb +0 -716
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 47e074b8c2bbdcfa377c95733f56fdd978dfa4d824dbae3e29f3bd75935dddd6
|
|
4
|
+
data.tar.gz: a1a348db0bd6dde039c0a43f62ac3ab43792cc7414e995a72c47798663a1cd59
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e8917cc8c995f0ca2cbdc9e94ad5d5d629ab95d8809b7eb728d360f0743a2b26e5cfd059254497c5a1df10ddb3b53258ee703c441a106d4febd42b26ea3b6e08
|
|
7
|
+
data.tar.gz: d35a8138d7cdbc5389b619ed757863cc0d61e1d8d025655d0fb6736a21815f04d24802f24236fdffdc8d430192db79798f77f7b9343193e0c16035d2be8fc5eb
|
data/README.adoc
CHANGED
|
@@ -120,7 +120,7 @@ Font format support::
|
|
|
120
120
|
* TTF, OTF (CFF1), OTF2 (CFF2), TTC, OTC font formats (production ready)
|
|
121
121
|
* CFF2 with variable-font blend/vsindex operators, FDSelect, subroutines
|
|
122
122
|
* Adobe Type 1 fonts (PFB/PFA) with bidirectional conversion (see link:docs/TYPE1_FONTS.adoc[Type 1 Fonts Guide])
|
|
123
|
-
* WOFF/WOFF2 format support with reading, writing, and conversion (
|
|
123
|
+
* WOFF/WOFF2 format support with reading, writing, and conversion — fully compliant with W3C WOFF 1.0 (https://www.w3.org/TR/WOFF/[Recommendation 13 December 2012]) and WOFF 2.0 (https://www.w3.org/TR/WOFF2/[Recommendation 08 August 2024]) specifications. See link:docs/WOFF_WOFF2_FORMATS.adoc[WOFF/WOFF2 Guide].
|
|
124
124
|
* Apple legacy font support: 'true' signature TrueType fonts and dfont format (see link:docs/APPLE_LEGACY_FONTS.adoc[Apple Legacy Fonts Guide])
|
|
125
125
|
* SVG font generation and SVG-to-glyph conversion (complete)
|
|
126
126
|
|
|
@@ -8,6 +8,79 @@ including reading, writing, and conversion between these and other font formats.
|
|
|
8
8
|
WOFF is a wrapper around SFNT fonts (TTF/OTF) using zlib compression, while WOFF2
|
|
9
9
|
uses Brotli compression for better compression ratios and table transformations.
|
|
10
10
|
|
|
11
|
+
== W3C specification compliance
|
|
12
|
+
|
|
13
|
+
Fontisan's WOFF and WOFF2 implementations are fully compliant with the
|
|
14
|
+
W3C Recommendations:
|
|
15
|
+
|
|
16
|
+
* **WOFF 1.0** (W3C Recommendation, 13 December 2012):
|
|
17
|
+
https://www.w3.org/TR/WOFF/[spec] — reading and writing
|
|
18
|
+
* **WOFF 2.0** (W3C Recommendation, 08 August 2024):
|
|
19
|
+
https://www.w3.org/TR/WOFF2/[spec] — reading and writing
|
|
20
|
+
|
|
21
|
+
=== WOFF 2.0 (spec section references below)
|
|
22
|
+
|
|
23
|
+
Compliance covers every normative requirement of the WOFF2 spec:
|
|
24
|
+
|
|
25
|
+
* *Section 3.2 (WOFF2 Header)*: 48-byte header with correct signature
|
|
26
|
+
(`wOF2`), flavor, file length, reserved=0, totalSfntSize,
|
|
27
|
+
totalCompressedSize, version, and meta/private offset/length fields.
|
|
28
|
+
* *Section 4.1 (Table directory)*: flags byte structure (bits 0-5 tag
|
|
29
|
+
index, bits 6-7 transform version); optional 4-byte custom tag when
|
|
30
|
+
index=63; `transformLength` field present IFF table is transformed.
|
|
31
|
+
* *Section 4.2 (Collection directory)*: full support for flavor=`ttcf`
|
|
32
|
+
font collections — CollectionHeader + CollectionFontEntry per font,
|
|
33
|
+
with deduplicated tables shared across fonts via directory indices.
|
|
34
|
+
* *Section 5 (Compressed data)*: single Brotli stream of all tables in
|
|
35
|
+
directory order; `DSIG` table removed per encoder rule; `head.flags`
|
|
36
|
+
bit 11 set to indicate lossless modifying transform.
|
|
37
|
+
* *Section 5.1 (Transformed glyf table)*: 36-byte header (uint16
|
|
38
|
+
version + uint16 optionFlags + uint16 numGlyphs + uint16 indexFormat
|
|
39
|
+
+ 7 × uint32 stream sizes); 7 streams in spec order (nContour,
|
|
40
|
+
nPoints, flags, glyph, composite, bbox with bboxBitmap prefix,
|
|
41
|
+
instructions); optional overlapSimpleBitmap when optionFlags bit 0
|
|
42
|
+
is set; bboxBitmap semantics per spec (composite glyphs always have
|
|
43
|
+
explicit bbox, simple glyphs omit when calculated bbox matches,
|
|
44
|
+
empty glyphs MUST have all-zero bbox).
|
|
45
|
+
* *Section 5.2 (Triplet coordinate encoding)*: variable-length
|
|
46
|
+
coordinate codec with all 128 flag index variants (1-4 byte
|
|
47
|
+
payloads based on coordinate magnitude); bit 7 = off-curve flag.
|
|
48
|
+
* *Section 5.3 (Transformed loca table)*: `transformLength` MUST be 0;
|
|
49
|
+
loca data omitted from compressed stream (reconstructed by decoder
|
|
50
|
+
from glyf).
|
|
51
|
+
* *Section 5.4 (Transformed hmtx table)*: optional version 1 transform
|
|
52
|
+
exploiting `lsb == xMin` redundancy.
|
|
53
|
+
* *Section 5.5 (Table order constraints)*: loca MUST immediately
|
|
54
|
+
follow glyf in table directory; for collections, loca MUST
|
|
55
|
+
immediately follow its corresponding glyf with no other tables in
|
|
56
|
+
between.
|
|
57
|
+
|
|
58
|
+
Validation: fontisan's WOFF2 output is verified byte-identical to
|
|
59
|
+
fontTools' output for transformed glyf tables, and round-trips
|
|
60
|
+
correctly through the W3C `woff2_decompress` reference tool.
|
|
61
|
+
|
|
62
|
+
=== WOFF 1.0
|
|
63
|
+
|
|
64
|
+
* *Section 4 (WOFF Header)*: 44-byte header with correct signature
|
|
65
|
+
(`wOFF`), flavor, length, numTables, reserved=0, totalSfntSize,
|
|
66
|
+
version, and meta/private offset/length fields.
|
|
67
|
+
* *Section 5 (Table directory)*: 20-byte entries with tag, offset,
|
|
68
|
+
compLength, origLength, origChecksum. Tables sorted in input font
|
|
69
|
+
order per spec ("MUST be stored in the same order as the
|
|
70
|
+
well-formed input font").
|
|
71
|
+
* *Section 6 (Font data tables)*: tables compressed individually with
|
|
72
|
+
zlib (compress2-compatible); each table 4-byte aligned and
|
|
73
|
+
zero-padded to next boundary ("MUST begin on 4-byte boundaries and
|
|
74
|
+
be zero-padded to the next 4-byte boundary").
|
|
75
|
+
* *Section 7 (Extended metadata)*: optional block of XML data,
|
|
76
|
+
always zlib-compressed ("it is never stored in uncompressed form").
|
|
77
|
+
* *Section 8 (Private data)*: optional block of arbitrary bytes,
|
|
78
|
+
always 4-byte aligned.
|
|
79
|
+
|
|
80
|
+
Validation: fontisan's WOFF 1 output round-trips through fontTools
|
|
81
|
+
without errors; all 4-byte alignment, padding, and compression
|
|
82
|
+
requirements verified by spec-conformance specs.
|
|
83
|
+
|
|
11
84
|
=== Display WOFF/WOFF2 font information
|
|
12
85
|
|
|
13
86
|
Show metadata specific to WOFF/WOFF2 fonts including version, compression details,
|
|
@@ -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/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
|
|
@@ -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
|