diff --git a/source/npm/qsharp/package.json b/source/npm/qsharp/package.json index 1ae07b0ec42..c905d14d188 100644 --- a/source/npm/qsharp/package.json +++ b/source/npm/qsharp/package.json @@ -24,6 +24,8 @@ "./katas-md": "./dist/katas-md.js", "./state-viz": "./ux/circuit-vis/state-viz/worker/index.ts", "./ux": "./ux/index.ts", + "./ux/qsharp-ux.css": "./ux/qsharp-ux.css", + "./ux/bloch/bloch.css": "./ux/bloch/bloch.css", "./qdk-theme.css": "./ux/qdk-theme.css", "./rz-array.json": "./rz-array.json" }, diff --git a/source/widgets/KATEX_LICENSE.txt b/source/widgets/KATEX_LICENSE.txt new file mode 100644 index 00000000000..37c6433e3be --- /dev/null +++ b/source/widgets/KATEX_LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2013-2020 Khan Academy and other contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/source/widgets/LICENSE.txt b/source/widgets/LICENSE.txt new file mode 100644 index 00000000000..9e841e7a26e --- /dev/null +++ b/source/widgets/LICENSE.txt @@ -0,0 +1,21 @@ + MIT License + + Copyright (c) Microsoft Corporation. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE diff --git a/source/widgets/js/bloch.css b/source/widgets/js/bloch.css new file mode 100644 index 00000000000..a1dc4f97d71 --- /dev/null +++ b/source/widgets/js/bloch.css @@ -0,0 +1,13 @@ +/* Copyright (c) Microsoft Corporation. + Licensed under the MIT License. */ + +/* + * The Bloch sphere renders matrices that require KaTeX's fonts for correct + * spacing and scalable delimiters. Keep these fonts out of the shared widget + * stylesheet so widgets that predate the Bloch sphere retain their original + * CSS payload. + */ +@import "qsharp-lang/ux/qsharp-ux.css"; +@import "qsharp-lang/ux/bloch/bloch.css"; +@import "./widgets.css"; +@import "katex/dist/katex.min.css"; diff --git a/source/widgets/js/bloch.tsx b/source/widgets/js/bloch.tsx new file mode 100644 index 00000000000..2f5d430c0db --- /dev/null +++ b/source/widgets/js/bloch.tsx @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { AnyModel } from "@anywidget/types"; +import mk from "@vscode/markdown-it-katex"; +import markdownIt from "markdown-it"; +import { render as prender } from "preact"; + +import { BlochSphere, setRenderer } from "qsharp-lang/ux"; +import "./bloch.css"; + +const md = markdownIt(); +md.use(mk); +setRenderer((input: string) => md.render(input)); + +type RenderArgs = { + model: AnyModel; + el: HTMLElement; +}; + +function render({ model, el }: RenderArgs) { + // VS Code may inject an opaque widget background after the widget CSS. + if ( + !el.ownerDocument.head.lastChild?.textContent?.includes("widget-css-fix") + ) { + const forceStyle = el.ownerDocument.createElement("style"); + forceStyle.textContent = `/* widget-css-fix */ .cell-output-ipywidget-background {background-color: transparent !important;}`; + el.ownerDocument.head.appendChild(forceStyle); + } + + const initialGates = model.get("_initial_gates") as string; + prender(, el); +} + +export default { + render, +}; diff --git a/source/widgets/js/subset-katex-fonts.mjs b/source/widgets/js/subset-katex-fonts.mjs new file mode 100644 index 00000000000..54d5a6ff213 --- /dev/null +++ b/source/widgets/js/subset-katex-fonts.mjs @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { readFile, writeFile } from "node:fs/promises"; + +const cssPath = new URL( + "../src/qsharp_widgets/static/bloch.css", + import.meta.url, +); + +// Chromium loads only these faces for the complete set of expressions emitted +// by the Bloch sphere. Keep this list descriptor-specific: KaTeX uses the same +// family name for multiple styles and weights. +const requiredFaces = new Set([ + "KaTeX_Main|normal|400", + "KaTeX_Math|italic|400", + "KaTeX_Size3|normal|400", + "KaTeX_Size4|normal|400", +]); + +const css = await readFile(cssPath, "utf8"); +const fontFacePattern = /@font-face\{[^}]*\}/g; +const foundRequiredFaces = new Set(); +let totalFaces = 0; +let removedFaces = 0; + +const subsetCss = css.replace(fontFacePattern, (fontFace) => { + totalFaces += 1; + + const family = readDescriptor(fontFace, "font-family"); + const style = readDescriptor(fontFace, "font-style") ?? "normal"; + const weight = readDescriptor(fontFace, "font-weight") ?? "400"; + const key = `${family}|${style}|${weight}`; + + if (requiredFaces.has(key)) { + foundRequiredFaces.add(key); + return fontFace; + } + + removedFaces += 1; + return ""; +}); + +const missingFaces = [...requiredFaces].filter( + (face) => !foundRequiredFaces.has(face), +); +if (missingFaces.length > 0) { + throw new Error( + `KaTeX CSS is missing required Bloch font faces: ${missingFaces.join(", ")}`, + ); +} +if (totalFaces === 0 || removedFaces === 0) { + throw new Error( + `Expected bundled KaTeX font faces to subset; found ${totalFaces}, removed ${removedFaces}`, + ); +} + +await writeFile(cssPath, subsetCss); +console.log( + `Subset KaTeX fonts in bloch.css: kept ${foundRequiredFaces.size}, removed ${removedFaces}`, +); + +function readDescriptor(fontFace, descriptor) { + const match = fontFace.match(new RegExp(`${descriptor}:([^;}]+)`)); + return match?.[1]; +} diff --git a/source/widgets/package.json b/source/widgets/package.json index 5926f1dceda..32808c8c961 100644 --- a/source/widgets/package.json +++ b/source/widgets/package.json @@ -1,7 +1,8 @@ { "scripts": { - "dev": "npm run build -- --sourcemap=inline --watch", - "build": "npx esbuild js/index.tsx --minify --format=esm --bundle --outdir=src/qsharp_widgets/static" + "dev": "npm run build:bundle -- --sourcemap=inline --watch", + "build": "npm run build:bundle && node js/subset-katex-fonts.mjs", + "build:bundle": "npx esbuild js/index.tsx js/bloch.tsx --minify --format=esm --bundle --outdir=src/qsharp_widgets/static --loader:.woff2=dataurl --loader:.woff=empty --loader:.ttf=empty" }, "devDependencies": {} } diff --git a/source/widgets/pyproject.toml b/source/widgets/pyproject.toml index ebb96e97b9f..5a156fdd373 100644 --- a/source/widgets/pyproject.toml +++ b/source/widgets/pyproject.toml @@ -6,6 +6,8 @@ build-backend = "hatchling.build" name = "qsharp-widgets" version = "0.0.0" readme = "README.md" +license = "MIT" +license-files = ["LICENSE.txt", "KATEX_LICENSE.txt"] dependencies = ["anywidget>=0.9.21"] [project.optional-dependencies] @@ -21,7 +23,11 @@ artifacts = ["src/qsharp_widgets/static/*"] [tool.hatch.build.hooks.jupyter-builder] build-function = "hatch_jupyter_builder.npm_builder" -ensured-targets = ["src/qsharp_widgets/static/index.js"] +ensured-targets = [ + "src/qsharp_widgets/static/index.js", + "src/qsharp_widgets/static/bloch.js", + "src/qsharp_widgets/static/bloch.css", +] # skip-if-exists = ["src/qsharp_widgets/static/index.js"] dependencies = ["hatch-jupyter-builder>=0.5.0"] diff --git a/source/widgets/src/qsharp_widgets/__init__.py b/source/widgets/src/qsharp_widgets/__init__.py index 5f5e172c7ea..fe9b2ed23cd 100644 --- a/source/widgets/src/qsharp_widgets/__init__.py +++ b/source/widgets/src/qsharp_widgets/__init__.py @@ -9,7 +9,6 @@ import anywidget import traitlets - try: __version__ = importlib.metadata.version("qsharp_widgets") except importlib.metadata.PackageNotFoundError: @@ -213,6 +212,35 @@ def __init__(self, circuit): self.layout.overflow = "visible scroll" +class BlochSphere(anywidget.AnyWidget): + _esm = pathlib.Path(__file__).parent / "static" / "bloch.js" + _css = pathlib.Path(__file__).parent / "static" / "bloch.css" + + comp = traitlets.Unicode("BlochSphere").tag(sync=True) + _initial_gates = traitlets.Unicode("").tag(sync=True) + + @property + def initial_gates(self): + """The gate sequence used when the widget was created.""" + return self._initial_gates + + def __init__(self, initial_gates=""): + """ + This function displays an interactive Bloch sphere for exploring + single-qubit states and gates. + + Parameters: + - initial_gates (optional): a whitespace-separated sequence of gate + tokens to replay when the widget is first shown. Fixed gates are + X, Y, Z, H, S, T, and SX; adjoints use a trailing apostrophe + (S', T', SX'); rotations are Rx(angle), Ry(angle), and Rz(angle) + with the angle in radians. For example: "X H Z" or "H Rx(1.5708) S'". + + `initial_gates` is read-only after the widget is created. + """ + super().__init__(_initial_gates=initial_gates) + + class Atoms(anywidget.AnyWidget): _esm = pathlib.Path(__file__).parent / "static" / "index.js" _css = pathlib.Path(__file__).parent / "static" / "index.css" @@ -233,7 +261,9 @@ class Entanglement(anywidget.AnyWidget): s1_entropies = traitlets.List().tag(sync=True) mutual_information = traitlets.List().tag(sync=True) labels = traitlets.List().tag(sync=True) - selected_indices = traitlets.List(allow_none=True, default_value=None).tag(sync=True) + selected_indices = traitlets.List(allow_none=True, default_value=None).tag( + sync=True + ) groups = traitlets.Dict(allow_none=True, default_value=None).tag(sync=True) options = traitlets.Dict().tag(sync=True) @@ -340,7 +370,9 @@ def __init__( raw_s1.tolist() if hasattr(raw_s1, "tolist") else list(raw_s1) ) mutual_information = ( - raw_mi.tolist() if hasattr(raw_mi, "tolist") else [list(row) for row in raw_mi] + raw_mi.tolist() + if hasattr(raw_mi, "tolist") + else [list(row) for row in raw_mi] ) n = len(s1_entropies) if labels is None: