fontisan 0.4.12 → 0.4.13

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: e97f99deb241e5abd1bbdaeb27849efb3a4597bf2139fc8a1fa815fcccbfdfcb
4
- data.tar.gz: ced71eac0e00e479581c5460e33672aa80ded30cd40cb590b5a09621c5441290
3
+ metadata.gz: 9f871e8b123fcb90294e234b49a73ee6ad5541a5064c11e34b47cf8a0c26646a
4
+ data.tar.gz: 90fb63c4e71aaf2bb91f4884f3b6cdca26b5b9bbb9dac3f0a812d96bca5d69f8
5
5
  SHA512:
6
- metadata.gz: cc72fc9f58560fddf2440ecead8a54060a06a6da38ac3de2212d893cb3dbb2dea88649c82c30ff8fb10d25389058a63770d4309e414856f031ab3d88659f4150
7
- data.tar.gz: bac0c91aa712516256d2ae7fb7ccee7dd31fe1549ace5bb533ea9d2b27b7087da4b0b200412794b3677b44c43bae79c87cbcaa849db3f6d993d00dac91a1e3a3
6
+ metadata.gz: 57fc98bbeb302426ef4fcf5453c0c37102ee6cf41c126d4cd724a727ffaee24313b6faf9a0ae1301642eefa85b93bca4371853a7891df304ac5c191c88a77aac
7
+ data.tar.gz: 4cbc0c95aa3eff13ed3dd479450d3615cf1a01f5e6a5e866c9f98b216491b14d92885178f4e03f23879a0eedcdffa03b8bb74653d5df39ab8d1a776f51d3cbcf
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Validation temporarily disabled - will be reimplemented with new DSL framework in Week 3+
4
- # require_relative "../validation/woff2_validator"
5
3
  require "yaml"
6
4
  require "stringio"
7
5
 
@@ -17,27 +15,16 @@ module Fontisan
17
15
  # 1. Load configuration settings
18
16
  # 2. Determine font flavor (TTF or CFF)
19
17
  # 3. Collect and order tables
20
- # 4. Transform tables (placeholder for glyf/loca/hmtx optimization)
21
- # 5. Compress all tables with single Brotli stream
22
- # 6. Build WOFF2 header and table directory
23
- # 7. Assemble complete WOFF2 binary
24
- # 8. (Optional) Validate encoded WOFF2
25
- #
26
- # For Phase 2 Milestone 2.1:
27
- # - Basic WOFF2 structure generation
28
- # - Brotli compression of table data
29
- # - Valid WOFF2 files for web font delivery
30
- # - Table transformations are architectural placeholders
18
+ # 4. Apply WOFF2 spec encoder rules (drop DSIG, mark head.flags)
19
+ # 5. Transform tables (placeholder for glyf/loca/hmtx optimization)
20
+ # 6. Compress all tables with single Brotli stream
21
+ # 7. Build WOFF2 header and table directory
22
+ # 8. Assemble complete WOFF2 binary
31
23
  #
32
24
  # @example Convert TTF to WOFF2
33
25
  # encoder = Woff2Encoder.new
34
26
  # result = encoder.convert(font)
35
27
  # File.binwrite('font.woff2', result[:woff2_binary])
36
- #
37
- # @example Convert with validation
38
- # encoder = Woff2Encoder.new
39
- # result = encoder.convert(font, validate: true)
40
- # puts result[:validation_report].text_summary if result[:validation_report]
41
28
  class Woff2Encoder
42
29
  include ConversionStrategy
43
30
 
@@ -64,12 +51,11 @@ module Fontisan
64
51
 
65
52
  # Convert font to WOFF2 format.
66
53
  #
67
- # Returns a hash with `:woff2_binary` containing the complete WOFF2 file.
68
- #
69
54
  # @param font [TrueTypeFont, OpenTypeFont] Source font
70
55
  # @param options [Hash{Symbol => Object}] Per-call options:
71
56
  # - `:brotli_quality` (0–11, default from config or 11)
72
- # - `:transform_tables` (bool, default false)
57
+ # - `:transform_tables` (bool, default false; glyf/loca are always
58
+ # transformed per WOFF2 spec section 5.3 regardless of this flag)
73
59
  # - `:quality` — legacy alias for `:brotli_quality` (backward compat)
