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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9f871e8b123fcb90294e234b49a73ee6ad5541a5064c11e34b47cf8a0c26646a
4
- data.tar.gz: 90fb63c4e71aaf2bb91f4884f3b6cdca26b5b9bbb9dac3f0a812d96bca5d69f8
3
+ metadata.gz: 422936ab5bbb785c6030c16efa61be31edd4c9e251828eabaa29e572a570bbb5
4
+ data.tar.gz: ad05bb618741fb0958e1be540af840debb9ffa4d92be469930f8e11f6ccd63fb
5
5
  SHA512:
6
- metadata.gz: 57fc98bbeb302426ef4fcf5453c0c37102ee6cf41c126d4cd724a727ffaee24313b6faf9a0ae1301642eefa85b93bca4371853a7891df304ac5c191c88a77aac
7
- data.tar.gz: 4cbc0c95aa3eff13ed3dd479450d3615cf1a01f5e6a5e866c9f98b216491b14d92885178f4e03f23879a0eedcdffa03b8bb74653d5df39ab8d1a776f51d3cbcf
6
+ metadata.gz: f9d938ed00f8bff70e0fe20b06751134faa1cb9c0f536467f1259866876d060f170ac94e0a89818a4f77662d873542c9a6bfb09d4da0de5577c3bda2a33f4efa
7
+ data.tar.gz: e6d7679235dab59a1a26bf5948f0651d147b12fec4218c000626ff0291fbec33593cef8ba0466e189d63c20f6cdc0d6c53b88626f47178656a272248e8e12872
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,
@@ -299,7 +299,7 @@ module Fontisan
299
299
  # Determine collection type from format
300
300
  #
301
301
  # @param format [Symbol] Target format
302
- # @return [Symbol, nil] Collection type (:ttc, :otc, :dfont) or nil
302
+ # @return [Symbol, nil] Collection type (:ttc, :otc, :dfont, :woff2_collection) or nil
303
303
  def collection_type_from_format(format)
304
304
  case format
305
305
  when :ttc
@@ -308,6 +308,8 @@ module Fontisan
308
308
  :otc
309
309
  when :dfont
310
310
  :dfont
311
+ when :woff2
312
+ :woff2_collection
311
313
  else
312
314
  # Check output extension
313
315
  ext = File.extname(@output_path).downcase
@@ -318,6 +320,8 @@ module Fontisan
318
320
  :otc
319
321
  when ".dfont"
320
322
  :dfont
323
+ when ".woff2"
324
+ :woff2_collection
321
325
  end
322
326
  end
323
327
  end
@@ -377,6 +377,42 @@ conversions:
377
377
  Unpacks dfont suitcase and repacks as OTC. If dfont contains TrueType
378
378
  fonts, they are converted to OpenType/CFF format.
379
379
 
380
+ # WOFF2 collection conversions (spec section 4.2)
381
+ - from: ttc
382
+ to: woff2_collection
383
+ strategy: collection_converter
384
+ description: "Convert TrueType Collection to WOFF2 collection"
385
+ status: implemented
386
+ notes: >
387
+ Unpacks TTC, encodes each font per WOFF2 spec, packs as a single
388
+ WOFF2 collection file (flavor='ttcf'). Tables shared across fonts
389
+ are deduplicated.
390
+
391
+ - from: otc
392
+ to: woff2_collection
393
+ strategy: collection_converter
394
+ description: "Convert OpenType Collection to WOFF2 collection"
395
+ status: implemented
396
+ notes: >
397
+ Unpacks OTC, encodes each font per WOFF2 spec, packs as a single
398
+ WOFF2 collection file (flavor='ttcf').
399
+
400
+ - from: woff2_collection
401
+ to: ttc
402
+ strategy: collection_converter
403
+ description: "Convert WOFF2 collection to TrueType Collection"
404
+ status: implemented
405
+ notes: >
406
+ Decodes WOFF2 collection, extracts each font, repacks as TTC.
407
+
408
+ - from: woff2_collection
409
+ to: otc
410
+ strategy: collection_converter
411
+ description: "Convert WOFF2 collection to OpenType Collection"
412
+ status: implemented
413
+ notes: >
414
+ Decodes WOFF2 collection, extracts each font, repacks as OTC.
415
+
380
416
  # Conversion compatibility matrix
381
417
  #
382
418
  # This section documents which source features are preserved in conversions.
@@ -121,9 +121,10 @@ module Fontisan
121
121
  raise ArgumentError, "Collection file not found: #{collection_path}"
122
122
  end
123
123
 
124
- unless %i[ttc otc dfont].include?(target_type)
124
+ unless %i[ttc otc dfont woff2_collection].include?(target_type)
125
125
  raise ArgumentError,
