fontisan 0.4.17 → 0.4.18

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b874b7ec1a441c955ee5e1a75d0477ddac875cfbb109cd4111919a94210f5ee3
4
- data.tar.gz: ef33acdcc6c296877c745852032a315588da42cfa1017e885bcf41e9fd66f474
3
+ metadata.gz: 2991840074aedcf587e7bbc673cd6b26b3c887f5e6b481b83c47d6a43151d287
4
+ data.tar.gz: 8cd0901c7e2fe8953e7ea24349c11c63b651ca989abbdfc4243e0ed3dba280f8
5
5
  SHA512:
6
- metadata.gz: c3ce64b5b2cdf9a2c2604a638b8aaf0c1b681f2dab67377acf61f187960c68891920026fe9474ddc334ac8c4fdc491dcbe635f99e5e4f4cc31342def2d55f3b4
7
- data.tar.gz: fa84952690ed773f7b38a40059bacf1efadc591c5cd2077a56e07854682c03c28228efc5e623b248302237fa2352336834feea77fe817ab655293cd3ebda0242
6
+ metadata.gz: 37053820640d91782777be8982ac22cf0bc09cdcaff60b484161ce711785ebb9462d3c333fe4564699e381db4557656d049f77394b64ecb2935d54e1f80d93be
7
+ data.tar.gz: 92d7207a5babbbfb155b4aec2cd9f4adfff4efcbe84886f58480a14596fcfa9941f6e406e4c7e4b3f9a822e7169277767cfd69ed8841ab4de3acfb8bdd64cb9e
@@ -28,6 +28,11 @@ module Fontisan
28
28
  class Woff2Encoder
29
29
  include ConversionStrategy
30
30
 
31
+ # Highest glyf offset addressable by short loca (uint16 max × 2).
32
+ # Above this, loca MUST use the long format. Public so specs can
33
+ # reference the same threshold the encoder uses.
34
+ SHORT_LOCA_GLYF_MAX = 0x1FFFE
35
+
31
36
  # @return [Hash] Configuration settings
32
37
  attr_reader :config
33
38
 
@@ -83,6 +88,13 @@ module Fontisan
83
88
  transform_enabled,
84
89
  glyf_transform:)
85
90
 
91
+ # Recompute head.checksumAdjustment for the SFNT the decoder
92
+ # reconstructs. Chrome's OTS validates this strictly; a stale
93
+ # source value fails once glyf/loca/head have been modified.
94
+ # Must run AFTER touch_head_modified! so the modified timestamp
95
+ # change is included in the checksum.
96
+ update_checksum_adjustment!(table_data, flavor:, glyf_transform:)
97
+
86
98
  compressed_data = compress_tables(entries, table_data,
87
99
  transformed_data, quality)
88
100
 
@@ -221,17 +233,17 @@ module Fontisan
221
233
 
222
234
  # Apply the WOFF2 glyf/loca paired transform per spec section 5.1/5.3.
223
235
  #
224
- # Returns a hash with the transformed glyf bytes and the original
225
- # loca length needed to emit its synthetic directory entry, or nil
226
- # for CFF fonts or fonts missing glyf/loca. The original glyf bytes
227
- # are kept in `table_data` so its `origLength` is preserved; the
228
- # transformed bytes are passed to the entries builder separately.
236
+ # Returns a hash with the transformed glyf bytes and reconstructed
237
+ # glyf/loca bytes the directory/checksum must reference, or nil for
238
+ # CFF fonts or fonts missing glyf/loca.
229
239
  #
230
- # glyf.origLength is computed by round-tripping the transformed glyf
231
- # through the decoder this guarantees the directory matches what
232
- # any compliant decoder will produce, including padding and minor
233
- # re-encoding differences (OFF section 5.3.3 allows multiple valid
234
- # reconstructions of the same glyph data).
240
+ # The source glyf is first canonicalized to 4-byte-per-glyph
241
+ # alignment via `Woff2::GlyfCanonicalizer`, matching fontTools'
242
+ # `_normaliseGlyfAndLoca(padding=4)`. Chrome's OTS fails to
243
+ # reconstruct "unpadded" glyf tables see fontTools'
244
+ # WOFF2Writer.close() comment. The canonical glyf is the input to
245
+ # the WOFF2 transform, the value of `glyf.origLength` in the
246
+ # directory, and the bytes the SFNT checksum is computed over.
235
247
  #
