dragonfly 0.5.7 → 0.6.0
Sign up to get free protection for your applications and to get access to all the features.
Potentially problematic release.
This version of dragonfly might be problematic. Click here for more details.
- data/.gitignore +1 -0
- data/.yardopts +1 -0
- data/History.md +109 -0
- data/VERSION +1 -1
- data/config.rb +1 -1
- data/dragonfly.gemspec +19 -16
- data/extra_docs/ActiveRecord.md +8 -7
- data/extra_docs/Analysers.md +1 -1
- data/extra_docs/Encoding.md +1 -1
- data/extra_docs/ExampleUseCases.md +66 -73
- data/extra_docs/GettingStarted.md +1 -1
- data/extra_docs/Processing.md +1 -1
- data/extra_docs/Shortcuts.md +2 -2
- data/extra_docs/UsingWithRails.md +25 -37
- data/features/rails_2.3.5.feature +1 -8
- data/features/rails_3.0.0.beta3.feature +7 -0
- data/features/steps/rails_steps.rb +1 -11
- data/features/support/env.rb +1 -1
- data/fixtures/files/app/views/albums/show.html.erb +1 -0
- data/fixtures/files/config/initializers/{aaa_dragonfly_load_path.rb → dragonfly.rb} +1 -0
- data/fixtures/rails_2.3.5/template.rb +4 -6
- data/fixtures/rails_3.0.0.beta3/template.rb +16 -0
- data/lib/dragonfly.rb +23 -1
- data/lib/dragonfly/active_record_extensions/attachment.rb +1 -1
- data/lib/dragonfly/analyser_list.rb +4 -0
- data/lib/dragonfly/analysis/base.rb +1 -0
- data/lib/dragonfly/analysis/r_magick_analyser.rb +7 -0
- data/lib/dragonfly/app.rb +3 -11
- data/lib/dragonfly/belongs_to_app.rb +24 -0
- data/lib/dragonfly/config/heroku_rails_images.rb +23 -0
- data/lib/dragonfly/config/r_magick_images.rb +69 -0
- data/lib/dragonfly/config/r_magick_text.rb +25 -0
- data/lib/dragonfly/config/rails_defaults.rb +18 -0
- data/lib/dragonfly/config/rails_images.rb +13 -0
- data/lib/dragonfly/configurable.rb +4 -3
- data/lib/dragonfly/data_storage/base.rb +2 -0
- data/lib/dragonfly/data_storage/s3data_store.rb +11 -4
- data/lib/dragonfly/delegator.rb +22 -10
- data/lib/dragonfly/encoder_list.rb +4 -0
- data/lib/dragonfly/encoding/base.rb +1 -0
- data/lib/dragonfly/encoding/r_magick_encoder.rb +52 -8
- data/lib/dragonfly/extended_temp_object.rb +26 -27
- data/lib/dragonfly/processing/base.rb +1 -0
- data/lib/dragonfly/processing/r_magick_processor.rb +12 -127
- data/lib/dragonfly/processing/r_magick_text_processor.rb +155 -0
- data/lib/dragonfly/processor_list.rb +4 -0
- data/lib/dragonfly/rails/images.rb +2 -14
- data/lib/dragonfly/temp_object.rb +41 -34
- data/spec/dragonfly/active_record_extensions/migration.rb +7 -0
- data/spec/dragonfly/active_record_extensions/model_spec.rb +10 -11
- data/spec/dragonfly/active_record_extensions/spec_helper.rb +1 -1
- data/spec/dragonfly/analysis/r_magick_analyser_spec.rb +14 -1
- data/spec/dragonfly/belongs_to_app_spec.rb +55 -0
- data/spec/dragonfly/configurable_spec.rb +21 -6
- data/spec/dragonfly/data_storage/s3_data_store_spec.rb +1 -0
- data/spec/dragonfly/delegator_spec.rb +23 -12
- data/spec/dragonfly/extended_temp_object_spec.rb +13 -29
- data/spec/dragonfly/processing/{rmagick_processor_spec.rb → r_magick_processor_spec.rb} +8 -75
- data/spec/dragonfly/processing/r_magick_text_processor_spec.rb +84 -0
- data/spec/dragonfly/temp_object_spec.rb +126 -151
- data/spec/dragonfly_spec.rb +12 -0
- data/spec/ginger_scenarios.rb +2 -2
- data/spec/image_matchers.rb +2 -2
- data/yard/setup.rb +12 -2
- data/yard/templates/default/fulldoc/html/css/common.css +1 -2
- data/yard/templates/default/layout/html/layout.erb +3 -2
- metadata +21 -18
- data/History.txt +0 -75
- data/features/rails_3.0.0.beta.feature +0 -15
- data/fixtures/dragonfly_setup.rb +0 -1
- data/fixtures/rails +0 -22
- data/fixtures/rails_3.0.0.beta/template.rb +0 -13
- data/generators/dragonfly_app/USAGE +0 -16
- data/generators/dragonfly_app/dragonfly_app_generator.rb +0 -24
- data/generators/dragonfly_app/templates/initializer.erb +0 -35
- data/lib/dragonfly/r_magick_configuration.rb +0 -67
- data/spec/dragonfly/active_record_extensions/initializer.rb +0 -1
@@ -0,0 +1,155 @@
|
|
1
|
+
require 'RMagick'
|
2
|
+
|
3
|
+
module Dragonfly
|
4
|
+
module Processing
|
5
|
+
|
6
|
+
class RMagickTextProcessor < Base
|
7
|
+
|
8
|
+
FONT_STYLES = {
|
9
|
+
'normal' => Magick::NormalStyle,
|
10
|
+
'italic' => Magick::ItalicStyle,
|
11
|
+
'oblique' => Magick::ObliqueStyle
|
12
|
+
}
|
13
|
+
|
14
|
+
FONT_STRETCHES = {
|
15
|
+
'normal' => Magick::NormalStretch,
|
16
|
+
'semi-condensed' => Magick::SemiCondensedStretch,
|
17
|
+
'condensed' => Magick::CondensedStretch,
|
18
|
+
'extra-condensed' => Magick::ExtraCondensedStretch,
|
19
|
+
'ultra-condensed' => Magick::UltraCondensedStretch,
|
20
|
+
'semi-expanded' => Magick::SemiExpandedStretch,
|
21
|
+
'expanded' => Magick::ExpandedStretch,
|
22
|
+
'extra-expanded' => Magick::ExtraExpandedStretch,
|
23
|
+
'ultra-expanded' => Magick::UltraExpandedStretch
|
24
|
+
}
|
25
|
+
|
26
|
+
FONT_WEIGHTS = {
|
27
|
+
'normal' => Magick::NormalWeight,
|
28
|
+
'bold' => Magick::BoldWeight,
|
29
|
+
'bolder' => Magick::BolderWeight,
|
30
|
+
'lighter' => Magick::LighterWeight,
|
31
|
+
'100' => 100,
|
32
|
+
'200' => 200,
|
33
|
+
'300' => 300,
|
34
|
+
'400' => 400,
|
35
|
+
'500' => 500,
|
36
|
+
'600' => 600,
|
37
|
+
'700' => 700,
|
38
|
+
'800' => 800,
|
39
|
+
'900' => 900
|
40
|
+
}
|
41
|
+
|
42
|
+
# HashWithCssStyleKeys is solely for being able to access a hash
|
43
|
+
# which has css-style keys (e.g. 'font-size') with the underscore
|
44
|
+
# symbol version
|
45
|
+
# @example
|
46
|
+
# opts = {'font-size' => '23px', :color => 'white'}
|
47
|
+
# opts = HashWithCssStyleKeys[opts]
|
48
|
+
# opts[:font_size] # ===> '23px'
|
49
|
+
# opts[:color] # ===> 'white'
|
50
|
+
class HashWithCssStyleKeys < Hash
|
51
|
+
def [](key)
|
52
|
+
super || (
|
53
|
+
str_key = key.to_s
|
54
|
+
css_key = str_key.gsub('_','-')
|
55
|
+
super(str_key) || super(css_key) || super(css_key.to_sym)
|
56
|
+
)
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
def text(temp_object, opts={})
|
61
|
+
opts = HashWithCssStyleKeys[opts]
|
62
|
+
|
63
|
+
draw = Magick::Draw.new
|
64
|
+
draw.gravity = Magick::CenterGravity
|
65
|
+
draw.text_antialias = true
|
66
|
+
|
67
|
+
# Font size
|
68
|
+
font_size = (opts[:font_size] || 12).to_i
|
69
|
+
|
70
|
+
# Scale up the text for better quality -
|
71
|
+
# it will be reshrunk at the end
|
72
|
+
s = scale_factor_for(font_size)
|
73
|
+
|
74
|
+
# Settings
|
75
|
+
draw.pointsize = font_size * s
|
76
|
+
draw.font = opts[:font] if opts[:font]
|
77
|
+
draw.font_family = opts[:font_family] if opts[:font_family]
|
78
|
+
draw.fill = opts[:color] if opts[:color]
|
79
|
+
draw.stroke = opts[:stroke_color] if opts[:stroke_color]
|
80
|
+
draw.font_style = FONT_STYLES[opts[:font_style]] if opts[:font_style]
|
81
|
+
draw.font_stretch = FONT_STRETCHES[opts[:font_stretch]] if opts[:font_stretch]
|
82
|
+
draw.font_weight = FONT_WEIGHTS[opts[:font_weight]] if opts[:font_weight]
|
83
|
+
|
84
|
+
# Padding
|
85
|
+
# NB the values are scaled up by the scale factor
|
86
|
+
pt, pr, pb, pl = parse_padding_string(opts[:padding]) if opts[:padding]
|
87
|
+
padding_top = (opts[:padding_top] || pt || 0) * s
|
88
|
+
padding_right = (opts[:padding_right] || pr || 0) * s
|
89
|
+
padding_bottom = (opts[:padding_bottom] || pb || 0) * s
|
90
|
+
padding_left = (opts[:padding_left] || pl || 0) * s
|
91
|
+
|
92
|
+
text = temp_object.data
|
93
|
+
|
94
|
+
# Calculate (scaled up) dimensions
|
95
|
+
metrics = draw.get_type_metrics(text)
|
96
|
+
width, height = metrics.width, metrics.height
|
97
|
+
|
98
|
+
scaled_up_width = padding_left + width + padding_right
|
99
|
+
scaled_up_height = padding_top + height + padding_bottom
|
100
|
+
|
101
|
+
# Draw the background
|
102
|
+
image = Magick::Image.new(scaled_up_width, scaled_up_height){
|
103
|
+
self.background_color = opts[:background_color] || 'transparent'
|
104
|
+
}
|
105
|
+
# Draw the text
|
106
|
+
draw.annotate(image, width, height, padding_left, padding_top, text)
|
107
|
+
|
108
|
+
# Scale back down again
|
109
|
+
image.scale!(1/s)
|
110
|
+
|
111
|
+
image.format = 'png'
|
112
|
+
|
113
|
+
# Output image as string
|
114
|
+
image.to_blob
|
115
|
+
end
|
116
|
+
|
117
|
+
private
|
118
|
+
|
119
|
+
# Use css-style padding declaration, i.e.
|
120
|
+
# 10 (all sides)
|
121
|
+
# 10 5 (top/bottom, left/right)
|
122
|
+
# 10 5 10 (top, left/right, bottom)
|
123
|
+
# 10 5 10 5 (top, right, bottom, left)
|
124
|
+
def parse_padding_string(str)
|
125
|
+
padding_parts = str.gsub('px','').split(/\s+/).map{|px| px.to_i}
|
126
|
+
case padding_parts.size
|
127
|
+
when 1
|
128
|
+
p = padding_parts.first
|
129
|
+
[p,p,p,p]
|
130
|
+
when 2
|
131
|
+
p,q = padding_parts
|
132
|
+
[p,q,p,q]
|
133
|
+
when 3
|
134
|
+
p,q,r = padding_parts
|
135
|
+
[p,q,r,q]
|
136
|
+
when 4
|
137
|
+
padding_parts
|
138
|
+
else raise ArgumentError, "Couldn't parse padding string '#{str}' - should be a css-style string"
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
def scale_factor_for(font_size)
|
143
|
+
# Scale approximately to 64 if below
|
144
|
+
min_size = 64
|
145
|
+
if font_size < min_size
|
146
|
+
(min_size.to_f / font_size).ceil
|
147
|
+
else
|
148
|
+
1
|
149
|
+
end.to_f
|
150
|
+
end
|
151
|
+
|
152
|
+
end
|
153
|
+
|
154
|
+
end
|
155
|
+
end
|
@@ -2,23 +2,11 @@ require 'dragonfly'
|
|
2
2
|
require 'rack/cache'
|
3
3
|
|
4
4
|
### The dragonfly app ###
|
5
|
-
|
6
5
|
app = Dragonfly::App[:images]
|
7
|
-
app.configure_with(Dragonfly::
|
8
|
-
app.configure do |c|
|
9
|
-
c.log = Rails.logger
|
10
|
-
c.datastore.configure do |d|
|
11
|
-
d.root_path = "#{Rails.root}/public/system/dragonfly/#{Rails.env}"
|
12
|
-
end
|
13
|
-
c.url_handler.configure do |u|
|
14
|
-
u.protect_from_dos_attacks = false
|
15
|
-
u.path_prefix = '/media'
|
16
|
-
end
|
17
|
-
end
|
6
|
+
app.configure_with(Dragonfly::Config::RailsImages)
|
18
7
|
|
19
8
|
### Extend active record ###
|
20
|
-
|
21
|
-
ActiveRecord::Base.register_dragonfly_app(:image, app)
|
9
|
+
Dragonfly.active_record_macro(:image, app)
|
22
10
|
|
23
11
|
### Insert the middleware ###
|
24
12
|
# Where the middleware is depends on the version of Rails
|
@@ -53,26 +53,34 @@ module Dragonfly
|
|
53
53
|
end
|
54
54
|
|
55
55
|
def data
|
56
|
-
@data ||= initialized_data || file.
|
56
|
+
@data ||= initialized_data || file.read
|
57
57
|
end
|
58
58
|
|
59
59
|
def tempfile
|
60
|
-
|
60
|
+
@tempfile ||= begin
|
61
|
+
if initialized_tempfile
|
62
|
+
@tempfile = initialized_tempfile
|
63
|
+
elsif initialized_data
|
64
|
+
@tempfile = Tempfile.new('dragonfly')
|
65
|
+
@tempfile.write(initialized_data)
|
66
|
+
elsif initialized_file
|
67
|
+
@tempfile = copy_to_tempfile(initialized_file.path)
|
68
|
+
end
|
69
|
+
@tempfile.close
|
61
70
|
@tempfile
|
62
|
-
elsif initialized_tempfile
|
63
|
-
initialized_tempfile.open
|
64
|
-
@tempfile = initialized_tempfile
|
65
|
-
elsif initialized_data
|
66
|
-
tempfile = Tempfile.new('dragonfly')
|
67
|
-
tempfile.write(initialized_data)
|
68
|
-
tempfile.open
|
69
|
-
@tempfile = tempfile
|
70
|
-
elsif initialized_file
|
71
|
-
@tempfile = copy_to_tempfile(initialized_file)
|
72
71
|
end
|
73
72
|
end
|
74
|
-
|
75
|
-
|
73
|
+
|
74
|
+
def file(&block)
|
75
|
+
f = tempfile.open
|
76
|
+
if block_given?
|
77
|
+
ret = yield f
|
78
|
+
f.close
|
79
|
+
else
|
80
|
+
ret = f
|
81
|
+
end
|
82
|
+
ret
|
83
|
+
end
|
76
84
|
|
77
85
|
def path
|
78
86
|
tempfile.path
|
@@ -104,17 +112,10 @@ module Dragonfly
|
|
104
112
|
end
|
105
113
|
|
106
114
|
def each(&block)
|
107
|
-
|
108
|
-
|
109
|
-
while part = string_io.read(block_size)
|
110
|
-
yield part
|
111
|
-
end
|
112
|
-
else
|
113
|
-
tempfile.open
|
114
|
-
while part = tempfile.read(block_size)
|
115
|
+
to_io do |io|
|
116
|
+
while part = io.read(block_size)
|
115
117
|
yield part
|
116
118
|
end
|
117
|
-
tempfile.close
|
118
119
|
end
|
119
120
|
end
|
120
121
|
|
@@ -127,6 +128,14 @@ module Dragonfly
|
|
127
128
|
File.new(path)
|
128
129
|
end
|
129
130
|
|
131
|
+
def to_io(&block)
|
132
|
+
if initialized_data
|
133
|
+
StringIO.open(initialized_data, &block)
|
134
|
+
else
|
135
|
+
file(&block)
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
130
139
|
protected
|
131
140
|
|
132
141
|
attr_accessor :initialized_data, :initialized_tempfile, :initialized_file
|
@@ -134,16 +143,14 @@ module Dragonfly
|
|
134
143
|
private
|
135
144
|
|
136
145
|
def reset!
|
137
|
-
|
138
|
-
instance_variable_set(var, nil)
|
139
|
-
end
|
146
|
+
@data = @tempfile = @initialized_data = @initialized_file = @initialized_tempfile = nil
|
140
147
|
end
|
141
148
|
|
142
149
|
def initialize_from_object!(obj)
|
143
150
|
case obj
|
144
151
|
when TempObject
|
145
152
|
@initialized_data = obj.initialized_data
|
146
|
-
@initialized_tempfile = copy_to_tempfile(obj.initialized_tempfile) if obj.initialized_tempfile
|
153
|
+
@initialized_tempfile = copy_to_tempfile(obj.initialized_tempfile.path) if obj.initialized_tempfile
|
147
154
|
@initialized_file = obj.initialized_file
|
148
155
|
when String
|
149
156
|
@initialized_data = obj
|
@@ -153,20 +160,20 @@ module Dragonfly
|
|
153
160
|
@initialized_file = obj
|
154
161
|
self.name = File.basename(obj.path)
|
155
162
|
else
|
156
|
-
raise ArgumentError, "#{self.class.name} must be initialized with a String, a File or
|
163
|
+
raise ArgumentError, "#{self.class.name} must be initialized with a String, a File, a Tempfile, or another TempObject"
|
157
164
|
end
|
158
165
|
self.name = obj.original_filename if obj.respond_to?(:original_filename)
|
159
166
|
end
|
160
167
|
|
161
|
-
def copy_to_tempfile(file)
|
162
|
-
tempfile = Tempfile.new('dragonfly')
|
163
|
-
FileUtils.cp File.expand_path(file.path), tempfile.path
|
164
|
-
tempfile
|
165
|
-
end
|
166
|
-
|
167
168
|
def block_size
|
168
169
|
self.class.block_size
|
169
170
|
end
|
170
171
|
|
172
|
+
def copy_to_tempfile(path)
|
173
|
+
tempfile = Tempfile.new('dragonfly')
|
174
|
+
FileUtils.cp File.expand_path(path), tempfile.path
|
175
|
+
tempfile
|
176
|
+
end
|
177
|
+
|
171
178
|
end
|
172
179
|
end
|
@@ -6,18 +6,17 @@ describe Item do
|
|
6
6
|
|
7
7
|
describe "registering dragonfly apps" do
|
8
8
|
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
ActiveRecord::Base.register_dragonfly_app(:video, @app2)
|
13
|
-
end
|
14
|
-
|
9
|
+
let(:app1){ Dragonfly::App[:images] }
|
10
|
+
let(:app2){ Dragonfly::App[:videos] }
|
11
|
+
|
15
12
|
it "should return the mapping of apps to attributes" do
|
13
|
+
Dragonfly.active_record_macro(:image, app1)
|
14
|
+
Dragonfly.active_record_macro(:video, app2)
|
16
15
|
Item.class_eval do
|
17
16
|
image_accessor :preview_image
|
18
17
|
video_accessor :trailer_video
|
19
18
|
end
|
20
|
-
Item.dragonfly_apps_for_attributes.should == {:preview_image =>
|
19
|
+
Item.dragonfly_apps_for_attributes.should == {:preview_image => app1, :trailer_video => app2}
|
21
20
|
end
|
22
21
|
|
23
22
|
end
|
@@ -36,7 +35,7 @@ describe Item do
|
|
36
35
|
|
37
36
|
before(:each) do
|
38
37
|
@app = Dragonfly::App[:images]
|
39
|
-
|
38
|
+
Dragonfly.active_record_macro(:image, @app)
|
40
39
|
Item.class_eval do
|
41
40
|
image_accessor :preview_image
|
42
41
|
end
|
@@ -259,7 +258,7 @@ describe Item do
|
|
259
258
|
|
260
259
|
before(:all) do
|
261
260
|
@app = Dragonfly::App[:images]
|
262
|
-
|
261
|
+
Dragonfly.active_record_macro(:image, @app)
|
263
262
|
end
|
264
263
|
|
265
264
|
describe "validates_presence_of" do
|
@@ -417,7 +416,7 @@ describe Item do
|
|
417
416
|
def number_of_As(temp_object); temp_object.data.count('A'); end
|
418
417
|
end
|
419
418
|
@app.register_analyser(custom_analyser)
|
420
|
-
|
419
|
+
Dragonfly.active_record_macro(:image, @app)
|
421
420
|
Item.class_eval do
|
422
421
|
image_accessor :preview_image
|
423
422
|
end
|
@@ -549,7 +548,7 @@ describe Item do
|
|
549
548
|
describe "inheritance" do
|
550
549
|
before(:all) do
|
551
550
|
@app = Dragonfly::App[:images]
|
552
|
-
|
551
|
+
Dragonfly.active_record_macro(:image, @app)
|
553
552
|
Car.class_eval do
|
554
553
|
image_accessor :image
|
555
554
|
end
|
@@ -28,4 +28,17 @@ describe Dragonfly::Analysis::RMagickAnalyser do
|
|
28
28
|
@analyser.depth(@beach).should == 8
|
29
29
|
end
|
30
30
|
|
31
|
-
|
31
|
+
it "should return the format" do
|
32
|
+
@analyser.format(@beach).should == :png
|
33
|
+
end
|
34
|
+
|
35
|
+
%w(width height aspect_ratio number_of_colours depth format).each do |meth|
|
36
|
+
it "should throw unable_to_handle in #{meth.inspect} if it's not an image file" do
|
37
|
+
temp_object = Dragonfly::TempObject.new('blah')
|
38
|
+
lambda{
|
39
|
+
@analyser.send(meth, temp_object)
|
40
|
+
}.should throw_symbol(:unable_to_handle)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
require File.dirname(__FILE__) + '/../spec_helper'
|
2
|
+
|
3
|
+
class Testoast
|
4
|
+
include Dragonfly::BelongsToApp
|
5
|
+
end
|
6
|
+
|
7
|
+
describe Dragonfly::BelongsToApp do
|
8
|
+
|
9
|
+
before(:each) do
|
10
|
+
@object = Testoast.new
|
11
|
+
end
|
12
|
+
|
13
|
+
describe "when the app is not set" do
|
14
|
+
it "should raise an error if the app is accessed" do
|
15
|
+
lambda{
|
16
|
+
@object.app
|
17
|
+
}.should raise_error(Dragonfly::BelongsToApp::NotConfigured)
|
18
|
+
end
|
19
|
+
it "should say it's not set" do
|
20
|
+
@object.app_set?.should be_false
|
21
|
+
end
|
22
|
+
it "should still return a log" do
|
23
|
+
@object.log.should be_a(Logger)
|
24
|
+
end
|
25
|
+
it "should cache the log" do
|
26
|
+
@object.log.should == @object.log
|
27
|
+
end
|
28
|
+
it "should return the app's log if it's subsequently set" do
|
29
|
+
@object.log.should be_a(Logger)
|
30
|
+
@object.app = (app = mock('app', :log => mock))
|
31
|
+
@object.log.should == app.log
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
describe "when the app is set" do
|
36
|
+
before(:each) do
|
37
|
+
@app = mock('app', :log => mock)
|
38
|
+
@object.app = @app
|
39
|
+
end
|
40
|
+
|
41
|
+
it "should return the app" do
|
42
|
+
@object.app.should == @app
|
43
|
+
end
|
44
|
+
|
45
|
+
it "should return the app's log" do
|
46
|
+
@object.log.should == @app.log
|
47
|
+
end
|
48
|
+
|
49
|
+
it "should say it's set" do
|
50
|
+
@object.app_set?.should be_true
|
51
|
+
end
|
52
|
+
|
53
|
+
end
|
54
|
+
|
55
|
+
end
|