From 79855681ae1968e776f20b73ef7f4e6b70b703bc Mon Sep 17 00:00:00 2001 From: Niraj Venkat <3744263+nirajvenkat@users.noreply.github.com> Date: Fri, 14 Jun 2024 22:20:04 +0400 Subject: [PATCH] [Unitary Hack] Save Python qsharp-widgets RE output to PNG (#1604) Submission for Unitary Hack issue: fixes #1063 This change adds the ability to save the resource estimates diagram locally as a PNG. For this I added a new dependency called `html2canvas` that takes a DOM and renders it to an image. This currently works only on IPython `qsharp-widgets`, could be extended to the VSCode version. Additionally we take into account the color theme within VSCode (which is provided in the stylesheet `qsharp-ux.css`) and this is passed in as the background color for rendering. Tested on (dark/light) Modern VSCode themes. https://github.com/microsoft/qsharp/assets/3744263/898de6d9-fba4-4fea-b729-a3bb698dc51c ~Current issues:~ - ~This button is placeholder, nicer version: https://github.com/microsoft/vscode-notebook-renderers/pull/118~ - ~Saved imaged is slightly truncated at the bottom~ --------- Co-authored-by: Mine Starks --- npm/qsharp/ux/estimatesOverview.tsx | 2 + npm/qsharp/ux/estimatesPanel.tsx | 2 + npm/qsharp/ux/qsharp-ux.css | 17 +++++ npm/qsharp/ux/saveImage.tsx | 107 ++++++++++++++++++++++++++++ npm/qsharp/ux/saveImageUtil.tsx | 72 +++++++++++++++++++ npm/qsharp/ux/scatterChart.tsx | 31 ++++++++ widgets/js/index.tsx | 2 + 7 files changed, 233 insertions(+) create mode 100644 npm/qsharp/ux/saveImage.tsx create mode 100644 npm/qsharp/ux/saveImageUtil.tsx diff --git a/npm/qsharp/ux/estimatesOverview.tsx b/npm/qsharp/ux/estimatesOverview.tsx index 22e00fa25c..098cdeb5b8 100644 --- a/npm/qsharp/ux/estimatesOverview.tsx +++ b/npm/qsharp/ux/estimatesOverview.tsx @@ -183,6 +183,7 @@ export function EstimatesOverview(props: { isSimplifiedView: boolean; onRowDeleted: (rowId: string) => void; setEstimate: (estimate: SingleEstimateResult | null) => void; + allowSaveImage: boolean; }) { const [selectedRow, setSelectedRow] = useState(null); const [selectedPoint, setSelectedPoint] = useState<[number, number]>(); @@ -277,6 +278,7 @@ export function EstimatesOverview(props: { )} onPointSelected={onPointSelected} selectedPoint={selectedPoint} + allowSaveImage={props.allowSaveImage} /> ); } diff --git a/npm/qsharp/ux/estimatesPanel.tsx b/npm/qsharp/ux/estimatesPanel.tsx index 8904dcbf65..b1f08c4163 100644 --- a/npm/qsharp/ux/estimatesPanel.tsx +++ b/npm/qsharp/ux/estimatesPanel.tsx @@ -14,6 +14,7 @@ export function EstimatesPanel(props: { runNames: string[]; calculating: boolean; onRowDeleted: (rowId: string) => void; + allowSaveImage?: boolean; }) { const [estimate, setEstimate] = useState(null); @@ -63,6 +64,7 @@ export function EstimatesPanel(props: { setEstimate={setEstimate} runNames={props.runNames} colors={props.colors} + allowSaveImage={props.allowSaveImage || false} > {!estimate ? null : ( <> diff --git a/npm/qsharp/ux/qsharp-ux.css b/npm/qsharp/ux/qsharp-ux.css index f7351115a2..5f31790f22 100644 --- a/npm/qsharp/ux/qsharp-ux.css +++ b/npm/qsharp/ux/qsharp-ux.css @@ -711,3 +711,20 @@ html { .qs-estimatesOverview-error { color: red; } + +.qs-estimatesOverview-saveIcon { + cursor: pointer; + width: 3em; + height: 3em; + background-color: var(--main-background); + position: absolute; + bottom: 5px; + left: 5px; + border: none; +} + +.qs-estimatesOverview-saveIconSvgPath { + fill: var(--main-color) !important; + fill-rule: evenodd !important; + clip-rule: evenodd !important; +} diff --git a/npm/qsharp/ux/saveImage.tsx b/npm/qsharp/ux/saveImage.tsx new file mode 100644 index 0000000000..0d2421664a --- /dev/null +++ b/npm/qsharp/ux/saveImage.tsx @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Writes resource estimator output to PNG file + +import { + getImageSize, + createImage, + nodeToDataURI, + toArray, +} from "./saveImageUtil.js"; + +async function cloneSingleNode( + node: T, +): Promise { + return node.cloneNode(false) as T; +} + +async function cloneChildren( + nativeNode: T, + clonedNode: T, +): Promise { + let children: T[] = []; + children = toArray((nativeNode.shadowRoot ?? nativeNode).childNodes); + + // Depth-first traversal of DOM objects + await children.reduce( + (deferred, child) => + deferred + .then(() => cloneNode(child)) + .then((clonedChild: HTMLElement | null) => { + if (clonedChild) { + clonedNode.appendChild(clonedChild); + } + }), + Promise.resolve(), + ); + + return clonedNode; +} + +function cloneCSSStyle(nativeNode: T, clonedNode: T) { + const targetStyle = clonedNode.style; + if (!targetStyle) { + return; + } + + const sourceStyle = window.getComputedStyle(nativeNode); + toArray(sourceStyle).forEach((name) => { + const value = sourceStyle.getPropertyValue(name); + targetStyle.setProperty(name, value, sourceStyle.getPropertyPriority(name)); + }); +} + +function decorate(nativeNode: T, clonedNode: T): T { + cloneCSSStyle(nativeNode, clonedNode); + return clonedNode; +} + +async function cloneNode(node: T): Promise { + return Promise.resolve(node) + .then((clonedNode) => cloneSingleNode(clonedNode) as Promise) + .then((clonedNode) => cloneChildren(node, clonedNode)) + .then((clonedNode) => decorate(node, clonedNode)); +} + +async function saveToPng( + node: T, + backgroundColor: string, +): Promise { + const { width, height } = getImageSize(node); + const clonedNode = (await cloneNode(node)) as HTMLElement; + const uri = await nodeToDataURI(clonedNode, width, height); + const img = await createImage(uri); + + const ratio = window.devicePixelRatio || 1; + const canvas = document.createElement("canvas"); + canvas.width = width * ratio; + canvas.height = height * ratio; + + const context = canvas.getContext("2d")!; + context.fillStyle = backgroundColor; + context.fillRect(0, 0, canvas.width, canvas.height); + + context.drawImage(img, 0, 0, canvas.width, canvas.height); + return canvas.toDataURL(); +} + +export async function saveToImage( + element: T, + filename = "image.png", +) { + const backgroundColor = + getComputedStyle(element).getPropertyValue("--main-background"); + const data = await saveToPng(element, backgroundColor); + const link = document.createElement("a"); + if (typeof link.download === "string") { + link.href = data; + link.download = filename; + + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } else { + window.open(data); + } +} diff --git a/npm/qsharp/ux/saveImageUtil.tsx b/npm/qsharp/ux/saveImageUtil.tsx new file mode 100644 index 0000000000..69a98a227c --- /dev/null +++ b/npm/qsharp/ux/saveImageUtil.tsx @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export function toArray(arrayLike: any): T[] { + const arr: T[] = []; + for (let i = 0, l = arrayLike.length; i < l; i++) { + arr.push(arrayLike[i]); + } + + return arr; +} + +function px(node: T, styleProperty: string) { + const win = node.ownerDocument.defaultView || window; + const val = win.getComputedStyle(node).getPropertyValue(styleProperty); + return val ? parseFloat(val.replace("px", "")) : 0; +} + +export function getImageSize(node: T) { + const leftBorder = px(node, "border-left-width"); + const rightBorder = px(node, "border-right-width"); + const topBorder = px(node, "border-top-width"); + const bottomBorder = px(node, "border-bottom-width"); + + return { + width: node.clientWidth + leftBorder + rightBorder, + height: node.clientHeight + topBorder + bottomBorder + 12, // Fixes up truncated region + }; +} + +export function createImage(url: string): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + img.decode = () => resolve(img) as any; + img.onload = () => resolve(img); + img.onerror = reject; + img.crossOrigin = "anonymous"; + img.decoding = "async"; + img.src = url; + }); +} + +export async function svgToDataURI(svg: Element): Promise { + return Promise.resolve() + .then(() => new XMLSerializer().serializeToString(svg)) + .then(encodeURIComponent) + .then((html) => `data:image/svg+xml;charset=utf-8,${html}`); +} + +export async function nodeToDataURI( + node: HTMLElement, + width: number, + height: number, +): Promise { + const xmlns = "http://www.w3.org/2000/svg"; + const svg = document.createElementNS(xmlns, "svg"); + const foreignObject = document.createElementNS(xmlns, "foreignObject"); + + svg.setAttribute("width", `${width}`); + svg.setAttribute("height", `${height}`); + svg.setAttribute("viewBox", `0 0 ${width} ${height}`); + + foreignObject.setAttribute("width", "100%"); + foreignObject.setAttribute("height", "100%"); + foreignObject.setAttribute("x", "0"); + foreignObject.setAttribute("y", "0"); + foreignObject.setAttribute("externalResourcesRequired", "true"); + + svg.appendChild(foreignObject); + foreignObject.appendChild(node); + return svgToDataURI(svg); +} diff --git a/npm/qsharp/ux/scatterChart.tsx b/npm/qsharp/ux/scatterChart.tsx index a9e92f5cc3..1acb3be6f7 100644 --- a/npm/qsharp/ux/scatterChart.tsx +++ b/npm/qsharp/ux/scatterChart.tsx @@ -2,7 +2,9 @@ // Licensed under the MIT License. import { useRef, useEffect } from "preact/hooks"; +import { createRef } from "preact"; import * as utils from "../src/utils.js"; +import { saveToImage } from "./saveImage.js"; export type ScatterSeries = { color: string; @@ -31,6 +33,7 @@ export function ScatterChart(props: { yAxis: Axis; onPointSelected(seriesIndex: number, pointIndex: number): void; selectedPoint?: [number, number]; + allowSaveImage: boolean; }) { const selectedTooltipDiv = useRef(null); @@ -154,6 +157,12 @@ export function ScatterChart(props: { } const selectedPoint = getSelectedPointData(); + const saveRef = createRef(); + + const handleSaveImage = async () => { + saveToImage(saveRef!.current); + }; + // Need to render first to get the element layout to position the tooltip useEffect(() => { if (!selectedTooltipDiv.current) return; @@ -182,6 +191,7 @@ export function ScatterChart(props: { onMouseOver={(ev) => onPointMouseEvent(ev, "over")} onMouseOut={(ev) => onPointMouseEvent(ev, "out")} onClick={(ev) => onPointMouseEvent(ev, "click")} + ref={saveRef} >
+ {props.allowSaveImage ? ( + + ) : null} ); } diff --git a/widgets/js/index.tsx b/widgets/js/index.tsx index fc8b95a5c8..1c61ad0467 100644 --- a/widgets/js/index.tsx +++ b/widgets/js/index.tsx @@ -122,6 +122,7 @@ function renderEstimatesOverview({ model, el }: RenderArgs) { isSimplifiedView={true} onRowDeleted={onRowDeleted} setEstimate={() => undefined} + allowSaveImage={true} >, el, ); @@ -161,6 +162,7 @@ function renderEstimatesPanel({ model, el }: RenderArgs) { renderer={mdRenderer} calculating={false} onRowDeleted={onRowDeleted} + allowSaveImage={true} >, el, );