diff --git a/ChangeLog b/ChangeLog index 5dde3fd3..24320e97 100644 --- a/ChangeLog +++ b/ChangeLog @@ -40,7 +40,11 @@ shortcomings: The musrview window opens larger than the ROOT6 canvas used to (1200x900 instead of 800x600), since the toolbar and the panels take their share of it. -The graphics export is unaffected and keeps producing 800x600 images. +The interactive 'Save graphics' mirrors the window: the exported graphics have +its size and the proportions the title, 'Fit parameters', 'Theory' and +'RUN INFO' blocks have been dragged to, and these panels keep the font size +they have on screen. The batch export (--png, --pdf, ...) has no window to +take anything over from and keeps producing the classic 800x600 layout. musrview can either open its window in the standard browser of the system (--browser) or in an embedded Qt6 WebEngine window (--embedded, the default), diff --git a/src/classes/PMusrWebCanvas.cpp b/src/classes/PMusrWebCanvas.cpp index 405bdf8d..367d5b8d 100644 --- a/src/classes/PMusrWebCanvas.cpp +++ b/src/classes/PMusrWebCanvas.cpp @@ -31,6 +31,7 @@ #include "config.h" #endif +#include #include #include #include @@ -46,6 +47,7 @@ #include #include "PMusrWebCanvas.h" +#include "PStringUtils.h" namespace musrview { @@ -201,8 +203,83 @@ class PBatchGraphicsGuard { TVirtualX *fSavedVirtualX; }; +//------------------------------------------------------------------------ +/** + *

Line spacing of the HTML panels (the 'line-height' of musrview.html), + * used to lay the exported panels out the same way. + */ +constexpr Double_t gPanelLineSpacing = 1.45; + +//------------------------------------------------------------------------ +/** + *

