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 +4 -4
- data/lib/fontisan/converters/woff2_encoder.rb +147 -242
- data/lib/fontisan/sfnt_font.rb +7 -2
- data/lib/fontisan/tables/head.rb +10 -0
- data/lib/fontisan/version.rb +1 -1
- data/lib/fontisan/woff2/directory.rb +37 -40
- data/lib/fontisan/woff2/encoder_rules.rb +66 -0
- data/lib/fontisan/woff2/glyf_loca_transform.rb +323 -0
- data/lib/fontisan/woff2/triplet_codec.rb +181 -0
- data/lib/fontisan/woff2.rb +3 -0
- data/lib/fontisan/woff_font.rb +5 -2
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9f871e8b123fcb90294e234b49a73ee6ad5541a5064c11e34b47cf8a0c26646a
|
|
4
|
+
data.tar.gz: 90fb63c4e71aaf2bb91f4884f3b6cdca26b5b9bbb9dac3f0a812d96bca5d69f8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
21
|
-
# 5.
|
|
22
|
-
# 6.
|
|
23
|
-
# 7.
|
|
24
|
-
# 8.
|
|
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
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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:
|
|
95
|
+
total_compressed_size: compressed_data.bytesize,
|
|
114
96
|
)
|
|
115
97
|
|
|
116
|
-
|
|
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,
|
|
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
|
-
#
|
|
300
|
-
#
|
|
301
|
-
#
|
|
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.
|
|
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
|
-
#
|
|
222
|
+
# Apply the WOFF2 glyf/loca paired transform per spec section 5.1/5.3.
|
|
323
223
|
#
|
|
324
|
-
#
|
|
325
|
-
#
|
|
326
|
-
#
|
|
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
|
-
# @
|
|
339
|
-
#
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
|
|
356
|
-
|
|
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
|
-
|
|
373
|
-
|
|
294
|
+
def build_entry(tag:, data:, transformer:, transform_enabled:,
|
|
295
|
+
transformed_data:)
|
|
296
|
+
return nil unless data
|
|
374
297
|
|
|
375
|
-
|
|
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
|
-
|
|
379
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
417
|
-
#
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
size = 12
|
|
421
|
-
|
|
422
|
-
#
|
|
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
|
-
#
|
|
429
|
-
|
|
430
|
-
|
|
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 #
|
|
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
|
-
|
|
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
|
data/lib/fontisan/sfnt_font.rb
CHANGED
|
@@ -431,9 +431,14 @@ module Fontisan
|
|
|
431
431
|
|
|
432
432
|
# Get list of all table tags (cached for performance)
|
|
433
433
|
#
|
|
434
|
-
#
|
|
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(
|
|
441
|
+
@table_names ||= tables.map { |entry| normalize_tag(entry.tag) }
|
|
437
442
|
end
|
|
438
443
|
|
|
439
444
|
# Get OOP SfntTable instance for a table
|
data/lib/fontisan/tables/head.rb
CHANGED
|
@@ -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
|
|
data/lib/fontisan/version.rb
CHANGED