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.
9 import os, zipfile, tempfile, time, traceback
11 from cStringIO import StringIO
12 import xml.etree.ElementTree as ET
15 from gettext import gettext as _
17 import helpers, tiledsurface, pixbufsurface, backgroundsurface, mypaintlib
18 import command, stroke, layer
22 class SaveLoadError(Exception):
23 """Expected errors on loading or saving, like missing permissions or non-existing files."""
28 This is the "model" in the Model-View-Controller design.
29 (The "view" would be ../gui/tileddrawwidget.py.)
30 It represents everything that the user would want to save.
33 The "controller" mostly in drawwindow.py.
34 It is possible to use it without any GUI attached (see ../tests/)
36 # Please note the following difficulty with the undo stack:
38 # Most of the time there is an unfinished (but already rendered)
39 # stroke pending, which has to be turned into a command.Action
40 # or discarded as empty before any other action is possible.
44 self.brush = brush.Brush()
46 self.canvas_observers = []
47 self.stroke_observers = [] # callback arguments: stroke, brush (brush is a temporary read-only convenience object)
48 self.doc_observers = []
49 self.frame_observers = []
52 self._frame = [0, 0, 0, 0]
53 self._frame_enabled = False
54 # Used by move_frame() to accumulate values
61 def move_frame(self, dx=0.0, dy=0.0):
62 """Move the frame. Accumulates changes and moves the frame once
63 the accumulated change reaches the minimum move step."""
64 # FIXME: Should be 1 (pixel aligned), not tile aligned
65 # This is due to PNG saving having to be tile aligned
68 def round_to_n(value, n):
69 return int(round(value/n)*n)
71 x, y, w, h = self.get_frame()
75 step_x = round_to_n(self._frame_dx, min_step)
76 step_y = round_to_n(self._frame_dy, min_step)
79 self.set_frame(x=x+step_x)
80 self._frame_dx -= step_x
83 self.set_frame(y=y+step_y)
84 self._frame_dy -= step_y
86 def set_frame(self, x=None, y=None, width=None, height=None):
87 """Set the size of the frame. Pass None to indicate no-change."""
89 for i, var in enumerate([x, y, width, height]):
91 # FIXME: must be aligned to tile size due to PNG saving
92 assert not var % N, "Frame size must be aligned to tile size"
95 for f in self.frame_observers: f()
97 def get_frame_enabled(self):
98 return self._frame_enabled
100 def set_frame_enabled(self, enabled):
101 self._frame_enabled = enabled
102 for f in self.frame_observers: f()
103 frame_enabled = property(get_frame_enabled)
105 def call_doc_observers(self):
106 for f in self.doc_observers:
110 def clear(self, init=False):
113 bbox = self.get_bbox()
114 # throw everything away, including undo stack
115 self.command_stack = command.CommandStack()
116 self.set_background((255, 255, 255))
118 self.layer_idx = None
120 # disallow undo of the first layer
121 self.command_stack.clear()
122 self.unsaved_painting_time = 0.0
125 for f in self.canvas_observers:
128 self.call_doc_observers()
130 def get_current_layer(self):
131 return self.layers[self.layer_idx]
132 layer = property(get_current_layer)
134 def split_stroke(self):
135 if not self.stroke: return
136 self.stroke.stop_recording()
137 if not self.stroke.empty:
138 self.command_stack.do(command.Stroke(self, self.stroke, self.snapshot_before_stroke))
139 del self.snapshot_before_stroke
140 self.unsaved_painting_time += self.stroke.total_painting_time
141 for f in self.stroke_observers:
142 f(self.stroke, self.brush)
145 def select_layer(self, idx):
146 self.do(command.SelectLayer(self, idx))
148 def move_layer(self, was_idx, new_idx):
149 self.do(command.MoveLayer(self, was_idx, new_idx))
151 def clear_layer(self):
152 if not self.layer.surface.is_empty():
153 self.do(command.ClearLayer(self))
155 def stroke_to(self, dtime, x, y, pressure, xtilt,ytilt):
157 self.stroke = stroke.Stroke()
158 self.stroke.start_recording(self.brush)
159 self.snapshot_before_stroke = self.layer.save_snapshot()
160 self.stroke.record_event(dtime, x, y, pressure, xtilt,ytilt)
163 l.surface.begin_atomic()
164 split = self.brush.stroke_to (l.surface, x, y, pressure, xtilt,ytilt, dtime)
165 l.surface.end_atomic()
170 def straight_line(self, src, dst):
172 # TODO: undo last stroke if it was very short... (but not at document level?)
173 real_brush = self.brush
174 self.brush = brush.Brush()
175 self.brush.copy_settings_from(real_brush)
180 x = numpy.linspace(src[0], dst[0], N)
181 y = numpy.linspace(src[1], dst[1], N)
182 # rest the brush in src for a minute, to avoid interpolation
183 # from the upper left corner (states are zero) (FIXME: the
184 # brush should handle this on its own, maybe?)
185 self.stroke_to(60.0, x[0], y[0], 0.0, 0.0, 0.0)
187 self.stroke_to(duration/N, x[i], y[i], pressure, 0.0, 0.0)
189 self.brush = real_brush
192 def layer_modified_cb(self, *args):
193 # for now, any layer modification is assumed to be visible
194 for f in self.canvas_observers:
197 def invalidate_all(self):
198 for f in self.canvas_observers:
204 cmd = self.command_stack.undo()
205 if not cmd or not cmd.automatic_undo:
211 cmd = self.command_stack.redo()
212 if not cmd or not cmd.automatic_undo:
217 self.command_stack.do(cmd)
219 def get_last_command(self):
221 return self.command_stack.get_last_command()
223 def set_brush(self, brush):
225 self.brush.copy_settings_from(brush)
229 for layer in self.layers:
230 # OPTIMIZE: only visible layers...
231 # careful: currently saving assumes that all layers are included
232 bbox = layer.surface.get_bbox()
233 res.expandToIncludeRect(bbox)
236 def get_effective_bbox(self):
237 """Return the effective bounding box of the document.
238 If the frame is enabled, this is the bounding box of the frame,
239 else the (dynamic) bounding box of the document."""
240 return self.get_frame() if self.frame_enabled else self.get_bbox()
242 def blit_tile_into(self, dst_8bit, tx, ty, mipmap_level=0, layers=None, background=None):
245 if background is None:
246 background = self.background
248 assert dst_8bit.dtype == 'uint8'
249 dst = numpy.empty((N, N, 3), dtype='uint16')
251 background.blit_tile_into(dst, tx, ty, mipmap_level)
254 surface = layer.surface
255 surface.composite_tile_over(dst, tx, ty, mipmap_level=mipmap_level, opacity=layer.effective_opacity)
257 mypaintlib.tile_convert_rgb16_to_rgb8(dst, dst_8bit)
259 def add_layer(self, insert_idx=None, after=None, name=''):
260 self.do(command.AddLayer(self, insert_idx, after, name))
262 def remove_layer(self,layer=None):
263 if len(self.layers) > 1:
264 self.do(command.RemoveLayer(self,layer))
268 def merge_layer_down(self):
269 dst_idx = self.layer_idx - 1
272 self.do(command.MergeLayer(self, dst_idx))
275 def load_layer_from_pixbuf(self, pixbuf, x=0, y=0):
276 arr = helpers.gdkpixbuf2numpy(pixbuf)
277 self.do(command.LoadLayer(self, arr, x, y))
279 def set_layer_visibility(self, visible, layer):
280 cmd = self.get_last_command()
281 if isinstance(cmd, command.SetLayerVisibility) and cmd.layer is layer:
283 self.do(command.SetLayerVisibility(self, visible, layer))
285 def set_layer_locked(self, locked, layer):
286 cmd = self.get_last_command()
287 if isinstance(cmd, command.SetLayerLocked) and cmd.layer is layer:
289 self.do(command.SetLayerLocked(self, locked, layer))
291 def set_layer_opacity(self, opacity, layer=None):
292 """Sets the opacity of a layer. If layer=None, works on the current layer"""
293 cmd = self.get_last_command()
294 if isinstance(cmd, command.SetLayerOpacity):
296 self.do(command.SetLayerOpacity(self, opacity, layer))
298 def set_background(self, obj):
299 # This is not an undoable action. One reason is that dragging
300 # on the color chooser would get tons of undo steps.
302 if not isinstance(obj, backgroundsurface.Background):
303 obj = backgroundsurface.Background(obj)
304 self.background = obj
306 self.invalidate_all()
308 def load_from_pixbuf(self, pixbuf):
309 """Load a document from a pixbuf."""
311 self.load_layer_from_pixbuf(pixbuf)
312 self.set_frame(*self.get_bbox())
314 def is_layered(self):
316 for l in self.layers:
317 if not l.surface.is_empty():
322 return len(self.layers) == 1 and self.layer.surface.is_empty()
324 def save(self, filename, **kwargs):
326 trash, ext = os.path.splitext(filename)
327 ext = ext.lower().replace('.', '')
328 save = getattr(self, 'save_' + ext, self.unsupported)
330 save(filename, **kwargs)
331 except gobject.GError, e:
332 traceback.print_exc()
334 #add a hint due to a very consfusing error message when there is no space left on device
335 raise SaveLoadError, _('Unable to save: %s\nDo you have enough space left on the device?') % e.message
337 raise SaveLoadError, _('Unable to save: %s') % e.message
339 traceback.print_exc()
340 raise SaveLoadError, _('Unable to save: %s') % e.strerror
341 self.unsaved_painting_time = 0.0
343 def load(self, filename):
344 if not os.path.isfile(filename):
345 raise SaveLoadError, _('File does not exist: %s') % repr(filename)
346 if not os.access(filename,os.R_OK):
347 raise SaveLoadError, _('You do not have the necessary permissions to open file: %s') % repr(filename)
348 trash, ext = os.path.splitext(filename)
349 ext = ext.lower().replace('.', '')
350 load = getattr(self, 'load_' + ext, self.unsupported)
353 except gobject.GError, e:
354 traceback.print_exc()
355 raise SaveLoadError, _('Error while loading: GError %s') % e
357 traceback.print_exc()
358 raise SaveLoadError, _('Error while loading: IOError %s') % e
359 self.command_stack.clear()
360 self.unsaved_painting_time = 0.0
361 self.call_doc_observers()
363 def unsupported(self, filename):
364 raise SaveLoadError, _('Unknown file format extension: %s') % repr(filename)
366 def render_as_pixbuf(self, *args, **kwargs):
367 return pixbufsurface.render_as_pixbuf(self, *args, **kwargs)
369 def render_thumbnail(self):
371 x, y, w, h = self.get_effective_bbox()
373 while mipmap_level < tiledsurface.MAX_MIPMAP_LEVEL and max(w, h) >= 512:
375 x, y, w, h = x/2, y/2, w/2, h/2
377 pixbuf = self.render_as_pixbuf(x, y, w, h, mipmap_level=mipmap_level)
378 assert pixbuf.get_width() == w and pixbuf.get_height() == h
379 pixbuf = helpers.scale_proportionally(pixbuf, 256, 256)
380 print 'Rendered thumbnail in', time.time() - t0, 'seconds.'
383 def save_png(self, filename, alpha=False, multifile=False):
384 doc_bbox = self.get_effective_bbox()
386 self.save_multifile_png(filename)
389 tmp_layer = layer.Layer()
390 for l in self.layers:
391 l.merge_into(tmp_layer)
392 tmp_layer.surface.save(filename, *doc_bbox)
394 pixbufsurface.save_as_png(self, filename, *doc_bbox, alpha=False)
396 def save_multifile_png(self, filename, alpha=False):
397 prefix, ext = os.path.splitext(filename)
398 # if we have a number already, strip it
399 l = prefix.rsplit('.', 1)
402 doc_bbox = self.get_effective_bbox()
403 for i, l in enumerate(self.layers):
404 filename = '%s.%03d%s' % (prefix, i+1, ext)
405 l.surface.save(filename, *doc_bbox)
407 def load_png(self, filename):
408 self.load_from_pixbuf(gdk.pixbuf_new_from_file(filename))
410 def load_jpg(self, filename):
411 self.load_from_pixbuf(gdk.pixbuf_new_from_file(filename))
414 def save_jpg(self, filename, quality=90):
415 doc_bbox = self.get_effective_bbox()
416 pixbuf = self.render_as_pixbuf(*doc_bbox)
417 pixbuf.save(filename, 'jpeg', options={'quality':str(quality)})
420 def save_ora(self, filename, options=None):
423 tempdir = tempfile.mkdtemp('mypaint')
424 # use .tmp extension, so we don't overwrite a valid file if there is an exception
425 z = zipfile.ZipFile(filename + '.tmpsave', 'w', compression=zipfile.ZIP_STORED)
426 # work around a permission bug in the zipfile library: http://bugs.python.org/issue3394
427 def write_file_str(filename, data):
428 zi = zipfile.ZipInfo(filename)
429 zi.external_attr = 0100644 << 16
431 write_file_str('mimetype', 'image/openraster') # must be the first file
432 image = ET.Element('image')
433 stack = ET.SubElement(image, 'stack')
434 x0, y0, w0, h0 = self.get_effective_bbox()
439 def store_pixbuf(pixbuf, name):
440 tmp = join(tempdir, 'tmp.png')
442 pixbuf.save(tmp, 'png')
443 print ' %.3fs pixbuf saving %s' % (time.time() - t1, name)
447 def store_surface(surface, name, rect=[]):
448 tmp = join(tempdir, 'tmp.png')
450 surface.save(tmp, *rect)
451 print ' %.3fs surface saving %s' % (time.time() - t1, name)
455 def add_layer(x, y, opac, surface, name, layer_name, visible=True, rect=[]):
456 layer = ET.Element('layer')
458 store_surface(surface, name, rect)
461 a['name'] = layer_name
465 a['opacity'] = str(opac)
467 a['visibility'] = 'visible'
469 a['visibility'] = 'hidden'
472 for idx, l in enumerate(reversed(self.layers)):
473 if l.surface.is_empty():
476 x, y, w, h = l.surface.get_bbox()
477 el = add_layer(x-x0, y-y0, opac, l.surface, 'data/layer%03d.png' % idx, l.name, l.visible, rect=(x, y, w, h))
480 l.save_strokemap_to_file(sio, -x, -y)
481 data = sio.getvalue(); sio.close()
482 name = 'data/layer%03d_strokemap.dat' % idx
483 el.attrib['mypaint_strokemap_v2'] = name
484 write_file_str(name, data)
486 # save background as layer (solid color or tiled)
488 # save as fully rendered layer
489 x, y, w, h = self.get_bbox()
490 l = add_layer(x, y, 1.0, bg, 'data/background.png', 'background', rect=(x,y,w,h))
491 x, y, w, h = bg.get_pattern_bbox()
492 # save as single pattern (with corrected origin)
493 store_surface(bg, 'data/background_tile.png', rect=(x+x0, y+y0, w, h))
494 l.attrib['background_tile'] = 'data/background_tile.png'
498 print ' starting to render full image for thumbnail...'
500 thumbnail_pixbuf = self.render_thumbnail()
501 store_pixbuf(thumbnail_pixbuf, 'Thumbnails/thumbnail.png')
502 print ' total %.3fs spent on thumbnail' % (time.time() - t2)
504 helpers.indent_etree(image)
505 xml = ET.tostring(image, encoding='UTF-8')
507 write_file_str('stack.xml', xml)
510 if os.path.exists(filename):
511 os.remove(filename) # windows needs that
512 os.rename(filename + '.tmpsave', filename)
514 print '%.3fs save_ora total' % (time.time() - t0)
516 return thumbnail_pixbuf
518 def load_ora(self, filename):
519 """Loads from an OpenRaster file"""
522 tempdir = tempfile.mkdtemp('mypaint')
523 z = zipfile.ZipFile(filename)
524 print 'mimetype:', z.read('mimetype').strip()
525 xml = z.read('stack.xml')
526 image = ET.fromstring(xml)
527 stack = image.find('stack')
529 w = int(image.attrib['w'])
530 h = int(image.attrib['h'])
532 def round_up_to_n(value, n):
533 assert value >= 0, "function undefined for negative numbers"
537 value = value - residual + n
540 def get_pixbuf(filename):
542 tmp = join(tempdir, 'tmp.png')
546 data = z.read(filename)
548 # support for bad zip files (saved by old versions of the GIMP ORA plugin)
549 data = z.read(filename.encode('utf-8'))
550 print 'WARNING: bad OpenRaster ZIP file. There is an utf-8 encoded filename that does not have the utf-8 flag set:', repr(filename)
554 res = gdk.pixbuf_new_from_file(tmp)
556 print ' %.3fs loading %s' % (time.time() - t1, filename)
559 def get_layers_list(root, x=0,y=0):
562 if item.tag == 'layer':
563 if 'x' in item.attrib:
564 item.attrib['x'] = int(item.attrib['x']) + x
565 if 'y' in item.attrib:
566 item.attrib['y'] = int(item.attrib['y']) + y
568 elif item.tag == 'stack':
569 stack_x = int( item.attrib.get('x', 0) )
570 stack_y = int( item.attrib.get('y', 0) )
571 res += get_layers_list(item, stack_x, stack_y)
573 print 'Warning: ignoring unsupported tag:', item.tag
576 self.clear() # this leaves one empty layer
578 # FIXME: don't require tile alignment for frame
579 self.set_frame(width=round_up_to_n(w, N), height=round_up_to_n(h, N))
581 for layer in get_layers_list(stack):
584 if 'background_tile' in a:
587 print a['background_tile']
588 self.set_background(get_pixbuf(a['background_tile']))
589 no_background = False
591 except backgroundsurface.BackgroundError, e:
592 print 'ORA background tile not usable:', e
594 src = a.get('src', '')
595 if not src.lower().endswith('.png'):
596 print 'Warning: ignoring non-png layer'
598 pixbuf = get_pixbuf(src)
599 name = a.get('name', '')
600 x = int(a.get('x', '0'))
601 y = int(a.get('y', '0'))
602 opac = float(a.get('opacity', '1.0'))
603 visible = not 'hidden' in a.get('visibility', 'visible')
604 self.add_layer(insert_idx=0, name=name)
607 self.load_layer_from_pixbuf(pixbuf, x, y)
608 layer = self.layers[0]
610 self.set_layer_opacity(helpers.clamp(opac, 0.0, 1.0), layer)
611 self.set_layer_visibility(visible, layer)
612 print ' %.3fs converting pixbuf to layer format' % (time.time() - t1)
614 fname = a.get('mypaint_strokemap_v2', None)
617 print 'Warning: dropping non-aligned strokemap'
619 sio = StringIO(z.read(fname))
620 layer.load_strokemap_from_file(sio, x, y)
625 if len(self.layers) == 1:
626 raise ValueError, 'Could not load any layer.'
629 # recognize solid or tiled background layers, at least those that mypaint <= 0.7.1 saves
632 if not p.get_has_alpha() and p.get_width() % N == 0 and p.get_height() % N == 0:
633 tiles = self.layers[0].surface.tiledict.values()
636 for tile in tiles[1:]:
637 if (tile.rgba != tiles[0].rgba).any():
641 arr = helpers.gdkpixbuf2numpy(p)
642 tile = arr[0:N,0:N,:]
643 self.set_background(tile.copy())
646 print ' %.3fs recognizing tiled background' % (time.time() - t1)
648 if len(self.layers) > 1:
649 # remove the still present initial empty top layer
650 self.select_layer(len(self.layers)-1)
652 # this leaves the topmost layer selected
654 print '%.3fs load_ora total' % (time.time() - t0)