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.
@@ -0,0 +1,73 @@
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
+ # Also forces head.indexToLocFormat=0 when the glyf/loca tables are
56
+ # transformed, because Chrome's OTS only accepts WOFF2 glyf
57
+ # indexFormat=0 (see GlyfLocaTransform). For the reconstructed font
58
+ # to be self-consistent, head.indexToLocFormat must match the loca
59
+ # format that the decoder will produce (always short, format=0).
60
+ #
61
+ # Uses the model-driven Head BinData record so the bits are set via a
62
+ # named attribute on a typed object, not via raw byte slicing.
63
+ def self.mark_lossless_modifying!(table_data)
64
+ return unless table_data.key?("head")
65
+
66
+ head = Tables::Head.read(table_data["head"])
67
+ head.flags |= Tables::Head::FLAG_LOSSLESS_MODIFYING
68
+ head.index_to_loc_format = 0
69
+ table_data["head"] = head.to_binary_s
70
+ end
71
+ end
72
+ end
73
+ end
@@ -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