fontisan 0.4.13 → 0.4.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,226 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+
5
+ module Fontisan
6
+ module Woff2
7
+ # Decodes a WOFF2 font collection per spec section 4.2. This is the
8
+ # decoder counterpart of {Woff2::CollectionEncoder}.
9
+ #
10
+ # Layout parsed:
11
+ # [WOFF2Header (48 bytes, flavor = 'ttcf')]
12
+ # [TableDirectory] # one entry per unique table
13
+ # [CollectionDirectory] # CollectionHeader + N CollectionFontEntry
14
+ # [CompressedFontData] # single brotli stream of all tables
15
+ #
16
+ # Each CollectionFontEntry references tables in the TableDirectory via
17
+ # indices. The decoder yields one reconstructed font per entry, with
18
+ # glyf/loca reconstruction per spec section 5.1/5.3 applied.
19
+ #
20
+ # Reference: W3C WOFF2 spec, section 4.2.
21
+ class CollectionDecoder
22
+ TTC_FLAVOR = 0x74746366 # 'ttcf'
23
+
24
+ # Parsed table directory entry: tag + raw bytes + transform state.
25
+ TableEntry = Struct.new(:tag, :raw_bytes, :orig_length, :transform_length,
26
+ keyword_init: true)
27
+
28
+ # Parsed CollectionFontEntry: per-font table indices + flavor.
29
+ FontEntry = Struct.new(:flavor, :table_indices, keyword_init: true)
30
+
31
+ # @param data [String] WOFF2 collection binary
32
+ def initialize(data)
33
+ @data = data.dup.force_encoding(Encoding::BINARY)
34
+ end
35
+
36
+ # Decode the WOFF2 collection, returning one Hash per font containing
37
+ # the reconstructed table data ready to assemble into an SFNT.
38
+ #
39
+ # @return [Array<Hash{Symbol => Object}>] per-font:
40
+ # {flavor:, tables: {tag ⇒ bytes}}
41
+ def decode
42
+ validate_signature!
43
+ validate_collection_flavor!
44
+
45
+ table_entries, post_dir_pos = read_table_directory
46
+ font_entries, post_coll_pos = read_collection_directory(post_dir_pos)
47
+ decompressed = decompress_table_data(post_coll_pos)
48
+
49
+ # Replace each table entry's raw_bytes by slicing from the
50
+ # decompressed stream at its cumulative offset.
51
+ cursor = 0
52
+ table_entries.each do |e|
53
+ len = e.transform_length || e.orig_length
54
+ e.raw_bytes = decompressed[cursor, len]
55
+ cursor += len
56
+ end
57
+
58
+ # For each font, gather its tables and reconstruct glyf/loca when
59
+ # the glyf was transformed.
60
+ font_entries.map do |fe|
61
+ font_tables = {}
62
+ fe.table_indices.each do |idx|
63
+ entry = table_entries.fetch(idx)
64
+ font_tables[entry.tag] = entry.raw_bytes
65
+ end
66
+ reconstruct_glyf_loca!(font_tables) if font_tables.key?("glyf")
67
+ { flavor: fe.flavor, tables: font_tables }
68
+ end
69
+ end
70
+
71
+ private
72
+
73
+ def validate_signature!
74
+ sig = @data[0, 4].unpack1("N")
75
+ return if sig == Woff2Header::SIGNATURE
76
+
77
+ raise InvalidFontError,
78
+ "Invalid WOFF2 signature: 0x#{sig.to_s(16)}"
79
+ end
80
+
81
+ def validate_collection_flavor!
82
+ flavor = @data[4, 4].unpack1("N")
83
+ return if flavor == TTC_FLAVOR
84
+
85
+ raise InvalidFontError,
86
+ "WOFF2 file is not a collection (flavor 0x#{flavor.to_s(16)}; " \
87
+ "expected 0x#{TTC_FLAVOR.to_s(16)} 'ttcf')"
88
+ end
89
+
90
+ def num_tables = @data[12, 2].unpack1("n")
91
+
92
+ def total_compressed_size = @data[20, 4].unpack1("N")
93
+
94
+ # Walk num_tables entries, returning the array of TableEntry and the
95
+ # byte position immediately after the table directory.
96
+ def read_table_directory
97
+ entries = []
98
+ pos = 48
99
+ num_tables.times do
100
+ entry, pos = read_one_directory_entry(pos)
101
+ entries << entry
102
+ end
103
+ [entries, pos]
104
+ end
105
+
106
+ def read_one_directory_entry(pos)
107
+ flags = @data.getbyte(pos)
108
+ pos += 1
109
+ tag_index = flags & 0x3F
110
+ transform_version = (flags >> 6) & 0x03
111
+ tag = if tag_index == 0x3F
112
+ t = @data[pos, 4]
113
+ pos += 4
114
+ t
115
+ else
116
+ Directory::KNOWN_TAGS.fetch(tag_index)
117
+ end
118
+ orig_length, pos = read_uint_base128(pos)
119
+ transform_length = nil
120
+ is_glyf_loca = ["glyf", "loca"].include?(tag)
121
+ is_transformed_hmtx = tag == "hmtx" && transform_version == 1
122
+ if (is_glyf_loca && transform_version.zero?) || is_transformed_hmtx
123
+ transform_length, pos = read_uint_base128(pos)
124
+ end
125
+ entry = TableEntry.new(tag:, raw_bytes: nil, orig_length:,
126
+ transform_length:)
127
+ [entry, pos]
128
+ end
129
+
130
+ # Read CollectionHeader + N CollectionFontEntry records.
131
+ def read_collection_directory(pos)
132
+ version = @data[pos, 4].unpack1("N")
133
+ pos += 4
134
+ raise InvalidFontError, "Unsupported CollectionHeader version 0x#{version.to_s(16)}" unless version == 0x00010000
135
+
136
+ num_fonts, pos = read_255_uint16(pos)
137
+ font_entries = Array.new(num_fonts) do
138
+ num_tables_f, pos2 = read_255_uint16(pos)
139
+ pos = pos2
140
+ flavor = @data[pos, 4].unpack1("N")
141
+ pos += 4
142
+ indices = Array.new(num_tables_f) do
143
+ idx, pos2 = read_255_uint16(pos)
144
+ pos = pos2
145
+ idx
146
+ end
147
+ FontEntry.new(flavor:, table_indices: indices)
148
+ end
149
+ [font_entries, pos]
150
+ end
151
+
152
+ def decompress_table_data(start_pos)
153
+ compressed = @data[start_pos, total_compressed_size]
154
+ Utilities::BrotliWrapper.decompress(compressed)
155
+ end
156
+
157
+ # Reconstruct glyf + loca per spec section 5.1/5.3 when glyf is in
158
+ # transformed format. Replaces font_tables["glyf"] with reconstructed
159
+ # bytes and adds font_tables["loca"].
160
+ def reconstruct_glyf_loca!(font_tables)
161
+ glyf_bytes = font_tables["glyf"]
162
+ return unless glyf_bytes && glyf_bytes.bytesize >= 36
163
+
164
+ num_glyphs = parse_num_glyphs(font_tables)
165
+ index_format = parse_index_format(font_tables)
166
+ return unless num_glyphs && index_format
167
+
168
+ result = GlyfLocaReconstruct.new(
169
+ transformed_glyf: glyf_bytes,
170
+ num_glyphs:,
171
+ index_format:,
172
+ ).reconstruct
173
+ font_tables["glyf"] = result[:glyf]
174
+ font_tables["loca"] = result[:loca]
175
+ end
176
+
177
+ def parse_num_glyphs(font_tables)
178
+ maxp = font_tables["maxp"]
179
+ return nil unless maxp && maxp.bytesize >= 6
180
+
181
+ maxp[4, 2].unpack1("n")
182
+ end
183
+
184
+ def parse_index_format(font_tables)
185
+ head = font_tables["head"]
186
+ return nil unless head && head.bytesize >= 52
187
+
188
+ head[50, 2].unpack1("n")
189
+ end
190
+
191
+ def read_uint_base128(pos)
192
+ value = 0
193
+ 5.times do
194
+ byte = @data.getbyte(pos)
195
+ pos += 1
196
+ if (byte & 0x80).zero?
197
+ value = (value << 7) | byte
198
+ return [value, pos]
199
+ end
200
+ value = (value << 7) | (byte & 0x7F)
201
+ end
202
+ raise InvalidFontError, "UIntBase128 sequence exceeds 5 bytes"
203
+ end
204
+
205
+ def read_255_uint16(pos)
206
+ code = @data.getbyte(pos)
207
+ pos += 1
208
+ case code
209
+ when 0..252 then [code, pos]
210
+ when 253
211
+ v = @data.getbyte(pos)
212
+ pos += 1
213
+ [253 + v, pos]
214
+ when 254
215
+ v = @data[pos, 2].unpack1("n")
216
+ pos += 2
217
+ [v, pos]
218
+ when 255
219
+ v = @data[pos, 2].unpack1("n")
220
+ pos += 2
221
+ [v + 506, pos]
222
+ end
223
+ end
224
+ end
225
+ end
226
+ end
@@ -0,0 +1,288 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+ require "digest/sha2"
5
+
6
+ module Fontisan
7
+ module Woff2
8
+ # Encodes a TTC/OTC font collection into WOFF2 collection format per
9
+ # spec section 4.2.
10
+ #
11
+ # Output layout:
12
+ # [WOFF2Header (48 bytes, flavor = 'ttcf')]
13
+ # [TableDirectory] # one entry per unique table
14
+ # [CollectionDirectory] # CollectionHeader + N CollectionFontEntry
15
+ # [CompressedFontData] # single brotli stream of all tables
16
+ #
17
+ # Each font references tables via indices in its CollectionFontEntry.
18
+ # Identical tables across fonts share a single directory entry.
19
+ #
20
+ # Per spec section 5.5, glyf/loca pairs from the same font MUST be
21
+ # adjacent (loca immediately follows glyf) in the directory.
22
+ class CollectionEncoder
23
+ TTC_FLAVOR = 0x74746366 # 'ttcf'
24
+
25
+ # @param brotli_quality [Integer] 0-11
26
+ def initialize(brotli_quality: 11)
27
+ @brotli_quality = brotli_quality
28
+ end
29
+
30
+ # Encode an array of fonts as a WOFF2 collection.
31
+ #
32
+ # @param fonts [Array<TrueTypeFont, OpenTypeFont>] Source fonts
33
+ # @return [String] WOFF2 collection binary
34
+ def encode_fonts(fonts)
35
+ raise ArgumentError, "fonts cannot be empty" if fonts.nil? || fonts.empty?
36
+
37
+ prepared = fonts.map.with_index { |font, i| prepare_font(font, i) }
38
+ _, font_to_ckeys = deduplicate(prepared)
39
+ layout = build_layout(prepared, font_to_ckeys)
40
+ compressed = compress_stream(layout)
41
+ assemble(fonts:, layout:, font_to_ckeys:, compressed:)
42
+ end
43
+
44
+ private
45
+
46
+ # Prepare a single font: collect tables, apply encoder rules, run
47
+ # glyf/loca transform. Returns a Hash:
48
+ # {index:, tables: {tag ⇒ bytes}, loca_orig_length: (or nil)}
49
+ def prepare_font(font, index)
50
+ tables = {}
51
+ font.table_names.each do |tag|
52
+ data = font.table_data[tag]
53
+ next unless data && !data.empty?
54
+
55
+ tables[tag] = data
56
+ end
57
+
58
+ EncoderRules.apply!(tables)
59
+ loca_orig_length = apply_glyf_loca_transform!(tables, font)
60
+
61
+ { index:, tables:, loca_orig_length: }
62
+ end
63
+
64
+ # Apply the glyf/loca transform in place. Removes "loca" from
65
+ # `tables`. Returns the original loca length, or nil if not applied.
66
+ def apply_glyf_loca_transform!(tables, font)
67
+ return nil unless tables.key?("glyf") && tables.key?("loca")
68
+
69
+ maxp = font.table("maxp")
70
+ head = font.table("head")
71
+ return nil unless maxp && head
72
+
73
+ loca_orig_length = tables["loca"].bytesize
74
+ tables["glyf"] = GlyfLocaTransform.new(
75
+ glyf_data: tables["glyf"],
76
+ loca_data: tables["loca"],
77
+ num_glyphs: maxp.num_glyphs,
78
+ index_format: head.index_to_loc_format,
79
+ ).transform
80
+ tables.delete("loca")
81
+ loca_orig_length
82
+ end
83
+
84
+ # Deduplicate identical tables across fonts. Returns:
85
+ # canonical: ckey → {tag, data}
86
+ # font_to_ckeys: Array (per font) of {tag ⇒ ckey}
87
+ def deduplicate(prepared)
88
+ canonical = {}
89
+ font_to_ckeys = Array.new(prepared.size) { {} }
90
+ seen = {} # "tag:sha256" → ckey
91
+
92
+ prepared.each do |pf|
93
+ pf[:tables].each do |tag, data|
94
+ cache_key = "#{tag}:#{Digest::SHA256.digest(data)}"
95
+ if (existing = seen[cache_key])
96
+ font_to_ckeys[pf[:index]][tag] = existing
97
+ else
98
+ ckey = "#{tag}@#{pf[:index]}"
99
+ canonical[ckey] = { tag:, data: }
100
+ seen[cache_key] = ckey
101
+ font_to_ckeys[pf[:index]][tag] = ckey
102
+ end
103
+ end
104
+ end
105
+
106
+ [canonical, font_to_ckeys]
107
+ end
108
+
109
+ # Walk fonts in order; for each font, walk tags in spec-mandated
110
+ # order (glyf → loca placeholder → rest alphabetical). Each unique
111
+ # ckey appears once in the layout.
112
+ #
113
+ # Loca placeholders are keyed by the glyf ckey they pair with, so
114
+ # fonts that share a transformed glyf (via deduplication) also
115
+ # share the loca placeholder. Per spec section 5.5, loca MUST
116
+ # immediately follow glyf in the directory.
117
+ def build_layout(prepared, font_to_ckeys)
118
+ seen = {}
119
+ entries = []
120
+
121
+ prepared.each do |pf|
122
+ tags = pf[:tables].keys
123
+ ordered = order_tags_for_font(tags, has_loca: pf[:loca_orig_length])
124
+
125
+ ordered.each do |tag|
126
+ if tag == "loca_placeholder"
127
+ # Handled below as part of the glyf branch.
128
+ next
129
+ end
130
+
131
+ ckey = font_to_ckeys[pf[:index]][tag]
132
+ unless seen[ckey]
133
+ data = pf[:tables][tag]
134
+ seen[ckey] = entries.size
135
+ entries << {
136
+ ckey:, tag:,
137
+ data:,
138
+ orig_length: data.bytesize,
139
+ transform_length: (tag == "glyf" ? data.bytesize : nil)
140
+ }
141
+ end
142
+
143
+ # loca placeholder immediately after glyf. Tied to glyf's ckey
144
+ # so fonts that share a glyf also share its loca placeholder.
145
+ if tag == "glyf" && pf[:loca_orig_length]
146
+ placeholder_key = "loca_placeholder_for:#{ckey}"
147
+ unless seen[placeholder_key]
148
+ seen[placeholder_key] = entries.size
149
+ entries << {
150
+ ckey: placeholder_key,
151
+ tag: "loca",
152
+ data: nil,
153
+ orig_length: pf[:loca_orig_length],
154
+ transform_length: 0,
155
+ }
156
+ end
157
+ end
158
+ end
159
+ end
160
+
161
+ entries
162
+ end
163
+
164
+ # Per-font tag order: glyf first, loca placeholder immediately after,
165
+ # rest alphabetical. Spec section 5.5.
166
+ def order_tags_for_font(tags, has_loca:)
167
+ return tags.sort unless has_loca
168
+
169
+ %w[glyf loca_placeholder] + (tags - ["glyf"]).sort
170
+ end
171
+
172
+ # Concatenate unique table data in directory order. Placeholder
173
+ # entries (loca after transform) contribute 0 bytes per spec 5.3.
174
+ def compress_stream(layout)
175
+ combined = String.new(encoding: Encoding::BINARY)
176
+ layout.each do |e|
177
+ combined << e[:data] if e[:data]
178
+ end
179
+ Utilities::BrotliWrapper.compress(combined, quality: @brotli_quality)
180
+ end
181
+
182
+ def assemble(fonts:, layout:, font_to_ckeys:, compressed:)
183
+ io = StringIO.new
184
+ io.set_encoding(Encoding::BINARY)
185
+
186
+ io.write("\x00" * 48) # reserve header
187
+ layout.each { |e| write_table_directory_entry(io, e) }
188
+
189
+ ckey_to_index = layout.each_with_index.to_h { |e, i| [e[:ckey], i] }
190
+ write_collection_directory(io, fonts, font_to_ckeys, ckey_to_index)
191
+
192
+ io.write(compressed)
193
+
194
+ # Pad to a 4-byte boundary. The W3C reference decoder validates that
195
+ # the file's `length` field matches the actual byte count, and many
196
+ # decoders expect a 4-byte-aligned file length for collections.
197
+ pad = (4 - (io.pos % 4)) % 4
198
+ io.write("\x00" * pad) if pad.positive?
199
+ total_size = io.size
200
+
201
+ io.pos = 0
202
+ write_header(io, num_tables: layout.size,
203
+ total_compressed_size: compressed.bytesize, total_size:)
204
+ io.string
205
+ end
206
+
207
+ def write_header(io, num_tables:, total_compressed_size:, total_size:)
208
+ io.write([Woff2::Woff2Header::SIGNATURE].pack("N"))
209
+ io.write([TTC_FLAVOR].pack("N"))
210
+ io.write([total_size].pack("N"))
211
+ io.write([num_tables].pack("n"))
212
+ io.write([0].pack("n"))
213
+ io.write([0].pack("N")) # totalSfntSize (ref only)
214
+ io.write([total_compressed_size].pack("N"))
215
+ io.write([1].pack("n")) # majorVersion
216
+ io.write([0].pack("n")) # minorVersion
217
+ io.write([0].pack("N")) # metaOffset
218
+ io.write([0].pack("N")) # metaLength
219
+ io.write([0].pack("N")) # metaOrigLength
220
+ io.write([0].pack("N")) # privOffset
221
+ io.write([0].pack("N")) # privLength
222
+ end
223
+
224
+ def write_table_directory_entry(io, entry)
225
+ dir_entry = Directory::Entry.new
226
+ dir_entry.tag = entry[:tag]
227
+ dir_entry.orig_length = entry[:orig_length]
228
+ dir_entry.transform_length = entry[:transform_length]
229
+ flags = dir_entry.calculate_flags
230
+ io.write([flags].pack("C"))
231
+ io.write(entry[:tag].ljust(4, "\x00")) unless dir_entry.known_tag?
232
+ io.write(Directory.encode_uint_base128(entry[:orig_length]))
233
+ return unless dir_entry.transformed?
234
+
235
+ io.write(Directory.encode_uint_base128(entry[:transform_length] || 0))
236
+ end
237
+
238
+ def write_collection_directory(io, fonts, font_to_ckeys, ckey_to_index)
239
+ # CollectionHeader
240
+ io.write([0x00010000].pack("N")) # version 1.0
241
+ io.write(encode_255_uint16(fonts.size))
242
+
243
+ # One CollectionFontEntry per font
244
+ fonts.each_with_index do |font, fi|
245
+ font_keys = font_to_ckeys[fi]
246
+ glyf_ckey = font_keys["glyf"]
247
+ placeholder_key = glyf_ckey && "loca_placeholder_for:#{glyf_ckey}"
248
+ has_placeholder = ckey_to_index.key?(placeholder_key)
249
+ # CollectionFontEntry index list is in font's natural alphabetical
250
+ # order (including "loca" if a placeholder exists). The glyf/loca
251
+ # adjacency constraint applies to the table directory, not the
252
+ # entry index list per spec section 5.5.
253
+ ordered = font_keys.keys
254
+ ordered << "loca" if has_placeholder
255
+ ordered = ordered.sort
256
+
257
+ io.write(encode_255_uint16(ordered.size))
258
+ io.write([font_flavor(font)].pack("N"))
259
+ ordered.each do |tag|
260
+ ckey = font_keys[tag] || placeholder_key
261
+ index = ckey_to_index.fetch(ckey)
262
+ io.write(encode_255_uint16(index))
263
+ end
264
+ end
265
+ end
266
+
267
+ def font_flavor(font)
268
+ if font.has_table?("CFF ") || font.has_table?("CFF2")
269
+ Constants::SFNT_VERSION_OTTO
270
+ else
271
+ Constants::SFNT_VERSION_TRUETYPE
272
+ end
273
+ end
274
+
275
+ def encode_255_uint16(value)
276
+ if value < 253
277
+ [value].pack("C")
278
+ elsif value < 506
279
+ [253, value - 253].pack("CC")
280
+ elsif value < 65_536
281
+ [254].pack("C") + [value].pack("n")
282
+ else
283
+ [255].pack("C") + [value - 506].pack("n")
284
+ end
285
+ end
286
+ end
287
+ end
288
+ end
@@ -52,13 +52,20 @@ module Fontisan
52
52
  # the 'flags' field of the head table ... to indicate that a recreated
53
53
  # font file was subjected to lossless modifying transform."
54
54
  #
55
- # Uses the model-driven Head BinData record so the bit is set via a
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
56
62
  # named attribute on a typed object, not via raw byte slicing.
57
63
  def self.mark_lossless_modifying!(table_data)
58
64
  return unless table_data.key?("head")
59
65
 
60
66
  head = Tables::Head.read(table_data["head"])
61
67
  head.flags |= Tables::Head::FLAG_LOSSLESS_MODIFYING
68
+ head.index_to_loc_format = 0
62
69
  table_data["head"] = head.to_binary_s
63
70
  end
64
71
  end