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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 422936ab5bbb785c6030c16efa61be31edd4c9e251828eabaa29e572a570bbb5
|
|
4
|
+
data.tar.gz: ad05bb618741fb0958e1be540af840debb9ffa4d92be469930f8e11f6ccd63fb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f9d938ed00f8bff70e0fe20b06751134faa1cb9c0f536467f1259866876d060f170ac94e0a89818a4f77662d873542c9a6bfb09d4da0de5577c3bda2a33f4efa
|
|
7
|
+
data.tar.gz: e6d7679235dab59a1a26bf5948f0651d147b12fec4218c000626ff0291fbec33593cef8ba0466e189d63c20f6cdc0d6c53b88626f47178656a272248e8e12872
|
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,
|
|
@@ -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
|
|
@@ -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/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