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.
@@ -46,15 +46,22 @@ module Fontisan
46
46
  "trak", "Zapf", "Silf", "Glat", "Gloc", "Feat", "Sill"
47
47
  ].freeze
48
48
 
49
- # Transformation versions
50
- # According to WOFF2 spec:
51
- # - glyf/loca: version 0 or 3 WITH transformLength = transformed
52
- # - glyf/loca: version 1 or 2 WITHOUT transformLength = not transformed
53
- # - hmtx: version 1 WITH transformLength = transformed
54
- # - hmtx: version 0, 2, or 3 WITHOUT transformLength = not transformed
55
- TRANSFORM_NONE = 3 # Use version 3 when not transformed (works for all tables)
56
- TRANSFORM_GLYF_LOCA = 0 # glyf/loca use version 0 when transformed
57
- TRANSFORM_HMTX = 1 # hmtx uses version 1 when transformed
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
- # Check if table is transformed
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 transformed
111
+ # @return [Boolean] True if a transformLength field should be written
98
112
  def transformed?
99
- !transform_length.nil? && transform_length.positive?
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 transformation version for this table
130
+ # Determine the transform version to encode in the flags byte.
117
131
  #
118
- # Returns the appropriate version based on:
119
- # 1. Whether table has transform_length set (is transformed)
120
- # 2. Which table it is (glyf/loca vs hmtx vs other)
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 transformed?
125
- # Table IS transformed - use appropriate transform version
126
- case tag
127
- when "glyf", "loca"
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
- # Table is NOT transformed - use version that indicates no transformation
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
@@ -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