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