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