From 7a3fa9427035aa975d21bfcc4255bb330df2f73d Mon Sep 17 00:00:00 2001 From: Andreas Suter Date: Fri, 11 Sep 2026 17:42:03 +0200 Subject: [PATCH] musrview: add "Save graphics" to the interactive (root7/web) viewer root6 musrview could save the plot as png/pdf/svg/...; the ROOT7 web rewrite dropped that. Add a "Save graphics" toolbar entry: the server renders the classic layout (same code path as --png/--pdf/... batch export) into the current working directory and hands it to the browser as a download, so the browser's own save dialog/download folder decides where it lands -- a native OS folder picker isn't reachable from here, since the --embedded window is a Qt6 QWebEngineView owned by ROOT itself, which auto-accepts downloads with no dialog. Also fixes rasterized batch export (png/gif/jpg) coming out blank when triggered from an already-running interactive session: TCanvas decides at *construction* time, from gROOT->IsBatch(), whether to build a real GUI canvas or a headless one, so canvas.SetBatch(kTRUE) after the fact was too late. Vector formats (pdf/eps/svg) don't need pixel readback and were unaffected, which is why this went unnoticed. Fixed by toggling gROOT's batch flag for the export canvas's lifetime. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017cuZPrSNaKjG8xHPVNwV16 --- src/classes/PMusrWebCanvas.cpp | 169 +++++++++++++++++++++++++-------- src/include/PMusrWebCanvas.h | 15 ++- src/musrview.cpp | 2 +- src/musrview_web/musrview.html | 30 ++++++ 4 files changed, 176 insertions(+), 40 deletions(-) diff --git a/src/classes/PMusrWebCanvas.cpp b/src/classes/PMusrWebCanvas.cpp index 3c772f34..66a8030f 100644 --- a/src/classes/PMusrWebCanvas.cpp +++ b/src/classes/PMusrWebCanvas.cpp @@ -253,6 +253,8 @@ void PMusrWebCanvas::Close() void PMusrWebCanvas::SetMsrHandler(PMsrHandler *msrHandler) { fModel->SetMsrHandler(msrHandler); + if (msrHandler) + fMsrFileName = msrHandler->GetFileName().Data(); } //-------------------------------------------------------------------------- @@ -870,14 +872,13 @@ void PMusrWebCanvas::ProcessMessage(unsigned connid, const std::string &msg) } if (msg.rfind("save:", 0) == 0) { - const std::string arg = msg.substr(5); - const size_t sep = arg.find(':'); - if (sep == std::string::npos) - return; - const std::string fileName = arg.substr(0, sep); - const std::string format = arg.substr(sep+1); - if (SaveGraphics(fileName, format)) - fWindow->Send(connid, "INFO:graphics written (" + JsonEscape(format) + ")"); + // save: - the file is written to the current working directory + // and handed to the browser as a download, so its own save dialog (or + // download folder) decides where it ends up + const std::string format = msg.substr(5); + std::string relUrl; + if (SaveGraphicsForDownload(format, relUrl)) + fWindow->Send(connid, "DOWNLOAD:" + JsonEscape(relUrl)); else fWindow->Send(connid, "ERROR:" + JsonEscape(fLastError)); return; @@ -926,6 +927,10 @@ Bool_t PMusrWebCanvas::Show(const std::string &where) fWindow->SetDefaultPage("file:" + webDir + "/musrview.html"); fWindow->SetGeometry(fWidth, fHeight); fWindow->SetConnLimit(1); + // lets the client download files written into the current working + // directory from "currentdir/", used by "Save graphics" (see + // SaveGraphicsForDownload()) + fWindow->SetUseCurrentDir(true); fWindow->SetDataCallBack([this](unsigned connid, const std::string &msg) { ProcessMessage(connid, msg); @@ -948,29 +953,20 @@ Bool_t PMusrWebCanvas::Show(const std::string &where) } //-------------------------------------------------------------------------- -// SaveGraphics (public) +// DefaultGraphicsBaseName (private) //-------------------------------------------------------------------------- /** - *

