1// Aseprite
2// Copyright (C) 2019-2022 Igara Studio S.A.
3// Copyright (C) 2001-2018 David Capello
4//
5// This program is distributed under the terms of
6// the End-User License Agreement for Aseprite.
7
8#ifdef HAVE_CONFIG_H
9#include "config.h"
10#endif
11
12#include "app/commands/cmd_save_file.h"
13
14#include "app/app.h"
15#include "app/commands/command.h"
16#include "app/commands/commands.h"
17#include "app/commands/params.h"
18#include "app/console.h"
19#include "app/context_access.h"
20#include "app/doc.h"
21#include "app/doc_undo.h"
22#include "app/file/file.h"
23#include "app/file/gif_format.h"
24#include "app/file/png_format.h"
25#include "app/file_selector.h"
26#include "app/i18n/strings.h"
27#include "app/job.h"
28#include "app/modules/gui.h"
29#include "app/pref/preferences.h"
30#include "app/recent_files.h"
31#include "app/restore_visible_layers.h"
32#include "app/ui/export_file_window.h"
33#include "app/ui/layer_frame_comboboxes.h"
34#include "app/ui/optional_alert.h"
35#include "app/ui/status_bar.h"
36#include "base/convert_to.h"
37#include "base/fs.h"
38#include "base/scoped_value.h"
39#include "base/thread.h"
40#include "doc/mask.h"
41#include "doc/sprite.h"
42#include "doc/tag.h"
43#include "fmt/format.h"
44#include "ui/ui.h"
45#include "undo/undo_state.h"
46
47namespace app {
48
49class SaveFileJob : public Job, public IFileOpProgress {
50public:
51 SaveFileJob(FileOp* fop)
52 : Job(Strings::save_file_saving().c_str())
53 , m_fop(fop)
54 {
55 }
56
57 void showProgressWindow() {
58 startJob();
59
60 if (isCanceled()) {
61 m_fop->stop();
62 }
63
64 waitJob();
65 }
66
67private:
68
69 // Thread to do the hard work: save the file to the disk.
70 virtual void onJob() override {
71 try {
72 m_fop->operate(this);
73 }
74 catch (const std::exception& e) {
75 m_fop->setError("Error saving file:\n%s", e.what());
76 }
77 m_fop->done();
78 }
79
80 virtual void ackFileOpProgress(double progress) override {
81 jobProgress(progress);
82 }
83
84 FileOp* m_fop;
85};
86
87//////////////////////////////////////////////////////////////////////
88
89SaveFileBaseCommand::SaveFileBaseCommand(const char* id, CommandFlags flags)
90 : CommandWithNewParams<SaveFileParams>(id, flags)
91{
92}
93
94void SaveFileBaseCommand::onLoadParams(const Params& params)
95{
96 CommandWithNewParams<SaveFileParams>::onLoadParams(params);
97
98 if (this->params().fromFrame.isSet() ||
99 this->params().toFrame.isSet()) {
100 doc::frame_t fromFrame = this->params().fromFrame();
101 doc::frame_t toFrame = this->params().toFrame();
102 m_selFrames.insert(fromFrame, toFrame);
103 m_adjustFramesByTag = true;
104 }
105 else {
106 m_selFrames.clear();
107 m_adjustFramesByTag = false;
108 }
109}
110
111// Returns true if there is a current sprite to save.
112// [main thread]
113bool SaveFileBaseCommand::onEnabled(Context* context)
114{
115 return context->checkFlags(ContextFlags::ActiveDocumentIsWritable);
116}
117
118std::string SaveFileBaseCommand::saveAsDialog(
119 Context* context,
120 const std::string& dlgTitle,
121 const std::string& initialFilename,
122 const MarkAsSaved markAsSaved,
123 const SaveInBackground saveInBackground,
124 const std::string& forbiddenFilename)
125{
126 Doc* document = context->activeDocument();
127
128 // Before we change the document filename to the copy, we save its
129 // preferences so in a future export operation the values persist,
130 // and we can re-export the original document with the same
131 // preferences.
132 Preferences::instance().save();
133
134 std::string filename = params().filename();
135 if (filename.empty() || params().ui()) {
136 base::paths exts = get_writable_extensions();
137 filename = initialFilename;
138
139#ifdef ENABLE_UI
140 if (context->isUIAvailable()) {
141 again:;
142 base::paths newfilename;
143 if (!params().ui() ||
144 !app::show_file_selector(
145 dlgTitle, filename, exts,
146 FileSelectorType::Save,
147 newfilename)) {
148 return std::string();
149 }
150
151 filename = newfilename.front();
152 if (!forbiddenFilename.empty() &&
153 base::normalize_path(forbiddenFilename) ==
154 base::normalize_path(filename)) {
155 ui::Alert::show(Strings::alerts_cannot_file_overwrite_on_export());
156 goto again;
157 }
158 }
159#endif // ENABLE_UI
160 }
161
162 if (filename.empty())
163 return std::string();
164
165 if (saveInBackground == SaveInBackground::On) {
166 saveDocumentInBackground(
167 context, document,
168 filename, markAsSaved);
169
170 // Reset the "saveCopy" document preferences of the new document
171 // (here "document" contains the new filename), because these
172 // preferences make sense only for the original document that was
173 // exported/copied, not for the new one.
174 //
175 // The new document (the copy) must have the default preferences
176 // just in case the user want to export it to other file (so a
177 // proper default export filename is calculated). This scenario is
178 // described here:
179 //
180 // https://github.com/aseprite/aseprite/issues/1964
181 //
182 auto& docPref = Preferences::instance().document(document);
183 docPref.saveCopy.clearSection();
184 }
185
186 return filename;
187}
188
189void SaveFileBaseCommand::saveDocumentInBackground(
190 const Context* context,
191 Doc* document,
192 const std::string& filename,
193 const MarkAsSaved markAsSaved,
194 const ResizeOnTheFly resizeOnTheFly,
195 const gfx::PointF& scale)
196{
197 if (params().aniDir.isSet()) {
198 switch (params().aniDir()) {
199 case AniDir::REVERSE:
200 m_selFrames = m_selFrames.makeReverse();
201 break;
202 case AniDir::PING_PONG:
203 m_selFrames = m_selFrames.makePingPong();
204 break;
205 }
206 }
207
208 gfx::Rect bounds;
209 if (params().bounds.isSet())
210 bounds = params().bounds();
211
212 FileOpROI roi(document, bounds,
213 params().slice(), params().tag(),
214 m_selFrames, m_adjustFramesByTag);
215
216 std::unique_ptr<FileOp> fop(
217 FileOp::createSaveDocumentOperation(
218 context,
219 roi,
220 filename,
221 params().filenameFormat(),
222 params().ignoreEmpty()));
223 if (!fop)
224 return;
225
226 if (resizeOnTheFly == ResizeOnTheFly::On)
227 fop->setOnTheFlyScale(scale);
228
229 SaveFileJob job(fop.get());
230 job.showProgressWindow();
231
232 if (fop->hasError()) {
233 Console console;
234 console.printf(fop->error().c_str());
235
236 // We don't know if the file was saved correctly or not. So mark
237 // it as it should be saved again.
238 document->impossibleToBackToSavedState();
239 }
240 // If the job was cancelled, mark the document as modified.
241 else if (fop->isStop()) {
242 document->impossibleToBackToSavedState();
243 }
244 else {
245 if (context->isUIAvailable() && params().ui())
246 App::instance()->recentFiles()->addRecentFile(filename);
247
248 if (markAsSaved == MarkAsSaved::On) {
249 document->markAsSaved();
250 document->setFilename(filename);
251 document->incrementVersion();
252 }
253
254#ifdef ENABLE_UI
255 if (context->isUIAvailable() && params().ui()) {
256 StatusBar::instance()->setStatusText(
257 2000, fmt::format(Strings::save_file_saved(),
258 base::get_file_name(filename)));
259 }
260#endif
261 }
262}
263
264//////////////////////////////////////////////////////////////////////
265
266class SaveFileCommand : public SaveFileBaseCommand {
267public:
268 SaveFileCommand();
269
270protected:
271 void onExecute(Context* context) override;
272};
273
274SaveFileCommand::SaveFileCommand()
275 : SaveFileBaseCommand(CommandId::SaveFile(), CmdRecordableFlag)
276{
277}
278
279// Saves the active document in a file.
280// [main thread]
281void SaveFileCommand::onExecute(Context* context)
282{
283 Doc* document = context->activeDocument();
284
285 // If the document is associated to a file in the file-system, we can
286 // save it directly without user interaction.
287 if (document->isAssociatedToFile()) {
288 const ContextReader reader(context);
289 const Doc* documentReader = reader.document();
290
291 saveDocumentInBackground(
292 context, document,
293 (params().filename.isSet() ? params().filename():
294 documentReader->filename()),
295 MarkAsSaved::On);
296 }
297 // If the document isn't associated to a file, we must to show the
298 // save-as dialog to the user to select for first time the file-name
299 // for this document.
300 else {
301 saveAsDialog(context, Strings::save_file_title(),
302 (params().filename.isSet() ? params().filename():
303 document->filename()),
304 MarkAsSaved::On);
305 }
306}
307
308class SaveFileAsCommand : public SaveFileBaseCommand {
309public:
310 SaveFileAsCommand();
311
312protected:
313 void onExecute(Context* context) override;
314};
315
316SaveFileAsCommand::SaveFileAsCommand()
317 : SaveFileBaseCommand(CommandId::SaveFileAs(), CmdRecordableFlag)
318{
319}
320
321void SaveFileAsCommand::onExecute(Context* context)
322{
323 Doc* document = context->activeDocument();
324 saveAsDialog(context, Strings::save_file_save_as(),
325 (params().filename.isSet() ? params().filename():
326 document->filename()),
327 MarkAsSaved::On);
328}
329
330class SaveFileCopyAsCommand : public SaveFileBaseCommand {
331public:
332 SaveFileCopyAsCommand();
333
334protected:
335 void onExecute(Context* context) override;
336
337private:
338 void moveToUndoState(Doc* doc,
339 const undo::UndoState* state);
340};
341
342SaveFileCopyAsCommand::SaveFileCopyAsCommand()
343 : SaveFileBaseCommand(CommandId::SaveFileCopyAs(), CmdRecordableFlag)
344{
345}
346
347void SaveFileCopyAsCommand::onExecute(Context* context)
348{
349 Doc* doc = context->activeDocument();
350 std::string outputFilename = params().filename();
351 std::string layers = kAllLayers;
352 int layersIndex = -1;
353 std::string frames = kAllFrames;
354 bool applyPixelRatio = false;
355 double scale = params().scale();
356 gfx::Rect bounds = params().bounds();
357 doc::AniDir aniDirValue = params().aniDir();
358 bool isForTwitter = false;
359
360#if ENABLE_UI
361 if (params().ui() && context->isUIAvailable()) {
362 ExportFileWindow win(doc);
363 bool askOverwrite = true;
364
365 win.SelectOutputFile.connect(
366 [this, &win, &askOverwrite, context, doc]() -> std::string {
367 std::string result =
368 saveAsDialog(
369 context, Strings::save_file_export(),
370 win.outputFilenameValue(),
371 MarkAsSaved::Off,
372 SaveInBackground::Off,
373 (doc->isAssociatedToFile() ? doc->filename():
374 std::string()));
375 if (!result.empty())
376 askOverwrite = false; // Already asked in the file selector dialog
377
378 return result;
379 });
380
381 if (params().filename.isSet()) {
382 std::string outputPath = base::get_file_path(outputFilename);
383 if (outputPath.empty()) {
384 outputPath = base::get_file_path(doc->filename());
385 outputFilename = base::join_path(outputPath, outputFilename);
386 }
387 win.setOutputFilename(outputFilename);
388 }
389
390 if (params().scale.isSet()) win.setResizeScale(scale);
391 if (params().aniDir.isSet()) win.setAniDir(aniDirValue);
392
393 if (params().slice.isSet()) win.setArea(params().slice());
394 else if (params().bounds.isSet() &&
395 doc->isMaskVisible() &&
396 doc->mask()->bounds() == params().bounds()) {
397 win.setArea(kSelectedCanvas);
398 }
399
400 win.remapWindow();
401 load_window_pos(&win, "ExportFile");
402 again:;
403 const bool result = win.show();
404 save_window_pos(&win, "ExportFile");
405 if (!result)
406 return;
407
408 outputFilename = win.outputFilenameValue();
409
410 if (askOverwrite &&
411 base::is_file(outputFilename)) {
412 int ret = OptionalAlert::show(
413 Preferences::instance().exportFile.showOverwriteFilesAlert,
414 1, // Yes is the default option when the alert dialog is disabled
415 fmt::format(Strings::alerts_overwrite_files_on_export(),
416 outputFilename));
417 if (ret != 1)
418 goto again;
419 }
420
421 // Save the preferences used to export the file, so if we open the
422 // window again, we will have the same options.
423 win.savePref();
424
425 layers = win.layersValue();
426 layersIndex = win.layersIndex();
427 frames = win.framesValue();
428 scale = win.resizeValue();
429 params().slice(win.areaValue()); // Set slice
430 if (win.areaValue() == kSelectedCanvas && doc->isMaskVisible())
431 bounds = doc->mask()->bounds();
432 applyPixelRatio = win.applyPixelRatio();
433 aniDirValue = win.aniDirValue();
434 isForTwitter = win.isForTwitter();
435 }
436#endif
437
438 gfx::PointF scaleXY(scale, scale);
439
440 // Pixel ratio
441 if (applyPixelRatio) {
442 doc::PixelRatio pr = doc->sprite()->pixelRatio();
443 scaleXY.x *= pr.w;
444 scaleXY.y *= pr.h;
445 }
446
447 // First of all we'll try to use the "on the fly" scaling, to avoid
448 // using a resize command to apply the export scale.
449 const undo::UndoState* undoState = nullptr;
450 bool undoResize = false;
451 const bool resizeOnTheFly = FileOp::checkIfFormatSupportResizeOnTheFly(outputFilename);
452 if (!resizeOnTheFly && (scaleXY.x != 1.0 ||
453 scaleXY.y != 1.0)) {
454 Command* resizeCmd = Commands::instance()->byId(CommandId::SpriteSize());
455 ASSERT(resizeCmd);
456 if (resizeCmd) {
457 int width = doc->sprite()->width();
458 int height = doc->sprite()->height();
459 int newWidth = int(double(width) * scaleXY.x);
460 int newHeight = int(double(height) * scaleXY.y);
461 if (newWidth < 1) newWidth = 1;
462 if (newHeight < 1) newHeight = 1;
463 if (width != newWidth || height != newHeight) {
464 doc->setInhibitBackup(true);
465 undoState = doc->undoHistory()->currentState();
466 undoResize = true;
467
468 Params params;
469 params.set("use-ui", "false");
470 params.set("width", base::convert_to<std::string>(newWidth).c_str());
471 params.set("height", base::convert_to<std::string>(newHeight).c_str());
472 params.set("resize-method", "nearest-neighbor"); // TODO add algorithm in the UI?
473 context->executeCommand(resizeCmd, params);
474 }
475 }
476 }
477
478 {
479 RestoreVisibleLayers layersVisibility;
480 if (context->isUIAvailable()) {
481 Site site = context->activeSite();
482
483 // Selected layers to export
484 calculate_visible_layers(site,
485 layers,
486 layersIndex,
487 layersVisibility);
488
489 // m_selFrames is not empty if fromFrame/toFrame parameters are
490 // specified.
491 if (m_selFrames.empty()) {
492 // Selected frames to export
493 SelectedFrames selFrames;
494 Tag* tag = calculate_selected_frames(
495 site, frames, selFrames);
496 if (tag)
497 params().tag(tag->name());
498 m_selFrames = selFrames;
499 }
500 m_adjustFramesByTag = false;
501 }
502
503 // Set other parameters
504 params().aniDir(aniDirValue);
505 if (!bounds.isEmpty())
506 params().bounds(bounds);
507
508 // TODO This should be set as options for the specific encoder
509 GifEncoderDurationFix fixGif(isForTwitter);
510 PngEncoderOneAlphaPixel fixPng(isForTwitter);
511
512 saveDocumentInBackground(
513 context, doc, outputFilename,
514 MarkAsSaved::Off,
515 (resizeOnTheFly ? ResizeOnTheFly::On:
516 ResizeOnTheFly::Off),
517 scaleXY);
518 }
519
520 // Undo resize
521 if (undoResize &&
522 undoState != doc->undoHistory()->currentState()) {
523 moveToUndoState(doc, undoState);
524 doc->setInhibitBackup(false);
525 }
526}
527
528void SaveFileCopyAsCommand::moveToUndoState(Doc* doc,
529 const undo::UndoState* state)
530{
531 try {
532 DocWriter writer(doc, 100);
533 doc->undoHistory()->moveToState(state);
534 doc->generateMaskBoundaries();
535 doc->notifyGeneralUpdate();
536 }
537 catch (const std::exception& ex) {
538 Console::showException(ex);
539 }
540}
541
542Command* CommandFactory::createSaveFileCommand()
543{
544 return new SaveFileCommand;
545}
546
547Command* CommandFactory::createSaveFileAsCommand()
548{
549 return new SaveFileAsCommand;
550}
551
552Command* CommandFactory::createSaveFileCopyAsCommand()
553{
554 return new SaveFileCopyAsCommand;
555}
556
557} // namespace app
558