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.
@@ -12,7 +12,8 @@ module Fontisan
12
12
  #
13
13
  # - `zlib_level` (0–9) — zlib compression level
14
14
  # - `uncompressed` (bool) — store tables uncompressed (legal per WOFF 1.0
15
- # §5.1; `compLength == origLength`)
15
+ # §5.1; `compLength == origLength`). Metadata is still compressed per
16
+ # spec §7 ("it is never stored in uncompressed form").
16
17
  # - `compression_threshold` (bytes) — skip compression for tables smaller
17
18
  # than N bytes (rarely needed; keeps tiny tables uncompressed)
18
19
  # - `metadata_xml` (string) — optional metadata block
@@ -119,24 +120,35 @@ module Fontisan
119
120
 
120
121
  # Write font to WOFF binary.
121
122
  #
123
+ # Layout per WOFF 1.0 spec:
124
+ # [header (44)] [table directory (20 × N)] [font tables, 4-byte aligned]
125
+ # [optional metadata, 4-byte aligned] [optional private data, 4-byte aligned]
126
+ #
127
+ # Each font table is padded with 0-3 null bytes to a 4-byte boundary
128
+ # (spec §5/§6: "Font data tables in the WOFF file have the same
129
+ # requirement: they MUST begin on 4-byte boundaries and be zero-padded
130
+ # to the next 4-byte boundary"). Padding aligns the next block.
131
+ #
122
132
  # @param font [TrueTypeFont, OpenTypeFont] Source font
123
133
  # @param zlib_level [Integer] 0–9
124
- # @param uncompressed [Boolean] skip zlib; store as-is
134
+ # @param uncompressed [Boolean] skip zlib; store tables as-is
125
135
  # @param compression_threshold [Integer] skip compression below N bytes
126
- # @param metadata [String, nil] optional metadata XML
136
+ # @param metadata [String, nil] optional metadata XML (always compressed
137
+ # per spec §7 — "it is never stored in uncompressed form")
127
138
  # @param private_data [String, nil] optional private data
128
139
  # @return [Hash{Symbol => String}] `{ woff_binary: <bytes> }`
129
140
  def write_font(font, zlib_level:, uncompressed:, compression_threshold:,
130
141
  metadata: nil, private_data: nil)
131
- tables_data = collect_tables_data(font)
142
+ tables = collect_tables_data(font)
132
143
  compressed_tables = compress_tables(
133
- tables_data,
144
+ tables,
134
145
  zlib_level: uncompressed ? 0 : zlib_level,
135
146
  skip_compression: uncompressed,
136
147
  compression_threshold: compression_threshold,
137
148
  )
138
- compressed_metadata = compress_metadata(metadata, zlib_level: zlib_level,
139
- skip_compression: uncompressed)
149
+ # Per spec §7, metadata MUST always be zlib-compressed regardless of
150
+ # the per-table `uncompressed` flag.
151
+ compressed_metadata = compress_metadata(metadata, zlib_level:)
140
152
  binary = build_woff_file(compressed_tables, font, compressed_metadata,
141
153
  private_data)
142
154
  { woff_binary: binary }
@@ -152,14 +164,16 @@ module Fontisan
152
164
  options.select { |k, _| names.include?(k.to_sym) }
153
165
  end
154
166
 
155
- # Collect all table data from font.
167
+ # Collect all table data from font in input-font order (spec §6: tables
168
+ # MUST be stored in the same order as the well-formed input font).
156
169
  #
157
170
  # @param font [TrueTypeFont, OpenTypeFont]
158
171
  # @return [Hash<String, String>]
159
172
  def collect_tables_data(font)
160
- font.table_names.to_h do |tag|
161
- [tag, font.table_data[tag]]
162
- end.compact
173
+ font.table_names.each_with_object({}) do |tag, h|
174
+ data = font.table_data[tag]
175
+ h[tag] = data if data
176
+ end
163
177
  end
164
178
 
165
179
  # Compress tables with zlib (or skip compression entirely).
@@ -197,71 +211,92 @@ module Fontisan
197
211
  end
198
212
  end
199
213
 
200
- # Compress metadata with zlib.
214
+ # Compress metadata with zlib. Per spec §7, metadata MUST always be
215
+ # zlib-compressed — `skip_compression` is intentionally not exposed
216
+ # here. The zlib output is used even when it is larger than the input
217
+ # (e.g. tiny XML payloads where zlib header+checksum overhead exceeds
218
+ # any compression gain); the spec mandates compression, not optimality.
219
+ # Returns nil if no metadata was supplied.
201
220
  #