126
- "Invalid target type: #{target_type}. Must be :ttc, :otc, or :dfont"
126
+ "Invalid target type: #{target_type}. Must be :ttc, :otc, " \
127
+ ":dfont, or :woff2_collection"
127
128
  end
128
129
 
129
130
  unless options[:output]
@@ -145,6 +146,8 @@ module Fontisan
145
146
  unpack_ttc_otc(collection_path)
146
147
  when :dfont
147
148
  unpack_dfont(collection_path)
149
+ when :woff2_collection
150
+ unpack_woff2_collection(collection_path)
148
151
  else
149
152
  raise Error, "Unknown collection type: #{source_type}"
150
153
  end
@@ -155,7 +158,7 @@ module Fontisan
155
158
  # Detect collection type from file
156
159
  #
157
160
  # @param path [String] Collection path
158
- # @return [Symbol] Collection type (:ttc, :otc, or :dfont)
161
+ # @return [Symbol] Collection type (:ttc, :otc, :dfont, or :woff2_collection)
159
162
  def detect_collection_type(path)
160
163
  File.open(path, "rb") do |io|
161
164
  signature = io.read(4)
@@ -165,6 +168,12 @@ module Fontisan
165
168
  # TTC or OTC - check extension
166
169
  ext = File.extname(path).downcase
167
170
  ext == ".otc" ? :otc : :ttc
171
+ elsif signature == "wOF2"
172
+ # WOFF2 collection or single font — distinguish by flavor
173
+ io.read(8) # signature + flavor
174
+ io.rewind
175
+ flavor = File.binread(path, 4, 4)&.unpack1("N")
176
+ flavor == Woff2::CollectionDecoder::TTC_FLAVOR ? :woff2_collection : (raise Error, "Not a WOFF2 collection: #{path}")
168
177
  elsif Parsers::DfontParser.dfont?(io)
169
178
  :dfont
170
179
  else
@@ -207,6 +216,15 @@ module Fontisan
207
216
  fonts
208
217
  end
209
218
 
219
+ # Unpack fonts from WOFF2 collection (spec section 4.2).
220
+ #
221
+ # @param path [String] WOFF2 collection path
222
+ # @return [Array<Font>] Unpacked fonts
223
+ def unpack_woff2_collection(path)
224
+ collection = FontLoader.load_collection(path)
225
+ collection.extract_fonts
226
+ end
227
+
210
228
  # Convert fonts if outline format change needed
211
229
  #
212
230
  # @param fonts [Array<Font>] Source fonts
@@ -380,6 +398,8 @@ conv_options = nil)
380
398
  repack_ttc_otc(fonts, target_type, output_path, options)
381
399
  when :dfont
382
400
  repack_dfont(fonts, output_path, options)
401
+ when :woff2_collection
402
+ repack_woff2_collection(fonts, output_path, options)
383
403
  else
384
404
  raise Error, "Unknown target type: #{target_type}"
385
405
  end
@@ -415,6 +435,19 @@ conv_options = nil)
415
435
  builder.build_to_file(output_path)
416
436
  end
417
437
 
438
+ # Repack fonts into a WOFF2 collection (spec section 4.2).
439
+ #
440
+ # @param fonts [Array<Font>] Fonts
441
+ # @param output_path [String] Output path (.woff2)
442
+ # @param options [Hash] Options (looks for :brotli_quality)
443
+ # @return [void]
444
+ def repack_woff2_collection(fonts, output_path, options)
445
+ brotli_quality = options.fetch(:brotli_quality, 11)
446
+ encoder = Woff2::CollectionEncoder.new(brotli_quality:)
447
+ bytes = encoder.encode_fonts(fonts)
448
+ File.binwrite(output_path, bytes)
449
+ end
450
+
418
451
  # Build conversion result
419
452
  #
420
453
  # @param input [String] Input path
@@ -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
 
@@ -45,6 +45,7 @@ module Fontisan
45
45
  ttc: TrueTypeCollection,
46
46
  otc: OpenTypeCollection,
47
47
  dfont: DfontCollection,
48
+ woff2_collection: Woff2Collection,
48
49
  }.freeze
49
50
  private_constant :COLLECTION_CLASSES
50
51
 
@@ -83,6 +84,8 @@ module Fontisan
83
84
  lazy: resolved_lazy)
84
85
  when :ttc, :otc then load_from_collection(path, format, font_index,
85
86
  mode: resolved_mode)
87
+ when :woff2_collection then load_from_collection(path, format, font_index,
88
+ mode: resolved_mode)
86
89
  when :dfont then load_dfont(path, font_index: font_index,
87
90
  mode: resolved_mode)
88
91
  when :pfa, :pfb then Type1Font.from_file(path, mode: resolved_mode)
