prawn-svg 0.23.1 → 0.24.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.travis.yml +3 -3
- data/README.md +15 -9
- data/lib/prawn-svg.rb +3 -0
- data/lib/prawn/svg/attributes.rb +1 -1
- data/lib/prawn/svg/attributes/clip_path.rb +6 -7
- data/lib/prawn/svg/attributes/opacity.rb +3 -3
- data/lib/prawn/svg/attributes/stroke.rb +6 -4
- data/lib/prawn/svg/calculators/arc_to_bezier_curve.rb +114 -0
- data/lib/prawn/svg/elements.rb +4 -1
- data/lib/prawn/svg/elements/base.rb +76 -69
- data/lib/prawn/svg/elements/container.rb +5 -6
- data/lib/prawn/svg/elements/gradient.rb +4 -4
- data/lib/prawn/svg/elements/image.rb +1 -1
- data/lib/prawn/svg/elements/line.rb +15 -7
- data/lib/prawn/svg/elements/marker.rb +72 -0
- data/lib/prawn/svg/elements/path.rb +23 -147
- data/lib/prawn/svg/elements/polygon.rb +14 -6
- data/lib/prawn/svg/elements/polyline.rb +12 -11
- data/lib/prawn/svg/elements/root.rb +3 -1
- data/lib/prawn/svg/elements/text.rb +38 -17
- data/lib/prawn/svg/font.rb +6 -6
- data/lib/prawn/svg/interface.rb +3 -0
- data/lib/prawn/svg/pathable.rb +130 -0
- data/lib/prawn/svg/properties.rb +122 -0
- data/lib/prawn/svg/state.rb +7 -29
- data/lib/prawn/svg/version.rb +1 -1
- data/spec/prawn/svg/elements/base_spec.rb +19 -32
- data/spec/prawn/svg/elements/line_spec.rb +37 -0
- data/spec/prawn/svg/elements/marker_spec.rb +90 -0
- data/spec/prawn/svg/elements/path_spec.rb +10 -10
- data/spec/prawn/svg/elements/polygon_spec.rb +49 -0
- data/spec/prawn/svg/elements/polyline_spec.rb +47 -0
- data/spec/prawn/svg/elements/style_spec.rb +1 -1
- data/spec/prawn/svg/elements/text_spec.rb +37 -5
- data/spec/prawn/svg/pathable_spec.rb +92 -0
- data/spec/prawn/svg/properties_spec.rb +186 -0
- data/spec/sample_svg/arrows.svg +73 -0
- data/spec/sample_svg/marker.svg +32 -0
- data/spec/sample_svg/polygon01.svg +25 -5
- metadata +23 -8
- data/lib/prawn/svg/attributes/color.rb +0 -5
- data/lib/prawn/svg/attributes/display.rb +0 -5
- data/lib/prawn/svg/attributes/font.rb +0 -38
- data/spec/prawn/svg/attributes/font_spec.rb +0 -52
@@ -1,22 +1,23 @@
|
|
1
1
|
class Prawn::SVG::Elements::Polyline < Prawn::SVG::Elements::Base
|
2
|
+
include Prawn::SVG::Pathable
|
3
|
+
|
2
4
|
def parse
|
3
5
|
require_attributes('points')
|
4
6
|
@points = parse_points(attributes['points'])
|
5
7
|
end
|
6
8
|
|
7
9
|
def apply
|
8
|
-
|
9
|
-
|
10
|
-
add_call 'move_to', *@points[0]
|
11
|
-
add_call_and_enter 'stroke'
|
12
|
-
@points[1..-1].each do |x, y|
|
13
|
-
add_call "line_to", x, y
|
14
|
-
end
|
10
|
+
apply_commands
|
11
|
+
apply_markers
|
15
12
|
end
|
16
13
|
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
14
|
+
protected
|
15
|
+
|
16
|
+
def commands
|
17
|
+
@commands ||= [
|
18
|
+
Prawn::SVG::Pathable::Move.new(@points[0])
|
19
|
+
] + @points[1..-1].map { |point|
|
20
|
+
Prawn::SVG::Pathable::Line.new(point)
|
21
|
+
}
|
21
22
|
end
|
22
23
|
end
|
@@ -7,7 +7,9 @@ class Prawn::SVG::Elements::Root < Prawn::SVG::Elements::Base
|
|
7
7
|
add_call 'fill_color', '000000'
|
8
8
|
add_call 'transformation_matrix', @document.sizing.x_scale, 0, 0, @document.sizing.y_scale, 0, 0
|
9
9
|
add_call 'transformation_matrix', 1, 0, 0, 1, @document.sizing.x_offset, @document.sizing.y_offset
|
10
|
+
end
|
10
11
|
|
11
|
-
|
12
|
+
def container?
|
13
|
+
true
|
12
14
|
end
|
13
15
|
end
|
@@ -20,7 +20,10 @@ class Prawn::SVG::Elements::Text < Prawn::SVG::Elements::Base
|
|
20
20
|
end
|
21
21
|
|
22
22
|
def apply
|
23
|
-
raise SkipElementQuietly if
|
23
|
+
raise SkipElementQuietly if computed_properties.display == "none"
|
24
|
+
|
25
|
+
font = select_font
|
26
|
+
apply_font(font) if font
|
24
27
|
|
25
28
|
add_call_and_enter "text_group" if name == 'text'
|
26
29
|
|
@@ -28,22 +31,18 @@ class Prawn::SVG::Elements::Text < Prawn::SVG::Elements::Base
|
|
28
31
|
add_call_and_enter "translate", document.distance(attributes['dx'] || 0), -document.distance(attributes['dy'] || 0)
|
29
32
|
end
|
30
33
|
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
34
|
+
# text_anchor isn't a Prawn option; we have to do some math to support it
|
35
|
+
# and so we handle this in Prawn::SVG::Interface#rewrite_call_arguments
|
36
|
+
opts = {
|
37
|
+
size: computed_properties.numerical_font_size,
|
38
|
+
style: font && font.subfamily,
|
39
|
+
text_anchor: computed_properties.text_anchor
|
40
|
+
}
|
36
41
|
|
37
|
-
|
38
|
-
|
39
|
-
if (anchor = attributes['text-anchor'] || state.text_anchor) &&
|
40
|
-
['start', 'middle', 'end'].include?(anchor)
|
41
|
-
opts[:text_anchor] = anchor
|
42
|
-
end
|
42
|
+
spacing = computed_properties.letter_spacing
|
43
|
+
spacing = spacing == 'normal' ? 0 : document.points(spacing)
|
43
44
|
|
44
|
-
|
45
|
-
add_call_and_enter 'character_spacing', document.points(spacing)
|
46
|
-
end
|
45
|
+
add_call_and_enter 'character_spacing', spacing
|
47
46
|
|
48
47
|
source.children.each do |child|
|
49
48
|
if child.node_type == :text
|
@@ -53,7 +52,6 @@ class Prawn::SVG::Elements::Text < Prawn::SVG::Elements::Base
|
|
53
52
|
opts[:at] = [@x_positions.first, @y_positions.first]
|
54
53
|
|
55
54
|
if @x_positions.length > 1 || @y_positions.length > 1
|
56
|
-
# TODO : isn't this just text.shift ?
|
57
55
|
add_call 'draw_text', text[0..0], opts.dup
|
58
56
|
text = text[1..-1]
|
59
57
|
|
@@ -73,7 +71,6 @@ class Prawn::SVG::Elements::Text < Prawn::SVG::Elements::Base
|
|
73
71
|
new_state.text_x_positions = @x_positions
|
74
72
|
new_state.text_y_positions = @y_positions
|
75
73
|
new_state.text_relative = @relative
|
76
|
-
new_state.text_anchor = opts[:text_anchor]
|
77
74
|
|
78
75
|
Prawn::SVG::Elements::Text.new(document, child, calls, new_state).process
|
79
76
|
|
@@ -83,5 +80,29 @@ class Prawn::SVG::Elements::Text < Prawn::SVG::Elements::Base
|
|
83
80
|
warnings << "Unknown tag '#{child.name}' inside text tag; ignoring"
|
84
81
|
end
|
85
82
|
end
|
83
|
+
|
84
|
+
# It's possible there was no text to render. In that case, add a 'noop' so
|
85
|
+
# character_spacing doesn't blow up when it finds it doesn't have a block to execute.
|
86
|
+
add_call 'noop' if calls.empty?
|
87
|
+
end
|
88
|
+
|
89
|
+
private
|
90
|
+
|
91
|
+
def select_font
|
92
|
+
font_families = [computed_properties.font_family, document.fallback_font_name]
|
93
|
+
font_style = :italic if computed_properties.font_style == 'italic'
|
94
|
+
font_weight = Prawn::SVG::Font.weight_for_css_font_weight(computed_properties.font_weight)
|
95
|
+
|
96
|
+
font_families.compact.each do |name|
|
97
|
+
font = document.font_registry.load(name, font_weight, font_style)
|
98
|
+
return font if font
|
99
|
+
end
|
100
|
+
|
101
|
+
warnings << "Font family '#{computed_properties.font_family}' style '#{computed_properties.font_style}' is not a known font, and the fallback font could not be found."
|
102
|
+
nil
|
103
|
+
end
|
104
|
+
|
105
|
+
def apply_font(font)
|
106
|
+
add_call 'font', font.name, style: font.subfamily
|
86
107
|
end
|
87
108
|
end
|
data/lib/prawn/svg/font.rb
CHANGED
@@ -11,12 +11,12 @@ class Prawn::SVG::Font
|
|
11
11
|
|
12
12
|
def self.weight_for_css_font_weight(weight)
|
13
13
|
case weight
|
14
|
-
when '100', '200', '300'
|
15
|
-
when '400', '500'
|
16
|
-
when '600'
|
17
|
-
when '700', 'bold'
|
18
|
-
when '800'
|
19
|
-
when '900'
|
14
|
+
when '100', '200', '300' then :light
|
15
|
+
when '400', '500', 'normal' then :normal
|
16
|
+
when '600' then :semibold
|
17
|
+
when '700', 'bold' then :bold
|
18
|
+
when '800' then :extrabold
|
19
|
+
when '900' then :black
|
20
20
|
end
|
21
21
|
end
|
22
22
|
|
data/lib/prawn/svg/interface.rb
CHANGED
@@ -169,6 +169,9 @@ module Prawn
|
|
169
169
|
# never want closepath to be automatically run as it stuffs up many drawing operations, such as dashes
|
170
170
|
# and line caps, and makes paths close that we didn't ask to be closed when fill is specified.
|
171
171
|
prawn.add_content 'B'
|
172
|
+
|
173
|
+
when 'noop'
|
174
|
+
yield
|
172
175
|
end
|
173
176
|
end
|
174
177
|
|
@@ -0,0 +1,130 @@
|
|
1
|
+
module Prawn::SVG::Pathable
|
2
|
+
Move = Struct.new(:destination)
|
3
|
+
Close = Struct.new(:destination)
|
4
|
+
Line = Struct.new(:destination)
|
5
|
+
Curve = Struct.new(:destination, :point1, :point2)
|
6
|
+
|
7
|
+
def bounding_box
|
8
|
+
points = commands.map { |command| translate(command.destination) }
|
9
|
+
x1, x2 = points.map(&:first).minmax
|
10
|
+
y2, y1 = points.map(&:last).minmax
|
11
|
+
|
12
|
+
[x1, y1, x2, y2]
|
13
|
+
end
|
14
|
+
|
15
|
+
protected
|
16
|
+
|
17
|
+
def apply_commands
|
18
|
+
commands.each do |command|
|
19
|
+
case command
|
20
|
+
when Move
|
21
|
+
add_call 'move_to', translate(command.destination)
|
22
|
+
when Close
|
23
|
+
add_call 'close_path'
|
24
|
+
when Line
|
25
|
+
add_call 'line_to', translate(command.destination)
|
26
|
+
when Curve
|
27
|
+
add_call 'curve_to', translate(command.destination), bounds: [translate(command.point1), translate(command.point2)]
|
28
|
+
else
|
29
|
+
raise NotImplementedError, "Unknown path command type"
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def apply_markers
|
35
|
+
if marker = extract_element_from_url_id_reference(properties.marker_start, "marker")
|
36
|
+
marker.apply_marker(self, point: commands.first.destination, angle: angles.first)
|
37
|
+
end
|
38
|
+
|
39
|
+
if marker = extract_element_from_url_id_reference(properties.marker_mid, "marker")
|
40
|
+
(1..commands.length-2).each do |index|
|
41
|
+
marker.apply_marker(self, point: commands[index].destination, angle: angles[index])
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
if marker = extract_element_from_url_id_reference(properties.marker_end, "marker")
|
46
|
+
marker.apply_marker(self, point: commands.last.destination, angle: angles.last)
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
def angles
|
51
|
+
return @angles if @angles
|
52
|
+
|
53
|
+
last_point = nil
|
54
|
+
|
55
|
+
destination_angles = commands.map do |command|
|
56
|
+
angles = case command
|
57
|
+
when Move
|
58
|
+
[nil, nil]
|
59
|
+
when Close, Line
|
60
|
+
angle = Math.atan2(command.destination[1] - last_point[1], command.destination[0] - last_point[0]) * 180.0 / Math::PI
|
61
|
+
[angle, angle]
|
62
|
+
when Curve
|
63
|
+
start = Math.atan2(command.point1[1] - last_point[1], command.point1[0] - last_point[0]) * 180.0 / Math::PI
|
64
|
+
stop = Math.atan2(command.destination[1] - command.point2[1], command.destination[0] - command.point2[0]) * 180.0 / Math::PI
|
65
|
+
[start, stop]
|
66
|
+
else
|
67
|
+
raise NotImplementedError, "Unknown path command type"
|
68
|
+
end
|
69
|
+
|
70
|
+
last_point = command.destination
|
71
|
+
angles
|
72
|
+
end
|
73
|
+
|
74
|
+
angles = destination_angles.each_cons(2).map do |first_angles, second_angles|
|
75
|
+
if first_angles.first.nil?
|
76
|
+
second_angles.first || 0
|
77
|
+
elsif second_angles.first.nil?
|
78
|
+
first_angles.last
|
79
|
+
else
|
80
|
+
first = first_angles.last
|
81
|
+
second = second_angles.last
|
82
|
+
bisect = (first + second) / 2.0
|
83
|
+
|
84
|
+
if (first - second).abs > 180
|
85
|
+
bisect >= 0 ? bisect - 180 : bisect + 180
|
86
|
+
else
|
87
|
+
bisect
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
if commands.last.is_a?(Close)
|
93
|
+
first = destination_angles.last.last || 0
|
94
|
+
second = angles.first
|
95
|
+
bisect = (first + second) / 2.0
|
96
|
+
|
97
|
+
angles << if (first - second).abs > 180
|
98
|
+
bisect >= 0 ? bisect - 180 : bisect + 180
|
99
|
+
else
|
100
|
+
bisect
|
101
|
+
end
|
102
|
+
else
|
103
|
+
angles << destination_angles.last.last
|
104
|
+
end
|
105
|
+
|
106
|
+
@angles = angles
|
107
|
+
end
|
108
|
+
|
109
|
+
def parse_points(points_string)
|
110
|
+
values = points_string.
|
111
|
+
to_s.
|
112
|
+
strip.
|
113
|
+
gsub(/(\d)-(\d)/, '\1 -\2').
|
114
|
+
split(Prawn::SVG::Elements::COMMA_WSP_REGEXP).
|
115
|
+
map(&:to_f)
|
116
|
+
|
117
|
+
if values.length % 2 == 1
|
118
|
+
document.warnings << "points attribute has an odd number of points; ignoring the last one"
|
119
|
+
values.pop
|
120
|
+
end
|
121
|
+
|
122
|
+
raise Prawn::SVG::Elements::Base::SkipElementQuietly if values.length == 0
|
123
|
+
|
124
|
+
values.each_slice(2).to_a
|
125
|
+
end
|
126
|
+
|
127
|
+
def translate(point)
|
128
|
+
[point[0].to_f, document.sizing.output_height - point[1].to_f]
|
129
|
+
end
|
130
|
+
end
|
@@ -0,0 +1,122 @@
|
|
1
|
+
class Prawn::SVG::Properties
|
2
|
+
Config = Struct.new(:default, :inheritable?, :keywords, :keyword_restricted?, :attr, :ivar)
|
3
|
+
|
4
|
+
EM = 16
|
5
|
+
FONT_SIZES = {
|
6
|
+
'xx-small' => EM / 4,
|
7
|
+
'x-small' => EM / 4 * 2,
|
8
|
+
'small' => EM / 4 * 3,
|
9
|
+
'medium' => EM / 4 * 4,
|
10
|
+
'large' => EM / 4 * 5,
|
11
|
+
'x-large' => EM / 4 * 6,
|
12
|
+
'xx-large' => EM / 4 * 7
|
13
|
+
}
|
14
|
+
|
15
|
+
PROPERTIES = {
|
16
|
+
"clip-path" => Config.new("none", false, %w(inherit none)),
|
17
|
+
"color" => Config.new('', true),
|
18
|
+
"display" => Config.new("inline", false, %w(inherit inline none), true),
|
19
|
+
"fill" => Config.new("black", true, %w(inherit none currentColor)),
|
20
|
+
"fill-opacity" => Config.new("1", true),
|
21
|
+
"font-family" => Config.new("sans-serif", true),
|
22
|
+
"font-size" => Config.new("medium", true, %w(inherit xx-small x-small small medium large x-large xx-large larger smaller)),
|
23
|
+
"font-style" => Config.new("normal", true, %w(inherit normal italic oblique), true),
|
24
|
+
"font-variant" => Config.new("normal", true, %w(inherit normal small-caps), true),
|
25
|
+
"font-weight" => Config.new("normal", true, %w(inherit normal bold 100 200 300 400 500 600 700 800 900), true), # bolder/lighter not supported
|
26
|
+
"letter-spacing" => Config.new("normal", true, %w(inherit normal)),
|
27
|
+
"marker-end" => Config.new("none", true, %w(inherit none)),
|
28
|
+
"marker-mid" => Config.new("none", true, %w(inherit none)),
|
29
|
+
"marker-start" => Config.new("none", true, %w(inherit none)),
|
30
|
+
"opacity" => Config.new("1", false),
|
31
|
+
"overflow" => Config.new('visible', false, %w(inherit visible hidden scroll auto), true),
|
32
|
+
"stop-color" => Config.new("black", false, %w(inherit none currentColor)),
|
33
|
+
"stroke" => Config.new("none", true, %w(inherit none currentColor)),
|
34
|
+
"stroke-dasharray" => Config.new("none", true, %w(inherit none)),
|
35
|
+
"stroke-linecap" => Config.new("butt", true, %w(inherit butt round square), true),
|
36
|
+
"stroke-opacity" => Config.new("1", true),
|
37
|
+
"stroke-width" => Config.new("1", true),
|
38
|
+
"text-anchor" => Config.new("start", true, %w(inherit start middle end), true),
|
39
|
+
}.freeze
|
40
|
+
|
41
|
+
PROPERTIES.each do |name, value|
|
42
|
+
value.attr = name.gsub("-", "_")
|
43
|
+
value.ivar = "@#{value.attr}"
|
44
|
+
end
|
45
|
+
|
46
|
+
PROPERTY_CONFIGS = PROPERTIES.values
|
47
|
+
NAMES = PROPERTIES.keys
|
48
|
+
ATTR_NAMES = PROPERTIES.keys.map { |name| name.gsub('-', '_') }
|
49
|
+
|
50
|
+
attr_accessor *ATTR_NAMES
|
51
|
+
|
52
|
+
def load_default_stylesheet
|
53
|
+
PROPERTY_CONFIGS.each do |config|
|
54
|
+
instance_variable_set(config.ivar, config.default)
|
55
|
+
end
|
56
|
+
|
57
|
+
self
|
58
|
+
end
|
59
|
+
|
60
|
+
def set(name, value)
|
61
|
+
if config = PROPERTIES[name.to_s.downcase]
|
62
|
+
value = value.strip
|
63
|
+
keyword = value.downcase
|
64
|
+
keywords = config.keywords || ['inherit']
|
65
|
+
|
66
|
+
if keywords.include?(keyword)
|
67
|
+
value = keyword
|
68
|
+
elsif config.keyword_restricted?
|
69
|
+
value = config.default
|
70
|
+
end
|
71
|
+
|
72
|
+
instance_variable_set(config.ivar, value)
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
def to_h
|
77
|
+
PROPERTIES.each.with_object({}) do |(name, config), result|
|
78
|
+
result[name] = instance_variable_get(config.ivar)
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
def load_hash(hash)
|
83
|
+
hash.each { |name, value| set(name, value) if value }
|
84
|
+
end
|
85
|
+
|
86
|
+
def compute_properties(other)
|
87
|
+
PROPERTY_CONFIGS.each do |config|
|
88
|
+
value = other.send(config.attr)
|
89
|
+
|
90
|
+
if value && value != 'inherit'
|
91
|
+
value = compute_font_size_property(value).to_s if config.attr == "font_size"
|
92
|
+
instance_variable_set(config.ivar, value)
|
93
|
+
|
94
|
+
elsif value.nil? && !config.inheritable?
|
95
|
+
instance_variable_set(config.ivar, config.default)
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
def numerical_font_size
|
101
|
+
# px = pt for PDFs
|
102
|
+
FONT_SIZES[font_size] || font_size.to_f
|
103
|
+
end
|
104
|
+
|
105
|
+
private
|
106
|
+
|
107
|
+
def compute_font_size_property(value)
|
108
|
+
if value[-1] == "%"
|
109
|
+
numerical_font_size * (value.to_f / 100.0)
|
110
|
+
elsif value == 'larger'
|
111
|
+
numerical_font_size + 4
|
112
|
+
elsif value == 'smaller'
|
113
|
+
numerical_font_size - 4
|
114
|
+
elsif value.match(/(\d|\.)em\z/i)
|
115
|
+
numerical_font_size * value.to_f
|
116
|
+
elsif value.match(/(\d|\.)rem\z/i)
|
117
|
+
value.to_f * EM
|
118
|
+
else
|
119
|
+
FONT_SIZES[value] || value.to_f
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
data/lib/prawn/svg/state.rb
CHANGED
@@ -1,39 +1,17 @@
|
|
1
1
|
class Prawn::SVG::State
|
2
2
|
attr_accessor :disable_drawing,
|
3
|
-
:
|
4
|
-
:
|
5
|
-
:
|
6
|
-
:fill_opacity, :stroke_opacity,
|
7
|
-
:fill, :stroke
|
3
|
+
:text_relative, :text_x_positions, :text_y_positions, :preserve_space,
|
4
|
+
:fill_opacity, :stroke_opacity, :stroke_width,
|
5
|
+
:computed_properties
|
8
6
|
|
9
7
|
def initialize
|
10
|
-
@
|
11
|
-
@stroke = false
|
8
|
+
@stroke_width = 1
|
12
9
|
@fill_opacity = 1
|
13
10
|
@stroke_opacity = 1
|
11
|
+
@computed_properties = Prawn::SVG::Properties.new.load_default_stylesheet
|
14
12
|
end
|
15
13
|
|
16
|
-
def
|
17
|
-
|
18
|
-
when 'fill' then @fill = true
|
19
|
-
when 'stroke' then @stroke = true
|
20
|
-
else raise
|
21
|
-
end
|
22
|
-
end
|
23
|
-
|
24
|
-
def disable_draw_type(type)
|
25
|
-
case type
|
26
|
-
when 'fill' then @fill = false
|
27
|
-
when 'stroke' then @stroke = false
|
28
|
-
else raise
|
29
|
-
end
|
30
|
-
end
|
31
|
-
|
32
|
-
def draw_type(type)
|
33
|
-
case type
|
34
|
-
when 'fill' then @fill
|
35
|
-
when 'stroke' then @stroke
|
36
|
-
else raise
|
37
|
-
end
|
14
|
+
def initialize_dup(other)
|
15
|
+
@computed_properties = @computed_properties.dup
|
38
16
|
end
|
39
17
|
end
|