Localisation updates for extension messages from Betawiki (2008-01-10 0:38 CET)
[toast/cookiecaptcha.git] / captcha.py
1 #!/usr/bin/python
2 #
3 # Script to generate distorted text images for a captcha system.
4 #
5 # Copyright (C) 2005 Neil Harris
6 #
7 # This program is free software; you can redistribute it and/or modify
8 # it under the terms of the GNU General Public License as published by
9 # the Free Software Foundation; either version 2 of the License, or
10 # (at your option) any later version.
11 #
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU General Public License for more details.
16 #
17 # You should have received a copy of the GNU General Public License along
18 # with this program; if not, write to the Free Software Foundation, Inc.,
19 # 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
20 # http://www.gnu.org/copyleft/gpl.html
21 #
22 # Further tweaks by Brion Vibber <brion@pobox.com>:
23 # 2006-01-26: Add command-line options for the various parameters
24 # 2007-02-19: Add --dirs param for hash subdirectory splits
25 # Tweaks by Greg Sabino Mullane <greg@turnstep.com>:
26 # 2008-01-06: Add regex check to skip words containing other than a-z
27
28 import random
29 import Image
30 import ImageFont
31 import ImageDraw
32 import ImageEnhance
33 import ImageOps
34 import math, string, md5
35 import getopt
36 import os
37 import sys
38 import re
39
40 # Does X-axis wobbly copy, sandwiched between two rotates
41 def wobbly_copy(src, wob, col, scale, ang):
42         x, y = src.size
43         f = random.uniform(4*scale, 5*scale)
44         p = random.uniform(0, math.pi*2)
45         rr = ang+random.uniform(-30, 30) # vary, but not too much
46         int_d = Image.new('RGB', src.size, 0) # a black rectangle
47         rot = src.rotate(rr, Image.BILINEAR)
48         # Do a cheap bounding-box op here to try to limit work below
49         bbx = rot.getbbox()
50         if bbx == None:
51                 print "whoops"
52                 return src
53         else:
54                 l, t, r, b= bbx
55         # and only do lines with content on
56         for i in range(t, b+1):
57                 # Drop a scan line in
58                 xoff = int(math.sin(p+(i*f/y))*wob)
59                 xoff += int(random.uniform(-wob*0.5, wob*0.5))
60                 int_d.paste(rot.crop((0, i, x, i+1)), (xoff, i))
61         # try to stop blurring from building up
62         int_d = int_d.rotate(-rr, Image.BILINEAR)
63         enh = ImageEnhance.Sharpness(int_d)
64         return enh.enhance(2)
65
66
67 def gen_captcha(text, fontname, fontsize, file_name):
68         """Generate a captcha image"""
69         # white text on a black background
70         bgcolor = 0x0
71         fgcolor = 0xffffff
72         # create a font object 
73         font = ImageFont.truetype(fontname,fontsize)
74         # determine dimensions of the text
75         dim = font.getsize(text)
76         # create a new image significantly larger that the text
77         edge = max(dim[0], dim[1]) + 2*min(dim[0], dim[1])
78         im = Image.new('RGB', (edge, edge), bgcolor)
79         d = ImageDraw.Draw(im)
80         x, y = im.size
81         # add the text to the image
82         d.text((x/2-dim[0]/2, y/2-dim[1]/2), text, font=font, fill=fgcolor)
83         k = 3
84         wob = 0.20*dim[1]/k
85         rot = 45
86         # Apply lots of small stirring operations, rather than a few large ones
87         # in order to get some uniformity of treatment, whilst
88         # maintaining randomness
89         for i in range(k):
90                 im = wobbly_copy(im, wob, bgcolor, i*2+3, rot+0)
91                 im = wobbly_copy(im, wob, bgcolor, i*2+1, rot+45)
92                 im = wobbly_copy(im, wob, bgcolor, i*2+2, rot+90)
93                 rot += 30
94         
95         # now get the bounding box of the nonzero parts of the image
96         bbox = im.getbbox()
97         bord = min(dim[0], dim[1])/4 # a bit of a border
98         im = im.crop((bbox[0]-bord, bbox[1]-bord, bbox[2]+bord, bbox[3]+bord))
99         # and turn into black on white
100         im = ImageOps.invert(im)
101                 
102         # save the image, in format determined from filename
103         im.save(file_name)
104
105 def gen_subdir(basedir, hash, levels):
106         """Generate a subdirectory path out of the first _levels_
107         characters of _hash_, and ensure the directories exist
108         under _basedir_."""
109         subdir = None
110         for i in range(0, levels):
111                 char = hash[i]
112                 if subdir:
113                         subdir = os.path.join(subdir, char)
114                 else:
115                         subdir = char
116                 fulldir = os.path.join(basedir, subdir)
117                 if not os.path.exists(fulldir):
118                         os.mkdir(fulldir)
119         return subdir
120
121 def try_pick_word(words, blacklist, verbose):
122         word1 = words[random.randint(0,len(words)-1)]
123         word2 = words[random.randint(0,len(words)-1)]
124         word = word1+word2
125         if verbose:
126                 print "word is %s" % word
127         r = re.compile('[^a-z]');
128         if r.search(word):
129                 print "skipping word pair '%s' because it contains non-alphabetic characters" % word
130                 return None
131
132         for naughty in blacklist:
133                 if naughty in word:
134                         if verbose:
135                                 print "skipping word pair '%s' because it contains blacklisted word '%s'" % (word, naughty)
136                         return None
137         return word
138
139 def pick_word(words, blacklist, verbose):
140         while True:
141                 word = try_pick_word(words, blacklist, verbose)
142                 if word:
143                         return word
144
145 def read_wordlist(filename):
146         return [string.lower(x.strip()) for x in open(wordlist).readlines()]
147
148 if __name__ == '__main__':
149         """This grabs random words from the dictionary 'words' (one
150         word per line) and generates a captcha image for each one,
151         with a keyed salted hash of the correct answer in the filename.
152         
153         To check a reply, hash it in the same way with the same salt and
154         secret key, then compare with the hash value given.
155         """
156         font = "VeraBd.ttf"
157         wordlist = "awordlist.txt"
158         blacklistfile = None
159         key = "CHANGE_THIS_SECRET!"
160         output = "."
161         count = 20
162         fill = 0
163         dirs = 0
164         verbose = False
165         
166         opts, args = getopt.getopt(sys.argv[1:], "", ["font=", "wordlist=", "blacklist=", "key=", "output=", "count=", "fill=", "dirs=", "verbose"])
167         for o, a in opts:
168                 if o == "--font":
169                         font = a
170                 if o == "--wordlist":
171                         wordlist = a
172                 if o == "--blacklist":
173                         blacklistfile = a
174                 if o == "--key":
175                         key = a
176                 if o == "--output":
177                         output = a
178                 if o == "--count":
179                         count = int(a)
180                 if o == "--fill":
181                         fill = int(a)
182                 if o == "--dirs":
183                         dirs = int(a)
184                 if o == "--verbose":
185                         verbose = True
186         
187         if fill:
188                 # Option processing order is not guaranteed, so count the output
189                 # files after...
190                 count = max(0, fill - len(os.listdir(output)))
191         
192         words = read_wordlist(wordlist)
193         words = [x for x in words
194                 if len(x) <= 5 and len(x) >= 4 and x[0] != "f"
195                 and x[0] != x[1] and x[-1] != x[-2]
196                 and (not "'" in x)]
197         
198         if blacklistfile:
199                 blacklist = read_wordlist(blacklistfile)
200         else:
201                 blacklist = []
202         
203         for i in range(count):
204                 word = pick_word(words, blacklist, verbose)
205                 salt = "%08x" % random.randrange(2**32)
206                 # 64 bits of hash is plenty for this purpose
207                 hash = md5.new(key+salt+word+key+salt).hexdigest()[:16]
208                 filename = "image_%s_%s.png" % (salt, hash)
209                 if dirs:
210                         subdir = gen_subdir(output, hash, dirs)
211                         filename = os.path.join(subdir, filename)
212                 if verbose:
213                         print filename
214                 gen_captcha(word, font, 40, os.path.join(output, filename))