236
248
  # @return [Hash{Symbol => Object}, nil]
237
249
  def apply_glyf_loca_transform!(table_data, font)
@@ -246,32 +258,55 @@ module Fontisan
246
258
  head = font.table("head")
247
259
  return nil unless maxp && head
248
260
 
249
- index_format = head.index_to_loc_format
250
-
251
- transformed = Woff2::GlyfLocaTransform.new(
261
+ source_format = head.index_to_loc_format
262
+
263
+ # Pick the most compact loca format the canonical glyf fits in.
264
+ # The canonical glyf adds ≤ 3 padding bytes per glyph; estimate
265
+ # the upper bound so the format choice is safe. fontTools'
266
+ # encoder does the same — short loca halves the loca table size
267
+ # and Chrome's OTS accepts both formats.
268
+ max_canonical_glyf_size = glyf_data.bytesize + 3 * maxp.num_glyphs
269
+ target_format = if max_canonical_glyf_size <= SHORT_LOCA_GLYF_MAX
270
+ 0
271
+ else
272
+ source_format
273
+ end
274
+
275
+ canonical = Woff2::GlyfCanonicalizer.new(
252
276
  glyf_data:,
253
277
  loca_data:,
254
278
  num_glyphs: maxp.num_glyphs,
255
- index_format:,
256
- ).transform
279
+ source_format:,
280
+ target_format:,
281
+ ).canonical
282
+ canonical_glyf = canonical[:glyf]
283
+ canonical_loca = canonical[:loca]
257
284
 
258
- # Round-trip through the decoder to get the exact reconstructed
259
- # sizes the directory must advertise. fontTools re-compiles glyf
260
- # during encoding, producing sizes that differ from the source by
261
- # small amounts (encoding variations per OFF section 5.3.3). Using
262
- # the source bytesize causes fontTools' decoder to reject the
263
- # WOFF2 with "not enough 'glyf' table data".
264
- reconstructed = Woff2::GlyfLocaReconstruct.new(
265
- transformed_glyf: transformed,
285
+ transformed = Woff2::GlyfLocaTransform.new(
286
+ glyf_data: canonical_glyf,
287
+ loca_data: canonical_loca,
266
288
  num_glyphs: maxp.num_glyphs,
267
- index_format:,
268
- ).reconstruct
289
+ index_format: target_format,
290
+ ).transform
269
291
 
270
- glyf_orig_length = reconstructed[:glyf].bytesize
271
- loca_orig_length = reconstructed[:loca].bytesize
292
+ if target_format != source_format
293
+ Woff2::EncoderRules.set_head_index_to_loc_format!(
294
+ table_data, target_format
295
+ )
296
+ end
297
+
298
+ glyf_orig_length = canonical_glyf.bytesize
299
+ loca_orig_length = canonical_loca.bytesize
300
+ table_data["glyf"] = canonical_glyf
272
301
  table_data.delete("loca")
273
302
 
274
- { transformed_glyf: transformed, glyf_orig_length:, loca_orig_length: }
303
+ {
304
+ transformed_glyf: transformed,
305
+ reconstructed_glyf: canonical_glyf,
306
+ reconstructed_loca: canonical_loca,
307
+ glyf_orig_length:,
308
+ loca_orig_length:,
309
+ }
275
310
  end
276
311
 
277
312
  def get_table_data(font, tag)
@@ -396,6 +431,45 @@ module Fontisan
396
431
  size
397
432
  end
398
433
 
