turing 0.0.7
Sign up to get free protection for your applications and to get access to all the features.
- data/CHANGES +138 -0
- data/COPYING +340 -0
- data/COPYRIGHT +19 -0
- data/README +26 -0
- data/Rakefile +75 -0
- data/TODO +1 -0
- data/lib/turing.rb +38 -0
- data/lib/turing/cgi_handler.rb +251 -0
- data/lib/turing/challenge.rb +214 -0
- data/lib/turing/image.rb +245 -0
- data/lib/turing/image_plugins/__squaring_helper.rb +66 -0
- data/lib/turing/image_plugins/black_squaring.rb +23 -0
- data/lib/turing/image_plugins/blending.rb +44 -0
- data/lib/turing/image_plugins/random_noise.rb +32 -0
- data/lib/turing/image_plugins/spiral.rb +56 -0
- data/lib/turing/image_plugins/white_squaring.rb +23 -0
- data/rdoc.jamis.rb +591 -0
- data/shared/README +9 -0
- data/shared/bgs/04.jpeg +0 -0
- data/shared/bgs/06.jpeg +0 -0
- data/shared/bgs/07.jpeg +0 -0
- data/shared/bgs/08.jpeg +0 -0
- data/shared/bgs/09.jpeg +0 -0
- data/shared/bgs/13.jpeg +0 -0
- data/shared/bgs/18.jpeg +0 -0
- data/shared/bgs/19.jpeg +0 -0
- data/shared/bgs/21.jpeg +0 -0
- data/shared/bgs/26.jpeg +0 -0
- data/shared/bgs/28.jpeg +0 -0
- data/shared/bgs/29.jpeg +0 -0
- data/shared/dictionary +411 -0
- data/shared/fonts/cour.ttf +0 -0
- data/shared/fonts/georgiai.ttf +0 -0
- data/shared/templates/challenge.rhtml +40 -0
- data/shared/templates/error.rhtml +32 -0
- data/shared/templates/success.rhtml +26 -0
- metadata +102 -0
data/lib/turing/image.rb
ADDED
@@ -0,0 +1,245 @@
|
|
1
|
+
#!/usr/bin/ruby
|
2
|
+
#
|
3
|
+
# Turing -- Ruby implementation of Captcha
|
4
|
+
#
|
5
|
+
# Copyright © 2005 Michal Šafránek <wejn@box.cz>
|
6
|
+
#
|
7
|
+
# This file is part of http://turing.rubyforge.org/
|
8
|
+
#
|
9
|
+
# See turing.rb in lib/ directory for license terms.
|
10
|
+
#
|
11
|
+
require 'rubygems'
|
12
|
+
require_gem 'gd2'
|
13
|
+
require 'turing'
|
14
|
+
require 'pathname'
|
15
|
+
|
16
|
+
# == Simple obfuscated image generator with plugin design
|
17
|
+
#
|
18
|
+
# Generates obfuscated image containing given word by using
|
19
|
+
# one of its registered plugins.
|
20
|
+
#
|
21
|
+
# For it's operation it requires gd2 gem, available from http://gd2.rubyforge.org/.
|
22
|
+
#
|
23
|
+
# Example of use:
|
24
|
+
# ti = Turing::Image.new(:width => 280, :height => 115)
|
25
|
+
# ti.generate(File.join(Dir.getwd, 'a.jpg'), "randomword")
|
26
|
+
# In this case we generate image using random plugin containing
|
27
|
+
# word "randomword". It is saved as <tt>`pwd`/a.jpg</tt>.
|
28
|
+
#
|
29
|
+
# === A word about plugins
|
30
|
+
# All plugins are "registered" by subclassing Turing::Image (which is
|
31
|
+
# implemented using <tt>self.inherited</tt>). It makes sense to subclass
|
32
|
+
# Turing::Image because that way you'll also get goodies like #write_string.
|
33
|
+
#
|
34
|
+
# Plugins are auto-loaded by require from <tt>lib/turing/image_plugins</tt>
|
35
|
+
# after Turing::Image is created but you're free to manually load any plugin
|
36
|
+
# you like.
|
37
|
+
#
|
38
|
+
# For inspiration on how to write new plugin visit any of the existing
|
39
|
+
# plugins in +image_plugins+ dir, minimal template would be:
|
40
|
+
# class MyCoolPlugin < Turing::Image
|
41
|
+
# def initialize(opts = {})
|
42
|
+
# super(opts)
|
43
|
+
# end
|
44
|
+
#
|
45
|
+
# def generate(img, word)
|
46
|
+
# write_string(img, 'cour.ttf', GD2::Color[0, 0, 0], word, 48)
|
47
|
+
# end
|
48
|
+
# end
|
49
|
+
class Turing::Image # {{{
|
50
|
+
# All content generating plugins
|
51
|
+
@@plugins = []
|
52
|
+
|
53
|
+
# Configure instance using options hash.
|
54
|
+
#
|
55
|
+
# *Warning*: Keys of this hash must be symbols.
|
56
|
+
#
|
57
|
+
# Accepted options:
|
58
|
+
# * +fontdir+: Directory containing .ttf fonts required by plugins. Default: gem's <tt>shared/fonts</tt> directory.
|
59
|
+
# * +bgdir+: Directory containing .jpeg files used as background by plugins. Default: gem's <tt>shared/bgs</tt> directory.
|
60
|
+
# * +outdir+: Output directory where to put image in case absolute path wasn't specified.
|
61
|
+
# * +width+: Width of the image.
|
62
|
+
# * +height+: Height of the image.
|
63
|
+
# * +method+: Use specified plugin instead of randomly selected. You must give class that implements +generate+ instance method. Default: nil.
|
64
|
+
def initialize(opts = {}) # {{{
|
65
|
+
raise ArgumentError, "Opts must be hash!" unless opts.kind_of? Hash
|
66
|
+
|
67
|
+
base = File.join(File.dirname(__FILE__), '..', '..', 'shared')
|
68
|
+
@options = {
|
69
|
+
:fontdir => File.join(base, 'fonts'),
|
70
|
+
:bgdir => File.join(base, 'bgs'),
|
71
|
+
:outdir => ENV["TMPDIR"] || '/tmp',
|
72
|
+
:width => 280,
|
73
|
+
:height => 115,
|
74
|
+
}
|
75
|
+
|
76
|
+
@options.merge!(opts)
|
77
|
+
end # }}}
|
78
|
+
|
79
|
+
# Generate image into +outname+ containing +word+ (using +method+).
|
80
|
+
#
|
81
|
+
# *Warning*: If you pass absolute filename as +outname+, +outdir+ will have no effect.
|
82
|
+
#
|
83
|
+
# *Warning*: There's no way to reset +method+ to random if it was specified upon instance creation.
|
84
|
+
def generate(outname, word, method = nil) # {{{
|
85
|
+
# select appropriate output plugin # {{{
|
86
|
+
m = method || @options[:method]
|
87
|
+
if m.nil?
|
88
|
+
if @@plugins.empty?
|
89
|
+
raise RuntimeError, "no generators plugins available!"
|
90
|
+
end
|
91
|
+
m = @@plugins[rand(@@plugins.size)]
|
92
|
+
end
|
93
|
+
unless m.instance_methods.include?("generate")
|
94
|
+
raise ArgumentError, "plugin #{m} doesn't have generate method"
|
95
|
+
end
|
96
|
+
# }}}
|
97
|
+
|
98
|
+
# prepend outname with outdir, if no absolute path given
|
99
|
+
unless Pathname.new(outname).absolute?
|
100
|
+
outname = File.join(@options[:outdir], outname)
|
101
|
+
end
|
102
|
+
|
103
|
+
img = GD2::Image.new(@options[:width], @options[:height])
|
104
|
+
|
105
|
+
img.draw do |canvas|
|
106
|
+
canvas.color = GD2::Color[255, 255, 255]
|
107
|
+
canvas.rectangle(0, 0, img.width - 1, img.height - 1, true)
|
108
|
+
end
|
109
|
+
|
110
|
+
m.new(@options).generate(img, word)
|
111
|
+
|
112
|
+
img.draw do |canvas|
|
113
|
+
canvas.color = GD2::Color[0, 0, 0]
|
114
|
+
canvas.rectangle(0, 0, img.width - 1, img.height - 1)
|
115
|
+
end
|
116
|
+
|
117
|
+
begin
|
118
|
+
File.open(outname, 'w') { |f| f.write(img.jpeg(90)) }
|
119
|
+
rescue
|
120
|
+
raise "Unable to write challenge: #{$!}"
|
121
|
+
end
|
122
|
+
|
123
|
+
true
|
124
|
+
end # }}}
|
125
|
+
|
126
|
+
# Private methods
|
127
|
+
private
|
128
|
+
|
129
|
+
# Write +string+ to +img+ using color +fg+ and +font+
|
130
|
+
# (with size +req_size+, if possible) at random coordinates
|
131
|
+
# and using random angle.
|
132
|
+
#
|
133
|
+
# Method checks bounding box so the string is guaranteed to stay
|
134
|
+
# within image's dimensions.
|
135
|
+
#
|
136
|
+
# May raise RuntimeError if it's completely impossible to find suitable
|
137
|
+
# fontsize for given dimensions.
|
138
|
+
def write_string(img, font, fg, string, req_size = nil) # {{{ # :doc:
|
139
|
+
# prepend fontname with fontdir, unless absolute path given
|
140
|
+
unless Pathname.new(font).absolute?
|
141
|
+
font = File.join(@options[:fontdir], font)
|
142
|
+
end
|
143
|
+
sizes = (16..42).to_a.reverse
|
144
|
+
turbulence = 5 # x%
|
145
|
+
mult = 0.85 * ((rand(turbulence*2 + 1) - turbulence) / 100.0 + 1.0)
|
146
|
+
|
147
|
+
# select angle
|
148
|
+
angle = -5 + rand(11)
|
149
|
+
|
150
|
+
# font size determination ...
|
151
|
+
chosen = nil # {{{
|
152
|
+
sizes.unshift(req_size) unless req_size.nil?
|
153
|
+
sizes.each do |size|
|
154
|
+
bounds = nil
|
155
|
+
begin
|
156
|
+
bounds = GD2::Font::TrueType.new(font, size).
|
157
|
+
bounding_rectangle(string, angle.degrees)
|
158
|
+
rescue
|
159
|
+
raise "Unable to detect bounding box: #{$!}"
|
160
|
+
end
|
161
|
+
|
162
|
+
minx, maxx = bounds.values.map { |x| x[0] }.sort.values_at(0, -1)
|
163
|
+
miny, maxy = bounds.values.map { |x| x[1] }.sort.values_at(0, -1)
|
164
|
+
|
165
|
+
bb_width = maxx - minx
|
166
|
+
bb_height = maxy - miny
|
167
|
+
|
168
|
+
if img.width * mult > bb_width && img.height * mult > bb_height
|
169
|
+
chosen = {
|
170
|
+
:size => size,
|
171
|
+
:width => bb_width,
|
172
|
+
:height => bb_height,
|
173
|
+
}
|
174
|
+
break
|
175
|
+
end
|
176
|
+
end # }}}
|
177
|
+
|
178
|
+
raise "Unable to select size" if chosen.nil?
|
179
|
+
|
180
|
+
x_base, y_base = 1, img.height / 2
|
181
|
+
|
182
|
+
x_offset = rand(((img.width - chosen[:width])*mult).to_i)
|
183
|
+
y_offset = rand((((img.height - chosen[:height]) / 2)*mult).to_i)
|
184
|
+
|
185
|
+
x = x_base + x_offset
|
186
|
+
y = y_base + y_offset
|
187
|
+
|
188
|
+
img.draw do |canvas|
|
189
|
+
canvas.move_to(x, y)
|
190
|
+
canvas.color = fg
|
191
|
+
canvas.font = GD2::Font::TrueType.new(font, chosen[:size])
|
192
|
+
canvas.text(string, angle.degrees)
|
193
|
+
end
|
194
|
+
|
195
|
+
img
|
196
|
+
end # }}}
|
197
|
+
|
198
|
+
# Record all plugins available
|
199
|
+
# (subclasses which implement class method generate)
|
200
|
+
def self.inherited(other) # {{{
|
201
|
+
(@@plugins ||= []) << other
|
202
|
+
end # }}}
|
203
|
+
end # }}}
|
204
|
+
|
205
|
+
# Load all image plugins ...
|
206
|
+
# {{{
|
207
|
+
plugin_dir = File.join(File.dirname(__FILE__), 'image_plugins')
|
208
|
+
if FileTest.directory?(plugin_dir)
|
209
|
+
Dir[File.join(plugin_dir, '*')].each do |f|
|
210
|
+
bn = File.basename(f)
|
211
|
+
begin
|
212
|
+
require 'turing/image_plugins/' + bn
|
213
|
+
rescue Object
|
214
|
+
raise RuntimeError, "Exception while loading plugin: #{bn}: #{$!}"
|
215
|
+
end
|
216
|
+
end
|
217
|
+
end
|
218
|
+
# }}}
|
219
|
+
|
220
|
+
# Test it out, when executed directly
|
221
|
+
if __FILE__ == $0 # {{{
|
222
|
+
ti = Turing::Image.new(:width => 280, :height => 115)
|
223
|
+
|
224
|
+
# debug loop
|
225
|
+
begin # {{{
|
226
|
+
loop do
|
227
|
+
dict_fn = File.join(File.dirname(__FILE__), '..', '..', 'shared',
|
228
|
+
'dictionary')
|
229
|
+
dict = File.open(dict_fn).readlines.map! { |x| x.chomp }
|
230
|
+
ti.generate(File.join(Dir.getwd, 'a.jpg'),
|
231
|
+
dict[rand(dict.size)])
|
232
|
+
pid = fork do
|
233
|
+
exec("xv", "a.jpg")
|
234
|
+
end
|
235
|
+
Process.detach(pid)
|
236
|
+
sleep 2
|
237
|
+
Process.kill(9, pid)
|
238
|
+
end
|
239
|
+
rescue Interrupt
|
240
|
+
ensure
|
241
|
+
File.unlink('a.jpg') rescue nil
|
242
|
+
end # }}}
|
243
|
+
end # }}}
|
244
|
+
|
245
|
+
# vim: set ts=4 sw=4 :
|
@@ -0,0 +1,66 @@
|
|
1
|
+
#
|
2
|
+
# Turing -- Ruby implementation of Captcha
|
3
|
+
#
|
4
|
+
# Copyright © 2005 Michal Šafránek <wejn@box.cz>
|
5
|
+
#
|
6
|
+
# This file is part of http://turing.rubyforge.org/
|
7
|
+
#
|
8
|
+
# See turing.rb in lib/ directory for license terms.
|
9
|
+
#
|
10
|
+
|
11
|
+
# == Squaring helper
|
12
|
+
# Skeleton for Turing::Image::*Squaring (mixin, actually).
|
13
|
+
module Turing::Image::SquaringHelper # {{{
|
14
|
+
# contract method - generate the challenge
|
15
|
+
def generate(img, word, bg = nil) # {{{
|
16
|
+
if bg.nil?
|
17
|
+
possible = Dir[File.join(@options[:bgdir], '*')]
|
18
|
+
bg = possible[rand(possible.size)]
|
19
|
+
else
|
20
|
+
unless FileTest.exists?(bg)
|
21
|
+
raise ArgumentError, "Wrong background!"
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
img_tmp = GD2::Image.load(File.open(bg, 'r'))
|
26
|
+
|
27
|
+
if img_tmp.width < img.width || img_tmp.height < img.height
|
28
|
+
raise "Background has insufficient dimensions"
|
29
|
+
end
|
30
|
+
|
31
|
+
img.merge_from(img_tmp, 0, 0, 0, 0, img.width, img.height, 30.percent)
|
32
|
+
|
33
|
+
# XXX: is this equivalent to "img_tmp.destroy" ?
|
34
|
+
img_tmp = nil
|
35
|
+
|
36
|
+
fg = GD2::Color[0, 0, 0]
|
37
|
+
|
38
|
+
write_string(img, 'cour.ttf', fg, word, 35)
|
39
|
+
|
40
|
+
raise RuntimeError, "no squaring color selected!" if @squaring_color.nil?
|
41
|
+
fg = @squaring_color
|
42
|
+
|
43
|
+
img.draw do |canvas|
|
44
|
+
canvas.color = fg
|
45
|
+
if rand(2).zero?
|
46
|
+
delta = word.size > 8 ? 6 : 4
|
47
|
+
0.step(img.width, delta) { |x| canvas.line(x, 0, x, img.height) }
|
48
|
+
0.step(img.height, delta) { |y| canvas.line(0, y, img.width, y) }
|
49
|
+
else
|
50
|
+
i = 0
|
51
|
+
while i <= img.width
|
52
|
+
canvas.line(i, 0, i, img.height)
|
53
|
+
i += 3 + rand(4)
|
54
|
+
end
|
55
|
+
|
56
|
+
i = 0
|
57
|
+
while i <= img.height
|
58
|
+
canvas.line(0, i, img.width, i)
|
59
|
+
i += 3 + rand(4)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end # }}}
|
64
|
+
end # }}}
|
65
|
+
|
66
|
+
# vim: set ts=4 sw=4 :
|
@@ -0,0 +1,23 @@
|
|
1
|
+
#
|
2
|
+
# Turing -- Ruby implementation of Captcha
|
3
|
+
#
|
4
|
+
# Copyright © 2005 Michal Šafránek <wejn@box.cz>
|
5
|
+
#
|
6
|
+
# This file is part of http://turing.rubyforge.org/
|
7
|
+
#
|
8
|
+
# See turing.rb in lib/ directory for license terms.
|
9
|
+
#
|
10
|
+
require 'turing/image_plugins/__squaring_helper'
|
11
|
+
|
12
|
+
# == BlackSquaring Turing test
|
13
|
+
# Blends text, background and makes square pattern over image.
|
14
|
+
class Turing::Image::BlackSquaring < Turing::Image # {{{
|
15
|
+
def initialize(opts = {})
|
16
|
+
super(opts)
|
17
|
+
@squaring_color = GD2::Color[0, 0, 0]
|
18
|
+
end
|
19
|
+
|
20
|
+
include Turing::Image::SquaringHelper
|
21
|
+
end # }}}
|
22
|
+
|
23
|
+
# vim: set ts=4 sw=4 :
|
@@ -0,0 +1,44 @@
|
|
1
|
+
#
|
2
|
+
# Turing -- Ruby implementation of Captcha
|
3
|
+
#
|
4
|
+
# Copyright © 2005 Michal Šafránek <wejn@box.cz>
|
5
|
+
#
|
6
|
+
# This file is part of http://turing.rubyforge.org/
|
7
|
+
#
|
8
|
+
# See turing.rb in lib/ directory for license terms.
|
9
|
+
#
|
10
|
+
|
11
|
+
# == Blending Turing test
|
12
|
+
# Blends text with background (highly imperfect for some backgrounds).
|
13
|
+
class Turing::Image::Blending < Turing::Image # {{{
|
14
|
+
# contract method - generate the challenge
|
15
|
+
def generate(img, word, bg = nil) # {{{
|
16
|
+
if bg.nil?
|
17
|
+
possible = Dir[File.join(@options[:bgdir], '*')]
|
18
|
+
bg = possible[rand(possible.size)]
|
19
|
+
else
|
20
|
+
unless FileTest.exists?(bg)
|
21
|
+
raise ArgumentError, "Wrong background!"
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
img_tmp = GD2::Image.load(File.open(bg, 'r'))
|
26
|
+
|
27
|
+
if img_tmp.width < img.width || img_tmp.height < img.height
|
28
|
+
raise "Background has insufficient dimensions"
|
29
|
+
end
|
30
|
+
|
31
|
+
img.copy_from(img_tmp, 0, 0, 0, 0, img.width, img.height)
|
32
|
+
|
33
|
+
# XXX: equivalent of img_tmp.destroy ?
|
34
|
+
img_tmp = nil
|
35
|
+
|
36
|
+
r = rand(32)
|
37
|
+
fg = GD2::Color[r, r, r, 40.percent]
|
38
|
+
|
39
|
+
write_string(img, 'georgiai.ttf', fg, word, 40)
|
40
|
+
|
41
|
+
end # }}}
|
42
|
+
end # }}}
|
43
|
+
|
44
|
+
# vim: set ts=4 sw=4 :
|
@@ -0,0 +1,32 @@
|
|
1
|
+
#
|
2
|
+
# Turing -- Ruby implementation of Captcha
|
3
|
+
#
|
4
|
+
# Copyright © 2005 Michal Šafránek <wejn@box.cz>
|
5
|
+
#
|
6
|
+
# This file is part of http://turing.rubyforge.org/
|
7
|
+
#
|
8
|
+
# See turing.rb in lib/ directory for license terms.
|
9
|
+
#
|
10
|
+
|
11
|
+
# == Random Noise turing test
|
12
|
+
# Generates random black noise, then text, then random white noise.
|
13
|
+
class Turing::Image::RandomNoise < Turing::Image # {{{
|
14
|
+
# contract method - generate the challenge
|
15
|
+
def generate(img, word, bg = nil) # {{{
|
16
|
+
fg = GD2::Color[255, 255, 255]
|
17
|
+
bg = GD2::Color[0, 0, 0]
|
18
|
+
|
19
|
+
0.upto(img.width * img.height * 0.4) do |i|
|
20
|
+
img[rand(img.width), rand(img.height)] = bg
|
21
|
+
end
|
22
|
+
|
23
|
+
write_string(img, 'georgiai.ttf', bg, word, 48)
|
24
|
+
|
25
|
+
quant = word.size > 7 ? 0.1 : 0.2
|
26
|
+
0.upto(img.width * img.height * quant) do |i|
|
27
|
+
img[rand(img.width), rand(img.height)] = fg
|
28
|
+
end
|
29
|
+
end # }}}
|
30
|
+
end # }}}
|
31
|
+
|
32
|
+
# vim: set ts=4 sw=4 :
|
@@ -0,0 +1,56 @@
|
|
1
|
+
#
|
2
|
+
# Turing -- Ruby implementation of Captcha
|
3
|
+
#
|
4
|
+
# Copyright © 2005 Michal Šafránek <wejn@box.cz>
|
5
|
+
#
|
6
|
+
# This file is part of http://turing.rubyforge.org/
|
7
|
+
# and is based on PHP captcha implementation "Session captcha"
|
8
|
+
# which can be found at http://sourceforge.net/projects/session-captcha/
|
9
|
+
#
|
10
|
+
# See turing.rb in lib/ directory for license terms.
|
11
|
+
#
|
12
|
+
|
13
|
+
# == Spiral Turing test
|
14
|
+
# Renders spirals plus text over them.
|
15
|
+
class Turing::Image::Spiral < Turing::Image # {{{
|
16
|
+
# contract method - generate the challenge
|
17
|
+
def generate(img, word) # {{{
|
18
|
+
fg = GD2::Color[185, 140, 140]
|
19
|
+
sfg = GD2::Color[225, 190, 190]
|
20
|
+
|
21
|
+
spiral(img, (img.width/3) + rand(img.width/3), rand(img.height), sfg)
|
22
|
+
|
23
|
+
write_string(img, 'cour.ttf', fg, word)
|
24
|
+
|
25
|
+
spiral(img, rand(img.width / 3), rand(img.height), sfg)
|
26
|
+
spiral(img, 2*(img.width/3) + rand(img.width/3), rand(img.height), sfg)
|
27
|
+
end # }}}
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
# renders spiral with center at [originx, originy] using color
|
32
|
+
def spiral(img, originx, originy, color) # {{{
|
33
|
+
theta = 1.0
|
34
|
+
thetac = 6.0
|
35
|
+
radius = 15.0
|
36
|
+
circles = 10.0
|
37
|
+
points = 35.0
|
38
|
+
img.draw do |canvas|
|
39
|
+
canvas.color = color
|
40
|
+
0.upto(circles * points - 1) do |i|
|
41
|
+
theta += thetac
|
42
|
+
rad = radius * (i.to_f / points)
|
43
|
+
x = rad * Math.cos(theta) + originx
|
44
|
+
y = rad * Math.sin(theta) + originy
|
45
|
+
theta += thetac
|
46
|
+
rad1 = radius * ((i.to_f + 1.to_f) / points)
|
47
|
+
x1 = rad1 * Math.cos(theta) + originx
|
48
|
+
y1 = rad1 * Math.sin(theta) + originy
|
49
|
+
canvas.line(x.round, y.round, x1.round, y1.round)
|
50
|
+
theta -= thetac
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end # }}}
|
54
|
+
end # }}}
|
55
|
+
|
56
|
+
# vim: set ts=4 sw=4 :
|