Text size of an exported panel which mirrors what the interactive view + * shows: an HTML panel keeps its font size and scrolls, whereas a TPaveText + * left to itself always scales its text to the pave. Here the font size of + * the page is the starting point and is only scaled *down*, as far as needed + * for all lines to fit into the pave -- in height as well as in width, the + * latter measured the same way TPaveText measures it. + * + * \param lines the text lines to be shown + * \param font ROOT font code the panel is drawn with + * \param wanted font height of the page as a fraction of the window height + * \param width width of the pave [NDC] + * \param height height of the pave [NDC] + * \param nSlots number of line slots which have to fit into the pave + * + * \return the text size [NDC], or \p wanted if there is nothing to scale + */ +Double_t PanelTextSize(const std::vector &lines, Int_t font, Double_t wanted, + Double_t width, Double_t height, size_t nSlots) +{ + if (wanted <= 0.0 || nSlots == 0 || width <= 0.0 || height <= 0.0 || lines.empty()) + return wanted; + + // vertical: nSlots lines, laid out with the line spacing of the HTML panel + Double_t size = std::min(wanted, 0.96*height/(gPanelLineSpacing*nSlots)); + + // horizontal: the longest line has to fit into the pave + TLatex latex; + latex.SetTextFont(font); + latex.SetTextSize(size); + Double_t longest = 0.0; + for (const auto &line : lines) { + latex.SetTitle(line.c_str()); + const Double_t w = latex.GetXsize(); + if (w > longest) + longest = w; + } + if (longest > 0.94*width) + size *= 0.94*width/longest; + + return size; +} + } // anonymous namespace +//-------------------------------------------------------------------------- +// ExportGeometry::Sanitize (public) +//-------------------------------------------------------------------------- +/** + *

Clips a geometry coming from the page into a range which still gives a + * usable layout, so that a bogus message cannot produce a degenerate canvas. + * The defaults (the classic 800x600 layout) are a fix point of this. + */ +void PMusrWebCanvas::ExportGeometry::Sanitize() +{ + width = std::clamp(width, 200, 4000); + height = std::clamp(height, 150, 4000); + + titleFrac = std::clamp(titleFrac, 0.0, 0.30); + infoFrac = std::clamp(infoFrac, 0.0, 0.60); + sideFrac = std::clamp(sideFrac, 0.0, 0.70); + fontFrac = std::clamp(fontFrac, 0.0, 0.10); + + // the parameter/theory split has to stay inside the side column + const Double_t lo = infoFrac + 0.02; + const Double_t hi = 1.0 - titleFrac - 0.02; + splitFrac = (hi > lo) ? std::clamp(splitFrac, lo, hi) : 0.5*(lo + hi); +} + //-------------------------------------------------------------------------- // Constructor //-------------------------------------------------------------------------- @@ -904,26 +981,43 @@ void PMusrWebCanvas::ProcessMessage(unsigned connid, const std::string &msg) } if (msg.rfind("save:", 0) == 0) { - // save::<params><theory><info> - each of the four trailing - // characters is '1'/'0', reflecting the page's current panel visibility - // (see the "panels" object in musrview.html). 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 arg = msg.substr(5); - const size_t sep = arg.find(':'); - const std::string format = (sep == std::string::npos) ? arg : arg.substr(0, sep); + // save:<format>:<title><params><theory><info> + // [:<width>:<height>:<titleFrac>:<infoFrac>:<sideFrac>:<splitFrac>:<fontFrac>] + // + // Each of the four characters of the second field is '1'/'0', reflecting + // the page's current panel visibility (see the "panels" object in + // musrview.html). The seven numbers behind it are the size and the + // proportions of the window, so that the exported graphics mirror what is + // on screen (see exportGeometry() there and ExportGeometry here); they are + // optional, a message without them gets the default layout. + // + // 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::vector<std::string> tok = PStringUtils::Split(msg.substr(5), ":"); + const std::string format = tok.empty() ? std::string() : tok[0]; PanelVisibility panels; - if (sep != std::string::npos) { - const std::string flags = arg.substr(sep+1); - if (flags.size() == 4) { - panels.title = (flags[0] == '1'); - panels.params = (flags[1] == '1'); - panels.theory = (flags[2] == '1'); - panels.info = (flags[3] == '1'); - } + if (tok.size() > 1 && tok[1].size() == 4) { + panels.title = (tok[1][0] == '1'); + panels.params = (tok[1][1] == '1'); + panels.theory = (tok[1][2] == '1'); + panels.info = (tok[1][3] == '1'); + } + ExportGeometry geom; + if (tok.size() >= 9) { + bool ok = true, allOk = true; + const Int_t w = PStringUtils::ToInt(tok[2], &ok); allOk &= ok; + const Int_t h = PStringUtils::ToInt(tok[3], &ok); allOk &= ok; + const Double_t title = PStringUtils::ToDouble(tok[4], &ok); allOk &= ok; + const Double_t info = PStringUtils::ToDouble(tok[5], &ok); allOk &= ok; + const Double_t side = PStringUtils::ToDouble(tok[6], &ok); allOk &= ok; + const Double_t split = PStringUtils::ToDouble(tok[7], &ok); allOk &= ok; + const Double_t font = PStringUtils::ToDouble(tok[8], &ok); allOk &= ok; + if (allOk) + geom = ExportGeometry{w, h, title, info, side, split, font}; } std::string relUrl; - if (SaveGraphicsForDownload(format, panels, relUrl)) + if (SaveGraphicsForDownload(format, panels, geom, relUrl)) fWindow->Send(connid, "DOWNLOAD:" + JsonEscape(relUrl)); else fWindow->Send(connid, "ERROR:" + JsonEscape(fLastError)); @@ -1074,7 +1168,7 @@ Bool_t PMusrWebCanvas::SaveGraphics(const std::string &graphicsFormat) return false; } - return RenderGraphics(base + "." + graphicsFormat, PanelVisibility{}); + return RenderGraphics(base + "." + graphicsFormat, PanelVisibility{}, ExportGeometry{}); } //-------------------------------------------------------------------------- @@ -1097,13 +1191,15 @@ Bool_t PMusrWebCanvas::SaveGraphics(const std::string &graphicsFormat) * \param graphicsFormat extension such as "png", "pdf", "eps", ... * \param panels which of the title/parameters/theory/run-info blocks to * include, mirroring the interactive view's current panel visibility + * \param geom size and proportions of the interactive view, so that the + * exported graphics mirror the window rather than a fixed layout * \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, const PanelVisibility &panels, - std::string &relUrl) + const ExportGeometry &geom, std::string &relUrl) { if (DefaultGraphicsBaseName().empty()) { fLastError = "msr-file name '" + fMsrFileName + "' is invalid."; @@ -1123,7 +1219,7 @@ Bool_t PMusrWebCanvas::SaveGraphicsForDownload(const std::string &graphicsFormat break; } - if (!RenderGraphics(fileName, panels)) + if (!RenderGraphics(fileName, panels, geom)) return false; relUrl = "currentdir/" + fileName; @@ -1139,18 +1235,22 @@ Bool_t PMusrWebCanvas::SaveGraphicsForDownload(const std::string &graphicsFormat * * <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 (which passes a default-constructed, - * all-visible \p panels). + * is even installed. The --png/--pdf/... batch export passes a default + * constructed, all-visible \p panels and \p geom and hence always produces the + * classic 800x600 layout, whereas the interactive Save hands over the state of + * the window, so that what is exported is what is on screen. * * \param fileName the file to write; its extension selects the format * \param panels which of the title/parameters/theory/run-info blocks to draw; * a hidden block is left out and its space given to its neighbours, * mirroring how the interactive view's CSS grid collapses hidden panels + * \param geom size of the canvas and the proportions of the blocks, i.e. the + * window size and the splitter positions of the interactive view * * \return true on success */ -Bool_t PMusrWebCanvas::RenderGraphics(const std::string &fileName, const PanelVisibility &panels) +Bool_t PMusrWebCanvas::RenderGraphics(const std::string &fileName, const PanelVisibility &panels, + const ExportGeometry &geomIn) { if (!fValid) { fLastError = "canvas is not valid."; @@ -1159,11 +1259,16 @@ Bool_t PMusrWebCanvas::RenderGraphics(const std::string &fileName, const PanelVi 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 - // (and the reference images of the test suite) rely on that. - const Int_t exportWidth = 800; - const Int_t exportHeight = 600; + ExportGeometry geom(geomIn); + geom.Sanitize(); + + // The size of the exported graphics is the size of the musrview window + // (without its toolbar and status bar, which the export does not have), so + // that resizing the window also resizes what is saved. The defaults, used + // by the --png/--pdf/... batch export where there is no window, are the + // classic 800x600 the reference images of the test suite rely on. + const Int_t exportWidth = geom.width; + const Int_t exportHeight = geom.height; // TCanvas decides at *construction* time, from gROOT->IsBatch(), whether to // build a real (mapped) GUI canvas or a headless one; canvas.SetBatch(kTRUE) @@ -1200,11 +1305,12 @@ Bool_t PMusrWebCanvas::RenderGraphics(const std::string &fileName, const PanelVi // a hidden block gives its space to its neighbours, mirroring how the // interactive view's CSS grid collapses hidden panels (see updateLayout()/ - // updateSideLayout() in musrview.html) - const Double_t yInfo = panels.info ? 0.1 : 0.0; - const Double_t yTitle = panels.title ? 0.95 : 1.0; + // updateSideLayout() in musrview.html); the sizes of the visible ones are + // the ones the user has dragged the splitters to + const Double_t yInfo = panels.info ? geom.infoFrac : 0.0; + const Double_t yTitle = panels.title ? 1.0 - geom.titleFrac : 1.0; const Bool_t sideVisible = panels.params || panels.theory; - const Double_t xTheo = sideVisible ? 0.75 : 1.0; + const Double_t xTheo = sideVisible ? 1.0 - geom.sideFrac : 1.0; const Int_t white = TColor::GetColor(255, 255, 255); std::unique_ptr<TPaveText> titlePad; @@ -1220,11 +1326,19 @@ Bool_t PMusrWebCanvas::RenderGraphics(const std::string &fileName, const PanelVi dataTheoryPad.SetFillColor(white); dataTheoryPad.Draw(); - // when both are visible the column is split 50/50, matching paramsFrac's - // default in musrview.html; a lone survivor takes the full column height - // (yInfo..yTitle) instead + // when both are visible the column is split where the user has dragged the + // handle to (0.5 by default, matching paramsFrac in musrview.html); a lone + // survivor takes the full column height (yInfo..yTitle) instead const Bool_t bothVisible = panels.params && panels.theory; - const Double_t sideSplit = bothVisible ? 0.5 : yInfo; + const Double_t sideSplit = bothVisible ? geom.splitFrac : yInfo; + + // Text size of the three panels. With a font size handed over by the page + // they keep the size they have on screen (scaled down where the pave is too + // small for it), instead of TPaveText/TLegend scaling the text to the pave: + // enlarging the window then shows more text rather than bigger text, just + // as the HTML panels do. Without it (batch export) the text is scaled by + // ROOT as before. + const Double_t sideWidth = 1.0 - xTheo; std::unique_ptr<TPaveText> parameterPad; if (panels.params) { @@ -1233,7 +1347,13 @@ Bool_t PMusrWebCanvas::RenderGraphics(const std::string &fileName, const PanelVi parameterPad->SetTextAlign(13); // top, left parameterPad->SetTextFont(102); // courier bold const std::vector<std::string> &lines = fModel->GetParameterLines(); - const Double_t yoffset = (lines.size() > 20) ? 1.0/(lines.size()+1) : 0.05; + Double_t yoffset = (lines.size() > 20) ? 1.0/(lines.size()+1) : 0.05; + const Double_t textSize = PanelTextSize(lines, 102, geom.fontFrac, sideWidth, + yTitle-sideSplit, lines.size()); + if (textSize > 0.0) { + parameterPad->SetTextSize(textSize); + yoffset = gPanelLineSpacing*textSize/(yTitle-sideSplit); + } for (size_t i=0; i<lines.size(); i++) parameterPad->AddText(0.03, 0.98-i*yoffset, lines[i].c_str()); parameterPad->Draw(); @@ -1247,7 +1367,13 @@ Bool_t PMusrWebCanvas::RenderGraphics(const std::string &fileName, const PanelVi theoryPad->SetTextAlign(13); theoryPad->SetTextFont(102); const std::vector<std::string> &lines = fModel->GetTheoryLines(); - const Double_t yoffset = (lines.size() > 20) ? 1.0/(lines.size()+1) : 0.05; + Double_t yoffset = (lines.size() > 20) ? 1.0/(lines.size()+1) : 0.05; + const Double_t textSize = PanelTextSize(lines, 102, geom.fontFrac, sideWidth, + theoryTop-yInfo, lines.size()+1); + if (textSize > 0.0) { + theoryPad->SetTextSize(textSize); + yoffset = gPanelLineSpacing*textSize/(theoryTop-yInfo); + } for (size_t i=0; i<lines.size(); i++) theoryPad->AddText(0.03, 0.98-(i+1)*yoffset, lines[i].c_str()); theoryPad->Draw(); @@ -1259,6 +1385,13 @@ Bool_t PMusrWebCanvas::RenderGraphics(const std::string &fileName, const PanelVi infoPad->SetFillColor(white); infoPad->SetTextAlign(12); infoPad->SetHeader(fModel->GetStatisticLine().c_str()); + // the legend keeps a margin for the symbols, hence the reduced width + std::vector<std::string> infoText(fModel->GetInfoLines()); + infoText.push_back(fModel->GetStatisticLine()); + const Double_t textSize = PanelTextSize(infoText, 42, geom.fontFrac, 1.0-infoPad->GetMargin(), + yInfo, infoText.size()); + if (textSize > 0.0) + infoPad->SetTextSize(textSize); // the legend always lists the runs of the plot block, also in the averaged // view where a single curve is shown const std::vector<std::string> &lines = fModel->GetInfoLines(); diff --git a/src/include/PMusrWebCanvas.h b/src/include/PMusrWebCanvas.h index 288b7f6b..43e0d8f2 100644 --- a/src/include/PMusrWebCanvas.h +++ b/src/include/PMusrWebCanvas.h @@ -130,16 +130,38 @@ class PMusrWebCanvas Bool_t info{true}; }; + /// Size and proportions of the exported layout. The interactive view fills + /// it in from its window, so that "Save graphics" reproduces what is on + /// screen: the window size as well as how much of it the user has given to + /// the title, to the 'Fit parameters'/'Theory' column and to the run info + /// by dragging the splitters. The defaults are the classic 800x600 layout + /// and are what the --png/--pdf/... batch export uses. + struct ExportGeometry { + Int_t width{800}; ///< canvas width [pixel] + Int_t height{600}; ///< canvas height [pixel] + Double_t titleFrac{0.05}; ///< height fraction of the title block + Double_t infoFrac{0.10}; ///< height fraction of the run-info block + Double_t sideFrac{0.25}; ///< width fraction of the parameter/theory column + Double_t splitFrac{0.50}; ///< height fraction, measured from the bottom, at + ///< which 'Fit parameters' and 'Theory' are split + Double_t fontFrac{0.0}; ///< on-screen panel font height as a fraction of + ///< the window height; <= 0 lets ROOT scale the text + /// Clips everything into a range which still gives a usable layout. + void Sanitize(); + }; + /// Writes the classic canvas layout into a graphics file named after the - /// msr-file (batch export, e.g. --png). All panels are included, since - /// there is no interactive view whose visibility state could be mirrored. + /// msr-file (batch export, e.g. --png). All panels are included and the + /// default geometry is used, since there is no interactive view whose + /// state could be mirrored. 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. \p panels mirrors - /// which blocks the current view has shown/hidden. + /// used by the interactive "Save graphics" toolbar entry. \p panels and + /// \p geom mirror which blocks the current view has shown/hidden and how + /// large the window and its panels currently are. Bool_t SaveGraphicsForDownload(const std::string &graphicsFormat, const PanelVisibility &panels, - std::string &relUrl); + const ExportGeometry &geom, std::string &relUrl); /// The name/location that graphics files are derived from by default, /// without extension. std::string DefaultGraphicsBaseName() const; @@ -194,8 +216,10 @@ class PMusrWebCanvas /// 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, - /// including only the panels \p panels marks visible. - Bool_t RenderGraphics(const std::string &fileName, const PanelVisibility &panels); + /// including only the panels \p panels marks visible, sized and split + /// according to \p geom. + Bool_t RenderGraphics(const std::string &fileName, const PanelVisibility &panels, + const ExportGeometry &geom); /// Serializes the objects of the current view for JSROOT. Bool_t BuildPlotJson(TString &json); diff --git a/src/musrview_web/musrview.html b/src/musrview_web/musrview.html index 49a0932c..8dd19286 100644 --- a/src/musrview_web/musrview.html +++ b/src/musrview_web/musrview.html @@ -597,6 +597,50 @@ makeSplitter($('side-split'), ev => { updateSideLayout(); }); +/** Size and proportions of the current view, handed to the C++ side with + * 'Save graphics' so that the exported graphics mirror the window instead of + * always using the fixed 800x600 layout of the batch export (see + * PMusrWebCanvas::ExportGeometry / RenderGraphics()). + * + * The exported layout has neither a toolbar nor a status bar, so the size + * reported here is the window without those two, and every fraction is + * relative to that content area. A boundary which is a splitter bar on + * screen is reported as the bar's centre line, since the export draws the + * blocks edge to edge. The font size is passed on as well: it is what lets + * the exported panels keep the size the text has on screen. + * + * Returns the values in the order the 'save:' message expects them, or null + * if the window is too small to measure anything sensible. */ +function exportGeometry() { + const appRect = $('app').getBoundingClientRect(); + const top = $('toolbar').getBoundingClientRect().bottom; + const bottom = $('statusbar').getBoundingClientRect().top; + + const w = Math.round(appRect.width); + const h = Math.round(bottom - top); + if (!(w > 50) || !(h > 50)) return null; + + const fracY = v => clamp(v / h, 0, 1); + const centreY = el => { const r = el.getBoundingClientRect(); return r.top + r.height/2; }; + const centreX = el => { const r = el.getBoundingClientRect(); return r.left + r.width/2; }; + + const sideVisible = panels.params.visible || panels.theory.visible; + const bothVisible = panels.params.visible && panels.theory.visible; + + const titleFrac = panels.title.visible + ? fracY($('titlebar').getBoundingClientRect().height) : 0; + const infoFrac = panels.info.visible ? fracY(bottom - centreY($('hsplit'))) : 0; + const sideFrac = sideVisible + ? clamp((appRect.right - centreX($('vsplit'))) / w, 0, 1) : 0; + const splitFrac = bothVisible ? fracY(bottom - centreY($('side-split'))) : 0; + + // the panel text size, as a fraction of the content height, is what the + // exported panels are scaled with + const fontPx = parseFloat(getComputedStyle($('params')).fontSize) || 12; + + return [w, h, titleFrac, infoFrac, sideFrac, splitFrac, fontPx / h]; +} + //-------------------------------------------------------------------------- // plotting //-------------------------------------------------------------------------- @@ -873,11 +917,16 @@ $('btn-export').onclick = () => { if (name) send('export:' + name); }; -$('btn-save').onclick = () => send('save:' + $('sel-save-format').value + ':' + - (panels.title.visible ? '1' : '0') + - (panels.params.visible ? '1' : '0') + - (panels.theory.visible ? '1' : '0') + - (panels.info.visible ? '1' : '0')); +$('btn-save').onclick = () => { + const flags = (panels.title.visible ? '1' : '0') + + (panels.params.visible ? '1' : '0') + + (panels.theory.visible ? '1' : '0') + + (panels.info.visible ? '1' : '0'); + const geom = exportGeometry(); + const geomMsg = geom + ? ':' + geom.map((v, i) => (i < 2 ? String(v) : v.toFixed(5))).join(':') : ''; + send('save:' + $('sel-save-format').value + ':' + flags + geomMsg); +}; $('pan-title').onclick = () => togglePanel('title'); $('pan-params').onclick = () => togglePanel('params');