434
+ # Recompute head.checksumAdjustment for the SFNT the WOFF2 decoder
435
+ # will reconstruct. The decoder assembles glyf/loca from the
436
+ # transformed stream and uses the source bytes for every other
437
+ # table; the resulting SFNT's whole-file uint32 sum must equal
438
+ # 0xB1B0AFBA once checksumAdjustment is applied. Source's value is
439
+ # stale after head.flags/glyf/indexToLocFormat edits and Chrome's
440
+ # OTS rejects files that fail the checksum check.
441
+ #
442
+ # Mutates `table_data["head"]` in place via the Head BinData model.
443
+ def update_checksum_adjustment!(table_data, flavor:, glyf_transform:)
444
+ return unless table_data.key?("head")
445
+
446
+ # The synthetic loca entry was removed from table_data by
447
+ # apply_glyf_loca_transform!, but it's still part of the
448
+ # reconstructed SFNT, so it must be included in the checksum.
449
+ tags = table_data.keys.sort
450
+ if glyf_transform && !tags.include?("loca")
451
+ loca_idx = tags.index { |t| t > "loca" } || tags.length
452
+ tags.insert(loca_idx, "loca")
453
+ end
454
+
455
+ tables = tags.map do |tag|
456
+ bytes = if tag == "glyf" && glyf_transform
457
+ glyf_transform[:reconstructed_glyf]
458
+ elsif tag == "loca" && glyf_transform
459
+ glyf_transform[:reconstructed_loca]
460
+ else
461
+ table_data[tag]
462
+ end
463
+ Woff2::SfntChecksum::Table.new(tag:, bytes:)
464
+ end
465
+
466
+ adjustment = Woff2::SfntChecksum.new(flavor:, tables:).adjustment
467
+
468
+ head = Tables::Head.read(table_data["head"])
469
+ head.checksum_adjustment = adjustment
470
+ table_data["head"] = head.to_binary_s
471
+ end
472
+
399
473
  def build_header(flavor:, num_tables:, total_sfnt_size:,
400
474
  total_compressed_size:)
401
475
  header = Woff2::Woff2Header.new
@@ -48,9 +48,7 @@ module Fontisan
48
48
  validate_data!(data)
49
49
 
50
50
  begin
51
- # Use Brotli gem with specified quality
52
- # The brotli gem doesn't expose mode constants, only quality
53
- Brotli.deflate(data, quality: quality)
51
+ Brotli.deflate(data, quality: quality, mode: mode)
54
52
  rescue StandardError => e
55
53
  raise Fontisan::Error,
56
54
  "Brotli compression failed: #{e.message}"
@@ -140,19 +138,6 @@ module Fontisan
140
138
  "Data must be a String-like object, got #{data.class}"
141
139
  end
142
140
  end
143
-
144
- # Convert mode symbol to Brotli constant
145
- #
146
- # NOTE: The brotli gem doesn't expose mode constants
147
- # This method is kept for API compatibility but unused
148
- #
149
- # @param mode [Symbol] Mode symbol
150
- # @return [Integer] Mode value (unused)
151
- def brotli_mode(_mode)
152
- # The brotli gem only accepts quality parameter
153
- # Mode is not configurable in current version
154
- 0
155
- end
156
141
  end
157
142
  end
158
143
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fontisan
4
- VERSION = "0.4.17"
4
+ VERSION = "0.4.18"
5
5
  end
@@ -33,6 +33,7 @@ module Fontisan
33
33
  def self.apply!(table_data)
34
34
  exclude_tables!(table_data)
35
35
  mark_lossless_modifying!(table_data)
36
+ touch_head_modified!(table_data)
36
37
  table_data
37
38
  end
38
39
 
@@ -61,6 +62,44 @@ module Fontisan
61
62
  head.flags |= Tables::Head::FLAG_LOSSLESS_MODIFYING
62
63
  table_data["head"] = head.to_binary_s
63
64
  end
