1# This file is part of MyPaint.
2# -*- coding: utf-8 -*-
3# Copyright (C) 2015-2018 by the MyPaint Development Team#
4#
5# This program is free software; you can redistribute it and/or modify
6# it under the terms of the GNU General Public License as published by
7# the Free Software Foundation; either version 2 of the License, or
8# (at your option) any later version.
9
10
11"""Common interfaces & routines for surface and surface-like objects"""
12
13from __future__ import division, print_function
14
15import abc
16import os
17import logging
18
19import numpy as np
20
21from . import mypaintlib
22import lib.helpers
23from lib.errors import FileHandlingError
24from lib.gettext import C_
25import lib.feedback
26from lib.pycompat import xrange
27
28
29logger = logging.getLogger(__name__)
30
31N = mypaintlib.TILE_SIZE
32
33# throttle excesssive calls to the save/render progress monitor objects
34TILES_PER_CALLBACK = 256
35
36
37class Bounded (object):
38    """Interface for objects with an inherent size"""
39
40    __metaclass__ = abc.ABCMeta
41
42    @abc.abstractmethod
43    def get_bbox(self):
44        """Returns the bounding box of the object, in model coords
45
46        :returns: the data bounding box
47        :rtype: lib.helpers.Rect
48
49        """
50
51
52class TileAccessible (Bounded):
53    """Interface for objects whose memory is accessible by tile"""
54
55    __metaclass__ = abc.ABCMeta
56
57    @abc.abstractmethod
58    def tile_request(self, tx, ty, readonly):
59        """Access by tile, read-only or read/write
60
61        :param int tx: Tile X coord (multiply by TILE_SIZE for pixels)
62        :param int ty: Tile Y coord (multiply by TILE_SIZE for pixels)
63        :param bool readonly: get a read-only tile
64
65        Implementations must be `@contextlib.contextmanager`s which
66        yield one tile array (NxNx16, fix15 data). If called in
67        read/write mode, implementations must either put back changed
68        data, or alternatively they must allow the underlying data to be
69        manipulated directly via the yielded object.
70
71        See lib.tiledsurface.MyPaintSurface.tile_request() for a fuller
72        explanation of this interface and its expectations.
73
74        """
75
76
77class TileBlittable (Bounded):
78    """Interface for unconditional copying by tile"""
79
80    __metaclass__ = abc.ABCMeta
81
82    @abc.abstractmethod
83    def blit_tile_into(self, dst, dst_has_alpha, tx, ty, *args, **kwargs):
84        """Copies one tile from this object into a NumPy array
85
86        :param numpy.ndarray dst: destination array
87        :param bool dst_has_alpha: destination has an alpha channel
88        :param int tx: Tile X coord (multiply by TILE_SIZE for pixels)
89        :param int ty: Tile Y coord (multiply by TILE_SIZE for pixels)
90        :param \*args: Implementation may extend this interface
91        :param \*\*kwargs: Implementation may extend this interface
92
93        The destination is typically of dimensions NxNx4, and is
94        typically of type uint16 or uint8. Implementations are expected
95        to check the details, and should raise ValueError if dst doesn't
96        have a sensible shape or type.
97
98        This is an unconditional copy of this object's raw visible data,
99        ignoring any flags or opacities on the object itself which would
100        otherwise control what you see.
101
102        If the source object really consists of multiple compositables
103        with special rendering flags, they should be composited normally
104        into an empty tile, and that resultant tile blitted.
105
106        """
107
108
109class TileCompositable (Bounded):
110    """Interface for compositing by tile, with modes/opacities/flags"""
111
112    __metaclass__ = abc.ABCMeta
113
114    @abc.abstractmethod
115    def composite_tile(self, dst, dst_has_alpha, tx, ty, mipmap_level=0,
116                       *args, **kwargs):
117        """Composites one tile from this object over a NumPy array.
118
119        :param dst: target tile array (uint16, NxNx4, 15-bit scaled int)
120        :param dst_has_alpha: alpha channel in dst should be preserved
121        :param int tx: Tile X coord (multiply by TILE_SIZE for pixels)
122        :param int ty: Tile Y coord (multiply by TILE_SIZE for pixels)
123        :param int mode: mode to use when compositing
124        :param \*args: Implementation may extend this interface
125        :param \*\*kwargs: Implementation may extend this interface
126
127        Composite one tile of this surface over the array dst, modifying
128        only dst. Unlike `blit_tile_into()`, this method must respect
129        any special rendering settings on the object itself.
130
131        """
132
133
134def get_tiles_bbox(tile_coords):
135    """Convert tile coords to a data bounding box
136
137    :param tile_coords: iterable of (tx, ty) coordinate pairs
138
139    >>> coords = [(0, 0), (-10, 4), (5, -2), (-3, 7)]
140    >>> get_tiles_bbox(coords[0:1])
141    Rect(0, 0, 64, 64)
142    >>> get_tiles_bbox(coords)
143    Rect(-640, -128, 1024, 640)
144    >>> get_tiles_bbox(coords[1:])
145    Rect(-640, -128, 1024, 640)
146    >>> get_tiles_bbox(coords[1:-1])
147    Rect(-640, -128, 1024, 448)
148    """
149    bounds = lib.helpers.coordinate_bounds(tile_coords)
150    if bounds is None:
151        return lib.helpers.Rect()
152    else:
153        x0, y0, x1, y1 = bounds
154        return lib.helpers.Rect(
155            N * x0, N * y0, N * (x1 - x0 + 1), N * (y1 - y0 + 1)
156        )
157
158
159def scanline_strips_iter(surface, rect, alpha=False,
160                         single_tile_pattern=False, **kwargs):
161    """Generate (render) scanline strips from a tile-blittable object
162
163    :param TileBlittable surface: Surface to iterate over
164    :param bool alpha: If true, write a PNG with alpha
165    :param bool single_tile_pattern: True if surface is a one tile only.
166    :param tuple \*\*kwargs: Passed to blit_tile_into.
167
168    The `alpha` parameter is passed to the surface's `blit_tile_into()`.
169    Rendering is skipped for all but the first line of single-tile patterns.
170
171    The scanline strips yielded by this generator are suitable for
172    feeding to a mypaintlib.ProgressivePNGWriter.
173
174    """
175    # Sizes
176    x, y, w, h = rect
177    assert w > 0
178    assert h > 0
179
180    # calculate bounding box in full tiles
181    render_tx = x // N
182    render_ty = y // N
183    render_tw = (x + w - 1) // N - render_tx + 1
184    render_th = (y + h - 1) // N - render_ty + 1
185
186    # buffer for rendering one tile row at a time
187    arr = np.empty((N, render_tw * N, 4), 'uint8')  # rgba or rgbu
188    # view into arr without the horizontal padding
189    arr_xcrop = arr[:, x-render_tx*N:x-render_tx*N+w, :]
190
191    first_row = render_ty
192    last_row = render_ty+render_th-1
193
194    for ty in range(render_ty, render_ty+render_th):
195        skip_rendering = False
196        if single_tile_pattern:
197            # optimization for simple background patterns
198            # e.g. solid color
199            if ty != first_row:
200                skip_rendering = True
201
202        for tx_rel in xrange(render_tw):
203            # render one tile
204            dst = arr[:, tx_rel*N:(tx_rel+1)*N, :]
205            if not skip_rendering:
206                tx = render_tx + tx_rel
207                try:
208                    surface.blit_tile_into(dst, alpha, tx, ty, **kwargs)
209                except Exception:
210                    logger.exception("Failed to blit tile %r of %r",
211                                     (tx, ty), surface)
212                    mypaintlib.tile_clear_rgba8(dst)
213
214        # yield a numpy array of the scanline without padding
215        res = arr_xcrop
216        if ty == last_row:
217            res = res[:y+h-ty*N, :, :]
218        if ty == first_row:
219            res = res[y-render_ty*N:, :, :]
220        yield res
221
222
223def save_as_png(surface, filename, *rect, **kwargs):
224    """Saves a tile-blittable surface to a file in PNG format
225
226    :param TileBlittable surface: Surface to save
227    :param unicode filename: The file to write
228    :param tuple \*rect: Rectangle (x, y, w, h) to save
229    :param bool alpha: If true, write a PNG with alpha
230    :param progress: Updates a UI every scanline strip.
231    :type progress: lib.feedback.Progress or None
232    :param bool single_tile_pattern: True if surface is one tile only.
233    :param bool save_srgb_chunks: Set to False to not save sRGB flags.
234    :param tuple \*\*kwargs: Passed to blit_tile_into (minus the above)
235
236    The `alpha` parameter is passed to the surface's `blit_tile_into()`
237    method, as well as to the PNG writer.  Rendering is
238    skipped for all but the first line for single-tile patterns.
239    If `*rect` is left unspecified, the surface's own bounding box will
240    be used.
241    If `save_srgb_chunks` is set to False, sRGB (and associated fallback
242    cHRM and gAMA) will not be saved. MyPaint's default behaviour is
243    currently to save these chunks.
244
245    Raises `lib.errors.FileHandlingError` with a descriptive string if
246    something went wrong.
247
248    """
249    # Horrible, dirty argument handling
250    alpha = kwargs.pop('alpha', False)
251    progress = kwargs.pop('progress', None)
252    single_tile_pattern = kwargs.pop("single_tile_pattern", False)
253    save_srgb_chunks = kwargs.pop("save_srgb_chunks", True)
254
255    # Sizes. Save at least one tile to allow empty docs to be written
256    if not rect:
257        rect = surface.get_bbox()
258    x, y, w, h = rect
259    if w == 0 or h == 0:
260        x, y, w, h = (0, 0, 1, 1)
261        rect = (x, y, w, h)
262
263    if not progress:
264        progress = lib.feedback.Progress()
265    num_strips = int((1 + ((y + h) // N)) - (y // N))
266    progress.items = num_strips
267
268    try:
269        logger.debug(
270            "Writing %r (%dx%d) alpha=%r srgb=%r",
271            filename,
272            w, h,
273            alpha,
274            save_srgb_chunks,
275        )
276        with open(filename, "wb") as writer_fp:
277            pngsave = mypaintlib.ProgressivePNGWriter(
278                writer_fp,
279                w, h,
280                alpha,
281                save_srgb_chunks,
282            )
283            scanline_strips = scanline_strips_iter(
284                surface, rect,
285                alpha=alpha,
286                single_tile_pattern=single_tile_pattern,
287                **kwargs
288            )
289            for scanline_strip in scanline_strips:
290                pngsave.write(scanline_strip)
291                if not progress:
292                    continue
293                try:
294                    progress += 1
295                except Exception:
296                    logger.exception(
297                        "Failed to update lib.feedback.Progress: "
298                        "dropping it"
299                    )
300                    progress = None
301            pngsave.close()
302        logger.debug("Finished writing %r", filename)
303        if progress:
304            progress.close()
305    except (IOError, OSError, RuntimeError) as err:
306        logger.exception(
307            "Caught %r from C++ png-writer code, re-raising as a "
308            "FileHandlingError",
309            err,
310        )
311        raise FileHandlingError(C_(
312            "low-level PNG writer failure report (dialog)",
313            u"Failed to write “{basename}”.\n\n"
314            u"Reason: {err}\n"
315            u"Target folder: “{dirname}”."
316        ).format(
317            err = err,
318            basename = os.path.basename(filename),
319            dirname = os.path.dirname(filename),
320        ))
321        # Other possible exceptions include TypeError, ValueError, but
322        # those indicate incorrect coding usually; just raise them
323        # normally.
324
325
326if __name__ == "__main__":
327    import doctest
328    doctest.testmod()
329