fontisan 0.2.0 → 0.2.1
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 +270 -131
- data/README.adoc +158 -4
- data/Rakefile +44 -47
- data/lib/fontisan/cli.rb +84 -33
- data/lib/fontisan/collection/builder.rb +81 -0
- data/lib/fontisan/collection/table_deduplicator.rb +76 -0
- data/lib/fontisan/commands/base_command.rb +16 -0
- data/lib/fontisan/commands/convert_command.rb +97 -170
- data/lib/fontisan/commands/instance_command.rb +71 -80
- data/lib/fontisan/commands/validate_command.rb +25 -0
- data/lib/fontisan/config/validation_rules.yml +1 -1
- data/lib/fontisan/constants.rb +10 -0
- data/lib/fontisan/converters/format_converter.rb +150 -1
- data/lib/fontisan/converters/outline_converter.rb +80 -18
- data/lib/fontisan/converters/woff_writer.rb +1 -1
- data/lib/fontisan/font_loader.rb +3 -5
- data/lib/fontisan/font_writer.rb +7 -6
- data/lib/fontisan/hints/hint_converter.rb +133 -0
- data/lib/fontisan/hints/postscript_hint_applier.rb +221 -140
- data/lib/fontisan/hints/postscript_hint_extractor.rb +100 -0
- data/lib/fontisan/hints/truetype_hint_applier.rb +90 -44
- data/lib/fontisan/hints/truetype_hint_extractor.rb +127 -0
- data/lib/fontisan/loading_modes.rb +2 -0
- data/lib/fontisan/models/font_export.rb +2 -2
- data/lib/fontisan/models/hint.rb +173 -1
- data/lib/fontisan/models/validation_report.rb +1 -1
- data/lib/fontisan/open_type_font.rb +25 -9
- data/lib/fontisan/open_type_font_extensions.rb +54 -0
- data/lib/fontisan/pipeline/format_detector.rb +249 -0
- data/lib/fontisan/pipeline/output_writer.rb +154 -0
- data/lib/fontisan/pipeline/strategies/base_strategy.rb +75 -0
- data/lib/fontisan/pipeline/strategies/instance_strategy.rb +93 -0
- data/lib/fontisan/pipeline/strategies/named_strategy.rb +118 -0
- data/lib/fontisan/pipeline/strategies/preserve_strategy.rb +56 -0
- data/lib/fontisan/pipeline/transformation_pipeline.rb +411 -0
- data/lib/fontisan/pipeline/variation_resolver.rb +165 -0
- data/lib/fontisan/tables/cff/charstring.rb +33 -4
- data/lib/fontisan/tables/cff/charstring_builder.rb +34 -0
- data/lib/fontisan/tables/cff/charstring_parser.rb +237 -0
- data/lib/fontisan/tables/cff/charstring_rebuilder.rb +172 -0
- data/lib/fontisan/tables/cff/dict_builder.rb +15 -0
- data/lib/fontisan/tables/cff/hint_operation_injector.rb +207 -0
- data/lib/fontisan/tables/cff/offset_recalculator.rb +70 -0
- data/lib/fontisan/tables/cff/private_dict_writer.rb +125 -0
- data/lib/fontisan/tables/cff/table_builder.rb +221 -0
- data/lib/fontisan/tables/cff.rb +2 -0
- data/lib/fontisan/tables/cff2/private_dict_blend_handler.rb +246 -0
- data/lib/fontisan/tables/cff2/region_matcher.rb +200 -0
- data/lib/fontisan/tables/cff2/table_builder.rb +574 -0
- data/lib/fontisan/tables/cff2/table_reader.rb +419 -0
- data/lib/fontisan/tables/cff2/variation_data_extractor.rb +212 -0
- data/lib/fontisan/tables/cff2.rb +9 -4
- data/lib/fontisan/tables/cvar.rb +2 -41
- data/lib/fontisan/tables/gvar.rb +2 -41
- data/lib/fontisan/true_type_font.rb +24 -9
- data/lib/fontisan/true_type_font_extensions.rb +54 -0
- data/lib/fontisan/utilities/checksum_calculator.rb +42 -0
- data/lib/fontisan/validation/checksum_validator.rb +2 -2
- data/lib/fontisan/validation/table_validator.rb +1 -1
- data/lib/fontisan/validation/variable_font_validator.rb +218 -0
- data/lib/fontisan/variation/converter.rb +120 -13
- data/lib/fontisan/variation/instance_writer.rb +341 -0
- data/lib/fontisan/variation/tuple_variation_header.rb +51 -0
- data/lib/fontisan/variation/variable_svg_generator.rb +268 -0
- data/lib/fontisan/variation/variation_preserver.rb +288 -0
- data/lib/fontisan/version.rb +1 -1
- data/lib/fontisan/version.rb.orig +9 -0
- data/lib/fontisan/woff2/glyf_transformer.rb +666 -0
- data/lib/fontisan/woff2/hmtx_transformer.rb +164 -0
- data/lib/fontisan/woff2_font.rb +475 -470
- data/lib/fontisan/woff_font.rb +16 -11
- data/lib/fontisan.rb +12 -0
- metadata +31 -2
|
@@ -258,6 +258,20 @@ module Fontisan
|
|
|
258
258
|
true
|
|
259
259
|
end
|
|
260
260
|
|
|
261
|
+
# Check if font is TrueType flavored
|
|
262
|
+
#
|
|
263
|
+
# @return [Boolean] false for OpenType fonts
|
|
264
|
+
def truetype?
|
|
265
|
+
false
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Check if font is CFF flavored
|
|
269
|
+
#
|
|
270
|
+
# @return [Boolean] true for OpenType fonts
|
|
271
|
+
def cff?
|
|
272
|
+
true
|
|
273
|
+
end
|
|
274
|
+
|
|
261
275
|
# Check if font has a specific table
|
|
262
276
|
#
|
|
263
277
|
# @param tag [String] The table tag to check for
|
|
@@ -490,6 +504,7 @@ module Fontisan
|
|
|
490
504
|
Constants::OS2_TAG => Tables::Os2,
|
|
491
505
|
Constants::POST_TAG => Tables::Post,
|
|
492
506
|
Constants::CMAP_TAG => Tables::Cmap,
|
|
507
|
+
Constants::CFF_TAG => Tables::Cff,
|
|
493
508
|
Constants::FVAR_TAG => Tables::Fvar,
|
|
494
509
|
Constants::GSUB_TAG => Tables::Gsub,
|
|
495
510
|
Constants::GPOS_TAG => Tables::Gpos,
|
|
@@ -558,18 +573,19 @@ module Fontisan
|
|
|
558
573
|
# @param path [String] Path to the OTF file
|
|
559
574
|
# @return [void]
|
|
560
575
|
def update_checksum_adjustment_in_file(path)
|
|
561
|
-
#
|
|
562
|
-
|
|
576
|
+
# Use tempfile-based checksum calculation for Windows compatibility
|
|
577
|
+
# This keeps the tempfile alive until we're done with the checksum
|
|
578
|
+
File.open(path, "r+b") do |io|
|
|
579
|
+
checksum, _tmpfile = Utilities::ChecksumCalculator.calculate_checksum_from_io_with_tempfile(io)
|
|
563
580
|
|
|
564
|
-
|
|
565
|
-
|
|
581
|
+
# Calculate adjustment
|
|
582
|
+
adjustment = Utilities::ChecksumCalculator.calculate_adjustment(checksum)
|
|
566
583
|
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
584
|
+
# Find head table position
|
|
585
|
+
head_entry = head_table
|
|
586
|
+
return unless head_entry
|
|
570
587
|
|
|
571
|
-
|
|
572
|
-
File.open(path, "r+b") do |io|
|
|
588
|
+
# Write adjustment to head table (offset 8 within head table)
|
|
573
589
|
io.seek(head_entry.offset + 8)
|
|
574
590
|
io.write([adjustment].pack("N"))
|
|
575
591
|
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
# Extensions to OpenTypeFont for table-based construction
|
|
5
|
+
class OpenTypeFont
|
|
6
|
+
# Create font from hash of tables
|
|
7
|
+
#
|
|
8
|
+
# This is used during font conversion when we have tables but not a file.
|
|
9
|
+
#
|
|
10
|
+
# @param tables [Hash<String, String>] Map of table tag to binary data
|
|
11
|
+
# @return [OpenTypeFont] New font instance
|
|
12
|
+
def self.from_tables(tables)
|
|
13
|
+
# Create minimal header structure
|
|
14
|
+
font = new
|
|
15
|
+
font.initialize_storage
|
|
16
|
+
font.loading_mode = LoadingModes::FULL
|
|
17
|
+
|
|
18
|
+
# Store table data
|
|
19
|
+
font.table_data = tables
|
|
20
|
+
|
|
21
|
+
# Build header from tables
|
|
22
|
+
num_tables = tables.size
|
|
23
|
+
max_power = 0
|
|
24
|
+
n = num_tables
|
|
25
|
+
while n > 1
|
|
26
|
+
n >>= 1
|
|
27
|
+
max_power += 1
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
search_range = (1 << max_power) * 16
|
|
31
|
+
entry_selector = max_power
|
|
32
|
+
range_shift = (num_tables * 16) - search_range
|
|
33
|
+
|
|
34
|
+
font.header.sfnt_version = 0x4F54544F # 'OTTO' for OpenType/CFF
|
|
35
|
+
font.header.num_tables = num_tables
|
|
36
|
+
font.header.search_range = search_range
|
|
37
|
+
font.header.entry_selector = entry_selector
|
|
38
|
+
font.header.range_shift = range_shift
|
|
39
|
+
|
|
40
|
+
# Build table directory
|
|
41
|
+
font.tables.clear
|
|
42
|
+
tables.each_key do |tag|
|
|
43
|
+
entry = TableDirectory.new
|
|
44
|
+
entry.tag = tag
|
|
45
|
+
entry.checksum = 0 # Will be calculated on write
|
|
46
|
+
entry.offset = 0 # Will be calculated on write
|
|
47
|
+
entry.table_length = tables[tag].bytesize
|
|
48
|
+
font.tables << entry
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
font
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../font_loader"
|
|
4
|
+
|
|
5
|
+
module Fontisan
|
|
6
|
+
module Pipeline
|
|
7
|
+
# Detects font format and capabilities
|
|
8
|
+
#
|
|
9
|
+
# This class analyzes font files to determine:
|
|
10
|
+
# - Format: TTF, OTF, TTC, OTC, WOFF, WOFF2, SVG
|
|
11
|
+
# - Variation type: static, gvar (TrueType variable), CFF2 (OpenType variable)
|
|
12
|
+
# - Capabilities: outline type, variation support, collection support
|
|
13
|
+
#
|
|
14
|
+
# Used by the universal transformation pipeline to determine conversion
|
|
15
|
+
# strategies and validate compatibility.
|
|
16
|
+
#
|
|
17
|
+
# @example Detecting a font's format
|
|
18
|
+
# detector = FormatDetector.new("font.ttf")
|
|
19
|
+
# info = detector.detect
|
|
20
|
+
# puts info[:format] # => :ttf
|
|
21
|
+
# puts info[:variation_type] # => :gvar
|
|
22
|
+
# puts info[:capabilities][:outline] # => :truetype
|
|
23
|
+
class FormatDetector
|
|
24
|
+
# @return [String] Path to font file
|
|
25
|
+
attr_reader :file_path
|
|
26
|
+
|
|
27
|
+
# @return [TrueTypeFont, OpenTypeFont, TrueTypeCollection, OpenTypeCollection, nil] Loaded font
|
|
28
|
+
attr_reader :font
|
|
29
|
+
|
|
30
|
+
# Initialize detector
|
|
31
|
+
#
|
|
32
|
+
# @param file_path [String] Path to font file
|
|
33
|
+
def initialize(file_path)
|
|
34
|
+
@file_path = file_path
|
|
35
|
+
@font = nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Detect format and capabilities
|
|
39
|
+
#
|
|
40
|
+
# @return [Hash] Detection results with :format, :variation_type, :capabilities
|
|
41
|
+
def detect
|
|
42
|
+
load_font
|
|
43
|
+
|
|
44
|
+
{
|
|
45
|
+
format: detect_format,
|
|
46
|
+
variation_type: detect_variation,
|
|
47
|
+
capabilities: detect_capabilities,
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Detect font format
|
|
52
|
+
#
|
|
53
|
+
# @return [Symbol] One of :ttf, :otf, :ttc, :otc, :woff, :woff2, :svg
|
|
54
|
+
def detect_format
|
|
55
|
+
# Check for SVG first (from file extension even if font failed to load)
|
|
56
|
+
return :svg if @file_path.end_with?(".svg")
|
|
57
|
+
|
|
58
|
+
return :unknown unless @font
|
|
59
|
+
|
|
60
|
+
# Use is_a? for proper class checking
|
|
61
|
+
case @font
|
|
62
|
+
when Fontisan::TrueTypeCollection
|
|
63
|
+
:ttc
|
|
64
|
+
when Fontisan::OpenTypeCollection
|
|
65
|
+
:otc
|
|
66
|
+
when Fontisan::TrueTypeFont
|
|
67
|
+
if @file_path.end_with?(".woff")
|
|
68
|
+
:woff
|
|
69
|
+
elsif @file_path.end_with?(".woff2")
|
|
70
|
+
:woff2
|
|
71
|
+
else
|
|
72
|
+
:ttf
|
|
73
|
+
end
|
|
74
|
+
when Fontisan::OpenTypeFont
|
|
75
|
+
if @file_path.end_with?(".woff")
|
|
76
|
+
:woff
|
|
77
|
+
elsif @file_path.end_with?(".woff2")
|
|
78
|
+
:woff2
|
|
79
|
+
else
|
|
80
|
+
:otf
|
|
81
|
+
end
|
|
82
|
+
else
|
|
83
|
+
:unknown
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Detect variation type
|
|
88
|
+
#
|
|
89
|
+
# @return [Symbol] One of :static, :gvar, :cff2
|
|
90
|
+
def detect_variation
|
|
91
|
+
return :static unless @font
|
|
92
|
+
|
|
93
|
+
# Collections don't have has_table? method
|
|
94
|
+
# Return :static for collections (variation detection would need to load first font)
|
|
95
|
+
return :static if collection?
|
|
96
|
+
|
|
97
|
+
# Check for variable font tables
|
|
98
|
+
if @font.has_table?("fvar")
|
|
99
|
+
# Variable font detected - check variation type
|
|
100
|
+
if @font.has_table?("gvar")
|
|
101
|
+
:gvar # TrueType variable font
|
|
102
|
+
elsif @font.has_table?("CFF2")
|
|
103
|
+
:cff2 # OpenType variable font (CFF2)
|
|
104
|
+
else
|
|
105
|
+
:static # Has fvar but no variation data (shouldn't happen)
|
|
106
|
+
end
|
|
107
|
+
else
|
|
108
|
+
:static
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Detect font capabilities
|
|
113
|
+
#
|
|
114
|
+
# @return [Hash] Capabilities hash
|
|
115
|
+
def detect_capabilities
|
|
116
|
+
return default_capabilities unless @font
|
|
117
|
+
|
|
118
|
+
# Check if this is a collection
|
|
119
|
+
is_collection = collection?
|
|
120
|
+
|
|
121
|
+
font_to_check = if is_collection
|
|
122
|
+
# Collections don't have fonts method, need to load first font
|
|
123
|
+
nil # Will handle in API usage
|
|
124
|
+
else
|
|
125
|
+
@font
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# For collections, return basic capabilities
|
|
129
|
+
if is_collection
|
|
130
|
+
return {
|
|
131
|
+
outline: :unknown, # Would need to load first font to know
|
|
132
|
+
variation: false, # Would need to load first font to know
|
|
133
|
+
collection: true,
|
|
134
|
+
tables: [],
|
|
135
|
+
}
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
return default_capabilities unless font_to_check
|
|
139
|
+
|
|
140
|
+
{
|
|
141
|
+
outline: detect_outline_type(font_to_check),
|
|
142
|
+
variation: detect_variation != :static,
|
|
143
|
+
collection: false,
|
|
144
|
+
tables: available_tables(font_to_check),
|
|
145
|
+
}
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Check if font is a collection
|
|
149
|
+
#
|
|
150
|
+
# @return [Boolean] True if collection (TTC/OTC)
|
|
151
|
+
def collection?
|
|
152
|
+
@font.is_a?(Fontisan::TrueTypeCollection) ||
|
|
153
|
+
@font.is_a?(Fontisan::OpenTypeCollection)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Check if font is variable
|
|
157
|
+
#
|
|
158
|
+
# @return [Boolean] True if variable font
|
|
159
|
+
def variable?
|
|
160
|
+
detect_variation != :static
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Check if format is compatible with target
|
|
164
|
+
#
|
|
165
|
+
# @param target_format [Symbol] Target format (:ttf, :otf, etc.)
|
|
166
|
+
# @return [Boolean] True if conversion is possible
|
|
167
|
+
def compatible_with?(target_format)
|
|
168
|
+
current_format = detect_format
|
|
169
|
+
variation_type = detect_variation
|
|
170
|
+
|
|
171
|
+
# Same format is always compatible
|
|
172
|
+
return true if current_format == target_format
|
|
173
|
+
|
|
174
|
+
# Collection formats
|
|
175
|
+
if %i[ttc otc].include?(current_format)
|
|
176
|
+
return %i[ttc otc].include?(target_format)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Variable font constraints
|
|
180
|
+
if variation_type == :static
|
|
181
|
+
# Static fonts can convert to any format
|
|
182
|
+
true
|
|
183
|
+
else
|
|
184
|
+
case variation_type
|
|
185
|
+
when :gvar
|
|
186
|
+
# TrueType variable can convert to TrueType formats
|
|
187
|
+
%i[ttf ttc woff woff2].include?(target_format)
|
|
188
|
+
when :cff2
|
|
189
|
+
# OpenType variable can convert to OpenType formats
|
|
190
|
+
%i[otf otc woff woff2].include?(target_format)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
private
|
|
196
|
+
|
|
197
|
+
# Load font from file
|
|
198
|
+
def load_font
|
|
199
|
+
# Check if it's a collection first
|
|
200
|
+
@font = if FontLoader.collection?(@file_path)
|
|
201
|
+
FontLoader.load_collection(@file_path)
|
|
202
|
+
else
|
|
203
|
+
FontLoader.load(@file_path, mode: :full)
|
|
204
|
+
end
|
|
205
|
+
rescue StandardError => e
|
|
206
|
+
warn "Failed to load font: #{e.message}"
|
|
207
|
+
@font = nil
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Detect outline type
|
|
211
|
+
#
|
|
212
|
+
# @param font [Font] Font object
|
|
213
|
+
# @return [Symbol] :truetype or :cff
|
|
214
|
+
def detect_outline_type(font)
|
|
215
|
+
if font.has_table?("glyf") || font.has_table?("gvar")
|
|
216
|
+
:truetype
|
|
217
|
+
elsif font.has_table?("CFF ") || font.has_table?("CFF2")
|
|
218
|
+
:cff
|
|
219
|
+
else
|
|
220
|
+
:unknown
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Get available tables
|
|
225
|
+
#
|
|
226
|
+
# @param font [Font] Font object
|
|
227
|
+
# @return [Array<String>] List of table tags
|
|
228
|
+
def available_tables(font)
|
|
229
|
+
return [] unless font.respond_to?(:table_names)
|
|
230
|
+
|
|
231
|
+
font.table_names
|
|
232
|
+
rescue StandardError
|
|
233
|
+
[]
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Default capabilities when font cannot be loaded
|
|
237
|
+
#
|
|
238
|
+
# @return [Hash] Default capabilities
|
|
239
|
+
def default_capabilities
|
|
240
|
+
{
|
|
241
|
+
outline: :unknown,
|
|
242
|
+
variation: false,
|
|
243
|
+
collection: false,
|
|
244
|
+
tables: [],
|
|
245
|
+
}
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../font_writer"
|
|
4
|
+
|
|
5
|
+
module Fontisan
|
|
6
|
+
module Pipeline
|
|
7
|
+
# Handles writing font tables to various output formats
|
|
8
|
+
#
|
|
9
|
+
# This class abstracts the complexity of writing different font formats:
|
|
10
|
+
# - SFNT formats (TTF, OTF) via FontWriter
|
|
11
|
+
# - WOFF via WoffWriter
|
|
12
|
+
# - WOFF2 via Woff2Encoder
|
|
13
|
+
#
|
|
14
|
+
# Single Responsibility: Coordinate output writing for different formats
|
|
15
|
+
#
|
|
16
|
+
# @example Write TTF font
|
|
17
|
+
# writer = OutputWriter.new("output.ttf", :ttf)
|
|
18
|
+
# writer.write(tables)
|
|
19
|
+
#
|
|
20
|
+
# @example Write OTF font
|
|
21
|
+
# writer = OutputWriter.new("output.otf", :otf)
|
|
22
|
+
# writer.write(tables)
|
|
23
|
+
class OutputWriter
|
|
24
|
+
# @return [String] Output file path
|
|
25
|
+
attr_reader :output_path
|
|
26
|
+
|
|
27
|
+
# @return [Symbol] Target format
|
|
28
|
+
attr_reader :format
|
|
29
|
+
|
|
30
|
+
# @return [Hash] Writing options
|
|
31
|
+
attr_reader :options
|
|
32
|
+
|
|
33
|
+
# Initialize output writer
|
|
34
|
+
#
|
|
35
|
+
# @param output_path [String] Path to write output
|
|
36
|
+
# @param format [Symbol] Target format (:ttf, :otf, :woff, :woff2)
|
|
37
|
+
# @param options [Hash] Writing options
|
|
38
|
+
def initialize(output_path, format, options = {})
|
|
39
|
+
@output_path = output_path
|
|
40
|
+
@format = format
|
|
41
|
+
@options = options
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Write font tables to output file
|
|
45
|
+
#
|
|
46
|
+
# @param tables [Hash<String, String>, Hash] Font tables (tag => binary data) or special format result
|
|
47
|
+
# @return [Integer] Number of bytes written
|
|
48
|
+
# @raise [ArgumentError] If format is unsupported
|
|
49
|
+
def write(tables)
|
|
50
|
+
case @format
|
|
51
|
+
when :ttf, :otf
|
|
52
|
+
write_sfnt(tables)
|
|
53
|
+
when :woff
|
|
54
|
+
write_woff(tables)
|
|
55
|
+
when :woff2
|
|
56
|
+
write_woff2(tables)
|
|
57
|
+
when :svg
|
|
58
|
+
write_svg(tables)
|
|
59
|
+
else
|
|
60
|
+
raise ArgumentError, "Unsupported output format: #{@format}"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
# Write SVG format
|
|
67
|
+
#
|
|
68
|
+
# @param result [Hash] Result with :svg_xml key
|
|
69
|
+
# @return [Integer] Number of bytes written
|
|
70
|
+
def write_svg(result)
|
|
71
|
+
svg_xml = result[:svg_xml] || result["svg_xml"]
|
|
72
|
+
raise ArgumentError, "SVG result must contain :svg_xml key" unless svg_xml
|
|
73
|
+
|
|
74
|
+
File.write(@output_path, svg_xml)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Write SFNT format (TTF or OTF)
|
|
78
|
+
#
|
|
79
|
+
# @param tables [Hash<String, String>] Font tables
|
|
80
|
+
# @return [Integer] Number of bytes written
|
|
81
|
+
def write_sfnt(tables)
|
|
82
|
+
sfnt_version = determine_sfnt_version
|
|
83
|
+
FontWriter.write_to_file(tables, @output_path, sfnt_version: sfnt_version)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Write WOFF format
|
|
87
|
+
#
|
|
88
|
+
# @param tables [Hash<String, String>] Font tables
|
|
89
|
+
# @return [Integer] Number of bytes written
|
|
90
|
+
def write_woff(tables)
|
|
91
|
+
require_relative "../converters/woff_writer"
|
|
92
|
+
|
|
93
|
+
writer = Converters::WoffWriter.new
|
|
94
|
+
font = build_font_from_tables(tables)
|
|
95
|
+
result = writer.convert(font, @options)
|
|
96
|
+
|
|
97
|
+
File.binwrite(@output_path, result[:woff_data])
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Write WOFF2 format
|
|
101
|
+
#
|
|
102
|
+
# @param tables [Hash<String, String>] Font tables
|
|
103
|
+
# @return [Integer] Number of bytes written
|
|
104
|
+
def write_woff2(tables)
|
|
105
|
+
require_relative "../converters/woff2_encoder"
|
|
106
|
+
|
|
107
|
+
encoder = Converters::Woff2Encoder.new
|
|
108
|
+
font = build_font_from_tables(tables)
|
|
109
|
+
result = encoder.convert(font, @options)
|
|
110
|
+
|
|
111
|
+
File.binwrite(@output_path, result[:woff2_binary])
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Determine SFNT version based on format and tables
|
|
115
|
+
#
|
|
116
|
+
# @return [Integer] SFNT version (0x00010000 for TTF, 0x4F54544F for OTF)
|
|
117
|
+
def determine_sfnt_version
|
|
118
|
+
case @format
|
|
119
|
+
when :ttf, :woff, :woff2 then 0x00010000
|
|
120
|
+
when :otf then 0x4F54544F # 'OTTO'
|
|
121
|
+
else raise ArgumentError, "Unsupported format: #{@format}"
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Build font object from tables
|
|
126
|
+
#
|
|
127
|
+
# Helper to create font object from tables for converters that need it.
|
|
128
|
+
#
|
|
129
|
+
# @param tables [Hash<String, String>] Font tables
|
|
130
|
+
# @return [Font] Font object
|
|
131
|
+
def build_font_from_tables(tables)
|
|
132
|
+
# Detect font type from tables
|
|
133
|
+
has_cff = tables.key?("CFF ") || tables.key?("CFF2")
|
|
134
|
+
has_glyf = tables.key?("glyf")
|
|
135
|
+
|
|
136
|
+
if has_cff
|
|
137
|
+
OpenTypeFont.from_tables(tables)
|
|
138
|
+
elsif has_glyf
|
|
139
|
+
TrueTypeFont.from_tables(tables)
|
|
140
|
+
else
|
|
141
|
+
# Default based on format
|
|
142
|
+
case @format
|
|
143
|
+
when :ttf, :woff, :woff2
|
|
144
|
+
TrueTypeFont.from_tables(tables)
|
|
145
|
+
when :otf
|
|
146
|
+
OpenTypeFont.from_tables(tables)
|
|
147
|
+
else
|
|
148
|
+
raise ArgumentError, "Cannot determine font type for format: #{@format}"
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Pipeline
|
|
5
|
+
module Strategies
|
|
6
|
+
# Base class for variation resolution strategies
|
|
7
|
+
#
|
|
8
|
+
# This abstract class defines the interface that all variation resolution
|
|
9
|
+
# strategies must implement. It follows the Strategy pattern to allow
|
|
10
|
+
# different approaches to handling variable font data during conversion.
|
|
11
|
+
#
|
|
12
|
+
# Subclasses must implement:
|
|
13
|
+
# - resolve(font): Process the font and return tables
|
|
14
|
+
# - preserves_variation?: Indicate if variation data is preserved
|
|
15
|
+
# - strategy_name: Return the strategy identifier
|
|
16
|
+
#
|
|
17
|
+
# @example Implementing a strategy
|
|
18
|
+
# class MyStrategy < BaseStrategy
|
|
19
|
+
# def resolve(font)
|
|
20
|
+
# # Implementation
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# def preserves_variation?
|
|
24
|
+
# false
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
# def strategy_name
|
|
28
|
+
# :my_strategy
|
|
29
|
+
# end
|
|
30
|
+
# end
|
|
31
|
+
class BaseStrategy
|
|
32
|
+
# @return [Hash] Strategy options
|
|
33
|
+
attr_reader :options
|
|
34
|
+
|
|
35
|
+
# Initialize strategy with options
|
|
36
|
+
#
|
|
37
|
+
# @param options [Hash] Strategy-specific options
|
|
38
|
+
def initialize(options = {})
|
|
39
|
+
@options = options
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Resolve variation data
|
|
43
|
+
#
|
|
44
|
+
# This method must be implemented by subclasses to process the font
|
|
45
|
+
# and return the appropriate tables based on the strategy.
|
|
46
|
+
#
|
|
47
|
+
# @param font [TrueTypeFont, OpenTypeFont] Font to process
|
|
48
|
+
# @return [Hash<String, String>] Map of table tags to binary data
|
|
49
|
+
# @raise [NotImplementedError] If not implemented by subclass
|
|
50
|
+
def resolve(font)
|
|
51
|
+
raise NotImplementedError,
|
|
52
|
+
"#{self.class.name} must implement #resolve"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Check if strategy preserves variation data
|
|
56
|
+
#
|
|
57
|
+
# @return [Boolean] True if variation data is preserved
|
|
58
|
+
# @raise [NotImplementedError] If not implemented by subclass
|
|
59
|
+
def preserves_variation?
|
|
60
|
+
raise NotImplementedError,
|
|
61
|
+
"#{self.class.name} must implement #preserves_variation?"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Get strategy name
|
|
65
|
+
#
|
|
66
|
+
# @return [Symbol] Strategy identifier
|
|
67
|
+
# @raise [NotImplementedError] If not implemented by subclass
|
|
68
|
+
def strategy_name
|
|
69
|
+
raise NotImplementedError,
|
|
70
|
+
"#{self.class.name} must implement #strategy_name"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base_strategy"
|
|
4
|
+
require_relative "../../variation/instance_generator"
|
|
5
|
+
require_relative "../../variation/variation_context"
|
|
6
|
+
|
|
7
|
+
module Fontisan
|
|
8
|
+
module Pipeline
|
|
9
|
+
module Strategies
|
|
10
|
+
# Strategy for generating static instances from variable fonts
|
|
11
|
+
#
|
|
12
|
+
# This strategy creates a static font instance at specific design space
|
|
13
|
+
# coordinates by applying variation deltas and removing variation tables.
|
|
14
|
+
# It's used for:
|
|
15
|
+
# - Variable TTF → Static TTF at specific weight
|
|
16
|
+
# - Variable OTF → Static OTF at specific coordinates
|
|
17
|
+
# - Variable → Static for any format conversion
|
|
18
|
+
#
|
|
19
|
+
# The strategy uses the InstanceGenerator to:
|
|
20
|
+
# 1. Apply variation deltas (gvar or CFF2 blend)
|
|
21
|
+
# 2. Apply metrics variations (HVAR, VVAR, MVAR)
|
|
22
|
+
# 3. Remove variation tables (fvar, gvar, CFF2, avar, etc.)
|
|
23
|
+
#
|
|
24
|
+
# If no coordinates are provided, uses default coordinates (axis default values).
|
|
25
|
+
#
|
|
26
|
+
# @example Generate instance at specific weight
|
|
27
|
+
# strategy = InstanceStrategy.new(coordinates: { "wght" => 700.0 })
|
|
28
|
+
# tables = strategy.resolve(variable_font)
|
|
29
|
+
# # tables has no variation tables
|
|
30
|
+
#
|
|
31
|
+
# @example Generate instance at default coordinates
|
|
32
|
+
# strategy = InstanceStrategy.new
|
|
33
|
+
# tables = strategy.resolve(variable_font)
|
|
34
|
+
class InstanceStrategy < BaseStrategy
|
|
35
|
+
# @return [Hash<String, Float>] Design space coordinates
|
|
36
|
+
attr_reader :coordinates
|
|
37
|
+
|
|
38
|
+
# Initialize strategy with coordinates
|
|
39
|
+
#
|
|
40
|
+
# @param options [Hash] Strategy options
|
|
41
|
+
# @option options [Hash<String, Float>] :coordinates Design space coordinates
|
|
42
|
+
# (axis tag => value). If not provided, uses default coordinates.
|
|
43
|
+
def initialize(options = {})
|
|
44
|
+
super
|
|
45
|
+
@coordinates = options[:coordinates] || {}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Resolve by generating static instance
|
|
49
|
+
#
|
|
50
|
+
# Creates a static font instance at the specified coordinates using
|
|
51
|
+
# the InstanceGenerator. If coordinates are not provided, uses the
|
|
52
|
+
# default coordinates from the font's axes.
|
|
53
|
+
#
|
|
54
|
+
# @param font [TrueTypeFont, OpenTypeFont] Variable font
|
|
55
|
+
# @return [Hash<String, String>] Static font tables
|
|
56
|
+
# @raise [Variation::InvalidCoordinatesError] If coordinates out of range
|
|
57
|
+
def resolve(font)
|
|
58
|
+
# Validate coordinates if provided
|
|
59
|
+
validate_coordinates(font) unless @coordinates.empty?
|
|
60
|
+
|
|
61
|
+
# Use InstanceGenerator to create static instance
|
|
62
|
+
generator = Variation::InstanceGenerator.new(font, @coordinates)
|
|
63
|
+
generator.generate
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Check if strategy preserves variation data
|
|
67
|
+
#
|
|
68
|
+
# @return [Boolean] Always false for this strategy
|
|
69
|
+
def preserves_variation?
|
|
70
|
+
false
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Get strategy name
|
|
74
|
+
#
|
|
75
|
+
# @return [Symbol] :instance
|
|
76
|
+
def strategy_name
|
|
77
|
+
:instance
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
# Validate coordinates against font axes
|
|
83
|
+
#
|
|
84
|
+
# @param font [TrueTypeFont, OpenTypeFont] Variable font
|
|
85
|
+
# @raise [Variation::InvalidCoordinatesError] If invalid
|
|
86
|
+
def validate_coordinates(font)
|
|
87
|
+
context = Variation::VariationContext.new(font)
|
|
88
|
+
context.validate_coordinates(@coordinates)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|