74
60
  # @return [Hash{Symbol => String}] `{ woff2_binary: <bytes> }`
75
61
  # @raise [ArgumentError] if any option fails validation
@@ -84,47 +70,32 @@ module Fontisan
84
70
  config["brotli"]["quality"]
85
71
  end
86
72
 
87
- # Detect font flavor
88
73
  flavor = detect_flavor(font)
89
-
90
- # Collect all tables
91
74
  table_data = collect_tables(font, options)
75
+ Woff2::EncoderRules.apply!(table_data)
76
+
77
+ glyf_transform = apply_glyf_loca_transform!(table_data, font)
92
78
 
93
- # Transform tables (if enabled)
94
79
  transformer = Woff2::TableTransformer.new(font)
95
80
  transform_enabled = resolved.fetch(:transform_tables, false)
81
+ entries, transformed_data = build_table_entries(table_data,
82
+ transformer,
83
+ transform_enabled,
84
+ glyf_transform:)
96
85
 
97
- # Build table directory entries
98
- entries = build_table_entries(table_data, transformer,
99
- transform_enabled)
100
-
101
- # Compress all table data into single stream
102
- compressed_data = compress_tables(entries, table_data, quality)
103
-
104
- # Calculate sizes
105
- total_sfnt_size = calculate_sfnt_size(table_data)
106
- total_compressed_size = compressed_data.bytesize
86
+ compressed_data = compress_tables(entries, table_data,
87
+ transformed_data, quality)
107
88
 
108
- # Build WOFF2 header
89
+ total_sfnt_size = calculate_sfnt_size(table_data,
90
+ glyf_transform:)
109
91
  header = build_header(
110
92
  flavor: flavor,
111
93
  num_tables: entries.size,
112
94
  total_sfnt_size: total_sfnt_size,
113
- total_compressed_size: total_compressed_size,
95
+ total_compressed_size: compressed_data.bytesize,
114
96
  )
115
97
 
116
- # Assemble WOFF2 binary
117
- woff2_binary = assemble_woff2(header, entries, compressed_data)
118
-
119
- # Prepare result
120
- { woff2_binary: woff2_binary }
121
-
122
- # Optional validation
123
- # Temporarily disabled - will be reimplemented with new DSL framework
124
- # if options[:validate]
125
- # validation_report = validate_encoding(woff2_binary, options)
126
- # result[:validation_report] = validation_report
127
- # end
98
+ { woff2_binary: assemble_woff2(header, entries, compressed_data) }
128
99
  end
129
100
 
130
101
  # Get list of supported conversions
@@ -150,7 +121,6 @@ module Fontisan
150
121
  "got: #{target_format}"
151
122
  end
152
123
 
153
- # Verify font has required tables
154
124
  required_tables = %w[head hhea maxp]
155
125
  required_tables.each do |tag|
156
126
  unless font.table(tag)
@@ -159,7 +129,6 @@ module Fontisan
159
129
  end
160
130
  end
161
131
 
162
- # Verify font has either glyf or CFF table
163
132
  unless font.has_table?("glyf") || font.has_table?("CFF ") || font.has_table?("CFF2")
164
133
  raise Fontisan::Error,
165
134
  "Font must have either glyf or CFF/CFF2 table"
@@ -170,51 +139,10 @@ module Fontisan
170
139
 
171
140
  private
172
141
 
173
- # Helper method to load WOFF2 from StringIO
174
- #
175
- # This is added to Woff2Font to support in-memory validation
176
- module Woff2FontMemoryLoader
177
- def self.from_file_io(io, path_for_report)
178
- io.rewind
179
-
180
- woff2 = Woff2Font.new
181
- woff2.io_source = Woff2Font::IOSource.new(path_for_report)
182
-
183
- # Read header
184
- woff2.header = Woff2::Woff2Header.read(io)
185
-
186
- # Validate signature
187
- unless woff2.header.signature == Woff2::Woff2Header::SIGNATURE
188
- raise InvalidFontError,
189
- "Invalid WOFF2 signature: expected 0x#{Woff2::Woff2Header::SIGNATURE.to_s(16)}, " \
190
- "got 0x#{woff2.header.signature.to_i.to_s(16)}"
191
- end
192
-
193
- # Read table directory
194
- woff2.table_entries = Woff2Font.read_table_directory_from_io(io,
195
- woff2.header)
196
-
197
- # Decompress tables
198
- woff2.decompressed_tables = Woff2Font.decompress_tables(io, woff2.header,
199
- woff2.table_entries)
200
-
201
- # Apply transformations
202
- Woff2Font.apply_transformations!(woff2.table_entries,
203
- woff2.decompressed_tables)
204
-
205
- woff2
206
- end
207
- end
208
-
209
- # Extend Woff2Font with in-memory loading
210
- Woff2Font.singleton_class.prepend(Woff2FontMemoryLoader)
211
-
212
142
  # Normalize legacy `:quality` option to `:brotli_quality`.