65
+
66
+ # Set head.indexToLocFormat to match the chosen output loca format.
67
+ #
68
+ # fontTools' WOFF2 encoder re-picks the loca format (preferring short
69
+ # when glyf fits). The reconstructed SFNT must carry the matching
70
+ # indexToLocFormat in head, or Chrome's OTS rejects the file with
71
+ # "Failed to convert WOFF 2.0 font to SFNT".
72
+ #
73
+ # @param table_data [Hash{String => String}]
74
+ # @param format_code [Integer] 0 (short) or 1 (long)
75
+ def self.set_head_index_to_loc_format!(table_data, format_code)
76
+ return unless table_data.key?("head")
77
+
78
+ head = Tables::Head.read(table_data["head"])
79
+ head.index_to_loc_format = format_code
80
+ table_data["head"] = head.to_binary_s
81
+ end
82
+
83
+ # Bump head.modified past head.created.
84
+ #
85
+ # Chrome's OTS rejects fonts whose modified timestamp is not strictly
86
+ # greater than created (source fonts often have modified == created,
87
+ # which fails this check). Setting modified = created + 1 is the
88
+ # minimal change that satisfies the constraint.
89
+ #
90
+ # Must be called BEFORE checksum recompute so the modified bytes are
91
+ # included in the checksum.
92
+ #
93
+ # @param table_data [Hash{String => String}]
94
+ def self.touch_head_modified!(table_data)
95
+ return unless table_data.key?("head")
96
+
97
+ head = Tables::Head.read(table_data["head"])
98
+ return if head.modified_raw.to_i > head.created_raw.to_i
99
+
100
+ head.modified_raw = head.created_raw + 1
101
+ table_data["head"] = head.to_binary_s
102
+ end
64
103
  end
65
104
  end
66
105
  end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Woff2
