fontisan 0.4.12 → 0.4.13
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/lib/fontisan/converters/woff2_encoder.rb +147 -242
- 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/directory.rb +37 -40
- data/lib/fontisan/woff2/encoder_rules.rb +66 -0
- data/lib/fontisan/woff2/glyf_loca_transform.rb +323 -0
- data/lib/fontisan/woff2/triplet_codec.rb +181 -0
- data/lib/fontisan/woff2.rb +3 -0
- data/lib/fontisan/woff_font.rb +5 -2
- metadata +4 -1
|
@@ -46,15 +46,22 @@ module Fontisan
|
|
|
46
46
|
"trak", "Zapf", "Silf", "Glat", "Gloc", "Feat", "Sill"
|
|
47
47
|
].freeze
|
|
48
48
|
|
|
49
|
-
# Transformation versions
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
49
|
+
# Transformation versions per WOFF2 spec section 5.1, 5.3, 5.4.
|
|
50
|
+
#
|
|
51
|
+
# glyf/loca:
|
|
52
|
+
# - version 0: transformed format (spec section 5.1 / 5.3)
|
|
53
|
+
# - version 3: null transform — original bytes passed to brotli as-is
|
|
54
|
+
# (Chrome's OTS does not accept version 3 for glyf/loca; encoders
|
|
55
|
+
# that want to interoperate with browsers MUST use version 0).
|
|
56
|
+
# hmtx:
|
|
57
|
+
# - version 0: null transform (default)
|
|
58
|
+
# - version 1: transformed format (spec section 5.4)
|
|
59
|
+
# All other tables use version 0 for the null transform.
|
|
60
|
+
TRANSFORM_NULL = 3 # glyf/loca null transform
|
|
61
|
+
TRANSFORM_NONE = TRANSFORM_NULL # deprecated alias kept for older callers
|
|
62
|
+
TRANSFORM_GLYF_LOCA = 0 # glyf/loca transformed format
|
|
63
|
+
TRANSFORM_HMTX = 1 # hmtx transformed format
|
|
64
|
+
TRANSFORM_HMTX_NULL = 0 # hmtx null transform
|
|
58
65
|
|
|
59
66
|
# Custom tag indicator
|
|
60
67
|
CUSTOM_TAG_INDEX = 0x3F
|
|
@@ -92,11 +99,18 @@ module Fontisan
|
|
|
92
99
|
KNOWN_TAGS.include?(tag)
|
|
93
100
|
end
|
|
94
101
|
|
|
95
|
-
#
|
|
102
|
+
# Whether this entry will be serialized with a transformLength field.
|
|
103
|
+
#
|
|
104
|
+
# Per WOFF2 spec section 4.1, transformLength is encoded when the
|
|
105
|
+
# table is in a transformed format. For loca the value is always 0
|
|
106
|
+
# (spec section 5.3), so we cannot use `.positive?` to detect the
|
|
107
|
+
# transformed state — the encoder explicitly sets `transform_length`
|
|
108
|
+
# (even to 0) when emitting a transformed entry, and leaves it nil
|
|
109
|
+
# otherwise.
|
|
96
110
|
#
|
|
97
|
-
# @return [Boolean] True if
|
|
111
|
+
# @return [Boolean] True if a transformLength field should be written
|
|
98
112
|
def transformed?
|
|
99
|
-
!transform_length.nil?
|
|
113
|
+
!transform_length.nil?
|
|
100
114
|
end
|
|
101
115
|
|
|
102
116
|
# Get transformation version from flags
|
|
@@ -113,39 +127,22 @@ module Fontisan
|
|
|
113
127
|
flags & 0x3F
|
|
114
128
|
end
|
|
115
129
|
|
|
116
|
-
# Determine
|
|
130
|
+
# Determine the transform version to encode in the flags byte.
|
|
117
131
|
#
|
|
118
|
-
#
|
|
119
|
-
#
|
|
120
|
-
#
|
|
132
|
+
# Per WOFF2 spec section 5.1, glyf/loca use version 0 for the
|
|
133
|
+
# transformed format and version 3 for the null transform; hmtx
|
|
134
|
+
# uses version 1 for transformed and version 0 for null. The
|
|
135
|
+
# encoder sets `transform_length` (or leaves it nil) to indicate
|
|
136
|
+
# which case applies — see `transformed?`.
|
|
121
137
|
#
|
|
122
138
|
# @return [Integer] Transform version (0-3)
|
|
123
139
|
def determine_transform_version
|
|
124
|
-
if
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
TRANSFORM_GLYF_LOCA # Version 0 for transformed glyf/loca
|
|
129
|
-
when "hmtx"
|
|
130
|
-
TRANSFORM_HMTX # Version 1 for transformed hmtx
|
|
131
|
-
else
|
|
132
|
-
TRANSFORM_NONE # Shouldn't happen, but use safe default
|
|
133
|
-
end
|
|
140
|
+
if ["glyf", "loca"].include?(tag)
|
|
141
|
+
transformed? ? TRANSFORM_GLYF_LOCA : TRANSFORM_NULL
|
|
142
|
+
elsif tag == "hmtx"
|
|
143
|
+
transformed? ? TRANSFORM_HMTX : TRANSFORM_HMTX_NULL
|
|
134
144
|
else
|
|
135
|
-
|
|
136
|
-
case tag
|
|
137
|
-
when "glyf", "loca"
|
|
138
|
-
# For glyf/loca, version 0 means transformed
|
|
139
|
-
# so use version 3 to indicate NOT transformed
|
|
140
|
-
TRANSFORM_NONE # Version 3
|
|
141
|
-
when "hmtx"
|
|
142
|
-
# For hmtx, version 1 means transformed
|
|
143
|
-
# so use version 0 to indicate NOT transformed
|
|
144
|
-
0
|
|
145
|
-
else
|
|
146
|
-
# All other tables use version 0 (no transformation)
|
|
147
|
-
0
|
|
148
|
-
end
|
|
145
|
+
0
|
|
149
146
|
end
|
|
150
147
|
end
|
|
151
148
|
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Woff2
|
|
5
|
+
# Applies WOFF2 spec "encoder MUST" rules to a table collection before
|
|
6
|
+
# the table directory is built and brotli compression is applied.
|
|
7
|
+
#
|
|
8
|
+
# Each spec-mandated transform lives here so the encoder stays a thin
|
|
9
|
+
# orchestrator and new rules are MECE/discoverable instead of sprinkled
|
|
10
|
+
# as inline conditionals across Woff2Encoder.
|
|
11
|
+
#
|
|
12
|
+
# Reference: W3C WOFF2 spec, section 5 ("Recommended table directory").
|
|
13
|
+
class EncoderRules
|
|
14
|
+
# Tables that MUST NOT appear in WOFF2 output.
|
|
15
|
+
#
|
|
16
|
+
# Per spec section 5: "The compliant WOFF2 encoder MUST remove the
|
|
17
|
+
# DSIG table from an input font data, prior to applying
|
|
18
|
+
# transformations and entropy coding steps." Chrome's OpenType
|
|
19
|
+
# Sanitizer (OTS) rejects WOFF2 files that still contain DSIG.
|
|
20
|
+
EXCLUDED_TABLES = %w[DSIG].freeze
|
|
21
|
+
|
|
22
|
+
# Tables that MUST be transformed (when present) per WOFF2 spec
|
|
23
|
+
# section 5.3: glyf and loca are paired — both are either
|
|
24
|
+
# transformed (version 0) or null-transformed (version 3) together.
|
|
25
|
+
# Browsers (Chrome OTS) only accept the transformed form.
|
|
26
|
+
TRANSFORMED_TABLES = %w[glyf loca].freeze
|
|
27
|
+
|
|
28
|
+
# Apply all WOFF2 encoder rules to `table_data` in place.
|
|
29
|
+
#
|
|
30
|
+
# @param table_data [Hash{String => String}] Map of tag → binary
|
|
31
|
+
# table data. Mutated.
|
|
32
|
+
# @return [Hash{String => String}] The same hash, for chaining.
|
|
33
|
+
def self.apply!(table_data)
|
|
34
|
+
exclude_tables!(table_data)
|
|
35
|
+
mark_lossless_modifying!(table_data)
|
|
36
|
+
table_data
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Whether `tag` is dropped by WOFF2 spec rules.
|
|
40
|
+
def self.excluded?(tag)
|
|
41
|
+
EXCLUDED_TABLES.include?(tag)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Drop spec-excluded tables from the collection.
|
|
45
|
+
def self.exclude_tables!(table_data)
|
|
46
|
+
EXCLUDED_TABLES.each { |tag| table_data.delete(tag) }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Set head.flags bit 11 to indicate the font was losslessly modified.
|
|
50
|
+
#
|
|
51
|
+
# Per spec section 5: "The WOFF 2.0 encoders MUST also set bit 11 of
|
|
52
|
+
# the 'flags' field of the head table ... to indicate that a recreated
|
|
53
|
+
# font file was subjected to lossless modifying transform."
|
|
54
|
+
#
|
|
55
|
+
# Uses the model-driven Head BinData record so the bit is set via a
|
|
56
|
+
# named attribute on a typed object, not via raw byte slicing.
|
|
57
|
+
def self.mark_lossless_modifying!(table_data)
|
|
58
|
+
return unless table_data.key?("head")
|
|
59
|
+
|
|
60
|
+
head = Tables::Head.read(table_data["head"])
|
|
61
|
+
head.flags |= Tables::Head::FLAG_LOSSLESS_MODIFYING
|
|
62
|
+
table_data["head"] = head.to_binary_s
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,323 @@
|
|
|
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, :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
|
+
def initialize(glyf_data:, loca_data:, num_glyphs:, index_format:)
|
|
51
|
+
@glyf_data = glyf_data
|
|
52
|
+
@loca_data = loca_data
|
|
53
|
+
@num_glyphs = num_glyphs
|
|
54
|
+
@index_format = index_format
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Encode the glyf/loca transform, returning the transformed glyf bytes.
|
|
58
|
+
#
|
|
59
|
+
# @return [String] transformed glyf bytes per spec section 5.1
|
|
60
|
+
def transform
|
|
61
|
+
s = Streams.new(@num_glyphs)
|
|
62
|
+
offsets = parse_loca_offsets
|
|
63
|
+
|
|
64
|
+
num_glyphs.times do |glyph_id|
|
|
65
|
+
start_off = offsets[glyph_id]
|
|
66
|
+
end_off = offsets[glyph_id + 1]
|
|
67
|
+
glyph_bytes = @glyf_data[start_off...end_off]
|
|
68
|
+
|
|
69
|
+
if glyph_bytes.nil? || glyph_bytes.empty? || glyph_bytes.bytesize.zero?
|
|
70
|
+
s.n_contour << [0].pack("s>")
|
|
71
|
+
next
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
num_contours = read_int16(glyph_bytes, 0)
|
|
75
|
+
bbox = read_bbox(glyph_bytes, 2)
|
|
76
|
+
|
|
77
|
+
case num_contours
|
|
78
|
+
when 0
|
|
79
|
+
s.n_contour << [0].pack("s>")
|
|
80
|
+
when -1
|
|
81
|
+
encode_composite(s:, glyph_bytes:, bbox:, glyph_id:)
|
|
82
|
+
else
|
|
83
|
+
encode_simple(s:, glyph_bytes:, num_contours:, bbox:, glyph_id:)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
assemble(s)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Mutable container for the 7 streams + 2 bitmaps. Avoids passing 9
|
|
91
|
+
# parameters through every helper. Defined at top-level so the Struct
|
|
92
|
+
# class isn't hidden behind `private` (Ruby treats constants as public
|
|
93
|
+
# regardless, but RuboCop flags the modifier as useless).
|
|
94
|
+
Streams = Struct.new(:n_contour, :n_points, :flags, :glyph, :composite,
|
|
95
|
+
:bbox_data, :instructions, :bbox_bitmap,
|
|
96
|
+
:overlap_bitmap) do
|
|
97
|
+
def initialize(num_glyphs)
|
|
98
|
+
super(*Array.new(7) { String.new(encoding: Encoding::BINARY) },
|
|
99
|
+
Array.new(((num_glyphs + 31) >> 5) << 2, 0),
|
|
100
|
+
Array.new((num_glyphs + 7) >> 3, 0))
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def has_overlap?
|
|
104
|
+
overlap_bitmap.any?(&:nonzero?)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# bboxStream on the wire = bboxBitmap || bboxStream (spec section 5.1).
|
|
108
|
+
def bbox_wire
|
|
109
|
+
bbox_bitmap.pack("C*") + bbox_data
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
def encode_simple(s:, glyph_bytes:, num_contours:, bbox:, glyph_id:)
|
|
116
|
+
s.n_contour << [num_contours].pack("s>")
|
|
117
|
+
|
|
118
|
+
io = StringIO.new(glyph_bytes)
|
|
119
|
+
io.pos = SIMPLE_HEADER_SIZE
|
|
120
|
+
end_pts = Array.new(num_contours) { io.read(2).unpack1("n") }
|
|
121
|
+
total_points = end_pts.last + 1
|
|
122
|
+
|
|
123
|
+
# nPoints stream: per-contour point counts (NOT endPtsOfContours).
|
|
124
|
+
prev_end = -1
|
|
125
|
+
end_pts.each do |ep|
|
|
126
|
+
s.n_points << encode_255_uint16(ep - prev_end)
|
|
127
|
+
prev_end = ep
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
inst_len = io.read(2).unpack1("n")
|
|
131
|
+
instructions = io.read(inst_len)
|
|
132
|
+
|
|
133
|
+
tt_flags = decode_truetype_flags(io, total_points)
|
|
134
|
+
xs = decode_coordinates(io, tt_flags, x_axis: true)
|
|
135
|
+
ys = decode_coordinates(io, tt_flags, x_axis: false)
|
|
136
|
+
|
|
137
|
+
# Re-encode each point as a WOFF2 triplet
|
|
138
|
+
prev_x = prev_y = 0
|
|
139
|
+
total_points.times do |i|
|
|
140
|
+
dx = xs[i] - prev_x
|
|
141
|
+
dy = ys[i] - prev_y
|
|
142
|
+
prev_x = xs[i]
|
|
143
|
+
prev_y = ys[i]
|
|
144
|
+
on_curve = (tt_flags[i] & FLAG_ON_CURVE).nonzero?
|
|
145
|
+
flag, payload = TripletCodec.encode(dx, dy, on_curve:)
|
|
146
|
+
s.flags << [flag].pack("C")
|
|
147
|
+
s.glyph << payload.pack("C*")
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Overlap bit lives on the first flag of each simple glyph
|
|
151
|
+
if (tt_flags.first & FLAG_OVERLAP_SIMPLE).nonzero?
|
|
152
|
+
s.overlap_bitmap[glyph_id >> 3] |= (0x80 >> (glyph_id & 7))
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# glyphStream carries per-glyph instructionLength; bytes go to instructionStream
|
|
156
|
+
s.glyph << encode_255_uint16(instructions.bytesize)
|
|
157
|
+
s.instructions << instructions
|
|
158
|
+
|
|
159
|
+
# Spec: simple glyphs omit bbox when it matches calculated bounds.
|
|
160
|
+
calculated = calc_bounds(xs, ys)
|
|
161
|
+
return if calculated == bbox
|
|
162
|
+
|
|
163
|
+
set_bbox_bit(s, glyph_id)
|
|
164
|
+
s.bbox_data << bbox.pack("s>*")
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def encode_composite(s:, glyph_bytes:, bbox:, glyph_id:)
|
|
168
|
+
s.n_contour << [-1].pack("s>")
|
|
169
|
+
|
|
170
|
+
io = StringIO.new(glyph_bytes)
|
|
171
|
+
io.pos = SIMPLE_HEADER_SIZE
|
|
172
|
+
|
|
173
|
+
components_end = nil
|
|
174
|
+
have_instructions = false
|
|
175
|
+
loop do
|
|
176
|
+
flags = io.read(2).unpack1("n")
|
|
177
|
+
io.read(2) # glyphIndex
|
|
178
|
+
io.read((flags & ARG_1_AND_2_ARE_WORDS).nonzero? ? 4 : 2)
|
|
179
|
+
io.read(component_transform_size(flags))
|
|
180
|
+
components_end = io.pos
|
|
181
|
+
have_instructions = (flags & WE_HAVE_INSTRUCTIONS).nonzero?
|
|
182
|
+
break if (flags & MORE_COMPONENTS).zero?
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
s.composite << glyph_bytes[SIMPLE_HEADER_SIZE...components_end]
|
|
186
|
+
|
|
187
|
+
if have_instructions
|
|
188
|
+
inst_len = io.read(2).unpack1("n")
|
|
189
|
+
instructions = io.read(inst_len)
|
|
190
|
+
s.glyph << encode_255_uint16(instructions.bytesize)
|
|
191
|
+
s.instructions << instructions
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Composite glyphs MUST have explicit bbox per spec section 5.1
|
|
195
|
+
set_bbox_bit(s, glyph_id)
|
|
196
|
+
s.bbox_data << bbox.pack("s>*")
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def component_transform_size(flags)
|
|
200
|
+
if (flags & WE_HAVE_A_SCALE).nonzero? then 2
|
|
201
|
+
elsif (flags & WE_HAVE_AN_X_AND_Y_SCALE).nonzero? then 4
|
|
202
|
+
elsif (flags & WE_HAVE_A_TWO_BY_TWO).nonzero? then 8
|
|
203
|
+
else
|
|
204
|
+
0
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def decode_truetype_flags(io, total_points)
|
|
209
|
+
flags = []
|
|
210
|
+
while flags.size < total_points
|
|
211
|
+
flag = io.read(1).unpack1("C")
|
|
212
|
+
flags << flag
|
|
213
|
+
if (flag & 0x08).nonzero? # REPEAT_FLAG
|
|
214
|
+
repeat = io.read(1).unpack1("C")
|
|
215
|
+
repeat.times { flags << flag }
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
flags
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def decode_coordinates(io, flags, x_axis:)
|
|
222
|
+
short_flag = x_axis ? 0x02 : 0x04
|
|
223
|
+
same_flag = x_axis ? 0x10 : 0x20
|
|
224
|
+
|
|
225
|
+
coord = 0
|
|
226
|
+
coords = []
|
|
227
|
+
flags.each do |flag|
|
|
228
|
+
if (flag & short_flag).nonzero?
|
|
229
|
+
delta = io.read(1).unpack1("C")
|
|
230
|
+
delta = -delta if (flag & same_flag).zero?
|
|
231
|
+
elsif (flag & same_flag).nonzero?
|
|
232
|
+
delta = 0
|
|
233
|
+
else
|
|
234
|
+
delta = read_int16_io(io)
|
|
235
|
+
end
|
|
236
|
+
coord += delta
|
|
237
|
+
coords << coord
|
|
238
|
+
end
|
|
239
|
+
coords
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def assemble(s)
|
|
243
|
+
bbox_bitmap_bytes = ((@num_glyphs + 31) >> 5) << 2
|
|
244
|
+
# Pad bbox_bitmap to required size (struct may have fewer bytes allocated)
|
|
245
|
+
bb = s.bbox_bitmap
|
|
246
|
+
if bb.size < bbox_bitmap_bytes
|
|
247
|
+
bb.concat(Array.new(bbox_bitmap_bytes - bb.size, 0))
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
out = String.new(encoding: Encoding::BINARY)
|
|
251
|
+
out << [0].pack("S>") # version
|
|
252
|
+
out << [(s.has_overlap? ? 1 : 0)].pack("S>") # optionFlags
|
|
253
|
+
out << [@num_glyphs].pack("S>")
|
|
254
|
+
out << [@index_format].pack("S>")
|
|
255
|
+
out << [s.n_contour.bytesize].pack("L>")
|
|
256
|
+
out << [s.n_points.bytesize].pack("L>")
|
|
257
|
+
out << [s.flags.bytesize].pack("L>")
|
|
258
|
+
out << [s.glyph.bytesize].pack("L>")
|
|
259
|
+
out << [s.composite.bytesize].pack("L>")
|
|
260
|
+
out << [s.bbox_wire.bytesize].pack("L>")
|
|
261
|
+
out << [s.instructions.bytesize].pack("L>")
|
|
262
|
+
out << s.n_contour
|
|
263
|
+
out << s.n_points
|
|
264
|
+
out << s.flags
|
|
265
|
+
out << s.glyph
|
|
266
|
+
out << s.composite
|
|
267
|
+
out << s.bbox_wire
|
|
268
|
+
out << s.instructions
|
|
269
|
+
out << s.overlap_bitmap.pack("C*") if s.has_overlap?
|
|
270
|
+
out
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def parse_loca_offsets
|
|
274
|
+
if @index_format.zero?
|
|
275
|
+
@loca_data.unpack("n*").map { |v| v * 2 }
|
|
276
|
+
else
|
|
277
|
+
@loca_data.unpack("N*")
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def set_bbox_bit(s, glyph_id)
|
|
282
|
+
s.bbox_bitmap[glyph_id >> 3] |= (0x80 >> (glyph_id & 7))
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def read_int16(bytes, offset)
|
|
286
|
+
v = (bytes.getbyte(offset) << 8) | bytes.getbyte(offset + 1)
|
|
287
|
+
v > 0x7FFF ? v - 0x10000 : v
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def read_int16_io(io)
|
|
291
|
+
v = io.read(2).unpack1("n")
|
|
292
|
+
v > 0x7FFF ? v - 0x10000 : v
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def read_bbox(bytes, offset)
|
|
296
|
+
[
|
|
297
|
+
read_int16(bytes, offset),
|
|
298
|
+
read_int16(bytes, offset + 2),
|
|
299
|
+
read_int16(bytes, offset + 4),
|
|
300
|
+
read_int16(bytes, offset + 6),
|
|
301
|
+
]
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def calc_bounds(xs, ys)
|
|
305
|
+
return nil if xs.empty?
|
|
306
|
+
|
|
307
|
+
[xs.min, ys.min, xs.max, ys.max]
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def encode_255_uint16(value)
|
|
311
|
+
if value < 253
|
|
312
|
+
[value].pack("C")
|
|
313
|
+
elsif value < 506
|
|
314
|
+
[253, value - 253].pack("CC")
|
|
315
|
+
elsif value < 65_536
|
|
316
|
+
[254].pack("C") + [value].pack("n")
|
|
317
|
+
else
|
|
318
|
+
[255].pack("C") + [value - 506].pack("n")
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
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
|
@@ -5,9 +5,12 @@
|
|
|
5
5
|
module Fontisan
|
|
6
6
|
module Woff2
|
|
7
7
|
autoload :Directory, "fontisan/woff2/directory"
|
|
8
|
+
autoload :EncoderRules, "fontisan/woff2/encoder_rules"
|
|
9
|
+
autoload :GlyfLocaTransform, "fontisan/woff2/glyf_loca_transform"
|
|
8
10
|
autoload :GlyfTransformer, "fontisan/woff2/glyf_transformer"
|
|
9
11
|
autoload :HmtxTransformer, "fontisan/woff2/hmtx_transformer"
|
|
10
12
|
autoload :TableTransformer, "fontisan/woff2/table_transformer"
|
|
13
|
+
autoload :TripletCodec, "fontisan/woff2/triplet_codec"
|
|
11
14
|
autoload :Woff2Header, "fontisan/woff2/header"
|
|
12
15
|
end
|
|
13
16
|
end
|