From dd4dbb795fefb97333a1be67f409e822a217ebdb Mon Sep 17 00:00:00 2001 From: hehoon <100522372+hehoon@users.noreply.github.com> Date: Sun, 14 Dec 2025 02:18:02 +0100 Subject: [PATCH 01/10] Add utilities to export RootObject as SVG/PNG/J(E)PG/WEBP --- QualityControl/public/common/rootDownload.js | 133 ++++++++++++++++++ .../public/object/objectTreePage.js | 19 ++- 2 files changed, 149 insertions(+), 3 deletions(-) create mode 100644 QualityControl/public/common/rootDownload.js diff --git a/QualityControl/public/common/rootDownload.js b/QualityControl/public/common/rootDownload.js new file mode 100644 index 000000000..5eaa6aae9 --- /dev/null +++ b/QualityControl/public/common/rootDownload.js @@ -0,0 +1,133 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction.p + */ + +import { triggerDownload } from './utils.js'; +import { generateDrawingOptionString } from '../../library/qcObject/utils.js'; + +/* global JSROOT */ + +/** + * Map of allowed svg file extensions to MIME types + * @type {Map} + */ +const SUPPORTED_SVG_FILE_TYPES = new Map([['svg', 'image/svg+xml']]); + +/** + * Map of allowed image file extensions to MIME types + * @type {Map} + */ +const SUPPORTED_IMAGE_FILE_TYPES = new Map([ + ['png', 'file/png'], + ['jpg', 'file/jpeg'], + ['jpeg', 'file/jpeg'], + ['webp', 'file/webp'], +]); + +/** + * Creates a detached DOM container and draws a JSROOT RootObject into it. + * @param {RootObject} root - The JSROOT RootObject to render. + * @param {string[]} [drawingOptions=[]] - Optional JSROOT drawing options. + * @returns {Promise} - The drawn SVG element. + */ +const renderRootObjectToSVG = async (root, drawingOptions = []) => { + const dom = document.createElement('div'); + await JSROOT.draw(dom, root, generateDrawingOptionString(root, drawingOptions)); + const svg = dom.querySelector('svg'); + if (!svg) { + throw new Error('SVG element not found after drawing RootObject'); + } + + // Ensure proper scaling + if (!svg.viewBox) { + const width = svg.clientWidth || svg.getBoundingClientRect().width; + const height = svg.clientHeight || svg.getBoundingClientRect().height; + svg.setAttribute('viewBox', `0 0 ${width} ${height}`); + } + svg.setAttribute('preserveAspectRatio', 'xMidYMid meet'); + + return svg; +}; + +/** + * Serializes an SVG element to a Blob and triggers download. + * @param {SVGElement} svg - The SVG element to download. + * @param {string} filename - The filename for the downloaded file. + */ +const downloadSVG = (svg, filename) => { + const filetype = filename.substring(filename.lastIndexOf('.') + 1).toLowerCase(); + const mime = SUPPORTED_SVG_FILE_TYPES.get(filetype); + if (!mime) { + throw new Error('File type is not supported'); + } + + const svgString = new XMLSerializer().serializeToString(svg); + const blob = new Blob([svgString], { type: mime }); + const url = URL.createObjectURL(blob); + try { + triggerDownload(url, filename); + } finally { + URL.revokeObjectURL(url); + } +}; + +/** + * Rasterize an SVG element to a PNG and triggers download. + * @param {SVGElement} svg - The SVG element to rasterize. + * @param {string} filename - The filename for the downloaded PNG. + */ +const downloadSVGAsImage = (svg, filename) => { + const filetype = filename.substring(filename.lastIndexOf('.') + 1).toLowerCase(); + const mime = SUPPORTED_IMAGE_FILE_TYPES.get(filetype); + if (!mime) { + throw new Error('Image file type is not supported'); + } + + const svgString = new XMLSerializer().serializeToString(svg); + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + const image = new Image(); + const [, , viewBoxWidth, viewBoxHeight] = svg.getAttribute('viewBox').split(' ').map(Number); + + canvas.width = image.width = viewBoxWidth; + canvas.height = image.height = viewBoxHeight; + + image.onload = () => { + context.drawImage(image, 0, 0, image.width, image.height); + triggerDownload(canvas.toDataURL(mime), filename); + image.src = ''; // free memory + }; + image.src = `data:image/svg+xml,${encodeURIComponent(svgString)}`; +}; + +/** + * Generates an SVG representation of a JSROOT RootObject and triggers download. + * @param {string} filename - The name of the file to download. + * @param {RootObject} root - The JSROOT RootObject to render. + * @param {string[]} [drawingOptions=[]] - Optional array of JSROOT drawing options. + */ +export const downloadRootObjectAsSVG = async (filename, root, drawingOptions = []) => { + const svg = await renderRootObjectToSVG(root, drawingOptions); + downloadSVG(svg, filename); +}; + +/** + * Generates a rasterized image of a JSROOT RootObject and triggers download. + * @param {string} filename - The name of the file to download. + * @param {RootObject} root - The JSROOT RootObject to render. + * @param {string[]} [drawingOptions=[]] - Optional array of JSROOT drawing options. + */ +export const downloadRootObjectAsImage = async (filename, root, drawingOptions = []) => { + const svg = await renderRootObjectToSVG(root, drawingOptions); + downloadSVGAsImage(svg, filename); +}; diff --git a/QualityControl/public/object/objectTreePage.js b/QualityControl/public/object/objectTreePage.js index f53b09d38..2b3d30da2 100644 --- a/QualityControl/public/object/objectTreePage.js +++ b/QualityControl/public/object/objectTreePage.js @@ -12,7 +12,7 @@ * or submit itself to any jurisdiction. */ -import { h, iconBarChart, iconCaretRight, iconResizeBoth, iconCaretBottom, iconCircleX } from '/js/src/index.js'; +import { h, iconBarChart, iconCaretRight, iconResizeBoth, iconCaretBottom, iconCircleX, imagE } from '/js/src/index.js'; import { spinner } from '../common/spinner.js'; import { draw } from '../common/object/draw.js'; import timestampSelectForm from './../common/timestampSelectForm.js'; @@ -20,6 +20,7 @@ import virtualTable from './virtualTable.js'; import { defaultRowAttributes, qcObjectInfoPanel } from '../common/object/objectInfoCard.js'; import { downloadButton } from '../common/downloadButton.js'; import { resizableDivider } from '../common/resizableDivider.js'; +import { downloadRootObjectAsImage } from '../common/rootDownload.js'; /** * Shows a page to explore though a tree of objects with a preview on the right if clicked @@ -93,14 +94,26 @@ function objectPanel(model) { * @returns {vnode} - virtual node element */ const drawPlot = (model, object) => { - const { name, validFrom, id } = object; + const { name, qcObject, validFrom, id, drawingOptions = [], displayHints = [] } = object; const href = validFrom ? `?page=objectView&objectName=${name}&ts=${validFrom}&id=${id}` : `?page=objectView&objectName=${name}`; return h('', { style: 'height:100%; display: flex; flex-direction: column' }, [ h('.item-action-row.flex-row.g1.p1', [ + h('button.btn', { + title: 'Download as PNG', + id: 'download-image-button', + onclick: async (event) => { + try { + event.target.disabled = true; + await downloadRootObjectAsImage(`${name}.png`, qcObject.root, [...drawingOptions, ...displayHints]); + } finally { + event.target.disabled = false; + } + }, + }, imagE()), downloadButton({ - href: model.objectViewModel.getDownloadQcdbObjectUrl(object.id), + href: model.objectViewModel.getDownloadQcdbObjectUrl(id), title: 'Download object', }), h( From d64f68d58fe1fae8e46d1d0db598befa66bb4909 Mon Sep 17 00:00:00 2001 From: hehoon <100522372+hehoon@users.noreply.github.com> Date: Sun, 14 Dec 2025 02:21:34 +0100 Subject: [PATCH 02/10] Add triggerDownload method to utils --- QualityControl/public/common/utils.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/QualityControl/public/common/utils.js b/QualityControl/public/common/utils.js index 14891f933..15e1296db 100644 --- a/QualityControl/public/common/utils.js +++ b/QualityControl/public/common/utils.js @@ -171,3 +171,20 @@ export const camelToTitleCase = (text) => { const titleCase = spaced.charAt(0).toUpperCase() + spaced.slice(1); return titleCase; }; + +/** + * Helper to trigger a download for a file + * @param {string} url - The URL to the file source + * @param {string} filename - The name of the file including the file extension + */ +export const triggerDownload = (url, filename) => { + const link = document.createElement('a'); + link.href = url; + link.download = filename; + try { + document.body.appendChild(link); + link.click(); + } finally { + document.body.removeChild(link); + } +}; From 3ea78b6fe4cff146e7a0af8da45c50e48947ad80 Mon Sep 17 00:00:00 2001 From: hehoon <100522372+hehoon@users.noreply.github.com> Date: Mon, 15 Dec 2025 10:09:04 +0100 Subject: [PATCH 03/10] Use JSROOT.makeSVG instead of JSROOT.draw --- QualityControl/public/common/rootDownload.js | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/QualityControl/public/common/rootDownload.js b/QualityControl/public/common/rootDownload.js index 5eaa6aae9..5f215560f 100644 --- a/QualityControl/public/common/rootDownload.js +++ b/QualityControl/public/common/rootDownload.js @@ -41,11 +41,19 @@ const SUPPORTED_IMAGE_FILE_TYPES = new Map([ * @returns {Promise} - The drawn SVG element. */ const renderRootObjectToSVG = async (root, drawingOptions = []) => { - const dom = document.createElement('div'); - await JSROOT.draw(dom, root, generateDrawingOptionString(root, drawingOptions)); - const svg = dom.querySelector('svg'); - if (!svg) { - throw new Error('SVG element not found after drawing RootObject'); + const svgString = await JSROOT.makeSVG({ + object: root, + option: generateDrawingOptionString(root, drawingOptions), + }); + if (!svgString) { + throw new Error('Failed to generate SVG'); + } + + const parser = new DOMParser(); + const doc = parser.parseFromString(svgString, 'image/svg+xml'); + const svg = doc.documentElement; + if (!(svg instanceof SVGElement)) { + throw new Error('Failed to parse SVG'); } // Ensure proper scaling From 0fa60f2c03c450c107f89cf93630b421e01fbb96 Mon Sep 17 00:00:00 2001 From: hehoon <100522372+hehoon@users.noreply.github.com> Date: Mon, 15 Dec 2025 10:09:26 +0100 Subject: [PATCH 04/10] Do not add the temporary download link to the document --- QualityControl/public/common/utils.js | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/QualityControl/public/common/utils.js b/QualityControl/public/common/utils.js index 15e1296db..dc50b9c90 100644 --- a/QualityControl/public/common/utils.js +++ b/QualityControl/public/common/utils.js @@ -181,10 +181,5 @@ export const triggerDownload = (url, filename) => { const link = document.createElement('a'); link.href = url; link.download = filename; - try { - document.body.appendChild(link); - link.click(); - } finally { - document.body.removeChild(link); - } + link.click(); }; From a8199655ef99de0ae5c1f534c8712efba115e4cf Mon Sep 17 00:00:00 2001 From: hehoon <100522372+hehoon@users.noreply.github.com> Date: Mon, 15 Dec 2025 12:51:00 +0100 Subject: [PATCH 05/10] Utilize JSROOT.makeImage now that 'blob:' is not blocked by CSP --- QualityControl/public/common/rootDownload.js | 141 ------------------ QualityControl/public/common/utils.js | 52 +++++++ .../public/object/objectTreePage.js | 7 +- 3 files changed, 56 insertions(+), 144 deletions(-) delete mode 100644 QualityControl/public/common/rootDownload.js diff --git a/QualityControl/public/common/rootDownload.js b/QualityControl/public/common/rootDownload.js deleted file mode 100644 index 5f215560f..000000000 --- a/QualityControl/public/common/rootDownload.js +++ /dev/null @@ -1,141 +0,0 @@ -/** - * @license - * Copyright 2019-2020 CERN and copyright holders of ALICE O2. - * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. - * All rights not expressly granted are reserved. - * - * This software is distributed under the terms of the GNU General Public - * License v3 (GPL Version 3), copied verbatim in the file "COPYING". - * - * In applying this license CERN does not waive the privileges and immunities - * granted to it by virtue of its status as an Intergovernmental Organization - * or submit itself to any jurisdiction.p - */ - -import { triggerDownload } from './utils.js'; -import { generateDrawingOptionString } from '../../library/qcObject/utils.js'; - -/* global JSROOT */ - -/** - * Map of allowed svg file extensions to MIME types - * @type {Map} - */ -const SUPPORTED_SVG_FILE_TYPES = new Map([['svg', 'image/svg+xml']]); - -/** - * Map of allowed image file extensions to MIME types - * @type {Map} - */ -const SUPPORTED_IMAGE_FILE_TYPES = new Map([ - ['png', 'file/png'], - ['jpg', 'file/jpeg'], - ['jpeg', 'file/jpeg'], - ['webp', 'file/webp'], -]); - -/** - * Creates a detached DOM container and draws a JSROOT RootObject into it. - * @param {RootObject} root - The JSROOT RootObject to render. - * @param {string[]} [drawingOptions=[]] - Optional JSROOT drawing options. - * @returns {Promise} - The drawn SVG element. - */ -const renderRootObjectToSVG = async (root, drawingOptions = []) => { - const svgString = await JSROOT.makeSVG({ - object: root, - option: generateDrawingOptionString(root, drawingOptions), - }); - if (!svgString) { - throw new Error('Failed to generate SVG'); - } - - const parser = new DOMParser(); - const doc = parser.parseFromString(svgString, 'image/svg+xml'); - const svg = doc.documentElement; - if (!(svg instanceof SVGElement)) { - throw new Error('Failed to parse SVG'); - } - - // Ensure proper scaling - if (!svg.viewBox) { - const width = svg.clientWidth || svg.getBoundingClientRect().width; - const height = svg.clientHeight || svg.getBoundingClientRect().height; - svg.setAttribute('viewBox', `0 0 ${width} ${height}`); - } - svg.setAttribute('preserveAspectRatio', 'xMidYMid meet'); - - return svg; -}; - -/** - * Serializes an SVG element to a Blob and triggers download. - * @param {SVGElement} svg - The SVG element to download. - * @param {string} filename - The filename for the downloaded file. - */ -const downloadSVG = (svg, filename) => { - const filetype = filename.substring(filename.lastIndexOf('.') + 1).toLowerCase(); - const mime = SUPPORTED_SVG_FILE_TYPES.get(filetype); - if (!mime) { - throw new Error('File type is not supported'); - } - - const svgString = new XMLSerializer().serializeToString(svg); - const blob = new Blob([svgString], { type: mime }); - const url = URL.createObjectURL(blob); - try { - triggerDownload(url, filename); - } finally { - URL.revokeObjectURL(url); - } -}; - -/** - * Rasterize an SVG element to a PNG and triggers download. - * @param {SVGElement} svg - The SVG element to rasterize. - * @param {string} filename - The filename for the downloaded PNG. - */ -const downloadSVGAsImage = (svg, filename) => { - const filetype = filename.substring(filename.lastIndexOf('.') + 1).toLowerCase(); - const mime = SUPPORTED_IMAGE_FILE_TYPES.get(filetype); - if (!mime) { - throw new Error('Image file type is not supported'); - } - - const svgString = new XMLSerializer().serializeToString(svg); - const canvas = document.createElement('canvas'); - const context = canvas.getContext('2d'); - const image = new Image(); - const [, , viewBoxWidth, viewBoxHeight] = svg.getAttribute('viewBox').split(' ').map(Number); - - canvas.width = image.width = viewBoxWidth; - canvas.height = image.height = viewBoxHeight; - - image.onload = () => { - context.drawImage(image, 0, 0, image.width, image.height); - triggerDownload(canvas.toDataURL(mime), filename); - image.src = ''; // free memory - }; - image.src = `data:image/svg+xml,${encodeURIComponent(svgString)}`; -}; - -/** - * Generates an SVG representation of a JSROOT RootObject and triggers download. - * @param {string} filename - The name of the file to download. - * @param {RootObject} root - The JSROOT RootObject to render. - * @param {string[]} [drawingOptions=[]] - Optional array of JSROOT drawing options. - */ -export const downloadRootObjectAsSVG = async (filename, root, drawingOptions = []) => { - const svg = await renderRootObjectToSVG(root, drawingOptions); - downloadSVG(svg, filename); -}; - -/** - * Generates a rasterized image of a JSROOT RootObject and triggers download. - * @param {string} filename - The name of the file to download. - * @param {RootObject} root - The JSROOT RootObject to render. - * @param {string[]} [drawingOptions=[]] - Optional array of JSROOT drawing options. - */ -export const downloadRootObjectAsImage = async (filename, root, drawingOptions = []) => { - const svg = await renderRootObjectToSVG(root, drawingOptions); - downloadSVGAsImage(svg, filename); -}; diff --git a/QualityControl/public/common/utils.js b/QualityControl/public/common/utils.js index dc50b9c90..4973dad01 100644 --- a/QualityControl/public/common/utils.js +++ b/QualityControl/public/common/utils.js @@ -13,6 +13,21 @@ */ import { isUserRoleSufficient } from '../../../../library/userRole.enum.js'; +import { generateDrawingOptionString } from '../../library/qcObject/utils.js'; + +/* global JSROOT */ + +/** + * Map of allowed `ROOT.makeImage` file extensions to MIME types + * @type {Map} + */ +const SUPPORTED_ROOT_IMAGE_FILE_TYPES = new Map([ + ['svg', 'image/svg+xml'], + ['png', 'file/png'], + ['jpg', 'file/jpeg'], + ['jpeg', 'file/jpeg'], + ['webp', 'file/webp'], +]); /** * Generates a new ObjectId @@ -183,3 +198,40 @@ export const triggerDownload = (url, filename) => { link.download = filename; link.click(); }; + +/** + * Downloads a file + * @param {Blob|MediaSource} file - The file to download + * @param {string} filename - The name of the file including the file extension + */ +export const downloadFile = (file, filename) => { + const url = URL.createObjectURL(file); + try { + triggerDownload(url, filename); + } finally { + URL.revokeObjectURL(url); + } +}; + +/** + * Generates a rasterized image of a JSROOT RootObject and triggers download. + * @param {string} filename - The name of the downloaded file including its extension. + * @param {RootObject} root - The JSROOT RootObject to render. + * @param {string[]} [drawingOptions=[]] - Optional array of JSROOT drawing options. + */ +export const downloadRoot = async (filename, root, drawingOptions = []) => { + const filetype = filename.substring(filename.lastIndexOf('.') + 1).toLowerCase(); + const mime = SUPPORTED_ROOT_IMAGE_FILE_TYPES.get(filetype); + if (!mime) { + throw new Error(`The file extension (${filetype}) is not supported`); + } + + const image = await JSROOT.makeImage({ + object: root, + option: generateDrawingOptionString(root, drawingOptions), + format: filetype, + as_buffer: true, + }); + const blob = new Blob([image], { type: mime }); + downloadFile(blob, filename); +}; diff --git a/QualityControl/public/object/objectTreePage.js b/QualityControl/public/object/objectTreePage.js index 2b3d30da2..57bbafee9 100644 --- a/QualityControl/public/object/objectTreePage.js +++ b/QualityControl/public/object/objectTreePage.js @@ -20,7 +20,7 @@ import virtualTable from './virtualTable.js'; import { defaultRowAttributes, qcObjectInfoPanel } from '../common/object/objectInfoCard.js'; import { downloadButton } from '../common/downloadButton.js'; import { resizableDivider } from '../common/resizableDivider.js'; -import { downloadRootObjectAsImage } from '../common/rootDownload.js'; +import { downloadRoot } from '../common/utils.js'; /** * Shows a page to explore though a tree of objects with a preview on the right if clicked @@ -95,18 +95,19 @@ function objectPanel(model) { */ const drawPlot = (model, object) => { const { name, qcObject, validFrom, id, drawingOptions = [], displayHints = [] } = object; + const { root } = qcObject; const href = validFrom ? `?page=objectView&objectName=${name}&ts=${validFrom}&id=${id}` : `?page=objectView&objectName=${name}`; return h('', { style: 'height:100%; display: flex; flex-direction: column' }, [ h('.item-action-row.flex-row.g1.p1', [ - h('button.btn', { + root.fArray?.length && h('button.btn', { title: 'Download as PNG', id: 'download-image-button', onclick: async (event) => { try { event.target.disabled = true; - await downloadRootObjectAsImage(`${name}.png`, qcObject.root, [...drawingOptions, ...displayHints]); + await downloadRoot(`${name}.webp`, root, [...drawingOptions, ...displayHints]); } finally { event.target.disabled = false; } From 71031aa16820bb7e624f3cb395b1a94438a3c348 Mon Sep 17 00:00:00 2001 From: hehoon <100522372+hehoon@users.noreply.github.com> Date: Mon, 15 Dec 2025 16:20:27 +0100 Subject: [PATCH 06/10] Change downloadable file to png --- QualityControl/public/object/objectTreePage.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/QualityControl/public/object/objectTreePage.js b/QualityControl/public/object/objectTreePage.js index 57bbafee9..db7efa3c9 100644 --- a/QualityControl/public/object/objectTreePage.js +++ b/QualityControl/public/object/objectTreePage.js @@ -107,7 +107,7 @@ const drawPlot = (model, object) => { onclick: async (event) => { try { event.target.disabled = true; - await downloadRoot(`${name}.webp`, root, [...drawingOptions, ...displayHints]); + await downloadRoot(`${name}.png`, root, [...drawingOptions, ...displayHints]); } finally { event.target.disabled = false; } From 2575b810579883a131a64c2ed88cc5f48e228d81 Mon Sep 17 00:00:00 2001 From: hehoon <100522372+hehoon@users.noreply.github.com> Date: Mon, 15 Dec 2025 16:20:46 +0100 Subject: [PATCH 07/10] Add tests --- .../test/public/pages/object-tree.test.js | 76 ++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/QualityControl/test/public/pages/object-tree.test.js b/QualityControl/test/public/pages/object-tree.test.js index 45788051d..1ac654ea0 100644 --- a/QualityControl/test/public/pages/object-tree.test.js +++ b/QualityControl/test/public/pages/object-tree.test.js @@ -65,7 +65,7 @@ export const objectTreePageTests = async (url, page, timeout = 5000, testParent) }); await testParent.test( - 'should have a correctly made download button', + 'should have a correctly made download root as object button', { timeout }, async () => { const objectId = '016fa8ac-f3b6-11ec-b9a9-c0a80209250c'; @@ -81,6 +81,80 @@ export const objectTreePageTests = async (url, page, timeout = 5000, testParent) }, ); + await testParent.test( + 'should have a correctly made download root as image button', + { timeout }, + async () => { + const exists = await page.evaluate(() => document.querySelector('#download-image-button') !== null); + + ok(exists, 'Expected ROOT image download button to exist'); + }, + ); + + await testParent.test( + 'clicking the download image button should download a png file', + { timeout }, + async () => { + // Set up mocks in the browser context + await page.evaluate(() => { + // Mock JSROOT.makeImage to return a dummy byte array instead of rendering + window.__originalMakeImage = window.JSROOT.makeImage; + window.JSROOT.makeImage = async () => new Uint8Array([1, 2, 3]); + + // Spy on URL.createObjectURL to capture the blob type and return a fixed Blob URL + window.__originalCreateObjectURL = URL.createObjectURL; + URL.createObjectURL = (blob) => { + window.__blobType = blob.type; + return 'blob:mock'; + }; + + // Spy on anchor element creation to track download href, filename, and click + const originalCreateElement = document.createElement.bind(document); + document.__originalCreateElement = originalCreateElement; + document.createElement = (tag) => { + if (tag === 'a') { + return { + set href(href) { + window.__fileHref = href; + }, + set download(name) { + window.__filename = name; + }, + click: () => { + window.__anchorClicked = true; + }, + }; + } + + return originalCreateElement(tag); + }; + }); + + await page.locator('#download-image-button').click(); + await delay(1000); + + // Collect results and restore original browser functions + const { blobType, fileHref, filename, anchorClicked } = await page.evaluate(() => { + // Revert mock changes + window.JSROOT.makeImage = window.__originalMakeImage; + URL.createObjectURL = window.__originalCreateObjectURL; + document.createElement = document.__originalCreateElement; + + return { + blobType: window.__blobType, + fileHref: window.__fileHref, + filename: window.__filename, + anchorClicked: window.__anchorClicked, + }; + }); + + strictEqual(blobType, 'file/png', 'Blob should have correct MIME type (png'); + strictEqual(anchorClicked, true, 'Internal anchor element should be clicked to start the download'); + strictEqual(fileHref, 'blob:mock', 'Internal (download) anchor href should be the Blob URL'); + strictEqual(filename, 'qc/test/object/1.png', 'Internal anchor download Blob file should have the correct name'); + }, + ); + await testParent.test( 'should have default panel width of 50% when width is null in localStorage', { timeout }, From 46336d507229ed0e17bc9d0134aae01fdc16c3cb Mon Sep 17 00:00:00 2001 From: hehoon <100522372+hehoon@users.noreply.github.com> Date: Mon, 15 Dec 2025 17:24:23 +0100 Subject: [PATCH 08/10] Add download root as image button to every JSROOT plot --- .../public/common/downloadRootImageButton.js | 38 +++++++++++ QualityControl/public/common/utils.js | 10 ++- .../view/panels/objectInfoResizePanel.js | 27 +++++--- .../public/object/objectTreePage.js | 19 ++---- .../public/pages/objectView/ObjectViewPage.js | 2 + .../test/public/pages/layout-list.test.js | 8 +++ .../test/public/pages/layout-show.test.js | 10 +++ .../test/public/pages/object-tree.test.js | 66 +------------------ .../object-view-from-layout-show.test.js | 12 +++- 9 files changed, 102 insertions(+), 90 deletions(-) create mode 100644 QualityControl/public/common/downloadRootImageButton.js diff --git a/QualityControl/public/common/downloadRootImageButton.js b/QualityControl/public/common/downloadRootImageButton.js new file mode 100644 index 000000000..e088cf6c3 --- /dev/null +++ b/QualityControl/public/common/downloadRootImageButton.js @@ -0,0 +1,38 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import { h, imagE } from '/js/src/index.js'; +import { downloadRoot, getFileExtensionFromName } from './utils.js'; + +/** + * Download root image button. + * @param {string} filename - The name of the downloaded file including its extension. + * @param {RootObject} root - The JSROOT RootObject to render. + * @param {string[]} [drawingOptions=[]] - Optional array of JSROOT drawing options. + * @returns {vnode} - Download root image button element. + */ +export function downloadRootImageButton(filename, root, drawingOptions = []) { + const filetype = getFileExtensionFromName(filename); + return root.fName && h(`button.btn.download-root-image-${filetype}-button`, { + title: `Download as ${filetype.toUpperCase()}`, + onclick: async (event) => { + try { + event.target.disabled = true; + await downloadRoot(filename, root, drawingOptions); + } finally { + event.target.disabled = false; + } + }, + }, imagE()); +} diff --git a/QualityControl/public/common/utils.js b/QualityControl/public/common/utils.js index 4973dad01..0a50495d1 100644 --- a/QualityControl/public/common/utils.js +++ b/QualityControl/public/common/utils.js @@ -187,6 +187,14 @@ export const camelToTitleCase = (text) => { return titleCase; }; +/** + * Get the file extension from a filename + * @param {string} filename - The file name including the file extension + * @returns {string} - the file extension + */ +export const getFileExtensionFromName = (filename) => + filename.substring(filename.lastIndexOf('.') + 1).toLowerCase().trim(); + /** * Helper to trigger a download for a file * @param {string} url - The URL to the file source @@ -220,7 +228,7 @@ export const downloadFile = (file, filename) => { * @param {string[]} [drawingOptions=[]] - Optional array of JSROOT drawing options. */ export const downloadRoot = async (filename, root, drawingOptions = []) => { - const filetype = filename.substring(filename.lastIndexOf('.') + 1).toLowerCase(); + const filetype = getFileExtensionFromName(filename); const mime = SUPPORTED_ROOT_IMAGE_FILE_TYPES.get(filetype); if (!mime) { throw new Error(`The file extension (${filetype}) is not supported`); diff --git a/QualityControl/public/layout/view/panels/objectInfoResizePanel.js b/QualityControl/public/layout/view/panels/objectInfoResizePanel.js index 40e08d452..e8bf66d53 100644 --- a/QualityControl/public/layout/view/panels/objectInfoResizePanel.js +++ b/QualityControl/public/layout/view/panels/objectInfoResizePanel.js @@ -15,16 +15,17 @@ import { downloadButton } from '../../../common/downloadButton.js'; import { defaultRowAttributes, qcObjectInfoPanel } from './../../../common/object/objectInfoCard.js'; import { h, iconResizeBoth, info } from '/js/src/index.js'; +import { downloadRootImageButton } from '../../../common/downloadRootImageButton.js'; /** * Builds 2 actionable buttons which are to be placed on top of a JSROOT plot * Buttons shall appear on hover of the plot * @param {Model} model - root model of the application - * @param {object} tabObject - tab dto representation + * @param {TabObject} tabObject - tab dto representation * @returns {vnode} - virtual node element */ export const objectInfoResizePanel = (model, tabObject) => { - const { name } = tabObject; + const { name, options: drawingOptions = [], ignoreDefaults } = tabObject; const { filterModel, router, object, services } = model; const isSelectedOpen = object.selectedOpen; const objectRemoteData = services.object.objectsLoadedMap[name]; @@ -34,6 +35,10 @@ export const objectInfoResizePanel = (model, tabObject) => { .forEach(([key, value]) => { uri += `&${key}=${encodeURI(value)}`; }); + const { displayHints = [], drawOptions = [] } = objectRemoteData?.payload ?? {}; + const toUseDrawingOptions = Array.from(new Set(ignoreDefaults + ? drawingOptions + : [...drawingOptions, ...displayHints, ...drawOptions])); return h('.text-right.resize-element.item-action-row.flex-row.g1', { style: 'display: none; padding: .25rem .25rem 0rem .25rem;', }, [ @@ -51,12 +56,18 @@ export const objectInfoResizePanel = (model, tabObject) => { h('.p1', qcObjectInfoPanel(objectRemoteData.payload, {}, defaultRowAttributes(model.notification))), ), ]), - objectRemoteData.isSuccess() && - downloadButton({ - href: model.objectViewModel.getDownloadQcdbObjectUrl(objectRemoteData.payload.id), - title: 'Download object', - id: `download-button-${objectRemoteData.payload.id}`, - }), + objectRemoteData.isSuccess() && [ + downloadRootImageButton( + `${objectRemoteData.payload.name}.png`, + objectRemoteData.payload.qcObject.root, + toUseDrawingOptions, + ), + downloadButton({ + href: model.objectViewModel.getDownloadQcdbObjectUrl(objectRemoteData.payload.id), + title: 'Download object', + id: `download-button-${objectRemoteData.payload.id}`, + }), + ], h('a.btn', { title: 'Open object plot in full screen', href: uri, diff --git a/QualityControl/public/object/objectTreePage.js b/QualityControl/public/object/objectTreePage.js index db7efa3c9..79e34ad66 100644 --- a/QualityControl/public/object/objectTreePage.js +++ b/QualityControl/public/object/objectTreePage.js @@ -12,7 +12,7 @@ * or submit itself to any jurisdiction. */ -import { h, iconBarChart, iconCaretRight, iconResizeBoth, iconCaretBottom, iconCircleX, imagE } from '/js/src/index.js'; +import { h, iconBarChart, iconCaretRight, iconResizeBoth, iconCaretBottom, iconCircleX } from '/js/src/index.js'; import { spinner } from '../common/spinner.js'; import { draw } from '../common/object/draw.js'; import timestampSelectForm from './../common/timestampSelectForm.js'; @@ -20,7 +20,7 @@ import virtualTable from './virtualTable.js'; import { defaultRowAttributes, qcObjectInfoPanel } from '../common/object/objectInfoCard.js'; import { downloadButton } from '../common/downloadButton.js'; import { resizableDivider } from '../common/resizableDivider.js'; -import { downloadRoot } from '../common/utils.js'; +import { downloadRootImageButton } from '../common/downloadRootImageButton.js'; /** * Shows a page to explore though a tree of objects with a preview on the right if clicked @@ -94,25 +94,14 @@ function objectPanel(model) { * @returns {vnode} - virtual node element */ const drawPlot = (model, object) => { - const { name, qcObject, validFrom, id, drawingOptions = [], displayHints = [] } = object; + const { name, qcObject, validFrom, id } = object; const { root } = qcObject; const href = validFrom ? `?page=objectView&objectName=${name}&ts=${validFrom}&id=${id}` : `?page=objectView&objectName=${name}`; return h('', { style: 'height:100%; display: flex; flex-direction: column' }, [ h('.item-action-row.flex-row.g1.p1', [ - root.fArray?.length && h('button.btn', { - title: 'Download as PNG', - id: 'download-image-button', - onclick: async (event) => { - try { - event.target.disabled = true; - await downloadRoot(`${name}.png`, root, [...drawingOptions, ...displayHints]); - } finally { - event.target.disabled = false; - } - }, - }, imagE()), + downloadRootImageButton(`${name}.png`, root, ['stat']), downloadButton({ href: model.objectViewModel.getDownloadQcdbObjectUrl(id), title: 'Download object', diff --git a/QualityControl/public/pages/objectView/ObjectViewPage.js b/QualityControl/public/pages/objectView/ObjectViewPage.js index b296c716c..b250c19fa 100644 --- a/QualityControl/public/pages/objectView/ObjectViewPage.js +++ b/QualityControl/public/pages/objectView/ObjectViewPage.js @@ -20,6 +20,7 @@ import { dateSelector } from '../../common/object/dateSelector.js'; import { defaultRowAttributes, qcObjectInfoPanel } from '../../common/object/objectInfoCard.js'; import { downloadButton } from '../../common/downloadButton.js'; import { visibilityToggleButton } from '../../common/visibilityButton.js'; +import { downloadRootImageButton } from '../../common/downloadRootImageButton.js'; /** * Shows a page to view an object on the whole page @@ -65,6 +66,7 @@ const objectPlotAndInfo = (objectViewModel) => ), ), h('.item-action-row.flex-row.g1.p2', [ + downloadRootImageButton(`${qcObject.name}.png`, qcObject.qcObject.root, drawingOptions), downloadButton({ href: objectViewModel.getDownloadQcdbObjectUrl(qcObject.id), title: 'Download object', diff --git a/QualityControl/test/public/pages/layout-list.test.js b/QualityControl/test/public/pages/layout-list.test.js index 8308a6013..3229e8659 100644 --- a/QualityControl/test/public/pages/layout-list.test.js +++ b/QualityControl/test/public/pages/layout-list.test.js @@ -47,6 +47,14 @@ export const layoutListPageTests = async (url, page, timeout = 5000, testParent) strictEqual(downloadCount, 0); }); + await testParent.test('should not show a download root as image button when there is no data', async () => { + await page.goto(`${url}?page=layoutShow&layoutId=671b8c22402408122e2f20dd&tab=main`, { waitUntil: 'networkidle0' }); + + const exists = await page.evaluate(() => document.querySelector('.download-root-image-png-button') !== null); + + strictEqual(exists, false); + }); + await testParent.test('should successfully load layoutList page "/"', { timeout }, async () => { await page.goto(`${url}${LAYOUT_LIST_PAGE_PARAM}`, { waitUntil: 'networkidle0' }); const location = await page.evaluate(() => window.location); diff --git a/QualityControl/test/public/pages/layout-show.test.js b/QualityControl/test/public/pages/layout-show.test.js index 1d96724a3..9f18e3580 100644 --- a/QualityControl/test/public/pages/layout-show.test.js +++ b/QualityControl/test/public/pages/layout-show.test.js @@ -45,6 +45,16 @@ export const layoutShowTests = async (url, page, timeout = 5000, testParent) => }, ); + await testParent.test( + 'should have a correctly made download root as image button', + { timeout }, + async () => { + const exists = await page.evaluate(() => document.querySelector('.download-root-image-png-button') !== null); + + ok(exists, 'Expected ROOT image download button to exist'); + }, + ); + await testParent.test('should remove query param only if option is invalid for any filter', { timeout }, async () => { const baseParams = `?page=layoutShow&layoutId=${LAYOUT_ID}&tab=main`; diff --git a/QualityControl/test/public/pages/object-tree.test.js b/QualityControl/test/public/pages/object-tree.test.js index 1ac654ea0..ae1bafce0 100644 --- a/QualityControl/test/public/pages/object-tree.test.js +++ b/QualityControl/test/public/pages/object-tree.test.js @@ -85,76 +85,12 @@ export const objectTreePageTests = async (url, page, timeout = 5000, testParent) 'should have a correctly made download root as image button', { timeout }, async () => { - const exists = await page.evaluate(() => document.querySelector('#download-image-button') !== null); + const exists = await page.evaluate(() => document.querySelector('.download-root-image-png-button') !== null); ok(exists, 'Expected ROOT image download button to exist'); }, ); - await testParent.test( - 'clicking the download image button should download a png file', - { timeout }, - async () => { - // Set up mocks in the browser context - await page.evaluate(() => { - // Mock JSROOT.makeImage to return a dummy byte array instead of rendering - window.__originalMakeImage = window.JSROOT.makeImage; - window.JSROOT.makeImage = async () => new Uint8Array([1, 2, 3]); - - // Spy on URL.createObjectURL to capture the blob type and return a fixed Blob URL - window.__originalCreateObjectURL = URL.createObjectURL; - URL.createObjectURL = (blob) => { - window.__blobType = blob.type; - return 'blob:mock'; - }; - - // Spy on anchor element creation to track download href, filename, and click - const originalCreateElement = document.createElement.bind(document); - document.__originalCreateElement = originalCreateElement; - document.createElement = (tag) => { - if (tag === 'a') { - return { - set href(href) { - window.__fileHref = href; - }, - set download(name) { - window.__filename = name; - }, - click: () => { - window.__anchorClicked = true; - }, - }; - } - - return originalCreateElement(tag); - }; - }); - - await page.locator('#download-image-button').click(); - await delay(1000); - - // Collect results and restore original browser functions - const { blobType, fileHref, filename, anchorClicked } = await page.evaluate(() => { - // Revert mock changes - window.JSROOT.makeImage = window.__originalMakeImage; - URL.createObjectURL = window.__originalCreateObjectURL; - document.createElement = document.__originalCreateElement; - - return { - blobType: window.__blobType, - fileHref: window.__fileHref, - filename: window.__filename, - anchorClicked: window.__anchorClicked, - }; - }); - - strictEqual(blobType, 'file/png', 'Blob should have correct MIME type (png'); - strictEqual(anchorClicked, true, 'Internal anchor element should be clicked to start the download'); - strictEqual(fileHref, 'blob:mock', 'Internal (download) anchor href should be the Blob URL'); - strictEqual(filename, 'qc/test/object/1.png', 'Internal anchor download Blob file should have the correct name'); - }, - ); - await testParent.test( 'should have default panel width of 50% when width is null in localStorage', { timeout }, diff --git a/QualityControl/test/public/pages/object-view-from-layout-show.test.js b/QualityControl/test/public/pages/object-view-from-layout-show.test.js index 07b65f280..d0e0d54f3 100644 --- a/QualityControl/test/public/pages/object-view-from-layout-show.test.js +++ b/QualityControl/test/public/pages/object-view-from-layout-show.test.js @@ -11,7 +11,7 @@ * or submit itself to any jurisdiction. */ -import { strictEqual, deepStrictEqual, match } from 'node:assert'; +import {strictEqual, deepStrictEqual, match, ok} from 'node:assert'; import { delay } from '../../testUtils/delay.js'; import { StorageKeysEnum } from '../../../public/common/enums/storageKeys.enum.js'; import { @@ -102,6 +102,16 @@ export const objectViewFromLayoutShowTests = async (url, page, timeout = 5000, t }, ); + await testParent.test( + 'should have a correctly made download root as image button', + { timeout }, + async () => { + const exists = await page.evaluate(() => document.querySelector('.download-root-image-png-button') !== null); + + ok(exists, 'Expected ROOT image download button to exist'); + }, + ); + await testParent.test( 'should take back the user to page=layoutShow when clicking "Back to layout"', { timeout }, From ce80a36ea5630745f399ed0027090b09aefdf861 Mon Sep 17 00:00:00 2001 From: hehoon <100522372+hehoon@users.noreply.github.com> Date: Wed, 17 Dec 2025 15:20:13 +0100 Subject: [PATCH 09/10] Do not display the download root as image button if the root (json) is of type checker --- QualityControl/public/common/downloadRootImageButton.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/QualityControl/public/common/downloadRootImageButton.js b/QualityControl/public/common/downloadRootImageButton.js index e088cf6c3..76f69d313 100644 --- a/QualityControl/public/common/downloadRootImageButton.js +++ b/QualityControl/public/common/downloadRootImageButton.js @@ -14,6 +14,7 @@ import { h, imagE } from '/js/src/index.js'; import { downloadRoot, getFileExtensionFromName } from './utils.js'; +import { isObjectOfTypeChecker } from '../../library/qcObject/utils.js'; /** * Download root image button. @@ -24,7 +25,7 @@ import { downloadRoot, getFileExtensionFromName } from './utils.js'; */ export function downloadRootImageButton(filename, root, drawingOptions = []) { const filetype = getFileExtensionFromName(filename); - return root.fName && h(`button.btn.download-root-image-${filetype}-button`, { + return !isObjectOfTypeChecker(root) && h(`button.btn.download-root-image-${filetype}-button`, { title: `Download as ${filetype.toUpperCase()}`, onclick: async (event) => { try { From 6d76478191f524f4c3d10fcd3c73dff470f31e1f Mon Sep 17 00:00:00 2001 From: hehoon <100522372+hehoon@users.noreply.github.com> Date: Wed, 17 Dec 2025 15:22:06 +0100 Subject: [PATCH 10/10] Rename download button title to 'Download root object' --- .../public/layout/view/panels/objectInfoResizePanel.js | 2 +- QualityControl/public/object/objectTreePage.js | 2 +- QualityControl/public/pages/objectView/ObjectViewPage.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/QualityControl/public/layout/view/panels/objectInfoResizePanel.js b/QualityControl/public/layout/view/panels/objectInfoResizePanel.js index e8bf66d53..e321bc03c 100644 --- a/QualityControl/public/layout/view/panels/objectInfoResizePanel.js +++ b/QualityControl/public/layout/view/panels/objectInfoResizePanel.js @@ -64,7 +64,7 @@ export const objectInfoResizePanel = (model, tabObject) => { ), downloadButton({ href: model.objectViewModel.getDownloadQcdbObjectUrl(objectRemoteData.payload.id), - title: 'Download object', + title: 'Download root object', id: `download-button-${objectRemoteData.payload.id}`, }), ], diff --git a/QualityControl/public/object/objectTreePage.js b/QualityControl/public/object/objectTreePage.js index 79e34ad66..9720f0e8a 100644 --- a/QualityControl/public/object/objectTreePage.js +++ b/QualityControl/public/object/objectTreePage.js @@ -104,7 +104,7 @@ const drawPlot = (model, object) => { downloadRootImageButton(`${name}.png`, root, ['stat']), downloadButton({ href: model.objectViewModel.getDownloadQcdbObjectUrl(id), - title: 'Download object', + title: 'Download root object', }), h( 'a.btn#fullscreen-button', diff --git a/QualityControl/public/pages/objectView/ObjectViewPage.js b/QualityControl/public/pages/objectView/ObjectViewPage.js index b250c19fa..a7576a438 100644 --- a/QualityControl/public/pages/objectView/ObjectViewPage.js +++ b/QualityControl/public/pages/objectView/ObjectViewPage.js @@ -69,7 +69,7 @@ const objectPlotAndInfo = (objectViewModel) => downloadRootImageButton(`${qcObject.name}.png`, qcObject.qcObject.root, drawingOptions), downloadButton({ href: objectViewModel.getDownloadQcdbObjectUrl(qcObject.id), - title: 'Download object', + title: 'Download root object', }), visibilityToggleButton( {