Writes the classic musrview layout (title, spectra, fit parameters, - * theory, run info) into a graphics file. + *

Derives the base name (no extension) under which this plot block's + * graphics are saved by default, mirroring the --png/--pdf/... batch export + * naming: _[_F]. Used both by the batch export and as the + * suggested name offered to the interactive "Save graphics" dialog. * - *

This path deliberately does not involve the browser: batch operation must - * not depend on a browser installation, and the output stays reproducible. - * - * \param msrFileName name of the msr-file; the output name is derived from it - * \param graphicsFormat extension such as "png", "pdf", "eps", ... - * - * \return true on success + * \return the base name, or empty if the msr-file name carries neither a + * ".msr" nor a ".mlog" extension to replace */ -Bool_t PMusrWebCanvas::SaveGraphics(const std::string &msrFileName, const std::string &graphicsFormat) +std::string PMusrWebCanvas::DefaultGraphicsBaseName() const { - if (!fValid) { - fLastError = "canvas is not valid."; - return false; - } - - // derive the output file name: _[_F]. - TString str(msrFileName.c_str()); + TString str(fMsrFileName.c_str()); Int_t idx = -1; Int_t size = 0; if (str.Contains(".msr")) { @@ -980,11 +976,8 @@ Bool_t PMusrWebCanvas::SaveGraphics(const std::string &msrFileName, const std::s idx = str.Index(".mlog"); size = 5; } - if (idx == -1) { - fLastError = "file name '" + msrFileName + "' is invalid."; - std::cerr << std::endl << ">> PMusrWebCanvas::SaveGraphics(): **ERROR** " << fLastError << std::endl; - return false; - } + if (idx == -1) + return std::string(); Char_t ext[64]; if (fMode.IsFourier()) @@ -992,10 +985,95 @@ Bool_t PMusrWebCanvas::SaveGraphics(const std::string &msrFileName, const std::s else snprintf(ext, sizeof(ext), "_%d", fPlotNumber); str.Replace(idx, size, ext, strlen(ext)); - idx += strlen(ext); - size = strlen(ext); - snprintf(ext, sizeof(ext), ".%s", graphicsFormat.c_str()); - str.Replace(idx, size, ext, strlen(ext)); + + return str.Data(); +} + +//-------------------------------------------------------------------------- +// SaveGraphics (public) +//-------------------------------------------------------------------------- +/** + *

Writes the classic musrview layout into a graphics file whose name is + * derived from the msr-file name. Used by the --png/--pdf/... batch export. + * + * \param graphicsFormat extension such as "png", "pdf", "eps", ... + * + * \return true on success + */ +Bool_t PMusrWebCanvas::SaveGraphics(const std::string &graphicsFormat) +{ + const std::string base = DefaultGraphicsBaseName(); + if (base.empty()) { + fLastError = "msr-file name '" + fMsrFileName + "' is invalid."; + std::cerr << std::endl << ">> PMusrWebCanvas::SaveGraphics(): **ERROR** " << fLastError << std::endl; + return false; + } + + return RenderGraphics(base + "." + graphicsFormat); +} + +//-------------------------------------------------------------------------- +// SaveGraphicsForDownload (public) +//-------------------------------------------------------------------------- +/** + *

Writes the classic musrview layout into the process' current working + * directory and reports the path under which the web window's "currentdir/" + * location (see Show()) makes it downloadable. + * + *

Used by the interactive "Save graphics" toolbar entry: rather than + * musrview picking a location on the (possibly remote) machine it runs on, + * the file is handed to the browser as a normal download, so the browser's + * own save dialog (or download folder) decides where it ends up. + * + * \param graphicsFormat extension such as "png", "pdf", "eps", ... + * \param relUrl the URL (relative to the web window) the file can be + * downloaded from, on success + * + * \return true on success + */ +Bool_t PMusrWebCanvas::SaveGraphicsForDownload(const std::string &graphicsFormat, std::string &relUrl) +{ + const std::string base = DefaultGraphicsBaseName(); + if (base.empty()) { + fLastError = "msr-file name '" + fMsrFileName + "' is invalid."; + return false; + } + + // ignore any directory the msr-file name may carry: the download is always + // served out of the current working directory (see Show()/currentdir/) + const std::string fileName = std::string(gSystem->BaseName(base.c_str())) + "." + graphicsFormat; + + if (!RenderGraphics(fileName)) + return false; + + relUrl = "currentdir/" + fileName; + return true; +} + +//-------------------------------------------------------------------------- +// RenderGraphics (private) +//-------------------------------------------------------------------------- +/** + *

Builds the classic musrview layout (title, spectra, fit parameters, + * theory, run info) and writes it into the given graphics file. + * + *

This path deliberately does not involve the browser: the file is written + * directly by ROOT from a batch TCanvas, so it works whether or not a browser + * is even installed, and interactive Save produces bit-for-bit the same + * output as the --png/--pdf/... batch export. + * + * \param fileName the file to write; its extension selects the format + * + * \return true on success + */ +Bool_t PMusrWebCanvas::RenderGraphics(const std::string &fileName) +{ + if (!fValid) { + fLastError = "canvas is not valid."; + return false; + } + + TString str(fileName.c_str()); // Build the classic layout. Its size is deliberately not the size of the // musrview window: the exported graphics have always been 800x600 and users @@ -1003,9 +1081,22 @@ Bool_t PMusrWebCanvas::SaveGraphics(const std::string &msrFileName, const std::s const Int_t exportWidth = 800; const Int_t exportHeight = 600; + // TCanvas decides at *construction* time, from gROOT->IsBatch(), whether to + // build a real (mapped) GUI canvas or a headless one; canvas.SetBatch(kTRUE) + // afterwards is too late to change that choice. In the interactive session + // gROOT is not in batch mode, so without this the export canvas would be a + // real, briefly-mapped GUI canvas, and rasterized output (png, jpg, gif) -- + // which is read back from that window's pixmap -- comes out blank; vector + // formats (pdf, eps, svg) are unaffected since they replay recorded + // primitives directly. Flipping gROOT's batch flag for the lifetime of this + // function is safe here because this canvas is the only one that ever + // exists in the process: the interactive display never builds a TCanvas of + // its own (see the class comment above). + const Bool_t wasBatch = gROOT->IsBatch(); + gROOT->SetBatch(kTRUE); + std::string name = "musrviewBatch" + std::to_string(fPlotNumber); TCanvas canvas(name.c_str(), fTitle.c_str(), exportWidth, exportHeight); - canvas.SetBatch(kTRUE); const Double_t yInfo = 0.1; const Double_t yTitle = 0.95; @@ -1060,8 +1151,10 @@ Bool_t PMusrWebCanvas::SaveGraphics(const std::string &msrFileName, const std::s } infoPad.Draw(); - if (!FillPlotPad(&dataTheoryPad)) + if (!FillPlotPad(&dataTheoryPad)) { + gROOT->SetBatch(wasBatch); return false; + } dataTheoryPad.Update(); canvas.cd(); @@ -1074,6 +1167,8 @@ Bool_t PMusrWebCanvas::SaveGraphics(const std::string &msrFileName, const std::s dataTheoryPad.GetListOfPrimitives()->Clear(); canvas.GetListOfPrimitives()->Clear(); + gROOT->SetBatch(wasBatch); + return true; } diff --git a/src/include/PMusrWebCanvas.h b/src/include/PMusrWebCanvas.h index b5744054..4cb95eac 100644 --- a/src/include/PMusrWebCanvas.h +++ b/src/include/PMusrWebCanvas.h @@ -121,8 +121,16 @@ class PMusrWebCanvas /// Called when the user quits; used to terminate the application. void SetDoneCallback(std::function cb) { fDoneCallback = std::move(cb); } - /// Writes the classic canvas layout into a graphics file. - Bool_t SaveGraphics(const std::string &msrFileName, const std::string &graphicsFormat); + /// Writes the classic canvas layout into a graphics file named after the + /// msr-file (batch export, e.g. --png). + Bool_t SaveGraphics(const std::string &graphicsFormat); + /// Writes the classic canvas layout into the current working directory and + /// returns the URL (relative to the web window) it can be downloaded from; + /// used by the interactive "Save graphics" toolbar entry. + Bool_t SaveGraphicsForDownload(const std::string &graphicsFormat, std::string &relUrl); + /// The name/location that graphics files are derived from by default, + /// without extension. + std::string DefaultGraphicsBaseName() const; /// Writes the currently shown data as ascii. Bool_t ExportData(const std::string &fileName); @@ -143,6 +151,7 @@ class PMusrWebCanvas Bool_t fDone{false}; ///< true once the user quit Int_t fTimeout{0}; ///< timeout in seconds std::string fLastError{}; ///< last error message + std::string fMsrFileName{}; ///< msr-file name, used to derive graphics/export file names std::unique_ptr fModel; ///< the data layer PMusrViewMode fMode{}; ///< what is currently shown @@ -170,6 +179,8 @@ class PMusrWebCanvas Bool_t CollectDrawables(std::vector> &out); /// Draws the spectra of the current mode into the given pad (graphics export). Bool_t FillPlotPad(TVirtualPad *pad); + /// Builds the classic layout and writes it into the given graphics file. + Bool_t RenderGraphics(const std::string &fileName); /// Serializes the objects of the current view for JSROOT. Bool_t BuildPlotJson(TString &json); diff --git a/src/musrview.cpp b/src/musrview.cpp index 2e3e7178..82261155 100644 --- a/src/musrview.cpp +++ b/src/musrview.cpp @@ -446,7 +446,7 @@ int main(int argc, char *argv[]) if (graphicsOutput) { for (auto &canvas : canvasList) { - if (!canvas->SaveGraphics(fileName, graphicsExtension)) { + if (!canvas->SaveGraphics(graphicsExtension)) { std::cerr << std::endl << ">> musrview **ERROR** " << canvas->GetLastError() << std::endl; result = PMUSR_MSR_RUN_ERROR; } diff --git a/src/musrview_web/musrview.html b/src/musrview_web/musrview.html index fc7d9292..e59f80b6 100644 --- a/src/musrview_web/musrview.html +++ b/src/musrview_web/musrview.html @@ -257,6 +257,20 @@

+
+ + +
+ @@ -320,6 +334,18 @@ function cmd(name) { send('cmd:' + name); } +/** Triggers a browser download of a file the server just wrote, from its URL + * relative to this web window (see PMusrWebCanvas::SaveGraphicsForDownload). */ +function downloadFile(relUrl) { + if (!handle) return; + const a = document.createElement('a'); + a.href = handle.getHRef(relUrl); + a.download = relUrl.slice(relUrl.lastIndexOf('/') + 1); + document.body.appendChild(a); + a.click(); + a.remove(); +} + /** True while the user is typing somewhere, e.g. into the text/latex dialog * which JSROOT opens from the canvas menu. This is what keeps a typed 'd' or * 'f' from being executed as the difference/Fourier command. */ @@ -647,6 +673,8 @@ $('btn-export').onclick = () => { if (name) send('export:' + name); }; +$('btn-save').onclick = () => send('save:' + $('sel-save-format').value); + $('pan-title').onclick = () => togglePanel('title'); $('pan-params').onclick = () => togglePanel('params'); $('pan-theory').onclick = () => togglePanel('theory'); @@ -683,6 +711,8 @@ const receiver = { setStatus(msg.slice(5)); } else if (msg.startsWith('ERROR:')) { setStatus(msg.slice(6), true); + } else if (msg.startsWith('DOWNLOAD:')) { + downloadFile(msg.slice(9)); } },