5
+ # Canonicalizes a source glyf table by padding each glyph to a 4-byte
6
+ # boundary, matching fontTools' WOFF2 encoder behavior.
7
+ #
8
+ # fontTools' WOFF2 writer calls `_normaliseGlyfAndLoca(padding=4)` before
9
+ # transforming the glyf table (see Lib/fontTools/ttLib/woff2.py). Each
10
+ # glyph is recompiled with `recalcBBoxes=False`, which makes
11
+ # `Glyph#compile` return the source glyph bytes verbatim, then the
12
+ # glyf-table-level compile pads each glyph to the configured alignment.
13
+ # The result is a glyf whose bytes match the source byte-for-byte
14
+ # except for added padding (≤ 3 bytes per glyph).
15
+ #
16
+ # Chrome's OpenType Sanitizer (OTS) fails to reconstruct "unpadded"
17
+ # glyf tables — see the long comment in WOFF2Writer.close():
18
+ # https://github.com/google/woff2/issues/15
19
+ # https://github.com/khaledhosny/ots/issues/60
20
+ #
21
+ # The canonical glyf is the bytes the WOFF2 transform encodes, the
22
+ # bytes the decoder reconstructs, the bytes `glyf.origLength` reports,
23
+ # and the bytes the SFNT checksum is computed over. All four must
24
+ # agree.
25
+ class GlyfCanonicalizer
26
+ # @param glyf_data [String] Source glyf table bytes
27
+ # @param loca_data [String] Source loca table bytes
28
+ # @param num_glyphs [Integer] Glyph count from maxp
29
+ # @param source_format [Integer] 0 (short loca) or 1 (long loca) —
30
+ # how to interpret the source loca
31
+ # @param target_format [Integer] 0 (short loca) or 1 (long loca) —
32
+ # how to emit the canonical loca. Defaults to `source_format`.
33
+ def initialize(glyf_data:, loca_data:, num_glyphs:, source_format:,
34
+ target_format: nil)
35
+ @glyf_data = glyf_data
36
+ @loca_data = loca_data
37
+ @num_glyphs = num_glyphs
38
+ @source_format = source_format
39
+ @target_format = target_format || source_format
40
+ end
41
+
42
+ # @return [Hash{Symbol => String}] `{ glyf:, loca: }` where glyf is
43
+ # padded to 4-byte alignment per glyph and loca is emitted in
44
+ # `target_format`.
45
+ def canonical
46
+ glyf = String.new(encoding: Encoding::BINARY)
47
+ new_offsets = [0]
48
+
49
+ @num_glyphs.times do |i|
50
+ start_offset = source_offsets[i]
51
+ end_offset = source_offsets[i + 1]
52
+ if start_offset < end_offset
53
+ glyf << @glyf_data.byteslice(start_offset, end_offset - start_offset)
54
+ end
55
+ remainder = glyf.bytesize % 4
56
+ glyf << ("\x00" * (4 - remainder)) if remainder.positive?
57
+ new_offsets << glyf.bytesize
58
+ end
59
+
60
+ { glyf:, loca: build_loca(new_offsets) }
61
+ end
62
+
63
+ private
64
+
65
+ def source_offsets
66
+ @source_offsets ||= begin
67
+ offsets = if @source_format.zero?
68
+ @loca_data.unpack("n*").map { |v| v * 2 }
69
+ else
70
+ @loca_data.unpack("N*")
71
+ end
72
+ if offsets.length != @num_glyphs + 1
73
+ raise InvalidFontError,
74
+ "loca has #{offsets.length} entries, expected #{@num_glyphs + 1}"
75
+ end
76
+ offsets
77
+ end
78
+ end
79
+
80
+ def build_loca(offsets)
81
+ if @target_format.zero?
82
+ offsets.map { |o| o / 2 }.pack("n*")
83
+ else
84
+ offsets.pack("N*")
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Woff2
5
+ # Computes head.checksumAdjustment for the SFNT that the WOFF2 decoder
6
+ # reconstructs at runtime.
7
+ #
8
+ # Per OpenType spec, head.checksumAdjustment must be set so that the
9
+ # uint32-wise sum of the entire SFNT file (offset table + table
10
+ # directory + all tables) equals the magic 0xB1B0AFBA. Chrome's OTS
11
+ # validates this; a stale value from the source font causes rejection
12
+ # with "Failed to convert WOFF 2.0 font to SFNT" once any table has
13
+ # been modified (head.flags bit 11, glyf/loca transformation, etc.).
14
+ #
15
+ # Reference: OpenType spec, "Calculating Checksums".
16
+ class SfntChecksum
17
+ # Per-table data the checksum is computed over. tag is the 4-byte
18
+ # table tag, bytes is the (already reconstructed, post-transform)
19
+ # table data the decoder will see.
20
+ Table = Struct.new(:tag, :bytes, keyword_init: true)
21
+
22
+ # @param flavor [Integer] sfnt version (0x00010000 for TrueType,
23
+ # 0x4F54544F for CFF)
24
+ # @param tables [Array<Table>] reconstructed tables in alphabetical
25
+ # tag order
26
+ def initialize(flavor:, tables:)
27
+ @flavor = flavor
28
+ @tables = tables
29
+ end
30
+
31
+ # Compute the head.checksumAdjustment value (uint32).
32
+ #
33
+ # @return [Integer]
34
+ def adjustment
35
+ (Constants::CHECKSUM_ADJUSTMENT_MAGIC - total_checksum) & 0xFFFFFFFF
36
+ end
37
+
38
+ private
39
+
40
+ # Whole-SFNT checksum with head.checksumAdjustment treated as 0.
41
+ def total_checksum
42
+ sum = offset_table_checksum
43
+ sum = (sum + directory_checksum) & 0xFFFFFFFF
44
+ @tables.each do |t|
45
+ sum = (sum + per_table_checksum(t)) & 0xFFFFFFFF
46
+ end
47
+ sum
48
+ end
49
+
50
+ # Checksum of the 12-byte offset table (sfnt version + numTables +
51
+ # searchRange + entrySelector + rangeShift).
52
+ def offset_table_checksum
53
+ n = @tables.size
54
+ search_range = (2**Integer(Math.log2(n))) * 16
55
+ entry_selector = Integer(Math.log2(n))
56
+ range_shift = n * 16 - search_range
57
+ bytes = [@flavor, n, search_range, entry_selector, range_shift]
58
+ .pack("I>S>S>S>S>")
59
+ uint32_sum(bytes.ljust(12, "\x00"))
60
+ end
61
+
62
+ # Checksum of the table directory (one 16-byte entry per table).
63
+ # Each entry is tag(4) + checksum(4) + offset(4) + length(4).
64
+ def directory_checksum
65
+ offsets = {}
66
+ cursor = 12 + @tables.size * 16
67
+ @tables.each do |t|
68
+ offsets[t.tag] = cursor
69
+ cursor += padded_size(t.bytes.bytesize)
70
+ end
71
+ bytes = String.new(encoding: Encoding::BINARY)
72
+ @tables.each do |t|
73
+ tag_bytes = t.tag.encode(Encoding::BINARY).ljust(4, " ")
74
+ bytes << tag_bytes
75
+ bytes << [per_table_checksum(t)].pack("I>")
76
+ bytes << [offsets[t.tag]].pack("I>")
77
+ bytes << [t.bytes.bytesize].pack("I>")
78
+ end
79
+ uint32_sum(bytes)
80
+ end
81
+
82
+ # OpenType per-table checksum. The head table's checksumAdjustment
83
+ # field (offset 8-11) is treated as zero per spec. Tables are
84
+ # zero-padded to a 4-byte boundary for checksum, even when their
85
+ # actual length is shorter.
86
+ def per_table_checksum(table)
87
+ bytes = table.bytes.dup.force_encoding(Encoding::BINARY)
88
+ if table.tag == "head"
89
+ bytes = "#{bytes.byteslice(0, 8)}\u0000\u0000\u0000\u0000#{bytes.byteslice(12..)}"
90
+ end
91
+ uint32_sum(pad_to_4(bytes))
92
+ end
93
+
94
+ def uint32_sum(bytes)
95
+ bytes.unpack("I>*").sum & 0xFFFFFFFF
96
+ end
97
+
98
+ def pad_to_4(bytes)
99
+ pad = (-bytes.bytesize) % 4
100
+ pad.zero? ? bytes : bytes + ("\x00" * pad)
101
+ end
102
+
103
+ def padded_size(size)
104
+ (size + 3) & ~3
105
+ end
106
+ end
107
+ end
108
+ end
@@ -8,9 +8,11 @@ module Fontisan
8
8
  autoload :CollectionEncoder, "fontisan/woff2/collection_encoder"