202
221
  # @param metadata [String, nil]
203
222
  # @param zlib_level [Integer]
204
- # @param skip_compression [Boolean]
205
223
  # @return [Hash, nil]
206
- def compress_metadata(metadata, zlib_level:, skip_compression:)
224
+ def compress_metadata(metadata, zlib_level:)
207
225
  return nil unless metadata
208
226
 
209
227
  original_length = metadata.bytesize
210
- if skip_compression
211
- return {
212
- original_data: metadata,
213
- compressed_data: metadata,
214
- original_length: original_length,
215
- compressed_length: original_length,
216
- }
217
- end
218
-
219
228
  compressed = Zlib::Deflate.deflate(metadata, zlib_level)
220
- use_compressed = compressed.bytesize < original_length
221
229
  {
222
230
  original_data: metadata,
223
- compressed_data: use_compressed ? compressed : metadata,
231
+ compressed_data: compressed,
224
232
  original_length: original_length,
225
- compressed_length: use_compressed ? compressed.bytesize : original_length,
233
+ compressed_length: compressed.bytesize,
226
234
  }
227
235
  end
228
236
 
237
+ # Padding bytes needed to bring `length` up to a 4-byte boundary.
238
+ def padding_to_4(length)
239
+ (4 - (length % 4)) % 4
240
+ end
241
+
229
242
  # Assemble complete WOFF binary.
230
243
  #
231
- # @param compressed_tables [Hash]
244
+ # Computes the full layout (offsets + padding) up front so the header
245
+ # fields (metaOffset, privOffset) point to the correct locations, then
246
+ # emits header → directory → tables → optional metadata → optional
247
+ # private data.
248
+ #
249
+ # @param compressed_tables [Hash<String, Hash>] in input-font order
232
250
  # @param font [TrueTypeFont, OpenTypeFont]
233
251
  # @param compressed_metadata [Hash, nil]
234
252
  # @param private_data [String, nil]
235
253
  # @return [String]
236
254
  def build_woff_file(compressed_tables, font, compressed_metadata,
237
255
  private_data)
238
- io = StringIO.new
239
- io.set_encoding(Encoding::BINARY)
240
-
241
256
  header_size = 44
242
257
  num_tables = compressed_tables.length
243
- table_dir_size = num_tables * 20
244
- data_offset = header_size + table_dir_size
245
- metadata_offset = data_offset
246
- metadata_size = compressed_metadata ? compressed_metadata[:compressed_length] : 0
247
- total_compressed_size = compressed_tables.values.sum do |t|
248
- t[:compressed_length]
258
+ data_start = header_size + (num_tables * 20)
259
+
260
+ # Lay out tables in input-font order with 4-byte alignment padding.
261
+ # Each entry: { tag:, info:, offset:, pad_bytes: }
262
+ entries = []
263
+ cursor = data_start
264
+ compressed_tables.each do |tag, info|
265
+ pad = padding_to_4(info[:compressed_length])
266
+ entries << { tag:, info:, offset: cursor, pad_bytes: "\x00" * pad }
267
+ cursor += info[:compressed_length] + pad
249
268
  end
250
- private_offset = data_offset + total_compressed_size + metadata_size
269
+ tables_end = cursor
270
+
271
+ metadata_size = compressed_metadata ? compressed_metadata[:compressed_length] : 0
272
+ # metaOffset/privOffset are 0 when their block is absent (spec §4:
273
+ # offsets to optional blocks; convention is 0 for "not present").
274
+ metadata_offset = compressed_metadata ? tables_end : 0
275
+ metadata_end = tables_end + metadata_size
276
+
251
277
  private_size = private_data ? private_data.bytesize : 0
252
- total_size = private_offset + private_size
253
- total_sfnt_size = compressed_tables.values.sum do |t|
254
- t[:original_length]
255
- end +
256
- header_size + table_dir_size
278
+ private_offset = private_data ? metadata_end : 0
279
+ total_size = metadata_end + private_size
280
+
281
+ # totalSfntSize: reconstructed SFNT size with per-table 4-byte padding.
282
+ # spec §4: "Total size needed for the uncompressed font data, including
283
+ # the sfnt header, directory, and font tables (including padding)."
284
+ sfnt_header_size = 12
285
+ sfnt_dir_size = num_tables * 16
286
+ sfnt_tables_size = compressed_tables.values.sum do |t|
287
+ t[:original_length] + padding_to_4(t[:original_length])
288
+ end
289
+ total_sfnt_size = sfnt_header_size + sfnt_dir_size + sfnt_tables_size
257
290
 
