fontisan 0.1.0 → 0.2.0
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/.rubocop_todo.yml +529 -65
- data/Gemfile +1 -0
- data/LICENSE +5 -1
- data/README.adoc +1301 -275
- data/Rakefile +27 -2
- data/benchmark/variation_quick_bench.rb +47 -0
- data/docs/EXTRACT_TTC_MIGRATION.md +549 -0
- data/fontisan.gemspec +4 -1
- data/lib/fontisan/binary/base_record.rb +22 -1
- data/lib/fontisan/cli.rb +309 -0
- data/lib/fontisan/collection/builder.rb +260 -0
- data/lib/fontisan/collection/offset_calculator.rb +227 -0
- data/lib/fontisan/collection/table_analyzer.rb +204 -0
- data/lib/fontisan/collection/table_deduplicator.rb +241 -0
- data/lib/fontisan/collection/writer.rb +306 -0
- data/lib/fontisan/commands/base_command.rb +8 -1
- data/lib/fontisan/commands/convert_command.rb +291 -0
- data/lib/fontisan/commands/export_command.rb +161 -0
- data/lib/fontisan/commands/info_command.rb +40 -6
- data/lib/fontisan/commands/instance_command.rb +295 -0
- data/lib/fontisan/commands/ls_command.rb +113 -0
- data/lib/fontisan/commands/pack_command.rb +241 -0
- data/lib/fontisan/commands/subset_command.rb +245 -0
- data/lib/fontisan/commands/unpack_command.rb +338 -0
- data/lib/fontisan/commands/validate_command.rb +178 -0
- data/lib/fontisan/commands/variable_command.rb +30 -1
- data/lib/fontisan/config/collection_settings.yml +56 -0
- data/lib/fontisan/config/conversion_matrix.yml +212 -0
- data/lib/fontisan/config/export_settings.yml +66 -0
- data/lib/fontisan/config/subset_profiles.yml +100 -0
- data/lib/fontisan/config/svg_settings.yml +60 -0
- data/lib/fontisan/config/validation_rules.yml +149 -0
- data/lib/fontisan/config/variable_settings.yml +99 -0
- data/lib/fontisan/config/woff2_settings.yml +77 -0
- data/lib/fontisan/constants.rb +69 -0
- data/lib/fontisan/converters/conversion_strategy.rb +96 -0
- data/lib/fontisan/converters/format_converter.rb +259 -0
- data/lib/fontisan/converters/outline_converter.rb +936 -0
- data/lib/fontisan/converters/svg_generator.rb +244 -0
- data/lib/fontisan/converters/table_copier.rb +117 -0
- data/lib/fontisan/converters/woff2_encoder.rb +416 -0
- data/lib/fontisan/converters/woff_writer.rb +391 -0
- data/lib/fontisan/error.rb +203 -0
- data/lib/fontisan/export/exporter.rb +262 -0
- data/lib/fontisan/export/table_serializer.rb +255 -0
- data/lib/fontisan/export/transformers/font_to_ttx.rb +172 -0
- data/lib/fontisan/export/transformers/head_transformer.rb +96 -0
- data/lib/fontisan/export/transformers/hhea_transformer.rb +59 -0
- data/lib/fontisan/export/transformers/maxp_transformer.rb +63 -0
- data/lib/fontisan/export/transformers/name_transformer.rb +63 -0
- data/lib/fontisan/export/transformers/os2_transformer.rb +121 -0
- data/lib/fontisan/export/transformers/post_transformer.rb +51 -0
- data/lib/fontisan/export/ttx_generator.rb +527 -0
- data/lib/fontisan/export/ttx_parser.rb +300 -0
- data/lib/fontisan/font_loader.rb +121 -12
- data/lib/fontisan/font_writer.rb +301 -0
- data/lib/fontisan/formatters/text_formatter.rb +102 -0
- data/lib/fontisan/glyph_accessor.rb +503 -0
- data/lib/fontisan/hints/hint_converter.rb +177 -0
- data/lib/fontisan/hints/postscript_hint_applier.rb +185 -0
- data/lib/fontisan/hints/postscript_hint_extractor.rb +254 -0
- data/lib/fontisan/hints/truetype_hint_applier.rb +71 -0
- data/lib/fontisan/hints/truetype_hint_extractor.rb +162 -0
- data/lib/fontisan/loading_modes.rb +113 -0
- data/lib/fontisan/metrics_calculator.rb +277 -0
- data/lib/fontisan/models/collection_font_summary.rb +52 -0
- data/lib/fontisan/models/collection_info.rb +76 -0
- data/lib/fontisan/models/collection_list_info.rb +37 -0
- data/lib/fontisan/models/font_export.rb +158 -0
- data/lib/fontisan/models/font_summary.rb +48 -0
- data/lib/fontisan/models/glyph_outline.rb +343 -0
- data/lib/fontisan/models/hint.rb +233 -0
- data/lib/fontisan/models/outline.rb +664 -0
- data/lib/fontisan/models/table_sharing_info.rb +40 -0
- data/lib/fontisan/models/ttx/glyph_order.rb +31 -0
- data/lib/fontisan/models/ttx/tables/binary_table.rb +67 -0
- data/lib/fontisan/models/ttx/tables/head_table.rb +74 -0
- data/lib/fontisan/models/ttx/tables/hhea_table.rb +74 -0
- data/lib/fontisan/models/ttx/tables/maxp_table.rb +55 -0
- data/lib/fontisan/models/ttx/tables/name_table.rb +45 -0
- data/lib/fontisan/models/ttx/tables/os2_table.rb +157 -0
- data/lib/fontisan/models/ttx/tables/post_table.rb +50 -0
- data/lib/fontisan/models/ttx/ttfont.rb +49 -0
- data/lib/fontisan/models/validation_report.rb +203 -0
- data/lib/fontisan/open_type_collection.rb +156 -2
- data/lib/fontisan/open_type_font.rb +296 -10
- data/lib/fontisan/optimizers/charstring_rewriter.rb +161 -0
- data/lib/fontisan/optimizers/pattern_analyzer.rb +308 -0
- data/lib/fontisan/optimizers/stack_tracker.rb +246 -0
- data/lib/fontisan/optimizers/subroutine_builder.rb +134 -0
- data/lib/fontisan/optimizers/subroutine_generator.rb +207 -0
- data/lib/fontisan/optimizers/subroutine_optimizer.rb +107 -0
- data/lib/fontisan/outline_extractor.rb +423 -0
- data/lib/fontisan/subset/builder.rb +268 -0
- data/lib/fontisan/subset/glyph_mapping.rb +215 -0
- data/lib/fontisan/subset/options.rb +142 -0
- data/lib/fontisan/subset/profile.rb +152 -0
- data/lib/fontisan/subset/table_subsetter.rb +461 -0
- data/lib/fontisan/svg/font_face_generator.rb +278 -0
- data/lib/fontisan/svg/font_generator.rb +264 -0
- data/lib/fontisan/svg/glyph_generator.rb +168 -0
- data/lib/fontisan/svg/view_box_calculator.rb +137 -0
- data/lib/fontisan/tables/cff/cff_glyph.rb +176 -0
- data/lib/fontisan/tables/cff/charset.rb +282 -0
- data/lib/fontisan/tables/cff/charstring.rb +905 -0
- data/lib/fontisan/tables/cff/charstring_builder.rb +322 -0
- data/lib/fontisan/tables/cff/charstrings_index.rb +162 -0
- data/lib/fontisan/tables/cff/dict.rb +351 -0
- data/lib/fontisan/tables/cff/dict_builder.rb +242 -0
- data/lib/fontisan/tables/cff/encoding.rb +274 -0
- data/lib/fontisan/tables/cff/header.rb +102 -0
- data/lib/fontisan/tables/cff/index.rb +237 -0
- data/lib/fontisan/tables/cff/index_builder.rb +170 -0
- data/lib/fontisan/tables/cff/private_dict.rb +284 -0
- data/lib/fontisan/tables/cff/top_dict.rb +236 -0
- data/lib/fontisan/tables/cff.rb +487 -0
- data/lib/fontisan/tables/cff2/blend_operator.rb +240 -0
- data/lib/fontisan/tables/cff2/charstring_parser.rb +591 -0
- data/lib/fontisan/tables/cff2/operand_stack.rb +232 -0
- data/lib/fontisan/tables/cff2.rb +341 -0
- data/lib/fontisan/tables/cvar.rb +242 -0
- data/lib/fontisan/tables/fvar.rb +2 -2
- data/lib/fontisan/tables/glyf/compound_glyph.rb +483 -0
- data/lib/fontisan/tables/glyf/compound_glyph_resolver.rb +136 -0
- data/lib/fontisan/tables/glyf/curve_converter.rb +343 -0
- data/lib/fontisan/tables/glyf/glyph_builder.rb +450 -0
- data/lib/fontisan/tables/glyf/simple_glyph.rb +382 -0
- data/lib/fontisan/tables/glyf.rb +235 -0
- data/lib/fontisan/tables/gvar.rb +270 -0
- data/lib/fontisan/tables/hhea.rb +124 -0
- data/lib/fontisan/tables/hmtx.rb +287 -0
- data/lib/fontisan/tables/hvar.rb +191 -0
- data/lib/fontisan/tables/loca.rb +322 -0
- data/lib/fontisan/tables/maxp.rb +192 -0
- data/lib/fontisan/tables/mvar.rb +185 -0
- data/lib/fontisan/tables/name.rb +99 -30
- data/lib/fontisan/tables/variation_common.rb +346 -0
- data/lib/fontisan/tables/vvar.rb +234 -0
- data/lib/fontisan/true_type_collection.rb +156 -2
- data/lib/fontisan/true_type_font.rb +297 -11
- data/lib/fontisan/utilities/brotli_wrapper.rb +159 -0
- data/lib/fontisan/utilities/checksum_calculator.rb +18 -0
- data/lib/fontisan/utils/thread_pool.rb +134 -0
- data/lib/fontisan/validation/checksum_validator.rb +170 -0
- data/lib/fontisan/validation/consistency_validator.rb +197 -0
- data/lib/fontisan/validation/structure_validator.rb +198 -0
- data/lib/fontisan/validation/table_validator.rb +158 -0
- data/lib/fontisan/validation/validator.rb +152 -0
- data/lib/fontisan/variable/axis_normalizer.rb +215 -0
- data/lib/fontisan/variable/delta_applicator.rb +313 -0
- data/lib/fontisan/variable/glyph_delta_processor.rb +218 -0
- data/lib/fontisan/variable/instancer.rb +344 -0
- data/lib/fontisan/variable/metric_delta_processor.rb +282 -0
- data/lib/fontisan/variable/region_matcher.rb +208 -0
- data/lib/fontisan/variable/static_font_builder.rb +213 -0
- data/lib/fontisan/variable/table_updater.rb +219 -0
- data/lib/fontisan/variation/blend_applier.rb +199 -0
- data/lib/fontisan/variation/cache.rb +298 -0
- data/lib/fontisan/variation/cache_key_builder.rb +162 -0
- data/lib/fontisan/variation/converter.rb +268 -0
- data/lib/fontisan/variation/data_extractor.rb +86 -0
- data/lib/fontisan/variation/delta_applier.rb +266 -0
- data/lib/fontisan/variation/delta_parser.rb +228 -0
- data/lib/fontisan/variation/inspector.rb +275 -0
- data/lib/fontisan/variation/instance_generator.rb +273 -0
- data/lib/fontisan/variation/interpolator.rb +231 -0
- data/lib/fontisan/variation/metrics_adjuster.rb +318 -0
- data/lib/fontisan/variation/optimizer.rb +418 -0
- data/lib/fontisan/variation/parallel_generator.rb +150 -0
- data/lib/fontisan/variation/region_matcher.rb +221 -0
- data/lib/fontisan/variation/subsetter.rb +463 -0
- data/lib/fontisan/variation/table_accessor.rb +105 -0
- data/lib/fontisan/variation/validator.rb +345 -0
- data/lib/fontisan/variation/variation_context.rb +211 -0
- data/lib/fontisan/version.rb +1 -1
- data/lib/fontisan/woff2/directory.rb +257 -0
- data/lib/fontisan/woff2/header.rb +101 -0
- data/lib/fontisan/woff2/table_transformer.rb +163 -0
- data/lib/fontisan/woff2_font.rb +712 -0
- data/lib/fontisan/woff_font.rb +483 -0
- data/lib/fontisan.rb +120 -0
- data/scripts/compare_stack_aware.rb +187 -0
- data/scripts/measure_optimization.rb +141 -0
- metadata +205 -4
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "zlib"
|
|
4
|
+
require_relative "conversion_strategy"
|
|
5
|
+
require_relative "../utilities/checksum_calculator"
|
|
6
|
+
|
|
7
|
+
module Fontisan
|
|
8
|
+
module Converters
|
|
9
|
+
# WOFF font writer for creating WOFF files from TTF/OTF fonts
|
|
10
|
+
#
|
|
11
|
+
# [`WoffWriter`](lib/fontisan/converters/woff_writer.rb) handles conversion
|
|
12
|
+
# from TrueType/OpenType fonts to WOFF format using zlib compression.
|
|
13
|
+
# This implements the WOFF 1.0 specification for web font optimization.
|
|
14
|
+
#
|
|
15
|
+
# **WOFF Format Features:**
|
|
16
|
+
# - Individual table compression with zlib
|
|
17
|
+
# - Optional metadata block (compressed XML)
|
|
18
|
+
# - Optional private data block
|
|
19
|
+
# - Proper header and table directory structure
|
|
20
|
+
# - Cross-platform compatibility
|
|
21
|
+
#
|
|
22
|
+
# **Compression Strategy:**
|
|
23
|
+
# - Each table is compressed individually for optimal ratios
|
|
24
|
+
# - Tables smaller than compression threshold remain uncompressed
|
|
25
|
+
# - Metadata and private data are compressed when present
|
|
26
|
+
# - All data is properly aligned and padded
|
|
27
|
+
#
|
|
28
|
+
# @example Converting TTF to WOFF
|
|
29
|
+
# writer = Fontisan::Converters::WoffWriter.new
|
|
30
|
+
# woff_data = writer.write_font(ttf_font, metadata: xml_metadata)
|
|
31
|
+
# File.write("output.woff", woff_data)
|
|
32
|
+
#
|
|
33
|
+
# @example With compression options
|
|
34
|
+
# writer = Fontisan::Converters::WoffWriter.new(
|
|
35
|
+
# compression_level: 9, # Maximum compression
|
|
36
|
+
# compression_threshold: 100 # Bytes - tables smaller than this stay uncompressed
|
|
37
|
+
# )
|
|
38
|
+
# woff_data = writer.write_font(ttf_font)
|
|
39
|
+
class WoffWriter
|
|
40
|
+
include ConversionStrategy
|
|
41
|
+
|
|
42
|
+
# WOFF signature constant
|
|
43
|
+
WOFF_SIGNATURE = 0x774F4646 # 'wOFF'
|
|
44
|
+
|
|
45
|
+
# WOFF version 1.0
|
|
46
|
+
WOFF_VERSION_MAJOR = 1
|
|
47
|
+
WOFF_VERSION_MINOR = 0
|
|
48
|
+
|
|
49
|
+
# Default compression settings
|
|
50
|
+
DEFAULT_COMPRESSION_LEVEL = 6
|
|
51
|
+
DEFAULT_COMPRESSION_THRESHOLD = 100 # bytes - don't compress smaller tables
|
|
52
|
+
|
|
53
|
+
# Compression level (0-9, where 9 is maximum)
|
|
54
|
+
attr_accessor :compression_level
|
|
55
|
+
|
|
56
|
+
# Minimum table size to compress (bytes)
|
|
57
|
+
attr_accessor :compression_threshold
|
|
58
|
+
|
|
59
|
+
# Optional metadata XML
|
|
60
|
+
attr_accessor :metadata_xml
|
|
61
|
+
|
|
62
|
+
# Optional private data
|
|
63
|
+
attr_accessor :private_data
|
|
64
|
+
|
|
65
|
+
# Initialize writer with compression options
|
|
66
|
+
#
|
|
67
|
+
# @param options [Hash] Writer options
|
|
68
|
+
# @option options [Integer] :compression_level zlib compression level (0-9)
|
|
69
|
+
# @option options [Integer] :compression_threshold minimum table size to compress
|
|
70
|
+
# @option options [String] :metadata_xml optional metadata XML
|
|
71
|
+
# @option options [String] :private_data optional private data
|
|
72
|
+
def initialize(options = {})
|
|
73
|
+
@compression_level = options[:compression_level] || DEFAULT_COMPRESSION_LEVEL
|
|
74
|
+
@compression_threshold = options[:compression_threshold] || DEFAULT_COMPRESSION_THRESHOLD
|
|
75
|
+
@metadata_xml = options[:metadata_xml]
|
|
76
|
+
@private_data = options[:private_data]
|
|
77
|
+
|
|
78
|
+
validate_compression_level!
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Convert font to WOFF format
|
|
82
|
+
#
|
|
83
|
+
# @param font [TrueTypeFont, OpenTypeFont] Source font
|
|
84
|
+
# @param options [Hash] Additional options for this conversion
|
|
85
|
+
# @return [String] WOFF file data as binary string
|
|
86
|
+
# @raise [ArgumentError] if font is invalid
|
|
87
|
+
def convert(font, options = {})
|
|
88
|
+
validate_font(font)
|
|
89
|
+
|
|
90
|
+
# Override instance options with per-conversion options
|
|
91
|
+
metadata = options[:metadata_xml] || @metadata_xml
|
|
92
|
+
private_data = options[:private_data] || @private_data
|
|
93
|
+
|
|
94
|
+
write_font(font, metadata: metadata, private_data: private_data)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Get supported conversions
|
|
98
|
+
#
|
|
99
|
+
# @return [Array<Array<Symbol>>] Supported conversion pairs
|
|
100
|
+
def supported_conversions
|
|
101
|
+
[
|
|
102
|
+
%i[ttf woff],
|
|
103
|
+
%i[otf woff],
|
|
104
|
+
]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Write font data to WOFF format
|
|
108
|
+
#
|
|
109
|
+
# @param font [TrueTypeFont, OpenTypeFont] Source font
|
|
110
|
+
# @param metadata [String, nil] Optional metadata XML
|
|
111
|
+
# @param private_data [String, nil] Optional private data
|
|
112
|
+
# @return [String] WOFF file data
|
|
113
|
+
def write_font(font, metadata: nil, private_data: nil)
|
|
114
|
+
# Collect all table data from font
|
|
115
|
+
tables_data = collect_tables_data(font)
|
|
116
|
+
|
|
117
|
+
# Compress tables
|
|
118
|
+
compressed_tables = compress_tables(tables_data)
|
|
119
|
+
|
|
120
|
+
# Build WOFF file
|
|
121
|
+
build_woff_file(compressed_tables, font, metadata, private_data)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
private
|
|
125
|
+
|
|
126
|
+
# Validate compression level
|
|
127
|
+
#
|
|
128
|
+
# @raise [ArgumentError] if compression level is invalid
|
|
129
|
+
def validate_compression_level!
|
|
130
|
+
unless @compression_level.between?(0, 9)
|
|
131
|
+
raise ArgumentError, "Compression level must be between 0 and 9, got #{@compression_level}"
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Validate font for conversion
|
|
136
|
+
#
|
|
137
|
+
# @param font [TrueTypeFont, OpenTypeFont] Font to validate
|
|
138
|
+
# @raise [ArgumentError] if font is invalid
|
|
139
|
+
def validate_font(font)
|
|
140
|
+
raise ArgumentError, "Font cannot be nil" if font.nil?
|
|
141
|
+
|
|
142
|
+
unless font.respond_to?(:tables) && font.respond_to?(:table_data)
|
|
143
|
+
raise ArgumentError, "Font must respond to :tables and :table_data"
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Collect all table data from font
|
|
148
|
+
#
|
|
149
|
+
# @param font [TrueTypeFont, OpenTypeFont] Source font
|
|
150
|
+
# @return [Hash<String, String>] Map of table tags to binary data
|
|
151
|
+
def collect_tables_data(font)
|
|
152
|
+
tables_data = {}
|
|
153
|
+
|
|
154
|
+
font.table_names.each do |tag|
|
|
155
|
+
data = font.table_data[tag]
|
|
156
|
+
tables_data[tag] = data if data
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
tables_data
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Compress tables with zlib
|
|
163
|
+
#
|
|
164
|
+
# @param tables_data [Hash<String, String>] Original table data
|
|
165
|
+
# @return [Hash<String, Hash>] Compressed table info with original/compressed sizes
|
|
166
|
+
def compress_tables(tables_data)
|
|
167
|
+
compressed_tables = {}
|
|
168
|
+
|
|
169
|
+
tables_data.each do |tag, data|
|
|
170
|
+
original_size = data.bytesize
|
|
171
|
+
|
|
172
|
+
# Only compress if table is large enough and compression is beneficial
|
|
173
|
+
if original_size >= @compression_threshold
|
|
174
|
+
compressed_data = Zlib::Deflate.deflate(data, @compression_level)
|
|
175
|
+
compressed_size = compressed_data.bytesize
|
|
176
|
+
|
|
177
|
+
# Only use compression if it actually reduces size
|
|
178
|
+
compressed_tables[tag] = if compressed_size < original_size
|
|
179
|
+
{
|
|
180
|
+
original_data: data,
|
|
181
|
+
compressed_data: compressed_data,
|
|
182
|
+
original_length: original_size,
|
|
183
|
+
compressed_length: compressed_size,
|
|
184
|
+
is_compressed: true,
|
|
185
|
+
}
|
|
186
|
+
else
|
|
187
|
+
# Compression didn't help, store uncompressed
|
|
188
|
+
{
|
|
189
|
+
original_data: data,
|
|
190
|
+
compressed_data: data,
|
|
191
|
+
original_length: original_size,
|
|
192
|
+
compressed_length: original_size,
|
|
193
|
+
is_compressed: false,
|
|
194
|
+
}
|
|
195
|
+
end
|
|
196
|
+
else
|
|
197
|
+
# Table too small to compress
|
|
198
|
+
compressed_tables[tag] = {
|
|
199
|
+
original_data: data,
|
|
200
|
+
compressed_data: data,
|
|
201
|
+
original_length: original_size,
|
|
202
|
+
compressed_length: original_size,
|
|
203
|
+
is_compressed: false,
|
|
204
|
+
}
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
compressed_tables
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Build complete WOFF file
|
|
212
|
+
#
|
|
213
|
+
# @param compressed_tables [Hash] Compressed table information
|
|
214
|
+
# @param font [TrueTypeFont, OpenTypeFont] Source font
|
|
215
|
+
# @param metadata [String, nil] Optional metadata XML
|
|
216
|
+
# @param private_data [String, nil] Optional private data
|
|
217
|
+
# @return [String] Complete WOFF file data
|
|
218
|
+
def build_woff_file(compressed_tables, font, metadata, private_data)
|
|
219
|
+
io = StringIO.new
|
|
220
|
+
io.set_encoding(Encoding::BINARY)
|
|
221
|
+
|
|
222
|
+
# Compress metadata if provided
|
|
223
|
+
compressed_metadata = compress_metadata(metadata)
|
|
224
|
+
|
|
225
|
+
# Calculate offsets and sizes
|
|
226
|
+
header_size = 44 # WOFF header size
|
|
227
|
+
num_tables = compressed_tables.length
|
|
228
|
+
table_dir_size = num_tables * 20 # Each table directory entry is 20 bytes
|
|
229
|
+
|
|
230
|
+
# Calculate data offset (after header + table directory)
|
|
231
|
+
data_offset = header_size + table_dir_size
|
|
232
|
+
|
|
233
|
+
# Calculate metadata and private data offsets
|
|
234
|
+
metadata_offset = data_offset
|
|
235
|
+
metadata_size = compressed_metadata ? compressed_metadata[:compressed_length] : 0
|
|
236
|
+
|
|
237
|
+
# Calculate total compressed data size
|
|
238
|
+
total_compressed_size = compressed_tables.values.sum { |table| table[:compressed_length] }
|
|
239
|
+
|
|
240
|
+
# Calculate private data offset (after table data + metadata)
|
|
241
|
+
private_offset = data_offset + total_compressed_size + metadata_size
|
|
242
|
+
private_size = private_data ? private_data.bytesize : 0
|
|
243
|
+
|
|
244
|
+
# Calculate total WOFF file size
|
|
245
|
+
total_size = private_offset + private_size
|
|
246
|
+
|
|
247
|
+
# Calculate total SFNT size (uncompressed)
|
|
248
|
+
total_sfnt_size = compressed_tables.values.sum { |table| table[:original_length] } +
|
|
249
|
+
header_size + table_dir_size
|
|
250
|
+
|
|
251
|
+
# Write WOFF header
|
|
252
|
+
write_woff_header(io, font, total_size, total_sfnt_size, num_tables,
|
|
253
|
+
compressed_metadata, metadata_offset, metadata_size,
|
|
254
|
+
private_offset, private_size)
|
|
255
|
+
|
|
256
|
+
# Write table directory
|
|
257
|
+
write_table_directory(io, compressed_tables, data_offset)
|
|
258
|
+
|
|
259
|
+
# Write compressed table data
|
|
260
|
+
write_compressed_table_data(io, compressed_tables)
|
|
261
|
+
|
|
262
|
+
# Write compressed metadata if present
|
|
263
|
+
write_metadata(io, compressed_metadata) if compressed_metadata
|
|
264
|
+
|
|
265
|
+
# Write private data if present
|
|
266
|
+
write_private_data(io, private_data) if private_data
|
|
267
|
+
|
|
268
|
+
io.string
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Compress metadata with zlib
|
|
272
|
+
#
|
|
273
|
+
# @param metadata [String, nil] Metadata XML
|
|
274
|
+
# @return [Hash, nil] Compressed metadata info or nil
|
|
275
|
+
def compress_metadata(metadata)
|
|
276
|
+
return nil unless metadata
|
|
277
|
+
|
|
278
|
+
original_length = metadata.bytesize
|
|
279
|
+
compressed_data = Zlib::Deflate.deflate(metadata, @compression_level)
|
|
280
|
+
compressed_length = compressed_data.bytesize
|
|
281
|
+
|
|
282
|
+
{
|
|
283
|
+
original_data: metadata,
|
|
284
|
+
compressed_data: compressed_data,
|
|
285
|
+
original_length: original_length,
|
|
286
|
+
compressed_length: compressed_length,
|
|
287
|
+
}
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Write WOFF header
|
|
291
|
+
#
|
|
292
|
+
# @param io [StringIO] Output stream
|
|
293
|
+
# @param font [TrueTypeFont, OpenTypeFont] Source font
|
|
294
|
+
# @param total_size [Integer] Total WOFF file size
|
|
295
|
+
# @param total_sfnt_size [Integer] Uncompressed SFNT size
|
|
296
|
+
# @param num_tables [Integer] Number of tables
|
|
297
|
+
# @param compressed_metadata [Hash, nil] Compressed metadata info
|
|
298
|
+
# @param metadata_offset [Integer] Metadata offset
|
|
299
|
+
# @param metadata_size [Integer] Compressed metadata size
|
|
300
|
+
# @param private_offset [Integer] Private data offset
|
|
301
|
+
# @param private_size [Integer] Private data size
|
|
302
|
+
# @return [void]
|
|
303
|
+
def write_woff_header(io, font, total_size, total_sfnt_size, num_tables,
|
|
304
|
+
compressed_metadata, metadata_offset, metadata_size,
|
|
305
|
+
private_offset, private_size)
|
|
306
|
+
# Determine flavor from font
|
|
307
|
+
flavor = if font.respond_to?(:cff?) && font.cff?
|
|
308
|
+
Constants::SFNT_VERSION_OTTO
|
|
309
|
+
else
|
|
310
|
+
# Default to TrueType for TrueType fonts and unknown types
|
|
311
|
+
Constants::SFNT_VERSION_TRUETYPE
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# Write WOFF header (44 bytes total)
|
|
315
|
+
io.write([WOFF_SIGNATURE].pack("N")) # signature (4 bytes)
|
|
316
|
+
io.write([flavor].pack("N")) # flavor (4 bytes)
|
|
317
|
+
io.write([total_size].pack("N")) # length (4 bytes)
|
|
318
|
+
io.write([num_tables].pack("n")) # numTables (2 bytes)
|
|
319
|
+
io.write([0].pack("n")) # reserved (2 bytes)
|
|
320
|
+
io.write([total_sfnt_size].pack("N")) # totalSfntSize (4 bytes)
|
|
321
|
+
io.write([WOFF_VERSION_MAJOR].pack("n")) # majorVersion (2 bytes)
|
|
322
|
+
io.write([WOFF_VERSION_MINOR].pack("n")) # minorVersion (2 bytes)
|
|
323
|
+
io.write([metadata_offset].pack("N")) # metaOffset (4 bytes)
|
|
324
|
+
io.write([metadata_size].pack("N")) # metaLength (4 bytes)
|
|
325
|
+
io.write([compressed_metadata ? compressed_metadata[:original_length] : 0].pack("N")) # metaOrigLength (4 bytes)
|
|
326
|
+
io.write([private_offset].pack("N")) # privOffset (4 bytes)
|
|
327
|
+
io.write([private_size].pack("N")) # privLength (4 bytes)
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# Write table directory
|
|
331
|
+
#
|
|
332
|
+
# @param io [StringIO] Output stream
|
|
333
|
+
# @param compressed_tables [Hash] Compressed table information
|
|
334
|
+
# @param data_offset [Integer] Starting offset for table data
|
|
335
|
+
# @return [void]
|
|
336
|
+
def write_table_directory(io, compressed_tables, data_offset)
|
|
337
|
+
current_offset = data_offset
|
|
338
|
+
|
|
339
|
+
# Sort tables by tag for consistent output
|
|
340
|
+
sorted_tables = compressed_tables.sort_by { |tag, _| tag }
|
|
341
|
+
|
|
342
|
+
sorted_tables.each do |tag, table_info|
|
|
343
|
+
# Calculate checksum of original table data
|
|
344
|
+
checksum = Utilities::ChecksumCalculator.calculate_table_checksum(table_info[:original_data])
|
|
345
|
+
|
|
346
|
+
# Write table directory entry (20 bytes)
|
|
347
|
+
io.write(tag) # tag (4 bytes)
|
|
348
|
+
io.write([current_offset].pack("N")) # offset (4 bytes)
|
|
349
|
+
io.write([table_info[:compressed_length]].pack("N")) # compLength (4 bytes)
|
|
350
|
+
io.write([table_info[:original_length]].pack("N")) # origLength (4 bytes)
|
|
351
|
+
io.write([checksum].pack("N")) # origChecksum (4 bytes)
|
|
352
|
+
|
|
353
|
+
# Update offset for next table
|
|
354
|
+
current_offset += table_info[:compressed_length]
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# Write compressed table data
|
|
359
|
+
#
|
|
360
|
+
# @param io [StringIO] Output stream
|
|
361
|
+
# @param compressed_tables [Hash] Compressed table information
|
|
362
|
+
# @return [void]
|
|
363
|
+
def write_compressed_table_data(io, compressed_tables)
|
|
364
|
+
# Sort tables by tag for consistent output (same order as directory)
|
|
365
|
+
sorted_tables = compressed_tables.sort_by { |tag, _| tag }
|
|
366
|
+
|
|
367
|
+
sorted_tables.each_value do |table_info|
|
|
368
|
+
io.write(table_info[:compressed_data])
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# Write metadata to output
|
|
373
|
+
#
|
|
374
|
+
# @param io [StringIO] Output stream
|
|
375
|
+
# @param compressed_metadata [Hash] Compressed metadata info
|
|
376
|
+
# @return [void]
|
|
377
|
+
def write_metadata(io, compressed_metadata)
|
|
378
|
+
io.write(compressed_metadata[:compressed_data])
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# Write private data to output
|
|
382
|
+
#
|
|
383
|
+
# @param io [StringIO] Output stream
|
|
384
|
+
# @param private_data [String] Private data
|
|
385
|
+
# @return [void]
|
|
386
|
+
def write_private_data(io, private_data)
|
|
387
|
+
io.write(private_data)
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
end
|
data/lib/fontisan/error.rb
CHANGED
|
@@ -12,4 +12,207 @@ module Fontisan
|
|
|
12
12
|
class MissingTableError < Error; end
|
|
13
13
|
|
|
14
14
|
class ParseError < Error; end
|
|
15
|
+
|
|
16
|
+
class SubsettingError < Error; end
|
|
17
|
+
|
|
18
|
+
# Base variation error with context and suggestions
|
|
19
|
+
#
|
|
20
|
+
# Provides detailed error information including context hash and
|
|
21
|
+
# actionable suggestions for resolution.
|
|
22
|
+
class VariationError < Error
|
|
23
|
+
# @return [Hash] Error context (axis, value, range, etc.)
|
|
24
|
+
attr_reader :context
|
|
25
|
+
|
|
26
|
+
# @return [String, nil] Suggested fix
|
|
27
|
+
attr_reader :suggestion
|
|
28
|
+
|
|
29
|
+
# Initialize variation error
|
|
30
|
+
#
|
|
31
|
+
# @param message [String] Error message
|
|
32
|
+
# @param context [Hash] Error context
|
|
33
|
+
# @param suggestion [String, nil] Suggested fix
|
|
34
|
+
def initialize(message, context: {}, suggestion: nil)
|
|
35
|
+
super(message)
|
|
36
|
+
@context = context
|
|
37
|
+
@suggestion = suggestion
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Get detailed error message with context and suggestion
|
|
41
|
+
#
|
|
42
|
+
# @return [String] Formatted error message
|
|
43
|
+
def detailed_message
|
|
44
|
+
msg = message
|
|
45
|
+
msg += "\nContext: #{@context.inspect}" if @context.any?
|
|
46
|
+
msg += "\nSuggestion: #{@suggestion}" if @suggestion
|
|
47
|
+
msg
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Invalid coordinate value for variation axis
|
|
52
|
+
#
|
|
53
|
+
# Raised when coordinate is outside valid axis range.
|
|
54
|
+
class InvalidCoordinatesError < VariationError
|
|
55
|
+
# Initialize with axis details
|
|
56
|
+
#
|
|
57
|
+
# @param axis [String] Axis tag
|
|
58
|
+
# @param value [Float] Invalid value
|
|
59
|
+
# @param range [Range, Array] Valid range
|
|
60
|
+
# @param message [String, nil] Custom message
|
|
61
|
+
def initialize(axis: nil, value: nil, range: nil, message: nil)
|
|
62
|
+
if message
|
|
63
|
+
super(message, context: { axis: axis, value: value, range: range })
|
|
64
|
+
else
|
|
65
|
+
min_val = range.is_a?(Range) ? range.min : range.first
|
|
66
|
+
max_val = range.is_a?(Range) ? range.max : range.last
|
|
67
|
+
|
|
68
|
+
super(
|
|
69
|
+
"Invalid coordinate for axis '#{axis}': #{value}",
|
|
70
|
+
context: { axis: axis, value: value, range: range },
|
|
71
|
+
suggestion: "Use value between #{min_val} and #{max_val}"
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Missing required variation table
|
|
78
|
+
#
|
|
79
|
+
# Raised when font lacks required variation tables.
|
|
80
|
+
class MissingVariationTableError < VariationError
|
|
81
|
+
# Initialize with table tag
|
|
82
|
+
#
|
|
83
|
+
# @param table [String] Missing table tag
|
|
84
|
+
# @param message [String, nil] Custom message
|
|
85
|
+
def initialize(table: nil, message: nil)
|
|
86
|
+
if message
|
|
87
|
+
super(message, context: { table: table })
|
|
88
|
+
else
|
|
89
|
+
super(
|
|
90
|
+
"Missing required variation table: #{table}",
|
|
91
|
+
context: { table: table },
|
|
92
|
+
suggestion: "This font is not a variable font or lacks #{table} table"
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Invalid variation axis specification
|
|
99
|
+
#
|
|
100
|
+
# Raised when axis definition is malformed or invalid.
|
|
101
|
+
class InvalidAxisError < VariationError
|
|
102
|
+
# Initialize with axis details
|
|
103
|
+
#
|
|
104
|
+
# @param axis [String] Axis tag
|
|
105
|
+
# @param reason [String] Why axis is invalid
|
|
106
|
+
def initialize(axis:, reason:)
|
|
107
|
+
super(
|
|
108
|
+
"Invalid variation axis '#{axis}': #{reason}",
|
|
109
|
+
context: { axis: axis, reason: reason },
|
|
110
|
+
suggestion: "Check axis definition in fvar table"
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Overlapping variation regions detected
|
|
116
|
+
#
|
|
117
|
+
# Raised when variation regions overlap improperly.
|
|
118
|
+
class RegionOverlapError < VariationError
|
|
119
|
+
# Initialize with region details
|
|
120
|
+
#
|
|
121
|
+
# @param region1 [Integer] First region index
|
|
122
|
+
# @param region2 [Integer] Second region index
|
|
123
|
+
def initialize(region1:, region2:)
|
|
124
|
+
super(
|
|
125
|
+
"Overlapping variation regions: #{region1} and #{region2}",
|
|
126
|
+
context: { region1: region1, region2: region2 },
|
|
127
|
+
suggestion: "Check variation region definitions for conflicts"
|
|
128
|
+
)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Delta count mismatch
|
|
133
|
+
#
|
|
134
|
+
# Raised when delta arrays have mismatched lengths.
|
|
135
|
+
class DeltaMismatchError < VariationError
|
|
136
|
+
# Initialize with delta details
|
|
137
|
+
#
|
|
138
|
+
# @param expected [Integer] Expected delta count
|
|
139
|
+
# @param actual [Integer] Actual delta count
|
|
140
|
+
# @param location [String] Where mismatch occurred
|
|
141
|
+
def initialize(expected:, actual:, location:)
|
|
142
|
+
super(
|
|
143
|
+
"Delta count mismatch at #{location}: expected #{expected}, got #{actual}",
|
|
144
|
+
context: { expected: expected, actual: actual, location: location },
|
|
145
|
+
suggestion: "Verify variation data integrity in #{location}"
|
|
146
|
+
)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Invalid instance index
|
|
151
|
+
#
|
|
152
|
+
# Raised when named instance index is out of range.
|
|
153
|
+
class InvalidInstanceIndexError < VariationError
|
|
154
|
+
# Initialize with instance details
|
|
155
|
+
#
|
|
156
|
+
# @param index [Integer] Requested index
|
|
157
|
+
# @param max [Integer] Maximum valid index
|
|
158
|
+
def initialize(index:, max:)
|
|
159
|
+
super(
|
|
160
|
+
"Invalid instance index: #{index} (max: #{max})",
|
|
161
|
+
context: { index: index, max: max },
|
|
162
|
+
suggestion: "Use index between 0 and #{max}"
|
|
163
|
+
)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Variation data corruption
|
|
168
|
+
#
|
|
169
|
+
# Raised when variation data appears corrupted or invalid.
|
|
170
|
+
class CorruptedVariationDataError < VariationError
|
|
171
|
+
# Initialize with corruption details
|
|
172
|
+
#
|
|
173
|
+
# @param table [String] Table with corrupted data
|
|
174
|
+
# @param details [String] Corruption details
|
|
175
|
+
def initialize(table:, details:)
|
|
176
|
+
super(
|
|
177
|
+
"Corrupted variation data in #{table}: #{details}",
|
|
178
|
+
context: { table: table, details: details },
|
|
179
|
+
suggestion: "Font file may be damaged, try re-downloading or using original"
|
|
180
|
+
)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Invalid variation data
|
|
185
|
+
#
|
|
186
|
+
# Raised when variation data is invalid but not necessarily corrupted.
|
|
187
|
+
# Used for validation failures.
|
|
188
|
+
class InvalidVariationDataError < VariationError
|
|
189
|
+
# Initialize with validation details
|
|
190
|
+
#
|
|
191
|
+
# @param message [String] Error message
|
|
192
|
+
# @param details [Hash] Error details
|
|
193
|
+
def initialize(message:, details: {})
|
|
194
|
+
super(
|
|
195
|
+
message,
|
|
196
|
+
context: details,
|
|
197
|
+
suggestion: "Check font variation data and structure"
|
|
198
|
+
)
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Variation data corrupted (for use in data_extractor)
|
|
203
|
+
#
|
|
204
|
+
# Raised when extracted variation data appears corrupted.
|
|
205
|
+
class VariationDataCorruptedError < VariationError
|
|
206
|
+
# Initialize with corruption details
|
|
207
|
+
#
|
|
208
|
+
# @param message [String] Error message
|
|
209
|
+
# @param details [Hash] Corruption details
|
|
210
|
+
def initialize(message:, details: {})
|
|
211
|
+
super(
|
|
212
|
+
message,
|
|
213
|
+
context: details,
|
|
214
|
+
suggestion: "Font variation data may be corrupted"
|
|
215
|
+
)
|
|
216
|
+
end
|
|
217
|
+
end
|
|
15
218
|
end
|