Skip to content

Commit

Permalink
[Unitary Hack] Save Python qsharp-widgets RE output to PNG (#1604)
Browse files Browse the repository at this point in the history
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:
microsoft/vscode-notebook-renderers#118
- ~Saved imaged is slightly truncated at the bottom~

---------

Co-authored-by: Mine Starks <[email protected]>
  • Loading branch information
nirajvenkat and minestarks authored Jun 14, 2024
1 parent 68bb77d commit 7985568
Show file tree
Hide file tree
Showing 7 changed files with 233 additions and 0 deletions.
2 changes: 2 additions & 0 deletions npm/qsharp/ux/estimatesOverview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(null);
const [selectedPoint, setSelectedPoint] = useState<[number, number]>();
Expand Down Expand Up @@ -277,6 +278,7 @@ export function EstimatesOverview(props: {
)}
onPointSelected={onPointSelected}
selectedPoint={selectedPoint}
allowSaveImage={props.allowSaveImage}
/>
);
}
Expand Down
2 changes: 2 additions & 0 deletions npm/qsharp/ux/estimatesPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export function EstimatesPanel(props: {
runNames: string[];
calculating: boolean;
onRowDeleted: (rowId: string) => void;
allowSaveImage?: boolean;
}) {
const [estimate, setEstimate] = useState<SingleEstimateResult | null>(null);

Expand Down Expand Up @@ -63,6 +64,7 @@ export function EstimatesPanel(props: {
setEstimate={setEstimate}
runNames={props.runNames}
colors={props.colors}
allowSaveImage={props.allowSaveImage || false}
></EstimatesOverview>
{!estimate ? null : (
<>
Expand Down
17 changes: 17 additions & 0 deletions npm/qsharp/ux/qsharp-ux.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
107 changes: 107 additions & 0 deletions npm/qsharp/ux/saveImage.tsx
Original file line number Diff line number Diff line change
@@ -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<T extends HTMLElement>(
node: T,
): Promise<HTMLElement> {
return node.cloneNode(false) as T;
}

async function cloneChildren<T extends HTMLElement>(
nativeNode: T,
clonedNode: T,
): Promise<T> {
let children: T[] = [];
children = toArray<T>((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<T extends HTMLElement>(nativeNode: T, clonedNode: T) {
const targetStyle = clonedNode.style;
if (!targetStyle) {
return;
}

const sourceStyle = window.getComputedStyle(nativeNode);
toArray<string>(sourceStyle).forEach((name) => {
const value = sourceStyle.getPropertyValue(name);
targetStyle.setProperty(name, value, sourceStyle.getPropertyPriority(name));
});
}

function decorate<T extends HTMLElement>(nativeNode: T, clonedNode: T): T {
cloneCSSStyle(nativeNode, clonedNode);
return clonedNode;
}

async function cloneNode<T extends HTMLElement>(node: T): Promise<T | null> {
return Promise.resolve(node)
.then((clonedNode) => cloneSingleNode(clonedNode) as Promise<T>)
.then((clonedNode) => cloneChildren(node, clonedNode))
.then((clonedNode) => decorate(node, clonedNode));
}

async function saveToPng<T extends HTMLElement>(
node: T,
backgroundColor: string,
): Promise<string> {
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<T extends HTMLElement>(
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);
}
}
72 changes: 72 additions & 0 deletions npm/qsharp/ux/saveImageUtil.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

export function toArray<T>(arrayLike: any): T[] {
const arr: T[] = [];
for (let i = 0, l = arrayLike.length; i < l; i++) {
arr.push(arrayLike[i]);
}

return arr;
}

function px<T extends HTMLElement>(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<T extends HTMLElement>(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<HTMLImageElement> {
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<string> {
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<string> {
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);
}
31 changes: 31 additions & 0 deletions npm/qsharp/ux/scatterChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -31,6 +33,7 @@ export function ScatterChart(props: {
yAxis: Axis;
onPointSelected(seriesIndex: number, pointIndex: number): void;
selectedPoint?: [number, number];
allowSaveImage: boolean;
}) {
const selectedTooltipDiv = useRef<HTMLDivElement>(null);

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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}
>
<line
class="qs-scatterChart-axis"
Expand Down Expand Up @@ -316,6 +326,27 @@ export function ScatterChart(props: {
</svg>
<div class="qs-scatterChart-selectedInfo" ref={selectedTooltipDiv}></div>
<div class="qs-scatterChart-tooltip"></div>
{props.allowSaveImage ? (
<button
role="button"
onClick={handleSaveImage}
className={"qs-estimatesOverview-saveIcon"}
>
<span>
<svg
width="75%"
height="75%"
viewBox="0 0 16 16"
xmlns="http://www.w3.org/2000/svg"
>
<path
className={"qs-estimatesOverview-saveIconSvgPath"}
d="M12.0147 2.8595L13.1397 3.9845L13.25 4.25V12.875L12.875 13.25H3.125L2.75 12.875V3.125L3.125 2.75H11.75L12.0147 2.8595ZM3.5 3.5V12.5H12.5V4.406L11.5947 3.5H10.25V6.5H5V3.5H3.5ZM8 3.5V5.75H9.5V3.5H8Z"
/>
</svg>
</span>
</button>
) : null}
</div>
);
}
2 changes: 2 additions & 0 deletions widgets/js/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ function renderEstimatesOverview({ model, el }: RenderArgs) {
isSimplifiedView={true}
onRowDeleted={onRowDeleted}
setEstimate={() => undefined}
allowSaveImage={true}
></EstimatesOverview>,
el,
);
Expand Down Expand Up @@ -161,6 +162,7 @@ function renderEstimatesPanel({ model, el }: RenderArgs) {
renderer={mdRenderer}
calculating={false}
onRowDeleted={onRowDeleted}
allowSaveImage={true}
></EstimatesPanel>,
el,
);
Expand Down

0 comments on commit 7985568

Please sign in to comment.