1 /*
2  * SavePlotAsPdfDialog.java
3  *
4  * Copyright (C) 2021 by RStudio, PBC
5  *
6  * Unless you have received this program directly from RStudio pursuant
7  * to the terms of a commercial license agreement with RStudio, then
8  * this program is licensed to you under the terms of version 3 of the
9  * GNU Affero General Public License. This program is distributed WITHOUT
10  * ANY EXPRESS OR IMPLIED WARRANTY, INCLUDING THOSE OF NON-INFRINGEMENT,
11  * MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. Please refer to the
12  * AGPL (http://www.gnu.org/licenses/agpl-3.0.txt) for more details.
13  *
14  */
15 package org.rstudio.studio.client.workbench.views.plots.ui.export;
16 
17 import java.util.ArrayList;
18 import java.util.List;
19 
20 import com.google.gwt.aria.client.Roles;
21 import com.google.gwt.safehtml.shared.SafeHtmlUtils;
22 import org.rstudio.core.client.BrowseCap;
23 import org.rstudio.core.client.ElementIds;
24 import org.rstudio.core.client.files.FileSystemContext;
25 import org.rstudio.core.client.files.FileSystemItem;
26 import org.rstudio.core.client.widget.FieldSetWrapperPanel;
27 import org.rstudio.core.client.widget.FormLabel;
28 import org.rstudio.core.client.widget.LayoutGrid;
29 import org.rstudio.core.client.widget.ModalDialogBase;
30 import org.rstudio.core.client.widget.Operation;
31 import org.rstudio.core.client.widget.OperationWithInput;
32 import org.rstudio.core.client.widget.ProgressIndicator;
33 import org.rstudio.core.client.widget.ProgressOperationWithInput;
34 import org.rstudio.core.client.widget.ThemedButton;
35 import org.rstudio.studio.client.RStudioGinjector;
36 import org.rstudio.studio.client.common.FileDialogs;
37 import org.rstudio.studio.client.common.GlobalDisplay;
38 import org.rstudio.studio.client.server.Bool;
39 import org.rstudio.studio.client.server.ServerRequestCallback;
40 import org.rstudio.studio.client.workbench.exportplot.ExportPlotResources;
41 import org.rstudio.studio.client.workbench.exportplot.ExportPlotUtils;
42 import org.rstudio.studio.client.workbench.model.SessionInfo;
43 import org.rstudio.studio.client.workbench.views.plots.model.PlotsServerOperations;
44 import org.rstudio.studio.client.workbench.views.plots.model.SavePlotAsPdfOptions;
45 
46 import com.google.gwt.dom.client.Style.Unit;
47 import com.google.gwt.event.dom.client.ChangeEvent;
48 import com.google.gwt.event.dom.client.ChangeHandler;
49 import com.google.gwt.event.dom.client.ClickEvent;
50 import com.google.gwt.event.dom.client.ClickHandler;
51 import com.google.gwt.i18n.client.NumberFormat;
52 import com.google.gwt.user.client.ui.CheckBox;
53 import com.google.gwt.user.client.ui.Composite;
54 import com.google.gwt.user.client.ui.HTML;
55 import com.google.gwt.user.client.ui.HasVerticalAlignment;
56 import com.google.gwt.user.client.ui.HorizontalPanel;
57 import com.google.gwt.user.client.ui.Label;
58 import com.google.gwt.user.client.ui.ListBox;
59 import com.google.gwt.user.client.ui.RadioButton;
60 import com.google.gwt.user.client.ui.TextBox;
61 import com.google.gwt.user.client.ui.VerticalPanel;
62 import com.google.gwt.user.client.ui.Widget;
63 
64 public class SavePlotAsPdfDialog extends ModalDialogBase
65 {
SavePlotAsPdfDialog(GlobalDisplay globalDisplay, PlotsServerOperations server, final SessionInfo sessionInfo, FileSystemItem defaultDirectory, String defaultPlotName, final SavePlotAsPdfOptions options, double plotWidth, double plotHeight, final OperationWithInput<SavePlotAsPdfOptions> onClose)66    public SavePlotAsPdfDialog(GlobalDisplay globalDisplay,
67                               PlotsServerOperations server,
68                               final SessionInfo sessionInfo,
69                               FileSystemItem defaultDirectory,
70                               String defaultPlotName,
71                               final SavePlotAsPdfOptions options,
72                               double plotWidth,
73                               double plotHeight,
74                               final OperationWithInput<SavePlotAsPdfOptions> onClose)
75    {
76       super(Roles.getDialogRole());
77       setText("Save Plot as PDF");
78 
79       globalDisplay_ = globalDisplay;
80       sessionInfo_ = sessionInfo;
81       server_ = server;
82       defaultDirectory_ = defaultDirectory;
83       defaultPlotName_ = defaultPlotName;
84       options_ = options;
85       plotWidth_ = plotWidth;
86       plotHeight_ = plotHeight;
87 
88       progressIndicator_ = addProgressIndicator();
89 
90       ThemedButton saveButton = new ThemedButton("Save",
91                                                  new ClickHandler() {
92          public void onClick(ClickEvent event)
93          {
94             attemptSavePdf(false, new Operation() {
95                @Override
96                public void execute()
97                {
98                   // get options to send back to caller for persistence
99                   PaperSize paperSize = paperSizeEditor_.selectedPaperSize();
100                   SavePlotAsPdfOptions pdfOptions = SavePlotAsPdfOptions.create(
101                                              paperSize.getWidth(),
102                                              paperSize.getHeight(),
103                                              isPortraitOrientation(),
104                                              useCairoPdf(),
105                                              viewAfterSaveCheckBox_.getValue());
106 
107                   onClose.execute(pdfOptions);
108 
109                   closeDialog();
110                }
111             });
112          }
113       });
114       addOkButton(saveButton);
115       addCancelButton();
116 
117 
118       ThemedButton previewButton =  new ThemedButton("Preview",
119                                                      new ClickHandler() {
120          @Override
121          public void onClick(ClickEvent event)
122          {
123             // get temp file for preview
124             FileSystemItem tempDir =
125                   FileSystemItem.createDir(sessionInfo.getTempDir());
126             FileSystemItem previewPath =
127                   FileSystemItem.createFile(tempDir.completePath("preview.pdf"));
128 
129             // invoke handler
130             SavePlotAsHandler handler = createSavePlotAsHandler();
131             handler.attemptSave(previewPath, true, true, null);
132          }
133       });
134       addLeftButton(previewButton, ElementIds.PREVIEW_BUTTON);
135    }
136 
137    @Override
focusInitialControl()138    protected void focusInitialControl()
139    {
140       fileNameTextBox_.setFocus(true);
141       fileNameTextBox_.selectAll();
142    }
143 
144    @Override
createMainWidget()145    protected Widget createMainWidget()
146    {
147       ExportPlotResources.Styles styles = ExportPlotResources.INSTANCE.styles();
148 
149       LayoutGrid grid = new LayoutGrid(7, 2);
150       grid.setStylePrimaryName(styles.savePdfMainWidget());
151 
152       // paper size
153       Label sizeLabel = new Label("PDF Size:");
154       grid.setWidget(0, 0, sizeLabel);
155 
156       // paper size label
157       paperSizeEditor_ = new PaperSizeEditor(sizeLabel);
158       grid.setWidget(0, 1, paperSizeEditor_);
159 
160       // orientation
161       Label orientationLabel = new Label("Orientation:");
162       grid.setWidget(1, 0, orientationLabel);
163       HorizontalPanel orientationPanel = new HorizontalPanel();
164       orientationPanel.setSpacing(kComponentSpacing);
165       VerticalPanel orientationGroupPanel = new VerticalPanel();
166       FieldSetWrapperPanel<VerticalPanel> orientationButtons =
167             new FieldSetWrapperPanel<>(orientationGroupPanel, orientationLabel);
168       final String kOrientationGroup = "Orientation";
169       portraitRadioButton_ = new RadioButton(kOrientationGroup, "Portrait");
170       orientationGroupPanel.add(portraitRadioButton_);
171       landscapeRadioButton_ = new RadioButton(kOrientationGroup, "Landscape");
172       orientationGroupPanel.add(landscapeRadioButton_);
173       orientationPanel.add(orientationButtons);
174       grid.setWidget(1, 1, orientationPanel);
175 
176       boolean haveCairoPdf = sessionInfo_.isCairoPdfAvailable();
177       if (haveCairoPdf)
178          grid.setWidget(2,  0, new Label("Options:"));
179       HorizontalPanel cairoPdfPanel = new HorizontalPanel();
180       String label = "Use cairo_pdf device";
181       if (BrowseCap.isMacintoshDesktop())
182          label = label + " (requires X11)";
183       chkCairoPdf_ = new CheckBox(label);
184       chkCairoPdf_.getElement().getStyle().setMarginLeft(kComponentSpacing,
185                                                          Unit.PX);
186       cairoPdfPanel.add(chkCairoPdf_);
187       chkCairoPdf_.setValue(haveCairoPdf && options_.getCairoPdf());
188       if (haveCairoPdf)
189          grid.setWidget(2, 1, cairoPdfPanel);
190 
191       grid.setWidget(3, 0, new HTML("&nbsp;"));
192 
193       ThemedButton directoryButton = new ThemedButton("Directory...");
194       directoryButton.setStylePrimaryName(styles.directoryButton());
195       directoryButton.getElement().getStyle().setMarginLeft(-2, Unit.PX);
196       grid.setWidget(4, 0, directoryButton);
197       directoryButton.addClickHandler(new ClickHandler() {
198          @Override
199          public void onClick(ClickEvent event)
200          {
201             fileDialogs_.chooseFolder(
202                "Choose Directory",
203                fileSystemContext_,
204                FileSystemItem.createDir(directoryTextBox_.getText().trim()),
205                new ProgressOperationWithInput<FileSystemItem>() {
206 
207                  public void execute(FileSystemItem input,
208                                      ProgressIndicator indicator)
209                  {
210                     if (input == null)
211                        return;
212 
213                     indicator.onCompleted();
214 
215                     // update default
216                     ExportPlotUtils.setDefaultSaveDirectory(input);
217 
218                     // set display
219                     setDirectory(input);
220                  }
221                });
222          }
223       });
224 
225 
226       directoryTextBox_ = new TextBox();
227       directoryTextBox_.setReadOnly(true);
228       Roles.getTextboxRole().setAriaLabelProperty(directoryTextBox_.getElement(), "Selected Directory");
229       setDirectory(defaultDirectory_);
230       directoryTextBox_.setStylePrimaryName(styles.savePdfDirectoryTextBox());
231       grid.setWidget(4, 1, directoryTextBox_);
232 
233       fileNameTextBox_ = new TextBox();
234       fileNameTextBox_.setText(defaultPlotName_);
235       fileNameTextBox_.setStylePrimaryName(styles.savePdfFileNameTextBox());
236       FormLabel fileNameLabel = new FormLabel("File name:", fileNameTextBox_);
237       fileNameLabel.setStylePrimaryName(styles.savePdfFileNameLabel());
238       grid.setWidget(5, 0, fileNameLabel);
239       grid.setWidget(5, 1, fileNameTextBox_);
240 
241 
242       // view after size
243       viewAfterSaveCheckBox_ = new CheckBox("View plot after saving");
244       viewAfterSaveCheckBox_.addStyleName(styles.savePdfViewAfterCheckbox());
245       viewAfterSaveCheckBox_.setValue(options_.getViewAfterSave());
246       grid.setWidget(6, 1, viewAfterSaveCheckBox_);
247 
248       // set default value
249       if (options_.getPortrait())
250          portraitRadioButton_.setValue(true);
251       else
252          landscapeRadioButton_.setValue(true);
253 
254       // return the widget
255       return grid;
256    }
257 
attemptSavePdf(boolean overwrite, final Operation onCompleted)258    private void attemptSavePdf(boolean overwrite,
259                                final Operation onCompleted)
260    {
261       // validate file name
262       FileSystemItem targetPath = getTargetPath();
263       if (targetPath == null)
264       {
265          globalDisplay_.showErrorMessage(
266             "File Name Required",
267             "You must provide a file name for the plot pdf.",
268             fileNameTextBox_);
269          return;
270       }
271 
272       // invoke handler
273       SavePlotAsHandler handler = createSavePlotAsHandler();
274       handler.attemptSave(targetPath,
275                           overwrite,
276                           viewAfterSaveCheckBox_.getValue(),
277                           onCompleted);
278    }
279 
280 
281 
getTargetPath()282    private FileSystemItem getTargetPath()
283    {
284       return ExportPlotUtils.composeTargetPath(".pdf", fileNameTextBox_, directory_);
285    }
286 
setDirectory(FileSystemItem directory)287    private void setDirectory(FileSystemItem directory)
288    {
289       // set directory
290       directory_ = directory;
291 
292       // set label
293       String dirLabel = ExportPlotUtils.shortDirectoryName(directory, 250);
294       directoryTextBox_.setText(dirLabel);
295    }
296 
297 
298 
299 
isPortraitOrientation()300    private boolean isPortraitOrientation()
301    {
302       return portraitRadioButton_.getValue();
303    }
304 
useCairoPdf()305    private boolean useCairoPdf()
306    {
307       return chkCairoPdf_.getValue();
308    }
309 
310    private class PaperSize
311    {
PaperSize(String name, double width, double height)312       public PaperSize(String name, double width, double height)
313       {
314          name_ = name;
315          width_ = width;
316          height_ = height;
317       }
318 
getName()319       public String getName() { return name_; }
getWidth()320       public double getWidth() { return width_; }
getHeight()321       public double getHeight() { return height_; }
322 
323       private final String name_;
324       private final double width_;
325       private final double height_;
326    }
327 
createSavePlotAsHandler()328    private SavePlotAsHandler createSavePlotAsHandler()
329    {
330       return new SavePlotAsHandler(
331          globalDisplay_,
332          progressIndicator_,
333          new SavePlotAsHandler.ServerOperations()
334          {
335             @Override
336             public void savePlot(
337                   FileSystemItem targetPath,
338                   boolean overwrite,
339                   ServerRequestCallback<Bool> requestCallback)
340             {
341                PaperSize paperSize = paperSizeEditor_.selectedPaperSize();
342                double width = paperSize.getWidth();
343                double height = paperSize.getHeight();
344                if (!isPortraitOrientation())
345                {
346                   width = paperSize.getHeight();
347                   height = paperSize.getWidth();
348                }
349 
350                server_.savePlotAsPdf(targetPath,
351                                      width,
352                                      height,
353                                      chkCairoPdf_.getValue(),
354                                      overwrite,
355                                      requestCallback);
356             }
357 
358             @Override
359             public String getFileUrl(FileSystemItem path)
360             {
361                return server_.getFileUrl(path);
362             }
363          });
364    }
365 
366    private class PaperSizeEditor extends Composite
367    {
368       public PaperSizeEditor(Label visibleLabel)
369       {
370          ExportPlotResources.Styles styles =
371                                        ExportPlotResources.INSTANCE.styles();
372 
373          paperSizes_.add(new PaperSize("US Letter", 8.5, 11));
374          paperSizes_.add(new PaperSize("US Legal", 8.5, 14));
375          paperSizes_.add(new PaperSize("A4", 8.27, 11.69));
376          paperSizes_.add(new PaperSize("A5", 5.83, 8.27));
377          paperSizes_.add(new PaperSize("A6", 4.13, 5.83));
378          paperSizes_.add(new PaperSize("4 x 6 in.", 4, 6));
379          paperSizes_.add(new PaperSize("5 x 7 in.", 5, 7));
380          paperSizes_.add(new PaperSize("6 x 8 in.", 6, 8));
381 
382          FieldSetWrapperPanel<HorizontalPanel> panel = new FieldSetWrapperPanel<>(
383                new HorizontalPanel(), visibleLabel);
384          panel.getPanel().setVerticalAlignment(HasVerticalAlignment.ALIGN_MIDDLE);
385          panel.getPanel().setSpacing(kComponentSpacing);
386 
387          // paper size list box
388          int selectedPaperSize = -1;
389          paperSizeListBox_ = new ListBox();
390          paperSizeListBox_.setStylePrimaryName(styles.savePdfSizeListBox());
391          Roles.getListboxRole().setAriaLabelProperty(paperSizeListBox_.getElement(), "Size Preset");
392          for (int i = 0; i < paperSizes_.size(); i++)
393          {
394             PaperSize paperSize = paperSizes_.get(i);
395             paperSizeListBox_.addItem(paperSize.getName());
396             if (paperSize.getWidth() == options_.getWidth() &&
397                 paperSize.getHeight() == options_.getHeight())
398             {
399                selectedPaperSize = i;
400             }
401          }
402          PaperSize customPaperSize = new PaperSize("(Device Size)",
403                                                    plotWidth_,
404                                                    plotHeight_);
405          paperSizes_.add(customPaperSize);
406          paperSizeListBox_.addItem(customPaperSize.getName());
407 
408          if (selectedPaperSize == -1)
409          {
410             setCustomPaperSize(plotWidth_, plotHeight_);
411             selectedPaperSize = paperSizes_.size() - 1;
412          }
413 
414          paperSizeListBox_.addChangeHandler(new ChangeHandler() {
415             public void onChange(ChangeEvent event)
416             {
417                updateSizeDescription();
418             }
419          });
420          panel.add(paperSizeListBox_);
421 
422          HorizontalPanel editPanel = new HorizontalPanel();
423          widthTextBox_ = new TextBox();
424          widthTextBox_.setStylePrimaryName(styles.savePdfPaperSizeTextBox());
425          Roles.getTextboxRole().setAriaLabelProperty(widthTextBox_.getElement(), "Width");
426          widthTextBox_.addChangeHandler(sizeTextBoxChangeHandler_);
427          editPanel.add(widthTextBox_);
428 
429          Label label = new Label();
430          label.getElement().setInnerSafeHtml(SafeHtmlUtils.fromSafeConstant("&times;"));
431          label.setStylePrimaryName(styles.savePdfPaperSizeX());
432          editPanel.add(label);
433 
434          heightTextBox_ = new TextBox();
435          heightTextBox_.setStylePrimaryName(styles.savePdfPaperSizeTextBox());
436          Roles.getTextboxRole().setAriaLabelProperty(heightTextBox_.getElement(), "Height");
437          heightTextBox_.addChangeHandler(sizeTextBoxChangeHandler_);
438          editPanel.add(heightTextBox_);
439          panel.add(editPanel);
440 
441          Label inchesLabel = new Label("inches");
442          inchesLabel.setStylePrimaryName(styles.savePdfPaperSizeX());
443          editPanel.add(inchesLabel);
444 
445          paperSizeListBox_.setSelectedIndex(selectedPaperSize);
446          updateSizeDescription();
447 
448          initWidget(panel);
449       }
450 
451       public PaperSize selectedPaperSize()
452       {
453          int selectedSize =  paperSizeListBox_.getSelectedIndex();
454          return paperSizes_.get(selectedSize);
455       }
456 
457       private void updateSizeDescription()
458       {
459          setPaperSize(selectedPaperSize());
460       }
461 
462       private void setPaperSize(PaperSize paperSize)
463       {
464          widthTextBox_.setText(sizeFormat_.format(paperSize.getWidth()));
465          heightTextBox_.setText(sizeFormat_.format(paperSize.getHeight()));
466       }
467 
468       private void setCustomPaperSize(double width, double height)
469       {
470          paperSizes_.remove(paperSizes_.size() - 1);
471          paperSizes_.add(new PaperSize("(Custom)", width, height));
472       }
473 
474       private ChangeHandler sizeTextBoxChangeHandler_ = new ChangeHandler() {
475          @Override
476          public void onChange(ChangeEvent event)
477          {
478             // read width and height
479             PaperSize defaultSize = selectedPaperSize();
480             double width = readSizeEntry(widthTextBox_, defaultSize.getWidth());
481             double height = readSizeEntry(heightTextBox_,
482                                           defaultSize.getHeight());
483 
484             // see if it matches an existing size
485             int sizeIndex = -1;
486             for (int i=0; i<paperSizes_.size(); i++)
487             {
488                PaperSize paperSize = paperSizes_.get(i);
489                if (paperSize.getWidth() == width &&
490                    paperSize.getHeight() == height)
491                {
492                   sizeIndex = i;
493                   break;
494                }
495             }
496 
497             // if it doesn't then update custom
498             if (sizeIndex == -1)
499             {
500                setCustomPaperSize(width, height);
501                sizeIndex = paperSizes_.size() - 1;
502             }
503 
504             // select
505             paperSizeListBox_.setSelectedIndex(sizeIndex);
506          }
507       };
508 
509       private double readSizeEntry(TextBox textBox, double defaultValue)
510       {
511          double size = defaultValue;
512          try
513          {
514             size = Double.parseDouble(textBox.getText().trim());
515 
516             if (size < kMimimumSize)
517                size = defaultValue;
518             else if (size > kMaximumSize)
519                size = defaultValue;
520          }
521          catch(NumberFormatException e)
522          {
523          }
524          textBox.setText(sizeFormat_.format(size));
525          return size;
526       }
527 
528 
529       private ListBox paperSizeListBox_;
530       private final TextBox widthTextBox_;
531       private final TextBox heightTextBox_;
532       private final List<PaperSize> paperSizes_ = new ArrayList<>();
533       private final NumberFormat sizeFormat_ = NumberFormat.getFormat("##0.00");
534 
535       private final double kMimimumSize = 3.0;
536       private final double kMaximumSize = 100.0;
537    }
538 
539 
540 
541    private final GlobalDisplay globalDisplay_;
542    private final SessionInfo sessionInfo_;
543    private final PlotsServerOperations server_;
544    private final SavePlotAsPdfOptions options_;
545    private final double plotWidth_;
546    private final double plotHeight_;
547    private final FileSystemItem defaultDirectory_;
548    private final String defaultPlotName_;
549    private final ProgressIndicator progressIndicator_;
550 
551    private TextBox fileNameTextBox_;
552    private FileSystemItem directory_;
553    private TextBox directoryTextBox_;
554    private PaperSizeEditor paperSizeEditor_;
555 
556    private RadioButton portraitRadioButton_;
557    private RadioButton landscapeRadioButton_;
558 
559    private CheckBox chkCairoPdf_;
560    private CheckBox viewAfterSaveCheckBox_;
561 
562    final int kComponentSpacing = 7;
563 
564    private final FileSystemContext fileSystemContext_ =
565       RStudioGinjector.INSTANCE.getRemoteFileSystemContext();
566 
567    private final FileDialogs fileDialogs_ =
568       RStudioGinjector.INSTANCE.getFileDialogs();
569 
570 }
571