1 # This file is part of MyPaint.
2 # Copyright (C) 2007-2008 by Martin Renold <martinxyz@gmx.ch>
4 # This program is free software; you can redistribute it and/or modify
5 # it under the terms of the GNU General Public License as published by
6 # the Free Software Foundation; either version 2 of the License, or
7 # (at your option) any later version.
12 - is a list of motion events
13 - knows everything needed to draw itself (brush settings / initial brush state)
14 - has fixed brush settings (only brush states can change during a stroke)
17 - is a container of several strokes (strokes can be removed)
18 - can be rendered as a whole
19 - can contain cache bitmaps, so it doesn't have to retrace all strokes all the time
22 - contains several layers
23 - knows the active layer and the current brush
24 - manages the undo history
25 - must be altered via undo/redo commands (except painting)
28 import os, zipfile, tempfile, time
30 import xml.etree.ElementTree as ET
33 import helpers, tiledsurface, pixbufsurface, backgroundsurface
34 import command, stroke, layer
35 import brush # FIXME: the brush module depends on gtk and everything, but we only need brush_lowlevel
38 class SaveLoadError(Exception):
39 """Expected errors on loading or saving, like missing permissions or non-existing files."""
44 This is the "model" in the Model-View-Controller design.
45 (The "view" would be ../gui/tileddrawwidget.py.)
46 It represenst everything that the user would want to save.
49 The "controller" mostly in drawwindow.py.
50 It should be possible to use it without any GUI attached.
52 Undo/redo is part of the model. The whole undo/redo stack can be
53 saved to disk (planned) and can be used to reconstruct
56 # Please note the following difficulty:
58 # Most of the time there is an unfinished (but already rendered)
59 # stroke pending, which has to be turned into a command.Action
60 # or discarded as empty before any other action is possible.
62 # TODO: the document should allow to "playback" (redo) a stroke
63 # partially and examine its timing (realtime playback / calculate
64 # total painting time) ?using half-done commands?
67 self.brush = brush.Brush_Lowlevel()
69 self.canvas_observers = []
70 self.stroke_observers = [] # callback arguments: stroke, brush (brush is a temporary read-only convenience object)
73 def clear(self, init=False):
76 bbox = self.get_bbox()
77 # throw everything away, including undo stack
78 self.command_stack = command.CommandStack()
79 self.set_background((255, 255, 255))
83 # disallow undo of the first layer
84 self.command_stack.clear()
85 self.unsaved_painting_time = 0.0
88 for f in self.canvas_observers:
91 def get_current_layer(self):
92 return self.layers[self.layer_idx]
93 layer = property(get_current_layer)
95 def split_stroke(self):
96 if not self.stroke: return
97 self.stroke.stop_recording()
98 if not self.stroke.empty:
99 self.command_stack.do(command.Stroke(self, self.stroke, self.snapshot_before_stroke))
100 del self.snapshot_before_stroke
101 self.unsaved_painting_time += self.stroke.total_painting_time
102 for f in self.stroke_observers:
103 f(self.stroke, self.brush)
106 def select_layer(self, idx):
107 self.do(command.SelectLayer(self, idx))
109 def clear_layer(self):
110 self.do(command.ClearLayer(self))
112 def stroke_to(self, dtime, x, y, pressure):
114 self.stroke = stroke.Stroke()
115 self.stroke.start_recording(self.brush)
116 self.snapshot_before_stroke = self.layer.save_snapshot()
117 self.stroke.record_event(dtime, x, y, pressure)
120 l.surface.begin_atomic()
121 split = self.brush.stroke_to (l.surface, x, y, pressure, dtime)
122 l.surface.end_atomic()
127 def layer_modified_cb(self, *args):
128 # for now, any layer modification is assumed to be visible
129 for f in self.canvas_observers:
132 def invalidate_all(self):
133 for f in self.canvas_observers:
139 cmd = self.command_stack.undo()
140 if not cmd or not cmd.automatic_undo:
146 cmd = self.command_stack.redo()
147 if not cmd or not cmd.automatic_undo:
152 self.command_stack.do(cmd)
154 def get_last_command(self):
156 return self.command_stack.get_last_command()
158 def set_brush(self, brush):
160 self.brush.copy_settings_from(brush)
164 for layer in self.layers:
165 # OPTIMIZE: only visible layers...
166 # careful: currently saving assumes that all layers are included
167 bbox = layer.surface.get_bbox()
168 res.expandToIncludeRect(bbox)
171 def blit_tile_into(self, dst, tx, ty, layers=None, background=None):
174 if background is None:
175 background = self.background
177 background.blit_tile_into(dst, tx, ty)
180 surface = layer.surface
181 surface.composite_tile_over(dst, tx, ty, layer.opacity)
183 def add_layer(self, insert_idx):
184 self.do(command.AddLayer(self, insert_idx))
186 def remove_layer(self):
187 self.do(command.RemoveLayer(self))
189 def merge_layer(self, dst_idx):
190 self.do(command.MergeLayer(self, dst_idx))
192 def load_layer_from_pixbuf(self, pixbuf, x=0, y=0):
193 arr = helpers.gdkpixbuf2numpy(pixbuf)
194 self.do(command.LoadLayer(self, arr, x, y))
196 def set_layer_opacity(self, opacity):
197 cmd = self.get_last_command()
198 if isinstance(cmd, command.SetLayerOpacity):
200 self.do(command.SetLayerOpacity(self, opacity))
202 def set_background(self, obj):
203 # This is not an undoable action. One reason is that dragging
204 # on the color chooser would get tons of undo steps.
206 if not isinstance(obj, backgroundsurface.Background):
207 obj = backgroundsurface.Background(obj)
208 self.background = obj
210 self.invalidate_all()
212 def get_background_pixbuf(self):
213 pixbuf = gdk.Pixbuf(gdk.COLORSPACE_RGB, False, 8, N, N)
214 arr = helpers.gdkpixbuf2numpy(pixbuf)
215 arr[:,:,:] = self.background
218 def load_from_pixbuf(self, pixbuf):
220 self.load_layer_from_pixbuf(pixbuf)
222 def is_layered(self):
224 for l in self.layers:
225 if not l.surface.is_empty():
229 def save(self, filename, **kwargs):
230 #if the file doesnt allready exist, we need to check permissions on the directory
231 if os.path.isfile(filename):
234 path = os.path.dirname(filename)
237 if not os.access(path,os.W_OK):
238 raise SaveLoadError, 'You do not have the necessary permissions to save file: ' + repr(filename)
239 trash, ext = os.path.splitext(filename)
240 ext = ext.lower().replace('.', '')
241 save = getattr(self, 'save_' + ext, self.unsupported)
242 save(filename, **kwargs)
243 self.unsaved_painting_time = 0.0
245 def load(self, filename):
246 if not os.path.isfile(filename):
247 raise SaveLoadError, 'File does not exist: ' + repr(filename)
248 if not os.access(filename,os.R_OK):
249 raise SaveLoadError, 'You do not have the necessary permissions to open file: ' + repr(filename)
250 trash, ext = os.path.splitext(filename)
251 ext = ext.lower().replace('.', '')
252 load = getattr(self, 'load_' + ext, self.unsupported)
254 self.command_stack.clear()
255 self.unsaved_painting_time = 0.0
257 def unsupported(self, filename):
258 raise SaveLoadError, 'Unknown file format extension: ' + repr(filename)
260 def render_as_pixbuf(self, *args):
261 return pixbufsurface.render_as_pixbuf(self, *args)
263 def save_png(self, filename, compression=2, alpha=False):
265 tmp_layer = layer.Layer()
266 for l in self.layers:
267 l.merge_into(tmp_layer)
268 pixbuf = tmp_layer.surface.render_as_pixbuf()
270 pixbuf = self.render_as_pixbuf()
271 pixbuf.save(filename, 'png', {'compression':str(compression)})
273 def load_png(self, filename):
274 self.load_from_pixbuf(gdk.pixbuf_new_from_file(filename))
276 def load_jpg(self, filename):
277 self.load_from_pixbuf(gdk.pixbuf_new_from_file(filename))
280 def save_jpg(self, filename, quality=90):
281 pixbuf = self.render_as_pixbuf()
282 pixbuf.save(filename, 'jpeg', options={'quality':str(quality)})
285 def save_ora(self, filename, options=None):
288 tempdir = tempfile.mkdtemp('mypaint')
289 # use .tmp extension, so we don't overwrite a valid file if there is an exception
290 z = zipfile.ZipFile(filename + '.tmpsave', 'w', compression=zipfile.ZIP_STORED)
291 # work around a permission bug in the zipfile library: http://bugs.python.org/issue3394
292 def write_file_str(filename, data):
293 zi = zipfile.ZipInfo(filename)
294 zi.external_attr = 0100644 << 16
296 write_file_str('mimetype', 'image/openraster') # must be the first file
297 image = ET.Element('image')
298 stack = ET.SubElement(image, 'stack')
299 x0, y0, w0, h0 = self.get_bbox()
304 def store_pixbuf(pixbuf, name):
305 tmp = join(tempdir, 'tmp.png')
307 pixbuf.save(tmp, 'png', {'compression':'2'})
308 print ' %.3fs saving %s compression 2' % (time.time() - t1, name)
312 def add_layer(x, y, opac, pixbuf, name):
313 layer = ET.Element('layer')
315 store_pixbuf(pixbuf, name)
320 a['opacity'] = str(opac)
322 for idx, l in enumerate(reversed(self.layers)):
323 if l.surface.is_empty():
326 x, y, w, h = l.surface.get_bbox()
327 pixbuf = l.surface.render_as_pixbuf()
328 add_layer(x-x0, y-y0, opac, pixbuf, 'data/layer%03d.png' % idx)
330 # save background as layer (solid color or tiled)
331 s = pixbufsurface.Surface(x0, y0, w0, h0)
332 s.fill(self.background)
333 add_layer(0, 0, 1.0, s.pixbuf, 'data/background.png')
337 print ' starting to render image for thumbnail...'
338 pixbuf = self.render_as_pixbuf()
339 w, h = pixbuf.get_width(), pixbuf.get_height()
341 w, h = 256, max(h*256/w, 1)
343 w, h = max(w*256/h, 1), 256
345 pixbuf = pixbuf.scale_simple(w, h, gdk.INTERP_BILINEAR)
346 print ' %.3fs scaling thumbnail' % (time.time() - t1)
347 store_pixbuf(pixbuf, 'Thumbnails/thumbnail.png')
348 print ' total %.3fs spent on thumbnail' % (time.time() - t2)
350 helpers.indent_etree(image)
351 xml = ET.tostring(image, encoding='UTF-8')
353 write_file_str('stack.xml', xml)
356 os.rename(filename + '.tmpsave', filename)
358 print '%.3fs save_ora total' % (time.time() - t0)
360 def load_ora(self, filename):
363 tempdir = tempfile.mkdtemp('mypaint')
364 z = zipfile.ZipFile(filename)
365 print 'mimetype:', z.read('mimetype').strip()
366 xml = z.read('stack.xml')
367 image = ET.fromstring(xml)
368 stack = image.find('stack')
370 self.clear() # this leaves one empty layer
372 if layer.tag != 'layer':
373 print 'Warning: ignoring unsupported tag:', layer.tag
376 src = a.get('src', '')
377 if not src.lower().endswith('.png'):
378 print 'Warning: ignoring non-png layer'
381 tmp = join(tempdir, 'tmp.png')
386 pixbuf = gdk.pixbuf_new_from_file(tmp)
387 print ' %.3fs loading %s' % (time.time() - t1, src)
390 x = int(a.get('x', '0'))
391 y = int(a.get('y', '0'))
392 opac = float(a.get('opacity', '1.0'))
393 self.add_layer(insert_idx=0)
396 self.load_layer_from_pixbuf(pixbuf, x, y)
397 self.layers[0].opacity = helpers.clamp(opac, 0.0, 1.0)
398 print ' %.3fs converting pixbuf to layer format' % (time.time() - t1)
402 if len(self.layers) == 1:
403 raise ValueError, 'Could not load any layer.'
405 # recognize solid or tiled background layers, at least those that mypaint saves
406 # (OpenRaster will probably get generator layers for this some day)
409 if not p.get_has_alpha() and p.get_width() % N == 0 and p.get_height() % N == 0:
410 tiles = self.layers[0].surface.tiledict.values()
413 for tile in tiles[1:]:
414 if (tile.rgba != tiles[0].rgba).any():
418 arr = helpers.gdkpixbuf2numpy(p)
419 tile = arr[0:N,0:N,:]
420 self.set_background(tile.copy())
423 print ' %.3fs recognizing tiled background' % (time.time() - t1)
425 if len(self.layers) > 1:
426 # remove the still present initial empty top layer
427 self.select_layer(len(self.layers)-1)
429 # this leaves the topmost layer selected
431 print '%.3fs load_ora total' % (time.time() - t0)