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