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
|
@@ -24,13 +24,13 @@ module Fontisan
|
|
|
24
24
|
# 4. Build gvar table structure
|
|
25
25
|
#
|
|
26
26
|
# @example Converting gvar to CFF2 blend
|
|
27
|
-
# converter =
|
|
27
|
+
# converter = Converter.new(font, axes)
|
|
28
28
|
# blend_data = converter.gvar_to_blend(glyph_id)
|
|
29
29
|
#
|
|
30
30
|
# @example Converting CFF2 blend to gvar
|
|
31
|
-
# converter =
|
|
31
|
+
# converter = Converter.new(font, axes)
|
|
32
32
|
# tuple_data = converter.blend_to_gvar(glyph_id)
|
|
33
|
-
class
|
|
33
|
+
class Converter
|
|
34
34
|
include TableAccessor
|
|
35
35
|
|
|
36
36
|
# @return [TrueTypeFont, OpenTypeFont] Font instance
|
|
@@ -72,19 +72,49 @@ module Fontisan
|
|
|
72
72
|
#
|
|
73
73
|
# @param glyph_id [Integer] Glyph ID
|
|
74
74
|
# @return [Hash, nil] Tuple data or nil
|
|
75
|
-
def blend_to_gvar(
|
|
75
|
+
def blend_to_gvar(glyph_id)
|
|
76
76
|
return nil unless has_variation_table?("CFF2")
|
|
77
77
|
|
|
78
78
|
cff2 = variation_table("CFF2")
|
|
79
79
|
return nil unless cff2
|
|
80
80
|
|
|
81
81
|
# Get CharString with blend operators
|
|
82
|
-
|
|
83
|
-
|
|
82
|
+
charstring = cff2.charstring_for_glyph(glyph_id)
|
|
83
|
+
return nil unless charstring
|
|
84
84
|
|
|
85
|
-
#
|
|
86
|
-
|
|
87
|
-
|
|
85
|
+
# Parse CharString to extract blend data
|
|
86
|
+
charstring.parse unless charstring.instance_variable_get(:@parsed)
|
|
87
|
+
blend_data = charstring.blend_data
|
|
88
|
+
return nil if blend_data.nil? || blend_data.empty?
|
|
89
|
+
|
|
90
|
+
# Convert blend data to tuple format
|
|
91
|
+
convert_blend_to_tuples_for_glyph(blend_data)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Convert all glyphs from gvar to blend format
|
|
95
|
+
#
|
|
96
|
+
# @param glyph_count [Integer] Number of glyphs
|
|
97
|
+
# @return [Hash<Integer, Hash>] Map of glyph_id to blend data
|
|
98
|
+
def convert_all_gvar_to_blend(glyph_count)
|
|
99
|
+
return {} unless can_convert?
|
|
100
|
+
|
|
101
|
+
(0...glyph_count).each_with_object({}) do |glyph_id, result|
|
|
102
|
+
blend_data = gvar_to_blend(glyph_id)
|
|
103
|
+
result[glyph_id] = blend_data if blend_data
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Convert all glyphs from blend to gvar format
|
|
108
|
+
#
|
|
109
|
+
# @param glyph_count [Integer] Number of glyphs
|
|
110
|
+
# @return [Hash<Integer, Hash>] Map of glyph_id to tuple data
|
|
111
|
+
def convert_all_blend_to_gvar(glyph_count)
|
|
112
|
+
return {} unless can_convert?
|
|
113
|
+
|
|
114
|
+
(0...glyph_count).each_with_object({}) do |glyph_id, result|
|
|
115
|
+
tuple_data = blend_to_gvar(glyph_id)
|
|
116
|
+
result[glyph_id] = tuple_data if tuple_data
|
|
117
|
+
end
|
|
88
118
|
end
|
|
89
119
|
|
|
90
120
|
# Check if variation data can be converted
|
|
@@ -99,6 +129,76 @@ module Fontisan
|
|
|
99
129
|
|
|
100
130
|
private
|
|
101
131
|
|
|
132
|
+
# Convert blend data from a glyph to tuple format
|
|
133
|
+
#
|
|
134
|
+
# @param blend_data [Array<Hash>] Array of blend operations
|
|
135
|
+
# @return [Hash] Tuple variation data
|
|
136
|
+
def convert_blend_to_tuples_for_glyph(blend_data)
|
|
137
|
+
# Each blend operation represents variation at different points
|
|
138
|
+
# We need to aggregate these into region-based tuples
|
|
139
|
+
|
|
140
|
+
# Extract all regions from blend operations
|
|
141
|
+
regions_map = {}
|
|
142
|
+
point_count = 0
|
|
143
|
+
|
|
144
|
+
blend_data.each_with_index do |blend_op, idx|
|
|
145
|
+
blend_op[:blends].each do |blend|
|
|
146
|
+
# Track the maximum point index we've seen
|
|
147
|
+
point_count = [point_count, idx + 1].max
|
|
148
|
+
|
|
149
|
+
# For each delta axis, we need to create or update a region
|
|
150
|
+
blend[:deltas].each_with_index do |delta, axis_index|
|
|
151
|
+
next if delta.zero? # Skip zero deltas
|
|
152
|
+
|
|
153
|
+
# Create region key based on unique delta pattern
|
|
154
|
+
region_key = "region_#{axis_index}"
|
|
155
|
+
|
|
156
|
+
regions_map[region_key] ||= {
|
|
157
|
+
axis_index: axis_index,
|
|
158
|
+
deltas_per_point: Array.new(point_count) { { x: 0, y: 0 } },
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
# Store this delta for this point
|
|
162
|
+
# Note: CFF2 blend deltas are per-coordinate, we need to map to x/y
|
|
163
|
+
# This is a simplified mapping - full implementation would track
|
|
164
|
+
# which coordinates are being varied
|
|
165
|
+
regions_map[region_key][:deltas_per_point][idx / 2] ||= { x: 0, y: 0 }
|
|
166
|
+
if idx.even?
|
|
167
|
+
regions_map[region_key][:deltas_per_point][idx / 2][:x] = delta
|
|
168
|
+
else
|
|
169
|
+
regions_map[region_key][:deltas_per_point][idx / 2][:y] = delta
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Convert regions to tuples
|
|
176
|
+
tuples = []
|
|
177
|
+
regions_map.each_value do |region_data|
|
|
178
|
+
axis_index = region_data[:axis_index]
|
|
179
|
+
|
|
180
|
+
# Build peak coordinates (one per axis)
|
|
181
|
+
peak = Array.new(@axes.length, 0.0)
|
|
182
|
+
peak[axis_index] = 1.0 if axis_index < @axes.length
|
|
183
|
+
|
|
184
|
+
# Build start/end (default full range)
|
|
185
|
+
start_vals = Array.new(@axes.length, -1.0)
|
|
186
|
+
end_vals = Array.new(@axes.length, 1.0)
|
|
187
|
+
|
|
188
|
+
tuples << {
|
|
189
|
+
peak: peak,
|
|
190
|
+
start: start_vals,
|
|
191
|
+
end: end_vals,
|
|
192
|
+
deltas: region_data[:deltas_per_point],
|
|
193
|
+
}
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
{
|
|
197
|
+
tuples: tuples,
|
|
198
|
+
point_count: point_count,
|
|
199
|
+
}
|
|
200
|
+
end
|
|
201
|
+
|
|
102
202
|
# Convert tuple variations to blend format
|
|
103
203
|
#
|
|
104
204
|
# @param tuple_data [Hash] Tuple variation data from gvar
|
|
@@ -172,12 +272,19 @@ module Fontisan
|
|
|
172
272
|
# @param tuple [Hash] Tuple data
|
|
173
273
|
# @param point_count [Integer] Number of points
|
|
174
274
|
# @return [Array<Hash>] Deltas with :x and :y
|
|
175
|
-
def parse_tuple_deltas(
|
|
176
|
-
#
|
|
177
|
-
|
|
275
|
+
def parse_tuple_deltas(tuple, point_count)
|
|
276
|
+
# If tuple has deltas array, use it
|
|
277
|
+
if tuple[:deltas].is_a?(Array)
|
|
278
|
+
return tuple[:deltas].map do |delta|
|
|
279
|
+
{ x: delta[:x] || 0, y: delta[:y] || 0 }
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Otherwise return zeros (placeholder for parsing raw delta data)
|
|
284
|
+
# Full implementation would:
|
|
285
|
+
# 1. Parse delta data from tuple[:data]
|
|
178
286
|
# 2. Decompress if needed
|
|
179
287
|
# 3. Return array of { x: dx, y: dy } for each point
|
|
180
|
-
|
|
181
288
|
Array.new(point_count) { { x: 0, y: 0 } }
|
|
182
289
|
end
|
|
183
290
|
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../font_writer"
|
|
4
|
+
require_relative "../converters/outline_converter"
|
|
5
|
+
require_relative "../converters/woff_writer"
|
|
6
|
+
require_relative "../error"
|
|
7
|
+
|
|
8
|
+
module Fontisan
|
|
9
|
+
module Variation
|
|
10
|
+
# Writes generated static font instances to files in various formats
|
|
11
|
+
#
|
|
12
|
+
# [`InstanceWriter`](lib/fontisan/variation/instance_writer.rb) takes
|
|
13
|
+
# instance tables generated by
|
|
14
|
+
# [`InstanceGenerator`](lib/fontisan/variation/instance_generator.rb) and
|
|
15
|
+
# writes them to files in the desired output format. It handles:
|
|
16
|
+
# - Format detection from file extension
|
|
17
|
+
# - Format conversion when needed (e.g., glyf → CFF for OTF)
|
|
18
|
+
# - SFNT version selection based on output format
|
|
19
|
+
# - Integration with FontWriter for binary output
|
|
20
|
+
# - Integration with OutlineConverter for format conversion
|
|
21
|
+
# - Integration with WoffWriter for WOFF packaging
|
|
22
|
+
#
|
|
23
|
+
# **Supported Output Formats:**
|
|
24
|
+
# - TTF (TrueType with glyf outlines)
|
|
25
|
+
# - OTF (OpenType with CFF outlines)
|
|
26
|
+
# - WOFF (Web Open Font Format)
|
|
27
|
+
# - WOFF2 (Web Open Font Format 2.0, future)
|
|
28
|
+
#
|
|
29
|
+
# @example Write instance to TTF
|
|
30
|
+
# tables = generator.generate
|
|
31
|
+
# InstanceWriter.write(tables, 'bold.ttf')
|
|
32
|
+
#
|
|
33
|
+
# @example Write instance to OTF with format conversion
|
|
34
|
+
# tables = generator.generate # from variable TTF
|
|
35
|
+
# InstanceWriter.write(tables, 'bold.otf', source_format: :ttf)
|
|
36
|
+
#
|
|
37
|
+
# @example Write instance to WOFF
|
|
38
|
+
# tables = generator.generate
|
|
39
|
+
# InstanceWriter.write(tables, 'bold.woff')
|
|
40
|
+
class InstanceWriter
|
|
41
|
+
# Supported output formats
|
|
42
|
+
SUPPORTED_FORMATS = %i[ttf otf woff woff2].freeze
|
|
43
|
+
|
|
44
|
+
# SFNT version constants
|
|
45
|
+
SFNT_VERSION_TRUETYPE = 0x00010000 # TrueType with glyf
|
|
46
|
+
SFNT_VERSION_CFF = 0x4F54544F # 'OTTO' for CFF
|
|
47
|
+
|
|
48
|
+
# Write instance tables to file
|
|
49
|
+
#
|
|
50
|
+
# @param tables [Hash<String, String>] Instance tables from
|
|
51
|
+
# InstanceGenerator
|
|
52
|
+
# @param output_path [String] Output file path
|
|
53
|
+
# @param options [Hash] Options
|
|
54
|
+
# @option options [Symbol] :format Output format (:ttf, :otf, :woff,
|
|
55
|
+
# :woff2)
|
|
56
|
+
# @option options [Symbol] :source_format Source format before instancing
|
|
57
|
+
# (:ttf or :otf)
|
|
58
|
+
# @option options [Boolean] :optimize Enable CFF optimization for OTF
|
|
59
|
+
# (default: false)
|
|
60
|
+
# @option options [Integer] :sfnt_version Override SFNT version
|
|
61
|
+
# @return [Integer] Number of bytes written
|
|
62
|
+
# @raise [ArgumentError] If parameters are invalid
|
|
63
|
+
# @raise [Error] If format conversion fails
|
|
64
|
+
def self.write(tables, output_path, options = {})
|
|
65
|
+
new(tables, options).write(output_path)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# @return [Hash<String, String>] Instance tables
|
|
69
|
+
attr_reader :tables
|
|
70
|
+
|
|
71
|
+
# @return [Hash] Writer options
|
|
72
|
+
attr_reader :options
|
|
73
|
+
|
|
74
|
+
# Initialize writer with instance tables
|
|
75
|
+
#
|
|
76
|
+
# @param tables [Hash<String, String>] Instance tables from
|
|
77
|
+
# InstanceGenerator
|
|
78
|
+
# @param options [Hash] Writer options
|
|
79
|
+
# @option options [Symbol] :source_format Source format before instancing
|
|
80
|
+
# @option options [Boolean] :optimize Enable CFF optimization
|
|
81
|
+
def initialize(tables, options = {})
|
|
82
|
+
@tables = tables
|
|
83
|
+
@options = options
|
|
84
|
+
validate_tables!
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Write instance to file
|
|
88
|
+
#
|
|
89
|
+
# @param output_path [String] Output file path
|
|
90
|
+
# @return [Integer] Number of bytes written
|
|
91
|
+
def write(output_path)
|
|
92
|
+
# Detect output format
|
|
93
|
+
format = detect_output_format(output_path)
|
|
94
|
+
validate_format!(format)
|
|
95
|
+
|
|
96
|
+
# Detect source format from tables
|
|
97
|
+
source_format = detect_source_format(@tables)
|
|
98
|
+
|
|
99
|
+
# Convert format if needed
|
|
100
|
+
output_tables = if format_conversion_needed?(source_format, format)
|
|
101
|
+
convert_format(source_format, format)
|
|
102
|
+
else
|
|
103
|
+
@tables
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Write to file based on format
|
|
107
|
+
case format
|
|
108
|
+
when :ttf, :otf
|
|
109
|
+
write_sfnt(output_tables, output_path, format)
|
|
110
|
+
when :woff
|
|
111
|
+
write_woff(output_tables, output_path, source_format)
|
|
112
|
+
when :woff2
|
|
113
|
+
raise Fontisan::Error,
|
|
114
|
+
"WOFF2 output not yet implemented (planned for Phase 6)"
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
# Validate instance tables
|
|
121
|
+
#
|
|
122
|
+
# @raise [ArgumentError] If tables are invalid
|
|
123
|
+
def validate_tables!
|
|
124
|
+
raise ArgumentError, "Tables cannot be nil" if @tables.nil?
|
|
125
|
+
|
|
126
|
+
unless @tables.is_a?(Hash)
|
|
127
|
+
raise ArgumentError,
|
|
128
|
+
"Tables must be a Hash, got: #{@tables.class}"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
if @tables.empty?
|
|
132
|
+
raise ArgumentError, "Tables cannot be empty"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Check for required tables
|
|
136
|
+
required_tables = %w[head hhea maxp]
|
|
137
|
+
required_tables.each do |tag|
|
|
138
|
+
unless @tables.key?(tag)
|
|
139
|
+
raise ArgumentError, "Missing required table: #{tag}"
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Detect output format from file path
|
|
145
|
+
#
|
|
146
|
+
# @param path [String] Output file path
|
|
147
|
+
# @return [Symbol] Format (:ttf, :otf, :woff, :woff2)
|
|
148
|
+
def detect_output_format(path)
|
|
149
|
+
return @options[:format] if @options[:format]
|
|
150
|
+
|
|
151
|
+
ext = File.extname(path).downcase
|
|
152
|
+
case ext
|
|
153
|
+
when ".ttf" then :ttf
|
|
154
|
+
when ".otf" then :otf
|
|
155
|
+
when ".woff" then :woff
|
|
156
|
+
when ".woff2" then :woff2
|
|
157
|
+
else
|
|
158
|
+
raise ArgumentError,
|
|
159
|
+
"Cannot determine format from extension: #{ext}. " \
|
|
160
|
+
"Supported: .ttf, .otf, .woff, .woff2"
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Validate output format
|
|
165
|
+
#
|
|
166
|
+
# @param format [Symbol] Format to validate
|
|
167
|
+
# @raise [ArgumentError] If format is not supported
|
|
168
|
+
def validate_format!(format)
|
|
169
|
+
unless SUPPORTED_FORMATS.include?(format)
|
|
170
|
+
raise ArgumentError,
|
|
171
|
+
"Unsupported format: #{format}. " \
|
|
172
|
+
"Supported: #{SUPPORTED_FORMATS.join(', ')}"
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Detect source format from instance tables
|
|
177
|
+
#
|
|
178
|
+
# @param tables [Hash<String, String>] Instance tables
|
|
179
|
+
# @return [Symbol] Source format (:ttf or :otf)
|
|
180
|
+
def detect_source_format(tables)
|
|
181
|
+
# Check for outline tables
|
|
182
|
+
if tables.key?("CFF ") || tables.key?("CFF2")
|
|
183
|
+
:otf
|
|
184
|
+
elsif tables.key?("glyf")
|
|
185
|
+
:ttf
|
|
186
|
+
else
|
|
187
|
+
# If no outline tables, use option or default to TTF
|
|
188
|
+
@options[:source_format] || :ttf
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Check if format conversion is needed
|
|
193
|
+
#
|
|
194
|
+
# @param source_format [Symbol] Source format
|
|
195
|
+
# @param target_format [Symbol] Target format
|
|
196
|
+
# @return [Boolean] True if conversion needed
|
|
197
|
+
def format_conversion_needed?(source_format, target_format)
|
|
198
|
+
# WOFF doesn't need outline conversion
|
|
199
|
+
return false if %i[woff woff2].include?(target_format)
|
|
200
|
+
|
|
201
|
+
# Check if outline formats differ
|
|
202
|
+
source_format != target_format
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Convert instance tables from source format to target format
|
|
206
|
+
#
|
|
207
|
+
# @param source_format [Symbol] Source format
|
|
208
|
+
# @param target_format [Symbol] Target format
|
|
209
|
+
# @return [Hash<String, String>] Converted tables
|
|
210
|
+
# @raise [Error] If conversion fails
|
|
211
|
+
def convert_format(source_format, target_format)
|
|
212
|
+
# Create temporary font object for conversion
|
|
213
|
+
temp_font = create_temp_font(@tables, source_format)
|
|
214
|
+
|
|
215
|
+
# Use OutlineConverter for format conversion
|
|
216
|
+
converter = Converters::OutlineConverter.new
|
|
217
|
+
converter.convert(
|
|
218
|
+
temp_font,
|
|
219
|
+
target_format: target_format,
|
|
220
|
+
optimize_cff: @options[:optimize] || false,
|
|
221
|
+
)
|
|
222
|
+
rescue StandardError => e
|
|
223
|
+
raise Fontisan::Error,
|
|
224
|
+
"Failed to convert instance from #{source_format} to " \
|
|
225
|
+
"#{target_format}: #{e.message}"
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Create temporary font object from tables
|
|
229
|
+
#
|
|
230
|
+
# @param tables [Hash<String, String>] Font tables
|
|
231
|
+
# @param format [Symbol] Font format
|
|
232
|
+
# @return [Object] Font object
|
|
233
|
+
def create_temp_font(tables, format)
|
|
234
|
+
# Create minimal font object that responds to required methods
|
|
235
|
+
font_class = format == :otf ? OpenTypeFont : TrueTypeFont
|
|
236
|
+
font = font_class.new
|
|
237
|
+
|
|
238
|
+
# Set table data
|
|
239
|
+
font.instance_variable_set(:@table_data, tables)
|
|
240
|
+
|
|
241
|
+
# Define required methods
|
|
242
|
+
font.define_singleton_method(:table_data) { tables }
|
|
243
|
+
font.define_singleton_method(:table_names) { tables.keys }
|
|
244
|
+
font.define_singleton_method(:has_table?) { |tag| tables.key?(tag) }
|
|
245
|
+
font.define_singleton_method(:table) do |tag|
|
|
246
|
+
# Return nil if table doesn't exist
|
|
247
|
+
return nil unless tables.key?(tag)
|
|
248
|
+
|
|
249
|
+
# Parse and return table object
|
|
250
|
+
# For conversion, we need to lazy-load tables
|
|
251
|
+
parse_table(tag, tables[tag])
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
font
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Parse table data into table object
|
|
258
|
+
#
|
|
259
|
+
# @param tag [String] Table tag
|
|
260
|
+
# @param data [String] Table binary data
|
|
261
|
+
# @return [Object] Parsed table object
|
|
262
|
+
def parse_table(tag, data)
|
|
263
|
+
# For OutlineConverter, we need head, maxp, loca, glyf for TTF
|
|
264
|
+
# and CFF for OTF
|
|
265
|
+
case tag
|
|
266
|
+
when "head"
|
|
267
|
+
Tables::Head.new.tap { |t| t.parse(data) }
|
|
268
|
+
when "maxp"
|
|
269
|
+
Tables::Maxp.new.tap { |t| t.parse(data) }
|
|
270
|
+
when "loca"
|
|
271
|
+
Tables::Loca.new.tap { |t| t.data = data }
|
|
272
|
+
when "glyf"
|
|
273
|
+
Tables::Glyf.new.tap { |t| t.data = data }
|
|
274
|
+
when "CFF "
|
|
275
|
+
Tables::Cff.new.tap { |t| t.parse(data) }
|
|
276
|
+
when "CFF2"
|
|
277
|
+
Tables::Cff2.new.tap { |t| t.parse(data) }
|
|
278
|
+
else
|
|
279
|
+
# For other tables, return a simple object that just holds data
|
|
280
|
+
Object.new.tap do |obj|
|
|
281
|
+
obj.define_singleton_method(:data) { data }
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
rescue StandardError => e
|
|
285
|
+
warn "Warning: Failed to parse #{tag} table: #{e.message}"
|
|
286
|
+
nil
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Write SFNT format (TTF or OTF)
|
|
290
|
+
#
|
|
291
|
+
# @param tables [Hash<String, String>] Output tables
|
|
292
|
+
# @param output_path [String] Output file path
|
|
293
|
+
# @param format [Symbol] Output format
|
|
294
|
+
# @return [Integer] Number of bytes written
|
|
295
|
+
def write_sfnt(tables, output_path, format)
|
|
296
|
+
# Determine SFNT version
|
|
297
|
+
sfnt_version = @options[:sfnt_version] || sfnt_version_for_format(
|
|
298
|
+
format,
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
# Write using FontWriter
|
|
302
|
+
FontWriter.write_to_file(tables, output_path,
|
|
303
|
+
sfnt_version: sfnt_version)
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# Write WOFF format
|
|
307
|
+
#
|
|
308
|
+
# @param tables [Hash<String, String>] Output tables
|
|
309
|
+
# @param output_path [String] Output file path
|
|
310
|
+
# @param source_format [Symbol] Source format (for flavor detection)
|
|
311
|
+
# @return [Integer] Number of bytes written
|
|
312
|
+
def write_woff(tables, output_path, source_format)
|
|
313
|
+
# Create temporary font for WOFF writer
|
|
314
|
+
temp_font = create_temp_font(tables, source_format)
|
|
315
|
+
|
|
316
|
+
# Add cff? method for WoffWriter flavor detection
|
|
317
|
+
temp_font.define_singleton_method(:cff?) do
|
|
318
|
+
tables.key?("CFF ") || tables.key?("CFF2")
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# Use WoffWriter to create WOFF
|
|
322
|
+
writer = Converters::WoffWriter.new
|
|
323
|
+
woff_data = writer.convert(temp_font)
|
|
324
|
+
|
|
325
|
+
# Write to file
|
|
326
|
+
File.binwrite(output_path, woff_data)
|
|
327
|
+
rescue StandardError => e
|
|
328
|
+
raise Fontisan::Error,
|
|
329
|
+
"Failed to write WOFF output: #{e.message}"
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# Get SFNT version for output format
|
|
333
|
+
#
|
|
334
|
+
# @param format [Symbol] Output format
|
|
335
|
+
# @return [Integer] SFNT version constant
|
|
336
|
+
def sfnt_version_for_format(format)
|
|
337
|
+
format == :otf ? SFNT_VERSION_CFF : SFNT_VERSION_TRUETYPE
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../binary/base_record"
|
|
4
|
+
|
|
5
|
+
module Fontisan
|
|
6
|
+
module Variation
|
|
7
|
+
# Tuple variation header structure
|
|
8
|
+
#
|
|
9
|
+
# Used by both gvar and cvar tables to describe variation tuples.
|
|
10
|
+
# Each tuple header contains metadata about peak coordinates,
|
|
11
|
+
# intermediate regions, and point number handling.
|
|
12
|
+
class TupleVariationHeader < Binary::BaseRecord
|
|
13
|
+
uint16 :variation_data_size
|
|
14
|
+
uint16 :tuple_index
|
|
15
|
+
|
|
16
|
+
# Tuple index flags
|
|
17
|
+
EMBEDDED_PEAK_TUPLE = 0x8000
|
|
18
|
+
INTERMEDIATE_REGION = 0x4000
|
|
19
|
+
PRIVATE_POINT_NUMBERS = 0x2000
|
|
20
|
+
TUPLE_INDEX_MASK = 0x0FFF
|
|
21
|
+
|
|
22
|
+
# Check if tuple has embedded peak coordinates
|
|
23
|
+
#
|
|
24
|
+
# @return [Boolean] True if embedded
|
|
25
|
+
def embedded_peak_tuple?
|
|
26
|
+
(tuple_index & EMBEDDED_PEAK_TUPLE) != 0
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Check if tuple has intermediate region
|
|
30
|
+
#
|
|
31
|
+
# @return [Boolean] True if intermediate region
|
|
32
|
+
def intermediate_region?
|
|
33
|
+
(tuple_index & INTERMEDIATE_REGION) != 0
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Check if tuple has private point numbers
|
|
37
|
+
#
|
|
38
|
+
# @return [Boolean] True if private points
|
|
39
|
+
def private_point_numbers?
|
|
40
|
+
(tuple_index & PRIVATE_POINT_NUMBERS) != 0
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Get shared tuple index
|
|
44
|
+
#
|
|
45
|
+
# @return [Integer] Tuple index
|
|
46
|
+
def shared_tuple_index
|
|
47
|
+
tuple_index & TUPLE_INDEX_MASK
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|