1/*
2 * Copyright (c) 2020 Alecaddd (https://alecaddd.com)
3 *
4 * This file is part of Akira.
5 *
6 * Akira is free software: you can redistribute it and/or modify
7 * it under the terms of the GNU General Public License as published by
8 * the Free Software Foundation, either version 3 of the License, or
9 * (at your option) any later version.
10
11 * Akira is distributed in the hope that it will be useful,
12 * but WITHOUT ANY WARRANTY; without even the implied warranty of
13 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 * GNU General Public License for more details.
15
16 * You should have received a copy of the GNU General Public License
17 * along with Akira. If not, see <https://www.gnu.org/licenses/>.
18 *
19 * Authored by: Alessandro "Alecaddd" Castellani <castellani.ale@gmail.com>
20 */
21
22public class Akira.Lib.Managers.ExportManager : Object {
23    private const string COLOR = "#41c9fd";
24    private const double LINE_WIDTH = 2.0;
25    private const double MIN_SIZE = 1.0;
26
27    public enum Type {
28        AREA,
29        SELECTION,
30        ARTBOARD
31    }
32
33    public weak Akira.Lib.Canvas canvas { get; construct; }
34    public Akira.Dialogs.ExportDialog export_dialog;
35
36    private double initial_x;
37    private double initial_y;
38    private double initial_width;
39    private double initial_height;
40
41    public Goo.CanvasRect area;
42    public Cairo.Format format;
43    public Cairo.Surface surface;
44    public Cairo.Context context;
45    public Gdk.PixbufLoader loader;
46    public Gee.HashMap<string, Gdk.Pixbuf> pixbufs { get; set construct; }
47
48    public ExportManager (Akira.Lib.Canvas canvas) {
49        Object (
50            canvas: canvas
51        );
52        pixbufs = new Gee.HashMap<string, Gdk.Pixbuf> ();
53    }
54
55    public Goo.CanvasRect create_area (Gdk.EventButton event) {
56        var dash = new Goo.CanvasLineDash (2, 5.0, 5.0);
57        var rgba_fill = Gdk.RGBA ();
58        rgba_fill.parse (COLOR);
59        rgba_fill.alpha = 0.1;
60        uint fill_color_rgba = Utils.Color.rgba_to_uint (rgba_fill);
61
62        area = new Goo.CanvasRect (
63            null,
64            Utils.AffineTransform.fix_size (event.x),
65            Utils.AffineTransform.fix_size (event.y),
66            2.0, 2.0,
67            "line-width", LINE_WIDTH / canvas.current_scale,
68            "stroke-color", COLOR,
69            "line-dash", dash,
70            "fill-color-rgba", fill_color_rgba,
71            null
72        );
73
74        initial_x = event.x;
75        initial_y = event.y;
76        initial_width = 1.0;
77        initial_height = 1.0;
78
79        area.set ("parent", canvas.get_root_item ());
80        area.can_focus = false;
81        area.pointer_events = Goo.CanvasPointerEvents.NONE;
82
83        return area;
84    }
85
86    public void resize_area (double x, double y) {
87        double area_width = area.width;
88        double area_height = area.height;
89        double area_x = area.x;
90        double area_y = area.y;
91
92        double delta_x = x - initial_x;
93        double delta_y = y - initial_y;
94
95        double new_width = delta_x;
96        double new_height = delta_y;
97
98        // Width size constraints.
99        if (Utils.AffineTransform.fix_size (x) < area_x && area_width != 1) {
100            // If the mouse event goes beyond the available width of the area
101            // super quickly, collapse the size to 1 and maintain the position.
102            new_width = -area_width + 1;
103        } else if (Utils.AffineTransform.fix_size (x) < area_x) {
104            // If the user keeps moving the mouse beyond the available width of the area
105            // prevent any size changes.
106            new_width = 0;
107        } else if (area_width == 1 && delta_x <= 0) {
108            // Don't update the size or position if the delta keeps increasing,
109            // meaning the user is still moving left.
110            new_width = 0;
111        }
112
113        // Height size constraints.
114        if (Utils.AffineTransform.fix_size (y) < area_y && area_height != 1) {
115            // If the mouse event goes beyond the available height of the area
116            // super quickly, collapse the size to 1 and maintain the position.
117            new_height = -area_height + 1;
118        } else if (Utils.AffineTransform.fix_size (y) < area_y) {
119            // If the user keeps moving the mouse beyond the available height of the area
120            // prevent any size changes.
121            new_height = 0;
122        } else if (area_height == 1 && delta_y <= 0) {
123            // Don't update the size or position if the delta keeps increasing,
124            // meaning the user is still moving down.
125            new_height = 0;
126        }
127
128        if (canvas.ctrl_is_pressed) {
129            new_height = new_width;
130            if (area_width != area_height) {
131                new_height = area_width - area_height;
132            }
133        }
134
135        area.set ("width", new_width + area.width);
136        area.set ("height", new_height + area.height);
137
138        // Update the initial coordinates to keep getting the correct delta.
139        initial_x = x;
140        initial_y = y;
141    }
142
143    public void clear () {
144        if (area == null) {
145            return;
146        }
147
148        area.remove ();
149    }
150
151    /**
152     * Trigger the creation of the pixbuf for the export_area action.
153     */
154    public void create_area_snapshot () {
155        // Hide the area before rendering.
156        area.visibility = Goo.CanvasItemVisibility.INVISIBLE;
157        // Open Export Dialog before we have the preview.
158        trigger_export_dialog (Type.AREA);
159        // Generate the image to export.
160        init_generate_area_pixbuf.begin ();
161    }
162
163    /**
164     * Trigger the creation of the pixbuf for the export_selection action.
165     */
166    public void create_selection_snapshot () {
167        canvas.window.event_bus.hide_select_effect ();
168        // Open Export Dialog before we have the preview.
169        trigger_export_dialog (Type.SELECTION);
170        // Generate the image to export.
171        init_generate_selection_pixbuf.begin ();
172    }
173
174    public void regenerate_pixbuf (Type type) {
175        switch (type) {
176            case AREA:
177                init_generate_area_pixbuf.begin ();
178                break;
179            case SELECTION:
180                canvas.window.event_bus.hide_select_effect ();
181                init_generate_selection_pixbuf.begin ();
182                break;
183        }
184    }
185
186    /**
187     * Use multithreading to handle async pixbuf loading without freezing the UI.
188     */
189    public async void init_generate_area_pixbuf () throws ThreadError {
190        if (Thread.supported () == false) {
191            error ("Threads are not supported!");
192        }
193
194        canvas.window.event_bus.export_preview (_("Generating preview, please wait…"));
195        SourceFunc callback = init_generate_area_pixbuf.callback;
196
197        new Thread<void*> (null, () => {
198            try {
199                generate_area_pixbuf ();
200            } catch (Error e) {
201                error ("Could not generate export preview: %s", e.message);
202            }
203
204            Idle.add ((owned) callback);
205            Thread.exit (null);
206
207            return null;
208        });
209
210        yield;
211
212        yield export_dialog.generate_export_preview ();
213        canvas.window.event_bus.preview_completed ();
214    }
215
216    public async void init_generate_selection_pixbuf () throws ThreadError {
217        if (Thread.supported () == false) {
218            error ("Threads are not supported!");
219        }
220
221        canvas.window.event_bus.export_preview (_("Generating preview, please wait…"));
222        SourceFunc callback = init_generate_selection_pixbuf.callback;
223
224        new Thread<void*> (null, () => {
225            try {
226                generate_selection_pixbuf ();
227            } catch (Error e) {
228                error ("Could not generate export preview: %s", e.message);
229            }
230
231            Idle.add ((owned) callback);
232            Thread.exit (null);
233
234            return null;
235        });
236
237        yield;
238
239        yield export_dialog.generate_export_preview ();
240        canvas.window.event_bus.preview_completed ();
241        canvas.window.event_bus.show_select_effect ();
242    }
243
244    public void generate_area_pixbuf () throws Error {
245        // Clear pixbuf array from previously stored values.
246        pixbufs.clear ();
247
248        if (settings.export_format == "png") {
249            format = Cairo.Format.ARGB32;
250        } else if (settings.export_format == "jpg") {
251            format = Cairo.Format.RGB24;
252        }
253
254        // Create the rendered image with Cairo.
255        surface = new Cairo.ImageSurface (
256            format,
257            (int) Math.round (area.width),
258            (int) Math.round (area.height)
259        );
260        context = new Cairo.Context (surface);
261
262        // Draw a white background if JPG export.
263        if (settings.export_format == "jpg" || !settings.export_alpha) {
264            context.set_source_rgba (1, 1, 1, 1);
265            context.rectangle (0, 0, (int) Math.round (area.width), (int) Math.round (area.height));
266            context.fill ();
267        }
268
269        // Move to the currently selected area.
270        context.translate (-area.bounds.x1, -area.bounds.y1);
271
272        // Render the selected area.
273        canvas.render (context, null, canvas.current_scale);
274
275        // Create pixbuf from stream.
276        try {
277            loader = new Gdk.PixbufLoader.with_mime_type ("image/png");
278        } catch (Error e) {
279            throw (e);
280        }
281
282        surface.write_to_png_stream ((data) => {
283            try {
284                loader.write ((uint8 []) data);
285            } catch (Error e) {
286                return Cairo.Status.DEVICE_ERROR;
287            }
288            return Cairo.Status.SUCCESS;
289        });
290        var scaled = rescale_image (loader.get_pixbuf ());
291
292        try {
293            loader.close ();
294        } catch (Error e) {
295            throw (e);
296        }
297
298        pixbufs.set (_("Untitled"), scaled);
299    }
300
301    public void generate_selection_pixbuf () throws Error {
302        // Clear pixbuf array from previously stored values.
303        pixbufs.clear ();
304
305        if (settings.export_format == "png") {
306            format = Cairo.Format.ARGB32;
307        } else if (settings.export_format == "jpg") {
308            format = Cairo.Format.RGB24;
309        }
310
311        // Loop through all the currently selected elements.
312        for (var i = 0; i < canvas.selected_bound_manager.selected_items.length (); i++) {
313            var item = canvas.selected_bound_manager.selected_items.nth_data (i);
314            var name = _("Untitled %i").printf (i);
315
316            // Weird goocanvas issue which sets the border to 0.**** instead of 0
317            // which causes a half pixel white border on export.
318            if (item.line_width < 1) {
319                var fill_color = item.fill_color_rgba;
320                item.set ("stroke-color-rgba", fill_color);
321                item.set ("line-width", 0.0);
322            }
323
324            // If the item is an artboard, account for the label's height.
325            if (item is Akira.Lib.Items.CanvasArtboard) {
326                var artboard = item as Akira.Lib.Items.CanvasArtboard;
327                name = artboard.name.name;
328            }
329
330            // Hide the ghost item.
331            ((Lib.Canvas) item.canvas).toggle_item_ghost (false);
332
333            // Always use the item's bounds so we can include borders.
334            double x1 = item.bounds.x1;
335            double x2 = item.bounds.x2;
336            double y1 = item.bounds.y1;
337            double y2 = item.bounds.y2;
338
339            // If the item is an artboard, use the bounds of the background item
340            // since the CanvasGroup bounds will grow based on the position of its children.
341            if (item is Items.CanvasArtboard) {
342                var item_artboard = item as Items.CanvasArtboard;
343                x1 = item_artboard.background.bounds.x1;
344                x2 = item_artboard.background.bounds.x2;
345                y1 = item_artboard.background.bounds.y1;
346                y2 = item_artboard.background.bounds.y2;
347            }
348
349            // Create the rendered image with Cairo.
350            surface = new Cairo.ImageSurface (
351                format,
352                (int) Math.round (x2 - x1),
353                (int) Math.round (y2 - y1)
354            );
355            context = new Cairo.Context (surface);
356
357            // Draw a white background if JPG export.
358            if (settings.export_format == "jpg" || !settings.export_alpha) {
359                context.set_source_rgba (1, 1, 1, 1);
360                context.rectangle (
361                    0, 0,
362                    (int) Math.round (x2 - x1),
363                    (int) Math.round (y2 - y1)
364                );
365                context.fill ();
366            }
367
368            // Move to the currently selected item.
369            context.translate (-x1, -y1);
370
371            // Render the selected item.
372            canvas.render (context, null, canvas.current_scale);
373
374            // Create pixbuf from stream.
375            try {
376                loader = new Gdk.PixbufLoader.with_mime_type ("image/png");
377            } catch (Error e) {
378                throw (e);
379            }
380
381            surface.write_to_png_stream ((data) => {
382                try {
383                    loader.write ((uint8 []) data);
384                } catch (Error e) {
385                    return Cairo.Status.DEVICE_ERROR;
386                }
387                return Cairo.Status.SUCCESS;
388            });
389            var scaled = rescale_image (loader.get_pixbuf (), item);
390
391            try {
392                loader.close ();
393            } catch (Error e) {
394                throw (e);
395            }
396
397            pixbufs.set (name, scaled);
398        }
399    }
400
401    public Gdk.Pixbuf rescale_image (Gdk.Pixbuf pixbuf, Lib.Items.CanvasItem? item = null) {
402        Gdk.Pixbuf scaled_image;
403
404        double width, height;
405
406        // If the item is null it means we're dealing with a custom area and we
407        // don't have the bounds manager.
408        if (item != null) {
409            // Use the item's bounds to include the border.
410            double x1 = item.bounds.x1;
411            double x2 = item.bounds.x2;
412            double y1 = item.bounds.y1;
413            double y2 = item.bounds.y2;
414
415            // If the item is an artboard, use the bounds of the background item
416            // since the CanvasGroup bounds will grow based on the position of its children.
417            if (item is Items.CanvasArtboard) {
418                var item_artboard = item as Items.CanvasArtboard;
419                x1 = item_artboard.background.bounds.x1;
420                x2 = item_artboard.background.bounds.x2;
421                y1 = item_artboard.background.bounds.y1;
422                y2 = item_artboard.background.bounds.y2;
423            }
424
425            width = x2 - x1;
426            height = y2 - y1;
427        } else {
428            width = area.width;
429            height = area.height;
430        }
431
432        switch (settings.export_scale) {
433            case 0:
434                scaled_image = pixbuf.scale_simple (
435                    (int) width / 2,
436                    (int) height / 2,
437                    Gdk.InterpType.BILINEAR
438                );
439                break;
440
441            case 2:
442                scaled_image = pixbuf.scale_simple (
443                    (int) width * 2,
444                    (int) height * 2,
445                    Gdk.InterpType.BILINEAR
446                );
447                break;
448
449            case 3:
450                scaled_image = pixbuf.scale_simple (
451                    (int) width * 4,
452                    (int) height * 4,
453                    Gdk.InterpType.BILINEAR
454                );
455                break;
456
457            default:
458                scaled_image = pixbuf.scale_simple (
459                    (int) width * 1,
460                    (int) height * 1,
461                    Gdk.InterpType.BILINEAR
462                );
463                break;
464        }
465
466        return scaled_image;
467    }
468
469    public void trigger_export_dialog (Type type) {
470        // Disable all those accels interfering with regular typing.
471        canvas.window.event_bus.disconnect_typing_accel ();
472
473        export_dialog = new Akira.Dialogs.ExportDialog (canvas.window, this, type);
474        export_dialog.show_all ();
475        export_dialog.present ();
476
477        // Update the dialog UI based on the stored gsettings options.
478        export_dialog.update_format_ui ();
479
480        // Store the dialog size into gsettings users don't get upset.
481        export_dialog.close.connect (() => {
482            int width, height;
483
484            export_dialog.get_size (out width, out height);
485            settings.export_width = width;
486            settings.export_height = height;
487
488            canvas.window.event_bus.connect_typing_accel ();
489            canvas.window.event_bus.set_focus_on_canvas ();
490
491            // Clean up the Manager.
492            context = null;
493            surface = null;
494            clear ();
495        });
496    }
497
498    public async void export_images () {
499        canvas.window.event_bus.exporting (_("Exporting images…"));
500
501        SourceFunc callback = export_images.callback;
502
503        new Thread<void*> (null, () => {
504            for (int i = 0; i < export_dialog.list_store.get_n_items (); i++) {
505                var model = (Akira.Models.ExportModel) export_dialog.list_store.get_object (i);
506
507                try {
508                    if (settings.export_format == "png") {
509                        model.pixbuf.save (
510                            settings.export_folder + "/" + model.filename + ".png",
511                            "png",
512                            "compression",
513                            settings.export_compression.to_string (),
514                            null);
515                    } else if (settings.export_format == "jpg") {
516                        model.pixbuf.save (
517                            settings.export_folder + "/" + model.filename + ".jpg",
518                            "jpeg",
519                            "quality",
520                            settings.export_quality.to_string (),
521                            null);
522                    }
523                } catch (Error e) {
524                    error ("Unable to export images: %s", e.message);
525                }
526            }
527
528            Idle.add ((owned) callback);
529            Thread.exit (null);
530
531            return null;
532        });
533
534        yield;
535
536        canvas.window.event_bus.export_completed ();
537    }
538}
539