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
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stringio"
|
|
4
|
+
|
|
5
|
+
module Fontisan
|
|
6
|
+
module Woff2
|
|
7
|
+
# Encodes glyf + loca into the WOFF2 transformed glyf table format per
|
|
8
|
+
# WOFF2 spec section 5.1.
|
|
9
|
+
#
|
|
10
|
+
# The loca table is reconstructed by the decoder (its data is omitted
|
|
11
|
+
# from the brotli-compressed stream). Its directory entry keeps
|
|
12
|
+
# origLength and sets transformLength = 0 per spec section 5.3.
|
|
13
|
+
#
|
|
14
|
+
# Output layout (spec section 5.1):
|
|
15
|
+
# uint16 version # always 0
|
|
16
|
+
# uint16 optionFlags # bit 0 = overlapSimpleBitmap present
|
|
17
|
+
# uint16 numGlyphs
|
|
18
|
+
# uint16 indexFormat # loca format (0=short, 1=long)
|
|
19
|
+
# uint32 × 7 stream sizes
|
|
20
|
+
# nContourStream: int16[numGlyphs]
|
|
21
|
+
# nPointsStream: 255UInt16 per contour (point count, not endpoint)
|
|
22
|
+
# flagStream: uint8[numPoints] (triplet flags)
|
|
23
|
+
# glyphStream: triplet payloads + 255UInt16 instructionLength per glyph
|
|
24
|
+
# compositeStream: raw component bytes per composite glyph
|
|
25
|
+
# bboxStream: bboxBitmap + explicit bbox int16[4] entries
|
|
26
|
+
# instructionStream: raw instruction bytes
|
|
27
|
+
# overlapSimpleBitmap (optional)
|
|
28
|
+
class GlyfLocaTransform
|
|
29
|
+
# TrueType simple-glyph flag bits per OpenType spec.
|
|
30
|
+
FLAG_ON_CURVE = 0x01
|
|
31
|
+
FLAG_OVERLAP_SIMPLE = 0x40
|
|
32
|
+
|
|
33
|
+
# TrueType composite-glyph flag bits.
|
|
34
|
+
ARG_1_AND_2_ARE_WORDS = 0x0001
|
|
35
|
+
WE_HAVE_A_SCALE = 0x0008
|
|
36
|
+
MORE_COMPONENTS = 0x0020
|
|
37
|
+
WE_HAVE_AN_X_AND_Y_SCALE = 0x0040
|
|
38
|
+
WE_HAVE_A_TWO_BY_TWO = 0x0080
|
|
39
|
+
WE_HAVE_INSTRUCTIONS = 0x0100
|
|
40
|
+
|
|
41
|
+
# Simple glyph records start with int16 numberOfContours + 4 int16 bbox.
|
|
42
|
+
SIMPLE_HEADER_SIZE = 10
|
|
43
|
+
|
|
44
|
+
attr_reader :num_glyphs, :source_index_format
|
|
45
|
+
|
|
46
|
+
# @param glyf_data [String] raw glyf table bytes
|
|
47
|
+
# @param loca_data [String] raw loca table bytes
|
|
48
|
+
# @param num_glyphs [Integer] from maxp
|
|
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).
|
|
54
|
+
def initialize(glyf_data:, loca_data:, num_glyphs:, index_format:)
|
|
55
|
+
@glyf_data = glyf_data
|
|
56
|
+
@loca_data = loca_data
|
|
57
|
+
@num_glyphs = num_glyphs
|
|
58
|
+
@source_index_format = index_format
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Encode the glyf/loca transform, returning the transformed glyf bytes.
|
|
62
|
+
#
|
|
63
|
+
# @return [String] transformed glyf bytes per spec section 5.1
|
|
64
|
+
def transform
|
|
65
|
+
s = Streams.new(@num_glyphs)
|
|
66
|
+
offsets = parse_loca_offsets
|
|
67
|
+
|
|
68
|
+
num_glyphs.times do |glyph_id|
|
|
69
|
+
start_off = offsets[glyph_id]
|
|
70
|
+
end_off = offsets[glyph_id + 1]
|
|
71
|
+
glyph_bytes = @glyf_data[start_off...end_off]
|
|
72
|
+
|
|
73
|
+
if glyph_bytes.nil? || glyph_bytes.empty? || glyph_bytes.bytesize.zero?
|
|
74
|
+
s.n_contour << [0].pack("s>")
|
|
75
|
+
next
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
num_contours = read_int16(glyph_bytes, 0)
|
|
79
|
+
bbox = read_bbox(glyph_bytes, 2)
|
|
80
|
+
|
|
81
|
+
case num_contours
|
|
82
|
+
when 0
|
|
83
|
+
s.n_contour << [0].pack("s>")
|
|
84
|
+
when -1
|
|
85
|
+
encode_composite(s:, glyph_bytes:, bbox:, glyph_id:)
|
|
86
|
+
else
|
|
87
|
+
encode_simple(s:, glyph_bytes:, num_contours:, bbox:, glyph_id:)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
assemble(s)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Mutable container for the 7 streams + 2 bitmaps. Avoids passing 9
|
|
95
|
+
# parameters through every helper. Defined at top-level so the Struct
|
|
96
|
+
# class isn't hidden behind `private` (Ruby treats constants as public
|
|
97
|
+
# regardless, but RuboCop flags the modifier as useless).
|
|
98
|
+
Streams = Struct.new(:n_contour, :n_points, :flags, :glyph, :composite,
|
|
99
|
+
:bbox_data, :instructions, :bbox_bitmap,
|
|
100
|
+
:overlap_bitmap) do
|
|
101
|
+
def initialize(num_glyphs)
|
|
102
|
+
super(*Array.new(7) { String.new(encoding: Encoding::BINARY) },
|
|
103
|
+
Array.new(((num_glyphs + 31) >> 5) << 2, 0),
|
|
104
|
+
Array.new((num_glyphs + 7) >> 3, 0))
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def has_overlap?
|
|
108
|
+
overlap_bitmap.any?(&:nonzero?)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# bboxStream on the wire = bboxBitmap || bboxStream (spec section 5.1).
|
|
112
|
+
def bbox_wire
|
|
113
|
+
bbox_bitmap.pack("C*") + bbox_data
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
def encode_simple(s:, glyph_bytes:, num_contours:, bbox:, glyph_id:)
|
|
120
|
+
s.n_contour << [num_contours].pack("s>")
|
|
121
|
+
|
|
122
|
+
io = StringIO.new(glyph_bytes)
|
|
123
|
+
io.pos = SIMPLE_HEADER_SIZE
|
|
124
|
+
end_pts = Array.new(num_contours) { io.read(2).unpack1("n") }
|
|
125
|
+
total_points = end_pts.last + 1
|
|
126
|
+
|
|
127
|
+
# nPoints stream: per-contour point counts (NOT endPtsOfContours).
|
|
128
|
+
prev_end = -1
|
|
129
|
+
end_pts.each do |ep|
|
|
130
|
+
s.n_points << encode_255_uint16(ep - prev_end)
|
|
131
|
+
prev_end = ep
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
inst_len = io.read(2).unpack1("n")
|
|
135
|
+
instructions = io.read(inst_len)
|
|
136
|
+
|
|
137
|
+
tt_flags = decode_truetype_flags(io, total_points)
|
|
138
|
+
xs = decode_coordinates(io, tt_flags, x_axis: true)
|
|
139
|
+
ys = decode_coordinates(io, tt_flags, x_axis: false)
|
|
140
|
+
|
|
141
|
+
# Re-encode each point as a WOFF2 triplet
|
|
142
|
+
prev_x = prev_y = 0
|
|
143
|
+
total_points.times do |i|
|
|
144
|
+
dx = xs[i] - prev_x
|
|
145
|
+
dy = ys[i] - prev_y
|
|
146
|
+
prev_x = xs[i]
|
|
147
|
+
prev_y = ys[i]
|
|
148
|
+
on_curve = (tt_flags[i] & FLAG_ON_CURVE).nonzero?
|
|
149
|
+
flag, payload = TripletCodec.encode(dx, dy, on_curve:)
|
|
150
|
+
s.flags << [flag].pack("C")
|
|
151
|
+
s.glyph << payload.pack("C*")
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Overlap bit lives on the first flag of each simple glyph
|
|
155
|
+
if (tt_flags.first & FLAG_OVERLAP_SIMPLE).nonzero?
|
|
156
|
+
s.overlap_bitmap[glyph_id >> 3] |= (0x80 >> (glyph_id & 7))
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# glyphStream carries per-glyph instructionLength; bytes go to instructionStream
|
|
160
|
+
s.glyph << encode_255_uint16(instructions.bytesize)
|
|
161
|
+
s.instructions << instructions
|
|
162
|
+
|
|
163
|
+
# Spec: simple glyphs omit bbox when it matches calculated bounds.
|
|
164
|
+
calculated = calc_bounds(xs, ys)
|
|
165
|
+
return if calculated == bbox
|
|
166
|
+
|
|
167
|
+
set_bbox_bit(s, glyph_id)
|
|
168
|
+
s.bbox_data << bbox.pack("s>*")
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def encode_composite(s:, glyph_bytes:, bbox:, glyph_id:)
|
|
172
|
+
s.n_contour << [-1].pack("s>")
|
|
173
|
+
|
|
174
|
+
io = StringIO.new(glyph_bytes)
|
|
175
|
+
io.pos = SIMPLE_HEADER_SIZE
|
|
176
|
+
|
|
177
|
+
components_end = nil
|
|
178
|
+
have_instructions = false
|
|
179
|
+
loop do
|
|
180
|
+
flags = io.read(2).unpack1("n")
|
|
181
|
+
io.read(2) # glyphIndex
|
|
182
|
+
io.read((flags & ARG_1_AND_2_ARE_WORDS).nonzero? ? 4 : 2)
|
|
183
|
+
io.read(component_transform_size(flags))
|
|
184
|
+
components_end = io.pos
|
|
185
|
+
have_instructions = (flags & WE_HAVE_INSTRUCTIONS).nonzero?
|
|
186
|
+
break if (flags & MORE_COMPONENTS).zero?
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
s.composite << glyph_bytes[SIMPLE_HEADER_SIZE...components_end]
|
|
190
|
+
|
|
191
|
+
if have_instructions
|
|
192
|
+
inst_len = io.read(2).unpack1("n")
|
|
193
|
+
instructions = io.read(inst_len)
|
|
194
|
+
s.glyph << encode_255_uint16(instructions.bytesize)
|
|
195
|
+
s.instructions << instructions
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Composite glyphs MUST have explicit bbox per spec section 5.1
|
|
199
|
+
set_bbox_bit(s, glyph_id)
|
|
200
|
+
s.bbox_data << bbox.pack("s>*")
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def component_transform_size(flags)
|
|
204
|
+
if (flags & WE_HAVE_A_SCALE).nonzero? then 2
|
|
205
|
+
elsif (flags & WE_HAVE_AN_X_AND_Y_SCALE).nonzero? then 4
|
|
206
|
+
elsif (flags & WE_HAVE_A_TWO_BY_TWO).nonzero? then 8
|
|
207
|
+
else
|
|
208
|
+
0
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def decode_truetype_flags(io, total_points)
|
|
213
|
+
flags = []
|
|
214
|
+
while flags.size < total_points
|
|
215
|
+
flag = io.read(1).unpack1("C")
|
|
216
|
+
flags << flag
|
|
217
|
+
if (flag & 0x08).nonzero? # REPEAT_FLAG
|
|
218
|
+
repeat = io.read(1).unpack1("C")
|
|
219
|
+
repeat.times { flags << flag }
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
flags
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def decode_coordinates(io, flags, x_axis:)
|
|
226
|
+
short_flag = x_axis ? 0x02 : 0x04
|
|
227
|
+
same_flag = x_axis ? 0x10 : 0x20
|
|
228
|
+
|
|
229
|
+
coord = 0
|
|
230
|
+
coords = []
|
|
231
|
+
flags.each do |flag|
|
|
232
|
+
if (flag & short_flag).nonzero?
|
|
233
|
+
delta = io.read(1).unpack1("C")
|
|
234
|
+
delta = -delta if (flag & same_flag).zero?
|
|
235
|
+
elsif (flag & same_flag).nonzero?
|
|
236
|
+
delta = 0
|
|
237
|
+
else
|
|
238
|
+
delta = read_int16_io(io)
|
|
239
|
+
end
|
|
240
|
+
coord += delta
|
|
241
|
+
coords << coord
|
|
242
|
+
end
|
|
243
|
+
coords
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def assemble(s)
|
|
247
|
+
bbox_bitmap_bytes = ((@num_glyphs + 31) >> 5) << 2
|
|
248
|
+
# Pad bbox_bitmap to required size (struct may have fewer bytes allocated)
|
|
249
|
+
bb = s.bbox_bitmap
|
|
250
|
+
if bb.size < bbox_bitmap_bytes
|
|
251
|
+
bb.concat(Array.new(bbox_bitmap_bytes - bb.size, 0))
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
out = String.new(encoding: Encoding::BINARY)
|
|
255
|
+
out << [0].pack("S>") # version
|
|
256
|
+
out << [(s.has_overlap? ? 1 : 0)].pack("S>") # optionFlags
|
|
257
|
+
out << [@num_glyphs].pack("S>")
|
|
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>")
|
|
262
|
+
out << [s.n_contour.bytesize].pack("L>")
|
|
263
|
+
out << [s.n_points.bytesize].pack("L>")
|
|
264
|
+
out << [s.flags.bytesize].pack("L>")
|
|
265
|
+
out << [s.glyph.bytesize].pack("L>")
|
|
266
|
+
out << [s.composite.bytesize].pack("L>")
|
|
267
|
+
out << [s.bbox_wire.bytesize].pack("L>")
|
|
268
|
+
out << [s.instructions.bytesize].pack("L>")
|
|
269
|
+
out << s.n_contour
|
|
270
|
+
out << s.n_points
|
|
271
|
+
out << s.flags
|
|
272
|
+
out << s.glyph
|
|
273
|
+
out << s.composite
|
|
274
|
+
out << s.bbox_wire
|
|
275
|
+
out << s.instructions
|
|
276
|
+
out << s.overlap_bitmap.pack("C*") if s.has_overlap?
|
|
277
|
+
out
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def parse_loca_offsets
|
|
281
|
+
if @source_index_format.zero?
|
|
282
|
+
@loca_data.unpack("n*").map { |v| v * 2 }
|
|
283
|
+
else
|
|
284
|
+
@loca_data.unpack("N*")
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def set_bbox_bit(s, glyph_id)
|
|
289
|
+
s.bbox_bitmap[glyph_id >> 3] |= (0x80 >> (glyph_id & 7))
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def read_int16(bytes, offset)
|
|
293
|
+
v = (bytes.getbyte(offset) << 8) | bytes.getbyte(offset + 1)
|
|
294
|
+
v > 0x7FFF ? v - 0x10000 : v
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def read_int16_io(io)
|
|
298
|
+
v = io.read(2).unpack1("n")
|
|
299
|
+
v > 0x7FFF ? v - 0x10000 : v
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def read_bbox(bytes, offset)
|
|
303
|
+
[
|
|
304
|
+
read_int16(bytes, offset),
|
|
305
|
+
read_int16(bytes, offset + 2),
|
|
306
|
+
read_int16(bytes, offset + 4),
|
|
307
|
+
read_int16(bytes, offset + 6),
|
|
308
|
+
]
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def calc_bounds(xs, ys)
|
|
312
|
+
return nil if xs.empty?
|
|
313
|
+
|
|
314
|
+
[xs.min, ys.min, xs.max, ys.max]
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def encode_255_uint16(value)
|
|
318
|
+
if value < 253
|
|
319
|
+
[value].pack("C")
|
|
320
|
+
elsif value < 506
|
|
321
|
+
[253, value - 253].pack("CC")
|
|
322
|
+
elsif value < 65_536
|
|
323
|
+
[254].pack("C") + [value].pack("n")
|
|
324
|
+
else
|
|
325
|
+
[255].pack("C") + [value - 506].pack("n")
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
end
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Woff2
|
|
5
|
+
# Variable-length coordinate codec for the WOFF2 glyf transform.
|
|
6
|
+
#
|
|
7
|
+
# Per WOFF2 spec section 5.2, each (dx, dy, on_curve) point is encoded as
|
|
8
|
+
# one flag byte plus 1-4 payload bytes ("triplets"). The flag byte's high
|
|
9
|
+
# bit is the on-curve flag; the low 7 bits select one of 128 encoding
|
|
10
|
+
# variants. Variants are chosen by coordinate magnitude so small deltas
|
|
11
|
+
# cost 1-2 bytes and only very large deltas cost 4.
|
|
12
|
+
#
|
|
13
|
+
# Reference: WOFF2 spec section 5.2 (triplet encoding table).
|
|
14
|
+
module TripletCodec
|
|
15
|
+
ON_CURVE_BIT = 0x00
|
|
16
|
+
OFF_CURVE_BIT = 0x80
|
|
17
|
+
|
|
18
|
+
# Encode a single (dx, dy) delta with the given on-curve flag.
|
|
19
|
+
#
|
|
20
|
+
# @param dx [Integer] X delta (signed, -65535..65535)
|
|
21
|
+
# @param dy [Integer] Y delta (signed, -65535..65535)
|
|
22
|
+
# @param on_curve [Boolean] true if this is an on-curve point
|
|
23
|
+
# @return [Array(Integer, Array<Integer>)] flag byte and payload bytes
|
|
24
|
+
def self.encode(dx, dy, on_curve:)
|
|
25
|
+
on_curve_bit = on_curve ? ON_CURVE_BIT : OFF_CURVE_BIT
|
|
26
|
+
abs_x = dx.abs
|
|
27
|
+
abs_y = dy.abs
|
|
28
|
+
x_sign_bit = dx.negative? ? 0 : 1
|
|
29
|
+
y_sign_bit = dy.negative? ? 0 : 1
|
|
30
|
+
xy_sign_bits = x_sign_bit + (2 * y_sign_bit)
|
|
31
|
+
|
|
32
|
+
if dx.zero? && abs_y < 1280
|
|
33
|
+
encode_y_only(on_curve_bit, abs_y, y_sign_bit)
|
|
34
|
+
elsif dy.zero? && abs_x < 1280
|
|
35
|
+
encode_x_only(on_curve_bit, abs_x, x_sign_bit)
|
|
36
|
+
elsif abs_x < 65 && abs_y < 65
|
|
37
|
+
encode_nibble_pair(on_curve_bit, abs_x, abs_y, xy_sign_bits)
|
|
38
|
+
elsif abs_x < 769 && abs_y < 769
|
|
39
|
+
encode_byte_pair(on_curve_bit, abs_x, abs_y, xy_sign_bits)
|
|
40
|
+
elsif abs_x < 4096 && abs_y < 4096
|
|
41
|
+
encode_12_bit_pair(on_curve_bit, abs_x, abs_y, xy_sign_bits)
|
|
42
|
+
else
|
|
43
|
+
encode_16_bit_pair(on_curve_bit, abs_x, abs_y, xy_sign_bits)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Decode one triplet given the flag byte and a cursor over payload bytes.
|
|
48
|
+
#
|
|
49
|
+
# @param flag [Integer] flag byte
|
|
50
|
+
# @param payload [Array<Integer>] payload bytes following the flag
|
|
51
|
+
# @return [Array(Integer, Integer, Boolean)] dx, dy, on_curve
|
|
52
|
+
def self.decode(flag, payload)
|
|
53
|
+
on_curve = (flag & OFF_CURVE_BIT).zero?
|
|
54
|
+
idx = flag & 0x7F
|
|
55
|
+
|
|
56
|
+
dx, dy = if idx < 10
|
|
57
|
+
decode_y_only(idx, payload)
|
|
58
|
+
elsif idx < 20
|
|
59
|
+
decode_x_only(idx, payload)
|
|
60
|
+
elsif idx < 84
|
|
61
|
+
decode_nibble_pair(idx, payload)
|
|
62
|
+
elsif idx < 120
|
|
63
|
+
decode_byte_pair(idx, payload)
|
|
64
|
+
elsif idx < 124
|
|
65
|
+
decode_12_bit_pair(idx, payload)
|
|
66
|
+
else
|
|
67
|
+
decode_16_bit_pair(idx, payload)
|
|
68
|
+
end
|
|
69
|
+
[dx, dy, on_curve]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
class << self
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
# --- Encoders ---
|
|
76
|
+
|
|
77
|
+
# Variants 0-9: dy only (1 byte payload), dx=0, |dy| < 1280.
|
|
78
|
+
def encode_y_only(on_curve_bit, abs_y, y_sign_bit)
|
|
79
|
+
flag = on_curve_bit + ((abs_y & 0xF00) >> 7) + y_sign_bit
|
|
80
|
+
[flag, [abs_y & 0xFF]]
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Variants 10-19: dx only (1 byte payload), dy=0, |dx| < 1280.
|
|
84
|
+
def encode_x_only(on_curve_bit, abs_x, x_sign_bit)
|
|
85
|
+
flag = on_curve_bit + 10 + ((abs_x & 0xF00) >> 7) + x_sign_bit
|
|
86
|
+
[flag, [abs_x & 0xFF]]
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Variants 20-83: 4-bit X + 4-bit Y deltas (1 byte payload).
|
|
90
|
+
# Encodes (abs - 1) so the range 1..64 maps to nibble values 0..63.
|
|
91
|
+
def encode_nibble_pair(on_curve_bit, abs_x, abs_y, xy_sign_bits)
|
|
92
|
+
flag = on_curve_bit + 20 +
|
|
93
|
+
((abs_x - 1) & 0x30) +
|
|
94
|
+
(((abs_y - 1) & 0x30) >> 2) +
|
|
95
|
+
xy_sign_bits
|
|
96
|
+
payload = (((abs_x - 1) & 0x0F) << 4) | ((abs_y - 1) & 0x0F)
|
|
97
|
+
[flag, [payload]]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Variants 84-119: 8-bit X + 8-bit Y deltas (2 byte payload).
|
|
101
|
+
# Encodes (abs - 1) so the range 1..768 maps to byte values 0..767.
|
|
102
|
+
def encode_byte_pair(on_curve_bit, abs_x, abs_y, xy_sign_bits)
|
|
103
|
+
flag = on_curve_bit + 84 +
|
|
104
|
+
(12 * (((abs_x - 1) & 0x300) >> 8)) +
|
|
105
|
+
(((abs_y - 1) & 0x300) >> 6) +
|
|
106
|
+
xy_sign_bits
|
|
107
|
+
[flag, [(abs_x - 1) & 0xFF, (abs_y - 1) & 0xFF]]
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Variants 120-123: 12-bit X + 12-bit Y deltas (3 byte payload).
|
|
111
|
+
def encode_12_bit_pair(on_curve_bit, abs_x, abs_y, xy_sign_bits)
|
|
112
|
+
flag = on_curve_bit + 120 + xy_sign_bits
|
|
113
|
+
payload = [
|
|
114
|
+
(abs_x >> 4) & 0xFF,
|
|
115
|
+
((abs_x & 0x0F) << 4) | ((abs_y >> 8) & 0x0F),
|
|
116
|
+
abs_y & 0xFF,
|
|
117
|
+
]
|
|
118
|
+
[flag, payload]
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Variants 124-127: 16-bit X + 16-bit Y deltas (4 byte payload).
|
|
122
|
+
def encode_16_bit_pair(on_curve_bit, abs_x, abs_y, xy_sign_bits)
|
|
123
|
+
flag = on_curve_bit + 124 + xy_sign_bits
|
|
124
|
+
payload = [
|
|
125
|
+
(abs_x >> 8) & 0xFF, abs_x & 0xFF,
|
|
126
|
+
(abs_y >> 8) & 0xFF, abs_y & 0xFF
|
|
127
|
+
]
|
|
128
|
+
[flag, payload]
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# --- Decoders ---
|
|
132
|
+
|
|
133
|
+
def decode_y_only(idx, payload)
|
|
134
|
+
sign = (idx & 1).zero? ? -1 : 1
|
|
135
|
+
magnitude = ((idx & 0x0E) << 7) + payload.fetch(0)
|
|
136
|
+
[0, sign * magnitude]
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def decode_x_only(idx, payload)
|
|
140
|
+
sign = (idx & 1).zero? ? -1 : 1
|
|
141
|
+
magnitude = (((idx - 10) & 0x0E) << 7) + payload.fetch(0)
|
|
142
|
+
[sign * magnitude, 0]
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def decode_nibble_pair(idx, payload)
|
|
146
|
+
x_sign = ((idx - 20) & 0x01).zero? ? -1 : 1
|
|
147
|
+
y_sign = ((idx - 20) & 0x02).zero? ? -1 : 1
|
|
148
|
+
x_mag_base = ((idx - 20) & 0x30) +
|
|
149
|
+
((payload.fetch(0) >> 4) & 0x0F) + 1
|
|
150
|
+
y_mag_base = (((idx - 20) & 0x0C) << 2) +
|
|
151
|
+
(payload.fetch(0) & 0x0F) + 1
|
|
152
|
+
[x_sign * x_mag_base, y_sign * y_mag_base]
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def decode_byte_pair(idx, payload)
|
|
156
|
+
x_sign = (idx & 1).zero? ? -1 : 1
|
|
157
|
+
y_sign = ((idx >> 1) & 1).zero? ? -1 : 1
|
|
158
|
+
x_mag = 1 + (((idx - 84) / 12) << 8) + payload.fetch(0)
|
|
159
|
+
y_mag = 1 + ((((idx - 84) % 12) >> 2) << 8) + payload.fetch(1)
|
|
160
|
+
[x_sign * x_mag, y_sign * y_mag]
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def decode_12_bit_pair(idx, payload)
|
|
164
|
+
x_sign = (idx & 1).zero? ? -1 : 1
|
|
165
|
+
y_sign = ((idx >> 1) & 1).zero? ? -1 : 1
|
|
166
|
+
x_mag = (payload.fetch(0) << 4) + (payload.fetch(1) >> 4)
|
|
167
|
+
y_mag = ((payload.fetch(1) & 0x0F) << 8) + payload.fetch(2)
|
|
168
|
+
[x_sign * x_mag, y_sign * y_mag]
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def decode_16_bit_pair(idx, payload)
|
|
172
|
+
x_sign = (idx & 1).zero? ? -1 : 1
|
|
173
|
+
y_sign = ((idx >> 1) & 1).zero? ? -1 : 1
|
|
174
|
+
x_mag = (payload.fetch(0) << 8) + payload.fetch(1)
|
|
175
|
+
y_mag = (payload.fetch(2) << 8) + payload.fetch(3)
|
|
176
|
+
[x_sign * x_mag, y_sign * y_mag]
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
data/lib/fontisan/woff2.rb
CHANGED
|
@@ -4,10 +4,14 @@
|
|
|
4
4
|
|
|
5
5
|
module Fontisan
|
|
6
6
|
module Woff2
|
|
7
|
+
autoload :CollectionEncoder, "fontisan/woff2/collection_encoder"
|
|
7
8
|
autoload :Directory, "fontisan/woff2/directory"
|
|
8
|
-
autoload :
|
|
9
|
+
autoload :EncoderRules, "fontisan/woff2/encoder_rules"
|
|
10
|
+
autoload :GlyfLocaReconstruct, "fontisan/woff2/glyf_loca_reconstruct"
|
|
11
|
+
autoload :GlyfLocaTransform, "fontisan/woff2/glyf_loca_transform"
|
|
9
12
|
autoload :HmtxTransformer, "fontisan/woff2/hmtx_transformer"
|
|
10
13
|
autoload :TableTransformer, "fontisan/woff2/table_transformer"
|
|
14
|
+
autoload :TripletCodec, "fontisan/woff2/triplet_codec"
|
|
11
15
|
autoload :Woff2Header, "fontisan/woff2/header"
|
|
12
16
|
end
|
|
13
17
|
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/woff_font.rb
CHANGED
|
@@ -248,9 +248,12 @@ module Fontisan
|
|
|
248
248
|
|
|
249
249
|
# Get list of all table tags
|
|
250
250
|
#
|
|
251
|
-
#
|
|
251
|
+
# Returns plain UTF-8 Ruby Strings so callers can compare with literals
|
|
252
|
+
# without BinData::String encoding-sensitive eql? failures.
|
|
253
|
+
#
|
|
254
|
+
# @return [Array<String>] Array of table tag strings (UTF-8)
|
|
252
255
|
def table_names
|
|
253
|
-
table_entries.map(
|
|
256
|
+
table_entries.map { |entry| entry.tag.to_s.force_encoding("UTF-8") }
|
|
254
257
|
end
|
|
255
258
|
|
|
256
259
|
# Get parsed table instance
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: fontisan
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.4.
|
|
4
|
+
version: 0.4.14
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ribose Inc.
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-07-
|
|
11
|
+
date: 2026-07-06 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: base64
|
|
@@ -718,11 +718,15 @@ files:
|
|
|
718
718
|
- lib/fontisan/version.rb
|
|
719
719
|
- lib/fontisan/version.rb.orig
|
|
720
720
|
- lib/fontisan/woff2.rb
|
|
721
|
+
- lib/fontisan/woff2/collection_encoder.rb
|
|
721
722
|
- lib/fontisan/woff2/directory.rb
|
|
722
|
-
- lib/fontisan/woff2/
|
|
723
|
+
- lib/fontisan/woff2/encoder_rules.rb
|
|
724
|
+
- lib/fontisan/woff2/glyf_loca_reconstruct.rb
|
|
725
|
+
- lib/fontisan/woff2/glyf_loca_transform.rb
|
|
723
726
|
- lib/fontisan/woff2/header.rb
|
|
724
727
|
- lib/fontisan/woff2/hmtx_transformer.rb
|
|
725
728
|
- lib/fontisan/woff2/table_transformer.rb
|
|
729
|
+
- lib/fontisan/woff2/triplet_codec.rb
|
|
726
730
|
- lib/fontisan/woff2_font.rb
|
|
727
731
|
- lib/fontisan/woff_font.rb
|
|
728
732
|
- scripts/compare_stack_aware.rb
|