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,400 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stringio"
|
|
4
|
+
|
|
5
|
+
module Fontisan
|
|
6
|
+
module Woff2
|
|
7
|
+
# Reconstructs glyf and loca tables from the WOFF2 transformed glyf
|
|
8
|
+
# stream per spec section 5.1. This is the decoder counterpart of
|
|
9
|
+
# {Woff2::GlyfLocaTransform}.
|
|
10
|
+
#
|
|
11
|
+
# The transformed glyf table is split into 7 streams preceded by a
|
|
12
|
+
# 36-byte header (uint16 version + uint16 optionFlags + uint16 numGlyphs
|
|
13
|
+
# + uint16 indexFormat + 7 × uint32 stream sizes). An optional
|
|
14
|
+
# overlapSimpleBitmap follows when optionFlags bit 0 is set.
|
|
15
|
+
#
|
|
16
|
+
# Reference: W3C WOFF2 spec, section 5.1.
|
|
17
|
+
class GlyfLocaReconstruct
|
|
18
|
+
# TrueType simple-glyph flag bits per OpenType spec.
|
|
19
|
+
FLAG_ON_CURVE = 0x01
|
|
20
|
+
FLAG_X_SHORT = 0x02
|
|
21
|
+
FLAG_Y_SHORT = 0x04
|
|
22
|
+
FLAG_REPEAT = 0x08
|
|
23
|
+
FLAG_X_SAME_OR_POS = 0x10
|
|
24
|
+
FLAG_Y_SAME_OR_POS = 0x20
|
|
25
|
+
FLAG_OVERLAP_SIMPLE = 0x40
|
|
26
|
+
|
|
27
|
+
# TrueType composite-glyph flag bits.
|
|
28
|
+
ARG_1_AND_2_ARE_WORDS = 0x0001
|
|
29
|
+
WE_HAVE_A_SCALE = 0x0008
|
|
30
|
+
MORE_COMPONENTS = 0x0020
|
|
31
|
+
WE_HAVE_AN_X_AND_Y_SCALE = 0x0040
|
|
32
|
+
WE_HAVE_A_TWO_BY_TWO = 0x0080
|
|
33
|
+
WE_HAVE_INSTRUCTIONS = 0x0100
|
|
34
|
+
|
|
35
|
+
# 8-byte header + 7 × 4-byte stream sizes.
|
|
36
|
+
HEADER_SIZE = 36
|
|
37
|
+
|
|
38
|
+
attr_reader :num_glyphs, :index_format
|
|
39
|
+
|
|
40
|
+
# @param transformed_glyf [String] bytes of the transformed glyf table
|
|
41
|
+
# @param num_glyphs [Integer] from maxp
|
|
42
|
+
# @param index_format [Integer] 0 (short) or 1 (long), from head
|
|
43
|
+
def initialize(transformed_glyf:, num_glyphs:, index_format:)
|
|
44
|
+
@data = transformed_glyf
|
|
45
|
+
@num_glyphs = num_glyphs
|
|
46
|
+
@index_format = index_format
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Reconstruct glyf and loca tables.
|
|
50
|
+
#
|
|
51
|
+
# @return [Hash{Symbol => String}] `{ glyf:, loca: }`
|
|
52
|
+
def reconstruct
|
|
53
|
+
header = parse_header
|
|
54
|
+
streams = read_streams(header)
|
|
55
|
+
|
|
56
|
+
glyf = String.new(encoding: Encoding::BINARY)
|
|
57
|
+
offsets = [0]
|
|
58
|
+
|
|
59
|
+
num_glyphs.times do |glyph_id|
|
|
60
|
+
glyph = decode_glyph(glyph_id, streams)
|
|
61
|
+
glyf << glyph
|
|
62
|
+
offsets << glyf.bytesize
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
{ glyf:, loca: build_loca(offsets) }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
# Parsed header. Holds the 4 uint16 fields plus the 7 uint32 stream
|
|
71
|
+
# sizes. Stored as a Hash so the keys document themselves.
|
|
72
|
+
def parse_header
|
|
73
|
+
version, option_flags, ng, idx_fmt = @data[0, 8].unpack("S>S>S>S>")
|
|
74
|
+
sizes = @data[8, 28].unpack("L>7")
|
|
75
|
+
|
|
76
|
+
if ng != @num_glyphs
|
|
77
|
+
raise InvalidFontError,
|
|
78
|
+
"WOFF2 glyf numGlyphs mismatch: header says #{ng}, expected #{@num_glyphs}"
|
|
79
|
+
end
|
|
80
|
+
if idx_fmt != @index_format
|
|
81
|
+
raise InvalidFontError,
|
|
82
|
+
"WOFF2 glyf indexFormat mismatch: header says #{idx_fmt}, expected #{@index_format}"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
{
|
|
86
|
+
version:,
|
|
87
|
+
option_flags:,
|
|
88
|
+
has_overlap_bitmap: (option_flags & 0x01).nonzero?,
|
|
89
|
+
stream_sizes: sizes,
|
|
90
|
+
}
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Slice the 7 streams out of the data block and set up per-stream
|
|
94
|
+
# StringIO cursors. The bboxStream is split into bboxBitmap (fixed
|
|
95
|
+
# size per numGlyphs) and bboxStream (variable, conditionally read
|
|
96
|
+
# per glyph). The optional overlapSimpleBitmap is read at the end.
|
|
97
|
+
def read_streams(header)
|
|
98
|
+
pos = HEADER_SIZE
|
|
99
|
+
bitmap_size = ((@num_glyphs + 31) >> 5) << 2
|
|
100
|
+
overlap_size = header[:has_overlap_bitmap] ? ((@num_glyphs + 7) >> 3) : 0
|
|
101
|
+
|
|
102
|
+
names = %i[n_contour n_points flags glyph composite bbox instructions]
|
|
103
|
+
streams = {}
|
|
104
|
+
names.each_with_index do |name, i|
|
|
105
|
+
size = header[:stream_sizes][i]
|
|
106
|
+
streams[name] = StringIO.new(@data[pos, size])
|
|
107
|
+
pos += size
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# bboxStream begins with bboxBitmap (4 × floor((numGlyphs+31)/32)).
|
|
111
|
+
bbox_blob = streams[:bbox].string
|
|
112
|
+
streams[:bbox_bitmap] = bbox_blob[0, bitmap_size] || ""
|
|
113
|
+
streams[:bbox_entries] = StringIO.new(bbox_blob[bitmap_size..] || "")
|
|
114
|
+
|
|
115
|
+
# overlapSimpleBitmap follows all streams when optionFlags bit 0 set.
|
|
116
|
+
streams[:overlap_bitmap] = header[:has_overlap_bitmap] ? @data[pos, overlap_size] : ""
|
|
117
|
+
|
|
118
|
+
# nContour is read positionally (int16 per glyph).
|
|
119
|
+
nc_data = streams[:n_contour].string
|
|
120
|
+
streams[:n_contour_values] = nc_data.unpack("s>*")
|
|
121
|
+
|
|
122
|
+
streams
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def decode_glyph(glyph_id, streams)
|
|
126
|
+
num_contours = streams[:n_contour_values][glyph_id]
|
|
127
|
+
|
|
128
|
+
case num_contours
|
|
129
|
+
when 0
|
|
130
|
+
# Empty glyph: just an entry in loca (loca[n] == loca[n-1]).
|
|
131
|
+
""
|
|
132
|
+
when -1
|
|
133
|
+
decode_composite(glyph_id, streams)
|
|
134
|
+
else
|
|
135
|
+
decode_simple(glyph_id, num_contours, streams)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Decode a simple glyph per spec section 5.1 "Decoding of Simple Glyphs".
|
|
140
|
+
def decode_simple(glyph_id, num_contours, streams)
|
|
141
|
+
# Step 1: read num_contours 255UInt16 values from nPointsStream to
|
|
142
|
+
# build endPtsOfContours[].
|
|
143
|
+
end_pts = []
|
|
144
|
+
cumulative = -1
|
|
145
|
+
num_contours.times do
|
|
146
|
+
pts_of_contour = read_255_uint16(streams[:n_points])
|
|
147
|
+
cumulative += pts_of_contour
|
|
148
|
+
end_pts << cumulative
|
|
149
|
+
end
|
|
150
|
+
total_points = end_pts.last + 1
|
|
151
|
+
|
|
152
|
+
# Step 2: read total_points flag bytes from flagStream (triplet flags).
|
|
153
|
+
triplet_flags = streams[:flags].read(total_points).bytes
|
|
154
|
+
|
|
155
|
+
# Step 3: read coordinate triplets from glyphStream (1-4 bytes per
|
|
156
|
+
# point depending on flag). Decode to (dx, dy) deltas.
|
|
157
|
+
xs = []
|
|
158
|
+
ys = []
|
|
159
|
+
on_curve_flags = []
|
|
160
|
+
x = 0
|
|
161
|
+
y = 0
|
|
162
|
+
triplet_flags.each do |flag|
|
|
163
|
+
dx, dy, oc = TripletCodec.decode(flag, read_triplet_payload(flag, streams[:glyph]))
|
|
164
|
+
x += dx
|
|
165
|
+
y += dy
|
|
166
|
+
xs << x
|
|
167
|
+
ys << y
|
|
168
|
+
on_curve_flags << (oc ? FLAG_ON_CURVE : 0)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Step 4-5: read instructionLength from glyphStream, bytes from instructionStream.
|
|
172
|
+
inst_len = read_255_uint16(streams[:glyph])
|
|
173
|
+
instructions = streams[:instructions].read(inst_len)
|
|
174
|
+
|
|
175
|
+
# Bbox: emit explicit bbox if bboxBitmap bit is set for this glyph,
|
|
176
|
+
# otherwise recalculate from coordinates.
|
|
177
|
+
bbox = if bbox_bit_set?(streams[:bbox_bitmap], glyph_id)
|
|
178
|
+
read_bbox(streams[:bbox_entries])
|
|
179
|
+
else
|
|
180
|
+
calc_bounds(xs, ys) || [0, 0, 0, 0]
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Re-encode in standard TrueType format.
|
|
184
|
+
build_simple_glyph_bytes(num_contours:, bbox:, end_pts:,
|
|
185
|
+
on_curve_flags:, xs:, ys:, instructions:,
|
|
186
|
+
overlap: overlap_bit_set?(streams[:overlap_bitmap], glyph_id))
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def decode_composite(_glyph_id, streams)
|
|
190
|
+
# Composite components are stored verbatim in compositeStream. Walk
|
|
191
|
+
# the component records to find their total byte length, then copy.
|
|
192
|
+
# Per spec section 5.1 "Decoding of Composite Glyphs": each component
|
|
193
|
+
# has a uint16 flags word, then 4-14 argument bytes.
|
|
194
|
+
composite_io = streams[:composite]
|
|
195
|
+
component_start = composite_io.pos
|
|
196
|
+
have_instructions = false
|
|
197
|
+
loop do
|
|
198
|
+
break if composite_io.eof?
|
|
199
|
+
|
|
200
|
+
flags = read_uint16_io(composite_io)
|
|
201
|
+
composite_io.read(2) # glyphIndex
|
|
202
|
+
composite_io.read((flags & ARG_1_AND_2_ARE_WORDS).nonzero? ? 4 : 2)
|
|
203
|
+
composite_io.read(composite_transform_size(flags))
|
|
204
|
+
have_instructions = (flags & WE_HAVE_INSTRUCTIONS).nonzero?
|
|
205
|
+
break if (flags & MORE_COMPONENTS).zero?
|
|
206
|
+
end
|
|
207
|
+
component_end = composite_io.pos
|
|
208
|
+
component_bytes = composite_io.string[component_start...component_end]
|
|
209
|
+
|
|
210
|
+
instructions = String.new(encoding: Encoding::BINARY)
|
|
211
|
+
if have_instructions
|
|
212
|
+
inst_len = read_255_uint16(streams[:glyph])
|
|
213
|
+
instructions = streams[:instructions].read(inst_len)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Composite glyphs always have explicit bbox in bboxStream.
|
|
217
|
+
bbox = read_bbox(streams[:bbox_entries])
|
|
218
|
+
|
|
219
|
+
# num_contours = -1 (int16), bbox 4 × int16, components, optional instructions
|
|
220
|
+
out = String.new(encoding: Encoding::BINARY)
|
|
221
|
+
out << [-1].pack("s>")
|
|
222
|
+
out << bbox.pack("s>*")
|
|
223
|
+
out << component_bytes
|
|
224
|
+
# If the last component had WE_HAVE_INSTRUCTIONS, the standard
|
|
225
|
+
# TrueType composite format expects a uint16 instructionLength
|
|
226
|
+
# followed by the instruction bytes.
|
|
227
|
+
if have_instructions
|
|
228
|
+
out << [instructions.bytesize].pack("n")
|
|
229
|
+
out << instructions
|
|
230
|
+
end
|
|
231
|
+
out
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def composite_transform_size(flags)
|
|
235
|
+
if (flags & WE_HAVE_A_SCALE).nonzero? then 2
|
|
236
|
+
elsif (flags & WE_HAVE_AN_X_AND_Y_SCALE).nonzero? then 4
|
|
237
|
+
elsif (flags & WE_HAVE_A_TWO_BY_TWO).nonzero? then 8
|
|
238
|
+
else
|
|
239
|
+
0
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Read 1-4 payload bytes for a triplet based on the flag's index bits.
|
|
244
|
+
def read_triplet_payload(flag, io)
|
|
245
|
+
idx = flag & 0x7F
|
|
246
|
+
n_bytes = if idx < 84 then 1
|
|
247
|
+
elsif idx < 120 then 2
|
|
248
|
+
elsif idx < 124 then 3
|
|
249
|
+
else 4
|
|
250
|
+
end
|
|
251
|
+
bytes = io.read(n_bytes)
|
|
252
|
+
bytes ? bytes.bytes : Array.new(n_bytes, 0)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def read_255_uint16(io)
|
|
256
|
+
return 0 if io.eof?
|
|
257
|
+
|
|
258
|
+
code = io.read(1)&.unpack1("C")
|
|
259
|
+
return 0 unless code
|
|
260
|
+
|
|
261
|
+
case code
|
|
262
|
+
when 0..252 then code
|
|
263
|
+
when 253
|
|
264
|
+
b = io.read(1)&.unpack1("C") || 0
|
|
265
|
+
253 + b
|
|
266
|
+
when 254
|
|
267
|
+
io.read(2)&.unpack1("n") || 0
|
|
268
|
+
|
|
269
|
+
when 255
|
|
270
|
+
v = io.read(2)&.unpack1("n") || 0
|
|
271
|
+
v + 506
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def read_uint16_io(io)
|
|
276
|
+
io.read(2)&.unpack1("n") || 0
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def bbox_bit_set?(bbox_bitmap, glyph_id)
|
|
280
|
+
return false if bbox_bitmap.nil? || bbox_bitmap.empty?
|
|
281
|
+
|
|
282
|
+
byte_idx = glyph_id >> 3
|
|
283
|
+
bit_idx = glyph_id & 7
|
|
284
|
+
(bbox_bitmap.getbyte(byte_idx) & (0x80 >> bit_idx)).nonzero?
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def overlap_bit_set?(overlap_bitmap, glyph_id)
|
|
288
|
+
return false unless overlap_bitmap && !overlap_bitmap.empty?
|
|
289
|
+
|
|
290
|
+
byte_idx = glyph_id >> 3
|
|
291
|
+
bit_idx = glyph_id & 7
|
|
292
|
+
(overlap_bitmap.getbyte(byte_idx) & (0x80 >> bit_idx)).nonzero?
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def read_bbox(io)
|
|
296
|
+
bytes = io.read(8)
|
|
297
|
+
return [0, 0, 0, 0] unless bytes&.bytesize == 8
|
|
298
|
+
|
|
299
|
+
bytes.unpack("s>*")
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def calc_bounds(xs, ys)
|
|
303
|
+
return nil if xs.empty?
|
|
304
|
+
|
|
305
|
+
[xs.min, ys.min, xs.max, ys.max]
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# Build a standard TrueType simple-glyph record from per-point arrays.
|
|
309
|
+
# Re-encodes coordinates using the standard TrueType flag scheme based
|
|
310
|
+
# on per-point delta magnitudes (per OFF section 5.3.3 spec rules).
|
|
311
|
+
def build_simple_glyph_bytes(num_contours:, bbox:, end_pts:, on_curve_flags:,
|
|
312
|
+
xs:, ys:, instructions:, overlap:)
|
|
313
|
+
out = String.new(encoding: Encoding::BINARY)
|
|
314
|
+
out << [num_contours].pack("s>")
|
|
315
|
+
out << bbox.pack("s>*")
|
|
316
|
+
out << end_pts.pack("n*")
|
|
317
|
+
out << [instructions.bytesize].pack("n")
|
|
318
|
+
out << instructions
|
|
319
|
+
|
|
320
|
+
prev_x = prev_y = 0
|
|
321
|
+
tt_flags = []
|
|
322
|
+
x_bytes = String.new(encoding: Encoding::BINARY)
|
|
323
|
+
y_bytes = String.new(encoding: Encoding::BINARY)
|
|
324
|
+
|
|
325
|
+
xs.each_with_index do |x, i|
|
|
326
|
+
y = ys[i]
|
|
327
|
+
dx = x - prev_x
|
|
328
|
+
dy = y - prev_y
|
|
329
|
+
prev_x = x
|
|
330
|
+
prev_y = y
|
|
331
|
+
|
|
332
|
+
base = on_curve_flags[i]
|
|
333
|
+
base |= FLAG_OVERLAP_SIMPLE if overlap && i.zero?
|
|
334
|
+
|
|
335
|
+
flag = encode_axis_bits(base, x_bytes, short_bit: FLAG_X_SHORT,
|
|
336
|
+
same_bit: FLAG_X_SAME_OR_POS,
|
|
337
|
+
value: dx)
|
|
338
|
+
flag = encode_axis_bits(flag, y_bytes, short_bit: FLAG_Y_SHORT,
|
|
339
|
+
same_bit: FLAG_Y_SAME_OR_POS,
|
|
340
|
+
value: dy)
|
|
341
|
+
tt_flags << flag
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
out << encode_flags_rle(tt_flags)
|
|
345
|
+
out << x_bytes
|
|
346
|
+
out << y_bytes
|
|
347
|
+
out
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# Encode one axis delta into standard TrueType byte layout. Returns
|
|
351
|
+
# the updated flag value (X bits if short_bit is FLAG_X_SHORT, Y bits
|
|
352
|
+
# if short_bit is FLAG_Y_SHORT).
|
|
353
|
+
def encode_axis_bits(base, out, short_bit:, same_bit:, value:)
|
|
354
|
+
if value.zero?
|
|
355
|
+
base | same_bit
|
|
356
|
+
elsif value.abs <= 255
|
|
357
|
+
flag = base | short_bit
|
|
358
|
+
flag |= same_bit if value.positive?
|
|
359
|
+
out << (value.abs & 0xFF)
|
|
360
|
+
flag
|
|
361
|
+
else
|
|
362
|
+
out << [value & 0xFFFF].pack("n")
|
|
363
|
+
base
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# Run-length encode the flags array per OFF spec: when two or more
|
|
368
|
+
# consecutive flags are identical, emit REPEAT_FLAG followed by count.
|
|
369
|
+
def encode_flags_rle(flags)
|
|
370
|
+
out = String.new(encoding: Encoding::BINARY)
|
|
371
|
+
i = 0
|
|
372
|
+
while i < flags.size
|
|
373
|
+
flag = flags[i]
|
|
374
|
+
count = 1
|
|
375
|
+
while i + count < flags.size && flags[i + count] == flag && count < 255
|
|
376
|
+
count += 1
|
|
377
|
+
end
|
|
378
|
+
if count > 1
|
|
379
|
+
out << (flag | FLAG_REPEAT).chr(Encoding::BINARY)
|
|
380
|
+
out << count.chr(Encoding::BINARY)
|
|
381
|
+
i += count
|
|
382
|
+
else
|
|
383
|
+
out << flag.chr(Encoding::BINARY)
|
|
384
|
+
i += 1
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
out
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
# Build loca table from accumulated glyph offsets per spec section 5.3.
|
|
391
|
+
def build_loca(offsets)
|
|
392
|
+
if @index_format.zero?
|
|
393
|
+
offsets.map { |o| o / 2 }.pack("n*")
|
|
394
|
+
else
|
|
395
|
+
offsets.pack("N*")
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
end
|
|
400
|
+
end
|
|
@@ -41,17 +41,21 @@ module Fontisan
|
|
|
41
41
|
# Simple glyph records start with int16 numberOfContours + 4 int16 bbox.
|
|
42
42
|
SIMPLE_HEADER_SIZE = 10
|
|
43
43
|
|
|
44
|
-
attr_reader :num_glyphs, :
|
|
44
|
+
attr_reader :num_glyphs, :source_index_format
|
|
45
45
|
|
|
46
46
|
# @param glyf_data [String] raw glyf table bytes
|
|
47
47
|
# @param loca_data [String] raw loca table bytes
|
|
48
48
|
# @param num_glyphs [Integer] from maxp
|
|
49
|
-
# @param index_format [Integer] 0 (short) or 1 (long), from head
|
|
49
|
+
# @param index_format [Integer] 0 (short) or 1 (long), from head.
|
|
50
|
+
# Used to parse the source loca; the WOFF2 transformed glyf
|
|
51
|
+
# always emits indexFormat=0 (Chrome OTS silently rejects any
|
|
52
|
+
# other value, even though WOFF2 spec section 5.1 allows the
|
|
53
|
+
# source's indexToLocFormat).
|
|
50
54
|
def initialize(glyf_data:, loca_data:, num_glyphs:, index_format:)
|
|
51
55
|
@glyf_data = glyf_data
|
|
52
56
|
@loca_data = loca_data
|
|
53
57
|
@num_glyphs = num_glyphs
|
|
54
|
-
@
|
|
58
|
+
@source_index_format = index_format
|
|
55
59
|
end
|
|
56
60
|
|
|
57
61
|
# Encode the glyf/loca transform, returning the transformed glyf bytes.
|
|
@@ -251,7 +255,10 @@ module Fontisan
|
|
|
251
255
|
out << [0].pack("S>") # version
|
|
252
256
|
out << [(s.has_overlap? ? 1 : 0)].pack("S>") # optionFlags
|
|
253
257
|
out << [@num_glyphs].pack("S>")
|
|
254
|
-
|
|
258
|
+
# Always emit indexFormat=0 per Chrome OTS requirement. WOFF2 spec
|
|
259
|
+
# section 5.1 allows the source's indexToLocFormat, but Chrome's
|
|
260
|
+
# OTS silently rejects any value other than 0.
|
|
261
|
+
out << [0].pack("S>")
|
|
255
262
|
out << [s.n_contour.bytesize].pack("L>")
|
|
256
263
|
out << [s.n_points.bytesize].pack("L>")
|
|
257
264
|
out << [s.flags.bytesize].pack("L>")
|
|
@@ -271,7 +278,7 @@ module Fontisan
|
|
|
271
278
|
end
|
|
272
279
|
|
|
273
280
|
def parse_loca_offsets
|
|
274
|
-
if @
|
|
281
|
+
if @source_index_format.zero?
|
|
275
282
|
@loca_data.unpack("n*").map { |v| v * 2 }
|
|
276
283
|
else
|
|
277
284
|
@loca_data.unpack("N*")
|
data/lib/fontisan/woff2.rb
CHANGED
|
@@ -4,10 +4,12 @@
|
|
|
4
4
|
|
|
5
5
|
module Fontisan
|
|
6
6
|
module Woff2
|
|
7
|
+
autoload :CollectionDecoder, "fontisan/woff2/collection_decoder"
|
|
8
|
+
autoload :CollectionEncoder, "fontisan/woff2/collection_encoder"
|
|
7
9
|
autoload :Directory, "fontisan/woff2/directory"
|
|
8
10
|
autoload :EncoderRules, "fontisan/woff2/encoder_rules"
|
|
11
|
+
autoload :GlyfLocaReconstruct, "fontisan/woff2/glyf_loca_reconstruct"
|
|
9
12
|
autoload :GlyfLocaTransform, "fontisan/woff2/glyf_loca_transform"
|
|
10
|
-
autoload :GlyfTransformer, "fontisan/woff2/glyf_transformer"
|
|
11
13
|
autoload :HmtxTransformer, "fontisan/woff2/hmtx_transformer"
|
|
12
14
|
autoload :TableTransformer, "fontisan/woff2/table_transformer"
|
|
13
15
|
autoload :TripletCodec, "fontisan/woff2/triplet_codec"
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stringio"
|
|
4
|
+
|
|
5
|
+
module Fontisan
|
|
6
|
+
# WOFF2 font collection (spec section 4.2).
|
|
7
|
+
#
|
|
8
|
+
# Wraps a WOFF2 collection file (flavor='ttcf') and exposes the same
|
|
9
|
+
# interface as `TrueTypeCollection`/`OpenTypeCollection` so collection
|
|
10
|
+
# consumers don't need to know which container format they're holding.
|
|
11
|
+
#
|
|
12
|
+
# Unlike TTC/OTC (which subclass `BaseCollection` for BinData parsing),
|
|
13
|
+
# WOFF2 collections are decoded eagerly via `Woff2::CollectionDecoder`
|
|
14
|
+
# because the per-font tables require Brotli decompression +
|
|
15
|
+
# glyf/loca reconstruction before they can be returned.
|
|
16
|
+
class Woff2Collection
|
|
17
|
+
include Collection::SharedLogic
|
|
18
|
+
|
|
19
|
+
# @return [String] Source WOFF2 binary
|
|
20
|
+
attr_reader :data
|
|
21
|
+
|
|
22
|
+
# @param data [String] WOFF2 collection binary
|
|
23
|
+
def initialize(data)
|
|
24
|
+
@data = data.force_encoding(Encoding::BINARY)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Load a WOFF2 collection from a file path.
|
|
28
|
+
#
|
|
29
|
+
# @param path [String]
|
|
30
|
+
# @return [Woff2Collection]
|
|
31
|
+
def self.from_file(path)
|
|
32
|
+
new(File.binread(path))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Number of fonts in the collection.
|
|
36
|
+
#
|
|
37
|
+
# @return [Integer]
|
|
38
|
+
def num_fonts
|
|
39
|
+
decoded.length
|
|
40
|
+
end
|
|
41
|
+
alias font_count num_fonts
|
|
42
|
+
|
|
43
|
+
# Per-font synthetic offsets. WOFF2 collections don't have byte offsets
|
|
44
|
+
# like TTC (each font is reconstructed in memory); the index is the
|
|
45
|
+
# only identifier. Returned for compatibility with `BaseCollection`'s
|
|
46
|
+
# interface.
|
|
47
|
+
#
|
|
48
|
+
# @return [Array<Integer>] 0..num_fonts-1
|
|
49
|
+
def font_offsets
|
|
50
|
+
(0...num_fonts).to_a
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Extract every font in the collection.
|
|
54
|
+
#
|
|
55
|
+
# @param _io [IO, nil] Ignored — WOFF2 collections decode in memory.
|
|
56
|
+
# @return [Array<TrueTypeFont, OpenTypeFont>]
|
|
57
|
+
def extract_fonts(_io = nil)
|
|
58
|
+
decoded.map { |entry| build_font(entry) }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Get a single font by index.
|
|
62
|
+
#
|
|
63
|
+
# @param index [Integer] 0-based font index
|
|
64
|
+
# @param _io [IO, nil] Ignored.
|
|
65
|
+
# @param _mode [Symbol] Ignored — full mode only (WOFF2 collections are
|
|
66
|
+
# decoded eagerly).
|
|
67
|
+
# @return [TrueTypeFont, OpenTypeFont, nil] nil if index out of range
|
|
68
|
+
def font(index, _io = nil, _mode: LoadingModes::FULL)
|
|
69
|
+
return nil if index >= num_fonts
|
|
70
|
+
|
|
71
|
+
build_font(decoded[index])
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Whether this object represents a font collection. WOFF2 collections
|
|
75
|
+
# are always collections.
|
|
76
|
+
#
|
|
77
|
+
# @return [Boolean]
|
|
78
|
+
def collection? = true
|
|
79
|
+
|
|
80
|
+
# Collections have no single SFNT table directory.
|
|
81
|
+
#
|
|
82
|
+
# @return [Array<String>] empty
|
|
83
|
+
def table_names = []
|
|
84
|
+
|
|
85
|
+
# Validate format correctness.
|
|
86
|
+
#
|
|
87
|
+
# @return [Boolean]
|
|
88
|
+
def valid?
|
|
89
|
+
flavor = @data[4, 4].unpack1("N")
|
|
90
|
+
flavor == Woff2::CollectionDecoder::TTC_FLAVOR
|
|
91
|
+
rescue StandardError
|
|
92
|
+
false
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# High-level pipeline format identifier. Owned by the collection class
|
|
96
|
+
# so the conversion pipeline can dispatch without case statements.
|
|
97
|
+
#
|
|
98
|
+
# @return [Symbol] :woff2_collection
|
|
99
|
+
def format = :woff2_collection
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
# Lazily decoded collection entries (one Hash per font).
|
|
104
|
+
def decoded
|
|
105
|
+
@decoded ||= Woff2::CollectionDecoder.new(@data).decode
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Build a TrueTypeFont or OpenTypeFont from a decoded font entry.
|
|
109
|
+
# Constructs an in-memory SFNT binary from the per-table data and
|
|
110
|
+
# loads it through the normal font reader path.
|
|
111
|
+
def build_font(entry)
|
|
112
|
+
sfnt = SfntBuilder.build(flavor: entry[:flavor], tables: entry[:tables])
|
|
113
|
+
sfnt_io = StringIO.new(sfnt)
|
|
114
|
+
font_class = truetype_flavor?(entry[:flavor]) ? TrueTypeFont : OpenTypeFont
|
|
115
|
+
font = font_class.read(sfnt_io)
|
|
116
|
+
font.initialize_storage
|
|
117
|
+
font.read_table_data(StringIO.new(sfnt))
|
|
118
|
+
font
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def truetype_flavor?(flavor)
|
|
122
|
+
[Constants::SFNT_VERSION_TRUETYPE, 0x00010000].include?(flavor)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
data/lib/fontisan/woff2_font.rb
CHANGED
|
@@ -538,59 +538,42 @@ module Fontisan
|
|
|
538
538
|
# @param decompressed_tables [Hash<String, String>] Decompressed tables
|
|
539
539
|
# @return [void] Modifies decompressed_tables in place
|
|
540
540
|
def self.apply_transformations!(table_entries, decompressed_tables)
|
|
541
|
-
# Find entries that need transformation
|
|
542
541
|
glyf_entry = table_entries.find { |e| e.tag == "glyf" }
|
|
543
542
|
hmtx_entry = table_entries.find { |e| e.tag == "hmtx" }
|
|
544
543
|
|
|
545
|
-
# Get required metadata for transformations
|
|
546
544
|
maxp_data = decompressed_tables["maxp"]
|
|
547
545
|
hhea_data = decompressed_tables["hhea"]
|
|
546
|
+
head_data = decompressed_tables["head"]
|
|
548
547
|
|
|
549
|
-
return unless maxp_data && hhea_data
|
|
548
|
+
return unless maxp_data && hhea_data && head_data
|
|
550
549
|
|
|
551
|
-
# Parse num_glyphs from maxp table
|
|
552
550
|
# maxp format: version(4) + numGlyphs(2) + ...
|
|
553
551
|
num_glyphs = maxp_data[4, 2].unpack1("n")
|
|
554
|
-
|
|
555
|
-
# Parse numberOfHMetrics from hhea table
|
|
556
552
|
# hhea format: ... + numberOfHMetrics(2) at offset 34
|
|
557
553
|
number_of_h_metrics = hhea_data[34, 2].unpack1("n")
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
#
|
|
563
|
-
#
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
num_glyphs,
|
|
573
|
-
variable_font: variable_font,
|
|
574
|
-
)
|
|
575
|
-
decompressed_tables["glyf"] = result[:glyf]
|
|
576
|
-
decompressed_tables["loca"] = result[:loca]
|
|
577
|
-
end
|
|
554
|
+
# head format: ... + indexToLocFormat(2) at offset 50
|
|
555
|
+
index_format = head_data[50, 2].unpack1("n")
|
|
556
|
+
|
|
557
|
+
# Reconstruct glyf/loca per spec section 5.1/5.3 when glyf is
|
|
558
|
+
# transformed. Per spec section 4.1, transformLength is present IFF
|
|
559
|
+
# the table is transformed, so a non-nil value indicates transform.
|
|
560
|
+
if glyf_entry&.transform_length && decompressed_tables["glyf"]
|
|
561
|
+
result = Woff2::GlyfLocaReconstruct.new(
|
|
562
|
+
transformed_glyf: decompressed_tables["glyf"],
|
|
563
|
+
num_glyphs:,
|
|
564
|
+
index_format:,
|
|
565
|
+
).reconstruct
|
|
566
|
+
decompressed_tables["glyf"] = result[:glyf]
|
|
567
|
+
decompressed_tables["loca"] = result[:loca]
|
|
578
568
|
end
|
|
579
569
|
|
|
580
|
-
#
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
if transformed_hmtx
|
|
588
|
-
decompressed_tables["hmtx"] = Woff2::HmtxTransformer.reconstruct(
|
|
589
|
-
transformed_hmtx,
|
|
590
|
-
num_glyphs,
|
|
591
|
-
number_of_h_metrics,
|
|
592
|
-
)
|
|
593
|
-
end
|
|
570
|
+
# Reconstruct hmtx per spec section 5.4 when hmtx is transformed.
|
|
571
|
+
if hmtx_entry&.transform_length && decompressed_tables["hmtx"]
|
|
572
|
+
decompressed_tables["hmtx"] = Woff2::HmtxTransformer.reconstruct(
|
|
573
|
+
decompressed_tables["hmtx"],
|
|
574
|
+
num_glyphs,
|
|
575
|
+
number_of_h_metrics,
|
|
576
|
+
)
|
|
594
577
|
end
|
|
595
578
|
end
|
|
596
579
|
|
data/lib/fontisan.rb
CHANGED
|
@@ -116,6 +116,7 @@ module Fontisan
|
|
|
116
116
|
autoload :OutlineExtractor, "fontisan/outline_extractor"
|
|
117
117
|
autoload :SfntFont, "fontisan/sfnt_font"
|
|
118
118
|
autoload :SfntTable, "fontisan/sfnt_table"
|
|
119
|
+
autoload :SfntBuilder, "fontisan/sfnt_builder"
|
|
119
120
|
autoload :Stitcher, "fontisan/stitcher"
|
|
120
121
|
autoload :StitcherCli, "fontisan/stitcher_cli"
|
|
121
122
|
autoload :Tasks, "fontisan/tasks"
|
|
@@ -123,6 +124,7 @@ module Fontisan
|
|
|
123
124
|
autoload :TrueTypeFont, "fontisan/true_type_font"
|
|
124
125
|
autoload :TrueTypeFontExtensions, "fontisan/true_type_font_extensions"
|
|
125
126
|
autoload :Type1Font, "fontisan/type1_font"
|
|
127
|
+
autoload :Woff2Collection, "fontisan/woff2_collection"
|
|
126
128
|
autoload :Woff2Font, "fontisan/woff2_font"
|
|
127
129
|
autoload :WoffFont, "fontisan/woff_font"
|
|
128
130
|
|