670e1bf19403fd173c5c89b97c9158f5845222ed
[swftools.git] / spec / edit_spec.py
1 #!/usr/bin/env python
2 import wx
3 import wx.lib.scrolledpanel as scrolled
4 import os
5 import re
6 import sys
7 import time
8 import thread
9 import traceback
10 import math
11
12 class Check:
13     def __init__(self, x,y):
14         self.x = x
15         self.y = y
16     def left(self):
17         return "pixel at (%d,%d)" % (self.x,self.y)
18     def right(self):
19         return ""
20     def verifies(self, model):
21         return True
22
23 class PixelColorCheck(Check):
24     def __init__(self, x,y, color):
25         Check.__init__(self,x,y)
26         self.color = color
27     def right(self):
28         return "is of color 0x%06x" % self.color
29     def verifies(self, model):
30         p = model.getPixel(self.x,self.y)
31         val = p[0]<<16 | p[1]<<8 | p[2]
32         return val == self.color
33
34 class TwoPixelCheck(Check): 
35     def __init__(self, x,y, x2,y2):
36         Check.__init__(self,x,y)
37         self.x2,self.y2 = x2,y2
38     def right(self):
39         return "pixel at (%d,%d)" % (self.x2,self.y2)
40
41 class PixelBrighterThan(TwoPixelCheck):
42     pass
43
44 class PixelDarkerThan(TwoPixelCheck):
45     pass
46
47 class PixelEqualTo(TwoPixelCheck):
48     pass
49
50 class AreaCheck(Check):
51     def __init__(self, x,y, x2,y2):
52         Check.__init__(self,x,y)
53         self.x2,self.y2 = x2,y2
54     def left(self):
55         return "area at (%d,%d,%d,%d)" % (self.x,self.y,self.x2,self.y2)
56
57 class AreaPlain(AreaCheck):
58     pass
59
60 class AreaNotPlain(AreaCheck):
61     pass
62
63 class AreaText(AreaCheck):
64     def __init__(self, x,y, x2, y2, text=""):
65         AreaCheck.__init__(self,x,y,x2,y2)
66         self.text = text
67
68 checktypes = [PixelColorCheck,PixelBrighterThan,PixelDarkerThan,PixelEqualTo,AreaPlain,AreaNotPlain,AreaText]
69
70 global TESTMODE
71
72 def convert_to_ppm(pdf):
73     print pdf
74     f = os.popen("pdfinfo "+pdf, "rb")
75     info = f.read()
76     f.close()
77     width,heigth = re.compile(r"Page size:\s*([0-9]+) x ([0-9]+) pts").findall(info)[0]
78     dpi = int(72.0 * 612 / int(width))
79     if TESTMODE:
80         os.system("pdf2swf -s zoom="+str(dpi)+" -p1 "+pdf+" -o test.swf")
81         os.system("swfrender --legacy test.swf -o test.png")
82         os.unlink("test.swf")
83         return "test.png"
84     else:
85         os.system("pdftoppm -r "+str(dpi)+" -f 1 -l 1 "+pdf+" test")
86     return "test-000001.ppm"
87
88
89 class Model:
90     def __init__(self, specfile, docfile, checks):
91         self.specfile = specfile
92         self.docfile = docfile
93         self.imgfilename = convert_to_ppm(self.docfile)
94         self.bitmap = wx.Bitmap(self.imgfilename)
95         self.image = wx.ImageFromBitmap(self.bitmap)
96         self.width = self.bitmap.GetWidth()
97         self.height = self.bitmap.GetHeight()
98         self.checks = checks
99         self.xy2check = {}
100         self.appendListeners = []
101         self.drawModeListeners = []
102         self.drawmode = PixelColorCheck
103
104     def close(self):
105         try: os.unlink(self.imgfilename)
106         except: pass
107
108     def getPixel(self,x,y):
109         return (self.image.GetRed(x,y), self.image.GetGreen(x,y), self.image.GetBlue(x,y))
110         
111     def setdrawmode(self, mode):
112         self.drawmode = mode
113         for f in self.drawModeListeners:
114             f()
115
116     def find(self, x, y):
117         return self.xy2check.get((x,y),None)
118
119     def delete(self, check):
120         i = self.checks.index(check)
121         del self.checks[i]
122         del self.xy2check[(check.x,check.y)]
123         for f in self.appendListeners:
124             f(check)
125
126     def append(self, check):
127         self.checks += [check]
128         self.xy2check[(check.x,check.y)] = check
129         for f in self.appendListeners:
130             f(check)
131
132     @staticmethod
133     def load(filename):
134         # convenience, allow to do "edit_spec.py file.pdf"
135         p,ext = os.path.splitext(filename)
136         if ext!=".rb":
137             path = p+".rb"
138             if not os.path.isfile(path):
139                 path = p+".spec.rb"
140                 if not os.path.isfile(path):
141                     print "No file %s found, creating new..." % path
142                     return Model(path, filename, [])
143         else:
144             path = filename
145
146         fi = open(path, "rb")
147         r_file = re.compile(r"^convert_file \"([^\"]*)\"")
148         r_pixelcolor = re.compile(r"^pixel_at\(([0-9]+),([0-9]+)\).should_be_of_color (0x[0-9a-fA-F]+)")
149         r_pixelbrighter = re.compile(r"^pixel_at\(([0-9]+),([0-9]+)\).should_be_brighter_than pixel_at\(([0-9]+),([0-9]+)\)")
150         r_pixeldarker = re.compile(r"^pixel_at\(([0-9]+),([0-9]+)\).should_be_darker_than pixel_at\(([0-9]+),([0-9]+)\)")
151         r_pixelequalto = re.compile(r"^pixel_at\(([0-9]+),([0-9]+)\).should_be_the_same_as pixel_at\(([0-9]+),([0-9]+)\)")
152         r_areaplain = re.compile(r"^area_at\(([0-9]+),([0-9]+),([0-9]+),([0-9]+)\).should_be_plain_colored")
153         r_areanotplain = re.compile(r"^area_at\(([0-9]+),([0-9]+),([0-9]+),([0-9]+)\).should_not_be_plain_colored")
154         r_areatext = re.compile(r"^area_at\(([0-9]+),([0-9]+),([0-9]+),([0-9]+)\).should_contain_text '(.*)'")
155         r_width = re.compile(r"^width.should be ([0-9]+)")
156         r_height = re.compile(r"^height.should be ([0-9]+)")
157         r_describe = re.compile(r"^describe \"pdf conversion\"")
158         r_header = re.compile(r"^require File.dirname")
159         r_end = re.compile(r"^end$")
160         filename = None
161         checks = []
162         for nr,line in enumerate(fi.readlines()):
163             line = line.strip()
164             if not line:
165                 continue
166             m = r_file.match(line)
167             if m: 
168                 if filename:
169                     raise Exception("can't load multi-file specs (in line %d)" % (nr+1))
170                 filename = m.group(1);
171                 model = Model(path, filename, [])
172                 continue
173             m = r_pixelcolor.match(line)
174             if m: model.append(PixelColorCheck(int(m.group(1)),int(m.group(2)),int(m.group(3),16)));continue
175             m = r_pixelbrighter.match(line)
176             if m: model.append(PixelBrighterThan(int(m.group(1)),int(m.group(2)),int(m.group(3)),int(m.group(4))));continue
177             m = r_pixeldarker.match(line)
178             if m: model.append(PixelDarkerThan(int(m.group(1)),int(m.group(2)),int(m.group(3)),int(m.group(4))));continue
179             m = r_pixelequalto.match(line)
180             if m: model.append(PixelEqualTo(int(m.group(1)),int(m.group(2)),int(m.group(3)),int(m.group(4))));continue
181             m = r_areaplain.match(line)
182             if m: model.append(AreaPlain(int(m.group(1)),int(m.group(2)),int(m.group(3)),int(m.group(4))));continue
183             m = r_areanotplain.match(line)
184             if m: model.append(AreaNotPlain(int(m.group(1)),int(m.group(2)),int(m.group(3)),int(m.group(4))));continue
185             m = r_areatext.match(line)
186             if m: model.append(AreaText(int(m.group(1)),int(m.group(2)),int(m.group(3)),int(m.group(4)),m.group(5)));continue
187             if r_width.match(line) or r_height.match(line):
188                 continue # compatibility
189             if r_describe.match(line) or r_end.match(line) or r_header.match(line):
190                 continue
191             print line
192             raise Exception("invalid file format: can't load this file (in line %d)" % (nr+1))
193
194         fi.close()
195         return model
196
197     def save(self):
198         path = self.specfile
199         fi = open(path, "wb")
200         fi.write("require File.dirname(__FILE__) + '/spec_helper'\n\ndescribe \"pdf conversion\" do\n")
201         fi.write("    convert_file \"%s\" do\n" % self.docfile)
202         for check in self.checks:
203             c = check.__class__
204             if c == PixelColorCheck:
205                 fi.write("        pixel_at(%d,%d).should_be_of_color 0x%06x\n" % (check.x,check.y,check.color))
206             elif c == PixelBrighterThan:
207                 fi.write("        pixel_at(%d,%d).should_be_brighter_than pixel_at(%d,%d)\n" % (check.x,check.y,check.x2,check.y2))
208             elif c == PixelDarkerThan:
209                 fi.write("        pixel_at(%d,%d).should_be_darker_than pixel_at(%d,%d)\n" % (check.x,check.y,check.x2,check.y2))
210             elif c == PixelEqualTo:
211                 fi.write("        pixel_at(%d,%d).should_be_the_same_as pixel_at(%d,%d)\n" % (check.x,check.y,check.x2,check.y2))
212             elif c == AreaPlain:
213                 fi.write("        area_at(%d,%d,%d,%d).should_be_plain_colored\n" % (check.x,check.y,check.x2,check.y2))
214             elif c == AreaNotPlain:
215                 fi.write("        area_at(%d,%d,%d,%d).should_not_be_plain_colored\n" % (check.x,check.y,check.x2,check.y2))
216             elif c == AreaText:
217                 fi.write("        area_at(%d,%d,%d,%d).should_contain_text '%s'\n" % (check.x,check.y,check.x2,check.y2,check.text))
218         fi.write("    end\n")
219         fi.write("end\n")
220         fi.close()
221
222 class ZoomWindow(wx.Window):
223     def __init__(self, parent, model):
224         wx.Window.__init__(self, parent, pos=(0,0), size=(15*32,15*32))
225         self.model = model
226         self.Bind(wx.EVT_PAINT, self.OnPaint)
227         self.x = 0
228         self.y = 0
229
230     def setpos(self,x,y):
231         self.x = x
232         self.y = y
233         self.Refresh()
234     
235     def OnPaint(self, event):
236         dc = wx.PaintDC(self)
237         self.Draw(dc)
238     
239     def Draw(self,dc=None):
240         if not dc:
241             dc = wx.ClientDC(self)
242         dc.SetBackground(wx.Brush((0,0,0)))
243         color = (0,255,0)
244         for yy in range(15):
245             y = self.y+yy-8
246             for xx in range(15):
247                 x = self.x+xx-8
248                 if 0<=x<self.model.width and 0<=y<self.model.height:
249                     color = self.model.getPixel(x,y)
250                 else:
251                     color = (0,0,0)
252                 dc.SetPen(wx.Pen(color))
253                 m = self.model.find(x,y)
254                 dc.SetBrush(wx.Brush(color))
255                 dc.DrawRectangle(32*xx, 32*yy, 32, 32)
256
257                 if (xx==8 and yy==8) or m:
258                     dc.SetPen(wx.Pen((0, 0, 0)))
259                     dc.DrawRectangleRect((32*xx, 32*yy, 32, 32))
260                     dc.DrawRectangleRect((32*xx+2, 32*yy+2, 28, 28))
261
262                     if (xx==8 and yy==8):
263                         dc.SetPen(wx.Pen((255, 255, 255)))
264                     else:
265                         dc.SetPen(wx.Pen((255, 255, 0)))
266                     dc.DrawRectangleRect((32*xx+1, 32*yy+1, 30, 30))
267                     #dc.SetPen(wx.Pen((0, 0, 0)))
268                     #dc.SetPen(wx.Pen(color))
269
270 class ImageWindow(wx.Window):
271     def __init__(self, parent, model, zoom):
272         wx.Window.__init__(self, parent)
273         self.model = model
274         self.Bind(wx.EVT_PAINT, self.OnPaint)
275         self.Bind(wx.EVT_MOUSE_EVENTS, self.OnMouse)
276         self.SetSize((model.width, model.height))
277         self.zoom = zoom
278         self.x = 0
279         self.y = 0
280         self.lastx = 0
281         self.lasty = 0
282         self.firstclick = None
283         self.model.drawModeListeners += [self.reset]
284
285     def reset(self):
286         self.firstclick = None
287
288     def OnMouseClick(self, event):
289         x = min(max(event.X, 0), self.model.width-1)
290         y = min(max(event.Y, 0), self.model.height-1)
291         if self.model.drawmode == PixelColorCheck:
292             check = self.model.find(x,y)
293             if check:
294                 self.model.delete(check)
295             else:
296                 p = self.model.getPixel(x,y)
297                 color = p[0]<<16|p[1]<<8|p[2]
298                 self.model.append(PixelColorCheck(x,y,color))
299         else:
300             if not self.firstclick:
301                 self.firstclick = (x,y)
302             else:
303                 x1,y1 = self.firstclick
304                 self.model.append(self.model.drawmode(x1,y1,x,y))
305                 self.firstclick = None
306
307         self.Refresh()
308
309     def OnMouse(self, event):
310         if event.LeftIsDown():
311             return self.OnMouseClick(event)
312         lastx = self.x
313         lasty = self.y
314         self.x = min(max(event.X, 0), self.model.width-1)
315         self.y = min(max(event.Y, 0), self.model.height-1)
316         if lastx!=self.x or lasty!=self.y:
317             self.zoom.setpos(self.x,self.y)
318             self.Refresh()
319
320     def OnPaint(self, event):
321         dc = wx.PaintDC(self)
322         self.Draw(dc)
323
324     def Draw(self,dc=None):
325         if not dc:
326             dc = wx.ClientDC(self)
327       
328         dc.SetBackground(wx.Brush((0,0,0)))
329         dc.DrawBitmap(self.model.bitmap, 0, 0, False)
330
331         red = wx.Pen((192,0,0),2)
332
333         if self.firstclick:
334             x,y = self.firstclick
335             if AreaCheck in self.model.drawmode.__bases__:
336                 dc.SetBrush(wx.TRANSPARENT_BRUSH)
337                 dc.DrawRectangle(x,y,self.x-x,self.y-y)
338                 dc.SetBrush(wx.WHITE_BRUSH)
339             elif TwoPixelCheck in self.model.drawmode.__bases__:
340                 x,y = self.firstclick
341                 dc.DrawLine(x,y,self.x,self.y)
342
343         for check in self.model.checks:
344             if TESTMODE and not check.verifies(model):
345                 dc.SetPen(red)
346             else:
347                 dc.SetPen(wx.BLACK_PEN)
348             if AreaCheck in check.__class__.__bases__:
349                 dc.SetBrush(wx.TRANSPARENT_BRUSH)
350                 dc.DrawRectangle(check.x,check.y,check.x2-check.x,check.y2-check.y)
351                 dc.SetBrush(wx.WHITE_BRUSH)
352             else:
353                 x = check.x
354                 y = check.y
355                 l = 0
356                 for r in range(10):
357                     r = (r+1)*3.141526/5
358                     dc.DrawLine(x+10*math.sin(l), y+10*math.cos(l), x+10*math.sin(r), y+10*math.cos(r))
359                     l = r
360                 dc.DrawLine(x,y,x+1,y)
361                 if TwoPixelCheck in check.__class__.__bases__:
362                     dc.DrawLine(x,y,check.x2,check.y2)
363             dc.SetPen(wx.BLACK_PEN)
364
365 class EntryPanel(scrolled.ScrolledPanel):
366     def __init__(self, parent, model):
367         self.model = model
368         scrolled.ScrolledPanel.__init__(self, parent, -1, size=(480,10*32), pos=(0,16*32))
369         self.id2check = {}
370         self.append(None)
371
372     def delete(self, event):
373         self.model.delete(self.id2check[event.Id])
374
375     def text(self, event):
376         check = self.id2check[event.GetEventObject().Id]
377         check.text = event.GetString()
378
379     def append(self, check):
380         self.vbox = wx.BoxSizer(wx.VERTICAL)
381         self.vbox.Add(wx.StaticLine(self, -1, size=(500,-1)), 0, wx.ALL, 5)
382         for nr,check in enumerate(model.checks):
383             hbox = wx.BoxSizer(wx.HORIZONTAL) 
384             
385             button = wx.Button(self, label="X", size=(32,32))
386             hbox.Add(button, 0, wx.ALIGN_CENTER_VERTICAL)
387             hbox.Add((16,16))
388             self.id2check[button.Id] = check
389             self.Bind(wx.EVT_BUTTON, self.delete, button)
390
391             def setdefault(lb,nr):
392                 lb.Select(nr);self.Bind(wx.EVT_CHOICE, lambda lb:lb.EventObject.Select(nr), lb)
393
394             desc = wx.StaticText(self, -1, check.left())
395
396             hbox.Add(desc, 0, wx.ALIGN_LEFT|wx.ALIGN_CENTER_VERTICAL|wx.ALL, 5)
397             if isinstance(check,AreaCheck):
398                 choices = ["is plain","is not plain","contains text"]
399                 lb = wx.Choice(self, -1, (100, 50), choices = choices)
400                 hbox.Add(lb, 0, wx.ALIGN_LEFT|wx.ALL, 5)
401                 if isinstance(check, AreaPlain):
402                     setdefault(lb,0)
403                 elif isinstance(check, AreaNotPlain):
404                     setdefault(lb,1)
405                 else:
406                     setdefault(lb,2)
407                     tb = wx.TextCtrl(self, -1, check.text, size=(100, 25))
408                     self.id2check[tb.Id] = check
409                     self.Bind(wx.EVT_TEXT, self.text, tb)
410
411                     hbox.Add(tb, 0, wx.ALIGN_LEFT|wx.ALL, 5)
412             elif isinstance(check,TwoPixelCheck):
413                 choices = ["is the same as","is brighter than","is darker than"]
414                 lb = wx.Choice(self, -1, (100, 50), choices = choices)
415                 hbox.Add(lb, 0, wx.ALIGN_LEFT|wx.ALL, 5)
416                 if isinstance(check, PixelEqualTo):
417                     setdefault(lb,0)
418                 elif isinstance(check, PixelBrighterThan):
419                     setdefault(lb,1)
420                 elif isinstance(check, PixelDarkerThan):
421                     setdefault(lb,2)
422             elif isinstance(check,PixelColorCheck):
423                 # TODO: color control
424                 pass
425             
426             desc2 = wx.StaticText(self, -1, check.right())
427             hbox.Add(desc2, 0, wx.ALIGN_LEFT|wx.ALIGN_CENTER_VERTICAL|wx.ALL, 5)
428             
429             self.vbox.Add(hbox)
430             self.vbox.Add(wx.StaticLine(self, -1, size=(500,-1)), 0, wx.ALL, 5)
431         self.end = wx.Window(self, -1, size=(1,1))
432         self.vbox.Add(self.end)
433         self.SetSizer(self.vbox)
434         self.SetAutoLayout(1)
435         self.SetupScrolling(scrollToTop=False)
436         self.ScrollChildIntoView(self.end)
437
438 class ToolChoiceWindow(wx.Choice):
439     def __init__(self, parent, model):
440         self.model = model
441         self.choices = [c.__name__ for c in checktypes]
442         wx.Choice.__init__(self, parent, -1, (100,50), choices = self.choices)
443         self.Bind(wx.EVT_CHOICE, self.choice)
444     def choice(self, event):
445         self.model.setdrawmode(eval(self.choices[self.GetCurrentSelection()]))
446
447 class MainFrame(wx.Frame):
448     def __init__(self, application, model):
449         wx.Frame.__init__(self, None, -1, style = wx.DEFAULT_FRAME_STYLE, pos=(50,50))
450         self.application = application
451       
452         self.toolchoice = ToolChoiceWindow(self, model)
453         self.toolchoice.Show()
454         self.zoom = ZoomWindow(self, model)
455         self.zoom.Show()
456         self.image = ImageWindow(self, model, self.zoom)
457         self.image.Show()
458         self.entries = EntryPanel(self, model)
459         self.entries.Show()
460         self.createToolbar()
461         model.appendListeners += [self.append]
462         
463         hbox = wx.BoxSizer(wx.HORIZONTAL)
464         hbox.Add(self.zoom)
465         hbox.Add((16,16))
466         vbox = wx.BoxSizer(wx.VERTICAL)
467         vbox.Add(self.toolchoice)
468         vbox.Add(self.image)
469         hbox.Add(vbox)
470         #vbox.Add(self.entries)
471         self.SetSizer(hbox)
472         self.SetAutoLayout(True)
473         hbox.Fit(self)
474
475     def append(self, new):
476         self.entries.Hide()
477         e = self.entries
478         del self.entries
479         e.Destroy()
480         self.entries = EntryPanel(self, model)
481         self.entries.Show()
482
483     def createToolbar(self):
484         tsize = (16,16)
485         self.toolbar = self.CreateToolBar(wx.TB_HORIZONTAL | wx.NO_BORDER | wx.TB_FLAT)
486         self.toolbar.AddSimpleTool(wx.ID_CUT,
487                                    wx.ArtProvider.GetBitmap(wx.ART_CROSS_MARK, wx.ART_TOOLBAR, tsize),
488                                    "Remove")
489         self.toolbar.AddSimpleTool(wx.ID_SETUP,
490                                    wx.ArtProvider.GetBitmap(wx.ART_TIP, wx.ART_TOOLBAR, tsize),
491                                    "Add")
492         self.toolbar.AddSimpleTool(wx.ID_SETUP,
493                                    wx.ArtProvider.GetBitmap(wx.ART_GO_UP, wx.ART_TOOLBAR, tsize),
494                                    "Add")
495         #self.toolbar.AddSeparator()
496         self.toolbar.Realize()
497
498
499 if __name__ == "__main__":
500     from optparse import OptionParser
501     global TESTMODE
502     parser = OptionParser()
503     parser.add_option("-t", "--test", dest="test", help="Test checks against swf", action="store_true")
504     (options, args) = parser.parse_args()
505
506     TESTMODE = options.test
507
508     app = wx.PySimpleApp()
509     model = Model.load(args[0])
510
511     main = MainFrame(app, model)
512     main.Show()
513     app.MainLoop()
514     model.save()
515     model.close()