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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017cuZPrSNaKjG8xHPVNwV16
This commit is contained in:
suter_aandClaude Sonnet 5 committed 2026-09-11 17:42:49 +02:00
1 parent 4302b40d9e
commit 7a3fa94270
4 files changed
+176 -40

No files matched your search

+132 -37
View File
@@ -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:<format> - 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/<name>", 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)
//--------------------------------------------------------------------------
/**
* <p>Writes the classic musrview layout (title, spectra, fit parameters,
* theory, run info) into a graphics file.
* <p>Derives the base name (no extension) under which this plot block's
* graphics are saved by default, mirroring the --png/--pdf/... batch export
* naming: <msr-base>_<plotNo>[_F]. Used both by the batch export and as the
* suggested name offered to the interactive "Save graphics" dialog.
*
* <p>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: <base>_<plotNo>[_F].<ext>
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)
//--------------------------------------------------------------------------
/**
* <p>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)
//--------------------------------------------------------------------------
/**
* <p>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.
*
* <p>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)
//--------------------------------------------------------------------------
/**
* <p>Builds the classic musrview layout (title, spectra, fit parameters,
* theory, run info) and writes it into the given graphics file.
*
* <p>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;
}
+13 -2
View File
@@ -121,8 +121,16 @@ class PMusrWebCanvas
/// Called when the user quits; used to terminate the application.
void SetDoneCallback(std::function<void(Int_t)> 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<PMusrViewModel> fModel; ///< the data layer
PMusrViewMode fMode{}; ///< what is currently shown
@@ -170,6 +179,8 @@ class PMusrWebCanvas
Bool_t CollectDrawables(std::vector<std::pair<TObject*, TString>> &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);
+1 -1
View File
@@ -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;
}
+30
View File
@@ -257,6 +257,20 @@
<div class="sep"></div>
<div class="toggle-group">
<button id="btn-save" title="save the current view as a graphics file; downloads it through the browser">Save graphics</button>
<select id="sel-save-format" title="graphics format">
<option value="png">png</option>
<option value="pdf">pdf</option>
<option value="svg">svg</option>
<option value="eps">eps</option>
<option value="gif">gif</option>
<option value="jpg">jpg</option>
<option value="xpm">xpm</option>
<option value="root">root</option>
</select>
</div>
<button id="btn-export" title="export the shown data as ascii">Export data</button>
<button id="btn-quit" title="close musrview (key: q)">Quit</button>
</div>
@@ -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));
}
},