@@ -185,7 +188,7 @@ module Fontisan
185
188
  when Constants::SFNT_OTTO_MAGIC then :otf
186
189
  when Constants::SFNT_TRUETYPE_MAGIC, Constants::SFNT_TRUE_MAGIC then :ttf
187
190
  when Constants::WOFF_MAGIC then :woff
188
- when Constants::WOFF2_MAGIC then :woff2
191
+ when Constants::WOFF2_MAGIC then scan_woff2(io)
189
192
  when Constants::DFONT_RESOURCE_HEADER
190
193
  io.rewind
191
194
  Parsers::DfontParser.dfont?(io) ? :dfont : nil
@@ -238,6 +241,18 @@ module Fontisan
238
241
  nil
239
242
  end
240
243
 
244
+ # Distinguish a WOFF2 collection (flavor='ttcf') from a single-font
245
+ # WOFF2. The 8-byte probe covers the WOFF2 signature + flavor field;
246
+ # spec section 3.2 requires flavor='ttcf' (0x74746366) for collections.
247
+ def self.scan_woff2(io)
248
+ io.rewind
249
+ io.read(4) # signature (already matched)
250
+ flavor = io.read(4)&.unpack1("N")
251
+ flavor == Woff2::CollectionDecoder::TTC_FLAVOR ? :woff2_collection : :woff2
252
+ rescue IOError
253
+ :woff2
254
+ end
255
+
241
256
  # Mode override from FONTISAN_MODE env var, or nil.
242
257
  def self.env_mode
243
258
  env_value = ENV["FONTISAN_MODE"]
@@ -287,6 +302,7 @@ module Fontisan
287
302
  private_class_method :detect,
288
303
  :type1_format_from_header,
289
304
  :scan_collection,
305
+ :scan_woff2,
290
306
  :env_mode,
291
307
  :env_lazy,
292
308
  :load_from_collection,
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+
5
+ module Fontisan
6
+ # Builds an SFNT (TrueType/OpenType) binary from a flavor + per-tag table
7
+ # data. Used whenever WOFF2-derived table data needs to be reassembled
8
+ # into a parseable SFNT (single-font decoding, WOFF2 collection → font).
9
+ #
10
+ # Single source of truth for the SFNT byte layout: 12-byte offset table,
11
+ # 16-byte table directory entries, padded table data.
12
+ module SfntBuilder
13
+ # @param flavor [Integer] SFNT version (Constants::SFNT_VERSION_TRUETYPE
14
+ # or Constants::SFNT_VERSION_OTTO)
15
+ # @param tables [Hash<String => String>] tag → table bytes
16
+ # @return [String] SFNT binary
17
+ def self.build(flavor:, tables:)
18
+ num_tables = tables.length
19
+ entry_selector = (Math.log(num_tables) / Math.log(2)).floor
20
+ search_range = (2**entry_selector) * 16
21
+ range_shift = (num_tables * 16) - search_range
22
+
23
+ out = String.new(encoding: Encoding::BINARY)
24
+ # Offset table (12 bytes)
25
+ out << [flavor].pack("N")
26
+ out << [num_tables].pack("n")
27
+ out << [search_range].pack("n")
28
+ out << [entry_selector].pack("n")
29
+ out << [range_shift].pack("n")
30
+
31
+ # Compute layout for table directory + table data with 4-byte padding
32
+ data_start = 12 + (num_tables * 16)
33
+ cursor = data_start
34
+ records = tables.map do |tag, data|
35
+ length = data.bytesize
36
+ checksum = Utilities::ChecksumCalculator.calculate_table_checksum(data)
37
+ padding = (4 - (length % 4)) % 4
38
+ record = { tag:, checksum:, offset: cursor, length:, data:, padding: }
39
+ cursor += length + padding
40
+ record
41
+ end
42
+
43
+ # Write all table directory entries first (16 bytes each, sorted
44
+ # alphabetically per SFNT convention for stable output), then all
45
+ # table data with 4-byte padding. The directory entries' offset
46
+ # fields were precomputed assuming this layout.
47
+ sorted = records.sort_by { |r| r[:tag] }
48
+ # rubocop:disable Style/CombinableLoops
49
+ # SFNT requires all directory entries before any table data
50
+ sorted.each do |r|
51
+ out << r[:tag].ljust(4, "\x00")
52
+ out << [r[:checksum]].pack("N")
53
+ out << [r[:offset]].pack("N")
54
+ out << [r[:length]].pack("N")
55
+ end
56
+
57
+ sorted.each do |r|
58
+ out << r[:data]
59
+ out << ("\x00" * r[:padding])
60
+ end
61
+ # rubocop:enable Style/CombinableLoops
62
+
63
+ out
64
+ end
65
+ end
66
+ end
@@ -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.15"
5
5
  end