143
+ #
213
144
  # Internal callers and older specs use `:quality`; the public DSL uses
214
145
  # `:brotli_quality`. Returns a new hash; does not mutate the input.
215
- #
216
- # @param options [Hash]
217
- # @return [Hash]
218
146
  def normalize_legacy_quality(options)
219
147
  return options unless options.key?(:quality) && !options.key?(:brotli_quality)
220
148
 
@@ -222,18 +150,11 @@ module Fontisan
222
150
  end
223
151
 
224
152
  # Slice options to those declared by this strategy.
225
- #
226
- # @param options [Hash]
227
- # @return [Hash]
228
153
  def strategy_options(options)
229
154
  names = self.class.supported_options.to_set(&:name)
230
155
  options.select { |k, _| names.include?(k.to_sym) }
231
156
  end
232
157
 
233
- # Load configuration from YAML file
234
- #
235
- # @param path [String, nil] Path to config file
236
- # @return [Hash] Configuration settings
237
158
  def load_configuration(path)
238
159
  config_path = path || default_config_path
239
160
 
@@ -247,21 +168,10 @@ module Fontisan
247
168
  default_configuration
248
169
  end
249
170
 
250
- # Get default configuration path
251
- #
252
- # @return [String] Path to config file
253
171
  def default_config_path
254
- File.join(
255
- __dir__,
256
- "..",
257
- "config",
258
- "woff2_settings.yml",
259
- )
172
+ File.join(__dir__, "..", "config", "woff2_settings.yml")
260
173
  end
261
174
 
262
- # Get default configuration
263
- #
264
- # @return [Hash] Default settings
265
175
  def default_configuration
266
176
  {
267
177
  "brotli" => {
@@ -269,7 +179,7 @@ module Fontisan
269
179
  "mode" => "font",
270
180
  },
271
181
  "transformations" => {
272
- "enabled" => true, # Enable transformations for better compression
182
+ "enabled" => true,
273
183
  "glyf_loca" => true,
274
184
  "hmtx" => true,
275
185
  },
@@ -279,10 +189,6 @@ module Fontisan
279
189
  }
280
190
  end
281
191
 
282
- # Detect font flavor (TTF or CFF)
283
- #
284
- # @param font [TrueTypeFont, OpenTypeFont] Font to detect
285
- # @return [Integer] Flavor value
286
192
  def detect_flavor(font)
287
193
  if font.has_table?("CFF ") || font.has_table?("CFF2")
288
194
  0x4F54544F # 'OTTO' for CFF
@@ -294,36 +200,59 @@ module Fontisan
294
200
  end
295
201
  end
296
202
 
297
- # Collect all tables from font
203
+ # Collect all tables from font.
298
204
  #
299
- # @param font [TrueTypeFont, OpenTypeFont] Source font
300
- # @param options [Hash] Conversion options
301
- # @return [Hash<String, String>] Map of tag to table data
205
+ # Spec-mandated exclusions (DSIG) and adjustments (head.flags bit 11)
206
+ # are applied by `Woff2::EncoderRules`, not here — this method is a
207
+ # pure reader.
302
208
  def collect_tables(font, _options = {})
303
- tables = {}
304
-
305
- # Get all table names from font
306
209
  table_names = if font.respond_to?(:table_names)
307
210
  font.table_names
308
211
  else
309
- # Fallback: try common tables
310
212
  %w[head hhea maxp OS/2 name cmap post hmtx glyf loca
311
213
  CFF]
312
214
  end
313
215
 
