-
Notifications
You must be signed in to change notification settings - Fork 385
/
data.py
1874 lines (1565 loc) · 64.3 KB
/
data.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# This file is part of MyPaint.
# -*- coding: utf-8 -*-
# Copyright (C) 2019 by The Mypaint Development Team
# Copyright (C) 2011-2017 by Andrew Chadwick <a.t.chadwick@gmail.com>
# Copyright (C) 2007-2012 by Martin Renold <martinxyz@gmx.ch>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
"""Data layer classes"""
## Imports
from __future__ import division, print_function
import zlib
import logging
import os
import time
import tempfile
import shutil
from copy import deepcopy
from random import randint
import uuid
import struct
import contextlib
from lib.brush import BrushInfo
from lib.gettext import C_
from lib.tiledsurface import N
import lib.tiledsurface as tiledsurface
import lib.strokemap
import lib.helpers as helpers
import lib.fileutils
import lib.pixbuf
import lib.modes
import lib.mypaintlib
from . import core
import lib.layer.error
import lib.autosave
import lib.xml
import lib.feedback
from . import rendering
from lib.pycompat import PY3
from lib.pycompat import unicode
if PY3:
from io import StringIO
from io import BytesIO
else:
from cStringIO import StringIO
logger = logging.getLogger(__name__)
## Base classes
class SurfaceBackedLayer (core.LayerBase, lib.autosave.Autosaveable):
"""Minimal Surface-backed layer implementation
This minimal implementation is backed by a surface, which is used
for rendering by by the main application; subclasses are free to
choose whether they consider the surface to be the canonical source
of layer data or something else with the surface being just a
preview.
"""
#: Suffixes allowed in load_from_openraster().
#: Values are strings with leading dots.
#: Use a list containing "" to allow *any* file to be loaded.
#: The first item in the list can be used as a default extension.
ALLOWED_SUFFIXES = []
#: Substitute content if the layer cannot be loaded.
FALLBACK_CONTENT = None
## Initialization
def __init__(self, surface=None, **kwargs):
"""Construct a new SurfaceBackedLayer
:param surface: Surface to use, overriding the default.
:param **kwargs: passed to superclass.
If `surface` is specified, content observers will not be attached, and
the layer will not be cleared during construction. The default is to
instantiate and use a new, observed, `tiledsurface.Surface`.
"""
super(SurfaceBackedLayer, self).__init__(**kwargs)
# Pluggable surface implementation
# Only connect observers if using the default tiled surface
if surface is None:
self._surface = tiledsurface.Surface()
self._surface.observers.append(self._content_changed)
else:
self._surface = surface
@classmethod
def new_from_surface_backed_layer(cls, src):
"""Clone from another SurfaceBackedLayer
:param cls: Called as a @classmethod
:param SurfaceBackedLayer src: Source layer
:return: A new instance of type `cls`.
"""
if not isinstance(src, SurfaceBackedLayer):
raise ValueError("Source must be a SurfaceBacedLayer")
layer = cls()
src_snap = src.save_snapshot()
assert isinstance(src_snap, SurfaceBackedLayerSnapshot)
SurfaceBackedLayerSnapshot.restore_to_layer(src_snap, layer)
return layer
def load_from_surface(self, surface):
"""Load the backing surface image's tiles from another surface"""
self._surface.load_from_surface(surface)
def load_from_strokeshape(self, strokeshape, bbox=None, center=None):
"""Load image tiles from a stroke shape object.
:param strokemap.StrokeShape strokeshape: source shape
:param tuple bbox: Optional (x,y,w,h) pixel bbox to render in.
:param tuple center: Optional (x,y) center of interest.
"""
strokeshape.render_to_surface(self._surface, bbox=bbox, center=center)
## Loading
def load_from_openraster(self, orazip, elem, cache_dir, progress,
x=0, y=0, **kwargs):
"""Loads layer flags and bitmap/surface data from a .ora zipfile
The normal behaviour is to load the surface data directly from
the OpenRaster zipfile without using a temporary file. This
method also checks the src attribute's suffix against
ALLOWED_SUFFIXES before attempting to load the surface.
See: _load_surface_from_orazip_member()
"""
# Load layer flags
super(SurfaceBackedLayer, self).load_from_openraster(
orazip,
elem,
cache_dir,
progress,
x=x, y=y,
**kwargs
)
# Read bitmap content into the surface
attrs = elem.attrib
src = attrs.get("src", None)
src_rootname, src_ext = os.path.splitext(src)
src_rootname = os.path.basename(src_rootname)
src_ext = src_ext.lower()
x += int(attrs.get('x', 0))
y += int(attrs.get('y', 0))
logger.debug(
"Trying to load %r at %+d%+d, as %r",
src,
x, y,
self.__class__.__name__,
)
suffixes = self.ALLOWED_SUFFIXES
if ("" not in suffixes) and (src_ext not in suffixes):
logger.debug(
"Abandoning load attempt, cannot load %rs from a %r "
"(supported file extensions: %r)",
self.__class__.__name__,
src_ext,
suffixes,
)
raise lib.layer.error.LoadingFailed(
"Only %r are supported" % (suffixes,),
)
# Delegate the actual loading part
self._load_surface_from_orazip_member(
orazip,
cache_dir,
src,
progress,
x, y,
)
def _load_surface_from_orazip_member(self, orazip, cache_dir,
src, progress, x, y):
"""Loads the surface from a member of an OpenRaster zipfile
Intended strictly for override by subclasses which need to first
extract and then keep the file around afterwards.
"""
pixbuf = lib.pixbuf.load_from_zipfile(
datazip=orazip,
filename=src,
progress=progress,
)
self.load_surface_from_pixbuf(pixbuf, x=x, y=y)
def load_from_openraster_dir(self, oradir, elem, cache_dir, progress,
x=0, y=0, **kwargs):
"""Loads layer flags and data from an OpenRaster-style dir"""
# Load layer flags
super(SurfaceBackedLayer, self).load_from_openraster_dir(
oradir,
elem,
cache_dir,
progress,
x=x, y=y,
**kwargs
)
# Read bitmap content into the surface
attrs = elem.attrib
src = attrs.get("src", None)
src_rootname, src_ext = os.path.splitext(src)
src_rootname = os.path.basename(src_rootname)
src_ext = src_ext.lower()
x += int(attrs.get('x', 0))
y += int(attrs.get('y', 0))
logger.debug(
"Trying to load %r at %+d%+d, as %r",
src,
x, y,
self.__class__.__name__,
)
suffixes = self.ALLOWED_SUFFIXES
if ("" not in suffixes) and (src_ext not in suffixes):
logger.debug(
"Abandoning load attempt, cannot load %rs from a %r "
"(supported file extensions: %r)",
self.__class__.__name__,
src_ext,
suffixes,
)
raise lib.layer.error.LoadingFailed(
"Only %r are supported" % (suffixes,),
)
# Delegate the actual loading part
self._load_surface_from_oradir_member(
oradir,
cache_dir,
src,
progress,
x, y,
)
def _load_surface_from_oradir_member(self, oradir, cache_dir,
src, progress, x, y):
"""Loads the surface from a file in an OpenRaster-like folder
Intended strictly for override by subclasses which need to
make copies to manage.
"""
self.load_surface_from_pixbuf_file(
os.path.join(oradir, src),
x, y,
progress,
)
def load_surface_from_pixbuf_file(self, filename, x=0, y=0,
progress=None, image_type=None):
"""Loads the layer's surface from any file which GdkPixbuf can open"""
if progress:
if progress.items is not None:
raise ValueError(
"load_surface_from_pixbuf_file() expects "
"unsized progress objects"
)
s = os.stat(filename)
progress.items = int(s.st_size)
try:
with open(filename, 'rb') as fp:
pixbuf = lib.pixbuf.load_from_stream(fp, progress, image_type)
except Exception as err:
if self.FALLBACK_CONTENT is None:
raise lib.layer.error.LoadingFailed(
"Failed to load %r: %r" % (filename, str(err)),
)
logger.warning("Failed to load %r: %r", filename, str(err))
logger.info("Using fallback content instead of %r", filename)
pixbuf = lib.pixbuf.load_from_stream(
StringIO(self.FALLBACK_CONTENT),
)
return self.load_surface_from_pixbuf(pixbuf, x, y)
def load_surface_from_pixbuf(self, pixbuf, x=0, y=0):
"""Loads the layer's surface from a GdkPixbuf"""
arr = helpers.gdkpixbuf2numpy(pixbuf)
surface = tiledsurface.Surface()
bbox = surface.load_from_numpy(arr, x, y)
self.load_from_surface(surface)
return bbox
def clear(self):
"""Clears the layer"""
self._surface.clear()
## Info methods
@property
def effective_opacity(self):
"""The opacity used when compositing a layer: zero if invisible"""
if self.visible:
return self.opacity
else:
return 0.0
def get_alpha(self, x, y, radius):
"""Gets the average alpha within a certain radius at a point"""
return self._surface.get_alpha(x, y, radius)
def get_bbox(self):
"""Returns the inherent bounding box of the surface, tile aligned"""
return self._surface.get_bbox()
def is_empty(self):
"""Tests whether the surface is empty"""
return self._surface.is_empty()
## Flood fill
def flood_fill(self, fill_args, dst_layer=None):
"""Fills a point on the surface with a color
See `PaintingLayer.flood_fill() for parameters and semantics. This
implementation does nothing.
"""
pass
## Rendering
def get_tile_coords(self):
return self._surface.get_tiles().keys()
def get_render_ops(self, spec):
"""Get rendering instructions."""
visible = self.visible
mode = self.mode
opacity = self.opacity
if spec.layers is not None:
if self not in spec.layers:
return []
mode_default = lib.modes.default_mode()
if spec.previewing:
mode = mode_default
opacity = 1.0
visible = True
elif spec.solo:
if self is spec.current:
visible = True
if not visible:
return []
ops = []
if (spec.current_overlay is not None) and (self is spec.current):
# Temporary special effects, e.g. layer blink.
ops.append((rendering.Opcode.PUSH, None, None, None))
ops.append((
rendering.Opcode.COMPOSITE, self._surface, mode_default, 1.0,
))
ops.extend(spec.current_overlay.get_render_ops(spec))
ops.append(rendering.Opcode.POP, None, mode, opacity)
else:
# The 99%+ case☺
ops.append((
rendering.Opcode.COMPOSITE, self._surface, mode, opacity,
))
return ops
## Translating
def get_move(self, x, y):
"""Get a translation/move object for this layer
:param x: Model X position of the start of the move
:param y: Model X position of the start of the move
:returns: A move object
"""
return SurfaceBackedLayerMove(self, x, y)
## Saving
@lib.fileutils.via_tempfile
def save_as_png(self, filename, *rect, **kwargs):
"""Save to a named PNG file
:param filename: filename to save to
:param *rect: rectangle to save, as a 4-tuple
:param **kwargs: passed to the surface's save_as_png() method
:rtype: Gdk.Pixbuf
"""
self._surface.save_as_png(filename, *rect, **kwargs)
def save_to_openraster(self, orazip, tmpdir, path,
canvas_bbox, frame_bbox, **kwargs):
"""Saves the layer's data into an open OpenRaster ZipFile"""
rect = self.get_bbox()
return self._save_rect_to_ora(orazip, tmpdir, "layer", path,
frame_bbox, rect, **kwargs)
def queue_autosave(self, oradir, taskproc, manifest, bbox, **kwargs):
"""Queues the layer for auto-saving"""
# Queue up a task which writes the surface as a PNG. This will
# be the file that's indexed by the <layer/>'s @src attribute.
#
# For looped layers - currently just the background layer - this
# PNG file has to fill the requested save bbox so that other
# apps will understand it. Other kinds of layer will just use
# their inherent data bbox size, which may be smaller.
#
# Background layers save a simple tile too, but with a
# mypaint-specific attribute name. If/when OpenRaster
# standardizes looped layer data, that code should be moved
# here.
png_basename = self.autosave_uuid + ".png"
png_relpath = os.path.join("data", png_basename)
png_path = os.path.join(oradir, png_relpath)
png_bbox = self._surface.looped and bbox or tuple(self.get_bbox())
if self.autosave_dirty or not os.path.exists(png_path):
task = tiledsurface.PNGFileUpdateTask(
surface = self._surface,
filename = png_path,
rect = png_bbox,
alpha = (not self._surface.looped), # assume that means bg
**kwargs
)
taskproc.add_work(task)
self.autosave_dirty = False
# Calculate appropriate offsets
png_x, png_y = png_bbox[0:2]
ref_x, ref_y = bbox[0:2]
x = png_x - ref_x
y = png_y - ref_y
assert (x == y == 0) or not self._surface.looped
# Declare and index what is about to be written
manifest.add(png_relpath)
elem = self._get_stackxml_element("layer", x, y)
elem.attrib["src"] = png_relpath
return elem
@staticmethod
def _make_refname(prefix, path, suffix, sep='-'):
"""Internal: standardized filename for something with a path"""
assert "." in suffix
path_ref = sep.join([("%02d" % (n,)) for n in path])
if not suffix.startswith("."):
suffix = sep + suffix
return "".join([prefix, sep, path_ref, suffix])
def _save_rect_to_ora(self, orazip, tmpdir, prefix, path,
frame_bbox, rect, progress=None, **kwargs):
"""Internal: saves a rectangle of the surface to an ORA zip"""
# Write PNG data via a tempfile
pngname = self._make_refname(prefix, path, ".png")
pngpath = os.path.join(tmpdir, pngname)
t0 = time.time()
self._surface.save_as_png(pngpath, *rect, progress=progress, **kwargs)
t1 = time.time()
logger.debug('%.3fs surface saving %r', t1 - t0, pngname)
# Archive and remove
storepath = "data/%s" % (pngname,)
orazip.write(pngpath, storepath)
os.remove(pngpath)
# Return details
png_bbox = tuple(rect)
png_x, png_y = png_bbox[0:2]
ref_x, ref_y = frame_bbox[0:2]
x = png_x - ref_x
y = png_y - ref_y
assert (x == y == 0) or not self._surface.looped
elem = self._get_stackxml_element("layer", x, y)
elem.attrib["src"] = storepath
return elem
## Painting symmetry axis
def set_symmetry_state(
self, active, center, symmetry_type, symmetry_lines, angle):
"""Set the surface's painting symmetry axis and active flag.
See `LayerBase.set_symmetry_state` for the params.
"""
cx, cy = center
self._surface.set_symmetry_state(
bool(active),
float(cx), float(cy),
int(symmetry_type), int(symmetry_lines),
float(angle)
)
## Snapshots
def save_snapshot(self):
"""Snapshots the state of the layer, for undo purposes"""
return SurfaceBackedLayerSnapshot(self)
## Trimming
def get_trimmable(self):
return True
def trim(self, rect):
"""Trim the layer to a rectangle, discarding data outside it
:param rect: A trimming rectangle in model coordinates
:type rect: tuple (x, y, w, h)
Only complete tiles are discarded by this method.
If a tile is neither fully inside nor fully outside the
rectangle, the part of the tile outside the rectangle will be
cleared.
"""
self.autosave_dirty = True
self._surface.trim(rect)
## Cleanup
def remove_empty_tiles(self):
"""Removes empty tiles.
:returns: Stats about the removal: (nremoved, ntotal)
:rtype: tuple
"""
removed, total = self._surface.remove_empty_tiles()
return (removed, total)
class SurfaceBackedLayerMove (object):
"""Move object wrapper for surface-backed layers
Layer Subclasses should extend this minimal implementation to
provide functionality for doing things other than the surface tiles
around.
"""
def __init__(self, layer, x, y):
super(SurfaceBackedLayerMove, self).__init__()
surface_move = layer._surface.get_move(x, y)
self._wrapped = surface_move
def update(self, dx, dy):
self._wrapped.update(dx, dy)
def cleanup(self):
self._wrapped.cleanup()
def process(self, n=200):
return self._wrapped.process(n)
class SurfaceBackedLayerSnapshot (core.LayerBaseSnapshot):
"""Minimal layer implementation's snapshot
Snapshots are stored in commands, and used to implement undo and redo.
They must be independent copies of the data, although copy-on-write
semantics are fine. Snapshot objects don't have to be _full and exact_
clones of the layer's data, but they do need to capture _inherent_
qualities of the layer. Mere metadata can be ignored. For the base
layer implementation, this means the surface tiles and the layer's
opacity.
"""
def __init__(self, layer):
super(SurfaceBackedLayerSnapshot, self).__init__(layer)
self.surface_sshot = layer._surface.save_snapshot()
def restore_to_layer(self, layer):
super(SurfaceBackedLayerSnapshot, self).restore_to_layer(layer)
layer._surface.load_snapshot(self.surface_sshot)
class FileBackedLayer (SurfaceBackedLayer, core.ExternallyEditable):
"""A layer with primarily file-based storage
File-based layers use temporary files for storage, and create one
file per edit of the layer in an external application. The only
operation which can change the file's content is editing the file in
an external app. The layer's position on the MyPaint canvas, its
mode and its opacity can be changed as normal.
The internal surface is used only to store and render a bitmap
preview of the layer's content.
"""
## Class constants
ALLOWED_SUFFIXES = []
REVISIONS_SUBDIR = u"revisions"
## Construction
def __init__(self, x=0, y=0, **kwargs):
"""Construct, with blank internal fields"""
super(FileBackedLayer, self).__init__(**kwargs)
self._workfile = None
self._x = int(round(x))
self._y = int(round(y))
self._keywords = kwargs.copy()
self._keywords["x"] = x
self._keywords["y"] = y
def _ensure_valid_working_file(self):
if self._workfile is not None:
return
ext = self.ALLOWED_SUFFIXES[0]
rev0_fp = tempfile.NamedTemporaryFile(
mode = "wb",
suffix = ext,
dir = self.revisions_dir,
delete = False,
)
self.write_blank_backing_file(rev0_fp, **self._keywords)
rev0_fp.close()
self._workfile = _ManagedFile(rev0_fp.name)
logger.info("Loading new blank working file from %r", rev0_fp.name)
self.load_surface_from_pixbuf_file(
rev0_fp.name,
x=self._x,
y=self._y,
)
redraw_bbox = self.get_full_redraw_bbox()
self._content_changed(*redraw_bbox)
@property
def revisions_dir(self):
cache_dir = self.root.doc.cache_dir
revisions_dir = os.path.join(cache_dir, self.REVISIONS_SUBDIR)
if not os.path.isdir(revisions_dir):
os.makedirs(revisions_dir)
return revisions_dir
def write_blank_backing_file(self, file, **kwargs):
"""Write out the zeroth backing file revision.
:param file: open file-like object to write bytes into.
:param **kwargs: all construction params, including x and y.
This operation is deferred until the file is needed.
"""
raise NotImplementedError
def _load_surface_from_orazip_member(self, orazip, cache_dir,
src, progress, x, y):
"""Loads the surface from a member of an OpenRaster zipfile
This override retains a managed copy of the extracted file in
the REVISIONS_SUBDIR of the cache folder.
"""
# Extract a copy of the file, and load that
tmpdir = os.path.join(cache_dir, "tmp")
if not os.path.isdir(tmpdir):
os.makedirs(tmpdir)
orazip.extract(src, path=tmpdir)
tmp_filename = os.path.join(tmpdir, src)
self.load_surface_from_pixbuf_file(
tmp_filename,
x, y,
progress,
)
# Move it to the revisions subdir, and manage it there.
revisions_dir = os.path.join(cache_dir, self.REVISIONS_SUBDIR)
if not os.path.isdir(revisions_dir):
os.makedirs(revisions_dir)
self._workfile = _ManagedFile(
unicode(tmp_filename),
move=True,
dir=revisions_dir,
)
# Record its loaded position
self._x = x
self._y = y
def _load_surface_from_oradir_member(self, oradir, cache_dir,
src, progress, x, y):
"""Loads the surface from a file in an OpenRaster-like folder
This override makes a managed copy of the original file in the
REVISIONS_SUBDIR of the cache folder.
"""
# Load the displayed surface tiles
super(FileBackedLayer, self)._load_surface_from_oradir_member(
oradir, cache_dir,
src, progress,
x, y,
)
# Copy it to the revisions subdir, and manage it there.
revisions_dir = os.path.join(cache_dir, self.REVISIONS_SUBDIR)
if not os.path.isdir(revisions_dir):
os.makedirs(revisions_dir)
self._workfile = _ManagedFile(
unicode(os.path.join(oradir, src)),
copy=True,
dir=revisions_dir,
)
# Record its loaded position
self._x = x
self._y = y
## Snapshots & cloning
def save_snapshot(self):
"""Snapshots the state of the layer and its strokemap for undo"""
return FileBackedLayerSnapshot(self)
def __deepcopy__(self, memo):
clone = super(FileBackedLayer, self).__deepcopy__(memo)
clone._workfile = deepcopy(self._workfile)
return clone
## Moving
def get_move(self, x, y):
"""Start a new move for the layer"""
return FileBackedLayerMove(self, x, y)
## Trimming (no-op for file-based layers)
def get_trimmable(self):
return False
def trim(self, rect):
pass
## Saving
def save_to_openraster(self, orazip, tmpdir, path,
canvas_bbox, frame_bbox, **kwargs):
"""Saves the working file to an OpenRaster zipfile"""
# No supercall in this override, but the base implementation's
# attributes method is useful.
ref_x, ref_y = frame_bbox[0:2]
x = self._x - ref_x
y = self._y - ref_y
elem = self._get_stackxml_element("layer", x, y)
# Pick a suitable name to store under.
self._ensure_valid_working_file()
src_path = unicode(self._workfile)
src_rootname, src_ext = os.path.splitext(src_path)
src_ext = src_ext.lower()
storename = self._make_refname("layer", path, src_ext)
storepath = "data/%s" % (storename,)
# Archive (but do not remove) the managed tempfile
orazip.write(src_path, storepath)
# Return details of what was written.
elem.attrib["src"] = unicode(storepath)
return elem
def queue_autosave(self, oradir, taskproc, manifest, bbox, **kwargs):
"""Queues the layer for auto-saving"""
# Again, no supercall. Autosave the backing file by copying it.
ref_x, ref_y = bbox[0:2]
x = self._x - ref_x
y = self._y - ref_y
elem = self._get_stackxml_element("layer", x, y)
# Pick a suitable name to store under.
self._ensure_valid_working_file()
src_path = unicode(self._workfile)
src_rootname, src_ext = os.path.splitext(src_path)
src_ext = src_ext.lower()
final_basename = self.autosave_uuid + src_ext
final_relpath = os.path.join("data", final_basename)
final_path = os.path.join(oradir, final_relpath)
if self.autosave_dirty or not os.path.exists(final_path):
final_dir = os.path.join(oradir, "data")
tmp_fp = tempfile.NamedTemporaryFile(
mode = "wb",
prefix = final_basename,
dir = final_dir,
delete = False,
)
tmp_path = tmp_fp.name
# Copy the managed tempfile now.
# Though perhaps this could be processed in chunks
# like other layers.
with open(src_path, "rb") as src_fp:
shutil.copyfileobj(src_fp, tmp_fp)
tmp_fp.close()
lib.fileutils.replace(tmp_path, final_path)
self.autosave_dirty = False
# Return details of what gets written.
manifest.add(final_relpath)
elem.attrib["src"] = unicode(final_relpath)
return elem
## Editing via external apps
def new_external_edit_tempfile(self):
"""Get a tempfile for editing in an external app"""
if self.root is None:
return
self._ensure_valid_working_file()
self._edit_tempfile = _ManagedFile(
unicode(self._workfile),
copy = True,
dir = self.external_edits_dir,
)
return unicode(self._edit_tempfile)
def load_from_external_edit_tempfile(self, tempfile_path):
"""Load content from an external-edit tempfile"""
redraw_bboxes = []
redraw_bboxes.append(self.get_full_redraw_bbox())
x = self._x
y = self._y
self.load_surface_from_pixbuf_file(tempfile_path, x=x, y=y)
redraw_bboxes.append(self.get_full_redraw_bbox())
self._workfile = _ManagedFile(
tempfile_path,
copy = True,
dir = self.revisions_dir,
)
self._content_changed(*tuple(core.combine_redraws(redraw_bboxes)))
self.autosave_dirty = True
class FileBackedLayerSnapshot (SurfaceBackedLayerSnapshot):
"""Snapshot subclass for file-backed layers"""
def __init__(self, layer):
super(FileBackedLayerSnapshot, self).__init__(layer)
self.workfile = layer._workfile
self.x = layer._x
self.y = layer._y
def restore_to_layer(self, layer):
super(FileBackedLayerSnapshot, self).restore_to_layer(layer)
layer._workfile = self.workfile
layer._x = self.x
layer._y = self.y
layer.autosave_dirty = True
class FileBackedLayerMove (SurfaceBackedLayerMove):
"""Move object wrapper for file-backed layers"""
def __init__(self, layer, x, y):
super(FileBackedLayerMove, self).__init__(layer, x, y)
self._layer = layer
self._start_x = layer._x
self._start_y = layer._y
def update(self, dx, dy):
super(FileBackedLayerMove, self).update(dx, dy)
# Update file position too.
self._layer._x = int(round(self._start_x + dx))
self._layer._y = int(round(self._start_y + dy))
# The file itself is the canonical source of the data,
# and just setting the position doesn't change that.
# So no need to set autosave_dirty here for these layers.
## Utility classes
class _ManagedFile (object):
"""Working copy of a file, as used by file-backed layers
Managed files take control of an unmanaged file on disk when they
are created, and unlink it from the disk when their object is
destroyed. If you need a fresh copy to work on, the standard copy()
implementation handles that in the way you'd expect.
The underlying filename can be accessed by converting to `unicode`.
"""
def __init__(self, file_path, copy=False, move=False, dir=None):
"""Initialize, taking control of an unmanaged file or a copy
:param unicode file_path: File to manage or manage a copy of
:param bool copy: Copy first, and manage the copy
:param bool move: Move first, and manage under the new name
:param unicode dir: Target folder for move or copy.
The file can be automatically copied or renamed first,
in which case the new file is managed instead of the original.
The new file will preserve the original's file extension,
but otherwise use UUID (random) syntax.
If `targdir` is undefined, this new file will be
created in the same folder as the original.
Creating these objects, or copying them, should only be
attempted from the main thread.
"""
assert isinstance(file_path, unicode)
assert os.path.isfile(file_path)
if dir:
assert os.path.isdir(dir)
super(_ManagedFile, self).__init__()
file_path = self._get_file_to_manage(
file_path,
copy=copy,
move=move,
dir=dir,
)
file_dir, file_basename = os.path.split(file_path)
self._dir = file_dir
self._basename = file_basename
def __copy__(self):
"""Shallow copies work just like deep copies"""
return deepcopy(self)
def __deepcopy__(self, memo):
"""Deep-copying a _ManagedFile copies the file"""
orig_path = unicode(self)
clone_path = self._get_file_to_manage(orig_path, copy=True)
logger.debug("_ManagedFile: cloned %r as %r within %r",
self._basename, os.path.basename(clone_path), self._dir)
return _ManagedFile(clone_path)
@staticmethod
def _get_file_to_manage(orig_path, copy=False, move=False, dir=None):
"""Obtain a file path to manage. Same params as constructor.
If asked to copy or rename first,
UUID-based naming is used without much error checking.
This should be sufficient for MyPaint's usage
because the document working dir is atomically constructed.
However it's not truly atomic or threadsafe.
"""
assert os.path.isfile(orig_path)
if not (copy or move):
return orig_path
orig_dir, orig_basename = os.path.split(orig_path)
orig_rootname, orig_ext = os.path.splitext(orig_basename)
if dir is None:
dir = orig_dir
new_unique_path = None
while new_unique_path is None:
new_rootname = unicode(uuid.uuid4())
new_basename = new_rootname + orig_ext
new_path = os.path.join(dir, new_basename)
if os.path.exists(new_path): # yeah, paranoia
logger.warn("UUID clash: %r exists", new_path)
continue
if move:
os.rename(orig_path, new_path)
else:
shutil.copy2(orig_path, new_path)
new_unique_path = new_path
assert os.path.isfile(new_unique_path)
return new_unique_path
def __str__(self):
if PY3:
return self.__unicode__()
else:
return self.__bytes__() # Always an error under Py2
def __bytes__(self):
raise NotImplementedError("Use unicode strings for file names.")
def __unicode__(self):
file_path = os.path.join(self._dir, self._basename)
assert isinstance(file_path, unicode)
return file_path
def __repr__(self):
return "_ManagedFile(%r)" % (self,)
def __del__(self):
try:
file_path = unicode(self)
except Exception:
logger.exception("_ManagedFile: cleanup of incomplete object. "
"File may still exist on disk.")
return
if os.path.exists(file_path):
logger.debug("_ManagedFile: %r is no longer referenced, deleting",
file_path)
os.unlink(file_path)
else:
logger.debug("_ManagedFile: %r was already removed, not deleting",
file_path)