fontisan 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +270 -131
- data/README.adoc +158 -4
- data/Rakefile +44 -47
- data/lib/fontisan/cli.rb +84 -33
- data/lib/fontisan/collection/builder.rb +81 -0
- data/lib/fontisan/collection/table_deduplicator.rb +76 -0
- data/lib/fontisan/commands/base_command.rb +16 -0
- data/lib/fontisan/commands/convert_command.rb +97 -170
- data/lib/fontisan/commands/instance_command.rb +71 -80
- data/lib/fontisan/commands/validate_command.rb +25 -0
- data/lib/fontisan/config/validation_rules.yml +1 -1
- data/lib/fontisan/constants.rb +10 -0
- data/lib/fontisan/converters/format_converter.rb +150 -1
- data/lib/fontisan/converters/outline_converter.rb +80 -18
- data/lib/fontisan/converters/woff_writer.rb +1 -1
- data/lib/fontisan/font_loader.rb +3 -5
- data/lib/fontisan/font_writer.rb +7 -6
- data/lib/fontisan/hints/hint_converter.rb +133 -0
- data/lib/fontisan/hints/postscript_hint_applier.rb +221 -140
- data/lib/fontisan/hints/postscript_hint_extractor.rb +100 -0
- data/lib/fontisan/hints/truetype_hint_applier.rb +90 -44
- data/lib/fontisan/hints/truetype_hint_extractor.rb +127 -0
- data/lib/fontisan/loading_modes.rb +2 -0
- data/lib/fontisan/models/font_export.rb +2 -2
- data/lib/fontisan/models/hint.rb +173 -1
- data/lib/fontisan/models/validation_report.rb +1 -1
- data/lib/fontisan/open_type_font.rb +25 -9
- data/lib/fontisan/open_type_font_extensions.rb +54 -0
- data/lib/fontisan/pipeline/format_detector.rb +249 -0
- data/lib/fontisan/pipeline/output_writer.rb +154 -0
- data/lib/fontisan/pipeline/strategies/base_strategy.rb +75 -0
- data/lib/fontisan/pipeline/strategies/instance_strategy.rb +93 -0
- data/lib/fontisan/pipeline/strategies/named_strategy.rb +118 -0
- data/lib/fontisan/pipeline/strategies/preserve_strategy.rb +56 -0
- data/lib/fontisan/pipeline/transformation_pipeline.rb +411 -0
- data/lib/fontisan/pipeline/variation_resolver.rb +165 -0
- data/lib/fontisan/tables/cff/charstring.rb +33 -4
- data/lib/fontisan/tables/cff/charstring_builder.rb +34 -0
- data/lib/fontisan/tables/cff/charstring_parser.rb +237 -0
- data/lib/fontisan/tables/cff/charstring_rebuilder.rb +172 -0
- data/lib/fontisan/tables/cff/dict_builder.rb +15 -0
- data/lib/fontisan/tables/cff/hint_operation_injector.rb +207 -0
- data/lib/fontisan/tables/cff/offset_recalculator.rb +70 -0
- data/lib/fontisan/tables/cff/private_dict_writer.rb +125 -0
- data/lib/fontisan/tables/cff/table_builder.rb +221 -0
- data/lib/fontisan/tables/cff.rb +2 -0
- data/lib/fontisan/tables/cff2/private_dict_blend_handler.rb +246 -0
- data/lib/fontisan/tables/cff2/region_matcher.rb +200 -0
- data/lib/fontisan/tables/cff2/table_builder.rb +574 -0
- data/lib/fontisan/tables/cff2/table_reader.rb +419 -0
- data/lib/fontisan/tables/cff2/variation_data_extractor.rb +212 -0
- data/lib/fontisan/tables/cff2.rb +9 -4
- data/lib/fontisan/tables/cvar.rb +2 -41
- data/lib/fontisan/tables/gvar.rb +2 -41
- data/lib/fontisan/true_type_font.rb +24 -9
- data/lib/fontisan/true_type_font_extensions.rb +54 -0
- data/lib/fontisan/utilities/checksum_calculator.rb +42 -0
- data/lib/fontisan/validation/checksum_validator.rb +2 -2
- data/lib/fontisan/validation/table_validator.rb +1 -1
- data/lib/fontisan/validation/variable_font_validator.rb +218 -0
- data/lib/fontisan/variation/converter.rb +120 -13
- data/lib/fontisan/variation/instance_writer.rb +341 -0
- data/lib/fontisan/variation/tuple_variation_header.rb +51 -0
- data/lib/fontisan/variation/variable_svg_generator.rb +268 -0
- data/lib/fontisan/variation/variation_preserver.rb +288 -0
- data/lib/fontisan/version.rb +1 -1
- data/lib/fontisan/version.rb.orig +9 -0
- data/lib/fontisan/woff2/glyf_transformer.rb +666 -0
- data/lib/fontisan/woff2/hmtx_transformer.rb +164 -0
- data/lib/fontisan/woff2_font.rb +475 -470
- data/lib/fontisan/woff_font.rb +16 -11
- data/lib/fontisan.rb +12 -0
- metadata +31 -2
|
@@ -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
|
|
@@ -239,6 +239,8 @@ module Fontisan
|
|
|
239
239
|
# 3-byte signed integer (16-bit)
|
|
240
240
|
b1 = io.getbyte
|
|
241
241
|
b2 = io.getbyte
|
|
242
|
+
raise CorruptedTableError, "Unexpected end of CharString reading shortint" if
|
|
243
|
+
b1.nil? || b2.nil?
|
|
242
244
|
value = (b1 << 8) | b2
|
|
243
245
|
value > 0x7FFF ? value - 0x10000 : value
|
|
244
246
|
when 32..246
|
|
@@ -247,14 +249,20 @@ module Fontisan
|
|
|
247
249
|
when 247..250
|
|
248
250
|
# Positive 2-byte integer: +108 to +1131
|
|
249
251
|
b2 = io.getbyte
|
|
252
|
+
raise CorruptedTableError, "Unexpected end of CharString reading positive integer" if
|
|
253
|
+
b2.nil?
|
|
250
254
|
(byte - 247) * 256 + b2 + 108
|
|
251
255
|
when 251..254
|
|
252
256
|
# Negative 2-byte integer: -108 to -1131
|
|
253
257
|
b2 = io.getbyte
|
|
258
|
+
raise CorruptedTableError, "Unexpected end of CharString reading negative integer" if
|
|
259
|
+
b2.nil?
|
|
254
260
|
-(byte - 251) * 256 - b2 - 108
|
|
255
261
|
when 255
|
|
256
262
|
# 5-byte signed integer (32-bit) as fixed-point 16.16
|
|
257
263
|
bytes = io.read(4)
|
|
264
|
+
raise CorruptedTableError, "Unexpected end of CharString reading fixed-point" if
|
|
265
|
+
bytes.nil? || bytes.length < 4
|
|
258
266
|
value = bytes.unpack1("l>") # Signed 32-bit big-endian
|
|
259
267
|
value / 65536.0 # Convert to float
|
|
260
268
|
else
|
|
@@ -360,6 +368,7 @@ module Fontisan
|
|
|
360
368
|
# rmoveto takes 2 operands, so if stack has 3 and width not parsed,
|
|
361
369
|
# first is width
|
|
362
370
|
parse_width_for_operator(width_parsed, 2)
|
|
371
|
+
return if @stack.size < 2 # Need at least 2 values
|
|
363
372
|
dy = @stack.pop
|
|
364
373
|
dx = @stack.pop
|
|
365
374
|
@x += dx
|
|
@@ -374,6 +383,7 @@ module Fontisan
|
|
|
374
383
|
# hmoveto takes 1 operand, so if stack has 2 and width not parsed,
|
|
375
384
|
# first is width
|
|
376
385
|
parse_width_for_operator(width_parsed, 1)
|
|
386
|
+
return if @stack.empty? # Need at least 1 value
|
|
377
387
|
dx = @stack.pop || 0
|
|
378
388
|
@x += dx
|
|
379
389
|
@path << { type: :move_to, x: @x, y: @y }
|
|
@@ -386,6 +396,7 @@ module Fontisan
|
|
|
386
396
|
# vmoveto takes 1 operand, so if stack has 2 and width not parsed,
|
|
387
397
|
# first is width
|
|
388
398
|
parse_width_for_operator(width_parsed, 1)
|
|
399
|
+
return if @stack.empty? # Need at least 1 value
|
|
389
400
|
dy = @stack.pop || 0
|
|
390
401
|
@y += dy
|
|
391
402
|
@path << { type: :move_to, x: @x, y: @y }
|
|
@@ -677,7 +688,7 @@ module Fontisan
|
|
|
677
688
|
dy3 = @stack.shift
|
|
678
689
|
|
|
679
690
|
x1 = @x + dx1
|
|
680
|
-
y1 = @y +
|
|
691
|
+
y1 = @y +dy1
|
|
681
692
|
x2 = x1 + dx2
|
|
682
693
|
y2 = y1 + dy2
|
|
683
694
|
@x = x2 + dx3
|
|
@@ -773,7 +784,11 @@ module Fontisan
|
|
|
773
784
|
|
|
774
785
|
# Call local subroutine
|
|
775
786
|
def callsubr
|
|
776
|
-
|
|
787
|
+
return if @stack.empty?
|
|
788
|
+
subr_num = @stack.pop
|
|
789
|
+
return unless subr_num # Guard against empty stack
|
|
790
|
+
|
|
791
|
+
subr_index = subr_num + @subroutine_bias
|
|
777
792
|
if @local_subrs && subr_index >= 0 && subr_index < @local_subrs.count
|
|
778
793
|
subr_data = @local_subrs[subr_index]
|
|
779
794
|
execute_subroutine(subr_data)
|
|
@@ -782,7 +797,11 @@ module Fontisan
|
|
|
782
797
|
|
|
783
798
|
# Call global subroutine
|
|
784
799
|
def callgsubr
|
|
785
|
-
|
|
800
|
+
return if @stack.empty?
|
|
801
|
+
subr_num = @stack.pop
|
|
802
|
+
return unless subr_num # Guard against empty stack
|
|
803
|
+
|
|
804
|
+
subr_index = subr_num + @global_subroutine_bias
|
|
786
805
|
if subr_index >= 0 && subr_index < @global_subrs.count
|
|
787
806
|
subr_data = @global_subrs[subr_index]
|
|
788
807
|
execute_subroutine(subr_data)
|
|
@@ -818,39 +837,49 @@ module Fontisan
|
|
|
818
837
|
|
|
819
838
|
# Arithmetic operators
|
|
820
839
|
def arithmetic_add
|
|
840
|
+
return if @stack.size < 2
|
|
821
841
|
b = @stack.pop
|
|
822
842
|
a = @stack.pop
|
|
823
843
|
@stack << (a + b)
|
|
824
844
|
end
|
|
825
845
|
|
|
826
846
|
def arithmetic_sub
|
|
847
|
+
return if @stack.size < 2
|
|
827
848
|
b = @stack.pop
|
|
828
849
|
a = @stack.pop
|
|
829
850
|
@stack << (a - b)
|
|
830
851
|
end
|
|
831
852
|
|
|
832
853
|
def arithmetic_mul
|
|
854
|
+
return if @stack.size < 2
|
|
833
855
|
b = @stack.pop
|
|
834
856
|
a = @stack.pop
|
|
835
857
|
@stack << (a * b)
|
|
836
858
|
end
|
|
837
859
|
|
|
838
860
|
def arithmetic_div
|
|
861
|
+
return if @stack.size < 2
|
|
839
862
|
b = @stack.pop
|
|
840
863
|
a = @stack.pop
|
|
864
|
+
return if b.zero?
|
|
841
865
|
@stack << (a / b.to_f)
|
|
842
866
|
end
|
|
843
867
|
|
|
844
868
|
def arithmetic_neg
|
|
869
|
+
return if @stack.empty?
|
|
845
870
|
@stack << -@stack.pop
|
|
846
871
|
end
|
|
847
872
|
|
|
848
873
|
def arithmetic_abs
|
|
874
|
+
return if @stack.empty?
|
|
849
875
|
@stack << @stack.pop.abs
|
|
850
876
|
end
|
|
851
877
|
|
|
852
878
|
def arithmetic_sqrt
|
|
853
|
-
|
|
879
|
+
return if @stack.empty?
|
|
880
|
+
val = @stack.pop
|
|
881
|
+
return if val.negative?
|
|
882
|
+
@stack << Math.sqrt(val)
|
|
854
883
|
end
|
|
855
884
|
|
|
856
885
|
# 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,237 @@
|
|
|
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
|
+
raise CorruptedTableError, "Unexpected end of CharString reading shortint" if
|
|
204
|
+
b1.nil? || b2.nil?
|
|
205
|
+
value = (b1 << 8) | b2
|
|
206
|
+
value > 0x7FFF ? value - 0x10000 : value
|
|
207
|
+
when 32..246
|
|
208
|
+
# Small integer: -107 to +107
|
|
209
|
+
byte - 139
|
|
210
|
+
when 247..250
|
|
211
|
+
# Positive 2-byte integer: +108 to +1131
|
|
212
|
+
b2 = io.getbyte
|
|
213
|
+
raise CorruptedTableError, "Unexpected end of CharString reading positive integer" if
|
|
214
|
+
b2.nil?
|
|
215
|
+
(byte - 247) * 256 + b2 + 108
|
|
216
|
+
when 251..254
|
|
217
|
+
# Negative 2-byte integer: -108 to -1131
|
|
218
|
+
b2 = io.getbyte
|
|
219
|
+
raise CorruptedTableError, "Unexpected end of CharString reading negative integer" if
|
|
220
|
+
b2.nil?
|
|
221
|
+
-(byte - 251) * 256 - b2 - 108
|
|
222
|
+
when 255
|
|
223
|
+
# 5-byte signed integer (32-bit) as fixed-point 16.16
|
|
224
|
+
bytes = io.read(4)
|
|
225
|
+
raise CorruptedTableError, "Unexpected end of CharString reading fixed-point" if
|
|
226
|
+
bytes.nil? || bytes.length < 4
|
|
227
|
+
value = bytes.unpack1("l>") # Signed 32-bit big-endian
|
|
228
|
+
value / 65536.0 # Convert to float
|
|
229
|
+
else
|
|
230
|
+
raise CorruptedTableError,
|
|
231
|
+
"Invalid CharString number byte: #{byte}"
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|