314
- table_names.each do |tag|
216
+ table_names.each_with_object({}) do |tag, tables|
315
217
  data = get_table_data(font, tag)
316
218
  tables[tag] = data if data && !data.empty?
317
219
  end
318
-
319
- tables
320
220
  end
321
221
 
322
- # Get table data from font
222
+ # Apply the WOFF2 glyf/loca paired transform per spec section 5.1/5.3.
323
223
  #
324
- # @param font [Object] Font object
325
- # @param tag [String] Table tag
326
- # @return [String, nil] Table data
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.
229
+ #
230
+ # @return [Hash{Symbol => Object}, nil]
231
+ def apply_glyf_loca_transform!(table_data, font)
232
+ return nil unless font.respond_to?(:has_table?)
233
+ return nil unless font.has_table?("glyf") && font.has_table?("loca")
234
+
235
+ glyf_data = table_data["glyf"]
236
+ loca_data = table_data["loca"]
237
+ return nil unless glyf_data && loca_data
238
+
239
+ maxp = font.table("maxp")
240
+ head = font.table("head")
241
+ return nil unless maxp && head
242
+
243
+ transformed = Woff2::GlyfLocaTransform.new(
244
+ glyf_data:,
245
+ loca_data:,
246
+ num_glyphs: maxp.num_glyphs,
247
+ index_format: head.index_to_loc_format,
248
+ ).transform
249
+
250
+ loca_orig_length = loca_data.bytesize
251
+ table_data.delete("loca")
252
+
253
+ { transformed_glyf: transformed, loca_orig_length: }
254
+ end
255
+
327
256
  def get_table_data(font, tag)
328
257
  if font.respond_to?(:table_data)
329
258
  font.table_data[tag]
@@ -333,119 +262,125 @@ module Fontisan
333
262
  end
334
263
  end
335
264
 
336
- # Build table directory entries
265
+ # Build table directory entries.
266
+ #
267
+ # When `glyf_transform` is provided, glyf gets a transformLength entry
268
+ # (transformed bytes via Woff2::GlyfLocaTransform) and a synthetic
269
+ # loca entry follows glyf per spec section 5.5 with transformLength=0.
337
270
  #
338
- # @param table_data [Hash<String, String>] Table data map
339
- # @param transformer [Woff2::TableTransformer] Table transformer
340
- # @param transform_enabled [Boolean] Enable transformations
341
- # @return [Array<Woff2::Directory::Entry>] Table entries
342
- def build_table_entries(table_data, transformer, transform_enabled)
271
+ # @return [Array(Array<Entry>, Hash<String, String>)]
272
+ # Pair of entries and the transformed-by-tag data map.
273
+ def build_table_entries(table_data, transformer, transform_enabled,
274
+ glyf_transform: nil)
343
275
  entries = []
344
276
  transformed_data = {}
345
277
 
346
- # Sort tables by tag for consistent output
347
- sorted_tags = table_data.keys.sort
348
-
349
- sorted_tags.each do |tag|
350
- # Skip loca if we're transforming glyf (loca is combined with glyf)
351
- if tag == "loca" && transform_enabled && transformer.transformable?("glyf")
352
- next
278
+ table_data.keys.sort.each do |tag|
279
+ if tag == "glyf" && glyf_transform
280
+ entries << build_glyf_entry(table_data["glyf"],
281
+ glyf_transform[:transformed_glyf])
282
+ entries << build_loca_entry(glyf_transform[:loca_orig_length])
283
+ transformed_data["glyf"] = glyf_transform[:transformed_glyf]
284
+ else
285
+ entry = build_entry(tag:, data: table_data[tag], transformer:,
286
+ transform_enabled:, transformed_data:)
287
+ entries << entry if entry
353
288
  end
289
+ end
354
290
 
355
- entry = Woff2::Directory::Entry.new
356
- entry.tag = tag
357
-
358
- # Get original table data
359
- data = table_data[tag]
360
- entry.orig_length = data.bytesize
361
-
362
- # Apply transformation if enabled and supported
363
- if transform_enabled && transformer.transformable?(tag)
364
- transformed = transformer.transform_table(tag)
365
- if transformed&.bytesize&.positive? && transformed.bytesize < data.bytesize
366
- # Transformation successful and reduces size
367
- entry.transform_length = transformed.bytesize
368
- transformed_data[tag] = transformed
369
- end
370
- end
291
+ [entries, transformed_data]
292
+ end
371
293
 
