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,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "strategies/base_strategy"
|
|
4
|
+
require_relative "strategies/preserve_strategy"
|
|
5
|
+
require_relative "strategies/instance_strategy"
|
|
6
|
+
require_relative "strategies/named_strategy"
|
|
7
|
+
|
|
8
|
+
module Fontisan
|
|
9
|
+
module Pipeline
|
|
10
|
+
# Resolves variation data using strategy pattern
|
|
11
|
+
#
|
|
12
|
+
# This class orchestrates variation resolution during font conversion by
|
|
13
|
+
# selecting and executing the appropriate strategy based on user intent.
|
|
14
|
+
# It follows the Strategy pattern to allow different approaches to handling
|
|
15
|
+
# variable font data.
|
|
16
|
+
#
|
|
17
|
+
# Three strategies are available:
|
|
18
|
+
# - PreserveStrategy: Keep variation data intact (for compatible formats)
|
|
19
|
+
# - InstanceStrategy: Generate static instance at coordinates
|
|
20
|
+
# - NamedStrategy: Use named instance from fvar table
|
|
21
|
+
#
|
|
22
|
+
# Strategy selection is explicit through the :strategy option. Each strategy
|
|
23
|
+
# has its own required and optional parameters.
|
|
24
|
+
#
|
|
25
|
+
# @example Preserve variation data
|
|
26
|
+
# resolver = VariationResolver.new(font, strategy: :preserve)
|
|
27
|
+
# tables = resolver.resolve
|
|
28
|
+
#
|
|
29
|
+
# @example Generate instance at coordinates
|
|
30
|
+
# resolver = VariationResolver.new(
|
|
31
|
+
# font,
|
|
32
|
+
# strategy: :instance,
|
|
33
|
+
# coordinates: { "wght" => 700.0 }
|
|
34
|
+
# )
|
|
35
|
+
# tables = resolver.resolve
|
|
36
|
+
#
|
|
37
|
+
# @example Use named instance
|
|
38
|
+
# resolver = VariationResolver.new(
|
|
39
|
+
# font,
|
|
40
|
+
# strategy: :named,
|
|
41
|
+
# instance_index: 0
|
|
42
|
+
# )
|
|
43
|
+
# tables = resolver.resolve
|
|
44
|
+
class VariationResolver
|
|
45
|
+
# @return [TrueTypeFont, OpenTypeFont] Font to process
|
|
46
|
+
attr_reader :font
|
|
47
|
+
|
|
48
|
+
# @return [Strategies::BaseStrategy] Selected strategy
|
|
49
|
+
attr_reader :strategy
|
|
50
|
+
|
|
51
|
+
# Initialize resolver with font and strategy
|
|
52
|
+
#
|
|
53
|
+
# @param font [TrueTypeFont, OpenTypeFont] Font to process
|
|
54
|
+
# @param options [Hash] Resolution options
|
|
55
|
+
# @option options [Symbol] :strategy Strategy to use (:preserve, :instance, :named)
|
|
56
|
+
# @option options [Hash] :coordinates Design space coordinates (for :instance)
|
|
57
|
+
# @option options [Integer] :instance_index Named instance index (for :named)
|
|
58
|
+
# @raise [ArgumentError] If strategy is missing or invalid
|
|
59
|
+
def initialize(font, options = {})
|
|
60
|
+
@font = font
|
|
61
|
+
|
|
62
|
+
strategy_type = options[:strategy]
|
|
63
|
+
raise ArgumentError, "strategy is required" unless strategy_type
|
|
64
|
+
|
|
65
|
+
@strategy = build_strategy(strategy_type, options)
|
|
66
|
+
|
|
67
|
+
# Validate strategy-specific requirements
|
|
68
|
+
validate_strategy_requirements(strategy_type, options)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Resolve variation data
|
|
72
|
+
#
|
|
73
|
+
# Delegates to the selected strategy to process the font and return
|
|
74
|
+
# the appropriate tables.
|
|
75
|
+
#
|
|
76
|
+
# @return [Hash<String, String>] Font tables after resolution
|
|
77
|
+
def resolve
|
|
78
|
+
@strategy.resolve(@font)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Check if resolution preserves variation data
|
|
82
|
+
#
|
|
83
|
+
# @return [Boolean] True if variation is preserved
|
|
84
|
+
def preserves_variation?
|
|
85
|
+
@strategy.preserves_variation?
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Get strategy name
|
|
89
|
+
#
|
|
90
|
+
# @return [Symbol] Strategy identifier
|
|
91
|
+
def strategy_name
|
|
92
|
+
@strategy.strategy_name
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
# Build strategy instance based on type
|
|
98
|
+
#
|
|
99
|
+
# @param type [Symbol] Strategy type (:preserve, :instance, :named)
|
|
100
|
+
# @param options [Hash] Strategy options
|
|
101
|
+
# @return [Strategies::BaseStrategy] Strategy instance
|
|
102
|
+
# @raise [ArgumentError] If strategy type is unknown
|
|
103
|
+
def build_strategy(type, options)
|
|
104
|
+
case type
|
|
105
|
+
when :preserve
|
|
106
|
+
Strategies::PreserveStrategy.new(options)
|
|
107
|
+
when :instance
|
|
108
|
+
Strategies::InstanceStrategy.new(options)
|
|
109
|
+
when :named
|
|
110
|
+
Strategies::NamedStrategy.new(options)
|
|
111
|
+
else
|
|
112
|
+
raise ArgumentError,
|
|
113
|
+
"Unknown strategy: #{type}. " \
|
|
114
|
+
"Valid strategies: :preserve, :instance, :named"
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Validate strategy-specific requirements
|
|
119
|
+
#
|
|
120
|
+
# @param type [Symbol] Strategy type
|
|
121
|
+
# @param options [Hash] Strategy options
|
|
122
|
+
# @raise [ArgumentError, InvalidCoordinatesError] If validation fails
|
|
123
|
+
def validate_strategy_requirements(type, options)
|
|
124
|
+
case type
|
|
125
|
+
when :instance
|
|
126
|
+
validate_instance_coordinates(options[:coordinates]) if options[:coordinates]
|
|
127
|
+
when :named
|
|
128
|
+
validate_named_instance_index(options[:instance_index]) if options[:instance_index]
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Validate coordinates for instance strategy
|
|
133
|
+
#
|
|
134
|
+
# @param coordinates [Hash] Coordinates to validate
|
|
135
|
+
# @raise [InvalidCoordinatesError] If coordinates invalid
|
|
136
|
+
def validate_instance_coordinates(coordinates)
|
|
137
|
+
return if coordinates.empty?
|
|
138
|
+
|
|
139
|
+
require_relative "../variation/variation_context"
|
|
140
|
+
context = Variation::VariationContext.new(@font)
|
|
141
|
+
context.validate_coordinates(coordinates)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Validate instance index for named strategy
|
|
145
|
+
#
|
|
146
|
+
# @param instance_index [Integer] Instance index to validate
|
|
147
|
+
# @raise [ArgumentError] If index invalid
|
|
148
|
+
def validate_named_instance_index(instance_index)
|
|
149
|
+
require_relative "../variation/variation_context"
|
|
150
|
+
context = Variation::VariationContext.new(@font)
|
|
151
|
+
|
|
152
|
+
unless context.fvar
|
|
153
|
+
raise ArgumentError, "Font is not a variable font (no fvar table)"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
instances = context.fvar.instances
|
|
157
|
+
if instance_index.negative? || instance_index >= instances.length
|
|
158
|
+
raise ArgumentError,
|
|
159
|
+
"Invalid instance index #{instance_index}. " \
|
|
160
|
+
"Font has #{instances.length} named instances."
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -122,11 +122,11 @@ module Fontisan
|
|
|
122
122
|
data = table.to_binary_s.dup
|
|
123
123
|
|
|
124
124
|
# Calculate new numberOfHMetrics
|
|
125
|
-
new_num_h_metrics = if hmtx
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
125
|
+
new_num_h_metrics = if hmtx&.h_metrics
|
|
126
|
+
hmtx.h_metrics.size
|
|
127
|
+
else
|
|
128
|
+
calculate_number_of_h_metrics
|
|
129
|
+
end
|
|
130
130
|
|
|
131
131
|
# Update numberOfHMetrics field (at offset 34, uint16)
|
|
132
132
|
data[34, 2] = [new_num_h_metrics].pack("n")
|
|
@@ -239,6 +239,11 @@ module Fontisan
|
|
|
239
239
|
# 3-byte signed integer (16-bit)
|
|
240
240
|
b1 = io.getbyte
|
|
241
241
|
b2 = io.getbyte
|
|
242
|
+
if b1.nil? || b2.nil?
|
|
243
|
+
raise CorruptedTableError,
|
|
244
|
+
"Unexpected end of CharString reading shortint"
|
|
245
|
+
end
|
|
246
|
+
|
|
242
247
|
value = (b1 << 8) | b2
|
|
243
248
|
value > 0x7FFF ? value - 0x10000 : value
|
|
244
249
|
when 32..246
|
|
@@ -247,14 +252,29 @@ module Fontisan
|
|
|
247
252
|
when 247..250
|
|
248
253
|
# Positive 2-byte integer: +108 to +1131
|
|
249
254
|
b2 = io.getbyte
|
|
255
|
+
if b2.nil?
|
|
256
|
+
raise CorruptedTableError,
|
|
257
|
+
"Unexpected end of CharString reading positive integer"
|
|
258
|
+
end
|
|
259
|
+
|
|
250
260
|
(byte - 247) * 256 + b2 + 108
|
|
251
261
|
when 251..254
|
|
252
262
|
# Negative 2-byte integer: -108 to -1131
|
|
253
263
|
b2 = io.getbyte
|
|
264
|
+
if b2.nil?
|
|
265
|
+
raise CorruptedTableError,
|
|
266
|
+
"Unexpected end of CharString reading negative integer"
|
|
267
|
+
end
|
|
268
|
+
|
|
254
269
|
-(byte - 251) * 256 - b2 - 108
|
|
255
270
|
when 255
|
|
256
271
|
# 5-byte signed integer (32-bit) as fixed-point 16.16
|
|
257
272
|
bytes = io.read(4)
|
|
273
|
+
if bytes.nil? || bytes.length < 4
|
|
274
|
+
raise CorruptedTableError,
|
|
275
|
+
"Unexpected end of CharString reading fixed-point"
|
|
276
|
+
end
|
|
277
|
+
|
|
258
278
|
value = bytes.unpack1("l>") # Signed 32-bit big-endian
|
|
259
279
|
value / 65536.0 # Convert to float
|
|
260
280
|
else
|
|
@@ -360,6 +380,8 @@ module Fontisan
|
|
|
360
380
|
# rmoveto takes 2 operands, so if stack has 3 and width not parsed,
|
|
361
381
|
# first is width
|
|
362
382
|
parse_width_for_operator(width_parsed, 2)
|
|
383
|
+
return if @stack.size < 2 # Need at least 2 values
|
|
384
|
+
|
|
363
385
|
dy = @stack.pop
|
|
364
386
|
dx = @stack.pop
|
|
365
387
|
@x += dx
|
|
@@ -374,6 +396,8 @@ module Fontisan
|
|
|
374
396
|
# hmoveto takes 1 operand, so if stack has 2 and width not parsed,
|
|
375
397
|
# first is width
|
|
376
398
|
parse_width_for_operator(width_parsed, 1)
|
|
399
|
+
return if @stack.empty? # Need at least 1 value
|
|
400
|
+
|
|
377
401
|
dx = @stack.pop || 0
|
|
378
402
|
@x += dx
|
|
379
403
|
@path << { type: :move_to, x: @x, y: @y }
|
|
@@ -386,6 +410,8 @@ module Fontisan
|
|
|
386
410
|
# vmoveto takes 1 operand, so if stack has 2 and width not parsed,
|
|
387
411
|
# first is width
|
|
388
412
|
parse_width_for_operator(width_parsed, 1)
|
|
413
|
+
return if @stack.empty? # Need at least 1 value
|
|
414
|
+
|
|
389
415
|
dy = @stack.pop || 0
|
|
390
416
|
@y += dy
|
|
391
417
|
@path << { type: :move_to, x: @x, y: @y }
|
|
@@ -773,7 +799,12 @@ module Fontisan
|
|
|
773
799
|
|
|
774
800
|
# Call local subroutine
|
|
775
801
|
def callsubr
|
|
776
|
-
|
|
802
|
+
return if @stack.empty?
|
|
803
|
+
|
|
804
|
+
subr_num = @stack.pop
|
|
805
|
+
return unless subr_num # Guard against empty stack
|
|
806
|
+
|
|
807
|
+
subr_index = subr_num + @subroutine_bias
|
|
777
808
|
if @local_subrs && subr_index >= 0 && subr_index < @local_subrs.count
|
|
778
809
|
subr_data = @local_subrs[subr_index]
|
|
779
810
|
execute_subroutine(subr_data)
|
|
@@ -782,7 +813,12 @@ module Fontisan
|
|
|
782
813
|
|
|
783
814
|
# Call global subroutine
|
|
784
815
|
def callgsubr
|
|
785
|
-
|
|
816
|
+
return if @stack.empty?
|
|
817
|
+
|
|
818
|
+
subr_num = @stack.pop
|
|
819
|
+
return unless subr_num # Guard against empty stack
|
|
820
|
+
|
|
821
|
+
subr_index = subr_num + @global_subroutine_bias
|
|
786
822
|
if subr_index >= 0 && subr_index < @global_subrs.count
|
|
787
823
|
subr_data = @global_subrs[subr_index]
|
|
788
824
|
execute_subroutine(subr_data)
|
|
@@ -818,39 +854,58 @@ module Fontisan
|
|
|
818
854
|
|
|
819
855
|
# Arithmetic operators
|
|
820
856
|
def arithmetic_add
|
|
857
|
+
return if @stack.size < 2
|
|
858
|
+
|
|
821
859
|
b = @stack.pop
|
|
822
860
|
a = @stack.pop
|
|
823
861
|
@stack << (a + b)
|
|
824
862
|
end
|
|
825
863
|
|
|
826
864
|
def arithmetic_sub
|
|
865
|
+
return if @stack.size < 2
|
|
866
|
+
|
|
827
867
|
b = @stack.pop
|
|
828
868
|
a = @stack.pop
|
|
829
869
|
@stack << (a - b)
|
|
830
870
|
end
|
|
831
871
|
|
|
832
872
|
def arithmetic_mul
|
|
873
|
+
return if @stack.size < 2
|
|
874
|
+
|
|
833
875
|
b = @stack.pop
|
|
834
876
|
a = @stack.pop
|
|
835
877
|
@stack << (a * b)
|
|
836
878
|
end
|
|
837
879
|
|
|
838
880
|
def arithmetic_div
|
|
881
|
+
return if @stack.size < 2
|
|
882
|
+
|
|
839
883
|
b = @stack.pop
|
|
840
884
|
a = @stack.pop
|
|
885
|
+
return if b.zero?
|
|
886
|
+
|
|
841
887
|
@stack << (a / b.to_f)
|
|
842
888
|
end
|
|
843
889
|
|
|
844
890
|
def arithmetic_neg
|
|
891
|
+
return if @stack.empty?
|
|
892
|
+
|
|
845
893
|
@stack << -@stack.pop
|
|
846
894
|
end
|
|
847
895
|
|
|
848
896
|
def arithmetic_abs
|
|
897
|
+
return if @stack.empty?
|
|
898
|
+
|
|
849
899
|
@stack << @stack.pop.abs
|
|
850
900
|
end
|
|
851
901
|
|
|
852
902
|
def arithmetic_sqrt
|
|
853
|
-
|
|
903
|
+
return if @stack.empty?
|
|
904
|
+
|
|
905
|
+
val = @stack.pop
|
|
906
|
+
return if val.negative?
|
|
907
|
+
|
|
908
|
+
@stack << Math.sqrt(val)
|
|
854
909
|
end
|
|
855
910
|
|
|
856
911
|
# Parse width for a specific operator
|
|
@@ -118,6 +118,40 @@ module Fontisan
|
|
|
118
118
|
@output.string
|
|
119
119
|
end
|
|
120
120
|
|
|
121
|
+
# Build a CharString from operation list
|
|
122
|
+
#
|
|
123
|
+
# This method takes operations from CharStringParser and encodes them
|
|
124
|
+
# back to binary CharString format. Useful for CharString modification.
|
|
125
|
+
#
|
|
126
|
+
# @param operations [Array<Hash>] Array of operation hashes from parser
|
|
127
|
+
# @return [String] Binary CharString data
|
|
128
|
+
def self.build_from_operations(operations)
|
|
129
|
+
new.build_from_operations(operations)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Instance method for building from operations
|
|
133
|
+
#
|
|
134
|
+
# @param operations [Array<Hash>] Array of operation hashes
|
|
135
|
+
# @return [String] Binary CharString data
|
|
136
|
+
def build_from_operations(operations)
|
|
137
|
+
@output = StringIO.new("".b)
|
|
138
|
+
|
|
139
|
+
operations.each do |op|
|
|
140
|
+
# Write operands
|
|
141
|
+
op[:operands].each { |operand| write_number(operand) }
|
|
142
|
+
|
|
143
|
+
# Write operator
|
|
144
|
+
write_operator(op[:name])
|
|
145
|
+
|
|
146
|
+
# Write hint data if present (for hintmask/cntrmask)
|
|
147
|
+
if op[:hint_data]
|
|
148
|
+
@output.write(op[:hint_data])
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
@output.string
|
|
153
|
+
end
|
|
154
|
+
|
|
121
155
|
# Build an empty CharString (for .notdef or empty glyphs)
|
|
122
156
|
#
|
|
123
157
|
# @param width [Integer, nil] Glyph width
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stringio"
|
|
4
|
+
|
|
5
|
+
module Fontisan
|
|
6
|
+
module Tables
|
|
7
|
+
class Cff
|
|
8
|
+
# CharString parser that converts binary CharString data to operation list
|
|
9
|
+
#
|
|
10
|
+
# Unlike [`CharString`](lib/fontisan/tables/cff/charstring.rb) which
|
|
11
|
+
# interprets and executes CharStrings for rendering, CharStringParser
|
|
12
|
+
# parses CharStrings into a list of operations that can be modified and
|
|
13
|
+
# rebuilt. This enables CharString manipulation for hint injection,
|
|
14
|
+
# subroutine optimization, and other transformations.
|
|
15
|
+
#
|
|
16
|
+
# Operation Format:
|
|
17
|
+
# ```ruby
|
|
18
|
+
# {
|
|
19
|
+
# type: :operator,
|
|
20
|
+
# name: :rmoveto,
|
|
21
|
+
# operands: [100, 200]
|
|
22
|
+
# }
|
|
23
|
+
# ```
|
|
24
|
+
#
|
|
25
|
+
# Reference: Adobe Type 2 CharString Format
|
|
26
|
+
# https://adobe-type-tools.github.io/font-tech-notes/pdfs/5177.Type2.pdf
|
|
27
|
+
#
|
|
28
|
+
# @example Parse a CharString
|
|
29
|
+
# parser = CharStringParser.new(charstring_data)
|
|
30
|
+
# operations = parser.parse
|
|
31
|
+
# operations.each { |op| puts "#{op[:name]} #{op[:operands]}" }
|
|
32
|
+
class CharStringParser
|
|
33
|
+
# Type 2 CharString operators (from CharString class)
|
|
34
|
+
OPERATORS = {
|
|
35
|
+
# Path construction operators
|
|
36
|
+
1 => :hstem,
|
|
37
|
+
3 => :vstem,
|
|
38
|
+
4 => :vmoveto,
|
|
39
|
+
5 => :rlineto,
|
|
40
|
+
6 => :hlineto,
|
|
41
|
+
7 => :vlineto,
|
|
42
|
+
8 => :rrcurveto,
|
|
43
|
+
10 => :callsubr,
|
|
44
|
+
11 => :return,
|
|
45
|
+
14 => :endchar,
|
|
46
|
+
18 => :hstemhm,
|
|
47
|
+
19 => :hintmask,
|
|
48
|
+
20 => :cntrmask,
|
|
49
|
+
21 => :rmoveto,
|
|
50
|
+
22 => :hmoveto,
|
|
51
|
+
23 => :vstemhm,
|
|
52
|
+
24 => :rcurveline,
|
|
53
|
+
25 => :rlinecurve,
|
|
54
|
+
26 => :vvcurveto,
|
|
55
|
+
27 => :hhcurveto,
|
|
56
|
+
28 => :shortint,
|
|
57
|
+
29 => :callgsubr,
|
|
58
|
+
30 => :vhcurveto,
|
|
59
|
+
31 => :hvcurveto,
|
|
60
|
+
# 12 prefix for two-byte operators
|
|
61
|
+
[12, 3] => :and,
|
|
62
|
+
[12, 4] => :or,
|
|
63
|
+
[12, 5] => :not,
|
|
64
|
+
[12, 9] => :abs,
|
|
65
|
+
[12, 10] => :add,
|
|
66
|
+
[12, 11] => :sub,
|
|
67
|
+
[12, 12] => :div,
|
|
68
|
+
[12, 14] => :neg,
|
|
69
|
+
[12, 15] => :eq,
|
|
70
|
+
[12, 18] => :drop,
|
|
71
|
+
[12, 20] => :put,
|
|
72
|
+
[12, 21] => :get,
|
|
73
|
+
[12, 22] => :ifelse,
|
|
74
|
+
[12, 23] => :random,
|
|
75
|
+
[12, 24] => :mul,
|
|
76
|
+
[12, 26] => :sqrt,
|
|
77
|
+
[12, 27] => :dup,
|
|
78
|
+
[12, 28] => :exch,
|
|
79
|
+
[12, 29] => :index,
|
|
80
|
+
[12, 30] => :roll,
|
|
81
|
+
[12, 34] => :hflex,
|
|
82
|
+
[12, 35] => :flex,
|
|
83
|
+
[12, 36] => :hflex1,
|
|
84
|
+
[12, 37] => :flex1,
|
|
85
|
+
}.freeze
|
|
86
|
+
|
|
87
|
+
# Operators that require hint mask bytes
|
|
88
|
+
HINTMASK_OPERATORS = %i[hintmask cntrmask].freeze
|
|
89
|
+
|
|
90
|
+
# @return [String] Binary CharString data
|
|
91
|
+
attr_reader :data
|
|
92
|
+
|
|
93
|
+
# @return [Array<Hash>] Parsed operations
|
|
94
|
+
attr_reader :operations
|
|
95
|
+
|
|
96
|
+
# Initialize parser with CharString data
|
|
97
|
+
#
|
|
98
|
+
# @param data [String] Binary CharString data
|
|
99
|
+
# @param stem_count [Integer] Number of stem hints (for hintmask)
|
|
100
|
+
def initialize(data, stem_count: 0)
|
|
101
|
+
@data = data
|
|
102
|
+
@stem_count = stem_count
|
|
103
|
+
@operations = []
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Parse CharString to operation list
|
|
107
|
+
#
|
|
108
|
+
# @return [Array<Hash>] Array of operation hashes
|
|
109
|
+
def parse
|
|
110
|
+
@operations = []
|
|
111
|
+
io = StringIO.new(@data)
|
|
112
|
+
operand_stack = []
|
|
113
|
+
|
|
114
|
+
until io.eof?
|
|
115
|
+
byte = io.getbyte
|
|
116
|
+
|
|
117
|
+
if operator_byte?(byte)
|
|
118
|
+
# Operator byte - read operator and create operation
|
|
119
|
+
operator = read_operator(io, byte)
|
|
120
|
+
|
|
121
|
+
# Read hint mask data if needed
|
|
122
|
+
hint_data = nil
|
|
123
|
+
if HINTMASK_OPERATORS.include?(operator)
|
|
124
|
+
hint_bytes = (@stem_count + 7) / 8
|
|
125
|
+
hint_data = io.read(hint_bytes) if hint_bytes.positive?
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Create operation
|
|
129
|
+
@operations << {
|
|
130
|
+
type: :operator,
|
|
131
|
+
name: operator,
|
|
132
|
+
operands: operand_stack.dup,
|
|
133
|
+
hint_data: hint_data,
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
# Clear operand stack
|
|
137
|
+
operand_stack.clear
|
|
138
|
+
else
|
|
139
|
+
# Operand byte - read number and push to stack
|
|
140
|
+
io.pos -= 1
|
|
141
|
+
number = read_number(io)
|
|
142
|
+
operand_stack << number
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
@operations
|
|
147
|
+
rescue StandardError => e
|
|
148
|
+
raise CorruptedTableError,
|
|
149
|
+
"Failed to parse CharString: #{e.message}"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Update stem count (needed for hintmask operations)
|
|
153
|
+
#
|
|
154
|
+
# @param count [Integer] Number of stem hints
|
|
155
|
+
def stem_count=(count)
|
|
156
|
+
@stem_count = count
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
private
|
|
160
|
+
|
|
161
|
+
# Check if byte is an operator
|
|
162
|
+
#
|
|
163
|
+
# @param byte [Integer] Byte value
|
|
164
|
+
# @return [Boolean] True if operator byte
|
|
165
|
+
def operator_byte?(byte)
|
|
166
|
+
byte <= 31 && byte != 28 # Operators are 0-31 except 28 (shortint)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Read operator from CharString
|
|
170
|
+
#
|
|
171
|
+
# @param io [StringIO] Input stream
|
|
172
|
+
# @param first_byte [Integer] First operator byte
|
|
173
|
+
# @return [Symbol] Operator name
|
|
174
|
+
def read_operator(io, first_byte)
|
|
175
|
+
if first_byte == 12
|
|
176
|
+
# Two-byte operator
|
|
177
|
+
second_byte = io.getbyte
|
|
178
|
+
raise CorruptedTableError, "Unexpected end of CharString" if
|
|
179
|
+
second_byte.nil?
|
|
180
|
+
|
|
181
|
+
operator_key = [first_byte, second_byte]
|
|
182
|
+
OPERATORS[operator_key] || :unknown
|
|
183
|
+
else
|
|
184
|
+
# Single-byte operator
|
|
185
|
+
OPERATORS[first_byte] || :unknown
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Read a number (integer or real) from CharString
|
|
190
|
+
#
|
|
191
|
+
# @param io [StringIO] Input stream
|
|
192
|
+
# @return [Integer, Float] The number value
|
|
193
|
+
def read_number(io)
|
|
194
|
+
byte = io.getbyte
|
|
195
|
+
raise CorruptedTableError, "Unexpected end of CharString" if
|
|
196
|
+
byte.nil?
|
|
197
|
+
|
|
198
|
+
case byte
|
|
199
|
+
when 28
|
|
200
|
+
# 3-byte signed integer (16-bit)
|
|
201
|
+
b1 = io.getbyte
|
|
202
|
+
b2 = io.getbyte
|
|
203
|
+
if b1.nil? || b2.nil?
|
|
204
|
+
raise CorruptedTableError,
|
|
205
|
+
"Unexpected end of CharString reading shortint"
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
value = (b1 << 8) | b2
|
|
209
|
+
value > 0x7FFF ? value - 0x10000 : value
|
|
210
|
+
when 32..246
|
|
211
|
+
# Small integer: -107 to +107
|
|
212
|
+
byte - 139
|
|
213
|
+
when 247..250
|
|
214
|
+
# Positive 2-byte integer: +108 to +1131
|
|
215
|
+
b2 = io.getbyte
|
|
216
|
+
if b2.nil?
|
|
217
|
+
raise CorruptedTableError,
|
|
218
|
+
"Unexpected end of CharString reading positive integer"
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
(byte - 247) * 256 + b2 + 108
|
|
222
|
+
when 251..254
|
|
223
|
+
# Negative 2-byte integer: -108 to -1131
|
|
224
|
+
b2 = io.getbyte
|
|
225
|
+
if b2.nil?
|
|
226
|
+
raise CorruptedTableError,
|
|
227
|
+
"Unexpected end of CharString reading negative integer"
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
-(byte - 251) * 256 - b2 - 108
|
|
231
|
+
when 255
|
|
232
|
+
# 5-byte signed integer (32-bit) as fixed-point 16.16
|
|
233
|
+
bytes = io.read(4)
|
|
234
|
+
if bytes.nil? || bytes.length < 4
|
|
235
|
+
raise CorruptedTableError,
|
|
236
|
+
"Unexpected end of CharString reading fixed-point"
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
value = bytes.unpack1("l>") # Signed 32-bit big-endian
|
|
240
|
+
value / 65536.0 # Convert to float
|
|
241
|
+
else
|
|
242
|
+
raise CorruptedTableError,
|
|
243
|
+
"Invalid CharString number byte: #{byte}"
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|