fontisan 0.1.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 +672 -69
- data/Gemfile +1 -0
- data/LICENSE +5 -1
- data/README.adoc +1477 -297
- data/Rakefile +63 -41
- 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 +364 -4
- data/lib/fontisan/collection/builder.rb +341 -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 +317 -0
- data/lib/fontisan/collection/writer.rb +306 -0
- data/lib/fontisan/commands/base_command.rb +24 -1
- data/lib/fontisan/commands/convert_command.rb +218 -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 +286 -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 +203 -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 +79 -0
- data/lib/fontisan/converters/conversion_strategy.rb +96 -0
- data/lib/fontisan/converters/format_converter.rb +408 -0
- data/lib/fontisan/converters/outline_converter.rb +998 -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 +122 -15
- data/lib/fontisan/font_writer.rb +302 -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 +310 -0
- data/lib/fontisan/hints/postscript_hint_applier.rb +266 -0
- data/lib/fontisan/hints/postscript_hint_extractor.rb +354 -0
- data/lib/fontisan/hints/truetype_hint_applier.rb +117 -0
- data/lib/fontisan/hints/truetype_hint_extractor.rb +289 -0
- data/lib/fontisan/loading_modes.rb +115 -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 +405 -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 +321 -19
- data/lib/fontisan/open_type_font_extensions.rb +54 -0
- 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/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/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 +934 -0
- data/lib/fontisan/tables/cff/charstring_builder.rb +356 -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/charstrings_index.rb +162 -0
- data/lib/fontisan/tables/cff/dict.rb +351 -0
- data/lib/fontisan/tables/cff/dict_builder.rb +257 -0
- data/lib/fontisan/tables/cff/encoding.rb +274 -0
- data/lib/fontisan/tables/cff/header.rb +102 -0
- data/lib/fontisan/tables/cff/hint_operation_injector.rb +207 -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/offset_recalculator.rb +70 -0
- data/lib/fontisan/tables/cff/private_dict.rb +284 -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/top_dict.rb +236 -0
- data/lib/fontisan/tables/cff.rb +489 -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/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 +346 -0
- data/lib/fontisan/tables/cvar.rb +203 -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 +231 -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 +321 -20
- data/lib/fontisan/true_type_font_extensions.rb +54 -0
- data/lib/fontisan/utilities/brotli_wrapper.rb +159 -0
- data/lib/fontisan/utilities/checksum_calculator.rb +60 -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/validation/variable_font_validator.rb +218 -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 +375 -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/instance_writer.rb +341 -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/tuple_variation_header.rb +51 -0
- data/lib/fontisan/variation/validator.rb +345 -0
- data/lib/fontisan/variation/variable_svg_generator.rb +268 -0
- data/lib/fontisan/variation/variation_context.rb +211 -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/directory.rb +257 -0
- data/lib/fontisan/woff2/glyf_transformer.rb +666 -0
- data/lib/fontisan/woff2/header.rb +101 -0
- data/lib/fontisan/woff2/hmtx_transformer.rb +164 -0
- data/lib/fontisan/woff2/table_transformer.rb +163 -0
- data/lib/fontisan/woff2_font.rb +717 -0
- data/lib/fontisan/woff_font.rb +488 -0
- data/lib/fontisan.rb +132 -0
- data/scripts/compare_stack_aware.rb +187 -0
- data/scripts/measure_optimization.rb +141 -0
- metadata +234 -4
|
@@ -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,231 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Variation
|
|
5
|
+
# Coordinate interpolator for variable fonts
|
|
6
|
+
#
|
|
7
|
+
# This class interpolates values in the variation design space by
|
|
8
|
+
# calculating scalars based on the current coordinates and variation
|
|
9
|
+
# regions/tuples.
|
|
10
|
+
#
|
|
11
|
+
# Interpolation Process:
|
|
12
|
+
# 1. Normalize user coordinates to [-1, 1] range based on axis min/default/max
|
|
13
|
+
# 2. For each variation region, calculate a scalar that represents how much
|
|
14
|
+
# that region contributes at the current coordinates
|
|
15
|
+
# 3. Apply the scalars to deltas to get the final interpolated value
|
|
16
|
+
#
|
|
17
|
+
# Region Scalar Calculation:
|
|
18
|
+
# For each axis, given a region [start, peak, end] and coordinate c:
|
|
19
|
+
# - If c < start or c > end: scalar = 0 (outside region)
|
|
20
|
+
# - If c in [start, peak]: scalar = (c - start) / (peak - start)
|
|
21
|
+
# - If c in [peak, end]: scalar = (end - c) / (end - peak)
|
|
22
|
+
# - If c == peak: scalar = 1 (at peak)
|
|
23
|
+
#
|
|
24
|
+
# For multi-axis regions, multiply the per-axis scalars together.
|
|
25
|
+
#
|
|
26
|
+
# Reference: OpenType Font Variations specification
|
|
27
|
+
#
|
|
28
|
+
# @example Interpolating a coordinate
|
|
29
|
+
# interpolator = Interpolator.new(axes)
|
|
30
|
+
# scalar = interpolator.calculate_scalar(
|
|
31
|
+
# coordinates: { "wght" => 600.0 },
|
|
32
|
+
# region: { "wght" => { start: 400, peak: 700, end: 900 } }
|
|
33
|
+
# )
|
|
34
|
+
# # => 0.666... (normalized position between 400 and 700)
|
|
35
|
+
class Interpolator
|
|
36
|
+
# @return [Array<VariationAxisRecord>] Variation axes
|
|
37
|
+
attr_reader :axes
|
|
38
|
+
|
|
39
|
+
# Initialize interpolator
|
|
40
|
+
#
|
|
41
|
+
# @param axes [Array<VariationAxisRecord>] Variation axes from fvar table
|
|
42
|
+
def initialize(axes)
|
|
43
|
+
@axes = axes || []
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Normalize a coordinate value to [-1, 1] range
|
|
47
|
+
#
|
|
48
|
+
# @param value [Float] User-space coordinate value
|
|
49
|
+
# @param axis_tag [String] Axis tag (e.g., "wght", "wdth")
|
|
50
|
+
# @return [Float] Normalized coordinate in [-1, 1]
|
|
51
|
+
def normalize_coordinate(value, axis_tag)
|
|
52
|
+
axis = find_axis(axis_tag)
|
|
53
|
+
return 0.0 unless axis
|
|
54
|
+
|
|
55
|
+
# Clamp to axis range
|
|
56
|
+
value = [[value, axis.min_value].max, axis.max_value].min
|
|
57
|
+
|
|
58
|
+
# Normalize to [-1, 1]
|
|
59
|
+
if value < axis.default_value
|
|
60
|
+
# Normalize between min and default (maps to -1..0)
|
|
61
|
+
range = axis.default_value - axis.min_value
|
|
62
|
+
return -1.0 if range.zero?
|
|
63
|
+
|
|
64
|
+
(value - axis.default_value) / range
|
|
65
|
+
elsif value > axis.default_value
|
|
66
|
+
# Normalize between default and max (maps to 0..1)
|
|
67
|
+
range = axis.max_value - axis.default_value
|
|
68
|
+
return 1.0 if range.zero?
|
|
69
|
+
|
|
70
|
+
(value - axis.default_value) / range
|
|
71
|
+
else
|
|
72
|
+
# At default value
|
|
73
|
+
0.0
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Normalize all coordinates
|
|
78
|
+
#
|
|
79
|
+
# @param coordinates [Hash<String, Float>] User-space coordinates
|
|
80
|
+
# @return [Hash<String, Float>] Normalized coordinates
|
|
81
|
+
def normalize_coordinates(coordinates)
|
|
82
|
+
result = {}
|
|
83
|
+
@axes.each do |axis|
|
|
84
|
+
tag = axis.axis_tag
|
|
85
|
+
value = coordinates[tag] || axis.default_value
|
|
86
|
+
result[tag] = normalize_coordinate(value, tag)
|
|
87
|
+
end
|
|
88
|
+
result
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Calculate scalar for a single axis region
|
|
92
|
+
#
|
|
93
|
+
# @param coord [Float] Normalized coordinate value [-1, 1]
|
|
94
|
+
# @param region [Hash] Region definition with :start, :peak, :end
|
|
95
|
+
# @return [Float] Scalar value [0, 1]
|
|
96
|
+
def calculate_axis_scalar(coord, region)
|
|
97
|
+
start_val = region[:start] || -1.0
|
|
98
|
+
peak = region[:peak] || 0.0
|
|
99
|
+
end_val = region[:end] || 1.0
|
|
100
|
+
|
|
101
|
+
# Outside region
|
|
102
|
+
return 0.0 if coord < start_val || coord > end_val
|
|
103
|
+
|
|
104
|
+
# At or beyond peak
|
|
105
|
+
return 1.0 if coord == peak
|
|
106
|
+
|
|
107
|
+
# Between start and peak
|
|
108
|
+
if coord < peak
|
|
109
|
+
range = peak - start_val
|
|
110
|
+
return 1.0 if range.zero?
|
|
111
|
+
|
|
112
|
+
(coord - start_val) / range
|
|
113
|
+
else
|
|
114
|
+
# Between peak and end
|
|
115
|
+
range = end_val - peak
|
|
116
|
+
return 1.0 if range.zero?
|
|
117
|
+
|
|
118
|
+
(end_val - coord) / range
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Calculate scalar for a multi-axis region
|
|
123
|
+
#
|
|
124
|
+
# For multi-axis regions, the final scalar is the product of per-axis scalars.
|
|
125
|
+
#
|
|
126
|
+
# @param coordinates [Hash<String, Float>] Normalized coordinates
|
|
127
|
+
# @param region [Hash<String, Hash>] Region definition per axis
|
|
128
|
+
# @return [Float] Combined scalar [0, 1]
|
|
129
|
+
def calculate_region_scalar(coordinates, region)
|
|
130
|
+
scalar = 1.0
|
|
131
|
+
|
|
132
|
+
region.each do |axis_tag, axis_region|
|
|
133
|
+
coord = coordinates[axis_tag] || 0.0
|
|
134
|
+
axis_scalar = calculate_axis_scalar(coord, axis_region)
|
|
135
|
+
|
|
136
|
+
# If any axis has zero scalar, entire region has zero contribution
|
|
137
|
+
return 0.0 if axis_scalar.zero?
|
|
138
|
+
|
|
139
|
+
scalar *= axis_scalar
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
scalar
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Calculate scalars for all regions
|
|
146
|
+
#
|
|
147
|
+
# @param coordinates [Hash<String, Float>] User-space coordinates
|
|
148
|
+
# @param regions [Array<Hash>] Array of region definitions
|
|
149
|
+
# @return [Array<Float>] Scalars for each region
|
|
150
|
+
def calculate_scalars(coordinates, regions)
|
|
151
|
+
# Normalize coordinates first
|
|
152
|
+
normalized = normalize_coordinates(coordinates)
|
|
153
|
+
|
|
154
|
+
# Calculate scalar for each region
|
|
155
|
+
regions.map do |region|
|
|
156
|
+
calculate_region_scalar(normalized, region)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Interpolate a value using deltas
|
|
161
|
+
#
|
|
162
|
+
# @param base_value [Numeric] Base value
|
|
163
|
+
# @param deltas [Array<Numeric>] Delta values (one per region)
|
|
164
|
+
# @param scalars [Array<Float>] Region scalars (one per region)
|
|
165
|
+
# @return [Float] Interpolated value
|
|
166
|
+
def interpolate_value(base_value, deltas, scalars)
|
|
167
|
+
result = base_value.to_f
|
|
168
|
+
|
|
169
|
+
deltas.each_with_index do |delta, index|
|
|
170
|
+
scalar = scalars[index] || 0.0
|
|
171
|
+
result += delta.to_f * scalar
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
result
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Interpolate a point (x, y coordinates)
|
|
178
|
+
#
|
|
179
|
+
# @param base_point [Hash] Base point with :x and :y
|
|
180
|
+
# @param delta_points [Array<Hash>] Delta points (one per region)
|
|
181
|
+
# @param scalars [Array<Float>] Region scalars
|
|
182
|
+
# @return [Hash] Interpolated point with :x and :y
|
|
183
|
+
def interpolate_point(base_point, delta_points, scalars)
|
|
184
|
+
x = base_point[:x].to_f
|
|
185
|
+
y = base_point[:y].to_f
|
|
186
|
+
|
|
187
|
+
delta_points.each_with_index do |delta_point, index|
|
|
188
|
+
scalar = scalars[index] || 0.0
|
|
189
|
+
x += delta_point[:x].to_f * scalar
|
|
190
|
+
y += delta_point[:y].to_f * scalar
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
{ x: x, y: y }
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Build region from tuple variation data
|
|
197
|
+
#
|
|
198
|
+
# Converts gvar tuple data to the region format used by interpolator
|
|
199
|
+
#
|
|
200
|
+
# @param tuple [Hash] Tuple variation data with :peak, :start, :end
|
|
201
|
+
# @return [Hash<String, Hash>] Region definition per axis
|
|
202
|
+
def build_region_from_tuple(tuple)
|
|
203
|
+
region = {}
|
|
204
|
+
|
|
205
|
+
@axes.each_with_index do |axis, axis_index|
|
|
206
|
+
peak = tuple[:peak] ? tuple[:peak][axis_index] : 0.0
|
|
207
|
+
start_val = tuple[:start] ? tuple[:start][axis_index] : -1.0
|
|
208
|
+
end_val = tuple[:end] ? tuple[:end][axis_index] : 1.0
|
|
209
|
+
|
|
210
|
+
region[axis.axis_tag] = {
|
|
211
|
+
start: start_val,
|
|
212
|
+
peak: peak,
|
|
213
|
+
end: end_val,
|
|
214
|
+
}
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
region
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
private
|
|
221
|
+
|
|
222
|
+
# Find axis by tag
|
|
223
|
+
#
|
|
224
|
+
# @param axis_tag [String] Axis tag
|
|
225
|
+
# @return [VariationAxisRecord, nil] Axis or nil
|
|
226
|
+
def find_axis(axis_tag)
|
|
227
|
+
@axes.find { |axis| axis.axis_tag == axis_tag }
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|