372
- # Calculate flags
373
- entry.flags = entry.calculate_flags
294
+ def build_entry(tag:, data:, transformer:, transform_enabled:,
295
+ transformed_data:)
296
+ return nil unless data
374
297
 
375
- entries << entry
298
+ # loca is combined into glyf during transformation.
299
+ return nil if tag == "loca" && transform_enabled && transformer.transformable?("glyf")
300
+
301
+ entry = Woff2::Directory::Entry.new
302
+ entry.tag = tag
303
+ entry.orig_length = data.bytesize
304
+
305
+ if transform_enabled && transformer.transformable?(tag) && tag != "glyf" && tag != "loca"
306
+ transformed = transformer.transform_table(tag)
307
+ if transformed&.bytesize&.positive? && transformed.bytesize < data.bytesize
308
+ entry.transform_length = transformed.bytesize
309
+ transformed_data[tag] = transformed
310
+ end
376
311
  end
377
312
 
378
- # Store transformed data for compression
379
- @transformed_data = transformed_data
313
+ entry.flags = entry.calculate_flags
314
+ entry
315
+ end
316
+
317
+ # Build the glyf directory entry. origLength is the original (input)
318
+ # glyf size; transformLength is the size of the WOFF2-transformed
319
+ # glyf stream per spec section 5.1.
320
+ def build_glyf_entry(orig_data, transformed_data)
321
+ entry = Woff2::Directory::Entry.new
322
+ entry.tag = "glyf"
323
+ entry.orig_length = orig_data.bytesize
324
+ entry.transform_length = transformed_data.bytesize
325
+ entry.flags = entry.calculate_flags
326
+ entry
327
+ end
380
328
 
381
- entries
329
+ # Build the synthetic loca directory entry for transformed glyf/loca.
330
+ # Per spec section 5.3, transformLength MUST be 0 and the data is
331
+ # omitted from the brotli-compressed block.
332
+ def build_loca_entry(orig_length)
333
+ entry = Woff2::Directory::Entry.new
334
+ entry.tag = "loca"
335
+ entry.orig_length = orig_length
336
+ entry.transform_length = 0
337
+ entry.flags = entry.calculate_flags
338
+ entry
382
339
  end
383
340
 
384
- # Compress all tables into single Brotli stream
385
- #
386
- # @param entries [Array<Woff2::Directory::Entry>] Table entries
387
- # @param table_data [Hash<String, String>] Original table data
388
- # @param quality [Integer] Brotli quality
389
- # @return [String] Compressed data
390
- def compress_tables(entries, table_data, quality)
391
- # Concatenate all table data in entry order
341
+ def compress_tables(entries, table_data, transformed_data, quality)
392
342
  combined_data = String.new(encoding: Encoding::BINARY)
393
343
 
394
344
  entries.each do |entry|
395
- # Use transformed data if available, otherwise use original
396
- data = if @transformed_data && @transformed_data[entry.tag]
397
- @transformed_data[entry.tag]
398
- else
399
- table_data[entry.tag]
400
- end
401
-
345
+ data = transformed_data[entry.tag] || table_data[entry.tag]
402
346
  next unless data
403
347
 
404
348
  combined_data << data
405
349
  end
406
350
 
407
- # Compress with Brotli
408
- Utilities::BrotliWrapper.compress(
409
- combined_data,
410
- quality: quality,
411
- )
351
+ Utilities::BrotliWrapper.compress(combined_data, quality: quality)
412
352
  end
413
353
 
414
- # Calculate total SFNT size (uncompressed)
354
+ # Calculate total SFNT size (uncompressed) per spec.
415
355
  #
416
- # @param table_data [Hash<String, String>] Table data map
417
- # @return [Integer] Total size in bytes
418
- def calculate_sfnt_size(table_data)
419
- # Header size (offset table)
420
- size = 12
421
-
422
- # Table directory size
423
- size += table_data.size * 16
356
+ # When glyf/loca are transformed, loca's data is omitted from the
357
+ # brotli-compressed block but is reconstructed by the decoder. The
358
+ # SFNT size still includes the reconstructed loca table.
359
+ def calculate_sfnt_size(table_data, glyf_transform: nil)
360
+ size = 12 # offset table
361
+ size += table_data.size * 16 # table directory
362
+ size += 16 if glyf_transform # synthetic loca directory entry
424
363
 
