fontisan 0.4.13 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9f871e8b123fcb90294e234b49a73ee6ad5541a5064c11e34b47cf8a0c26646a
4
- data.tar.gz: 90fb63c4e71aaf2bb91f4884f3b6cdca26b5b9bbb9dac3f0a812d96bca5d69f8
3
+ metadata.gz: 47e074b8c2bbdcfa377c95733f56fdd978dfa4d824dbae3e29f3bd75935dddd6
4
+ data.tar.gz: a1a348db0bd6dde039c0a43f62ac3ab43792cc7414e995a72c47798663a1cd59
5
5
  SHA512:
6
- metadata.gz: 57fc98bbeb302426ef4fcf5453c0c37102ee6cf41c126d4cd724a727ffaee24313b6faf9a0ae1301642eefa85b93bca4371853a7891df304ac5c191c88a77aac
7
- data.tar.gz: 4cbc0c95aa3eff13ed3dd479450d3615cf1a01f5e6a5e866c9f98b216491b14d92885178f4e03f23879a0eedcdffa03b8bb74653d5df39ab8d1a776f51d3cbcf
6
+ metadata.gz: e8917cc8c995f0ca2cbdc9e94ad5d5d629ab95d8809b7eb728d360f0743a2b26e5cfd059254497c5a1df10ddb3b53258ee703c441a106d4febd42b26ea3b6e08
7
+ data.tar.gz: d35a8138d7cdbc5389b619ed757863cc0d61e1d8d025655d0fb6736a21815f04d24802f24236fdffdc8d430192db79798f77f7b9343193e0c16035d2be8fc5eb
data/README.adoc CHANGED
@@ -120,7 +120,7 @@ Font format support::
120
120
  * TTF, OTF (CFF1), OTF2 (CFF2), TTC, OTC font formats (production ready)
121
121
  * CFF2 with variable-font blend/vsindex operators, FDSelect, subroutines
122
122
  * Adobe Type 1 fonts (PFB/PFA) with bidirectional conversion (see link:docs/TYPE1_FONTS.adoc[Type 1 Fonts Guide])