9
9
  autoload :Directory, "fontisan/woff2/directory"
10
10
  autoload :EncoderRules, "fontisan/woff2/encoder_rules"
11
+ autoload :GlyfCanonicalizer, "fontisan/woff2/glyf_canonicalizer"
11
12
  autoload :GlyfLocaReconstruct, "fontisan/woff2/glyf_loca_reconstruct"
12
13
  autoload :GlyfLocaTransform, "fontisan/woff2/glyf_loca_transform"
13
14
  autoload :HmtxTransformer, "fontisan/woff2/hmtx_transformer"
15
+ autoload :SfntChecksum, "fontisan/woff2/sfnt_checksum"
14
16
  autoload :TableTransformer, "fontisan/woff2/table_transformer"
15
17
  autoload :TripletCodec, "fontisan/woff2/triplet_codec"
16
18
  autoload :UInt255, "fontisan/woff2/uint255"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fontisan
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.17
4
+ version: 0.4.18
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-07-06 00:00:00.000000000 Z
11
+ date: 2026-07-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64
@@ -723,10 +723,12 @@ files:
723
723
  - lib/fontisan/woff2/collection_encoder.rb
724
724
  - lib/fontisan/woff2/directory.rb
725
725
  - lib/fontisan/woff2/encoder_rules.rb
726
+ - lib/fontisan/woff2/glyf_canonicalizer.rb
726
727
  - lib/fontisan/woff2/glyf_loca_reconstruct.rb
727
728
  - lib/fontisan/woff2/glyf_loca_transform.rb
728
729
  - lib/fontisan/woff2/header.rb
729
730
  - lib/fontisan/woff2/hmtx_transformer.rb
731
+ - lib/fontisan/woff2/sfnt_checksum.rb
730
732
  - lib/fontisan/woff2/table_transformer.rb
731
733
  - lib/fontisan/woff2/triplet_codec.rb
732
734
  - lib/fontisan/woff2/uint255.rb