291
+ io = StringIO.new
292
+ io.set_encoding(Encoding::BINARY)
258
293
  write_woff_header(
259
294
  io, font, total_size, total_sfnt_size, num_tables,
260
295
  compressed_metadata, metadata_offset, metadata_size,
261
296
  private_offset, private_size
262
297
  )
263
- write_table_directory(io, compressed_tables, data_offset)
264
- write_compressed_table_data(io, compressed_tables)
298
+ write_table_directory(io, entries)
299
+ write_compressed_table_data(io, entries)
265
300
  write_metadata(io, compressed_metadata) if compressed_metadata
266
301
  write_private_data(io, private_data) if private_data
267
302
 
@@ -293,25 +328,25 @@ module Fontisan
293
328
  io.write([private_size].pack("N")) # privLength
294
329
  end
295
330
 
296
- # Write table directory entries (20 bytes each).
297
- def write_table_directory(io, compressed_tables, data_offset)
298
- current_offset = data_offset
299
- compressed_tables.sort_by { |tag, _| tag }.each do |tag, info|
331
+ # Write table directory entries (20 bytes each), one per laid-out entry.
332
+ def write_table_directory(io, entries)
333
+ entries.each do |e|
300
334
  checksum = Utilities::ChecksumCalculator
301
- .calculate_table_checksum(info[:original_data])
302
- io.write(tag) # tag
303
- io.write([current_offset].pack("N")) # offset
304
- io.write([info[:compressed_length]].pack("N")) # compLength
305
- io.write([info[:original_length]].pack("N")) # origLength
306
- io.write([checksum].pack("N")) # origChecksum
307
- current_offset += info[:compressed_length]
335
+ .calculate_table_checksum(e[:info][:original_data])
336
+ io.write(e[:tag]) # tag
337
+ io.write([e[:offset]].pack("N")) # offset
338
+ io.write([e[:info][:compressed_length]].pack("N")) # compLength
339
+ io.write([e[:info][:original_length]].pack("N")) # origLength
340
+ io.write([checksum].pack("N")) # origChecksum
308
341
  end
309
342
  end
310
343
 
311
- # Write compressed table data, sorted by tag (matches directory order).
312
- def write_compressed_table_data(io, compressed_tables)
313
- compressed_tables.sort_by { |tag, _| tag }.each do |_, info|
314
- io.write(info[:compressed_data])
344
+ # Write each table's compressed data followed by 4-byte alignment
345
+ # padding (spec §5/§6: tables MUST be zero-padded to next boundary).
346
+ def write_compressed_table_data(io, entries)
347
+ entries.each do |e|
348
+ io.write(e[:info][:compressed_data])
349
+ io.write(e[:pad_bytes])
315
350
  end
316
351
  end
317
352
 
@@ -431,9 +431,14 @@ module Fontisan
431
431
 
432
432
  # Get list of all table tags (cached for performance)
433
433
  #
434
- # @return [Array<String>] Array of table tag strings
434
+ # Returns plain UTF-8 Ruby Strings, not BinData::String instances —
435
+ # callers can rely on `eql?`/`==`/Hash lookup working against literal
436
+ # strings. BinData::String is encoding-sensitive and breaks Hash key
437
+ # comparison silently.
438
+ #
439
+ # @return [Array<String>] Array of table tag strings (UTF-8)
435
440
  def table_names
436
- @table_names ||= tables.map(&:tag)
441
+ @table_names ||= tables.map { |entry| normalize_tag(entry.tag) }
437
442
  end
438
443
 
439
444
  # Get OOP SfntTable instance for a table
@@ -18,6 +18,16 @@ module Fontisan
18
18
  # Magic number that must be present in the head table
19
19
  MAGIC_NUMBER = 0x5F0F3CF5
20
20
 
21
+ # Bit flags for the `flags` field (uint16), per OpenType spec.
22
+ # Only bits we currently reference are named here; see the OpenType
23
+ # `head` table specification for the full list.
24
+ #
25
+ # Bit 11 indicates the font data has been losslessly modified
26
+ # (e.g. by subsetting or WOFF2 encoding). Per WOFF2 spec section 5,
27
+ # encoders MUST set this bit. Chrome's OpenType Sanitizer (OTS)
28
+ # rejects WOFF2 files where it is not set.
29
+ FLAG_LOSSLESS_MODIFYING = 0x0800
30
+
21
31
  # Version as 16.16 fixed-point (stored as int32)
22
32
  int32 :version_raw
23
33
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fontisan
4
- VERSION = "0.4.12"
4
+ VERSION = "0.4.14"
5
5
  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
@@ -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