123
- * WOFF/WOFF2 format support with reading, writing, and conversion (see link:docs/WOFF_WOFF2_FORMATS.adoc[WOFF/WOFF2 Guide])
123
+ * WOFF/WOFF2 format support with reading, writing, and conversion — fully compliant with W3C WOFF 1.0 (https://www.w3.org/TR/WOFF/[Recommendation 13 December 2012]) and WOFF 2.0 (https://www.w3.org/TR/WOFF2/[Recommendation 08 August 2024]) specifications. See link:docs/WOFF_WOFF2_FORMATS.adoc[WOFF/WOFF2 Guide].
124
124
  * Apple legacy font support: 'true' signature TrueType fonts and dfont format (see link:docs/APPLE_LEGACY_FONTS.adoc[Apple Legacy Fonts Guide])
125
125
  * SVG font generation and SVG-to-glyph conversion (complete)
126
126
 
@@ -8,6 +8,79 @@ including reading, writing, and conversion between these and other font formats.
8
8
  WOFF is a wrapper around SFNT fonts (TTF/OTF) using zlib compression, while WOFF2
9
9
  uses Brotli compression for better compression ratios and table transformations.
10
10
 
11
+ == W3C specification compliance
12
+
13
+ Fontisan's WOFF and WOFF2 implementations are fully compliant with the
14
+ W3C Recommendations:
15
+
16
+ * **WOFF 1.0** (W3C Recommendation, 13 December 2012):
17
+ https://www.w3.org/TR/WOFF/[spec] — reading and writing
18
+ * **WOFF 2.0** (W3C Recommendation, 08 August 2024):
19
+ https://www.w3.org/TR/WOFF2/[spec] — reading and writing
20
+
21
+ === WOFF 2.0 (spec section references below)
22
+
23
+ Compliance covers every normative requirement of the WOFF2 spec:
24
+
25
+ * *Section 3.2 (WOFF2 Header)*: 48-byte header with correct signature
26
+ (`wOF2`), flavor, file length, reserved=0, totalSfntSize,
27
+ totalCompressedSize, version, and meta/private offset/length fields.
28
+ * *Section 4.1 (Table directory)*: flags byte structure (bits 0-5 tag
29
+ index, bits 6-7 transform version); optional 4-byte custom tag when
30
+ index=63; `transformLength` field present IFF table is transformed.
31
+ * *Section 4.2 (Collection directory)*: full support for flavor=`ttcf`
32
+ font collections — CollectionHeader + CollectionFontEntry per font,
33
+ with deduplicated tables shared across fonts via directory indices.
34
+ * *Section 5 (Compressed data)*: single Brotli stream of all tables in
35
+ directory order; `DSIG` table removed per encoder rule; `head.flags`
36
+ bit 11 set to indicate lossless modifying transform.
37
+ * *Section 5.1 (Transformed glyf table)*: 36-byte header (uint16
38
+ version + uint16 optionFlags + uint16 numGlyphs + uint16 indexFormat
39
+ + 7 × uint32 stream sizes); 7 streams in spec order (nContour,
40
+ nPoints, flags, glyph, composite, bbox with bboxBitmap prefix,
41
+ instructions); optional overlapSimpleBitmap when optionFlags bit 0
42
+ is set; bboxBitmap semantics per spec (composite glyphs always have
43
+ explicit bbox, simple glyphs omit when calculated bbox matches,
44
+ empty glyphs MUST have all-zero bbox).
45
+ * *Section 5.2 (Triplet coordinate encoding)*: variable-length
46
+ coordinate codec with all 128 flag index variants (1-4 byte
47
+ payloads based on coordinate magnitude); bit 7 = off-curve flag.
48
+ * *Section 5.3 (Transformed loca table)*: `transformLength` MUST be 0;
49
+ loca data omitted from compressed stream (reconstructed by decoder
50
+ from glyf).
51
+ * *Section 5.4 (Transformed hmtx table)*: optional version 1 transform
52
+ exploiting `lsb == xMin` redundancy.
53
+ * *Section 5.5 (Table order constraints)*: loca MUST immediately
54
+ follow glyf in table directory; for collections, loca MUST
55
+ immediately follow its corresponding glyf with no other tables in
56
+ between.
57
+
58
+ Validation: fontisan's WOFF2 output is verified byte-identical to
59
+ fontTools' output for transformed glyf tables, and round-trips
60
+ correctly through the W3C `woff2_decompress` reference tool.
61
+
62
+ === WOFF 1.0
63
+
64
+ * *Section 4 (WOFF Header)*: 44-byte header with correct signature
65
+ (`wOFF`), flavor, length, numTables, reserved=0, totalSfntSize,
66
+ version, and meta/private offset/length fields.
67
+ * *Section 5 (Table directory)*: 20-byte entries with tag, offset,
68
+ compLength, origLength, origChecksum. Tables sorted in input font
69
+ order per spec ("MUST be stored in the same order as the
70
+ well-formed input font").
71
+ * *Section 6 (Font data tables)*: tables compressed individually with
72
+ zlib (compress2-compatible); each table 4-byte aligned and
73
+ zero-padded to next boundary ("MUST begin on 4-byte boundaries and
74
+ be zero-padded to the next 4-byte boundary").
75
+ * *Section 7 (Extended metadata)*: optional block of XML data,
76
+ always zlib-compressed ("it is never stored in uncompressed form").
77
+ * *Section 8 (Private data)*: optional block of arbitrary bytes,
78
+ always 4-byte aligned.
79
+
80
+ Validation: fontisan's WOFF 1 output round-trips through fontTools
81
+ without errors; all 4-byte alignment, padding, and compression
82
+ requirements verified by spec-conformance specs.
83
+
11
84
  === Display WOFF/WOFF2 font information
12
85
 
13
86
  Show metadata specific to WOFF/WOFF2 fonts including version, compression details,
@@ -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
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fontisan
4
- VERSION = "0.4.13"
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
@@ -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