1 /*
2  * NotebookPlots.cpp
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 
16 #include "SessionRmdNotebook.hpp"
17 #include "NotebookPlots.hpp"
18 #include "NotebookOutput.hpp"
19 #include "../SessionPlots.hpp"
20 
21 #include <boost/format.hpp>
22 
23 #include <core/BoostSignals.hpp>
24 #include <core/Exec.hpp>
25 #include <core/StringUtils.hpp>
26 
27 #include <core/system/FileMonitor.hpp>
28 
29 #include <session/SessionModuleContext.hpp>
30 
31 #include <r/RExec.hpp>
32 #include <r/RSexp.hpp>
33 #include <r/session/RGraphics.hpp>
34 #include <r/ROptions.hpp>
35 #include <r/RRoutines.hpp>
36 
37 #define kPlotPrefix "_rs_chunk_plot_"
38 #define kGoldenRatio 1.618
39 
40 using namespace rstudio::core;
41 
42 namespace rstudio {
43 namespace session {
44 namespace modules {
45 namespace rmarkdown {
46 namespace notebook {
47 namespace {
48 
isPlotPath(const FilePath & path)49 bool isPlotPath(const FilePath& path)
50 {
51    return path.hasExtensionLowerCase(".png") &&
52           string_utils::isPrefixOf(path.getStem(), kPlotPrefix);
53 }
54 
rs_recordExternalPlot(SEXP plotFilesSEXP)55 SEXP rs_recordExternalPlot(SEXP plotFilesSEXP)
56 {
57    std::vector<std::string> plotFiles;
58    if (r::sexp::fillVectorString(plotFilesSEXP, &plotFiles))
59    {
60       for (const std::string& plotFile : plotFiles)
61       {
62          if (plotFile.empty())
63             continue;
64          FilePath plot = module_context::resolveAliasedPath(plotFile);
65          if (!plot.exists())
66             continue;
67          events().onPlotOutput(plot, FilePath(), json::Value(), 0);
68       }
69    }
70    return R_NilValue;
71 }
72 
73 } // anonymous namespace
74 
PlotCapture()75 PlotCapture::PlotCapture() :
76    hasPlots_(false),
77    plotPending_(false),
78    lastOrdinal_(0)
79 {
80 }
81 
~PlotCapture()82 PlotCapture::~PlotCapture()
83 {
84 }
85 
processPlots(bool ignoreEmpty)86 void PlotCapture::processPlots(bool ignoreEmpty)
87 {
88    // ensure plot folder exists
89    if (!plotFolder_.exists())
90       return;
91 
92    // collect plots from the folder
93    std::vector<FilePath> folderContents;
94    Error error = plotFolder_.getChildren(folderContents);
95    if (error)
96       LOG_ERROR(error);
97 
98    // sort entries by filename
99    std::sort(
100             folderContents.begin(),
101             folderContents.end(),
102             std::less<FilePath>());
103 
104    for (const FilePath& path : folderContents)
105    {
106       if (isPlotPath(path))
107       {
108          // we might find an empty plot file if it hasn't been flushed to disk
109          // yet--ignore these
110          if (ignoreEmpty && path.getSize() == 0)
111             continue;
112 
113          // record height/width along with plot
114          json::Object metadata;
115          metadata["height"] = height_;
116          metadata["width"] = width_;
117          metadata["size_behavior"] = static_cast<int>(sizeBehavior_);
118 
119          // use cached conditions if we have them; otherwise, check accumulator
120          if (conditions_.empty())
121          {
122             metadata["conditions"] = endConditionCapture();
123          }
124          else
125          {
126             metadata["conditions"] = conditions_.front();
127             conditions_.pop_front();
128          }
129 
130          // emit the plot and the snapshot file
131          events().onPlotOutput(path, snapshotFile_, metadata, lastOrdinal_);
132 
133          // we've consumed the snapshot file, so clear it
134          snapshotFile_ = FilePath();
135          lastOrdinal_ = 0;
136 
137          // clean up the plot so it isn't emitted twice
138          error = path.removeIfExists();
139          if (error)
140             LOG_ERROR(error);
141       }
142    }
143 }
144 
saveSnapshot()145 void PlotCapture::saveSnapshot()
146 {
147    // no work to do if we don't have a display list to write
148    if (lastPlot_.isNil())
149       return;
150 
151    // if there's a plot on the device, write its display list before it's
152    // cleared for the next page
153    FilePath outputFile = plotFolder_.completePath(
154       core::system::generateUuid(false) + kDisplayListExt);
155 
156    Error error = r::exec::RFunction(
157          ".rs.saveNotebookGraphics",
158          lastPlot_.get(),
159          string_utils::utf8ToSystem(outputFile.getAbsolutePath())).call();
160 
161    if (error)
162       LOG_ERROR(error);
163    else
164       snapshotFile_ = outputFile;
165 
166    // clear the display list now that it's been written
167    lastPlot_.releaseNow();
168 }
169 
onExprComplete()170 void PlotCapture::onExprComplete()
171 {
172    r::sexp::Protect protect;
173 
174    // no action if no plots were created in this chunk
175    if (!hasPlots_)
176       return;
177 
178    // no action if nothing on device list (implies no graphics output)
179    if (!isGraphicsDeviceActive())
180       return;
181 
182    // finish capturing the conditions, if any
183    if (capturingConditions())
184       conditions_.push_back(endConditionCapture());
185 
186    // if we were expecting a new plot to be produced by the previous
187    // expression, process the plot folder
188    if (plotPending_)
189    {
190       plotPending_ = false;
191       processPlots(true);
192    }
193 
194    // check the current state of the graphics device against the last known
195    // state
196    SEXP plot = R_NilValue;
197    Error error = r::exec::RFunction("recordPlot").call(&plot, &protect);
198    if (error)
199    {
200       LOG_ERROR(error);
201       return;
202    }
203 
204    // detect changes and save last state
205    bool unchanged = false;
206    if (!lastPlot_.isNil())
207       r::exec::RFunction("identical", plot, lastPlot_.get()).call(&unchanged);
208    lastPlot_.set(plot);
209 
210    // if the state changed, reserve an ordinal at this position
211    if (!unchanged)
212    {
213       OutputPair pair = lastChunkOutput(docId_, chunkId_, nbCtxId_);
214       lastOrdinal_ = ++pair.ordinal;
215       pair.outputType = ChunkOutputPlot;
216       updateLastChunkOutput(docId_, chunkId_, pair);
217 
218       // notify the client so it can create a placeholder
219       json::Object unit;
220       unit[kChunkOutputType]    = static_cast<int>(ChunkOutputOrdinal);
221       unit[kChunkOutputValue]   = static_cast<int>(lastOrdinal_);
222       unit[kChunkOutputOrdinal] = static_cast<int>(lastOrdinal_);
223       json::Object placeholder;
224       placeholder[kChunkId]         = chunkId_;
225       placeholder[kChunkDocId]      = docId_;
226       placeholder[kChunkOutputPath] = unit;
227 
228       module_context::enqueClientEvent(ClientEvent(
229                client_events::kChunkOutput, placeholder));
230    }
231 
232 }
233 
removeGraphicsDevice()234 void PlotCapture::removeGraphicsDevice()
235 {
236    // take a snapshot of the last plot's display list before we turn off the
237    // device (if we haven't emitted it yet)
238    if (hasPlots_ &&
239        sizeBehavior_ == PlotSizeAutomatic &&
240        snapshotFile_.isEmpty())
241       saveSnapshot();
242 
243    // turn off the graphics device, if it was ever turned on -- this has the
244    // side effect of writing the device's remaining output to files
245    if (isGraphicsDeviceActive())
246    {
247       Error error = r::exec::RFunction("dev.off").call();
248       if (error)
249          LOG_ERROR(error);
250 
251       // some operations may trigger the graphics device without actually
252       // writing a plot; ignore these
253       if (hasPlots_)
254          processPlots(false);
255    }
256    hasPlots_ = false;
257 }
258 
onBeforeNewPlot()259 void PlotCapture::onBeforeNewPlot()
260 {
261    if (!lastPlot_.isNil())
262    {
263       // save the snapshot of the plot to disk
264       if (sizeBehavior_ == PlotSizeAutomatic)
265          saveSnapshot();
266    }
267    beginConditionCapture();
268    plotPending_ = true;
269    hasPlots_ = true;
270 }
271 
onNewPlot()272 void PlotCapture::onNewPlot()
273 {
274    beginConditionCapture();
275    hasPlots_ = true;
276    processPlots(true);
277 }
278 
279 // begins capturing plot output
connectPlots(const std::string & docId,const std::string & chunkId,const std::string & nbCtxId,double height,double width,PlotSizeBehavior sizeBehavior,const FilePath & plotFolder)280 core::Error PlotCapture::connectPlots(const std::string& docId,
281       const std::string& chunkId, const std::string& nbCtxId,
282       double height, double width, PlotSizeBehavior sizeBehavior,
283       const FilePath& plotFolder)
284 {
285    // save identifiers
286    docId_ = docId;
287    chunkId_ = chunkId;
288    nbCtxId_ = nbCtxId;
289 
290    // clean up any stale plots from the folder
291    plotFolder_ = plotFolder;
292    std::vector<FilePath> folderContents;
293    Error error = plotFolder.getChildren(folderContents);
294    if (error)
295       return error;
296 
297    for (const core::FilePath& file : folderContents)
298    {
299       // remove if it looks like a plot
300       if (isPlotPath(file))
301       {
302          error = file.remove();
303          if (error)
304          {
305             // this is non-fatal
306             LOG_ERROR(error);
307          }
308       }
309    }
310 
311    // infer height/width if only one is given
312    if (height == 0 && width > 0)
313       height = width / kGoldenRatio;
314    else if (height > 0 && width == 0)
315       width = height * kGoldenRatio;
316    width_ = width;
317    height_ = height;
318    sizeBehavior_ = sizeBehavior;
319 
320    // save old device option
321    deviceOption_.set(r::options::getOption("device"));
322 
323    // set option for notebook graphics device (must succeed)
324    error = setGraphicsOption();
325    if (error)
326       return error;
327 
328    onBeforeNewPlot_ = plots::events().onBeforeNewPlot.connect(
329          boost::bind(&PlotCapture::onBeforeNewPlot, this));
330 
331    onBeforeNewGridPage_ = plots::events().onBeforeNewGridPage.connect(
332          boost::bind(&PlotCapture::onBeforeNewPlot, this));
333 
334    onNewPlot_ = plots::events().onNewPlot.connect(
335          boost::bind(&PlotCapture::onNewPlot, this));
336 
337    NotebookCapture::connect();
338    return Success();
339 }
340 
disconnect()341 void PlotCapture::disconnect()
342 {
343    if (connected())
344    {
345       // remove the graphics device if we created it
346       removeGraphicsDevice();
347 
348       // restore the graphics device option
349       r::options::setOption("device", deviceOption_.get());
350 
351       onNewPlot_.disconnect();
352       onBeforeNewPlot_.disconnect();
353       onBeforeNewGridPage_.disconnect();
354    }
355    NotebookCapture::disconnect();
356 }
357 
setGraphicsOption()358 core::Error PlotCapture::setGraphicsOption()
359 {
360    Error error;
361 
362    // create the notebook graphics device
363    r::exec::RFunction setOption(".rs.setNotebookGraphicsOption");
364 
365    // the folder in which to place the rendered plots (this is a sibling of the
366    // main chunk output folder)
367    setOption.addParam(
368       string_utils::utf8ToSystem(plotFolder_.getAbsolutePath()) +
369             "/" kPlotPrefix "%03d.png");
370 
371    // device dimensions
372    setOption.addParam(height_);
373    setOption.addParam(width_);
374 
375    // sizing behavior drives units -- user specified units are in inches but
376    // we use pixels when scaling automatically
377    setOption.addParam(sizeBehavior_ == PlotSizeManual ? "in" : "px");
378 
379    // device parameters
380    setOption.addParam(r::session::graphics::device::devicePixelRatio());
381 
382    // other args (OS dependent)
383    setOption.addParam(r::session::graphics::extraBitmapParams());
384 
385    return setOption.call();
386 }
387 
isGraphicsDeviceActive()388 bool PlotCapture::isGraphicsDeviceActive()
389 {
390    r::sexp::Protect protect;
391    SEXP devlist = R_NilValue;
392    Error error = r::exec::RFunction("dev.list").call(&devlist, &protect);
393    if (error)
394    {
395       // We have seen AV crashes reading the contents of the devlist pointer, so if there's an error
396       // log it and presume the graphics device is not active. The result will be that we don't
397       // capture plots in this chunk since our graphics device doesn't appear to be engaged.
398       LOG_ERROR(error);
399       return false;
400    }
401    if (r::sexp::isNull(devlist))
402       return false;
403    return true;
404 }
405 
initPlots()406 core::Error initPlots()
407 {
408    RS_REGISTER_CALL_METHOD(rs_recordExternalPlot, 1);
409 
410    ExecBlock initBlock;
411    initBlock.addFunctions()
412       (boost::bind(module_context::sourceModuleRFile, "NotebookPlots.R"));
413 
414    return initBlock.execute();
415 }
416 
417 } // namespace notebook
418 } // namespace rmarkdown
419 } // namespace modules
420 } // namespace session
421 } // namespace rstudio
422 
423