fontisan 0.2.0 → 0.2.2
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 +119 -308
- data/README.adoc +1525 -1323
- data/Rakefile +45 -47
- data/benchmark/variation_quick_bench.rb +4 -4
- data/docs/FONT_HINTING.adoc +562 -0
- data/docs/VARIABLE_FONT_OPERATIONS.adoc +599 -0
- data/lib/fontisan/cli.rb +92 -34
- data/lib/fontisan/collection/builder.rb +82 -0
- data/lib/fontisan/collection/offset_calculator.rb +2 -0
- data/lib/fontisan/collection/table_deduplicator.rb +76 -0
- data/lib/fontisan/commands/base_command.rb +21 -2
- data/lib/fontisan/commands/convert_command.rb +96 -165
- data/lib/fontisan/commands/info_command.rb +111 -5
- data/lib/fontisan/commands/instance_command.rb +77 -85
- data/lib/fontisan/commands/validate_command.rb +28 -0
- data/lib/fontisan/config/validation_rules.yml +1 -1
- data/lib/fontisan/constants.rb +34 -24
- data/lib/fontisan/converters/format_converter.rb +154 -1
- data/lib/fontisan/converters/outline_converter.rb +101 -34
- data/lib/fontisan/converters/woff_writer.rb +9 -4
- data/lib/fontisan/font_loader.rb +14 -9
- data/lib/fontisan/font_writer.rb +9 -6
- data/lib/fontisan/formatters/text_formatter.rb +45 -1
- data/lib/fontisan/hints/hint_converter.rb +131 -2
- data/lib/fontisan/hints/hint_validator.rb +284 -0
- data/lib/fontisan/hints/postscript_hint_applier.rb +219 -140
- data/lib/fontisan/hints/postscript_hint_extractor.rb +151 -16
- data/lib/fontisan/hints/truetype_hint_applier.rb +90 -44
- data/lib/fontisan/hints/truetype_hint_extractor.rb +134 -11
- data/lib/fontisan/hints/truetype_instruction_analyzer.rb +261 -0
- data/lib/fontisan/hints/truetype_instruction_generator.rb +266 -0
- data/lib/fontisan/loading_modes.rb +6 -4
- data/lib/fontisan/models/collection_brief_info.rb +31 -0
- data/lib/fontisan/models/font_info.rb +3 -30
- data/lib/fontisan/models/hint.rb +183 -12
- data/lib/fontisan/models/outline.rb +4 -1
- data/lib/fontisan/open_type_font.rb +28 -10
- data/lib/fontisan/open_type_font_extensions.rb +54 -0
- data/lib/fontisan/optimizers/pattern_analyzer.rb +2 -1
- data/lib/fontisan/optimizers/subroutine_generator.rb +1 -1
- data/lib/fontisan/pipeline/format_detector.rb +249 -0
- data/lib/fontisan/pipeline/output_writer.rb +159 -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 +416 -0
- data/lib/fontisan/pipeline/variation_resolver.rb +165 -0
- data/lib/fontisan/subset/table_subsetter.rb +5 -5
- data/lib/fontisan/tables/cff/charstring.rb +58 -3
- data/lib/fontisan/tables/cff/charstring_builder.rb +34 -0
- data/lib/fontisan/tables/cff/charstring_parser.rb +249 -0
- data/lib/fontisan/tables/cff/charstring_rebuilder.rb +172 -0
- data/lib/fontisan/tables/cff/dict_builder.rb +19 -1
- data/lib/fontisan/tables/cff/hint_operation_injector.rb +209 -0
- data/lib/fontisan/tables/cff/offset_recalculator.rb +70 -0
- data/lib/fontisan/tables/cff/private_dict_writer.rb +131 -0
- data/lib/fontisan/tables/cff/table_builder.rb +221 -0
- data/lib/fontisan/tables/cff.rb +2 -0
- data/lib/fontisan/tables/cff2/charstring_parser.rb +14 -8
- data/lib/fontisan/tables/cff2/private_dict_blend_handler.rb +247 -0
- data/lib/fontisan/tables/cff2/region_matcher.rb +200 -0
- data/lib/fontisan/tables/cff2/table_builder.rb +580 -0
- data/lib/fontisan/tables/cff2/table_reader.rb +421 -0
- data/lib/fontisan/tables/cff2/variation_data_extractor.rb +212 -0
- data/lib/fontisan/tables/cff2.rb +10 -5
- data/lib/fontisan/tables/cvar.rb +2 -41
- data/lib/fontisan/tables/glyf/compound_glyph_resolver.rb +2 -1
- data/lib/fontisan/tables/glyf/curve_converter.rb +10 -4
- data/lib/fontisan/tables/glyf/glyph_builder.rb +27 -10
- data/lib/fontisan/tables/gvar.rb +2 -41
- data/lib/fontisan/tables/name.rb +4 -4
- data/lib/fontisan/true_type_font.rb +27 -10
- 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/cache.rb +3 -1
- data/lib/fontisan/variation/converter.rb +121 -13
- data/lib/fontisan/variation/delta_applier.rb +2 -1
- data/lib/fontisan/variation/inspector.rb +2 -1
- data/lib/fontisan/variation/instance_generator.rb +2 -1
- data/lib/fontisan/variation/instance_writer.rb +341 -0
- data/lib/fontisan/variation/optimizer.rb +6 -3
- data/lib/fontisan/variation/subsetter.rb +32 -10
- 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 +291 -0
- data/lib/fontisan/version.rb +1 -1
- data/lib/fontisan/version.rb.orig +9 -0
- data/lib/fontisan/woff2/glyf_transformer.rb +693 -0
- data/lib/fontisan/woff2/hmtx_transformer.rb +164 -0
- data/lib/fontisan/woff2_font.rb +489 -468
- data/lib/fontisan/woff_font.rb +16 -11
- data/lib/fontisan.rb +54 -2
- data/scripts/measure_optimization.rb +15 -7
- metadata +37 -2
|
@@ -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
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base_strategy"
|
|
4
|
+
require_relative "instance_strategy"
|
|
5
|
+
require_relative "../../variation/variation_context"
|
|
6
|
+
|
|
7
|
+
module Fontisan
|
|
8
|
+
module Pipeline
|
|
9
|
+
module Strategies
|
|
10
|
+
# Strategy for generating instances from named instances
|
|
11
|
+
#
|
|
12
|
+
# This strategy creates a static font instance using coordinates from
|
|
13
|
+
# a named instance defined in the fvar table. It extracts the coordinates
|
|
14
|
+
# from the specified instance and delegates to InstanceStrategy for
|
|
15
|
+
# actual generation.
|
|
16
|
+
#
|
|
17
|
+
# Named instances are predefined design space coordinates stored in the
|
|
18
|
+
# fvar table, typically representing common styles like "Bold", "Light",
|
|
19
|
+
# "Condensed", etc.
|
|
20
|
+
#
|
|
21
|
+
# @example Generate "Bold" instance
|
|
22
|
+
# strategy = NamedStrategy.new(instance_index: 0)
|
|
23
|
+
# tables = strategy.resolve(variable_font)
|
|
24
|
+
#
|
|
25
|
+
# @example Use specific named instance
|
|
26
|
+
# # Find instance by name first, then use index
|
|
27
|
+
# fvar = font.table("fvar")
|
|
28
|
+
# bold_index = fvar.instances.find_index { |i| i[:name] =~ /Bold/ }
|
|
29
|
+
# strategy = NamedStrategy.new(instance_index: bold_index)
|
|
30
|
+
# tables = strategy.resolve(variable_font)
|
|
31
|
+
class NamedStrategy < BaseStrategy
|
|
32
|
+
# @return [Integer] Named instance index
|
|
33
|
+
attr_reader :instance_index
|
|
34
|
+
|
|
35
|
+
# Initialize strategy with instance index
|
|
36
|
+
#
|
|
37
|
+
# @param options [Hash] Strategy options
|
|
38
|
+
# @option options [Integer] :instance_index Index of named instance in fvar
|
|
39
|
+
# @raise [ArgumentError] If instance_index not provided
|
|
40
|
+
def initialize(options = {})
|
|
41
|
+
super
|
|
42
|
+
@instance_index = options[:instance_index]
|
|
43
|
+
|
|
44
|
+
if @instance_index.nil?
|
|
45
|
+
raise ArgumentError, "instance_index is required for NamedStrategy"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Resolve by using named instance coordinates
|
|
50
|
+
#
|
|
51
|
+
# Extracts coordinates from the fvar table's named instance and
|
|
52
|
+
# delegates to InstanceStrategy for actual instance generation.
|
|
53
|
+
#
|
|
54
|
+
# @param font [TrueTypeFont, OpenTypeFont] Variable font
|
|
55
|
+
# @return [Hash<String, String>] Static font tables
|
|
56
|
+
# @raise [ArgumentError] If instance index is invalid
|
|
57
|
+
def resolve(font)
|
|
58
|
+
# Extract coordinates from named instance
|
|
59
|
+
coordinates = extract_coordinates(font)
|
|
60
|
+
|
|
61
|
+
# Use InstanceStrategy to generate instance
|
|
62
|
+
instance_strategy = InstanceStrategy.new(coordinates: coordinates)
|
|
63
|
+
instance_strategy.resolve(font)
|
|
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] :named
|
|
76
|
+
def strategy_name
|
|
77
|
+
:named
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
# Extract coordinates from named instance in fvar table
|
|
83
|
+
#
|
|
84
|
+
# @param font [TrueTypeFont, OpenTypeFont] Variable font
|
|
85
|
+
# @return [Hash<String, Float>] Design space coordinates
|
|
86
|
+
# @raise [ArgumentError] If instance index is invalid
|
|
87
|
+
def extract_coordinates(font)
|
|
88
|
+
context = Variation::VariationContext.new(font)
|
|
89
|
+
|
|
90
|
+
unless context.fvar
|
|
91
|
+
raise ArgumentError, "Font is not a variable font (no fvar table)"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
instances = context.fvar.instances
|
|
95
|
+
if @instance_index.negative? || @instance_index >= instances.length
|
|
96
|
+
raise ArgumentError,
|
|
97
|
+
"Invalid instance index #{@instance_index}. " \
|
|
98
|
+
"Font has #{instances.length} named instances."
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
instance = instances[@instance_index]
|
|
102
|
+
axes = context.axes
|
|
103
|
+
|
|
104
|
+
# Map instance coordinates to axis tags
|
|
105
|
+
coordinates = {}
|
|
106
|
+
instance[:coordinates].each_with_index do |value, i|
|
|
107
|
+
next if i >= axes.length
|
|
108
|
+
|
|
109
|
+
axis = axes[i]
|
|
110
|
+
coordinates[axis.axis_tag] = value
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
coordinates
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base_strategy"
|
|
4
|
+
|
|
5
|
+
module Fontisan
|
|
6
|
+
module Pipeline
|
|
7
|
+
module Strategies
|
|
8
|
+
# Strategy for preserving variation data during conversion
|
|
9
|
+
#
|
|
10
|
+
# This strategy maintains all variation tables intact, making it suitable
|
|
11
|
+
# for conversions between compatible formats:
|
|
12
|
+
# - Variable TTF → Variable TTF (same format)
|
|
13
|
+
# - Variable OTF → Variable OTF (same format)
|
|
14
|
+
# - Variable TTF → Variable WOFF/WOFF2 (packaging change only)
|
|
15
|
+
# - Variable OTF → Variable WOFF/WOFF2 (packaging change only)
|
|
16
|
+
#
|
|
17
|
+
# The strategy copies all font tables including:
|
|
18
|
+
# - Variation tables: fvar, gvar/CFF2, avar, HVAR, VVAR, MVAR
|
|
19
|
+
# - Base tables: All non-variation tables
|
|
20
|
+
#
|
|
21
|
+
# @example Preserve variation data
|
|
22
|
+
# strategy = PreserveStrategy.new
|
|
23
|
+
# tables = strategy.resolve(variable_font)
|
|
24
|
+
# # tables includes fvar, gvar, etc.
|
|
25
|
+
class PreserveStrategy < BaseStrategy
|
|
26
|
+
# Resolve by preserving all variation data
|
|
27
|
+
#
|
|
28
|
+
# Returns all font tables including variation tables. This is a simple
|
|
29
|
+
# copy operation that maintains the variable font's full capabilities.
|
|
30
|
+
#
|
|
31
|
+
# @param font [TrueTypeFont, OpenTypeFont] Variable font
|
|
32
|
+
# @return [Hash<String, String>] All font tables
|
|
33
|
+
def resolve(font)
|
|
34
|
+
# Return a copy of all font tables
|
|
35
|
+
# This preserves variation tables (fvar, gvar, CFF2, avar, HVAR, etc.)
|
|
36
|
+
# and all base tables
|
|
37
|
+
font.table_data.dup
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Check if strategy preserves variation data
|
|
41
|
+
#
|
|
42
|
+
# @return [Boolean] Always true for this strategy
|
|
43
|
+
def preserves_variation?
|
|
44
|
+
true
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Get strategy name
|
|
48
|
+
#
|
|
49
|
+
# @return [Symbol] :preserve
|
|
50
|
+
def strategy_name
|
|
51
|
+
:preserve
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "format_detector"
|
|
4
|
+
require_relative "variation_resolver"
|
|
5
|
+
require_relative "../converters/format_converter"
|
|
6
|
+
require_relative "../font_loader"
|
|
7
|
+
require_relative "../font_writer"
|
|
8
|
+
require_relative "output_writer"
|
|
9
|
+
|
|
10
|
+
module Fontisan
|
|
11
|
+
module Pipeline
|
|
12
|
+
# Orchestrates universal font transformation pipeline
|
|
13
|
+
#
|
|
14
|
+
# This is the main entry point for font conversion operations. It coordinates:
|
|
15
|
+
# 1. Format detection (via FormatDetector)
|
|
16
|
+
# 2. Font loading (via FontLoader)
|
|
17
|
+
# 3. Variation resolution (via VariationResolver)
|
|
18
|
+
# 4. Format conversion (via FormatConverter)
|
|
19
|
+
# 5. Output writing (via OutputWriter)
|
|
20
|
+
# 6. Validation (optional, via Validation::Validator)
|
|
21
|
+
#
|
|
22
|
+
# The pipeline follows a clear MECE architecture where each phase has a
|
|
23
|
+
# single responsibility and produces well-defined outputs.
|
|
24
|
+
#
|
|
25
|
+
# @example Basic TTF to OTF conversion
|
|
26
|
+
# pipeline = TransformationPipeline.new("input.ttf", "output.otf")
|
|
27
|
+
# result = pipeline.transform
|
|
28
|
+
# puts result[:success] # => true
|
|
29
|
+
#
|
|
30
|
+
# @example Variable font instance generation
|
|
31
|
+
# pipeline = TransformationPipeline.new(
|
|
32
|
+
# "variable.ttf",
|
|
33
|
+
# "bold.ttf",
|
|
34
|
+
# coordinates: { "wght" => 700.0 }
|
|
35
|
+
# )
|
|
36
|
+
# result = pipeline.transform
|
|
37
|
+
class TransformationPipeline
|
|
38
|
+
# @return [String] Input file path
|
|
39
|
+
attr_reader :input_path
|
|
40
|
+
|
|
41
|
+
# @return [String] Output file path
|
|
42
|
+
attr_reader :output_path
|
|
43
|
+
|
|
44
|
+
# @return [Hash] Transformation options
|
|
45
|
+
attr_reader :options
|
|
46
|
+
|
|
47
|
+
# Initialize transformation pipeline
|
|
48
|
+
#
|
|
49
|
+
# @param input_path [String] Path to input font
|
|
50
|
+
# @param output_path [String] Path to output font
|
|
51
|
+
# @param options [Hash] Transformation options
|
|
52
|
+
# @option options [Symbol] :target_format Target format (:ttf, :otf, :woff, :woff2)
|
|
53
|
+
# @option options [Hash] :coordinates Instance coordinates (for variable fonts)
|
|
54
|
+
# @option options [Integer] :instance_index Named instance index
|
|
55
|
+
# @option options [Boolean] :preserve_variation Preserve variation data (default: auto)
|
|
56
|
+
# @option options [Boolean] :validate Validate output (default: true)
|
|
57
|
+
# @option options [Boolean] :verbose Verbose output (default: false)
|
|
58
|
+
def initialize(input_path, output_path, options = {})
|
|
59
|
+
@input_path = input_path
|
|
60
|
+
@output_path = output_path
|
|
61
|
+
@options = default_options.merge(options)
|
|
62
|
+
@variation_strategy = nil
|
|
63
|
+
|
|
64
|
+
validate_paths!
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Execute transformation pipeline
|
|
68
|
+
#
|
|
69
|
+
# This is the main entry point. It orchestrates:
|
|
70
|
+
# 1. Format detection
|
|
71
|
+
# 2. Font loading
|
|
72
|
+
# 3. Variation resolution
|
|
73
|
+
# 4. Format conversion
|
|
74
|
+
# 5. Output writing
|
|
75
|
+
# 6. Validation (optional)
|
|
76
|
+
#
|
|
77
|
+
# @return [Hash] Transformation result with :success, :output_path, :details
|
|
78
|
+
# @raise [Error] If transformation fails
|
|
79
|
+
def transform
|
|
80
|
+
log "Starting transformation: #{@input_path} → #{@output_path}"
|
|
81
|
+
|
|
82
|
+
# Phase 1: Detect input format
|
|
83
|
+
detection = detect_input_format
|
|
84
|
+
log "Detected: #{detection[:format]} (#{detection[:variation_type]})"
|
|
85
|
+
|
|
86
|
+
# Phase 2: Load font
|
|
87
|
+
font = load_font(detection)
|
|
88
|
+
log "Loaded: #{font.class.name}"
|
|
89
|
+
|
|
90
|
+
# Phase 3: Resolve variation
|
|
91
|
+
tables = resolve_variation(font, detection)
|
|
92
|
+
log "Resolved variation using #{@variation_strategy} strategy"
|
|
93
|
+
|
|
94
|
+
# Phase 4: Convert format
|
|
95
|
+
tables = convert_format(tables, detection)
|
|
96
|
+
log "Converted to #{target_format}"
|
|
97
|
+
|
|
98
|
+
# Phase 5: Write output
|
|
99
|
+
write_output(tables, detection)
|
|
100
|
+
log "Written to #{@output_path}"
|
|
101
|
+
|
|
102
|
+
# Phase 6: Validate (optional)
|
|
103
|
+
validate_output if @options[:validate] && !same_format_conversion? && !export_only_format?
|
|
104
|
+
log "Validation passed" if @options[:validate] && !export_only_format?
|
|
105
|
+
|
|
106
|
+
{
|
|
107
|
+
success: true,
|
|
108
|
+
output_path: @output_path,
|
|
109
|
+
details: build_details(detection),
|
|
110
|
+
}
|
|
111
|
+
rescue StandardError => e
|
|
112
|
+
handle_error(e)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
# Detect input format and capabilities
|
|
118
|
+
#
|
|
119
|
+
# @return [Hash] Detection results from FormatDetector
|
|
120
|
+
def detect_input_format
|
|
121
|
+
detector = FormatDetector.new(@input_path)
|
|
122
|
+
detector.detect
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Load font with appropriate mode
|
|
126
|
+
#
|
|
127
|
+
# @param detection [Hash] Detection results
|
|
128
|
+
# @return [Font] Loaded font object
|
|
129
|
+
def load_font(_detection)
|
|
130
|
+
FontLoader.load(@input_path, mode: :full)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Resolve variation data
|
|
134
|
+
#
|
|
135
|
+
# @param font [Font] Loaded font
|
|
136
|
+
# @param detection [Hash] Detection results
|
|
137
|
+
# @return [Hash] Processed font tables
|
|
138
|
+
def resolve_variation(font, detection)
|
|
139
|
+
# Static fonts - use preserve strategy (just copy tables)
|
|
140
|
+
return resolve_static_font(font) if detection[:variation_type] == :static
|
|
141
|
+
|
|
142
|
+
# Variable fonts - determine strategy
|
|
143
|
+
strategy = determine_variation_strategy(detection)
|
|
144
|
+
@variation_strategy = strategy
|
|
145
|
+
|
|
146
|
+
resolver = VariationResolver.new(
|
|
147
|
+
font,
|
|
148
|
+
strategy: strategy,
|
|
149
|
+
**variation_options,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
resolver.resolve
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Resolve static font (just copy tables)
|
|
156
|
+
#
|
|
157
|
+
# @param font [Font] Static font
|
|
158
|
+
# @return [Hash] Font tables
|
|
159
|
+
def resolve_static_font(font)
|
|
160
|
+
@variation_strategy = :preserve
|
|
161
|
+
|
|
162
|
+
# Get all tables from font - use table_data directly
|
|
163
|
+
font.table_data.dup
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Determine variation strategy based on options and compatibility
|
|
167
|
+
#
|
|
168
|
+
# @param detection [Hash] Detection results
|
|
169
|
+
# @return [Symbol] Strategy type (:preserve, :instance, :named)
|
|
170
|
+
def determine_variation_strategy(detection)
|
|
171
|
+
# User explicitly requested instance generation
|
|
172
|
+
if @options[:coordinates] || @options[:instance_index]
|
|
173
|
+
return @options[:instance_index] ? :named : :instance
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Check if preservation is possible
|
|
177
|
+
if can_preserve_variation?(detection)
|
|
178
|
+
@options.fetch(:preserve_variation, true) ? :preserve : :instance
|
|
179
|
+
else
|
|
180
|
+
# Cannot preserve - must generate instance
|
|
181
|
+
:instance
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Check if variation can be preserved for target format
|
|
186
|
+
#
|
|
187
|
+
# @param detection [Hash] Detection results
|
|
188
|
+
# @return [Boolean] True if variation preservable
|
|
189
|
+
def can_preserve_variation?(detection)
|
|
190
|
+
source_format = detection[:format]
|
|
191
|
+
target = target_format
|
|
192
|
+
|
|
193
|
+
# Same format
|
|
194
|
+
return true if source_format == target
|
|
195
|
+
|
|
196
|
+
# Same outline family (packaging change only)
|
|
197
|
+
same_outline_family?(source_format, target)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Check if formats are in same outline family
|
|
201
|
+
#
|
|
202
|
+
# @param source [Symbol] Source format
|
|
203
|
+
# @param target [Symbol] Target format
|
|
204
|
+
# @return [Boolean] True if same family
|
|
205
|
+
def same_outline_family?(source, target)
|
|
206
|
+
truetype_formats = %i[ttf ttc woff woff2]
|
|
207
|
+
opentype_formats = %i[otf otc woff woff2]
|
|
208
|
+
|
|
209
|
+
(truetype_formats.include?(source) && truetype_formats.include?(target)) ||
|
|
210
|
+
(opentype_formats.include?(source) && opentype_formats.include?(target))
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Convert format if needed
|
|
214
|
+
#
|
|
215
|
+
# @param tables [Hash] Font tables
|
|
216
|
+
# @param detection [Hash] Detection results
|
|
217
|
+
# @return [Hash] Converted tables
|
|
218
|
+
def convert_format(tables, detection)
|
|
219
|
+
source_format = detection[:format]
|
|
220
|
+
target = target_format
|
|
221
|
+
|
|
222
|
+
# No conversion needed for same format
|
|
223
|
+
return tables if source_format == target
|
|
224
|
+
|
|
225
|
+
# Use FormatConverter for outline conversion
|
|
226
|
+
if needs_outline_conversion?(source_format, target) || target == :svg
|
|
227
|
+
converter = Converters::FormatConverter.new
|
|
228
|
+
# Create temporary font object from tables
|
|
229
|
+
font = build_font_from_tables(tables, source_format)
|
|
230
|
+
converter.convert(font, target, @options)
|
|
231
|
+
else
|
|
232
|
+
# Just packaging change - tables can be used as-is
|
|
233
|
+
tables
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Check if outline conversion is needed
|
|
238
|
+
#
|
|
239
|
+
# @param source [Symbol] Source format
|
|
240
|
+
# @param target [Symbol] Target format
|
|
241
|
+
# @return [Boolean] True if outline conversion needed
|
|
242
|
+
def needs_outline_conversion?(source, target)
|
|
243
|
+
# TTF ↔ OTF requires outline conversion
|
|
244
|
+
ttf_formats = %i[ttf ttc woff woff2]
|
|
245
|
+
otf_formats = %i[otf otc]
|
|
246
|
+
|
|
247
|
+
(ttf_formats.include?(source) && otf_formats.include?(target)) ||
|
|
248
|
+
(otf_formats.include?(source) && ttf_formats.include?(target))
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Write output font file
|
|
252
|
+
#
|
|
253
|
+
# @param tables [Hash] Font tables
|
|
254
|
+
# @param detection [Hash] Detection results
|
|
255
|
+
def write_output(tables, _detection)
|
|
256
|
+
writer = OutputWriter.new(@output_path, target_format, @options)
|
|
257
|
+
writer.write(tables)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# Validate output file
|
|
261
|
+
#
|
|
262
|
+
# @raise [ValidationError] If validation fails
|
|
263
|
+
def validate_output
|
|
264
|
+
return unless File.exist?(@output_path)
|
|
265
|
+
|
|
266
|
+
require_relative "../validation/validator"
|
|
267
|
+
|
|
268
|
+
# Load font for validation
|
|
269
|
+
font = FontLoader.load(@output_path, mode: :full)
|
|
270
|
+
validator = Validation::Validator.new
|
|
271
|
+
result = validator.validate(font, @output_path)
|
|
272
|
+
|
|
273
|
+
return if result.valid
|
|
274
|
+
|
|
275
|
+
error_messages = result.errors.map(&:message).join(", ")
|
|
276
|
+
raise Error, "Output validation failed: #{error_messages}"
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Get target format
|
|
280
|
+
#
|
|
281
|
+
# @return [Symbol] Target format
|
|
282
|
+
def target_format
|
|
283
|
+
@options[:target_format] || detect_target_from_extension
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Detect target format from output path extension
|
|
287
|
+
#
|
|
288
|
+
# @return [Symbol] Detected format
|
|
289
|
+
def detect_target_from_extension
|
|
290
|
+
ext = File.extname(@output_path).downcase
|
|
291
|
+
case ext
|
|
292
|
+
when ".ttf" then :ttf
|
|
293
|
+
when ".otf" then :otf
|
|
294
|
+
when ".woff" then :woff
|
|
295
|
+
when ".woff2" then :woff2
|
|
296
|
+
else
|
|
297
|
+
raise ArgumentError,
|
|
298
|
+
"Cannot determine target format from extension: #{ext}"
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# Get variation options for VariationResolver
|
|
303
|
+
#
|
|
304
|
+
# @return [Hash] Variation options
|
|
305
|
+
def variation_options
|
|
306
|
+
opts = {}
|
|
307
|
+
opts[:coordinates] = @options[:coordinates] if @options[:coordinates]
|
|
308
|
+
if @options[:instance_index]
|
|
309
|
+
opts[:instance_index] =
|
|
310
|
+
@options[:instance_index]
|
|
311
|
+
end
|
|
312
|
+
opts
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# Validate input and output paths
|
|
316
|
+
#
|
|
317
|
+
# @raise [ArgumentError] If paths invalid
|
|
318
|
+
def validate_paths!
|
|
319
|
+
unless File.exist?(@input_path)
|
|
320
|
+
raise ArgumentError, "Input file not found: #{@input_path}"
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
output_dir = File.dirname(@output_path)
|
|
324
|
+
unless File.directory?(output_dir)
|
|
325
|
+
raise ArgumentError, "Output directory not found: #{output_dir}"
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# Build font object from tables
|
|
330
|
+
#
|
|
331
|
+
# @param tables [Hash] Font tables
|
|
332
|
+
# @param format [Symbol] Font format
|
|
333
|
+
# @return [Font] Font object
|
|
334
|
+
def build_font_from_tables(tables, format)
|
|
335
|
+
# Detect outline type from tables
|
|
336
|
+
has_cff = tables.key?("CFF ") || tables.key?("CFF2")
|
|
337
|
+
has_glyf = tables.key?("glyf")
|
|
338
|
+
|
|
339
|
+
if has_cff
|
|
340
|
+
OpenTypeFont.from_tables(tables)
|
|
341
|
+
elsif has_glyf
|
|
342
|
+
TrueTypeFont.from_tables(tables)
|
|
343
|
+
else
|
|
344
|
+
# Default based on format
|
|
345
|
+
case format
|
|
346
|
+
when :ttf, :woff, :woff2
|
|
347
|
+
TrueTypeFont.from_tables(tables)
|
|
348
|
+
when :otf
|
|
349
|
+
OpenTypeFont.from_tables(tables)
|
|
350
|
+
else
|
|
351
|
+
raise ArgumentError,
|
|
352
|
+
"Cannot determine font type: format=#{format}, has_cff=#{has_cff}, has_glyf=#{has_glyf}"
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# Build transformation details
|
|
358
|
+
#
|
|
359
|
+
# @param detection [Hash] Detection results
|
|
360
|
+
# @return [Hash] Transformation details
|
|
361
|
+
def build_details(detection)
|
|
362
|
+
{
|
|
363
|
+
source_format: detection[:format],
|
|
364
|
+
source_variation: detection[:variation_type],
|
|
365
|
+
target_format: target_format,
|
|
366
|
+
variation_strategy: @variation_strategy,
|
|
367
|
+
variation_preserved: @variation_strategy == :preserve,
|
|
368
|
+
}
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# Handle transformation error
|
|
372
|
+
#
|
|
373
|
+
# @param error [StandardError] Error that occurred
|
|
374
|
+
# @raise [Error] Re-raises with context
|
|
375
|
+
def handle_error(error)
|
|
376
|
+
log "ERROR: #{error.message}"
|
|
377
|
+
log error.backtrace.first(5).join("\n") if @options[:verbose]
|
|
378
|
+
|
|
379
|
+
raise Error, "Transformation failed: #{error.message}"
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# Log message if verbose
|
|
383
|
+
#
|
|
384
|
+
# @param message [String] Message to log
|
|
385
|
+
def log(message)
|
|
386
|
+
puts "[TransformationPipeline] #{message}" if @options[:verbose]
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# Default options
|
|
390
|
+
#
|
|
391
|
+
# @return [Hash] Default options
|
|
392
|
+
def default_options
|
|
393
|
+
{
|
|
394
|
+
validate: true,
|
|
395
|
+
verbose: false,
|
|
396
|
+
preserve_variation: nil, # Auto-determine
|
|
397
|
+
}
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
# Check if this is a same-format conversion
|
|
401
|
+
#
|
|
402
|
+
# @return [Boolean] True if source and target formats are the same
|
|
403
|
+
def same_format_conversion?
|
|
404
|
+
detection = detect_input_format
|
|
405
|
+
detection[:format] == target_format
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# Check if target format is export-only (cannot be validated)
|
|
409
|
+
#
|
|
410
|
+
# @return [Boolean] True if format is export-only
|
|
411
|
+
def export_only_format?
|
|
412
|
+
%i[svg woff woff2].include?(target_format)
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
end
|