425
- # Table data size (with padding)
426
364
  table_data.each_value do |data|
427
365
  size += data.bytesize
428
- # Add padding to 4-byte boundary
429
- padding = (4 - (data.bytesize % 4)) % 4
430
- size += padding
366
+ size += (4 - (data.bytesize % 4)) % 4 # pad to 4-byte boundary
367
+ end
368
+
369
+ if glyf_transform
370
+ loca_len = glyf_transform[:loca_orig_length]
371
+ size += loca_len
372
+ size += (4 - (loca_len % 4)) % 4
431
373
  end
432
374
 
433
375
  size
434
376
  end
435
377
 
436
- # Build WOFF2 header
437
- #
438
- # @param flavor [Integer] Font flavor
439
- # @param num_tables [Integer] Number of tables
440
- # @param total_sfnt_size [Integer] Uncompressed size
441
- # @param total_compressed_size [Integer] Compressed size
442
- # @return [Woff2::Woff2Header] WOFF2 header
443
378
  def build_header(flavor:, num_tables:, total_sfnt_size:,
444
- total_compressed_size:)
379
+ total_compressed_size:)
445
380
  header = Woff2::Woff2Header.new
446
381
  header.signature = Woff2::Woff2Header::SIGNATURE
447
382
  header.flavor = flavor
448
- header.file_length = 0 # Will be updated later
383
+ header.file_length = 0 # updated by update_woff2_length!
449
384
  header.num_tables = num_tables
450
385
  header.reserved = 0
451
386
  header.total_sfnt_size = total_sfnt_size
@@ -457,59 +392,29 @@ total_compressed_size:)
457
392
  header.meta_orig_length = 0
458
393
  header.priv_offset = 0
459
394
  header.priv_length = 0
460
-
461
395
  header
462
396
  end
463
397
 
464
- # Assemble complete WOFF2 binary
465
- #
466
- # @param header [Woff2::Woff2Header] WOFF2 header
467
- # @param entries [Array<Woff2::Directory::Entry>] Table entries
468
- # @param compressed_data [String] Compressed table data
469
- # @return [String] Complete WOFF2 binary
470
398
  def assemble_woff2(header, entries, compressed_data)
471
399
  woff2_data = String.new(encoding: Encoding::BINARY)
400
+ woff2_data << header.to_binary_s
472
401
 
473
- # Write header (placeholder, we'll update file_length later)
474
- header_binary = header.to_binary_s
475
- woff2_data << header_binary
476
-
477
- # Write table directory
478
402
  entries.each do |entry|
479
403
  woff2_data << [entry.flags].pack("C")
480
-
481
- # Write custom tag if needed
482
- unless entry.known_tag?
483
- woff2_data << entry.tag.ljust(4, "\x00")
484
- end
485
-
486
- # Write orig_length (UIntBase128)
404
+ woff2_data << entry.tag.ljust(4, "\x00") unless entry.known_tag?
487
405
  woff2_data << Woff2::Directory.encode_uint_base128(entry.orig_length)
488
-
489
- # Write transform_length if present
490
406
  if entry.transformed?
491
407
  woff2_data << Woff2::Directory.encode_uint_base128(entry.transform_length)
492
408
  end
493
409
  end
494
410
 
495
- # Write compressed data
496
411
  woff2_data << compressed_data
497
-
498
- # Update header file_length field
499
412
  update_woff2_length!(woff2_data)
500
-
501
413
  woff2_data
502
414
  end
503
415
 
504
- # Update WOFF2 file length in header
505
- #
506
- # @param woff2_data [String] WOFF2 binary (modified in place)
507
- # @return [void]
508
416
  def update_woff2_length!(woff2_data)
509
- total_length = woff2_data.bytesize
510
-
511
- # file_length field is at offset 8 in header (uint32)
512
- woff2_data[8, 4] = [total_length].pack("N")
417
+ woff2_data[8, 4] = [woff2_data.bytesize].pack("N")
513
418
  end
514
419
  end
515
420
  end
@@ -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.13"
5
5
  end